diff --git a/scripts/audit_lua_nesting.lua b/scripts/audit_lua_nesting.lua index 14bcaa6..2b4f0a6 100644 --- a/scripts/audit_lua_nesting.lua +++ b/scripts/audit_lua_nesting.lua @@ -1,5 +1,4 @@ ---- audit_lua_nesting.lua — Walk Lua source files and flag any block ---- nesting deeper than 5 levels. +--- audit_lua_nesting.lua — Walk Lua source files and flag any block nesting deeper than 5 levels. --- --- Usage: --- luajit scripts/audit_lua_nesting.lua scripts/duffle.lua scripts/ps1_meta.lua @@ -11,11 +10,10 @@ --- **Implementation**: a hand-rolled depth tracker that counts: --- - `do`, `function`, `if`, `for`, `while`, `repeat` -> depth +1 --- - `end`, `until` -> depth -1 ---- - `else`, `elseif` -> depth unchanged +--- - `else`, `elseif` -> depth unchanged --- ---- **Caveats**: doesn't handle string/comment state (will miscount ---- braces inside strings). For our metaprogram files (no embedded ---- code generation), this is acceptable. +--- **Caveats**: doesn't fully handle string/comment state (will miscount braces inside multi-line strings or block comments). +--- For our metaprogram files (no embedded code generation), this is acceptable. local M = {} @@ -28,115 +26,107 @@ local BLOCK_OPEN = { ["repeat"] = true, } -local function is_block_close(token) - return token == "end" or token == "until" -end +local function is_block_close(token) return token == "end" or token == "until" end -- (internal) Walk one source file and return a list of --- {line, depth, token} entries where depth > MAX_NESTING. +-- {line, depth, token} entries where depth > max_nesting. local function audit_file(path, max_nesting) local f = io.open(path, "r") - if not f then - error("Cannot open " .. path) - end + if not f then error("Cannot open " .. path) end local content = f:read("*a") f:close() local violations = {} - local depth = 0 - local line = 1 - local pos = 1 - local len = #content - local token_start = 0 + local depth = 0 + local line = 1 + local pos = 1 + local src_len = #content + local token_idx = 0 - local function read_ident_at(p) - -- Lua ident: [a-zA-Z_][a-zA-Z0-9_]* - local start = p - if start > len then return nil end - local ch = content:sub(start, start) - if not (ch:match("[%a_]")) then return nil end - p = p + 1 - while p <= len do - local c = content:sub(p, p) - if not (c:match("[%w_]")) then break end - p = p + 1 + local function read_ident_at(start_pos) + local ident_start = start_pos + if ident_start > src_len then return nil end + local first_ch = content:sub(ident_start, ident_start) + if not (first_ch:match("[%a_]")) then return nil end + local scan = start_pos + 1 + while scan <= src_len do + local ch = content:sub(scan, scan) + if not (ch:match("[%w_]")) then break end + scan = scan + 1 end - return content:sub(start, p - 1), p + return content:sub(ident_start, scan - 1), scan end - local function skip_string_or_comment(p) - local ch = content:sub(p, p) + -- Skip past a string literal or comment starting at `start_pos`. + -- Returns the position just past the construct, or nil if `start_pos` + -- is not the start of a string/comment. + local function skip_string_or_comment(start_pos) + local ch = content:sub(start_pos, start_pos) if ch == '"' or ch == "'" then - -- String literal: skip to matching end-quote. - p = p + 1 - while p <= len do - local c = content:sub(p, p) + local scan = start_pos + 1 + while scan <= src_len do + local c = content:sub(scan, scan) if c == "\\" then - p = p + 2 + scan = scan + 2 elseif c == ch then - p = p + 1 - break + return scan + 1 else - p = p + 1 + scan = scan + 1 end end - return p - elseif ch == "-" and content:sub(p + 1, p + 1) == "-" then - -- Lua comment: -- to end of line. - p = p + 2 - if content:sub(p, p + 1) == "[[" and content:sub(p + 2, p + 3) == "[" then + return src_len + 1 + elseif ch == "-" and content:sub(start_pos + 1, start_pos + 1) == "-" then + local scan = start_pos + 2 + if content:sub(scan, scan + 1) == "[[" and content:sub(scan + 2, scan + 3) == "[" then -- Long bracket comment [==[ ... ]==] - p = p + 2 + scan = scan + 2 local eq = "" - while content:sub(p, p) == "=" do + while content:sub(scan, scan) == "=" do eq = eq .. "=" - p = p + 1 + scan = scan + 1 end local close_marker = "]" .. eq .. "]" - local close_pos = content:find(close_marker, p, true) + local close_pos = content:find(close_marker, scan, true) if close_pos then - p = close_pos + #close_marker + return close_pos + #close_marker else - p = len + 1 + return src_len + 1 end else - while p <= len and content:sub(p, p) ~= "\n" do p = p + 1 end + while scan <= src_len and content:sub(scan, scan) ~= "\n" do scan = scan + 1 end + return scan + 1 end - return p - elseif ch == "[" and content:sub(p + 1, p + 1) == "[" then - -- Long bracket string: [==[ ... ]==] - p = p + 2 + elseif ch == "[" and content:sub(start_pos + 1, start_pos + 1) == "[" then + local scan = start_pos + 2 local eq = "" - while content:sub(p, p) == "=" do + while content:sub(scan, scan) == "=" do eq = eq .. "=" - p = p + 1 + scan = scan + 1 end local close_marker = "]" .. eq .. "]" - local close_pos = content:find(close_marker, p, true) + local close_pos = content:find(close_marker, scan, true) if close_pos then - p = close_pos + #close_marker + return close_pos + #close_marker else - p = len + 1 + return src_len + 1 end - return p end return nil end - local token_count = 0 - while pos <= len do + while pos <= src_len do local ch = content:sub(pos, pos) if ch == "\n" then line = line + 1 end local skip_to = skip_string_or_comment(pos) if skip_to then - for i = pos, skip_to - 1 do - if content:sub(i, i) == "\n" then line = line + 1 end + for scan = pos, skip_to - 1 do + if content:sub(scan, scan) == "\n" then line = line + 1 end end pos = skip_to elseif ch:match("[%a_]") then local tok, next_pos = read_ident_at(pos) - token_count = token_count + 1 + token_idx = token_idx + 1 if BLOCK_OPEN[tok] then depth = depth + 1 if depth > max_nesting then @@ -172,18 +162,17 @@ end if arg and arg[1] then local max_nesting = 5 local files = {} - for i = 1, #arg do - if arg[i] == "--max" and arg[i + 1] then - max_nesting = tonumber(arg[i + 1]) or 5 + for arg_idx = 1, #arg do + if arg[arg_idx] == "--max" and arg[arg_idx + 1] then + max_nesting = tonumber(arg[arg_idx + 1]) or 5 else - files[#files + 1] = arg[i] + files[#files + 1] = arg[arg_idx] end end -- Accept either a directory or a file path. Directory args are -- expanded via `dir /b *.lua` (Windows) or `ls *.lua` (Unix). local function is_dir(p) - -- Try opening it as a file; if that succeeds, it's not a dir. local f = io.open(p, "r") if f then f:close() return false end return true @@ -238,4 +227,4 @@ if arg and arg[1] then end end -return M \ No newline at end of file +return M diff --git a/scripts/duffle.lua b/scripts/duffle.lua index d751d75..92c6890 100644 --- a/scripts/duffle.lua +++ b/scripts/duffle.lua @@ -5,20 +5,15 @@ --- - **Character classification** (`is_space`, `is_alpha`, `is_alnum`, `is_digit`, plus the byte-fast `_byte` variants). --- - **String primitives** (`trim`, `dirname`, `basename_no_ext`, `find_byte`). --- - **I/O primitives** (`read_file`, `write_file`, `ensure_dir`). ---- - **C-language scanner** (`skip_ws_and_cmt`, `skip_str_or_cmt`, ---- `read_ident`, `read_parens`, `read_braces`, `read_brackets`, ---- `read_balanced`, `scan_to_char`, `split_top_level_commas`). +--- - **C-language scanner** (`skip_ws_and_cmt`, `skip_str_or_cmt`, `read_ident`, `read_parens`, `read_braces`, `read_brackets`, `read_balanced`, `scan_to_char`, `split_top_level_commas`). --- - **Word-count loader** (`load_word_counts` for `WORD_COUNT(...)` metadata files). --- - **Line lookup** (`LineIndex` returns an O(log N) `line_of(pos)` closure for source-mapping). ---- - **Domain tables** (`WAVE_CONTEXT_REGS`, `TAPE_ATOM_MACROS`, ---- `GTE_PIPELINE_LATENCY`, `GP0_CMD_SIZE`, `GP0_CMD_BY_SHAPE`, ---- `GP0_MACRO_CONTRIB`, `INSTRUCTION_LATENCY`). ---- - **Process-bootstrap helper** (`setup_package_path` — replaces the 8-line `arg[0]`-resolution boilerplate duplicated across 7 entry scripts) +--- - **Domain tables** (`WAVE_CONTEXT_REGS`, `TAPE_ATOM_MACROS`, `GTE_PIPELINE_LATENCY`, `GP0_CMD_SIZE`, `GP0_CMD_BY_SHAPE`, `GP0_MACRO_CONTRIB`, `INSTRUCTION_LATENCY`). +--- - **Process-bootstrap helper** (`setup_package_path`replaces the 8-line `arg[0]`-resolution boilerplate duplicated across 7 entry scripts) --- --- **Conventions**: tabs (1/level), EmmyLua annotations, no regex. --- Lua 5.3 compatible; no ``/``, no `continue`, no ---- 5.4 string.dump improvements. LuaJIT 5.1+extensions model is the ---- primary target. +--- 5.4 string.dump improvements. LuaJIT 5.1+extensions model is the primary target. --- --- **No `:match` / `:gmatch` regex use anywhere**; all delimiter- --- splitting is hand-rolled or via LPeg (the regex-free PEG library). @@ -81,30 +76,22 @@ local BYTE_DIGIT_9 = 57 -- '9' -- Section -1: Bootstrap (path-setup at module load) -- ════════════════════════════════════════════════════════════════════════════ -- --- When duffle.lua is first loaded (via `dofile` from an entry script --- or via `require` from a passes script), the code below runs and sets --- `package.path` + `package.cpath` so subsequent `require`s resolve. +-- When duffle.lua is first loaded (via `dofile` from an entry script or via `require` from a passes script), +-- the code below sruns and sets `package.path` + `package.cpath` so subsequent `require`s resolve. -- Idempotent: re-loads just re-set the same paths. -- -- **Entry scripts** trigger this with one line: --- `local duffle = dofile(arg[0]:match("(.*[/\\])") .. "/../duffle.lua")` --- which runs this top-level + returns `M`. +-- `local duffle = dofile(arg[0]:match("(.*[/\\])") .. "/../duffle.lua")` which runs this top-level + returns `M`. -- --- **Passes scripts** are loaded via `require("passes.X")` from the --- entry script; by the time they run, the entry script has already --- triggered this bootstrap, so the paths are set. --- --- **Why `git rev-parse`?** Hardcoding paths like --- `C:\\projects\\Pikuma\\ps1\\...` breaks portability. Git gives us --- the canonical repo root regardless of where it lives. +-- **Passes scripts** are loaded via `require("passes.X")` from the entry script; by the time they run, +-- the entry script has already triggered this bootstrap, so the paths are set. --- Resolve the repo root via `git rev-parse --show-toplevel` (cached). --- Returns a path with a trailing separator, or nil if not in a git repo. --- @return string|nil local function find_repo_root() - -- Cached in `package.loaded` (process-global) so all 8 entry scripts - -- + passes scripts share one git call. Without this, git rev-parse - -- runs once per script load = 8 × ~150ms = 1.2s wasted per build. + -- Cached in `package.loaded` (process-global) so all 8 entry scripts + passes scripts share one git call. + -- Without this, git rev-parse runs once per script load. if package.loaded.__duffle_repo_root__ then return package.loaded.__duffle_repo_root__ end local p = io.popen("git rev-parse --show-toplevel 2>nul") local root @@ -118,7 +105,7 @@ local function find_repo_root() return root end ---- Set `package.path` (for `require("duffle")` + `require("passes.X")`) +--- Set `package.path` (for `require("duffle")` + `require("passes.X")`) --- and `package.cpath` (for `lpeg.dll` on Windows). function M.setup_package_path() local repo_root = find_repo_root() @@ -127,8 +114,7 @@ function M.setup_package_path() os.exit(2) end - -- From the repo root, derive both `scripts/` and `scripts/passes/` - -- so `require("duffle")` AND `require("passes.annotation")` resolve. + -- From the repo root, derive both `scripts/` and `scripts/passes/` so `require("duffle")` AND `require("passes.annotation")` resolve. local scripts_dir = repo_root .. "scripts/" local passes_dir = repo_root .. "scripts/passes/" package.path = scripts_dir .. "?.lua;" @@ -137,30 +123,25 @@ function M.setup_package_path() .. passes_dir .. "?/init.lua;" .. package.path --- cpath: only needed on Windows for the bundled lpeg.dll. (LPeg --- is optional -- duffle.lua's `pcall(require, "lpeg")` falls back --- to hand-rolled scanners if the .dll isn't loadable.) +-- cpath: only needed on Windows for the bundled lpeg.dll. +-- (LPeg is optional -- duffle.lua's `pcall(require, "lpeg")` falls back to hand-rolled scanners if the .dll isn't loadable.) if package.config:sub(1, 1) == "\\" then package.cpath = repo_root .. "toolchain/luajit-2.1/lib/lua/5.1/?.dll;" .. package.cpath end end --- NOTE: `M.setup_package_path()` is NOT auto-called here. The entry --- scripts explicitly `dofile("duffle_paths.lua")` first, which calls --- `M.setup_package_path()`. The function exists for the helper to use --- (so the path-setup logic is centralized in duffle.lua). +-- NOTE: `M.setup_package_path()` is NOT auto-called here. The entry scripts explicitly `dofile("duffle_paths.lua")` first, which calls `M.setup_package_path()`. +-- The function exists for the helper to use (so the path-setup logic is centralized in duffle.lua). -- ════════════════════════════════════════════════════════════════════════════ -- Section 0: LPeg patterns (compiled once at module load) -- ════════════════════════════════════════════════════════════════════════════ -- --- LPeg is a PEG library (no regex). All patterns below are first-class --- pattern values; they're cheap to build and reuse. +-- LPeg is a PEG library (no regex). All patterns below are first-class pattern values; they're cheap to build and reuse. -- --- Note: lpeg is required lazily because Lua 5.5 may not have it on its --- cpath at the same location as LuaJIT. We attempt the require and fall --- back to the hand-rolled implementations if it fails. +-- Note: lpeg is required lazily because Lua 5.5 may not have it on its cpath at the same location as LuaJIT. +-- We attempt the require and fall back to the hand-rolled implementations if it fails. local lpeg_ok, lpeg = pcall(require, "lpeg") local lpeg_lib = nil @@ -169,13 +150,13 @@ local lpeg_str_or_cmt_pat, lpeg_ws_and_cmt_pat local lpeg_scan_to_target_pat -- generic "anything but target or balanced group" matcher if lpeg_ok then - lpeg_lib = lpeg + lpeg_lib = lpeg local P, S, R = lpeg.P, lpeg.S, lpeg.R -- Character class patterns local alpha_pat = R("AZ", "az") + P("_") local digit_pat = R("09") - lpeg_alnum_pat = alpha_pat + digit_pat + lpeg_alnum_pat = alpha_pat + digit_pat -- Identifier: alpha followed by zero+ alnum. Capture as a string. lpeg_alpha_pat = alpha_pat @@ -200,9 +181,7 @@ if lpeg_ok then -- Used by scan_to_char for non-ident / non-bracket chars. -- We accept any single char except the target. -- The balanced-group stepping is handled by the caller (via read_balanced). - lpeg_scan_to_target_pat = function(target) - return (P(1) - P(target))^0 - end + lpeg_scan_to_target_pat = function(target) return (P(1) - P(target))^0 end end -- ════════════════════════════════════════════════════════════════════════════ @@ -214,19 +193,16 @@ end -- is_space_byte(b), is_alpha_byte(b), etc. — accept a single-byte INTEGER -- -- The byte-based versions are 5-10x faster in tight loops because they --- avoid the string allocation per s:sub(i, i) call. +-- avoid the string allocation per s:sub(pos, pos) call. -- Whitespace characters per C locale. -function M.is_space_byte(b) - return b == BYTE_SPACE or b == BYTE_TAB or b == BYTE_NEWLINE - or b == BYTE_CR or b == BYTE_VT or b == BYTE_FF -end +function M.is_space_byte(b) return b == BYTE_SPACE or b == BYTE_TAB or b == BYTE_NEWLINE or b == BYTE_CR or b == BYTE_VT or b == BYTE_FF end -- Letters (a-z, A-Z) and underscore. function M.is_alpha_byte(b) if not b then return false end - if b >= BYTE_LOWER_A and b <= BYTE_LOWER_Z then return true end -- 'a'..'z' - if b >= BYTE_UPPER_A and b <= BYTE_UPPER_Z then return true end -- 'A'..'Z' + if b >= BYTE_LOWER_A and b <= BYTE_LOWER_Z then return true end -- 'a'..'z' + if b >= BYTE_UPPER_A and b <= BYTE_UPPER_Z then return true end -- 'A'..'Z' return b == BYTE_UNDERSCORE end @@ -245,8 +221,8 @@ end function M.is_alpha(c) if type(c) == "number" then return M.is_alpha_byte(c) end if not c or #c == 0 then return false end - if c >= "a" and c <= "z" then return true end - if c >= "A" and c <= "Z" then return true end + if c >= "a" and c <= "z" then return true end + if c >= "A" and c <= "Z" then return true end return c == "_" end function M.is_digit(c) @@ -261,10 +237,8 @@ function M.is_alnum(c) return M.is_alpha(c) or M.is_digit(c) end -- Trim leading and trailing whitespace from a string. function M.trim(s) - local a = 1 - while a <= #s and M.is_space_byte(s:byte(a)) do a = a + 1 end - local b = #s - while b >= a and M.is_space_byte(s:byte(b)) do b = b - 1 end + local a = 1; while a <= #s and M.is_space_byte(s:byte(a)) do a = a + 1 end + local b = #s; while b >= a and M.is_space_byte(s:byte(b)) do b = b - 1 end return s:sub(a, b) end @@ -286,7 +260,7 @@ function M.dirname(path) local last_sep = 0 for pos = 1, #path do local b = path:byte(pos) - if b == BYTE_SLASH or b == BYTE_BACKSLASH then last_sep = pos end + if b == BYTE_SLASH or b == BYTE_BACKSLASH then last_sep = pos end end if last_sep == 0 then return "." end return path:sub(1, last_sep - 1) @@ -297,7 +271,7 @@ function M.basename_no_ext(path) local last_sep = 0 for pos = 1, #path do local b = path:byte(pos) - if b == BYTE_SLASH or b == BYTE_BACKSLASH then last_sep = pos end + if b == BYTE_SLASH or b == BYTE_BACKSLASH then last_sep = pos end end local a = last_sep + 1 local last_dot = #path + 1 @@ -312,18 +286,16 @@ end -- ════════════════════════════════════════════════════════════════════════════ function M.read_file(path) - local f = io.open(path, "r") + local f = io.open(path, "r") if not f then error("Cannot open " .. path) end - local content = f:read("*a") - f:close() + local content = f:read("*a"); f:close() return content end function M.write_file(path, content) - local f = io.open(path, "w") + local f = io.open(path, "w") if not f then error("Cannot write " .. path) end - f:write(content) - f:close() + f:write(content); f:close() end -- Cache of directories already verified to exist in this process. Each @@ -337,8 +309,7 @@ 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')) + os.execute(is_win and ('if not exist "' .. path .. '" mkdir "' .. path .. '"') or ('mkdir -p "' .. path .. '" 2>/dev/null')) end -- Test helper: clear the cache (used by tests + between process runs). @@ -350,92 +321,92 @@ function M._reset_ensured_dirs() _ensured_dirs = {} end -- ════════════════════════════════════════════════════════════════════════════ -- LPeg-backed skipper when LPeg is available, hand-rolled fallback otherwise. --- Returns position just past the construct, or `i` unchanged if no --- string/comment starts at position i. -function M.skip_str_or_cmt(s, i) +-- Returns position just past the construct, or `pos` unchanged if no +-- string/comment starts at position `pos`. +function M.skip_str_or_cmt(s, pos) if lpeg_ok then - local new_pos = lpeg.match(lpeg_str_or_cmt_pat, s, i) + local new_pos = lpeg.match(lpeg_str_or_cmt_pat, s, pos) if new_pos then return new_pos end - return i + return pos end --- Hand-rolled fallback (kept for builds where LPeg isn't available). - local c = s:byte(i) + -- Hand-rolled fallback (kept for builds where LPeg isn't available). + local c = s:byte(pos) if c == BYTE_DQUOTE or c == BYTE_SQUOTE then -- '"' or '\'' - i = i + 1 - while i <= #s do - local b = s:byte(i) - if b == BYTE_BACKSLASH then i = i + 2 -- '\\' - elseif b == c then return i + 1 - else i = i + 1 end + pos = pos + 1 + while pos <= #s do + local b = s:byte(pos) + if b == BYTE_BACKSLASH then pos = pos + 2 -- '\\' + elseif b == c then return pos + 1 + else pos = pos + 1 end end return #s + 1 elseif c == BYTE_SLASH then -- '/' - local nx = s:byte(i + 1) + local nx = s:byte(pos + 1) if nx == BYTE_SLASH then -- '//' - while i <= #s and s:byte(i) ~= BYTE_NEWLINE do i = i + 1 end - return i + while pos <= #s and s:byte(pos) ~= BYTE_NEWLINE do pos = pos + 1 end + return pos elseif nx == BYTE_STAR then -- '/*' - i = i + 2 - while i <= #s - 1 do - if s:byte(i) == BYTE_STAR and s:byte(i + 1) == BYTE_SLASH then -- '*/' - return i + 2 + pos = pos + 2 + while pos <= #s - 1 do + if s:byte(pos) == BYTE_STAR and s:byte(pos + 1) == BYTE_SLASH then -- '*/' + return pos + 2 end - i = i + 1 + pos = pos + 1 end return #s + 1 end end - return i + return pos end --- Skip whitespace AND C-style comments starting at position i. +-- Skip whitespace AND C-style comments starting at position `pos`. -- LPeg-backed when available; ~5-10x faster than the hand-rolled version. -function M.skip_ws_and_cmt(s, i) +function M.skip_ws_and_cmt(s, pos) if lpeg_ok then - local new_pos = lpeg.match(lpeg_ws_and_cmt_pat, s, i) + local new_pos = lpeg.match(lpeg_ws_and_cmt_pat, s, pos) if new_pos then return new_pos end - return i + return pos end -- Hand-rolled fallback. local len = #s - while i <= len do - if M.is_space_byte(s:byte(i)) then - i = i + 1 + while pos <= len do + if M.is_space_byte(s:byte(pos)) then + pos = pos + 1 else - local nx = M.skip_str_or_cmt(s, i) - if nx > i then i = nx else break end + local nx = M.skip_str_or_cmt(s, pos) + if nx > pos then pos = nx else break end end end - return i + return pos end -- Read a C-style identifier (alpha followed by zero+ alnum) starting at --- position i. Returns the identifier string + the position just past it, --- or nil + i if no identifier starts here. -function M.read_ident(s, i) +-- position `pos`. Returns the identifier string + the position just past +-- it, or nil + pos if no identifier starts here. +function M.read_ident(s, pos) if lpeg_ok then - local result = lpeg.match(lpeg_ident_pat, s, i) - if result then return result, i + #result end - return nil, i + local result = lpeg.match(lpeg_ident_pat, s, pos) + if result then return result, pos + #result end + return nil, pos end -- Hand-rolled fallback. - if not M.is_alpha_byte(s:byte(i)) then return nil, i end - local a = i - i = i + 1 - while i <= #s and M.is_alnum_byte(s:byte(i)) do i = i + 1 end - return s:sub(a, i - 1), i + if not M.is_alpha_byte(s:byte(pos)) then return nil, pos end + local a = pos + pos = pos + 1 + while pos <= #s and M.is_alnum_byte(s:byte(pos)) do pos = pos + 1 end + return s:sub(a, pos - 1), pos end -- Read a balanced-delimited group (parens, braces, or brackets) starting --- at position i. Returns the inner content (between the delimiters) + --- the position just past the closing delimiter, or nil + i if `s[i]` +-- at position `pos`. Returns the inner content (between the delimiters) + +-- the position just past the closing delimiter, or nil + pos if `s[pos]` -- isn't `open_char`. -- -- (Hand-rolled; the depth counting makes pure LPeg awkward here.) -function M.read_balanced(s, open_char, close_char, i) +function M.read_balanced(s, open_char, close_char, pos) local open_byte = open_char:byte() - if s:byte(i) ~= open_byte then return nil, i end - local pos = i + 1 + if s:byte(pos) ~= open_byte then return nil, pos end + pos = pos + 1 local len = #s local depth = 1 local a = pos @@ -450,16 +421,16 @@ function M.read_balanced(s, open_char, close_char, i) pos = pos + 1 else local nx = M.skip_str_or_cmt(s, pos) - pos = (nx > pos) and nx or (pos + 1) + if nx > pos then pos = nx else pos = pos + 1 end end end return s:sub(a, pos - 1), pos + 1 end -- Convenience specializations of read_balanced. -M.read_parens = function(s, i) return M.read_balanced(s, "(", ")", i) end -M.read_braces = function(s, i) return M.read_balanced(s, "{", "}", i) end -M.read_brackets = function(s, i) return M.read_balanced(s, "[", "]", i) end +M.read_parens = function(s, pos) return M.read_balanced(s, "(", ")", pos) end +M.read_braces = function(s, pos) return M.read_balanced(s, "{", "}", pos) end +M.read_brackets = function(s, pos) return M.read_balanced(s, "[", "]", pos) end -- Scan forward from position `start` until we find a specific single byte -- `target`, transparently stepping over balanced parens/braces/brackets. @@ -469,13 +440,13 @@ function M.scan_to_char(s, target, start) local pos = start while pos <= #s do local c = s:byte(pos) - if c == target_byte then return pos end + if c == target_byte then return pos end if c == BYTE_OPEN_PAREN then local _, a = M.read_balanced(s, "(", ")", pos); pos = a elseif c == BYTE_OPEN_BRACE then local _, a = M.read_balanced(s, "{", "}", pos); pos = a elseif c == BYTE_OPEN_BRACK then local _, a = M.read_balanced(s, "[", "]", pos); pos = a else local nx = M.skip_str_or_cmt(s, pos) - pos = (nx > pos) and nx or (pos + 1) + pos = (nx > pos) and nx or (pos + 1) end end return nil @@ -527,22 +498,15 @@ function M.split_top_level_commas(body) if has_real_content(chunk) then tokens[#tokens + 1] = chunk elseif #tokens > 0 then - -- Pure comment/string chunk at top level (no - -- preceding instruction content within this chunk). - -- APPEND it to the LAST token so emit-context - -- callers (components.lua build_component_lines) - -- can convert `// trailing comment` to `/* */` - -- and emit it with the macro body. For word - -- counting, count_token_words only inspects the - -- leading ident, so a trailing comment doesn't + -- Pure comment/string chunk at top level (no preceding instruction content within this chunk). + -- APPEND it to the LAST token so emit-context callers (components.lua build_component_lines) + -- can convert `// trailing comment` to `/* */` and emit it with the macro body. + -- For word counting, count_token_words only inspects the leading ident, so a trailing comment doesn't -- affect the count. -- - -- This is the second-half fix to commit 98e27c2: - -- the first fix correctly broke top-level comments - -- off from the NEXT statement (fixing macro-call - -- word counts); this fix preserves them on the - -- PREVIOUS statement (restoring the comments in - -- the emitted .macs.h output). + -- This is the second-half fix to commit 98e27c2: the first fix correctly broke top-level comments + -- off from the NEXT statement (fixing macro-call word counts); + -- this fix preserves them on the PREVIOUS statement (restoring the comments in the emitted .macs.h output). tokens[#tokens] = tokens[#tokens] .. chunk end end @@ -560,15 +524,15 @@ function M.split_top_level_commas(body) local _, a = M.read_brackets(body, pos); pos = a elseif c == BYTE_COMMA then -- ',' emit(pos - 1) - pos = pos + 1 + pos = pos + 1 token_start = pos elseif c == BYTE_SEMI then -- ';' emit(pos - 1) - pos = pos + 1 + pos = pos + 1 token_start = pos elseif c == BYTE_NEWLINE then -- '\n' emit(pos - 1) - pos = pos + 1 + pos = pos + 1 token_start = pos else local nx = M.skip_str_or_cmt(body, pos) @@ -731,14 +695,14 @@ M.GTE_PIPELINE_LATENCY = { -- Aliases (must have the same value as their canonical target) ["gte_cmdw_rotate_translate_perspective_single"] = 2, ["gte_cmdw_rotate_translate_perspective_triple"] = 2, - ["gte_cmdw_avg_sort_z4"] = 2, + ["gte_cmdw_avg_sort_z4"] = 2, -- Outer product aliases (same canonical op, 0 pre-fill nops). -- gte_cmdw_op = canonical GTE-internal short form -- gte_cmdw_outer_product = NOCASH / SDK-readable form -- gte_cmdw_wedge = geometric-algebra (exterior-product) form ["gte_cmdw_outer_product"] = 0, - ["gte_cmdw_wedge"] = 0, + ["gte_cmdw_wedge"] = 0, } -- GP0 packet sizes (total words including the 1-word tag) per GP0 cmd byte. @@ -914,18 +878,18 @@ M.INSTRUCTION_LATENCY = { -- mac_* helpers (cycle cost = sum of the expanded instructions) -- mac_yield transfers control; cycle budget is 0 (the next atom -- absorbs the cost). - ["mac_yield"] = 0, - ["mac_pack_color_word"] = 3, -- lui + ori + sw - ["mac_format_f3_color"] = 3, -- = mac_pack_color_word - ["mac_format_g4_color"] = 12, -- 4 x mac_pack_color_word - ["mac_load_tri_indices"] = 3, -- 3 x lhu - ["mac_gte_load_tri_verts"] = 18, -- 3 x {sll, addu, lw, lw, mtc2, mtc2} - ["mac_gte_store_f3_post_rtpt"] = 3, - ["mac_gte_store_g3_post_rtpt"] = 3, + ["mac_yield"] = 0, + ["mac_pack_color_word"] = 3, -- lui + ori + sw + ["mac_format_f3_color"] = 3, -- = mac_pack_color_word + ["mac_format_g4_color"] = 12, -- 4 x mac_pack_color_word + ["mac_load_tri_indices"] = 3, -- 3 x lhu + ["mac_gte_load_tri_verts"] = 18, -- 3 x {sll, addu, lw, lw, mtc2, mtc2} + ["mac_gte_store_f3_post_rtpt"] = 3, + ["mac_gte_store_g3_post_rtpt"] = 3, ["mac_gte_store_g4_p012_post_rtpt_pre_rtps"] = 3, - ["mac_gte_store_g4_p3_post_rtps"] = 1, - ["mac_insert_ot_tag_f3"] = 11, -- 11 .word slots in the macro body - ["mac_insert_ot_tag_g4"] = 11, + ["mac_gte_store_g4_p3_post_rtps"] = 1, + ["mac_insert_ot_tag_f3"] = 11, -- 11 .word slots in the macro body + ["mac_insert_ot_tag_g4"] = 11, -- Annotation markers (emit no code; pure metaprogram hints) ["atom_label"] = 0, ["atom_offset"] = 0, diff --git a/scripts/duffle_paths.lua b/scripts/duffle_paths.lua index 454d681..4cd5357 100644 --- a/scripts/duffle_paths.lua +++ b/scripts/duffle_paths.lua @@ -1,62 +1,37 @@ ---- duffle_paths.lua — Single-line bootstrap helper for the tape-atom ---- Lua scripts. ---- ---- Each entry script (ps1_meta.lua, word_count_eval.lua, and the 5 ---- passes/*.lua files) starts with: +--- duffle_paths.lua — Single-line bootstrap helper for the tape-atom Lua scripts. --- +--- Each entry script (ps1_meta.lua, word_count_eval.lua, and the 5 passes/*.lua files) starts with: --- ```lua --- local duffle = dofile((arg[0]:match("(.*[/\\])") or "./") .. "duffle_paths.lua") --- ``` ---- ---- That single line: (a) locates this helper via `arg[0]`, (b) loads ---- it (which sets `package.path` + `package.cpath` via `git rev-parse`), ---- (c) returns the `M` table (a wrapper around the setup function). ---- After this line, `require("duffle")` and `require("passes.X")` both ---- resolve normally. ---- ---- **Why a helper instead of inline?** ---- - The 8-line path-setup boilerplate was duplicated across 7 entry ---- scripts (one per file). Single source of truth here. ---- - Mirrors the build script's pattern in `build_psyq.ps1`: ---- `$path_root = split-path -Path $PSScriptRoot -Parent;` then ---- derive everything from there. ---- ---- **Why `git rev-parse --show-toplevel`?** ---- Hardcoding `C:\\projects\\Pikuma\\ps1\\...` breaks portability. Git ---- gives us the canonical repo root regardless of where the repo lives ---- on disk. +--- That single line: (a) locates this helper via `arg[0]`, +--- (b) loads it (which sets `package.path` + `package.cpath` via `git rev-parse`), +--- (c) returns the `M` table (a wrapper around the setup function). +--- After this line, `require("duffle")` and `require("passes.X")` both resolve normally. 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 = 8 × ~150ms = 1.2s wasted per build on Windows. +-- 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 = 8 × ~150ms = 1.2s wasted per build on Windows. 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. +--- Returns a normalized path with a trailing forward-slash, or nil if not in a git repo. --- @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 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). + -- 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 end ---- Set `package.path` (for `require("duffle")` + `require("passes.X")`) ---- and `package.cpath` (for `lpeg.dll` on Windows). +--- Set `package.path` (for `require("duffle")` + `require("passes.X")`) and `package.cpath` (for `lpeg.dll` on Windows). --- Idempotent: safe to call multiple times (just re-sets the same paths). function M.setup() local repo_root = find_repo_root() @@ -82,4 +57,4 @@ end -- Run the setup as a side effect. M.setup() -return M \ No newline at end of file +return M diff --git a/scripts/passes/annotation.lua b/scripts/passes/annotation.lua index 089b643..8b7052b 100644 --- a/scripts/passes/annotation.lua +++ b/scripts/passes/annotation.lua @@ -1,25 +1,14 @@ --- passes/annotation.lua — Atom-annotation DSL validator. --- ---- Validates `MipsAtom_(name) atom_info(atom_bind(Binds_X), ---- atom_reads(...), atom_writes(...)) { ... }` declarations in source files. ---- Also reads: ---- - `Binds_*` struct declarations (`typedef Struct_(Binds_X) { ... };`) ---- - `TAPE_WORDS(mac_X, N)` pragma directives (`#pragma` + `_Pragma`) +--- Validates `MipsAtom_(name) atom_info(atom_bind(Binds_X), atom_reads(...), atom_writes(...)) { ... }` declarations in source files. +--- Also reads: `Binds_*` struct declarations (`typedef Struct_(Binds_X) { ... };`) --- --- Writes: ---- - `/.errors.h` — one per module, with ---- `#error` directives on findings (the C compile will surface the ---- error) ---- - The annotations.txt report is rendered by `passes/report.lua` ---- from the per-module results stashed in `ctx.flags._annot_results` ---- ---- **Ported from** `scripts/tape_atom_annotation_pass.lua:78-545 + ---- 1081-1407` (validation only — NOT rendering, which goes to ---- `passes/report.lua`). +--- - `/.errors.h` — one per module, with `#error` directives on findings (the C compile will surface the error) +--- - The annotations.txt report is rendered by `passes/report.lua` from the per-module results stashed in `ctx.flags._annot_results` --- --- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, ---- Lua 5.3 compatible. See ---- `C:\projects\Pikuma\ps1-ai\conductor\code_styleguides\lua.md`. +--- Lua 5.3 compatible -- ════════════════════════════════════════════════════════════════════════════ -- Module-scope requires + package.path setup @@ -170,7 +159,7 @@ local BYTE_COMMA = 44 --- @param s string --- @return string[] local function split_csv_top(s) - local tokens = {} + local tokens = {} local pos = 1 local chunk_a = 1 local depth = 0 @@ -179,13 +168,13 @@ local function split_csv_top(s) local ch = s:byte(pos) if ch == BYTE_OPEN_PAREN or ch == BYTE_OPEN_BRACE or ch == BYTE_OPEN_BRACK then depth = depth + 1 - pos = pos + 1 + pos = pos + 1 elseif ch == BYTE_CLOSE_PAREN or ch == BYTE_CLOSE_BRACE or ch == BYTE_CLOSE_BRACK then depth = depth - 1 - pos = pos + 1 + pos = pos + 1 elseif ch == BYTE_COMMA and depth == 0 then tokens[#tokens + 1] = s:sub(chunk_a, pos - 1) - pos = pos + 1 + pos = pos + 1 chunk_a = pos else pos = pos + 1 @@ -259,9 +248,7 @@ end -- Resolve any phase_* / R_* alias macros in a register list. -- (Phase / region / cadence aliases have been dropped. Kept as an -- identity function so callers can stay uniform.) -local function resolve_reg_aliases(regs) - return regs -end +local function resolve_reg_aliases(regs) return regs end -- Parse a comma-separated inner content (e.g. inside atom_reads(...)) -- into a list of trimmed identifiers with aliases resolved. @@ -269,7 +256,7 @@ local function parse_regs_list(inner) local out = {} for _, r in ipairs(split_csv_top(inner)) do local trimmed = trim(r) - if trimmed ~= "" then out[#out + 1] = trimmed end + if trimmed ~= "" then out[#out + 1] = trimmed end end return resolve_reg_aliases(out) end @@ -387,7 +374,7 @@ local function parse_pragma_directive(source, ident_pos, after_ident) end if entry then local line_of = duffle.LineIndex(source) - entry.line = line_of(ident_pos) + entry.line = line_of(ident_pos) end return entry, eol end @@ -417,7 +404,7 @@ local function find_macro_word_annotations(source) local entry, new_pos = parse_pragma_operator(source, pos, after_ident) if entry then local line_of = duffle.LineIndex(source) - entry.line = line_of(pos) + entry.line = line_of(pos) out[#out + 1] = entry end pos = new_pos @@ -479,7 +466,7 @@ end --- @param line_of fun(pos: integer): integer --- @return BindsStruct|nil, integer local function parse_typedef_binds(source, ident_pos, after_typedef, line_of) - local after_type = skip_ws_and_cmt(source, after_typedef) + local after_type = skip_ws_and_cmt(source, after_typedef) local type_ident, after_type_ident = read_ident(source, after_type) if type_ident ~= STRUCT_TYPE then return nil, after_type_ident or (after_type + 1) @@ -491,13 +478,13 @@ local function parse_typedef_binds(source, ident_pos, after_typedef, line_of) end local inner, after_paren = read_parens(source, open_paren) - local name = trim(inner) + local name = trim(inner) local brace = scan_to_char(source, "{", after_paren) if not brace then return nil, open_paren + 1 end local body, after_brace = read_braces(source, brace) - local fields, bytes = parse_binds_body(body) + local fields, bytes = parse_binds_body(body) -- Only emit Binds_* structs (other Struct_ typedefs are ignored). if name:sub(1, BINDS_PREFIX_LEN) ~= BINDS_PREFIX then @@ -554,10 +541,8 @@ end --- @param pos integer --- @return string|nil, integer local function read_alnum_ident(s, pos) - local str_len = #s - while pos <= str_len and is_space(s:sub(pos, pos)) do pos = pos + 1 end - local start = pos - while pos <= str_len and is_alnum(s:sub(pos, pos)) do pos = pos + 1 end + local str_len = #s; while pos <= str_len and is_space(s:sub(pos, pos)) do pos = pos + 1 end + local start = pos; while pos <= str_len and is_alnum(s:sub(pos, pos)) do pos = pos + 1 end if pos == start then return nil, pos end return s:sub(start, pos - 1), pos end @@ -586,14 +571,14 @@ local function find_atom_names(source) pos = open_paren + 1 else local inner, after_paren = read_parens(source, open_paren) - local name, _ = read_alnum_ident(inner, 1) + local name, _ = read_alnum_ident(inner, 1) if name and name ~= "" then out[#out + 1] = { line = line_of(pos), name = name } end local brace = scan_to_char(source, "{", after_paren) if brace then local _, after_brace = read_braces(source, brace) - pos = after_brace + pos = after_brace else pos = open_paren + 1 end @@ -609,39 +594,24 @@ end -- ════════════════════════════════════════════════════════════════════════════ --- True iff the parsed arg is a register-list call (any recognized form). -local function is_regs_arg(a) - return a and (a.kind == "atom_reads" or a.kind == "atom_writes" or a.kind == "regs") -end +local function is_regs_arg(a) return a and (a.kind == "atom_reads" or a.kind == "atom_writes" or a.kind == "regs") end ---- Per-macro arg-shape handlers. Each takes (entry, args) and mutates ---- Per-atom_info sub-call dispatch. Each takes (entry, args) and mutates ---- entry.{reads, writes, binds, errors}. The new annotation shape is: ---- ---- MipsAtom_(name) atom_info( ---- atom_bind(Binds_X) ---- , atom_reads(...) ---- , atom_writes(...) ---- ) { ... }; ---- ---- All sub-calls are order-independent; each is dispatched on its ---- `kind` (atom_bind / atom_reads / atom_writes) when parsed. +--- Per-macro arg-shape handlers. Each takes (entry, args) and mutates Per-atom_info sub-call dispatch. +--- Each takes (entry, args) and mutates entry.{reads, writes, binds, errors}. +--- All sub-calls are order-independent; each is dispatched on its `kind` (atom_bind / atom_reads / atom_writes) when parsed. local ANNOT_ARG_HANDLERS = {} -- atom_info(atom_bind(Binds_X), atom_reads(...), atom_writes(...)) function ANNOT_ARG_HANDLERS.info(entry, args) for _, arg in ipairs(args) do - if arg.kind == "atom_bind" then - entry.binds = arg.value - elseif arg.kind == "atom_reads" then - entry.reads = arg.value - elseif arg.kind == "atom_writes" then - entry.writes = arg.value - elseif arg.kind == "ident" then + if arg.kind == "atom_bind" then entry.binds = arg.value + elseif arg.kind == "atom_reads" then entry.reads = arg.value + elseif arg.kind == "atom_writes" then entry.writes = arg.value + elseif arg.kind == "ident" then -- Reserved for future phase tokens. Currently ignored. -- (Could be reintroduced as `phase_*` sub-calls of atom_info.) else - entry.errors[#entry.errors + 1] = string.format( - "unexpected atom_info arg kind=%s value=%s", arg.kind, tostring(arg.value)) + entry.errors[#entry.errors + 1] = string.format("unexpected atom_info arg kind=%s value=%s", arg.kind, tostring(arg.value)) end end end @@ -660,27 +630,25 @@ local function new_annot_entry(line, ident, name, kind) } end ---- Try to parse an `atom_info(...)` call right after the `MipsAtom_(name)` ---- parens. Returns the annotation entry (if present) and the new source ---- position past the atom_info call. Returns nil if no atom_info follows. +--- Try to parse an `atom_info(...)` call right after the `MipsAtom_(name)` parens. +--- Returns the annotation entry (if present) and the new source position past the atom_info call. +--- Returns nil if no atom_info follows. --- @param source string --- @param atom_name string --- @param after_mipsatom_paren integer -- position past the MipsAtom_(...) close paren --- @param line_of fun(pos: integer): integer --- @return AtomAnnotation|nil, integer -- (entry or nil, new source position) local function parse_atom_info_call(source, atom_name, after_mipsatom_paren, line_of) - local lookahead = skip_ws_and_cmt(source, after_mipsatom_paren) + local lookahead = skip_ws_and_cmt(source, after_mipsatom_paren) local look_ident, look_after = read_ident(source, lookahead) if look_ident ~= ATOM_INFO then return nil, after_mipsatom_paren end local info_open = skip_ws_and_cmt(source, look_after) - if source:byte(info_open) ~= BYTE_OPEN_PAREN then - return nil, info_open + 1 - end + if source:byte(info_open) ~= BYTE_OPEN_PAREN then return nil, info_open + 1 end local info_inner, info_after = read_parens(source, info_open) - local args = parse_atom_annot_args(info_inner) - local entry = new_annot_entry(line_of(lookahead), ATOM_INFO, atom_name, "info") + local args = parse_atom_annot_args(info_inner) + local entry = new_annot_entry(line_of(lookahead), ATOM_INFO, atom_name, "info") ANNOT_ARG_HANDLERS.info(entry, args) return entry, info_after end @@ -741,10 +709,10 @@ end local function validate(ctx, src) local source = src.text - local annots = find_atom_annotations(source) - local macros = find_macro_word_annotations(source) - local binds = find_binds_structs(source) - local atoms = find_atom_names(source) + local annots = find_atom_annotations(source) + local macros = find_macro_word_annotations(source) + local binds = find_binds_structs(source) + local atoms = find_atom_names(source) local atom_index = {} for _, a in ipairs(atoms) do atom_index[a.name] = a end diff --git a/scripts/passes/components.lua b/scripts/passes/components.lua index b6de5f5..6d1112a 100644 --- a/scripts/passes/components.lua +++ b/scripts/passes/components.lua @@ -1,19 +1,12 @@ --- passes/components.lua — Component-macro header generator. --- ---- Walks every source for `MipsAtomComp_(ac_X) { body }` (and the ---- function-form `MipsAtomComp_Proc_(ac_X, { body })`) declarations ---- and emits a per-directory `.macs.h` containing one ---- `#define mac_X(sig) \` macro per component + `WORD_COUNT(mac_X, N)` +--- Walks every source for `MipsAtomComp_(ac_X) { body }` +--- (and the function-form `MipsAtomComp_Proc_(ac_X, { body })`) declarations +--- and emits a per-directory `.macs.h` containing one `#define mac_X(sig) \` macro per component + `WORD_COUNT(mac_X, N)` --- entries for downstream offset computation. --- ---- **Ported from** `tape_atom_annotation_pass.lua:604-1079` ---- (`find_component_atoms`, `preceding_comment_block`, ---- `extract_arg_names`, `convert_line_comments_to_block`, ---- `compute_component_word_count`, `emit_component_macros_h`). ---- --- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, ---- Lua 5.3 compatible. See ---- `C:\projects\Pikuma\ps1-ai\conductor\code_styleguides\lua.md`. +--- Lua 5.3 compatible. --- @class Component --- @field name string @@ -28,13 +21,9 @@ -- Module-scope requires + package.path setup -- ════════════════════════════════════════════════════════════════════════════ --- Resolve `arg[0]` to an absolute-ish script directory so that --- `require("duffle")` resolves against `scripts/` regardless of CWD. --- Note: this boilerplate is duplicated in 6 other entry scripts; a --- Phase-6 extraction target (`duffle.setup_package_path()`). --- Bootstrap: see `ps1_meta.lua` for the rationale. +-- Resolve `arg[0]` to an absolute-ish script directory so that `require("duffle")` resolves against `scripts/` regardless of CWD. dofile((arg[0]:match("(.*[/\\])") or "./") .. "duffle_paths.lua") -local duffle = require("duffle") +local duffle = require("duffle") local word_count_eval = require("word_count_eval") -- ════════════════════════════════════════════════════════════════════════════ @@ -97,23 +86,19 @@ local GEN_SUBDIR = "gen" -- Local helpers (file I/O + path normalization) -- ════════════════════════════════════════════════════════════════════════════ --- Write content to disk in binary mode so LF line endings are preserved on --- Windows (text mode would convert LF -> CRLF, breaking byte-identical diffs --- against git-tracked gen/*.macs.h files which are stored as LF). +-- Write content to disk in binary mode so LF line endings are preserved on Windows +-- (text mode would convert LF -> CRLF, breaking byte-identical diffs against git-tracked gen/*.macs.h files which are stored as LF). -- @param path string -- @param content string local function write_file_lf(path, content) local f = io.open(path, "wb") if not f then error("Cannot write " .. path) end - f:write(content) - f:close() + f:write(content); f:close() end --- Convert a (possibly relative) path to an absolute Windows path. The --- pre-rework output's "// Source:" comment line used the absolute path --- (e.g. "C:\projects\Pikuma\ps1\code\duffle\lottes_tape.h"); if we want --- byte-identical output, we must normalize relative -> absolute before --- emitting that comment. +-- Convert a (possibly relative) path to an absolute Windows path. +-- The pre-rework output's "// Source:" comment line used the absolute path (e.g. "C:\projects\Pikuma\ps1\code\duffle\lottes_tape.h"); +-- If we want byte-identical output, we must normalize relative -> absolute before emitting that comment. -- @param path string -- @return string local function to_absolute_path(path) @@ -135,10 +120,6 @@ end local M = {} --- ════════════════════════════════════════════════════════════════════════════ --- Ported helpers (verbatim from tape_atom_annotation_pass.lua:604-1079) --- ════════════════════════════════════════════════════════════════════════════ - -- ════════════════════════════════════════════════════════════════════════════ -- Function-args extraction (precedes MipsAtomComp_Proc_ invocations) -- ════════════════════════════════════════════════════════════════════════════ diff --git a/scripts/passes/static_analysis.lua b/scripts/passes/static_analysis.lua index ea7583c..d17d9cd 100644 --- a/scripts/passes/static_analysis.lua +++ b/scripts/passes/static_analysis.lua @@ -162,23 +162,23 @@ local OUTPUT_EXTENSION = ".static_analysis.txt" local function find_atom_bodies(source_text) local line_of = duffle.LineIndex(source_text) local out = {} - local len = #source_text - local i = 1 - while i <= len do - i = duffle.skip_ws_and_cmt(source_text, i); if i > len then break end + local src_len = #source_text + local pos = 1 + while pos <= src_len do + pos = duffle.skip_ws_and_cmt(source_text, pos); if pos > src_len then break end -- Skip preprocessor directives (#define / #include / #pragma / -- etc). Otherwise the `#define MipsAtom_(sym) ...` definition -- in lottes_tape.h gets matched as an atom named "sym" and -- its `body` swallows the next real atom declaration via -- duffle.scan_to_char("{", ...). - if source_text:sub(i, i) == "#" then - local j = i - while j <= len and source_text:byte(j) ~= 10 do j = j + 1 end - i = j + 1 + if source_text:sub(pos, pos) == "#" then + local eol_pos = pos + while eol_pos <= src_len and source_text:byte(eol_pos) ~= 10 do eol_pos = eol_pos + 1 end + pos = eol_pos + 1 else - local ident, after = duffle.read_ident(source_text, i) + local ident, ident_end = duffle.read_ident(source_text, pos) if not ident then - i = i + 1 + pos = pos + 1 elseif ident == "MipsAtom_" or ident == "MipsAtomComp_" or ident == "MipsAtomComp_Proc_" then @@ -190,69 +190,69 @@ local function find_atom_bodies(source_text) else kind = "comp_proc" end - local open = duffle.skip_ws_and_cmt(source_text, after) - if source_text:sub(open, open) ~= "(" then - i = open + 1 + local open_paren = duffle.skip_ws_and_cmt(source_text, ident_end) + if source_text:sub(open_paren, open_paren) ~= "(" then + pos = open_paren + 1 else - local inner, after_paren = duffle.read_parens(source_text, open) + local inner, after_paren = duffle.read_parens(source_text, open_paren) if kind == "comp_proc" then -- MipsAtomComp_Proc_(sym, { body }) -- The body is inside the LAST `{ ... }` in the args -- (the macro takes 2 args: sym name, then body in {}). -- Find the last `{` in `inner`, then the matching `}`. - local last_open - for k = #inner, 1, -1 do - if inner:sub(k, k) == "{" then last_open = k; break end + local last_brace_pos + for search_pos = #inner, 1, -1 do + if inner:sub(search_pos, search_pos) == "{" then last_brace_pos = search_pos; break end end - if not last_open then - i = open + 1 + if not last_brace_pos then + pos = open_paren + 1 else -- Walk forward to find matching `}` honoring balanced -- ()/[] and strings. We could call duffle.read_braces - -- from last_open+1, but read_braces expects to start at + -- from last_brace_pos+1, but read_braces expects to start at -- the brace itself. Inline the walk for clarity. local depth = 1 - local j = last_open + 1 - while j <= #inner and depth > 0 do - local c = inner:byte(j) + local inner_pos = last_brace_pos + 1 + while inner_pos <= #inner and depth > 0 do + local c = inner:byte(inner_pos) if c == 123 then - depth = depth + 1; j = j + 1 + depth = depth + 1; inner_pos = inner_pos + 1 elseif c == 125 then depth = depth - 1 if depth == 0 then break end - j = j + 1 + inner_pos = inner_pos + 1 elseif c == 40 then - local _, a = duffle.read_parens(inner, j); j = a + local _, a = duffle.read_parens(inner, inner_pos); inner_pos = a elseif c == 91 then - local _, a = duffle.read_brackets(inner, j); j = a + local _, a = duffle.read_brackets(inner, inner_pos); inner_pos = a elseif c == 34 or c == 39 then - j = duffle.duffle.skip_str_or_cmt(inner, j) + 1 + inner_pos = duffle.duffle.skip_str_or_cmt(inner, inner_pos) + 1 else - j = j + 1 + inner_pos = inner_pos + 1 end end if depth ~= 0 then -- unmatched; bail - i = open + 1 + pos = open_paren + 1 else -- First ident in `inner` is the comp name. local name_match = inner:match("^%s*([%w_]+)") local name = name_match or "?" - local body = inner:sub(last_open + 1, j - 1) + local body = inner:sub(last_brace_pos + 1, inner_pos - 1) -- body_off in full source: position right after the - -- LAST `{` in `inner`, which sits at `open+1+last_open` - -- (open+1 = just inside the outer paren, +last_open - -- = at the `{`). - local body_off = open + 1 + last_open + -- LAST `{` in `inner`, which sits at `open_paren+1+last_brace_pos` + -- (open_paren+1 = just inside the outer paren, + -- +last_brace_pos = at the `{`). + local body_off = open_paren + 1 + last_brace_pos out[#out + 1] = { - line = line_of(i), + line = line_of(pos), name = name, body = body, body_off = body_off + 1, kind = kind, } - i = after_paren + pos = after_paren end end else @@ -260,34 +260,34 @@ local function find_atom_bodies(source_text) -- MipsAtomComp_(sym) { body }; -- name is the first arg, body is the FIRST { ... } after -- the paren. - local a = 1 - while a <= #inner and inner:sub(a, a):match("[%s]") do a = a + 1 end - local b = a - while b <= #inner and inner:sub(b, b):match("[%w_]") do b = b + 1 end - local name = inner:sub(a, b - 1) + local name_start = 1 + while name_start <= #inner and inner:sub(name_start, name_start):match("[%s]") do name_start = name_start + 1 end + local name_end = name_start + while name_end <= #inner and inner:sub(name_end, name_end):match("[%w_]") do name_end = name_end + 1 end + local name = inner:sub(name_start, name_end - 1) if name == "" then - i = open + 1 + pos = open_paren + 1 else local brace = duffle.scan_to_char(source_text, "{", after_paren) if brace then local body, after_brace = duffle.read_braces(source_text, brace) local body_off = brace + 1 out[#out + 1] = { - line = line_of(i), + line = line_of(pos), name = name, body = body, body_off = body_off, kind = kind, } - i = after_brace + pos = after_brace else - i = open + 1 + pos = open_paren + 1 end end end end else - i = after + pos = ident_end end end -- close the new preprocessor-skip else end @@ -309,11 +309,11 @@ local function build_body_line_index(body) local index = {} local len = #body local newline_count = 0 - for i = 1, len do - if i > 1 then - index[i] = newline_count + 1 -- line of `i` relative to body + for pos = 1, len do + if pos > 1 then + index[pos] = newline_count + 1 -- line of `pos` relative to body end - if body:byte(i) == 10 then -- '\n' + if body:byte(pos) == 10 then -- '\n' newline_count = newline_count + 1 end end @@ -324,7 +324,7 @@ 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 arg) +-- `nop2` -> 2 (i.e. `nop, nop` baked into one asm word) -- `nop,` / `nop2,` -> same as above; strip trailing comma defensively -- anything else -> 0 -- @@ -363,37 +363,37 @@ local function tokenize_body(body) -- 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 i = rel - while i <= len do - local c = body:byte(i) + 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, i); i = a + local _, a = duffle.read_parens(body, scan); scan = a elseif c == 123 then -- '{' - local _, a = duffle.read_braces(body, i); i = a + local _, a = duffle.read_braces(body, scan); scan = a elseif c == 91 then -- '[' - local _, a = duffle.read_brackets(body, i); i = a + local _, a = duffle.read_brackets(body, scan); scan = a elseif c == 34 or c == 39 then -- '"' or '\'' - i = duffle.duffle.skip_str_or_cmt(body, i) + 1 + scan = duffle.duffle.skip_str_or_cmt(body, scan) + 1 else - i = i + 1 + scan = scan + 1 end end - -- Extract token [rel .. i-1] - local tok = duffle.trim(body:sub(rel, i - 1)) + -- 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 i <= len then - i = i + 1 + if scan <= len then + scan = scan + 1 -- Also skip whitespace before next token - local w = duffle.skip_ws_and_cmt(body, i) - if w > i then i = w end + local w = duffle.skip_ws_and_cmt(body, scan) + if w > scan then scan = w end end - rel = i + rel = scan end return out end @@ -514,13 +514,13 @@ local function check_mac_yield_uniformity(atoms, findings) local count = 0 local last_idx = 0 - for i, t in ipairs(tokens) do + 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 count = count + 1 - last_idx = i + last_idx = tok_idx end end local function line_for(idx) @@ -554,8 +554,8 @@ local function check_mac_yield_uniformity(atoms, findings) -- 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 j = last_idx + 1, #tokens do - local t = tokens[j].tok + 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 post_non_nop = true @@ -607,63 +607,63 @@ end local function find_binds_structs(source_text) local line_of = duffle.LineIndex(source_text) local out = {} - local len = #source_text - local i = 1 - while i <= len do - i = duffle.skip_ws_and_cmt(source_text, i); if i > len then break end - if source_text:sub(i, i) == "#" then - local j = i - while j <= len and source_text:byte(j) ~= 10 do j = j + 1 end - i = j + 1 + local src_len = #source_text + local pos = 1 + while pos <= src_len do + pos = duffle.skip_ws_and_cmt(source_text, pos); if pos > src_len then break end + if source_text:sub(pos, pos) == "#" then + local eol_pos = pos + while eol_pos <= src_len and source_text:byte(eol_pos) ~= 10 do eol_pos = eol_pos + 1 end + pos = eol_pos + 1 else - local ident, after = duffle.read_ident(source_text, i) + local ident, ident_end = duffle.read_ident(source_text, pos) if not ident then - i = i + 1 + pos = pos + 1 elseif ident == "typedef" then - local j = duffle.skip_ws_and_cmt(source_text, after) - local id2, after2 = duffle.read_ident(source_text, j) + local after_typedef = duffle.skip_ws_and_cmt(source_text, ident_end) + local id2, id2_end = duffle.read_ident(source_text, after_typedef) if id2 ~= "Struct_" then - i = after2 or (j + 1) + pos = id2_end or (after_typedef + 1) else - local open = duffle.skip_ws_and_cmt(source_text, after2) - if source_text:sub(open, open) ~= "(" then - i = open + 1 + local open_paren = duffle.skip_ws_and_cmt(source_text, id2_end) + if source_text:sub(open_paren, open_paren) ~= "(" then + pos = open_paren + 1 else - local inner, after_paren = duffle.read_parens(source_text, open) + local inner, after_paren = duffle.read_parens(source_text, open_paren) local name = duffle.trim(inner) local brace = duffle.scan_to_char(source_text, "{", after_paren) if not brace then - i = open + 1 + pos = open_paren + 1 else local body, after_brace = duffle.read_braces(source_text, brace) local fields = {} local byte_off = 0 - local k = 1 - while k <= #body do - k = duffle.skip_ws_and_cmt(body, k); if k > #body then break end - local tid, tafter = duffle.read_ident(body, k) - if not tid then - k = k + 1 - elseif tid == "U4" then - local fid, fafter = duffle.read_ident(body, duffle.skip_ws_and_cmt(body, tafter)) - if fid then - fields[#fields + 1] = { name = fid, offset = byte_off } + local body_pos = 1 + while body_pos <= #body do + body_pos = duffle.skip_ws_and_cmt(body, body_pos); if body_pos > #body then break end + local type_ident, type_end = duffle.read_ident(body, body_pos) + if not type_ident then + body_pos = body_pos + 1 + elseif type_ident == "U4" then + local field_ident, field_end = duffle.read_ident(body, duffle.skip_ws_and_cmt(body, type_end)) + if field_ident then + fields[#fields + 1] = { name = field_ident, offset = byte_off } byte_off = byte_off + 4 end - k = fafter or (tafter + 1) + body_pos = field_end or (type_end + 1) else - k = tafter + 1 + body_pos = type_end + 1 end end if name:sub(1, 6) == "Binds_" then - out[#out + 1] = { line = line_of(i), name = name, fields = fields, bytes = byte_off } + out[#out + 1] = { line = line_of(pos), name = name, fields = fields, bytes = byte_off } end - i = after_brace + pos = after_brace end end end else - i = after + pos = ident_end end end end @@ -678,77 +678,77 @@ end local function find_atom_info(source_text) local line_of = duffle.LineIndex(source_text) local out = {} - local len = #source_text - local i = 1 - while i <= len do - i = duffle.skip_ws_and_cmt(source_text, i); if i > len then break end - if source_text:sub(i, i) == "#" then - local j = i - while j <= len and source_text:byte(j) ~= 10 do j = j + 1 end - i = j + 1 + local src_len = #source_text + local pos = 1 + while pos <= src_len do + pos = duffle.skip_ws_and_cmt(source_text, pos); if pos > src_len then break end + if source_text:sub(pos, pos) == "#" then + local eol_pos = pos + while eol_pos <= src_len and source_text:byte(eol_pos) ~= 10 do eol_pos = eol_pos + 1 end + pos = eol_pos + 1 else - local ident, after = duffle.read_ident(source_text, i) + local ident, ident_end = duffle.read_ident(source_text, pos) if not ident then - i = i + 1 + pos = pos + 1 elseif ident == "MipsAtom_" then - local open = duffle.skip_ws_and_cmt(source_text, after) - if source_text:sub(open, open) ~= "(" then - i = open + 1 + local open_paren = duffle.skip_ws_and_cmt(source_text, ident_end) + if source_text:sub(open_paren, open_paren) ~= "(" then + pos = open_paren + 1 else - local inner, after_paren = duffle.read_parens(source_text, open) - local a = 1 - while a <= #inner and inner:sub(a, a):match("[%s]") do a = a + 1 end - local b = a - while b <= #inner and inner:sub(b, b):match("[%w_]") do b = b + 1 end - local atom_name = inner:sub(a, b - 1) + local inner, after_paren = duffle.read_parens(source_text, open_paren) + local name_start = 1 + while name_start <= #inner and inner:sub(name_start, name_start):match("[%s]") do name_start = name_start + 1 end + local name_end = name_start + while name_end <= #inner and inner:sub(name_end, name_end):match("[%w_]") do name_end = name_end + 1 end + local atom_name = inner:sub(name_start, name_end - 1) local lookahead = duffle.skip_ws_and_cmt(source_text, after_paren) - local look_ident, look_after = duffle.read_ident(source_text, lookahead) + local look_ident, look_end = duffle.read_ident(source_text, lookahead) if look_ident == "atom_info" then - local info_open = duffle.skip_ws_and_cmt(source_text, look_after) + local info_open = duffle.skip_ws_and_cmt(source_text, look_end) if source_text:sub(info_open, info_open) == "(" then local info_inner, info_after = duffle.read_parens(source_text, info_open) local binds, reads, writes = nil, nil, nil - local j = 1 - while j <= #info_inner do - j = duffle.skip_ws_and_cmt(info_inner, j); if j > #info_inner then break end - local sub_ident, sub_after = duffle.read_ident(info_inner, j) + local sub_pos = 1 + while sub_pos <= #info_inner do + sub_pos = duffle.skip_ws_and_cmt(info_inner, sub_pos); if sub_pos > #info_inner then break end + local sub_ident, sub_end = duffle.read_ident(info_inner, sub_pos) if not sub_ident then - j = j + 1 + sub_pos = sub_pos + 1 elseif sub_ident == "atom_bind" then - local sub_open = duffle.skip_ws_and_cmt(info_inner, sub_after) + local sub_open = duffle.skip_ws_and_cmt(info_inner, sub_end) if info_inner:sub(sub_open, sub_open) == "(" then local sub_inner, sub_after2 = duffle.read_parens(info_inner, sub_open) binds = duffle.trim(sub_inner) - j = sub_after2 + sub_pos = sub_after2 else - j = sub_open + 1 + sub_pos = sub_open + 1 end elseif sub_ident == "atom_reads" or sub_ident == "atom_writes" then local kind = sub_ident - local sub_open = duffle.skip_ws_and_cmt(info_inner, sub_after) + local sub_open = duffle.skip_ws_and_cmt(info_inner, sub_end) if info_inner:sub(sub_open, sub_open) == "(" then local sub_inner, sub_after2 = duffle.read_parens(info_inner, sub_open) local regs = {} - local p = 1 - while p <= #sub_inner do - p = duffle.skip_ws_and_cmt(sub_inner, p); if p > #sub_inner then break end - local pid, pa = duffle.read_ident(sub_inner, p) - if pid then - regs[#regs + 1] = duffle.trim(pid) - p = pa + local sub_inner_pos = 1 + while sub_inner_pos <= #sub_inner do + sub_inner_pos = duffle.skip_ws_and_cmt(sub_inner, sub_inner_pos); if sub_inner_pos > #sub_inner then break end + local reg_ident, reg_end = duffle.read_ident(sub_inner, sub_inner_pos) + if reg_ident then + regs[#regs + 1] = duffle.trim(reg_ident) + sub_inner_pos = reg_end else - p = p + 1 + sub_inner_pos = sub_inner_pos + 1 end - if p > #sub_inner then break end - if sub_inner:sub(p, p) == "," then p = p + 1 end + if sub_inner_pos > #sub_inner then break end + if sub_inner:sub(sub_inner_pos, sub_inner_pos) == "," then sub_inner_pos = sub_inner_pos + 1 end end if kind == "atom_reads" then reads = regs else writes = regs end - j = sub_after2 + sub_pos = sub_after2 else - j = sub_open + 1 + sub_pos = sub_open + 1 end else - j = sub_after + sub_pos = sub_end end end out[#out + 1] = { @@ -756,16 +756,16 @@ local function find_atom_info(source_text) reads = reads or {}, writes = writes or {}, info_line = line_of(lookahead), } - i = info_after + pos = info_after else - i = info_open + 1 + pos = info_open + 1 end else - i = after_paren + pos = after_paren end end else - i = after + pos = ident_end end end end @@ -852,8 +852,8 @@ local function check_abi_handoff(atoms, atom_infos, binds_index, findings) end if #found_field_seq == #expected_field_seq then - for k = 1, #expected_field_seq do - if found_field_seq[k] ~= expected_field_seq[k] then + for field_idx = 1, #expected_field_seq do + if found_field_seq[field_idx] ~= expected_field_seq[field_idx] then findings[#findings + 1] = { atom = a.name, line = a.line, check = "abi_handoff", kind = "error", @@ -993,9 +993,9 @@ end --- walker uses them as branch targets. local function find_atom_labels(tokens) local labels = {} - for i, t in ipairs(tokens) do + for tok_idx, t in ipairs(tokens) do local name = t.tok:match("^atom_label%s*%(%s*([%w_]+)%s*%)") - if name then labels[name] = i end + if name then labels[name] = tok_idx end end return labels end @@ -1005,20 +1005,20 @@ end --- `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(i)`) to decide whether a token is a branch; it +--- (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[i] = nil` ---- REMOVES the key from the Lua table, which would make `is_branch(i)` +--- (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 i, t in ipairs(tokens) do + for tok_idx, t in ipairs(tokens) do if t.tok:match("^branch_[%w_]+%s*%(") then -- branch_(rs, atom_offset(F, label)) or -- branch_(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[i] = label or false -- `false` = known branch, unknown target + targets[tok_idx] = label or false -- `false` = known branch, unknown target end end return targets @@ -1052,9 +1052,9 @@ local function analyze_atom_paths(atom) local n = #tokens local costs = {} local unknown_set = {} - for i, t in ipairs(tokens) do + for tok_idx, t in ipairs(tokens) do local c, _, unknown = token_cycles(t.tok) - costs[i] = c + costs[tok_idx] = c if unknown then unknown_set[t.tok:match("^([%w_]+)") or "?"] = true end @@ -1063,42 +1063,42 @@ local function analyze_atom_paths(atom) -- 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(i) - local tok = tokens[i].tok + local function is_terminator(tok_idx) + local tok = tokens[tok_idx].tok return tok == "mac_yield" or tok:match("^mac_yield%s*%(") 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 i skips i+1 (the BD slot) in its + -- 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[i] ~= nil` because - -- `branches[i]` returns nil for both "absent" AND "present with - -- nil value" — distinguishing them requires the key check. - local function is_branch(i) - local v = branches[i] +-- 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. + 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 end - local function successors(i) - local tok = tokens[i].tok - if is_terminator(i) then - return {}, i -- empty list; term = i signals "path ends here" + local function successors(tok_idx) + local tok = tokens[tok_idx].tok + if is_terminator(tok_idx) then + return {}, tok_idx -- empty list; term = tok_idx signals "path ends here" end - if is_branch(i) then - local label = branches[i] -- may be false for literal-offset branches + if is_branch(tok_idx) then + local label = branches[tok_idx] -- may be false for literal-offset branches local succ = {} - -- Fall-through: skip the BD slot (i+1). Use i+2. - if i + 2 <= n then - succ[#succ + 1] = i + 2 + -- Fall-through: skip the BD slot (tok_idx+1). Use tok_idx+2. + if tok_idx + 2 <= n then + succ[#succ + 1] = tok_idx + 2 end -- Taken: only if the branch has a known atom_offset target. if label then @@ -1114,8 +1114,8 @@ local function analyze_atom_paths(atom) return succ, nil end -- Normal token: just the next one - if i + 1 <= n then - return { i + 1 }, nil + if tok_idx + 1 <= n then + return { tok_idx + 1 }, nil end return {}, nil end @@ -1129,16 +1129,16 @@ local function analyze_atom_paths(atom) local cycles_max = -1 local path_count = 0 local has_loops = false - local function dfs(i, acc, visited) + local function dfs(tok_idx, acc, visited) if path_count >= MAX_PATHS then return end if _G._DEBUG_DFS then - io.stderr:write(string.format("dfs(i=%d, acc=%d)\n", i, acc)) + io.stderr:write(string.format("dfs(tok_idx=%d, acc=%d)\n", tok_idx, acc)) end - if visited[i] then + if visited[tok_idx] then has_loops = true if _G._DEBUG_DFS_LOOP then - io.stderr:write(string.format(" -> LOOP at i=%d (tok=%s) acc=%d\n", - i, tokens[i].tok, acc)) + io.stderr:write(string.format(" -> LOOP at tok_idx=%d (tok=%s) acc=%d\n", + tok_idx, tokens[tok_idx].tok, acc)) end return end @@ -1146,14 +1146,14 @@ local function analyze_atom_paths(atom) -- Add this token's cost. For a branch, ADD the BD-slot cost too -- (and skip the BD slot in the successor list — already done in -- `successors` above for fall-through; for taken path the BD - -- slot was at i+1 which is now skipped entirely). - local cost = costs[i] - if is_branch(i) and i + 1 <= n then - cost = cost + costs[i + 1] + -- slot was at tok_idx+1 which is now skipped entirely). + local cost = costs[tok_idx] + if is_branch(tok_idx) and tok_idx + 1 <= n then + cost = cost + costs[tok_idx + 1] end local new_acc = acc + cost - local succ, term = successors(i) + local succ, term = successors(tok_idx) if term then -- Terminator: record the path's cycle sum. We do NOT add -- the terminator token to `visited` -- a path ends here, so @@ -1166,18 +1166,18 @@ local function analyze_atom_paths(atom) if new_acc > cycles_max then cycles_max = new_acc end return end - visited[i] = true - for _, next_i in ipairs(succ) do - dfs(next_i, new_acc, visited) + visited[tok_idx] = true + for _, next_tok_idx in ipairs(succ) do + dfs(next_tok_idx, new_acc, visited) end - visited[i] = nil + visited[tok_idx] = nil end if n >= 1 then dfs(1, 0, {}) end -- cycles_full: sum of every token's cost (the previous model; useful -- for comparing against the path-aware min/max). local cycles_full = 0 - for i = 1, n do cycles_full = cycles_full + costs[i] end + for tok_idx = 1, n do cycles_full = cycles_full + costs[tok_idx] end -- If no paths were recorded (e.g. atom body is empty), cycles_min/max -- default to 0 (atom costs nothing). cycles_full is 0 too in that case. @@ -1185,7 +1185,7 @@ local function analyze_atom_paths(atom) if cycles_max == -1 then cycles_max = 0 end local unknown_list = {} - for k in pairs(unknown_set) do unknown_list[#unknown_list + 1] = k end + for macro_name in pairs(unknown_set) do unknown_list[#unknown_list + 1] = macro_name end table.sort(unknown_list) -- branch_count: number of `branch_*(...)` tokens. (More useful than @@ -1220,7 +1220,7 @@ local function count_atom_cycles(atom) end end local unknown_list = {} - for k in pairs(unknown_set) do unknown_list[#unknown_list + 1] = k end + for macro_name in pairs(unknown_set) do unknown_list[#unknown_list + 1] = macro_name end return total, unknown_list end diff --git a/scripts/passes/word_count_eval.lua b/scripts/passes/word_count_eval.lua index 09b8119..67d4701 100644 --- a/scripts/passes/word_count_eval.lua +++ b/scripts/passes/word_count_eval.lua @@ -107,6 +107,28 @@ end -- │ Shared utility: scan_dir │ -- └────────────────────────────────────────────────────────────────────┘ +--- 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. +--- +--- The `.macs.h` files produced by the components pass always live at +--- `//gen/`. We can shortcut the `dir /b /s` walk by +--- listing modules first (one `dir /b /ad`), then walking each `/gen/` +--- (one `dir /b` per module, no recursion). For projects with 2 modules and +--- 0 .macs.h files, this drops the cost from ~52ms (full recursive walk of +--- the entire project tree) to ~5ms. +--- +--- @param dir string -- directory to scan (absolute or relative) +--- @param suffix string -- file pattern, e.g. "*.macs.h" +--- @return string[] +-- Cache the scan_dir result per (dir, suffix) in package.loaded. Each +-- `io.popen` call on Windows is ~50-100ms of subprocess overhead, so +-- caching the result saves a fixed cost on every build. The cache +-- persists for the lifetime of the Lua process (cleared when ps1_meta.lua +-- exits). 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. @@ -115,17 +137,47 @@ end --- @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 return results end + 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. + cache = cache or {} + cache[key] = results + package.loaded[SCAN_CACHE_KEY] = cache + return results 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 │ -- └────────────────────────────────────────────────────────────────────┘ diff --git a/scripts/ps1_meta.lua b/scripts/ps1_meta.lua index 5514359..d2fe8dd 100644 --- a/scripts/ps1_meta.lua +++ b/scripts/ps1_meta.lua @@ -79,9 +79,9 @@ local PASS_FLAG_DISPATCH_KEY = "__pass__" --- @class PassOutputEntry --- @field [string] string -- dynamic shape; key is the output kind - -- (e.g. "macs_h", "offsets_h", "errors_h", - -- "annotations_txt", "static_analysis_txt", - -- "summary_txt"), value is the path + -- (e.g. "macs_h", "offsets_h", "errors_h", + -- "annotations_txt", "static_analysis_txt", + -- "summary_txt"), value is the path --- @class Finding --- @field line integer -- source line (or 0 for pass-level) @@ -186,11 +186,11 @@ local function request_all_passes(args) end end --- Per-flag handlers. Each handler takes (args, argv, i) and returns --- the new i (so multi-arg flags like --source FILE advance it). --- Returning nil + os.exit() handles termination flags (--help). --- This replaces the 8-way `if/elseif/elseif...` chain that nested --- 4 levels deep and made the dispatch logic hard to scan. +-- Per-flag handlers. Each handler takes (args, argv, arg_idx) and +-- returns the new arg_idx (so multi-arg flags like --source FILE +-- advance it). Returning nil + os.exit() handles termination flags +-- (--help). This replaces the 8-way `if/elseif/elseif...` chain +-- that nested 4 levels deep and made the dispatch logic hard to scan. local FLAG_HANDLERS = {} -- ════════════════════════════════════════════════════════════════════════════ @@ -233,11 +233,11 @@ EXAMPLE: ]]) end --- Per-flag handlers. Each takes (args, argv, i) and returns the new i --- (so multi-arg flags like --source FILE advance it). Termination --- flags like --help call os.exit() instead. This replaces the 8-way --- `if/elseif/elseif...` chain that nested 4 levels deep and made the --- dispatch logic hard to scan. +-- Per-flag handlers. Each takes (args, argv, arg_idx) and returns +-- the new arg_idx (so multi-arg flags like --source FILE advance +-- it). Termination flags like --help call os.exit() instead. This +-- replaces the 8-way `if/elseif/elseif...` chain that nested 4 +-- levels deep and made the dispatch logic hard to scan. -- -- Populated AFTER print_help so the --help handler can reference it -- as an upvalue (Lua resolves locals at closure-call time, but if the @@ -255,24 +255,24 @@ FLAG_HANDLERS["--verbose"] = function(args) args.verbose = true end -FLAG_HANDLERS["--source"] = function(args, argv, i) - args.sources[#args.sources + 1] = argv[i + 1] - return i + 1 +FLAG_HANDLERS["--source"] = function(args, argv, arg_idx) + args.sources[#args.sources + 1] = argv[arg_idx + 1] + return arg_idx + 1 end -FLAG_HANDLERS["--metadata"] = function(args, argv, i) - args.metadata = argv[i + 1] - return i + 1 +FLAG_HANDLERS["--metadata"] = function(args, argv, arg_idx) + args.metadata = argv[arg_idx + 1] + return arg_idx + 1 end -FLAG_HANDLERS["--out-root"] = function(args, argv, i) - args.out_root = argv[i + 1] - return i + 1 +FLAG_HANDLERS["--out-root"] = function(args, argv, arg_idx) + args.out_root = argv[arg_idx + 1] + return arg_idx + 1 end -FLAG_HANDLERS["--project-root"] = function(args, argv, i) - args.project_root = argv[i + 1] - return i + 1 +FLAG_HANDLERS["--project-root"] = function(args, argv, arg_idx) + args.project_root = argv[arg_idx + 1] + return arg_idx + 1 end -- Pass-flag handler. Reads the closed-set table, expands --all, @@ -539,12 +539,12 @@ local function render_dep_graph(passes, requested, closed) local function add(s) lines[#lines + 1] = s end add("[ps1_meta] Resolved dependency order (closed under deps):") - for i, name in ipairs(closed) do + for pass_idx, name in ipairs(closed) do local p = passes[name] local deps_str = (#p.deps == 0) and "(no deps)" or "(deps: " .. table.concat(p.deps, ", ") .. ")" add(string.format(" %d. %-22s %-45s [%s]", - i, name, deps_str, p.kind)) + pass_idx, name, deps_str, p.kind)) end add("") @@ -556,11 +556,11 @@ local function render_dep_graph(passes, requested, closed) add(" +-----------+ +-----------------+ +-----------------+") add(" | word- |-->| components |-->| offsets |") add(" | counts | +-----------------+ +-----------------+") - add(" | (load) | | ^") - add(" +-----------+ | |") - add(" | v |") - add(" | code//gen/.macs.h |") - add(" | (header - co-located for #include) |") + add(" | (load) | | ^") + add(" +-----------+ | |") + add(" | v |") + add(" | code//gen/.macs.h |") + add(" | (header - co-located for #include) |") add(" | |") add(" | +-----------------+ |") add(" +---------->| annotation |--------------+") @@ -569,7 +569,7 @@ local function render_dep_graph(passes, requested, closed) add(" | v |") add(" | build/gen/.errors.h |") add(" | build/gen/.annotations.txt |") - add(" | (report - NOT #included) |") + add(" | (report - NOT #included) |") add(" | |") add(" | +-----------------+ |") add(" +---------->| static-analysis |--------------+")