mirror of
https://github.com/Ed94/pikuma_ps1.git
synced 2026-07-12 12:21:26 -07:00
final pass on metaprogram
This commit is contained in:
+16
-32
@@ -12,19 +12,13 @@
|
||||
--- - **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 `<close>`/`<toclose>`, no `continue`, no
|
||||
--- 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).
|
||||
|
||||
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
|
||||
-- Required native extension: lfs (LuaFileSystem). Built by `update_deps.ps1` to
|
||||
-- `toolchain/lfs/lfs.dll` and wired into package.cpath by `scripts/duffle_paths.lua`.
|
||||
-- If lfs is missing, `require` throws — fail loud per the build-tool convention.
|
||||
local lfs = require("lfs")
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Cross-file type aliases
|
||||
@@ -315,7 +309,7 @@ end
|
||||
-- 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).
|
||||
-- The CWD is memoized on first call (one lfs.currentdir() per process — ~0ms).
|
||||
-- Without the cache, calling this per-source in the components pass added ~1.5s to a 30-source build.
|
||||
-- @param path string
|
||||
-- @return string
|
||||
@@ -329,16 +323,8 @@ function M.to_absolute_path(path)
|
||||
_absolute_path_cache[path] = result
|
||||
return result
|
||||
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
|
||||
-- lfs.currentdir() is ~0ms vs io.popen("cd") at ~50ms per call on Windows.
|
||||
local cwd = lfs.currentdir()
|
||||
if not cwd then _absolute_path_cache[path] = path; return path end
|
||||
cwd = cwd:gsub("/", "\\")
|
||||
local tail = (path:gsub("/", "\\"))
|
||||
@@ -356,15 +342,9 @@ local _ensured_dirs = {}
|
||||
function M.ensure_dir(path)
|
||||
if _ensured_dirs[path] then return end
|
||||
_ensured_dirs[path] = true
|
||||
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
|
||||
-- lfs.attributes + lfs.mkdir: ~0ms when dir exists, ~2ms when creating. 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
|
||||
end
|
||||
|
||||
-- Test helper: clear the cache (used by tests + between process runs).
|
||||
@@ -585,8 +565,9 @@ end
|
||||
-- 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 = {}
|
||||
local _tokenize_body_cache = {}
|
||||
local _tokenize_body_simple_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`.
|
||||
@@ -633,12 +614,15 @@ 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.
|
||||
--- Memoized on body string (R7 lift; mirror of M.tokenize_body's memoization).
|
||||
--- @param body string
|
||||
--- @return string[]
|
||||
function M.tokenize_body_simple(body)
|
||||
if _tokenize_body_simple_cache[body] ~= nil then return _tokenize_body_simple_cache[body] end
|
||||
local tokens = M.split_top_level_commas(body)
|
||||
local out = {}
|
||||
for i = 1, #tokens do out[i] = M.trim(tokens[i]) end
|
||||
_tokenize_body_simple_cache[body] = out
|
||||
return out
|
||||
end
|
||||
|
||||
|
||||
+11
-33
@@ -30,47 +30,25 @@ local CACHE_KEY = "__duffle_repo_root__"
|
||||
--- 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.
|
||||
--- If `debug.getinfo` can't parse this script's path (shouldn't happen — dofile always populates source),
|
||||
--- return nil and let `M.setup()` fail loud.
|
||||
--- @return string|nil
|
||||
local function find_repo_root()
|
||||
if package.loaded[CACHE_KEY] then return package.loaded[CACHE_KEY] end
|
||||
|
||||
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).
|
||||
-- What remains is the directory containing this script, i.e. `<repo>/scripts/`.
|
||||
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
|
||||
if not scripts_dir then return nil 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
|
||||
-- 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
|
||||
|
||||
--- Set `package.path` (for `require("duffle")` + `require("passes.X")`) and
|
||||
|
||||
+210
-128
@@ -3,16 +3,13 @@
|
||||
--- 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) { ... };`)
|
||||
---
|
||||
--- Source scanning: done ONCE upstream by `duffle.scan_source()` (ps1_meta.lua pre-scans each
|
||||
--- source and stashes the result in `src.scan`). This pass is pure: read from the scan, run
|
||||
--- checks, emit findings. No source re-walking.
|
||||
--- Source scanning: done ONCE upstream by `duffle.scan_source()` (ps1_meta.lua pre-scans each source and stashes the result in `src.scan`).
|
||||
---
|
||||
--- Writes:
|
||||
--- - `<ctx.out_root>/<dir_basename>.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
|
||||
--- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, Lua 5.3 compatible
|
||||
|
||||
-- Bootstrap: same as entry scripts. See `ps1_meta.lua` for the rationale.
|
||||
-- Bootstrap: load `scripts/duffle_paths.lua` (sets package.path + package.cpath).
|
||||
@@ -21,13 +18,12 @@
|
||||
-- Bootstrap: load `duffle_paths.lua` via `debug.getinfo(1, "S").source` (works both standalone + when require'd).
|
||||
-- duffle_paths.lua sets package.path then returns `require("duffle")` at the bottom, so the dofile value IS the duffle module.
|
||||
local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./"
|
||||
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
|
||||
local write_file = duffle.write_file
|
||||
local ensure_dir = duffle.ensure_dir
|
||||
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
|
||||
local write_file = duffle.write_file
|
||||
local ensure_dir = duffle.ensure_dir
|
||||
|
||||
-- Domain tables (single source of truth in duffle.lua).
|
||||
local WAVE_CONTEXT_REGS = duffle.WAVE_CONTEXT_REGS
|
||||
local TAPE_ATOM_MACROS = duffle.TAPE_ATOM_MACROS
|
||||
|
||||
local function is_wave_context_reg(n) return WAVE_CONTEXT_REGS[n] ~= nil end
|
||||
|
||||
@@ -68,11 +64,22 @@ local function is_wave_context_reg(n) return WAVE_CONTEXT_REGS[n] ~= nil end
|
||||
--- @field binds string|nil -- Binds_X name if any
|
||||
--- @field reads string[] -- R_* names (read targets)
|
||||
--- @field writes string[] -- R_* names (write targets)
|
||||
--- @field errors string[]|nil -- parse-time errors from scan_source (atom_info body malformed)
|
||||
|
||||
--- @class Finding
|
||||
--- @field line integer -- source line (or 0 for pass-level)
|
||||
--- @field msg string -- finding message
|
||||
|
||||
--- @class Findings
|
||||
--- @field errors Finding[]
|
||||
--- @field warnings Finding[]
|
||||
--- @field info Finding[]
|
||||
|
||||
--- @class PipeCtx
|
||||
--- @field atom_index table<string, AtomAnnotation> -- name -> AtomAnnotation (only kind=="atom")
|
||||
--- @field binds_index table<string, BindsStruct> -- name -> BindsStruct
|
||||
--- @field annot_counts table<string, integer> -- name -> annotation count (for unique_annotation check)
|
||||
|
||||
--- @class AnnotatedResult
|
||||
--- @field atoms AtomEntry[]
|
||||
--- @field annots AtomAnnotation[]
|
||||
@@ -82,6 +89,154 @@ local function is_wave_context_reg(n) return WAVE_CONTEXT_REGS[n] ~= nil end
|
||||
--- @field warnings Finding[]
|
||||
--- @field info Finding[]
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Per-check functions (the CHECK_RULES table's payload)
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
--
|
||||
-- Each check has a uniform `append_to_findings` shape (errors[] / warnings[] / info[]).
|
||||
-- The dispatcher in `validate()` decides which findings list each check writes to — by convention,
|
||||
-- "existence" checks (declaration must exist, struct must exist) write errors[]; "shape" checks
|
||||
-- (writes/reads must be wave-context) write warnings[]. The `macro_word_drift` check writes
|
||||
-- both errors[] (missing/mismatch) and info[] (match).
|
||||
|
||||
--- Check: every annotated atom must have a matching MipsAtom_(name) declaration.
|
||||
--- @param a AtomAnnotation
|
||||
--- @param pipe_ctx PipeCtx
|
||||
--- @param findings Findings
|
||||
local function check_atom_decl_exists(a, pipe_ctx, findings)
|
||||
if not pipe_ctx.atom_index[a.name] then
|
||||
findings.errors[#findings.errors + 1] = {
|
||||
line = a.line,
|
||||
msg = string.format("annotation for '%s' has no matching MipsAtom_(%s) { ... }", a.name, a.name),
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
--- Check: every atom may have AT MOST ONE annotation.
|
||||
--- Post-loop: needs full-corpus `annot_counts` from pipe_ctx.
|
||||
--- @param pipe_ctx PipeCtx
|
||||
--- @param findings Findings
|
||||
local function check_unique_annotation(pipe_ctx, findings)
|
||||
for name, n in pairs(pipe_ctx.annot_counts) do
|
||||
if n > 1 then
|
||||
findings.errors[#findings.errors + 1] = {
|
||||
line = pipe_ctx.atom_index[name] and pipe_ctx.atom_index[name].line or 0,
|
||||
msg = string.format("MipsAtom_(%s) has %d annotations (expected at most 1)", name, n),
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Check: BIND atoms must reference a real Binds_* struct.
|
||||
--- Demoted from error to warning (2026-07-10): the same condition is now caught by passes/static_analysis.lua's
|
||||
--- check_abi_handoff() as an error. Emitting a warning here keeps the annotation pass from being stop-on-error
|
||||
--- for the common test-fixture case, while still surfacing the issue in the report.
|
||||
--- The static-analysis report remains the source of truth for build-stopping errors.
|
||||
--- @param a AtomAnnotation
|
||||
--- @param pipe_ctx PipeCtx
|
||||
--- @param findings Findings
|
||||
local function check_binds_struct_exists(a, pipe_ctx, findings)
|
||||
if not a.binds then return end
|
||||
if pipe_ctx.binds_index[a.binds] then return end
|
||||
findings.warnings[#findings.warnings + 1] = {
|
||||
line = a.line,
|
||||
msg = string.format("'%s' binds '%s' but no Struct_(%s) { ... } "
|
||||
.. "declaration found (also flagged as an error by check_abi_handoff in the static-analysis pass)"
|
||||
, a.name, a.binds, a.binds),
|
||||
}
|
||||
end
|
||||
|
||||
--- Check: Binds_* struct fields must correspond to known wave-context registers.
|
||||
--- Also checks that all `atom_writes(...)` entries are wave-context registers.
|
||||
--- @param a AtomAnnotation
|
||||
--- @param pipe_ctx PipeCtx
|
||||
--- @param findings Findings
|
||||
local function check_binds_field_wave_context(a, pipe_ctx, findings)
|
||||
if not (a.binds and pipe_ctx.binds_index[a.binds]) then return end
|
||||
local bs = pipe_ctx.binds_index[a.binds]
|
||||
|
||||
for _, f in ipairs(bs.fields) do
|
||||
local candidate = "R_" .. f.name
|
||||
if not is_wave_context_reg(candidate) then
|
||||
findings.warnings[#findings.warnings + 1] = {
|
||||
line = bs.line,
|
||||
msg = string.format("%s field '%s' doesn't match a known wave-context register (candidate '%s')", a.binds, f.name, candidate),
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
for _, w in ipairs(a.writes) do
|
||||
if not is_wave_context_reg(w) then
|
||||
findings.warnings[#findings.warnings + 1] = {
|
||||
line = a.line,
|
||||
msg = string.format("%s writes '%s' which is not a known wave-context register", a.name, w),
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Check: atom_reads(...) entries should be wave-context registers (or R_TapePtr for rbind).
|
||||
--- @param a AtomAnnotation
|
||||
--- @param pipe_ctx PipeCtx
|
||||
--- @param findings Findings
|
||||
local function check_reads_wave_context(a, pipe_ctx, findings)
|
||||
for _, r in ipairs(a.reads) do
|
||||
if not is_wave_context_reg(r) and r ~= "R_TapePtr" then
|
||||
findings.warnings[#findings.warnings + 1] = {
|
||||
line = a.line,
|
||||
msg = string.format("atom '%s' reads '%s' which is not a known wave-context register", a.name, r),
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Check: TAPE_WORDS(mac_X, N) ↔ WORD_COUNT(mac_X, N) drift.
|
||||
--- Three outcomes: missing (error), mismatch (error), match (info).
|
||||
--- @param m MacroEntry
|
||||
--- @param wc table<string, integer> -- the shared word-count table (from ctx.shared.word_counts)
|
||||
--- @param findings Findings
|
||||
local function check_macro_word_drift(m, wc, findings)
|
||||
local declared = wc[m.name]
|
||||
if not declared then
|
||||
findings.errors[#findings.errors + 1] = {
|
||||
line = m.line,
|
||||
msg = string.format("TAPE_WORDS(%s, %d) but '%s' is not in metadata.h", m.name, m.words, m.name),
|
||||
}
|
||||
return
|
||||
end
|
||||
if declared ~= m.words then
|
||||
findings.errors[#findings.errors + 1] = {
|
||||
line = m.line,
|
||||
msg = string.format("DRIFT: TAPE_WORDS(%s, %d) but metadata.h declares WORD_COUNT(%s, %d)", m.name, m.words, m.name, declared),
|
||||
}
|
||||
return
|
||||
end
|
||||
findings.info[#findings.info + 1] = {
|
||||
line = m.line,
|
||||
msg = string.format("OK: %s = %d words", m.name, m.words),
|
||||
}
|
||||
end
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- CHECK_RULES — data-driven check dispatch (the plex pattern)
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
--
|
||||
-- Each rule entry picks one of three "shapes" of dispatch:
|
||||
-- per_annot(annot, pipe_ctx, findings) — runs once per AtomAnnotation
|
||||
-- post(pipe_ctx, findings) — runs once after all per_annot calls complete (full-corpus aggregation)
|
||||
-- per_macro(macro, wc, findings) — runs once per TAPE_WORDS / _Pragma macro declaration
|
||||
--
|
||||
-- Adding a new check = 1 row here + 1 function above. The `validate()` dispatch loop never needs editing.
|
||||
|
||||
local CHECK_RULES = {
|
||||
{ name = "atom_decl_exists", per_annot = check_atom_decl_exists },
|
||||
{ name = "binds_struct_exists", per_annot = check_binds_struct_exists },
|
||||
{ name = "binds_field_wave_context", per_annot = check_binds_field_wave_context },
|
||||
{ name = "reads_wave_context", per_annot = check_reads_wave_context },
|
||||
{ name = "unique_annotation", post = check_unique_annotation },
|
||||
{ name = "macro_word_drift", per_macro = check_macro_word_drift },
|
||||
}
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Validation
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -115,138 +270,65 @@ local function validate(ctx, src)
|
||||
binds = info.binds,
|
||||
reads = info.reads or {},
|
||||
writes = info.writes or {},
|
||||
errors = {},
|
||||
errors = info.errors,
|
||||
}
|
||||
end
|
||||
|
||||
-- Index atoms by name for lookup.
|
||||
local atom_index = {}
|
||||
for _, a in ipairs(atoms) do atom_index[a.name] = a end
|
||||
|
||||
-- Index binds by name for lookup.
|
||||
local binds_index = {}
|
||||
for _, b in ipairs(scan.binds) do binds_index[b.name] = b end
|
||||
|
||||
local errors = {}
|
||||
local warnings = {}
|
||||
local info = {}
|
||||
|
||||
-- 1. Every annotated atom must exist as a real MipsAtom_ declaration.
|
||||
for _, a in ipairs(annots) do
|
||||
if not atom_index[a.name] then
|
||||
errors[#errors + 1] = {
|
||||
line = a.line,
|
||||
msg = string.format("annotation for '%s' has no matching MipsAtom_(%s) { ... }", a.name, a.name),
|
||||
}
|
||||
-- Build pipe_ctx (Fleury: expose structure). Pre-compute everything the per-check functions need.
|
||||
-- Single source of truth for atom / binds / annotation-count lookups.
|
||||
local pipe_ctx = {
|
||||
atom_index = {},
|
||||
binds_index = {},
|
||||
annot_counts = {},
|
||||
}
|
||||
for _, a in ipairs(atoms) do pipe_ctx.atom_index [a.name] = a end
|
||||
for _, b in ipairs(scan.binds) do pipe_ctx.binds_index[b.name] = b end
|
||||
for _, a in ipairs(annots) do
|
||||
if a.name then
|
||||
pipe_ctx.annot_counts[a.name] = (pipe_ctx.annot_counts[a.name] or 0) + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- Findings live in a single struct with three lists (errors / warnings / info).
|
||||
-- Each check writes to the list appropriate for its severity.
|
||||
local findings = { errors = {}, warnings = {}, info = {} }
|
||||
|
||||
-- Propagate parse-time errors from scan_source's atom_info parsing.
|
||||
-- These are errors found in the atom_info(...) body itself (e.g., malformed args).
|
||||
-- They are pre-existing in the scan payload — we just lift them into our findings list.
|
||||
for _, a in ipairs(annots) do
|
||||
if a.errors then
|
||||
for _, msg in ipairs(a.errors) do
|
||||
errors[#errors + 1] = {line = a.line, msg = string.format("'%s': %s", a.name, msg)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- 2. Every atom may have AT MOST ONE annotation (no duplicates).
|
||||
-- (Atoms with ZERO annotations are valid in the new minimal shape.)
|
||||
local count_per_atom = {}
|
||||
for _, a in ipairs(annots) do
|
||||
if a.name then
|
||||
count_per_atom[a.name] = (count_per_atom[a.name] or 0) + 1
|
||||
end
|
||||
end
|
||||
for name, n in pairs(count_per_atom) do
|
||||
if n > 1 then
|
||||
errors[#errors + 1] = {
|
||||
line = atom_index[name] and atom_index[name].line or 0,
|
||||
msg = string.format("MipsAtom_(%s) has %d annotations (expected at most 1)", name, n),
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
-- 3. (Phase validity check DROPPED. Phases were removed from the annotation DSL.)
|
||||
|
||||
-- 4. BIND atoms must reference a real Binds_* struct.
|
||||
for _, a in ipairs(annots) do
|
||||
if a.binds then
|
||||
if not binds_index[a.binds] then
|
||||
-- Demoted from error to warning (2026-07-10): the same condition is now caught by passes/static_analysis.lua's
|
||||
-- check_abi_handoff() as an error. Emitting a warning here keeps the annotation pass from being stop-on-error
|
||||
-- for the common test-fixture case, while still surfacing the issue in the report.
|
||||
-- The static-analysis report remains the source of truth for build-stopping errors.
|
||||
warnings[#warnings + 1] = {
|
||||
findings.errors[#findings.errors + 1] = {
|
||||
line = a.line,
|
||||
msg = string.format("'%s' binds '%s' but no Struct_(%s) { ... } declaration found (also flagged as an error by check_abi_handoff in the static-analysis pass)", a.name, a.binds, a.binds),
|
||||
msg = string.format("'%s': %s", a.name, msg),
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- 5. BIND writes must be wave-context registers that match Binds_ fields.
|
||||
-- THE per-annotation pipeline. ONE loop. CHECK_RULES dispatches per_annot rules.
|
||||
for _, a in ipairs(annots) do
|
||||
if a.binds and binds_index[a.binds] then
|
||||
local bs = binds_index[a.binds]
|
||||
|
||||
for _, f in ipairs(bs.fields) do
|
||||
local candidate = "R_" .. f.name
|
||||
if not is_wave_context_reg(candidate) then
|
||||
warnings[#warnings + 1] = {
|
||||
line = bs.line,
|
||||
msg = string.format("%s field '%s' doesn't match a known wave-context register (candidate '%s')", a.binds, f.name, candidate),
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
for _, w in ipairs(a.writes) do
|
||||
if not is_wave_context_reg(w) then
|
||||
warnings[#warnings + 1] = {
|
||||
line = a.line,
|
||||
msg = string.format("%s writes '%s' which is not a known wave-context register", a.name, w),
|
||||
}
|
||||
end
|
||||
end
|
||||
for _, rule in ipairs(CHECK_RULES) do
|
||||
if rule.per_annot then rule.per_annot(a, pipe_ctx, findings) end
|
||||
end
|
||||
end
|
||||
|
||||
-- 6. INFO reads should be wave-context registers (or R_TapePtr for rbind).
|
||||
for _, a in ipairs(annots) do
|
||||
for _, r in ipairs(a.reads) do
|
||||
if not is_wave_context_reg(r) and r ~= "R_TapePtr" then
|
||||
warnings[#warnings + 1] = {
|
||||
line = a.line,
|
||||
msg = string.format("atom '%s' reads '%s' which is not a known wave-context register", a.name, r),
|
||||
}
|
||||
end
|
||||
end
|
||||
-- Post-loop rules (one-shot checks that need full-corpus aggregation in pipe_ctx).
|
||||
for _, rule in ipairs(CHECK_RULES) do
|
||||
if rule.post then rule.post(pipe_ctx, findings) end
|
||||
end
|
||||
|
||||
-- 7. TAPE_WORDS(mac_X, N) ↔ WORD_COUNT(mac_X, N) drift.
|
||||
-- Three outcomes: missing (error), mismatch (error), match (info).
|
||||
local function check_macro_drift(m, declared)
|
||||
if not declared then
|
||||
errors[#errors + 1] = {
|
||||
line = m.line,
|
||||
msg = string.format("TAPE_WORDS(%s, %d) but '%s' is not in metadata.h", m.name, m.words, m.name),
|
||||
}
|
||||
return
|
||||
end
|
||||
if declared ~= m.words then
|
||||
errors[#errors + 1] = {
|
||||
line = m.line,
|
||||
msg = string.format("DRIFT: TAPE_WORDS(%s, %d) but metadata.h declares WORD_COUNT(%s, %d)", m.name, m.words, m.name, declared),
|
||||
}
|
||||
return
|
||||
end
|
||||
info[#info + 1] = {
|
||||
line = m.line,
|
||||
msg = string.format("OK: %s = %d words", m.name, m.words),
|
||||
}
|
||||
end
|
||||
-- Per-macro rules (TAPE_WORDS vs WORD_COUNT drift).
|
||||
local wc = ctx.shared.word_counts
|
||||
for _, m in ipairs(scan.macros) do
|
||||
check_macro_drift(m, ctx.shared.word_counts[m.name])
|
||||
for _, rule in ipairs(CHECK_RULES) do
|
||||
if rule.per_macro then rule.per_macro(m, wc, findings) end
|
||||
end
|
||||
end
|
||||
|
||||
-- 8. Information summary.
|
||||
info[#info + 1] = {
|
||||
-- Information summary (always emitted).
|
||||
findings.info[#findings.info + 1] = {
|
||||
line = 0,
|
||||
msg = string.format("scanned: %d atom(s), %d annotation(s), %d macro-word-decl(s), %d binds struct(s)",
|
||||
#atoms, #annots, #scan.macros, #scan.binds),
|
||||
@@ -257,9 +339,9 @@ local function validate(ctx, src)
|
||||
annots = annots,
|
||||
macros = scan.macros,
|
||||
binds = scan.binds,
|
||||
errors = errors,
|
||||
warnings = warnings,
|
||||
info = info,
|
||||
errors = findings.errors,
|
||||
warnings = findings.warnings,
|
||||
info = findings.info,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -300,7 +382,7 @@ end
|
||||
|
||||
--- Stash aggregated per-module results for the report pass to consume.
|
||||
local function emit_module_annotations_stub(ctx, dir, dir_basename, atoms_count)
|
||||
ctx.flags = ctx.flags or {}
|
||||
ctx.flags = ctx.flags or {}
|
||||
ctx.flags._annot_results = ctx.flags._annot_results or {}
|
||||
ctx.flags._annot_results[#ctx.flags._annot_results + 1] = {
|
||||
dir = dir,
|
||||
@@ -339,20 +421,20 @@ function M.run(ctx)
|
||||
local dir_errors = {}
|
||||
local dir_warnings = {}
|
||||
-- Per-source validate() results, cached for the report pass (it reads from this instead of re-validating each source).
|
||||
ctx.flags = ctx.flags or {}
|
||||
ctx.flags = ctx.flags or {}
|
||||
ctx.flags._annot_source_results = ctx.flags._annot_source_results or {}
|
||||
for _, src in ipairs(dir_sources) do
|
||||
local result = validate(ctx, src)
|
||||
local result = validate(ctx, src)
|
||||
result.source = src.path -- tag for downstream rendering
|
||||
ctx.flags._annot_source_results[src.path] = result -- stash so report.lua reads from cache instead of re-running validate()
|
||||
dir_atoms = dir_atoms + #result.atoms
|
||||
for _, e in ipairs(result.errors) do
|
||||
dir_errors[#dir_errors + 1] = { line = e.line, msg = e.msg, source = src.path }
|
||||
errors[#errors + 1] = { line = e.line, msg = e.msg }
|
||||
errors [#errors + 1] = { line = e.line, msg = e.msg }
|
||||
end
|
||||
for _, w in ipairs(result.warnings) do
|
||||
dir_warnings[#dir_warnings + 1] = { line = w.line, msg = w.msg }
|
||||
warnings[#warnings + 1] = { line = w.line, msg = w.msg }
|
||||
warnings [#warnings + 1] = { line = w.line, msg = w.msg }
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
-- both standalone and when require'd from the orchestrator.
|
||||
-- Bootstrap: load `duffle_paths.lua` via `debug.getinfo(1, "S").source` (works both standalone + when require'd).
|
||||
-- duffle_paths.lua sets package.path then returns `require("duffle")` at the bottom, so the dofile value IS the duffle module.
|
||||
local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./"
|
||||
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
|
||||
local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./"
|
||||
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
|
||||
local word_count_eval = require("word_count_eval")
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -107,13 +107,14 @@ local M = {}
|
||||
-- @param before_pos integer
|
||||
-- @return integer|nil
|
||||
local function find_last_name_open_paren(source, name, before_pos)
|
||||
local search = source:sub(1, before_pos)
|
||||
local name_open = name .. "("
|
||||
local last_idx = nil
|
||||
local scan_pos = 1
|
||||
while true do
|
||||
local found = search:find(name_open, scan_pos, true) -- plain (no regex)
|
||||
if not found then break end
|
||||
-- Pass `before_pos + 1` so string.find only returns positions < before_pos + 1
|
||||
-- (string.find's 4th arg `plain` is true; we use the 3rd arg `init` for the upper bound).
|
||||
local found = source:find(name_open, scan_pos, true)
|
||||
if not found or found >= before_pos then break end
|
||||
last_idx = found
|
||||
scan_pos = found + #name_open
|
||||
end
|
||||
@@ -126,15 +127,15 @@ end
|
||||
--- Convention: function form is
|
||||
--- `FI_ MipsAtom ac_X(args) MipsAtomComp_Proc_(ac_X, { body })`
|
||||
--- We find the LAST occurrence of `"ac_X("` before `before_pos` and extract the args from inside the parens.
|
||||
--- We then verify the preceding context ends with `MipsAtom` (the function-decl keyword
|
||||
--- with possible qualifiers between).
|
||||
--- We then verify the preceding context ends with `MipsAtom`
|
||||
--- (the function-decl keyword with possible qualifiers between).
|
||||
---
|
||||
--- @param source string
|
||||
--- @param name string
|
||||
--- @param before_pos integer
|
||||
--- @return string|nil
|
||||
local function find_function_args_for(source, name, before_pos)
|
||||
local last_idx = find_last_name_open_paren(source, name, before_pos)
|
||||
local last_idx = find_last_name_open_paren(source, name, before_pos)
|
||||
if not last_idx then return nil end
|
||||
|
||||
-- Verify the preceding context ends with "MipsAtom" (with possible qualifiers between).
|
||||
@@ -224,7 +225,7 @@ end
|
||||
-- ends at `close_end_pos`. Returns (block_text, new_scan_pos) where `new_scan_pos`
|
||||
-- is where to continue scanning for more comments, or nil if no block comment was found.
|
||||
local function capture_block_comment(source, close_end_pos)
|
||||
local open_at = find_block_comment_open(source, close_end_pos)
|
||||
local open_at = find_block_comment_open(source, close_end_pos)
|
||||
if not open_at then return nil end
|
||||
local block_start = extend_left_over_indent(source, open_at)
|
||||
return source:sub(block_start, close_end_pos), block_start
|
||||
@@ -255,18 +256,18 @@ local function preceding_comment_block(source, pos)
|
||||
local pieces = {}
|
||||
while true do
|
||||
local non_ws = skip_ws_backward(source, scan_pos)
|
||||
if non_ws == 0 then break end
|
||||
if non_ws == 0 then break end
|
||||
|
||||
local is_block_close = non_ws >= 2 and source:sub(non_ws - 1, non_ws) == "*/"
|
||||
local is_line_end = source:sub(non_ws, non_ws) == "\n" or source:sub(non_ws, non_ws) == "\r"
|
||||
|
||||
if is_block_close then
|
||||
local block_text, new_scan_pos = capture_block_comment(source, non_ws)
|
||||
local block_text, new_scan_pos = capture_block_comment(source, non_ws)
|
||||
if not block_text then break end
|
||||
table.insert(pieces, 1, block_text)
|
||||
scan_pos = new_scan_pos
|
||||
elseif is_line_end then
|
||||
local line_text, new_scan_pos = capture_line_comment(source, non_ws)
|
||||
local line_text, new_scan_pos = capture_line_comment(source, non_ws)
|
||||
if not line_text then break end
|
||||
table.insert(pieces, 1, line_text)
|
||||
scan_pos = new_scan_pos
|
||||
@@ -282,8 +283,8 @@ end
|
||||
-- Argument-name extraction
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Walk `trimmed` backward from `pos` over trailing whitespace / asterisks / brackets, returning the position of the first
|
||||
-- non-trailer character (i.e. the end of the identifier).
|
||||
-- Walk `trimmed` backward from `pos` over trailing whitespace / asterisks / brackets,
|
||||
-- returning the position of the first non-trailer character (i.e. the end of the identifier).
|
||||
-- @param trimmed string
|
||||
-- @param pos integer
|
||||
-- @return integer
|
||||
@@ -322,9 +323,6 @@ end
|
||||
--- `"U4 off, U4 code, U1 r, U1 g, U1 b"` -> `{"off", "code", "r", "g", "b"}`
|
||||
--- `"U4 *ptr"` -> `{"ptr"}`
|
||||
--- `""` -> nil
|
||||
---
|
||||
--- No regex — uses `duffle.is_alnum` + plain string ops.
|
||||
---
|
||||
--- @param args_str string|nil
|
||||
--- @return string[]|nil
|
||||
local function extract_arg_names(args_str)
|
||||
@@ -334,9 +332,9 @@ local function extract_arg_names(args_str)
|
||||
for _, tok in ipairs(tokens) do
|
||||
local trimmed = duffle.trim(tok)
|
||||
if trimmed ~= "" then
|
||||
local ident_end = trim_trailer_back(trimmed, #trimmed)
|
||||
local ident_end = trim_trailer_back(trimmed, #trimmed)
|
||||
local ident_start = trim_ident_back(trimmed, ident_end) + 1
|
||||
local name = trimmed:sub(ident_start, ident_end)
|
||||
local name = trimmed:sub(ident_start, ident_end)
|
||||
if name ~= "" then names[#names + 1] = name end
|
||||
end
|
||||
end
|
||||
@@ -379,7 +377,6 @@ end
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Convert `//` line comments to `/* */` block comments in a token.
|
||||
--
|
||||
-- C macros use `\` line-continuations; a `//` comment before `\` would consume the continuation,
|
||||
-- breaking the macro. We convert `//` to `/* */` so the multi-line macro structure is preserved.
|
||||
--
|
||||
@@ -403,8 +400,8 @@ local function convert_line_comments_to_block(s)
|
||||
while eol <= len and result:byte(eol) ~= BYTE_NEWLINE do
|
||||
eol = eol + 1
|
||||
end
|
||||
local before = result:sub(1, pos - 1)
|
||||
local comment = result:sub(pos + 2, eol - 1) -- skip the `//`
|
||||
local before = result:sub(1, pos - 1)
|
||||
local comment = result:sub(pos + 2, eol - 1) -- skip the `//`
|
||||
local after
|
||||
if eol <= len and result:byte(eol) == BYTE_NEWLINE then
|
||||
after = " */" .. result:sub(eol) -- keep the newline
|
||||
@@ -422,8 +419,9 @@ end
|
||||
-- Word-count computation (memoized recursive lookup)
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Strip the `mac_` prefix from a component-call ident so we can look it up against the components-by-name table. Returns the ident unchanged
|
||||
-- if it doesn't start with the prefix (so a non-component ident like `mask_upper` falls through to the wc-table branch).
|
||||
-- Strip the `mac_` prefix from a component-call ident so we can look it up against the components-by-name table.
|
||||
-- Returns the ident unchanged if it doesn't start with the prefix
|
||||
-- (so a non-component ident like `mask_upper` falls through to the wc-table branch).
|
||||
-- @param ident string|nil
|
||||
-- @return string|nil
|
||||
local function strip_mac_prefix(ident)
|
||||
@@ -476,8 +474,7 @@ end
|
||||
--- Compute word counts for every component in `components` in a single pass.
|
||||
--- The name-lookup table + memoization cache are built ONCE (per source) instead of per-component,
|
||||
--- so the cache survives across siblings and a component's recursive `mac_Y(...)` references hit memoized values
|
||||
--- instead of re-walking the body. Previously each call rebuilt both tables (O(N) tables per call → O(N^2)).
|
||||
---
|
||||
--- instead of re-walking the body.
|
||||
--- Cycle detection (A -> B -> A) is preserved via the in-progress `-1` sentinel in `cache`.
|
||||
---
|
||||
--- @param components Component[]
|
||||
@@ -530,7 +527,7 @@ end
|
||||
--- @return string
|
||||
local function signature_from_args(args_str)
|
||||
local arg_names = extract_arg_names(args_str)
|
||||
if arg_names and #arg_names > 0 then
|
||||
if arg_names and #arg_names > 0 then
|
||||
return table.concat(arg_names, ", ")
|
||||
end
|
||||
return "..."
|
||||
@@ -540,7 +537,7 @@ end
|
||||
--- The last 2 chars are always that pair.
|
||||
local function strip_trailing_continuation(lines)
|
||||
local last = lines[#lines]
|
||||
if last:sub(-2) == " \\" then
|
||||
if last:sub(-2) == " \\" then
|
||||
lines[#lines] = last:sub(1, -3)
|
||||
end
|
||||
end
|
||||
@@ -627,14 +624,13 @@ end
|
||||
-- @return string -- the output directory
|
||||
-- @return string -- the full output path
|
||||
local function compute_macs_h_path(src)
|
||||
local out_dir = src.dir .. "/" .. GEN_SUBDIR
|
||||
local out_path = out_dir .. "/" .. duffle.basename_no_ext(src.dir) .. ".macs.h"
|
||||
local out_dir = src.dir .. "/" .. GEN_SUBDIR
|
||||
local out_path = out_dir .. "/" .. duffle.basename_no_ext(src.dir) .. ".macs.h"
|
||||
return out_dir, out_path
|
||||
end
|
||||
|
||||
--- Emit a per-source `.macs.h` header with the `mac_X` macros + `WORD_COUNT` entries. Writes in BINARY mode so LF line endings are
|
||||
--- preserved (the git blob is LF; Windows text-mode would emit CRLF and break the byte-identical diff).
|
||||
---
|
||||
--- Emit a per-source `.macs.h` header with the `mac_X` macros + `WORD_COUNT` entries.
|
||||
--- Writes in BINARY mode so LF line endings are preserved (the git blob is LF; Windows text-mode would emit CRLF and break the byte-identical diff).
|
||||
--- Honors `ctx.dry_run`: prints the intended path but does not write the file.
|
||||
---
|
||||
--- @param ctx PassCtx
|
||||
@@ -644,7 +640,6 @@ end
|
||||
--- @return string|nil -- path to the written file (nil if no components)
|
||||
local function emit_component_macros_h(ctx, src, components, counts)
|
||||
if #components == 0 then return nil end
|
||||
|
||||
local out_dir, out_path = compute_macs_h_path(src)
|
||||
local lines = header_boilerplate(src)
|
||||
|
||||
@@ -655,7 +650,6 @@ local function emit_component_macros_h(ctx, src, components, counts)
|
||||
end
|
||||
|
||||
local content = table.concat(lines, "\n") .. "\n"
|
||||
|
||||
if ctx.dry_run then
|
||||
print(string.format(" -> %s (dry-run)", out_path))
|
||||
return out_path
|
||||
|
||||
@@ -132,6 +132,14 @@ local function record_offset_marker(branches, args, at_pos)
|
||||
end
|
||||
end
|
||||
|
||||
-- MARKER_TO_HANDLER — data-driven marker dispatch (the plex pattern).
|
||||
-- Maps the marker ident to its recorder function. Each handler takes (out_table, args, at_pos).
|
||||
-- Adding a new marker type = 1 row + 1 recorder function.
|
||||
local MARKER_TO_HANDLER = {
|
||||
[LABEL_MARKER] = record_label_marker,
|
||||
[OFFSET_MARKER] = record_offset_marker,
|
||||
}
|
||||
|
||||
--- Scan a single token for atom_label/atom_offset markers, walking through balanced groups transparently (so nested calls are found).
|
||||
--- @param token string
|
||||
--- @param at_pos integer -- the branch-free word position of this token in the body
|
||||
@@ -146,13 +154,13 @@ local function scan_for_atom_markers(token, at_pos, labels, branches)
|
||||
local ch = token:sub(pos, pos)
|
||||
if duffle.is_alpha(ch) then
|
||||
local ident, after = duffle.read_ident(token, pos)
|
||||
if ident == LABEL_MARKER then
|
||||
local handler = MARKER_TO_HANDLER[ident]
|
||||
if handler then
|
||||
local args, after_paren = extract_ident_args(token, after)
|
||||
record_label_marker(labels, args, at_pos)
|
||||
pos = after_paren or after
|
||||
elseif ident == OFFSET_MARKER then
|
||||
local args, after_paren = extract_ident_args(token, after)
|
||||
record_offset_marker(branches, args, at_pos)
|
||||
-- Marker found — dispatch to its recorder. markers share labels and branches as
|
||||
-- out-tables; the recorder picks which one(s) to write to based on its semantics.
|
||||
-- (record_label_marker writes to labels; record_offset_marker writes to branches.)
|
||||
handler(ident == LABEL_MARKER and labels or branches, args, at_pos)
|
||||
pos = after_paren or after
|
||||
else
|
||||
pos = after
|
||||
|
||||
@@ -261,6 +261,28 @@ local function render_module_warnings_section(add, results, total_warnings)
|
||||
add("")
|
||||
end
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- SECTION_RENDERERS — data-driven section dispatch (the plex pattern)
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
--
|
||||
-- Each entry maps a section to its (header, render_fn). The render_fn signature:
|
||||
-- render_fn(add, results, totals)
|
||||
-- add -- the `add(line)` closure from the surrounding report renderer
|
||||
-- results -- AnnotationResult[] (per-source results)
|
||||
-- totals -- {atoms, annots, binds, macros, errors, warnings} counts
|
||||
--
|
||||
-- Sections that need to render "(none)" vs iterate use totals.errors / totals.warnings;
|
||||
-- other sections ignore the totals arg.
|
||||
-- Adding a new section = 1 row here + 1 render_<thing>_section function.
|
||||
local SECTION_RENDERERS = {
|
||||
{ header = SECTION_HEADER_ATOMS, render = render_module_atoms_section },
|
||||
{ header = SECTION_HEADER_ANNOTS, render = render_module_annots_section },
|
||||
{ header = SECTION_HEADER_BINDS, render = render_module_binds_section },
|
||||
{ header = SECTION_HEADER_MACROS, render = render_module_macros_section },
|
||||
{ header = SECTION_HEADER_ERRORS, render = function(add, results, totals) return render_module_errors_section(add, results, totals.errors) end },
|
||||
{ header = SECTION_HEADER_WARNINGS, render = function(add, results, totals) return render_module_warnings_section(add, results, totals.warnings) end },
|
||||
}
|
||||
|
||||
--- Render the per-MODULE annotation report (one `<dir_basename>.annotations.txt`).
|
||||
--- @param dir string -- module directory path
|
||||
--- @param sources SourceFile[] -- sources in this module
|
||||
@@ -282,12 +304,22 @@ local function render_module_report(dir, sources, results)
|
||||
total_atoms, total_annots, total_binds, total_macros))
|
||||
add("")
|
||||
|
||||
render_module_atoms_section(add, results)
|
||||
render_module_annots_section(add, results)
|
||||
render_module_binds_section(add, results)
|
||||
render_module_macros_section(add, results)
|
||||
render_module_errors_section(add, results, total_errors)
|
||||
render_module_warnings_section(add, results, total_warnings)
|
||||
-- Bundle the totals so the section renderers don't need separate parameter lists.
|
||||
-- Errors/warnings sections need their total count to decide "(none)" vs iterate.
|
||||
-- Sections without totals (atoms/annots/binds/macros) ignore this arg.
|
||||
local totals = {
|
||||
atoms = total_atoms, annots = total_annots, binds = total_binds,
|
||||
macros = total_macros, errors = total_errors, warnings = total_warnings,
|
||||
}
|
||||
|
||||
-- THE per-section dispatch. ONE loop over SECTION_RENDERERS. Each renderer writes its
|
||||
-- header + content via the `add` closure (pre-bound above).
|
||||
-- Adding a new section = 1 row here + 1 render_<thing>_section function.
|
||||
for _, section in ipairs(SECTION_RENDERERS) do
|
||||
add(section.header)
|
||||
section.render(add, results, totals)
|
||||
add("")
|
||||
end
|
||||
|
||||
return table.concat(lines, "\n") .. "\n"
|
||||
end
|
||||
@@ -347,7 +379,8 @@ end
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- (internal) Pull per-source validate() results from the annotation pass's stash.
|
||||
-- The annotation pass runs first in the dep chain and caches results in `ctx.flags._annot_source_results`; we read from there instead of re-validating each source.
|
||||
-- The annotation pass runs first in the dep chain and caches results in `ctx.flags._annot_source_results`;
|
||||
-- we read from there instead of re-validating each source.
|
||||
-- Returns the list of module results + the flat list of all results (for the project-wide summary).
|
||||
-- @param ctx PassCtx
|
||||
-- @param dir_sources SourceFile[]
|
||||
|
||||
+303
-250
@@ -1,6 +1,6 @@
|
||||
--- passes/scan_source.lua — Source pre-scan pass (the "mega entity" pass).
|
||||
---
|
||||
--- Single source-walk pass that produces the fat `SourceScan` payload consumed by all downstream passes. Walks each `ctx.sources` entry once,
|
||||
--- Single source-walk pass that produces the fat `SourceScan` payload consumed by all downstream passes. Walks each `ctx.sources` entry once,
|
||||
--- extracting every construct type the metaprograms need:
|
||||
---
|
||||
--- MipsAtom_ (kind = "atom", with optional atom_info inner)
|
||||
@@ -14,8 +14,7 @@
|
||||
--- This is the first pass in the dep graph (no deps).
|
||||
--- Every other pass that reads source structure depends on this one — see `ps1_meta.lua :: PASSES`.
|
||||
---
|
||||
--- **Conventions**: tabs (1/level), EmmyLua annotations, no regex,
|
||||
--- Lua 5.3 compatible.
|
||||
--- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, Lua 5.3 compatible
|
||||
|
||||
-- Bootstrap: same as entry scripts. See `ps1_meta.lua` for the rationale.
|
||||
-- Bootstrap: load `scripts/duffle_paths.lua` (sets package.path + package.cpath).
|
||||
@@ -35,7 +34,7 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
|
||||
--- @field raw_atoms AtomEntry[] -- MipsCode code_<name> { body } (offsets pass only)
|
||||
--- @field binds BindsEntry[] -- typedef Struct_(Binds_X) { fields } (fields pre-parsed)
|
||||
--- @field atom_infos AtomInfoEntry[] -- MipsAtom_(name) atom_info(...) (sub-calls pre-parsed)
|
||||
--- @field macros MacroEntry[] -- #pragma mac_X tape_atom words=N + _Pragma("...")
|
||||
--- @field macros MacroEntry[] -- #pragma mac_X tape_atom words=N + _Pragma("...")
|
||||
--- @field line_of fun(pos: integer): integer -- shared LineIndex closure
|
||||
|
||||
--- @class SourceFile
|
||||
@@ -74,7 +73,7 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
|
||||
--- @field comment string|nil -- populated by components pass (backward lookup)
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Local helpers
|
||||
-- Local helpers (shared by per-form parsers)
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- C qualifier keywords that may precede a MipsAtom_ / MipsCode declaration.
|
||||
@@ -86,6 +85,22 @@ local QUALIFIER_KEYWORDS = {
|
||||
["internal"] = true, ["LP_"] = true, ["global"] = true, ["gkknown"] = true,
|
||||
}
|
||||
|
||||
-- "ac_" prefix length on component names (e.g., `MipsAtomComp_(ac_X, ...)`). The components pass strips
|
||||
-- this prefix to derive the macro name (e.g., `mac_X`). Single source of truth — was duplicated in two
|
||||
-- branches of the pre-refactor scan_source.
|
||||
local AC_PREFIX = "ac_"
|
||||
local AC_PREFIX_LEN = 3
|
||||
|
||||
-- Strip the "ac_" prefix from a component name. Returns the input unchanged if it doesn't start with the prefix.
|
||||
-- @param raw_name string
|
||||
-- @return string
|
||||
local function strip_ac_prefix(raw_name)
|
||||
if #raw_name > AC_PREFIX_LEN and raw_name:sub(1, AC_PREFIX_LEN) == AC_PREFIX then
|
||||
return raw_name:sub(AC_PREFIX_LEN + 1)
|
||||
end
|
||||
return raw_name
|
||||
end
|
||||
|
||||
-- Parse the U4 fields from a Binds_X body. Returns (fields, byte_count).
|
||||
local function scan_binds_fields(body)
|
||||
local fields = {}
|
||||
@@ -147,13 +162,13 @@ local function scan_atom_info_subcalls(info_inner)
|
||||
if info_inner:sub(sub_open, sub_open) == "(" then
|
||||
local sub_inner, sub_after2 = duffle.read_parens(info_inner, sub_open)
|
||||
-- scan: atom_bind(<Binds_X>)
|
||||
binds = duffle.trim(sub_inner)
|
||||
binds = duffle.trim(sub_inner)
|
||||
sub_pos = sub_after2
|
||||
else
|
||||
sub_pos = sub_open + 1
|
||||
end
|
||||
elseif sub_ident == "atom_reads" or sub_ident == "atom_writes" then
|
||||
local kind = sub_ident
|
||||
local kind = sub_ident
|
||||
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)
|
||||
@@ -181,6 +196,262 @@ local function scan_skip_qualifiers(source, pos)
|
||||
end
|
||||
end
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Per-form parsers (the DECL_PARSERS table's payload)
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
--
|
||||
-- Each parser has the uniform signature:
|
||||
-- parser(source, pos, ident_end, line_of, out) -> new_pos
|
||||
-- where:
|
||||
-- source -- the full source text
|
||||
-- pos -- position of the construct's leading ident (e.g., `M` of `MipsAtom_`)
|
||||
-- ident_end -- position past the leading ident (where the `(` should be)
|
||||
-- line_of -- closure over LineIndex(source) for 1-based line lookups
|
||||
-- out -- the SourceScan out table (mutated in place: out.atoms / out.raw_atoms / out.binds / out.atom_infos / out.macros)
|
||||
-- returns -- new position after the construct
|
||||
--
|
||||
-- All parsers read source-as-written via the duffle primitives (skip_ws_and_cmt / read_parens / read_braces / read_balanced).
|
||||
-- No regex per the no_regex constraint; no hand-rolled depth tracking (the MipsAtomComp_Proc_ brace matcher
|
||||
-- now uses duffle.read_braces instead of bespoke byte-dispatch).
|
||||
--
|
||||
-- Adding a new construct = 1 row in DECL_PARSERS + 1 parser function. The scan_source() loop never needs editing.
|
||||
|
||||
--- Parse: `MipsAtom_(<name>) [atom_info(<binds>, <reads>, <writes>)] { <body> }`
|
||||
--- @param source string
|
||||
--- @param pos integer
|
||||
--- @param ident_end integer
|
||||
--- @param line_of fun(pos: integer): integer
|
||||
--- @param out SourceScan
|
||||
--- @return integer
|
||||
local function parse_mips_atom(source, pos, ident_end, line_of, out)
|
||||
local open_paren = duffle.skip_ws_and_cmt(source, ident_end)
|
||||
if source:sub(open_paren, open_paren) ~= "(" then return open_paren + 1 end
|
||||
local inner, after_paren = duffle.read_parens(source, open_paren)
|
||||
|
||||
local raw_name = duffle.read_ident(inner, 1)
|
||||
|
||||
-- Lookahead for atom_info(...) between `)` and `{`. Captures sub-calls; updates brace search start.
|
||||
local brace_search_pos = after_paren
|
||||
local lookahead = duffle.skip_ws_and_cmt(source, after_paren)
|
||||
local look_ident, look_end = duffle.read_ident(source, lookahead)
|
||||
if look_ident == "atom_info" then
|
||||
local info_open = duffle.skip_ws_and_cmt(source, look_end)
|
||||
if source:sub(info_open, info_open) == "(" then
|
||||
local info_inner, info_after = duffle.read_parens(source, info_open)
|
||||
local ai_binds, ai_reads, ai_writes = scan_atom_info_subcalls(info_inner)
|
||||
out.atom_infos[#out.atom_infos + 1] = {
|
||||
atom_name = raw_name or "?", binds = ai_binds,
|
||||
reads = ai_reads or {}, writes = ai_writes or {},
|
||||
info_line = line_of(lookahead),
|
||||
}
|
||||
brace_search_pos = info_after
|
||||
end
|
||||
end
|
||||
|
||||
local brace = duffle.scan_to_char(source, "{", brace_search_pos)
|
||||
if not brace then return open_paren + 1 end
|
||||
|
||||
local body, after_brace = duffle.read_braces(source, brace)
|
||||
if raw_name and raw_name ~= "" then
|
||||
out.atoms[#out.atoms + 1] = {
|
||||
line = line_of(pos), name = raw_name, body = body, body_off = brace + 1,
|
||||
kind = "atom", raw_name = raw_name,
|
||||
ident_pos = pos, after_paren = after_paren,
|
||||
}
|
||||
end
|
||||
|
||||
return after_brace
|
||||
end
|
||||
|
||||
--- Parse: `MipsAtomComp_(<name>) { <body> }`
|
||||
--- @param source string
|
||||
--- @param pos integer
|
||||
--- @param ident_end integer
|
||||
--- @param line_of fun(pos: integer): integer
|
||||
--- @param out SourceScan
|
||||
--- @return integer
|
||||
local function parse_mips_atom_comp(source, pos, ident_end, line_of, out)
|
||||
local open_paren = duffle.skip_ws_and_cmt(source, ident_end)
|
||||
if source:sub(open_paren, open_paren) ~= "(" then return open_paren + 1 end
|
||||
local inner, after_paren = duffle.read_parens(source, open_paren)
|
||||
|
||||
local raw_name = duffle.read_ident(inner, 1)
|
||||
if not raw_name then return open_paren + 1 end
|
||||
|
||||
local brace = duffle.scan_to_char(source, "{", after_paren)
|
||||
if not brace then return open_paren + 1 end
|
||||
|
||||
local body, after_brace = duffle.read_braces(source, brace)
|
||||
|
||||
out.atoms[#out.atoms + 1] = {
|
||||
line = line_of(pos), name = strip_ac_prefix(raw_name), body = body, body_off = brace + 1,
|
||||
kind = "comp_bare", raw_name = raw_name,
|
||||
ident_pos = pos, after_paren = after_paren,
|
||||
}
|
||||
|
||||
return after_brace
|
||||
end
|
||||
|
||||
--- Parse: `MipsAtomComp_Proc_(<name>, { <body> })` — body is inside the LAST `{` in args.
|
||||
--- @param source string
|
||||
--- @param pos integer
|
||||
--- @param ident_end integer
|
||||
--- @param line_of fun(pos: integer): integer
|
||||
--- @param out SourceScan
|
||||
--- @return integer
|
||||
local function parse_mips_atom_comp_proc(source, pos, ident_end, line_of, out)
|
||||
local open_paren = duffle.skip_ws_and_cmt(source, ident_end)
|
||||
if source:sub(open_paren, open_paren) ~= "(" then return open_paren + 1 end
|
||||
local inner, after_paren = duffle.read_parens(source, open_paren)
|
||||
|
||||
-- Find the LAST `{` in inner (the body brace, not any potential embedded braces in expressions).
|
||||
local last_brace_pos = nil
|
||||
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_brace_pos then return after_paren end
|
||||
|
||||
-- Use duffle.read_braces to find the matching close brace.
|
||||
-- Replaces the pre-refactor hand-rolled depth tracker (~25 LOC of `if c == 123 then depth = depth + 1 ...`).
|
||||
-- If close_pos is past the end of inner, the brace didn't match (malformed input); skip.
|
||||
local body, close_pos = duffle.read_braces(inner, last_brace_pos)
|
||||
if close_pos > #inner + 1 then return after_paren end
|
||||
|
||||
local raw_name = inner:match("^%s*([%w_]+)") or "?"
|
||||
-- Position of body[1] in source = open_paren + 1 (start of inner) + last_brace_pos + 1 (past '{').
|
||||
local body_off = open_paren + 2 + last_brace_pos
|
||||
|
||||
out.atoms[#out.atoms + 1] = {
|
||||
line = line_of(pos), name = strip_ac_prefix(raw_name), body = body, body_off = body_off,
|
||||
kind = "comp_proc", raw_name = raw_name,
|
||||
ident_pos = pos, after_paren = after_paren,
|
||||
}
|
||||
|
||||
return after_paren
|
||||
end
|
||||
|
||||
--- Parse: `MipsCode code_<name> { <body> }` (raw atom form — offsets pass only).
|
||||
--- @param source string
|
||||
--- @param pos integer
|
||||
--- @param ident_end integer
|
||||
--- @param line_of fun(pos: integer): integer
|
||||
--- @param out SourceScan
|
||||
--- @return integer
|
||||
local function parse_mips_code(source, pos, ident_end, line_of, out)
|
||||
local next_pos = duffle.skip_ws_and_cmt(source, ident_end)
|
||||
local next_ident, next_after = duffle.read_ident(source, next_pos)
|
||||
if not next_ident or #next_ident <= 5 or next_ident:sub(1, 5) ~= "code_" then
|
||||
return ident_end
|
||||
end
|
||||
|
||||
local atom_name = next_ident:sub(6)
|
||||
local brace_pos = duffle.scan_to_char(source, "{", next_after)
|
||||
if not brace_pos then return ident_end end
|
||||
|
||||
local body, after_brace = duffle.read_braces(source, brace_pos)
|
||||
out.raw_atoms[#out.raw_atoms + 1] = {
|
||||
line = line_of(pos), name = atom_name, body = body, body_off = brace_pos + 1,
|
||||
kind = "raw_atom", raw_name = atom_name,
|
||||
}
|
||||
|
||||
return after_brace
|
||||
end
|
||||
|
||||
--- Parse: `typedef Struct_(<name>) { <fields> }` — only emits when name starts with `Binds_`.
|
||||
--- @param source string
|
||||
--- @param pos integer
|
||||
--- @param ident_end integer
|
||||
--- @param line_of fun(pos: integer): integer
|
||||
--- @param out SourceScan
|
||||
--- @return integer
|
||||
local function parse_typedef_binds(source, pos, ident_end, line_of, out)
|
||||
local after_typedef = duffle.skip_ws_and_cmt(source, ident_end)
|
||||
local id2, id2_end = duffle.read_ident(source, after_typedef)
|
||||
if id2 ~= "Struct_" then return ident_end end
|
||||
|
||||
local open_paren = duffle.skip_ws_and_cmt(source, id2_end)
|
||||
if source:sub(open_paren, open_paren) ~= "(" then return id2_end end
|
||||
|
||||
local inner, after_paren = duffle.read_parens(source, open_paren)
|
||||
local name = duffle.trim(inner)
|
||||
|
||||
local brace = duffle.scan_to_char(source, "{", after_paren)
|
||||
if not brace then return open_paren + 1 end
|
||||
|
||||
local body, after_brace = duffle.read_braces(source, brace)
|
||||
if name:sub(1, 6) == "Binds_" then
|
||||
local fields, byte_off = scan_binds_fields(body)
|
||||
out.binds[#out.binds + 1] = { line = line_of(pos), name = name, fields = fields, bytes = byte_off }
|
||||
end
|
||||
|
||||
return after_brace
|
||||
end
|
||||
|
||||
--- Parse: `_Pragma("mac_X tape_atom words=N")` (operator form).
|
||||
--- @param source string
|
||||
--- @param pos integer
|
||||
--- @param ident_end integer
|
||||
--- @param line_of fun(pos: integer): integer
|
||||
--- @param out SourceScan
|
||||
--- @return integer
|
||||
local function parse_pragma_macro(source, pos, ident_end, line_of, out)
|
||||
local open_paren = duffle.skip_ws_and_cmt(source, ident_end)
|
||||
if source:sub(open_paren, open_paren) ~= "(" then return open_paren + 1 end
|
||||
|
||||
local str, str_end = duffle.read_parens(source, open_paren)
|
||||
str = duffle.trim(str)
|
||||
if str:sub(1, 1) ~= '"' or str:sub(-1) ~= '"' then return str_end end
|
||||
|
||||
local inner = str:sub(2, -2)
|
||||
local space = duffle.find_byte(inner, 32, 1)
|
||||
if not space then return str_end end
|
||||
|
||||
local name = inner:sub(1, space - 1)
|
||||
local rest = inner:sub(space + 1)
|
||||
local eq = duffle.find_byte(rest, 61, 1)
|
||||
if not eq then return str_end end
|
||||
|
||||
local key = duffle.trim(rest:sub(1, eq - 1))
|
||||
local val = duffle.trim(rest:sub(eq + 1))
|
||||
if key == "tape_atom words" or key == "words" then
|
||||
out.macros[#out.macros + 1] = { line = line_of(pos), name = name, words = tonumber(val) or 0 }
|
||||
end
|
||||
|
||||
return str_end
|
||||
end
|
||||
|
||||
--- Parse: `pragma` ident (no-op — directive form `#pragma` is handled by `skip_preprocessor_line` upstream).
|
||||
--- If we reach this parser it means the directive skip didn't fire, which can happen for non-#-prefixed pragma.
|
||||
--- Just advance past the ident.
|
||||
--- @param source string
|
||||
--- @param pos integer
|
||||
--- @param ident_end integer
|
||||
--- @param line_of fun(pos: integer): integer
|
||||
--- @param out SourceScan
|
||||
--- @return integer
|
||||
local function parse_pragma_dummy(source, pos, ident_end, line_of, out)
|
||||
return ident_end
|
||||
end
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- DECL_PARSERS — data-driven construct dispatch (the plex pattern)
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
--
|
||||
-- Each entry maps a leading ident to its parser function. The main scan_source() loop is one line of dispatch:
|
||||
-- local parser = DECL_PARSERS[ident]; if parser then pos = parser(...) end
|
||||
--
|
||||
-- Adding a new construct = 1 row here + 1 parser function above.
|
||||
|
||||
local DECL_PARSERS = {
|
||||
MipsAtom_ = parse_mips_atom,
|
||||
MipsAtomComp_ = parse_mips_atom_comp,
|
||||
MipsAtomComp_Proc_ = parse_mips_atom_comp_proc,
|
||||
MipsCode = parse_mips_code,
|
||||
typedef = parse_typedef_binds,
|
||||
_Pragma = parse_pragma_macro,
|
||||
pragma = parse_pragma_dummy,
|
||||
}
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- The single source walker
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -191,13 +462,16 @@ end
|
||||
--- @return table -- SourceScan { atoms, raw_atoms, binds, atom_infos, macros, line_of }
|
||||
local function scan_source(source)
|
||||
local line_of = duffle.LineIndex(source)
|
||||
local atoms = {}
|
||||
local raw_atoms = {}
|
||||
local binds = {}
|
||||
local atom_infos = {}
|
||||
local macros = {}
|
||||
local pos = 1
|
||||
local src_len = #source
|
||||
local out = {
|
||||
atoms = {},
|
||||
raw_atoms = {},
|
||||
binds = {},
|
||||
atom_infos = {},
|
||||
macros = {},
|
||||
line_of = line_of,
|
||||
}
|
||||
local pos = 1
|
||||
local src_len = #source
|
||||
|
||||
while pos <= src_len do
|
||||
pos = duffle.skip_ws_and_cmt(source, pos)
|
||||
@@ -206,250 +480,29 @@ local function scan_source(source)
|
||||
-- Skip preprocessor directives (#define / #include / #pragma / etc).
|
||||
-- _Pragma is an operator (not a directive) — it doesn't start with #.
|
||||
local pp_pos = duffle.skip_preprocessor_line(source, pos)
|
||||
if pp_pos then pos = pp_pos; goto continue end
|
||||
|
||||
-- Skip C qualifiers (static, const, etc.) that may precede a declaration.
|
||||
pos = scan_skip_qualifiers(source, pos)
|
||||
if pos > src_len then break end
|
||||
|
||||
local ident, ident_end = duffle.read_ident(source, pos)
|
||||
-- scan: <ident>
|
||||
if not ident then pos = pos + 1; goto continue end
|
||||
|
||||
-- ── MipsAtom_ / MipsAtomComp_ / MipsAtomComp_Proc_ ──
|
||||
if ident == "MipsAtom_" or ident == "MipsAtomComp_" or ident == "MipsAtomComp_Proc_" then
|
||||
local is_atom = ident == "MipsAtom_"
|
||||
local is_comp = ident == "MipsAtomComp_"
|
||||
local is_proc = ident == "MipsAtomComp_Proc_"
|
||||
local kind = is_atom and "atom" or (is_comp and "comp_bare" or "comp_proc")
|
||||
local open_paren = duffle.skip_ws_and_cmt(source, ident_end)
|
||||
if source:sub(open_paren, open_paren) ~= "(" then pos = open_paren + 1; goto continue end
|
||||
|
||||
local inner, after_paren = duffle.read_parens(source, open_paren)
|
||||
-- scan: <ident>(<args>)
|
||||
|
||||
if is_proc then
|
||||
-- MipsAtomComp_Proc_(name, { body }) — body is inside the LAST { } in args.
|
||||
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 last_brace_pos then
|
||||
local depth = 1
|
||||
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; inner_pos = inner_pos + 1
|
||||
elseif c == 125 then depth = depth - 1; if depth == 0 then break end; inner_pos = inner_pos + 1
|
||||
elseif c == 40 then local _, a = duffle.read_parens(inner, inner_pos); inner_pos = a
|
||||
elseif c == 91 then local _, a = duffle.read_brackets(inner, inner_pos); inner_pos = a
|
||||
elseif c == 34 or c == 39 then inner_pos = duffle.skip_str_or_cmt(inner, inner_pos) + 1
|
||||
else inner_pos = inner_pos + 1 end
|
||||
end
|
||||
if depth == 0 then
|
||||
-- scan: <ident>(<name>, { <body> })
|
||||
local name_match = inner:match("^%s*([%w_]+)")
|
||||
local raw_name = name_match or "?"
|
||||
-- Strip "ac_" prefix for component names (components pass convention).
|
||||
local name = raw_name
|
||||
if #raw_name > 3 and raw_name:sub(1, 3) == "ac_" then
|
||||
name = raw_name:sub(4)
|
||||
end
|
||||
local body = inner:sub(last_brace_pos + 1, inner_pos - 1)
|
||||
local body_off = open_paren + 1 + last_brace_pos
|
||||
atoms[#atoms + 1] = {
|
||||
line = line_of(pos), name = name, body = body, body_off = body_off + 1,
|
||||
kind = kind, raw_name = raw_name,
|
||||
ident_pos = pos, after_paren = after_paren,
|
||||
args = nil, comment = nil,
|
||||
}
|
||||
end
|
||||
end
|
||||
pos = after_paren
|
||||
else
|
||||
-- MipsAtom_(name) { body } OR MipsAtomComp_(name) { body }
|
||||
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 raw_name = inner:sub(name_start, name_end - 1)
|
||||
-- scan: <ident>(<name>)
|
||||
if raw_name ~= "" then
|
||||
local brace = duffle.scan_to_char(source, "{", after_paren)
|
||||
-- scan: <ident>(<name>) {
|
||||
if brace then
|
||||
local body, after_brace = duffle.read_braces(source, brace)
|
||||
-- scan: <ident>(<name>) { <body> }
|
||||
-- Strip "ac_" prefix for component names (components pass convention).
|
||||
local disp_name = raw_name
|
||||
if is_comp and #raw_name > 3 and raw_name:sub(1, 3) == "ac_" then
|
||||
disp_name = raw_name:sub(4)
|
||||
end
|
||||
atoms[#atoms + 1] = {
|
||||
line = line_of(pos), name = disp_name, body = body, body_off = brace + 1,
|
||||
kind = kind, raw_name = raw_name,
|
||||
ident_pos = pos, after_paren = after_paren,
|
||||
args = nil, comment = nil,
|
||||
}
|
||||
pos = after_brace
|
||||
if pp_pos then
|
||||
pos = pp_pos
|
||||
else
|
||||
-- Skip C qualifiers (static, const, etc.) that may precede a declaration.
|
||||
pos = scan_skip_qualifiers(source, pos)
|
||||
if pos <= src_len then
|
||||
local ident, ident_end = duffle.read_ident(source, pos)
|
||||
if ident then
|
||||
local parser = DECL_PARSERS[ident]
|
||||
if parser then
|
||||
pos = parser(source, pos, ident_end, line_of, out)
|
||||
else
|
||||
pos = open_paren + 1
|
||||
-- Unrecognized ident — advance past it.
|
||||
pos = ident_end
|
||||
end
|
||||
else
|
||||
pos = open_paren + 1
|
||||
pos = pos + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- For MipsAtom_ entries: check if atom_info(...) follows.
|
||||
if is_atom then
|
||||
local lookahead = duffle.skip_ws_and_cmt(source, after_paren)
|
||||
local look_ident, look_end = duffle.read_ident(source, lookahead)
|
||||
-- scan: MipsAtom_(<name>) <look_ident>
|
||||
if look_ident == "atom_info" then
|
||||
local info_open = duffle.skip_ws_and_cmt(source, look_end)
|
||||
if source:sub(info_open, info_open) == "(" then
|
||||
local info_inner, info_after = duffle.read_parens(source, info_open)
|
||||
-- scan: MipsAtom_(<name>) atom_info(<binds>, <reads>, <writes>)
|
||||
-- Find the atom name from the just-parsed atom entry (last one added).
|
||||
local last_atom = atoms[#atoms]
|
||||
local atom_name = last_atom and last_atom.raw_name or "?"
|
||||
local ai_binds, ai_reads, ai_writes = scan_atom_info_subcalls(info_inner)
|
||||
atom_infos[#atom_infos + 1] = {
|
||||
atom_name = atom_name, binds = ai_binds,
|
||||
reads = ai_reads or {}, writes = ai_writes or {},
|
||||
info_line = line_of(lookahead),
|
||||
}
|
||||
-- Don't advance pos past info_after — the body { ... } still needs to be skipped
|
||||
-- by the brace scan below. But if there's no body (forward decl), advance.
|
||||
local body_brace = duffle.scan_to_char(source, "{", info_after)
|
||||
if body_brace then
|
||||
local _, after_body = duffle.read_braces(source, body_brace)
|
||||
pos = after_body
|
||||
else
|
||||
pos = info_after
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
goto continue
|
||||
end
|
||||
|
||||
-- ── MipsCode code_<name> { body } (raw atom form — offsets pass only) ──
|
||||
if ident == "MipsCode" then
|
||||
local next_pos = duffle.skip_ws_and_cmt(source, ident_end)
|
||||
local next_ident, next_after = duffle.read_ident(source, next_pos)
|
||||
-- scan: MipsCode <next_ident>
|
||||
if next_ident and #next_ident > 5 and next_ident:sub(1, 5) == "code_" then
|
||||
local atom_name = next_ident:sub(6)
|
||||
-- scan: MipsCode code_<name>
|
||||
local brace_pos = duffle.scan_to_char(source, "{", next_after)
|
||||
-- scan: MipsCode code_<name> {
|
||||
if brace_pos then
|
||||
local body, after_brace = duffle.read_braces(source, brace_pos)
|
||||
-- scan: MipsCode code_<name> { <body> }
|
||||
raw_atoms[#raw_atoms + 1] = {
|
||||
line = line_of(pos), name = atom_name, body = body, body_off = brace_pos + 1,
|
||||
kind = "raw_atom", raw_name = atom_name,
|
||||
}
|
||||
pos = after_brace
|
||||
goto continue
|
||||
end
|
||||
end
|
||||
pos = ident_end
|
||||
goto continue
|
||||
end
|
||||
|
||||
-- ── typedef Struct_(Binds_X) { fields } ──
|
||||
if ident == "typedef" then
|
||||
local after_typedef = duffle.skip_ws_and_cmt(source, ident_end)
|
||||
local id2, id2_end = duffle.read_ident(source, after_typedef)
|
||||
-- scan: typedef <id2>
|
||||
if id2 == "Struct_" then
|
||||
local open_paren = duffle.skip_ws_and_cmt(source, id2_end)
|
||||
if source:sub(open_paren, open_paren) == "(" then
|
||||
local inner, after_paren = duffle.read_parens(source, open_paren)
|
||||
-- scan: typedef Struct_(<name>)
|
||||
local name = duffle.trim(inner)
|
||||
local brace = duffle.scan_to_char(source, "{", after_paren)
|
||||
-- scan: typedef Struct_(<name>) {
|
||||
if brace then
|
||||
local body, after_brace = duffle.read_braces(source, brace)
|
||||
-- scan: typedef Struct_(<name>) { <fields> }
|
||||
if name:sub(1, 6) == "Binds_" then
|
||||
local fields, byte_off = scan_binds_fields(body)
|
||||
binds[#binds + 1] = { line = line_of(pos), name = name, fields = fields, bytes = byte_off }
|
||||
end
|
||||
pos = after_brace
|
||||
goto continue
|
||||
end
|
||||
pos = open_paren + 1
|
||||
goto continue
|
||||
end
|
||||
pos = id2_end or (after_typedef + 1)
|
||||
goto continue
|
||||
end
|
||||
pos = ident_end
|
||||
goto continue
|
||||
end
|
||||
|
||||
-- ── _Pragma("mac_X tape_atom words=N") (operator form) ──
|
||||
if ident == "_Pragma" then
|
||||
local open_paren = duffle.skip_ws_and_cmt(source, ident_end)
|
||||
if source:sub(open_paren, open_paren) == "(" then
|
||||
local str, str_end = duffle.read_parens(source, open_paren)
|
||||
-- scan: _Pragma(<string>)
|
||||
str = duffle.trim(str)
|
||||
if str:sub(1, 1) == '"' and str:sub(-1) == '"' then
|
||||
local inner = str:sub(2, -2)
|
||||
local space = duffle.find_byte(inner, 32, 1)
|
||||
if space then
|
||||
local name = inner:sub(1, space - 1)
|
||||
local rest = inner:sub(space + 1)
|
||||
local eq = duffle.find_byte(rest, 61, 1)
|
||||
if eq then
|
||||
local key = duffle.trim(rest:sub(1, eq - 1))
|
||||
local val = duffle.trim(rest:sub(eq + 1))
|
||||
if key == "tape_atom words" or key == "words" then
|
||||
macros[#macros + 1] = { line = line_of(pos), name = name, words = tonumber(val) or 0 }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
pos = str_end
|
||||
goto continue
|
||||
end
|
||||
pos = open_paren + 1
|
||||
goto continue
|
||||
end
|
||||
|
||||
-- ── #pragma mac_X tape_atom words=N (directive form) ──
|
||||
-- (preprocessor skip above handles # lines, but pragma is an ident here
|
||||
-- only if it appeared without a leading # — which happens when the
|
||||
-- preprocessor skip didn't fire because the # was on a previous line.
|
||||
-- The annotation pass handles this via its own skip_preprocessor_line,
|
||||
-- but scan_source handles it here by checking the ident.)
|
||||
if ident == "pragma" then
|
||||
-- This shouldn't normally fire — #pragma lines are skipped by
|
||||
-- skip_preprocessor_line above. If we get here, it's a _Pragma
|
||||
-- variant or a non-#-prefixed pragma. Just advance.
|
||||
pos = ident_end
|
||||
goto continue
|
||||
end
|
||||
|
||||
-- ── Unrecognized ident — advance past it ──
|
||||
pos = ident_end
|
||||
|
||||
::continue::
|
||||
end
|
||||
|
||||
return {
|
||||
atoms = atoms,
|
||||
raw_atoms = raw_atoms,
|
||||
binds = binds,
|
||||
atom_infos = atom_infos,
|
||||
macros = macros,
|
||||
line_of = line_of,
|
||||
}
|
||||
return out
|
||||
end
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
--- `["static-analysis"] = { module = "passes.static_analysis", kind = "validation", deps = {"word-counts", "components"},
|
||||
--- out = { { kind = "report", path_template = "<out_root>/<basename>.static_analysis.txt" } } }`
|
||||
---
|
||||
--- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, Lua 5.3 compatible. See `lua.md` in the ps1-ai styleguides.
|
||||
--- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, Lua 5.3 compatible.
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Module-scope requires + package.path setup
|
||||
@@ -114,25 +114,6 @@ local OUTPUT_EXTENSION = ".static_analysis.txt"
|
||||
--- @field findings Finding[] -- findings for this atom
|
||||
--- @field total_cycles integer -- sum of token cycle costs
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Source scanning — delegated to duffle.scan_source (ps1_meta.lua pre-scans)
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
--
|
||||
-- The orchestrator calls duffle.scan_source once per source and stashes the fat SourceScan in src.scan.
|
||||
-- validate() below reads from src.scan — no source walking in this pass.
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Body tokenizer (top-level comma splitter + per-token classification)
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
--- Build a map: `body_relative_char_offset` -> `body_relative_line`.
|
||||
--- 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.
|
||||
---
|
||||
-- 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.
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- classify_tokens — per-token classification (the plex's pre-computed data layer)
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -156,16 +137,30 @@ local OUTPUT_EXTENSION = ".static_analysis.txt"
|
||||
-- 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
|
||||
--- @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
|
||||
--- @field mac_format_shape string|nil -- "f3" / "g4" etc. for mac_format_X_color; nil otherwise
|
||||
--- @field is_gte_store boolean -- ident matches `mac_gte_store_<shape>`
|
||||
--- @field is_ot_tag boolean -- ident matches `mac_insert_ot_tag_<shape>`
|
||||
--- @field writes_r_prim_cursor boolean -- store_word targeting R_PrimCursor
|
||||
--- @field reads_r_tape_ptr boolean -- any token referencing R_TapePtr
|
||||
--- @field o_arg1 string|nil -- first arg of O_(<a>, <b>) captures; nil for non-O_ tokens
|
||||
--- @field o_arg2 string|nil -- second arg of O_(<a>, <b>) captures
|
||||
--- @field s_arg1 string|nil -- arg of S_(<a>) captures; nil for non-S_ tokens
|
||||
|
||||
-- Patterns for O_(<arg1>, <arg2>) and S_(<arg>) captures. UNANCHORED — the substring can appear
|
||||
-- anywhere in the token (e.g., `load_word(R_T0, R_TapePtr, O_(Binds_X, field))` matches at position ~24).
|
||||
-- The binds_name match is deferred to check_abi_handoff (which compares tc.o_arg1 == atom.info.binds).
|
||||
local O_PATTERN = "O_%(([%w_]+),%s*([%w_]+)%s*%)"
|
||||
local S_PATTERN = "S_%(([%w_]+)%s*%)"
|
||||
|
||||
local function classify_tokens(tokens)
|
||||
local n = #tokens
|
||||
@@ -186,6 +181,16 @@ local function classify_tokens(tokens)
|
||||
local is_load_word = ident == "load_word"
|
||||
local is_store_word = ident == "store_word"
|
||||
|
||||
-- Per-check pre-computes (R3 lift). Each pre-compute eliminates one per-token regex/string-find
|
||||
-- call from check_abi_handoff / check_gpu_portstore_shape.
|
||||
local mac_format_shape = nil
|
||||
local is_gte_store = false
|
||||
local is_ot_tag = false
|
||||
local writes_r_prim_cursor = false
|
||||
local reads_r_tape_ptr = false
|
||||
local o_arg1, o_arg2 = nil, nil
|
||||
local s_arg1 = nil
|
||||
|
||||
if ident == "atom_label" then
|
||||
is_atom_label = true
|
||||
label_name = tok:match("^atom_label%s*%(%s*([%w_]+)%s*%)")
|
||||
@@ -194,17 +199,40 @@ local function classify_tokens(tokens)
|
||||
branch_label = tok:match("atom_offset%s*%([^,]+,%s*([%w_]+)%s*%)") or false
|
||||
end
|
||||
|
||||
-- mac_format_X_color / mac_gte_store_<shape> / mac_insert_ot_tag_<shape> (used by check_gpu_portstore_shape).
|
||||
local shape = ident:match("^mac_format_([%w_]+)_color$")
|
||||
if shape then mac_format_shape = shape end
|
||||
if ident:match("^mac_gte_store_[%w_]+$") then is_gte_store = true end
|
||||
if ident:match("^mac_insert_ot_tag_[%w_]+$") then is_ot_tag = true end
|
||||
|
||||
-- O_(<arg1>, <arg2>) / S_(<arg>) captures (used by check_abi_handoff).
|
||||
-- Cheap pattern match — anchored, fails fast on non-matching tokens.
|
||||
o_arg1, o_arg2 = tok:match(O_PATTERN)
|
||||
if not o_arg1 then s_arg1 = tok:match(S_PATTERN) end
|
||||
|
||||
-- R_TapePtr + R_PrimCursor references (used by check_abi_handoff / check_gpu_portstore_shape).
|
||||
if tok:find("R_TapePtr", 1, true) then reads_r_tape_ptr = true end
|
||||
if is_store_word and tok:find("R_PrimCursor", 1, true) then writes_r_prim_cursor = true end
|
||||
|
||||
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,
|
||||
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,
|
||||
mac_format_shape = mac_format_shape,
|
||||
is_gte_store = is_gte_store,
|
||||
is_ot_tag = is_ot_tag,
|
||||
writes_r_prim_cursor = writes_r_prim_cursor,
|
||||
reads_r_tape_ptr = reads_r_tape_ptr,
|
||||
o_arg1 = o_arg1,
|
||||
o_arg2 = o_arg2,
|
||||
s_arg1 = s_arg1,
|
||||
}
|
||||
-- Advance the nop run for the NEXT token.
|
||||
if nop_words > 0 then
|
||||
@@ -406,29 +434,28 @@ local function check_abi_handoff(atom, pipe_ctx, findings)
|
||||
local tc = atom.paths.tok_class
|
||||
local found_field_set = {}
|
||||
local found_advance = false
|
||||
local bind_re = "O_%(" .. binds_name .. ",%s*([%w_]+)%s*%)"
|
||||
for tok_idx, t in ipairs(tokens) do
|
||||
local tok = t.tok
|
||||
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>))
|
||||
if field then
|
||||
found_field_set[field] = true
|
||||
else
|
||||
local body_line = atom.line + line_in_body[t.rel]
|
||||
findings[#findings + 1] = {
|
||||
atom = atom.name, line = body_line,
|
||||
check = "abi_handoff", kind = "error",
|
||||
msg = string.format("%s at line %d has load_word(R_TapePtr, O_(%s, <non-ident>)); expected O_(%s, <field>)",
|
||||
atom.name, body_line, binds_name, binds_name),
|
||||
}
|
||||
end
|
||||
|
||||
-- Reads from tc_entry fields pre-computed by classify_tokens (R3 lift). Eliminates 3 per-token
|
||||
-- string-find/match calls (R_TapePtr + O_(binds_name,...) + bind_re) → 3 O(1) field reads.
|
||||
for tok_idx = 1, #tokens do
|
||||
local tc_entry = tc[tok_idx]
|
||||
-- scan: load_word(R_*, R_TapePtr, O_(<Binds_X>, <field>))
|
||||
if tc_entry.is_load_word and tc_entry.reads_r_tape_ptr and tc_entry.o_arg1 == binds_name then
|
||||
local field = tc_entry.o_arg2
|
||||
if field then
|
||||
found_field_set[field] = true
|
||||
else
|
||||
local body_line = atom.line + line_in_body[tokens[tok_idx].rel]
|
||||
findings[#findings + 1] = {
|
||||
atom = atom.name, line = body_line,
|
||||
check = "abi_handoff", kind = "error",
|
||||
msg = string.format("%s at line %d has load_word(R_TapePtr, O_(%s, <non-ident>)); expected O_(%s, <field>)",
|
||||
atom.name, body_line, binds_name, binds_name),
|
||||
}
|
||||
end
|
||||
end
|
||||
if tok:find("R_TapePtr", 1, true)
|
||||
and tok:find("S_(" .. binds_name .. ")", 1, true) then
|
||||
-- scan: add_ui_self(R_TapePtr, S_(<Binds_X>))
|
||||
-- scan: add_ui_self(R_TapePtr, S_(<Binds_X>))
|
||||
if tc_entry.reads_r_tape_ptr and tc_entry.s_arg1 == binds_name then
|
||||
found_advance = true
|
||||
end
|
||||
end
|
||||
@@ -468,7 +495,6 @@ 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.
|
||||
--- 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 = atom.paths.tokens
|
||||
@@ -479,31 +505,30 @@ local function check_gpu_portstore_shape(atom, pipe_ctx, findings)
|
||||
local contrib = 0
|
||||
local saw_format = false
|
||||
local saw_prim_write = false
|
||||
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 = ident:match("^mac_format_([%w_]+)_color$")
|
||||
|
||||
-- Reads from tc_entry fields pre-computed by classify_tokens (R3 lift). Eliminates 4 per-token
|
||||
-- string matches (mac_format_X_color + mac_gte_store_<shape> + mac_insert_ot_tag_<shape> + R_PrimCursor)
|
||||
for tok_idx = 1, #tokens do
|
||||
local tc_entry = tc[tok_idx]
|
||||
local shape = tc_entry.mac_format_shape
|
||||
if shape and duffle.GP0_CMD_BY_SHAPE[shape] then
|
||||
if not cmd_byte then
|
||||
cmd_byte = duffle.GP0_CMD_BY_SHAPE[shape]
|
||||
cmd_line = atom.line + line_in_body[t.rel]
|
||||
cmd_line = atom.line + line_in_body[tokens[tok_idx].rel]
|
||||
end
|
||||
saw_format = true
|
||||
local contrib_key = "mac_format_" .. shape .. "_color"
|
||||
local n = duffle.GP0_MACRO_CONTRIB[contrib_key]
|
||||
local n = duffle.GP0_MACRO_CONTRIB["mac_format_" .. shape .. "_color"]
|
||||
if n then contrib = contrib + n end
|
||||
end
|
||||
if ident:match("^mac_gte_store_[%w_]+$") then
|
||||
local n = duffle.GP0_MACRO_CONTRIB[ident]
|
||||
if tc_entry.is_gte_store then
|
||||
local n = duffle.GP0_MACRO_CONTRIB[tc_entry.ident]
|
||||
if n then contrib = contrib + n end
|
||||
end
|
||||
if ident:match("^mac_insert_ot_tag_[%w_]+$") then
|
||||
local n = duffle.GP0_MACRO_CONTRIB[ident]
|
||||
if tc_entry.is_ot_tag then
|
||||
local n = duffle.GP0_MACRO_CONTRIB[tc_entry.ident]
|
||||
if n then contrib = contrib + n end
|
||||
end
|
||||
if tc[tok_idx].is_store_word and tok:find("R_PrimCursor", 1, true) then
|
||||
if tc_entry.writes_r_prim_cursor then
|
||||
saw_prim_write = true
|
||||
end
|
||||
end
|
||||
@@ -536,10 +561,6 @@ end
|
||||
-- Check #5: per-atom cycle budget (uses analyze_atom_paths's unknown_macros)
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- 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).
|
||||
--- The BD-slot nop after a branch is absorbed into the branch's cost (MIPS-accurate: BD slot always runs),
|
||||
|
||||
@@ -28,17 +28,13 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
|
||||
-- Constants
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Windows separator chars — used to convert `dir` output (which uses `\`) into POSIX paths (which our scripts expect).
|
||||
-- Windows separator char — used by `fname:match` to recognize `.macs.h` files.
|
||||
local PATH_SEP_BACKSLASH = "\\"
|
||||
local PATH_SEP_FORWARD = "/"
|
||||
|
||||
-- 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
|
||||
-- Required native extension: lfs (LuaFileSystem). Built by `update_deps.ps1` to
|
||||
-- `toolchain/lfs/lfs.dll` and wired into package.cpath by `scripts/duffle_paths.lua`.
|
||||
-- If lfs is missing, `require` throws — fail loud per the build-tool convention.
|
||||
local lfs = require("lfs")
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Type declarations
|
||||
@@ -106,26 +102,20 @@ end
|
||||
-- └────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
--- 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 `<project_root>/<module>/gen/`.
|
||||
--- We can shortcut the `dir /b /s` walk by listing modules first (one `dir /b /ad`), then walking each `<module>/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.
|
||||
--- Native walk via lfs.attributes + lfs.dir: ~2ms vs ~56ms for the prior `dir /b /s` subprocess.
|
||||
---
|
||||
--- @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.
|
||||
-- Cache the scan_dir result per (dir, suffix) in package.loaded.
|
||||
-- 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__"
|
||||
|
||||
--- 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.
|
||||
--- Native directory enumeration via lfs (~2ms). Zero subprocess spawns.
|
||||
---
|
||||
--- @param dir string -- project root directory
|
||||
--- @param suffix string -- file pattern, e.g. "*.macs.h"
|
||||
@@ -137,33 +127,20 @@ function M.scan_dir(dir, suffix)
|
||||
if cache and cache[key] then return cache[key] end
|
||||
|
||||
local results = {}
|
||||
|
||||
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
|
||||
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
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user