5 Commits
Author SHA1 Message Date
ed 97d2f66c5a eliminated most lag (runs in ms) 2026-07-11 17:34:52 -04:00
ed d9406553b3 finally starting to approach decent performance. 2026-07-11 17:30:16 -04:00
ed e662d175ab lifting tokenize_body, using lfs package 2026-07-11 16:47:09 -04:00
ed 5387a07b84 progress on static analysis 2026-07-11 15:18:27 -04:00
ed 65d805e3ba start to generalize check rules.. 2026-07-11 14:57:48 -04:00
12 changed files with 494 additions and 452 deletions
+2
View File
@@ -15,3 +15,5 @@ toolchain/PSn00bSDK
*.a
.sentry-native
.vscode/settings.json
toolchain/lfs
toolchain/lpeg
+138 -9
View File
@@ -20,6 +20,12 @@
local M = {}
-- Optional native extension: lfs (LuaFileSystem). When present, ensure_dir uses
-- lfs.attributes + lfs.mkdir instead of spawning `cmd.exe mkdir` — saves ~55ms per
-- unique directory on Windows. Built by `update_deps.ps1` to `toolchain/lfs/lfs.dll`
-- and wired into package.cpath by `scripts/duffle_paths.lua`.
local lfs = pcall(require, "lfs") and require("lfs") or nil
-- ════════════════════════════════════════════════════════════════════════════
-- Cross-file type aliases
-- ════════════════════════════════════════════════════════════════════════════
@@ -308,21 +314,37 @@ end
-- Convert a (possibly relative) path to an absolute path, using CWD if needed.
-- Normalizes forward slashes to backslashes on Windows.
-- Used for byte-identical emit: the // Source: comment line uses the absolute path.
--
-- The CWD is memoized (one `io.popen("cd")` per process — ~50ms on Windows).
-- Without the cache, calling this per-source in the components pass added ~1.5s to a 30-source build.
-- @param path string
-- @return string
local _absolute_path_cache = {}
function M.to_absolute_path(path)
if _absolute_path_cache[path] then return _absolute_path_cache[path] end
if #path >= 2 and path:sub(2, 2) == ":" then
-- Already absolute; normalize slashes for consistency.
return (path:gsub("/", "\\"))
local result = (path:gsub("/", "\\"))
_absolute_path_cache[path] = result
return result
end
local p = io.popen("cd")
if not p then return path end
local cwd = p:read("*l")
p:close()
if not cwd then return path end
-- Native: lfs.currentdir() is ~0ms vs io.popen("cd") at ~50ms per call.
local cwd
if lfs then
cwd = lfs.currentdir()
else
local p = io.popen("cd")
if not p then _absolute_path_cache[path] = path; return path end
cwd = p:read("*l")
p:close()
end
if not cwd then _absolute_path_cache[path] = path; return path end
cwd = cwd:gsub("/", "\\")
local tail = (path:gsub("/", "\\"))
return cwd .. "\\" .. tail
local result = cwd .. "\\" .. tail
_absolute_path_cache[path] = result
return result
end
-- Cache of directories already verified to exist in this process.
@@ -334,8 +356,15 @@ local _ensured_dirs = {}
function M.ensure_dir(path)
if _ensured_dirs[path] then return end
_ensured_dirs[path] = true
local is_win = package.config:sub(1, 1) == "\\"
os.execute(is_win and ('if not exist "' .. path .. '" mkdir "' .. path .. '"') or ('mkdir -p "' .. path .. '" 2>/dev/null'))
if lfs then
-- Native: ~0ms when dir exists (the common case). lfs.mkdir on a new dir is ~2ms (no shell spawn).
-- Falls through silently if lfs.mkdir fails (e.g. permission denied); the subsequent write_file will surface the error.
if lfs.attributes(path, "mode") ~= "directory" then lfs.mkdir(path) end
else
-- Fallback: shell mkdir. Slow (~55ms per call on Windows due to cmd.exe spawn) but works without lfs.
local is_win = package.config:sub(1, 1) == "\\"
os.execute(is_win and ('if not exist "' .. path .. '" mkdir "' .. path .. '"') or ('mkdir -p "' .. path .. '" 2>/dev/null'))
end
end
-- Test helper: clear the cache (used by tests + between process runs).
@@ -549,6 +578,106 @@ function M.split_top_level_commas(body)
return tokens
end
-- ════════════════════════════════════════════════════════════════════════════
-- Section 4b: tokenize_body + build_body_line_index (shared, memoized)
-- ════════════════════════════════════════════════════════════════════════════
-- Moved here from passes/static_analysis.lua so all passes can share the memoized
-- per-body tokenization. The memoization key is the body string (immutable per pass).
local _tokenize_body_cache = {}
local _body_line_index_cache = {}
--- Tokenize the body inner-text into a flat list of `{tok, rel}` pairs.
--- `tok` is the trimmed token string; `rel` is the byte offset within `body`.
--- Memoized on the body string — first call pays O(body_len), subsequent calls return cached.
--- @param body string
--- @return table[] -- {{tok=string, rel=integer}, ...}
function M.tokenize_body(body)
if _tokenize_body_cache[body] ~= nil then return _tokenize_body_cache[body] end
local out = {}
local len = #body
local rel = 1
while rel <= len do
local ws_end = M.skip_ws_and_cmt(body, rel)
if ws_end > rel then rel = ws_end end
if rel > len then break end
local scan = rel
while scan <= len do
local c = body:byte(scan)
if c == 44 then break end -- ','
if c == 10 then break end -- '\n'
if c == 59 then break end -- ';'
if c == 40 then local _, a = M.read_parens (body, scan); scan = a -- '('
elseif c == 123 then local _, a = M.read_braces (body, scan); scan = a -- '{'
elseif c == 91 then local _, a = M.read_brackets (body, scan); scan = a -- '['
elseif c == 34 or c == 39 then scan = M.skip_str_or_cmt(body, scan) + 1 -- '"' or '\''
else
scan = scan + 1
end
end
local tok = M.trim(body:sub(rel, scan - 1))
if tok ~= "" then out[#out + 1] = { tok = tok, rel = rel } end
if scan <= len then
scan = scan + 1
local w = M.skip_ws_and_cmt(body, scan)
if w > scan then scan = w end
end
rel = scan
end
_tokenize_body_cache[body] = out
return out
end
--- Tokenize the body into a flat list of trimmed string tokens (preserves comments).
--- Uses `split_top_level_commas` (which appends trailing comments to the previous token)
--- so the components pass can emit `/* Words: ... */` comments in the .macs.h output.
--- @param body string
--- @return string[]
function M.tokenize_body_simple(body)
local tokens = M.split_top_level_commas(body)
local out = {}
for i = 1, #tokens do out[i] = M.trim(tokens[i]) end
return out
end
--- Build a line-index: count `\n` chars from offset 1 up to the offset; that count + 1 is the line number (1-based).
--- Memoized on the body string.
--- @param body string
--- @return table -- index[pos] = line_number
function M.build_body_line_index(body)
if _body_line_index_cache[body] ~= nil then return _body_line_index_cache[body] end
local index = {}
local len = #body
local newline_count = 0
for pos = 1, len do
if pos > 1 then
index[pos] = newline_count + 1
end
if body:byte(pos) == 10 then
newline_count = newline_count + 1
end
end
index[len + 1] = newline_count + 1
_body_line_index_cache[body] = index
return index
end
--- Find the end of a marker call (`atom_label(...)` or `atom_offset(...)`).
--- Returns the position past the closing `)`, or nil if the token isn't a marker call.
--- @param tok string
--- @return integer|nil
function M.find_marker_call_end(tok)
local ident, after = M.read_ident(tok, 1)
if not ident then return nil end
if ident ~= "atom_label" and ident ~= "atom_offset" then return nil end
local paren_pos = M.skip_ws_and_cmt(tok, after)
if tok:sub(paren_pos, paren_pos) ~= "(" then return nil end
local _, close = M.read_parens(tok, paren_pos)
return close
end
-- ════════════════════════════════════════════════════════════════════════════
-- Section 5: load_word_counts
-- ════════════════════════════════════════════════════════════════════════════
+51 -14
View File
@@ -19,24 +19,58 @@
local M = {}
-- Cache key for the repo root. Stored in `package.loaded` (process-global) so all 8 entry scripts + passes scripts share one git call.
-- Without this cache, `git rev-parse --show-toplevel` runs once per script load.
-- Cache key for the repo root. Stored in `package.loaded` (process-global) so all 8 entry scripts + passes scripts share one resolution.
local CACHE_KEY = "__duffle_repo_root__"
--- Resolve the repo root via git (cached after first call).
--- Returns a normalized path with a trailing forward-slash, or nil if not in a git repo.
--- Resolve the repo root from this script's own path. Zero shell spawn.
--- `duffle_paths.lua` always lives at `<repo>/scripts/duffle_paths.lua`, so the repo root is the
--- parent of the directory containing this script. We derive it directly from `debug.getinfo(1, "S").source`
--- (returns `@<path>` for the currently-running chunk).
---
--- Replaces the prior `io.popen("git rev-parse --show-toplevel")` approach, which cost ~100-180ms per
--- LuaJIT process on Windows due to git's CLI startup. The path-derive approach costs <1ms.
---
--- If this script's path can't be parsed (shouldn't happen — dofile/debug.getinfo always populates source),
--- fall back to a defensive walk: starting from this script's directory, walk UP until we find a parent that
--- contains a `scripts/` directory. The first match is the repo root.
--- @return string|nil
local function find_repo_root()
if package.loaded[CACHE_KEY] then return package.loaded[CACHE_KEY] end
local p = io.popen("git rev-parse --show-toplevel 2>nul")
local root
if p then root = p:read("*l"); p:close() end
if not root or root == "" then return nil end
-- Normalize to forward slashes (Windows accepts both, but mixed `\` + `/` confuses LuaJIT's file APIs).
root = root:gsub("\\", "/")
if not root:match("/$") then root = root .. "/" end
package.loaded[CACHE_KEY] = root
return root
local source = debug.getinfo(1, "S").source
-- Strip the leading `@` (Lua's dofile marker) and the trailing `/duffle_paths.lua` filename.
-- What remains is the directory containing this script, i.e. `<repo>/scripts/` (with trailing slash or not).
local scripts_dir = source and source:match("^@?(.*)[/\\]duffle_paths%.lua$")
if scripts_dir then
-- The repo root is the parent of `scripts/`. Strip the trailing `scripts/` (with or without trailing slash).
local root = scripts_dir:gsub("scripts[\\/]?$", "")
root = root:gsub("\\", "/")
if root == "" then root = "./" end
if not root:match("/$") then root = root .. "/" end
package.loaded[CACHE_KEY] = root
return root
end
-- Defensive fallback: walk UP from this script's directory until we find a parent that contains `scripts/`.
-- In practice this branch never fires — debug.getinfo always returns a source for dofile()'d chunks.
local lfs = pcall(require, "lfs") and require("lfs") or nil
if lfs then
local dir = source and source:match("^@?(.*[/\\])") or "./"
dir = dir:gsub("\\", "/")
while dir and dir ~= "" do
local candidate_scripts = dir .. "scripts"
if lfs.attributes(candidate_scripts, "mode") == "directory" then
dir = dir:gsub("/$", "")
package.loaded[CACHE_KEY] = dir .. "/"
return dir .. "/"
end
local parent = dir:match("^(.*)/[^/]+/$")
if not parent then break end
dir = parent .. "/"
end
end
return nil
end
--- Set `package.path` (for `require("duffle")` + `require("passes.X")`) and
@@ -62,9 +96,12 @@ function M.setup()
.. package.path
-- lpeg: built by `update_deps.ps1` to `toolchain/lpeg/lpeg.dll`.
-- Wire its directory into cpath so `require("lpeg")` resolves.
-- lfs: compiled from pcsx-redux's vendored luafilesystem source to `toolchain/lfs/lfs.dll`.
-- Wire both directories into cpath so `require("lpeg")` and `require("lfs")` resolve.
local lpeg_dir = repo_root .. "toolchain/lpeg/"
local lfs_dir = repo_root .. "toolchain/lfs/"
package.cpath = lpeg_dir .. "?.dll;"
.. lfs_dir .. "?.dll;"
.. package.cpath
end
+2 -1
View File
@@ -329,7 +329,8 @@ function M.run(ctx)
-- Per-DIRECTORY (per-module) aggregation. Group sources by `src.dir`,
-- validate every source in the dir, then emit ONE errors.h per dir.
local by_dir = duffle.group_sources_by_dir(ctx.sources)
-- `ctx.by_dir` is pre-computed in build_ctx (shared across all passes).
local by_dir = ctx.by_dir or duffle.group_sources_by_dir(ctx.sources)
for dir, dir_sources in pairs(by_dir) do
local dir_basename = dir:match("([^/\\]+)$") or dir
+12 -13
View File
@@ -350,6 +350,8 @@ end
-- Project pre-scanned MipsAtomComp_ / MipsAtomComp_Proc_ entries into Component shape.
-- Does per-source backward lookups for args (preceding function decl) and comment (preceding comment block).
-- Carries `body_tokens` forward from scan-source so word_count_rec reads from the precomputed table
-- instead of calling duffle.tokenize_body again.
-- @param source string -- the full source text (needed for backward lookups)
-- @param scan table -- SourceScan from duffle.scan_source
-- @return Component[]
@@ -360,11 +362,12 @@ local function project_components(source, scan)
local args = find_function_args_for(source, a.raw_name, a.ident_pos)
local comment = preceding_comment_block(source, a.ident_pos)
out[#out + 1] = {
line = a.line,
name = a.name,
body = a.body,
args = args,
comment = comment,
line = a.line,
name = a.name,
body = a.body,
body_tokens = a.body_tokens,
args = args,
comment = comment,
}
end
end
@@ -445,8 +448,9 @@ local function word_count_rec(name, comp_by_name, wc, cache)
local n
if cc then
n = 0
for _, t in ipairs(duffle.split_top_level_commas(cc.body)) do
local trimmed = duffle.trim(t)
local tokens = cc.body_tokens
for _, t in ipairs(tokens) do
local trimmed = t.tok
if trimmed ~= "" then
local lookup = strip_mac_prefix(duffle.read_ident(trimmed, 1))
if lookup and comp_by_name[lookup] then
@@ -518,12 +522,7 @@ end
--- @param body string
--- @return string[]
local function tokens_from_body(body)
local out = {}
for _, t in ipairs(duffle.split_top_level_commas(body)) do
local trimmed = duffle.trim(t)
if trimmed ~= "" then out[#out + 1] = trimmed end
end
return out
return duffle.tokenize_body_simple(body)
end
--- Determine the macro signature: function-args list (function form) or variadic-ignored (bare form).
+21 -48
View File
@@ -164,44 +164,6 @@ local function scan_for_atom_markers(token, at_pos, labels, branches)
end
end
--- Find the end position (just past the closing ')') of the first atom_label/atom_offset call in `tok`. Returns 0 if no such call.
--- @param tok string
--- @return integer -- 0 if no marker call found; otherwise end-1 (just past ')')
local function find_marker_call_end(tok)
local pos = 1
local tok_len = #tok
while pos <= tok_len do
pos = duffle.skip_ws_and_cmt(tok, pos)
if pos > tok_len then break end
local ch = tok:sub(pos, pos)
if duffle.is_space(ch) then
pos = pos + 1
elseif ch == "/" then
-- comment — skip past it (delegated to duffle.skip_str_or_cmt)
local nx = duffle.skip_str_or_cmt(tok, pos)
pos = (nx > pos) and nx or (pos + 1)
else
local ident, after_ident = duffle.read_ident(tok, pos)
-- scan: <ident>
if ident == LABEL_MARKER or ident == OFFSET_MARKER then
-- scan: atom_label(<name>) OR atom_offset(<tag>, <target>)
local open_paren = duffle.skip_ws_and_cmt(tok, after_ident)
if tok:sub(open_paren, open_paren) == "(" then
local _, end_paren = duffle.read_parens(tok, open_paren)
return end_paren - 1
end
return 0
end
pos = after_ident or (pos + 1)
end
end
return 0
end
-- ════════════════════════════════════════════════════════════════════════════
-- Per-atom body scan (for atom_label / atom_offset markers)
-- ════════════════════════════════════════════════════════════════════════════
-- (internal) Count words emitted by the rest of `tok` after a marker call
-- (the marker call itself emits 0 words, but the source pattern may bundle the marker with the next instruction on the same line,
-- separated by no top-level comma).
@@ -210,9 +172,12 @@ end
-- @param word_counts table
-- @return integer
local function count_marker_rest(tok, word_counts)
local marker_end = find_marker_call_end(tok)
if marker_end <= 0 or marker_end >= #tok then return 0 end
local rest = duffle.trim(tok:sub(marker_end + 1))
-- duffle.find_marker_call_end returns the position PAST the closing `)` of the marker call
-- (or nil if `tok` isn't a marker call). Canonical impl in duffle.lua is faster than the
-- file-local copy that used to live here (byte-indexed, no `tok:sub` per char).
local marker_end = duffle.find_marker_call_end(tok)
if not marker_end or marker_end >= #tok then return 0 end
local rest = duffle.trim(tok:sub(marker_end))
if rest == "" then return 0 end
return count_token_words(rest, word_counts)
end
@@ -230,11 +195,17 @@ end
--- @param body string
--- @param word_counts table
--- @return table<string, integer>, table[], integer
local function scan_atom_body(body, word_counts)
-- scan_atom_body: walk pre-tokenized body for atom_label/atom_offset markers + word counts.
-- Uses `atom.body_tokens` from the SourceScan payload (pre-tokenized by scan-source pass).
-- @param body_tokens table[] -- {{tok=string, rel=integer}, ...} from duffle.tokenize_body
-- @param word_counts table
-- @return table, table, integer -- labels, branches, total_words
local function scan_atom_body(body_tokens, word_counts)
local pos = 0
local labels = {}
local branches = {}
for _, tok in ipairs(duffle.split_top_level_commas(body)) do
for _, t in ipairs(body_tokens) do
local tok = t.tok
if is_marker_token(tok) then
-- Marker call: record at the current pos, do NOT advance pos.
scan_for_atom_markers(tok, pos, labels, branches)
@@ -342,17 +313,19 @@ end
local M = {}
-- Project the pre-scanned SourceScan entries into the {name, body} shape this pass needs.
-- Project the pre-scanned SourceScan entries into the {name, body, body_tokens} shape this pass needs.
-- MipsAtom_ entries have kind="atom"; MipsCode code_<name> entries have kind="raw_atom".
-- `body_tokens` is set by scan-source on every `scan.atoms[i]` / `scan.raw_atoms[i]`; we carry it forward
-- so `scan_atom_body` reads from the precomputed table directly (no per-atom tokenize_body fallback).
-- @param scan table -- SourceScan from duffle.scan_source
-- @return table[] -- list of {name=, body=}
-- @return table[] -- list of {name=, body=, body_tokens=}
local function project_atoms(scan)
local out = {}
for _, a in ipairs(scan.atoms) do
out[#out + 1] = { name = a.raw_name, body = a.body }
out[#out + 1] = { name = a.raw_name, body = a.body, body_tokens = a.body_tokens }
end
for _, a in ipairs(scan.raw_atoms) do
out[#out + 1] = { name = a.name, body = a.body }
out[#out + 1] = { name = a.name, body = a.body, body_tokens = a.body_tokens }
end
return out
end
@@ -368,7 +341,7 @@ local function process_source(ctx, src)
local atoms_data = {}
for _, atom in ipairs(atoms) do
local labels, branches, total = scan_atom_body(atom.body, ctx.shared.word_counts)
local labels, branches, total = scan_atom_body(atom.body_tokens, ctx.shared.word_counts)
atoms_data[#atoms_data + 1] = {
name = atom.name,
total_words = total,
+1 -1
View File
@@ -404,7 +404,7 @@ function M.run(ctx)
local warnings = {}
local module_entries = (ctx.flags and ctx.flags._annot_results) or {}
local by_dir = duffle.group_sources_by_dir(ctx.sources)
local by_dir = ctx.by_dir or duffle.group_sources_by_dir(ctx.sources)
if not ctx.dry_run then duffle.ensure_dir(ctx.out_root) end
+10
View File
@@ -467,6 +467,16 @@ local M = {}
function M.run(ctx)
for _, src in ipairs(ctx.sources) do
src.scan = scan_source(src.text)
-- Pre-tokenize each atom body once (plex: single source of truth).
-- Downstream passes (offsets, word-counts, components, static-analysis) read from
-- `atom.body_tokens` instead of calling `split_top_level_commas` / `tokenize_body` independently.
-- The tokens are memoized in duffle.lua's cache, so re-access is O(1).
for _, atom in ipairs(src.scan.atoms) do
atom.body_tokens = duffle.tokenize_body(atom.body)
end
for _, atom in ipairs(src.scan.raw_atoms or {}) do
atom.body_tokens = duffle.tokenize_body(atom.body)
end
end
return { outputs = {}, errors = {}, warnings = {} }
end
+195 -260
View File
@@ -129,180 +129,142 @@ local OUTPUT_EXTENSION = ".static_analysis.txt"
--- Used by the checks to convert per-token offsets in the body to lina numbers relative to the start of `body`.
--- The atom's source-line of the body-start is added by the caller.
---
-- Memoization cache for build_body_line_index / tokenize_body.
-- Atom bodies are immutable for the duration of a validate() pass (they come from src.scan which is set once by scan-source),
-- so the same body string is safe to use as a cache key. Each call site (5 check_* functions + analyze_atom_paths)
-- was re-running the full O(body_len) scan on the SAME body. After memoization, the first call pays the O(body_len) cost,
-- every subsequent call on the same body returns the cached table in O(1).
--
-- (Future-proofing: if a caller ever mutates the returned tokens / line-index tables, the cache aliasing means those
-- mutations would leak across atoms. Current callers all read-only; safe today.)
local _body_line_index_cache = {}
local _tokenize_body_cache = {}
-- NOTE: `tokenize_body` and `build_body_line_index` moved to `duffle.lua` as shared
-- memoized utilities (`duffle.tokenize_body`, `duffle.build_body_line_index`).
-- The local copies were deleted; all callers now use the duffle versions.
--- Simple line-counting: count `\n` chars from offset 1 up to the offset; that count + 1 is the line number (1-based).
local function build_body_line_index(body)
if _body_line_index_cache[body] ~= nil then return _body_line_index_cache[body] end
local index = {}
local len = #body
local newline_count = 0
for pos = 1, len do
if pos > 1 then
index[pos] = newline_count + 1 -- line of `pos` relative to body
-- ════════════════════════════════════════════════════════════════════════════
-- classify_tokens — per-token classification (the plex's pre-computed data layer)
-- ════════════════════════════════════════════════════════════════════════════
-- ONE forward pass over the token list produces a flat table of per-token classifications.
-- Every check + analyze_atom_paths reads from this table instead of re-scanning the token strings.
--
-- The classification is stored on `atom.paths.tok_class` as an array indexed by token index (1..#tokens).
-- Each entry has:
-- ident — the leading identifier (e.g. "load_word", "gte_cmdw_rtpt", "nop", "mac_yield")
-- nop_words — 0 / 1 / 2 (for "nop" / "nop2" / anything else)
-- nop_prefix — consecutive nop words ending just BEFORE this token (forward-pass pre-compute;
-- replaces the backward walk in count_preceding_nops — O(N) instead of O(N²))
-- is_yield — true if this token is `mac_yield` or `mac_yield(...)`
-- is_atom_label — true if this token is `atom_label(name)`; label_name has the name
-- is_branch — true if this token is `branch_*(...)`; branch_label has the label or false
-- is_load_word — true if this token starts with `load_word(`
-- is_store_word — true if this token starts with `store_word(`
--
-- Checks that need the leading ident use `tok_class.ident` instead of re-matching the token string.
-- Checks that need "how many nops before token i" use `tok_class.nop_prefix` instead of walking backwards.
--- @class TokClass
--- @field ident string -- leading identifier
--- @field nop_words integer -- 0/1/2
--- @field nop_prefix integer -- consecutive nop words before this token
--- @field is_yield boolean
--- @field is_atom_label boolean
--- @field label_name string|nil -- for atom_label(name)
--- @field is_branch boolean
--- @field branch_label string|false|nil -- for branch_*(..., atom_offset(F, label))
--- @field is_load_word boolean
--- @field is_store_word boolean
local function classify_tokens(tokens)
local n = #tokens
local tc = {}
local nop_run = 0 -- running count of consecutive nop words (forward pass)
for tok_idx, t in ipairs(tokens) do
local tok = t.tok
local ident = tok:match("^([%w_]+)") or "?"
local nop_words = 0
if ident == "nop" then nop_words = 1
elseif ident == "nop2" then nop_words = 2 end
local is_yield = ident == "mac_yield"
local is_atom_label = false
local label_name = nil
local is_branch = false
local branch_label = nil
local is_load_word = ident == "load_word"
local is_store_word = ident == "store_word"
if ident == "atom_label" then
is_atom_label = true
label_name = tok:match("^atom_label%s*%(%s*([%w_]+)%s*%)")
elseif tok:match("^branch_[%w_]+%s*%(") then
is_branch = true
branch_label = tok:match("atom_offset%s*%([^,]+,%s*([%w_]+)%s*%)") or false
end
if body:byte(pos) == 10 then -- '\n'
newline_count = newline_count + 1
tc[tok_idx] = {
ident = ident,
nop_words = nop_words,
nop_prefix = nop_run,
is_yield = is_yield,
is_atom_label = is_atom_label,
label_name = label_name,
is_branch = is_branch,
branch_label = branch_label,
is_load_word = is_load_word,
is_store_word = is_store_word,
}
-- Advance the nop run for the NEXT token.
if nop_words > 0 then
nop_run = nop_run + nop_words
else
nop_run = 0
end
end
-- Offsets beyond the body still resolve to the final line
index[len + 1] = newline_count + 1
_body_line_index_cache[body] = index
return index
end
--- Count of COP2-nop words contributed by a single top-level token.
-- `nop` -> 1
-- `nop2` -> 2 (i.e. `nop, nop` baked into one asm word)
-- `nop,` / `nop2,` -> same as above; strip trailing comma defensively
-- anything else -> 0
--
-- (Branch-delay-slot nops like `branch_*(..., nop)` are tokenized separately by split_top_level_commas:
-- The branch arg ends before the trailing comma, and `nop` becomes its own token.
-- So no special handling is needed here.)
local function nop_word_count(token)
local s = duffle.trim(token)
-- Strip trailing comma(s) (defensive against raw text via, but our tokenize_body already strips them; this is a safety net)
s = s:gsub(",$", "")
s = duffle.trim(s)
if s == "nop" then return 1 end
if s == "nop2" then return 2 end
return 0
end
--- Tokenize the body inner-text into a flat list of `(token, body_rel_offset)`
--- pairs (nested parens/braces/brackets are honored; comments and strings are skipped).
--- `body_rel_offset` is the char offset within `body` of the start of the token.
--- Callers add it to the atom's `body_off` to get an absolute source position for line tracking.
local function tokenize_body(body)
if _tokenize_body_cache[body] ~= nil then return _tokenize_body_cache[body] end
local out = {}
local len = #body
local rel = 1
while rel <= len do
-- Find next non-whitespace, non-comment start
local ws_end = duffle.skip_ws_and_cmt(body, rel)
if ws_end > rel then
rel = ws_end
end
if rel > len then break end
-- Find comma/newline/semicolon after this token.
-- Read balanced groups so commas inside parens/braces/brackets aren't treated as separators.
-- Comments / strings are skipped.
local scan = rel
while scan <= len do
local c = body:byte(scan)
if c == 44 then break end -- ','
if c == 10 then break end -- '\n'
if c == 59 then break end -- ';'
if c == 40 then local _, a = duffle.read_parens (body, scan); scan = a -- '('
elseif c == 123 then local _, a = duffle.read_braces (body, scan); scan = a -- '{'
elseif c == 91 then local _, a = duffle.read_brackets (body, scan); scan = a -- '['
elseif c == 34 or c == 39 then scan = duffle.skip_str_or_cmt(body, scan) + 1 -- '"' or '\''
else
scan = scan + 1
end
end
-- Extract token [rel .. scan-1]
local tok = duffle.trim(body:sub(rel, scan - 1))
if tok ~= "" then
out[#out + 1] = { tok = tok, rel = rel }
end
-- Move past the separator
if scan <= len then
scan = scan + 1
-- Also skip whitespace before next token
local w = duffle.skip_ws_and_cmt(body, scan)
if w > scan then scan = w end
end
rel = scan
end
_tokenize_body_cache[body] = out
return out
return tc
end
-- ════════════════════════════════════════════════════════════════════════════
-- Check #1: GTE pipeline-fill
-- ════════════════════════════════════════════════════════════════════════════
-- Count consecutive nop words immediately BEFORE token index `ti` in the token list.
-- Walks backwards from ti-1, accumulating nop_word_count, stopping at the first non-nop.
-- scan: nop, nop, <non-nop> -> have = count of nop words before ti
local function count_preceding_nops(tokens, ti)
local have = 0
local where_ti = ti - 1
while where_ti >= 1 do
local n = nop_word_count(tokens[where_ti].tok)
if n == 0 then break end
have = have + n
where_ti = where_ti - 1
end
return have
end
-- Check a single gte_cmdw_* token for pipeline-fill compliance.
-- Uses the pre-computed `tok_class` entry (nop_prefix replaces the backward walk; ident replaces the per-token match).
local function check_one_gte_cmdw(atom, tc_entry, ti, line_in_body, findings)
local ident = tc_entry.ident
if not ident:match("^gte_cmdw_") then return end
-- Check a single gte_cmdw_* token for pipeline-fill compliance.
-- Emits a finding if the preceding nops are insufficient (error) or the macro isn't in the latency table (warning).
-- scan: <nop>... <gte_cmdw_X> -> validate nop count vs GTE_PIPELINE_LATENCY[X]
local function check_one_gte_cmdw(a, tok, tokens, ti, line_in_body, findings)
local cmdw_full = tok:match("^(gte_cmdw_[%w_]+)%s*[,%)]") or tok:match("^(gte_cmdw_[%w_]+)%s*$")
if not cmdw_full then return end
local variant = cmdw_full:match("^gte_cmdw_(.+)$")
local need = duffle.GTE_PIPELINE_LATENCY[cmdw_full]
local line = a.line + line_in_body[tokens[ti].rel]
local variant = ident:match("^gte_cmdw_(.+)$")
local need = duffle.GTE_PIPELINE_LATENCY[ident]
local line = atom.line + line_in_body[atom.paths.tokens[ti].rel]
if need == nil then
-- alias or new gte_cmdw_<X> not yet in latency table
findings[#findings + 1] = {
atom = a.name,
atom = atom.name,
line = line,
check = "gte_pipeline_fill",
kind = "warning",
msg = string.format(
"%s at line %d uses `gte_cmdw_%s` but that macro is not in duffle.GTE_PIPELINE_LATENCY -- add a min_nops entry",
a.name, line, variant),
atom.name, line, variant),
}
elseif need > 0 then
local have = count_preceding_nops(tokens, ti)
local have = tc_entry.nop_prefix
if have < need then
findings[#findings + 1] = {
atom = a.name,
atom = atom.name,
line = line,
check = "gte_pipeline_fill",
kind = "error",
msg = string.format(
"%s at line %d needs %d nop word%s immediately BEFORE `gte_cmdw_%s`; only %d found",
a.name, line, need, need == 1 and "" or "s", variant, have),
atom.name, line, need, need == 1 and "" or "s", variant, have),
}
end
end
end
--- Walk the token list. Whenever we hit a `gte_cmdw_<X>` token, count consecutive nop words immediately preceding it.
--- If count < the minimum declared in `duffle.GTE_PIPELINE_LATENCY[X]`, record a finding.
--- Aliases are resolved against the lookup table directly; if a macro name is not in the table, emit a soft warning
--- (the user might have added a new gte_cmdw_* but not updated duffle.lua).
--- Per-atom: walk this atom's tokens, check every `gte_cmdw_*` for pipeline-fill compliance.
--- Signature changed from `(atoms, findings)` to `(atom, findings)` in Stage 1B of the plex move:
--- the per-atom iteration now lives in validate()'s single loop; each check is a per-atom predicate.
local function check_gte_pipeline_fill(atom, findings)
local tokens = tokenize_body(atom.body)
local line_in_body = build_body_line_index(atom.body)
local tn = #tokens
local ti = 1
while ti <= tn do
check_one_gte_cmdw(atom, tokens[ti].tok, tokens, ti, line_in_body, findings)
ti = ti + 1
--- Per-atom: check every `gte_cmdw_*` for pipeline-fill compliance.
--- Uses the pre-computed `atom.paths.tok_class` — nop_prefix (forward-pass pre-compute)
--- replaces the old backward walk; ident replaces the per-token `tok:match` classification.
local function check_gte_pipeline_fill(atom, pipe_ctx, findings)
local tc = atom.paths.tok_class
local line_in_body = atom.paths.line_in_body
local tn = #atom.paths.tokens
for ti = 1, tn do
check_one_gte_cmdw(atom, tc[ti], ti, line_in_body, findings)
end
end
@@ -315,7 +277,8 @@ end
---
--- Empty bodies are not currently flagged — runtime infrastructure atoms like `MipsAtom_(yield) { mac_yield() }`
--- and `MipsAtom_(tape_exit) { jump_reg(rret_addr), nop }` are valid as-is; mac_yield at the end is the contract.
local function check_mac_yield_uniformity(atom, findings)
--- Stage 2: signature uniformized to `(atom, pipe_ctx, findings)` — pipe_ctx is ignored here.
local function check_mac_yield_uniformity(atom, pipe_ctx, findings)
-- Per-kind semantics:
-- MipsAtom_ (baked atom): exactly 1 mac_yield at the end of the body. Control transfer is the atom's job.
-- MipsAtomComp_ (bare static-array component): ZERO mac_yield.
@@ -324,16 +287,15 @@ local function check_mac_yield_uniformity(atom, findings)
-- Same reasoning -- it's a function returning a MipsAtom slice, invoked from a parent atom.
--
-- The GTE pipeline-fill check applies to all 3 kinds (see check_gte_pipeline_fill). Only the mac_yield rule branches on kind.
local tokens = tokenize_body(atom.body)
local line_in_body = build_body_line_index(atom.body)
local tokens = atom.paths.tokens
local line_in_body = atom.paths.line_in_body
local tc = atom.paths.tok_class
local n = #tokens
local count = 0
local last_idx = 0
for tok_idx, t in ipairs(tokens) do
local tok = t.tok
-- Match `mac_yield(...)` or just `mac_yield`. The bareword
-- variant is rare in modern style but tolerated.
if tok:match("^mac_yield%s*%(") or tok == "mac_yield" then
for tok_idx = 1, n do
if tc[tok_idx].is_yield then
count = count + 1
last_idx = tok_idx
end
@@ -364,14 +326,13 @@ local function check_mac_yield_uniformity(atom, findings)
"%s at line %d has %d `mac_yield()` calls; exactly 1 is allowed",
atom.name, line_for(last_idx), count),
}
elseif last_idx < #tokens then
elseif last_idx < n then
-- 1 call, but not the last token. We DON'T fail if the post-token is just `nop` or `nop2` or a branch with `, nop` delay slot.
-- It's the standard "yield, then BD nop" idiom.
local post_non_nop = false
for search_idx = last_idx + 1, #tokens do
local t = tokens[search_idx].tok
if t ~= "" and t ~= "nop" and t ~= "nop2"
and not t:match("%,%s*nop%)%s*$") then
for search_idx = last_idx + 1, n do
if tc[search_idx].nop_words == 0
and tokens[search_idx].tok ~= "" then
post_non_nop = true
break
end
@@ -406,6 +367,7 @@ local function check_mac_yield_uniformity(atom, findings)
end
end
end
-- ════════════════════════════════════════════════════════════════════════════
-- Check #3: ABI handoff discipline
-- ════════════════════════════════════════════════════════════════════════════
@@ -439,14 +401,15 @@ local function check_abi_handoff(atom, pipe_ctx, findings)
}
return
end
local tokens = tokenize_body(atom.body)
local line_in_body = build_body_line_index(atom.body)
local tokens = atom.paths.tokens
local line_in_body = atom.paths.line_in_body
local tc = atom.paths.tok_class
local found_field_set = {}
local found_advance = false
local bind_re = "O_%(" .. binds_name .. ",%s*([%w_]+)%s*%)"
for _, t in ipairs(tokens) do
for tok_idx, t in ipairs(tokens) do
local tok = t.tok
if tok:match("^load_word%s*%(") then
if tc[tok_idx].is_load_word then
if tok:find("R_TapePtr", 1, true) and tok:find("O_(" .. binds_name .. ",", 1, true) then
local field = tok:match(bind_re)
-- scan: load_word(R_*, R_TapePtr, O_(<Binds_X>, <field>))
@@ -505,22 +468,23 @@ end
--- - Atoms containing a `mac_<name>(...)` call whose name is not in duffle.GP0_MACRO_CONTRIB emit a "new macro; update duffle.GP0_MACRO_CONTRIB" advisory.
---
--- Applies only to `kind = "atom"` (baked atoms). Components don't emit full primitives.
--- Per-atom: detect which GP0 primitive the atom is emitting, sum the macro contributions,
--- compare to the expected packet size. Signature changed in Stage 1B: `(atom, findings)`.
local function check_gpu_portstore_shape(atom, findings)
--- Stage 2: signature uniformized to `(atom, pipe_ctx, findings)` — pipe_ctx is ignored here.
local function check_gpu_portstore_shape(atom, pipe_ctx, findings)
if atom.kind ~= "atom" then return end
local tokens = tokenize_body(atom.body)
local line_in_body = build_body_line_index(atom.body)
local tokens = atom.paths.tokens
local line_in_body = atom.paths.line_in_body
local tc = atom.paths.tok_class
local cmd_byte = nil
local cmd_line = nil
local contrib = 0
local saw_format = false
local saw_prim_write = false
for _, t in ipairs(tokens) do
for tok_idx, t in ipairs(tokens) do
local tok = t.tok
local ident = tc[tok_idx].ident
-- Match `mac_format_<shape>_color(...)` and strip `_color`
-- to get the bare shape suffix (f3 / g4 / etc).
local shape = tok:match("^mac_format_([%w_]+)_color%s*%(") or tok:match("^mac_format_([%w_]+)_color%s*$")
local shape = ident:match("^mac_format_([%w_]+)_color$")
if shape and duffle.GP0_CMD_BY_SHAPE[shape] then
if not cmd_byte then
cmd_byte = duffle.GP0_CMD_BY_SHAPE[shape]
@@ -531,17 +495,15 @@ local function check_gpu_portstore_shape(atom, findings)
local n = duffle.GP0_MACRO_CONTRIB[contrib_key]
if n then contrib = contrib + n end
end
local gte_store = tok:match("^mac_gte_store_[%w_]+")
if gte_store then
local n = duffle.GP0_MACRO_CONTRIB[gte_store]
if ident:match("^mac_gte_store_[%w_]+$") then
local n = duffle.GP0_MACRO_CONTRIB[ident]
if n then contrib = contrib + n end
end
local ot_tag = tok:match("^mac_insert_ot_tag_([%w_]+)")
if ot_tag then
local n = duffle.GP0_MACRO_CONTRIB["mac_insert_ot_tag_" .. ot_tag]
if ident:match("^mac_insert_ot_tag_[%w_]+$") then
local n = duffle.GP0_MACRO_CONTRIB[ident]
if n then contrib = contrib + n end
end
if tok:match("^store_word%s*%(") and tok:find("R_PrimCursor", 1, true) then
if tc[tok_idx].is_store_word and tok:find("R_PrimCursor", 1, true) then
saw_prim_write = true
end
end
@@ -571,57 +533,12 @@ local function check_gpu_portstore_shape(atom, findings)
end
-- ════════════════════════════════════════════════════════════════════════════
-- Check #5: per-atom cycle budget
-- Check #5: per-atom cycle budget (uses analyze_atom_paths's unknown_macros)
-- ════════════════════════════════════════════════════════════════════════════
--- Compute the cycle cost of one token. The token is a string like
--- `add_ui(R_T0, R_T1, 4)` or `nop2` or `gte_cmdw_rtpt`. Returns:
--- cycles - integer cycle cost (from duffle.INSTRUCTION_LATENCY, or duffle.UNKNOWN_INSTRUCTION_CYCLES if not in the table)
--- macro_name - the bare ident (e.g. `add_ui`, `gte_cmdw_rtpt`, `nop2`, `mac_yield`)
--- unknown - true iff the macro wasn't in duffle.INSTRUCTION_LATENCY.
--- The function strips trailing `()` from function-call style macros so `mac_yield()` and `mac_yield` resolve identically.
local function token_cycles(tok)
-- Extract the leading ident. Tolerate `(...)` args.
local ident = tok:match("^([%w_]+)")
if not ident then return duffle.UNKNOWN_INSTRUCTION_CYCLES, "?", true end
local cost = duffle.INSTRUCTION_LATENCY[ident]
if cost == nil then
return duffle.UNKNOWN_INSTRUCTION_CYCLES, ident, true
end
return cost, ident, false
end
--- Find every `atom_label(name)` token in the token list and return a map `label_name -> token_idx`. Labels are 0-cost markers;
--- the path walker uses them as branch targets.
local function find_atom_labels(tokens)
local labels = {}
for tok_idx, t in ipairs(tokens) do
local name = t.tok:match("^atom_label%s*%(%s*([%w_]+)%s*%)")
if name then labels[name] = tok_idx end
end
return labels
end
--- Find every `branch_*(...)` token in the token list and return a map `token_idx -> label_name|false`.
--- If the branch's args contain an `atom_offset(F, label)` call, the label name is recorded; otherwise the branch's target is unknown
--- (likely a literal offset) and we record `false` as a sentinel.
--- The CFG walker checks KEY PRESENCE (via `is_branch(tok_idx)`) to decide whether a token is a branch; it checks the value
--- to decide whether the taken-path target is known.
--- (We can't use `nil` for the unknown-target case because `targets[tok_idx] = nil` REMOVES the key from the Lua table,
--- which would make `is_branch(tok_idx)` return false for both "not a branch" and "branch with unknown target".)
local function find_branch_targets(tokens)
local targets = {}
for tok_idx, t in ipairs(tokens) do
if t.tok:match("^branch_[%w_]+%s*%(") then
-- branch_<cond>(rs, atom_offset(F, label)) or
-- branch_<cond>(rs, rt, atom_offset(F, label))
-- atom_offset's arg list is (flag, name); we want the name.
local label = t.tok:match("atom_offset%s*%([^,]+,%s*([%w_]+)%s*%)")
targets[tok_idx] = label or false -- `false` = known branch, unknown target
end
end
return targets
end
-- NOTE: `token_cycles`, `find_atom_labels`, `find_branch_targets` were removed
-- when `classify_tokens` (the pre-computed per-token classification) replaced them.
-- The classification lives on `atom.paths.tok_class`; analyze_atom_paths reads it.
--- Walk all paths through an atom body and return per-path cycle sums.
--- Builds a tiny CFG: each token has a "next" pointer; branches have two (fall-through + taken).
@@ -638,43 +555,44 @@ end
--- (warning; loop bodies aren't supported)
--- unknown_macros - list of unique macro names not in duffle.INSTRUCTION_LATENCY
local function analyze_atom_paths(atom)
local tokens = tokenize_body(atom.body)
local labels = find_atom_labels(tokens)
local branches = find_branch_targets(tokens)
local tokens = atom.paths.tokens or duffle.tokenize_body(atom.body)
local tc = atom.paths.tok_class or classify_tokens(tokens)
local n = #tokens
-- Pre-compute per-token cycle costs and identify terminators.
local n = #tokens
local costs = {}
local unknown_set = {}
for tok_idx, t in ipairs(tokens) do
local c, _, unknown = token_cycles(t.tok)
costs[tok_idx] = c
if unknown then
unknown_set[t.tok:match("^([%w_]+)") or "?"] = true
-- Build label + branch maps from the pre-computed classification (no re-scan).
local labels = {}
local branches = {}
for tok_idx = 1, n do
local c = tc[tok_idx]
if c.is_atom_label and c.label_name then
labels[c.label_name] = tok_idx
end
if c.is_branch then
branches[tok_idx] = c.branch_label
end
end
-- A token is a terminator if it's `mac_yield` or `mac_yield(...)`.
-- The yield transfers control; we don't count its cost (the next atom's prologue absorbs it).
local function is_terminator(tok_idx)
local tok = tokens[tok_idx].tok
return tok == "mac_yield" or tok:match("^mac_yield%s*%(")
-- Pre-compute per-token cycle costs from the pre-computed ident (no re-match).
local costs = {}
local unknown_set = {}
for tok_idx = 1, n do
local c = tc[tok_idx]
local cost = duffle.INSTRUCTION_LATENCY[c.ident]
if cost == nil then
cost = duffle.UNKNOWN_INSTRUCTION_CYCLES
unknown_set[c.ident] = true
end
costs[tok_idx] = cost
end
-- CFG successor function. Returns a list of next token indices for the given position.
-- Branch tokens produce 2 successors (fall-through + taken); normal tokens produce 1 (next); terminators produce 0.
-- BD-slot absorption: a branch at tok_idx skips tok_idx+1 (the BD slot) in its fall-through path;
-- the BD slot's cost is added to the branch's own cost instead (so it's counted once).
--
-- A token is a "branch" if its index is a KEY in the `branches` map
-- (regardless of whether the value is nil — a branch with nil target means "literal offset, taken path is unknown").
-- We check key-presence via `branches[tok_idx] ~= nil` because `branches[tok_idx]` returns nil for both "absent" AND "present with nil value".
-- Distinguishing them requires the key check.
-- A token is a terminator if it's `mac_yield`.
local function is_terminator(tok_idx)
return tc[tok_idx].is_yield
end
-- A token is a "branch" if the classification says so.
local function is_branch(tok_idx)
local v = branches[tok_idx]
if v == nil then return false end
-- v is non-nil: either a string (atom_offset target) or false (literal offset, no target). Both indicate a branch.
return true
return tc[tok_idx].is_branch
end
local function successors(tok_idx)
local tok = tokens[tok_idx].tok
@@ -807,6 +725,23 @@ local function check_per_atom_cycle_budget(atom, pipe_ctx, findings)
end
end
-- ════════════════════════════════════════════════════════════════════════════
-- CHECK_RULES — data-driven check dispatch (Muratori: data over control flow)
-- ════════════════════════════════════════════════════════════════════════════
-- Each rule is a table entry: { name, per_atom }.
-- `per_atom(atom, pipe_ctx, findings)` runs once per atom inside validate()'s single loop.
-- Adding a new check = 1 row here + 1 check_* function. No validate() edit required.
-- This is the plex pattern: the iteration is in ONE place (validate), the variation is in DATA (this table).
local CHECK_RULES = {
{ name = "gte_pipeline_fill", per_atom = check_gte_pipeline_fill },
{ name = "mac_yield_uniformity", per_atom = check_mac_yield_uniformity },
{ name = "abi_handoff", per_atom = check_abi_handoff },
{ name = "gpu_portstore_shape", per_atom = check_gpu_portstore_shape },
{ name = "per_atom_cycle_budget", per_atom = check_per_atom_cycle_budget },
}
-- ════════════════════════════════════════════════════════════════════════════
-- Per-source validation
-- ════════════════════════════════════════════════════════════════════════════
@@ -849,18 +784,18 @@ local function validate(ctx, src)
local findings = {}
for _, a in ipairs(atoms) do
a.paths = a.paths or {}
a.paths.tokens = tokenize_body(a.body)
a.paths.line_in_body = build_body_line_index(a.body)
a.paths.tokens = a.body_tokens
a.paths.line_in_body = duffle.build_body_line_index(a.body)
a.paths.tok_class = classify_tokens(a.paths.tokens)
-- analyze_atom_paths fills the *cycles / branches / has_loops / unknown_macros* fields of a.paths.
analyze_atom_paths(a)
-- Run all 5 checks on this one atom. Each is now a per-atom predicate.
check_gte_pipeline_fill(a, findings)
check_mac_yield_uniformity(a, findings)
check_abi_handoff(a, pipe_ctx, findings)
check_gpu_portstore_shape(a, findings)
check_per_atom_cycle_budget(a, pipe_ctx, findings)
-- Run all checks on this one atom via the CHECK_RULES data table (Muratori: data over control flow).
-- Adding a new check = 1 row in CHECK_RULES; this loop never needs editing.
for _, rule in ipairs(CHECK_RULES) do
rule.per_atom(a, pipe_ctx, findings)
end
end
local errors = {}
@@ -1115,7 +1050,7 @@ function M.run(ctx)
--
-- Group sources by `src.dir`. The first component of `dir` is the module name (e.g. "code/duffle" -> "duffle", "code/gte_hello" -> "gte_hello").
-- Output path is `<out_root>/<module_basename>.static_analysis.txt`.
local by_dir = duffle.group_sources_by_dir(ctx.sources)
local by_dir = ctx.by_dir or duffle.group_sources_by_dir(ctx.sources)
for dir, dir_sources in pairs(by_dir) do
-- Run validate() against every source in this directory; accumulate atoms / findings / errors / warnings.
+41 -106
View File
@@ -4,7 +4,6 @@
--- 1. **Public utilities** (used by `passes/components.lua`, `passes/offsets.lua`, `passes/annotation.lua`):
--- - `M.count_token_words(token, wc)` — words emitted by one token
--- - `M.scan_dir(dir, suffix)` — glob walk for *.macs.h
--- - `M.count_body_words(body, wc)` — words emitted by an atom body
--- 2. **Pass entry** `M.run(ctx)` — loads metadata.h + *.macs.h into `ctx.shared.word_counts` for downstream passes.
--- 3. **Internal helpers** for the body scanner.
---
@@ -29,13 +28,17 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
-- Constants
-- ════════════════════════════════════════════════════════════════════════════
-- Windows separator chars — used to convert `dir /b /s` output (which uses `\`) into POSIX paths (which our scripts expect).
-- Windows separator chars — used to convert `dir` output (which uses `\`) into POSIX paths (which our scripts expect).
local PATH_SEP_BACKSLASH = "\\"
local PATH_SEP_FORWARD = "/"
-- Glob command for Windows directory walk. `dir /b /s` lists all matching files recursively with bare paths (no headers);
-- `2>nul` discards the "file not found" stderr when nothing matches.
local DIR_GLOB_CMD = 'dir /b /s "%s\\%s" 2>nul'
-- Fallback glob command (subprocess). Used when `lfs` (LuaFileSystem) is not available.
-- Scoped to `code\` to avoid walking `.git/`, `toolchain/`, `build/`, etc.
local DIR_GLOB_CMD = 'dir /b /s "%s\\code\\%s" 2>nul'
-- Try to load lfs (LuaFileSystem). If available, scan_dir uses native directory enumeration (~2ms)
-- instead of spawning `dir /b /s` as a subprocess (~56ms). Built by update_deps.ps1 into toolchain/lfs/lfs.dll.
local lfs = pcall(require, "lfs") and require("lfs") or nil
-- ════════════════════════════════════════════════════════════════════════════
-- Type declarations
@@ -120,36 +123,50 @@ end
-- If a build removes/creates .macs.h files mid-process, the caller can invalidate by calling `M._invalidate_scan_cache()`.
local SCAN_CACHE_KEY = "__word_count_eval_scan_cache__"
--- Recursively scan a directory for files matching a glob suffix.
--- No regex per the no_regex constraint — uses plain byte matching via `dir /b /s` on Windows.
--- Scan `code/` for files matching `suffix` (e.g. `*.macs.h`).
--- Uses `lfs` (LuaFileSystem) when available — native directory enumeration at ~2ms.
--- Falls back to `dir /b /s` subprocess (~56ms) when `lfs` is not compiled.
---
--- @param dir string -- directory to scan (absolute or relative)
--- @param dir string -- project root directory
--- @param suffix string -- file pattern, e.g. "*.macs.h"
--- @return string[]
function M.scan_dir(dir, suffix)
local key = dir .. "\0" .. suffix
-- Check the in-process cache first. (Mostly helps when a build triggers multiple `M.run` calls -- e.g.
-- the audit_lua_nesting script's stress tests but the cost is ~free either way.)
local cache = package.loaded[SCAN_CACHE_KEY]
if cache and cache[key] then return cache[key] end
local results = {}
local pipe = io.popen(DIR_GLOB_CMD:format(dir, suffix))
if not pipe then
-- Cache the empty result too (avoids re-scan if the dir is genuinely empty -- e.g. a clean build before components has run yet).
cache = cache or {}
cache[key] = results
package.loaded[SCAN_CACHE_KEY] = cache
return results
end
for raw_line in pipe:lines() do
local path = raw_line:gsub(PATH_SEP_BACKSLASH, PATH_SEP_FORWARD)
results[#results + 1] = path
end
pipe:close()
-- Cache the result.
if lfs then
-- Native walk: list code/<module>/gen/ for matching files. Zero subprocess spawns.
local code_dir = dir .. "/code"
if lfs.attributes(code_dir, "mode") == "directory" then
for mod_name in lfs.dir(code_dir) do
if mod_name ~= "." and mod_name ~= ".." then
local gen_path = code_dir .. "/" .. mod_name .. "/gen"
if lfs.attributes(gen_path, "mode") == "directory" then
for fname in lfs.dir(gen_path) do
if fname:match("%.macs%.h$") then
results[#results + 1] = gen_path .. "/" .. fname
end
end
end
end
end
end
else
-- Fallback: single `dir /b /s` subprocess scoped to code\.
local pipe = io.popen(DIR_GLOB_CMD:format(dir, suffix))
if pipe then
for raw_line in pipe:lines() do
results[#results + 1] = raw_line:gsub(PATH_SEP_BACKSLASH, PATH_SEP_FORWARD)
end
pipe:close()
end
end
-- Cache the result (including empty results).
cache = cache or {}
cache[key] = results
package.loaded[SCAN_CACHE_KEY] = cache
@@ -160,88 +177,6 @@ end
--- Invalidate the scan cache (call after creating new .macs.h files in the same Lua process — usually not needed).
function M._invalidate_scan_cache() package.loaded[SCAN_CACHE_KEY] = nil end
-- ┌────────────────────────────────────────────────────────────────────┐
-- │ Shared utility: count_body_words │
-- └────────────────────────────────────────────────────────────────────┘
--- Count words emitted by an entire atom body (a brace-delimited block).
--- Splits by top-level commas; for each token, delegates to count_token_words.
--- Handles `atom_label(name)` / `atom_offset(tag, name)` markers
--- (record at current pos, do NOT advance pos; if the marker call bundles an instruction after it, count that instruction too).
---
--- @param body string -- brace-delimited atom body (without braces)
--- @param wc WordCounts -- the shared word-count table
--- @return integer -- total words
function M.count_body_words(body, wc)
local total = 0
for _, tok in ipairs(duffle.split_top_level_commas(body)) do
local pos = 1
local tok_len = #tok
while pos <= tok_len and duffle.is_space(tok:sub(pos, pos)) do
pos = pos + 1
end
local leading_ident = duffle.read_ident(tok, pos)
local is_marker = leading_ident == "atom_label" or leading_ident == "atom_offset"
if is_marker then
-- Marker call: record at current pos, do NOT advance pos.
-- But the source pattern may bundle the marker with the next instruction on a new line (no top-level comma between them).
-- In that case, the rest of `tok` after the marker call is a real instruction that must still be counted.
local marker_end = M.find_marker_call_end(tok)
if marker_end > 0 and marker_end < #tok then
local rest = duffle.trim(tok:sub(marker_end + 1))
if rest ~= "" then
total = total + M.count_token_words(rest, wc)
end
end
else
total = total + M.count_token_words(tok, wc)
end
end
return total
end
--- Find the end position (just past the closing ')') of the first atom_label/atom_offset call in `tok`. Returns 0 if no such call.
--- Internal helper for count_body_words.
---
--- @param tok string
--- @return integer -- 0 if no marker call found
function M.find_marker_call_end(tok)
local pos = 1
local tok_len = #tok
while pos <= tok_len do
pos = duffle.skip_ws_and_cmt(tok, pos)
if pos > tok_len then break end
local ch = tok:sub(pos, pos)
if duffle.is_space(ch) then
pos = pos + 1
elseif ch == "/" then
-- comment — skip past it (delegated to duffle.skip_str_or_cmt)
local nx = duffle.skip_str_or_cmt(tok, pos)
pos = (nx > pos) and nx or (pos + 1)
else
local ident, after_ident = duffle.read_ident(tok, pos)
local marker_end = find_marker_end(tok, ident, after_ident)
if marker_end > 0 then return marker_end end
pos = after_ident or (pos + 1)
end
end
return 0
end
-- (internal) If `ident` is `atom_label`/`atom_offset` followed by `(...)`, return the position just past the closing ')'.
-- Otherwise 0.
-- @param tok string
-- @param ident string|nil
-- @param after_ident integer
-- @return integer
local function find_marker_end(tok, ident, after_ident)
if ident ~= "atom_label" and ident ~= "atom_offset" then return 0 end
local open_paren = duffle.skip_ws_and_cmt(tok, after_ident)
if tok:sub(open_paren, open_paren) ~= "(" then return 0 end
local _, end_paren = duffle.read_parens(tok, open_paren)
return end_paren - 1
end
-- ┌────────────────────────────────────────────────────────────────────┐
-- │ Pass entry: M.run(ctx) — "word-counts" pass │
-- └────────────────────────────────────────────────────────────────────┘
+6
View File
@@ -355,8 +355,14 @@ local function build_ctx(args)
}
end
-- Pre-compute the per-directory grouping once (Fleury: expose structure).
-- Three passes (annotation, report, static-analysis) call group_sources_by_dir with the same ctx.sources;
-- computing it here and stashing on ctx.by_dir eliminates 2 redundant calls.
local by_dir = duffle.group_sources_by_dir(sources)
return {
sources = sources,
by_dir = by_dir,
metadata_path = args.metadata,
shared = {},
upstream = {},
+15
View File
@@ -101,6 +101,21 @@ push-location $path_lpeg
& gcc @lpeg_compile_args
pop-location
# ════════════════════════════════════════════════════════════════════════════
# lfs (LuaFileSystem) — compiled from pcsx-redux's vendored luafilesystem source.
# Used by word_count_eval.lua :: scan_dir for native directory enumeration (~2ms)
# instead of spawning `dir /b /s` as a subprocess (~56ms).
# Source: toolchain/pcsx-redux/third_party/luafilesystem/src/lfs.c
# Output: toolchain/lfs/lfs.dll
# ════════════════════════════════════════════════════════════════════════════
$path_lfs = join-path $path_toolchain 'lfs'
verify-path $path_lfs
$lfs_src = join-path $path_pcsx_redux 'third_party\luafilesystem\src\lfs.c'
$lfs_dll = join-path $path_lfs 'lfs.dll'
$lfs_dll_import = join-path $luajit_lib_dir 'libluajit-5.1.dll.a'
& gcc -O2 -shared "-I$lua_inc_dir" -o $lfs_dll $lfs_src $lfs_dll_import
# ════════════════════════════════════════════════════════════════════════════
# OpenBIOS — built from the PCSX-Redux source tree via make + mipsel-none-elf
#