Review Pass: Type annotations.

This commit is contained in:
ed
2026-08-19 23:18:32 -04:00
parent c226e8a7d3
commit 449216967b
19 changed files with 5602 additions and 429 deletions
+203 -53
View File
@@ -22,7 +22,9 @@
-- Uses `debug.getinfo` to find this file's own directory, so it works 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.
--- @type string
local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./"
--- @type DuffleExport
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
-- ════════════════════════════════════════════════════════════════════════════
@@ -30,62 +32,75 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
-- ════════════════════════════════════════════════════════════════════════════
-- Atom component declaration identifiers.
--- @type string
local ATOM_COMP_PROC = "MipsAtomComp_Proc_"
--- @type string
local MIPS_ATOM = "Slice_MipsCode" -- prefix on the function declaration that wraps an AtomComp_Proc_
-- Component-name prefixes.
--- @type string
local AC_PREFIX = "ac_" -- arg to MipsAtomComp_(ac_X); the X is the atom name
--- @type integer
local AC_PREFIX_LEN = 3
--- @type string
local MAC_PREFIX = "mac_" -- prefix on generated macros; the rest is the atom name
--- @type integer
local MAC_PREFIX_LEN = 4
-- ASCII byte values used in tokenization.
--- @type integer
local BYTE_NEWLINE = 10
--- @type integer
local BYTE_SLASH = 47
-- Output gen subdirectory + filename (per-directory aggregation; the directory name is the namespace).
--- @type string
local GEN_SUBDIR = "gen"
--- @type string
local MACS_FILENAME = "macs.h"
-- ════════════════════════════════════════════════════════════════════════════
-- Type declarations
-- ════════════════════════════════════════════════════════════════════════════
--- @class SourceFile
--- @field path string -- Absolute path to the source file
--- @field text string -- Full source text
--- @field dir string -- Directory containing the source
--- @field basename string -- Filename without extension
--- @field scan table -- Pre-scanned SourceScan payload (from duffle.scan_source)
--- @class PassCtx
--- @field sources SourceFile[] -- All source files in the build
--- @field metadata_path string -- Path to word_count.metadata.h
--- @field shared table -- Cross-pass shared state
--- @field out_root string -- Output root (e.g. "build/gen")
--- @field project_root string -- Project root (e.g. "code/")
--- @field upstream table<string, table> -- Per-pass upstream outputs
--- @field flags table -- CLI flags
--- @field verbose boolean -- Log diagnostic info
--- @class PassResult
--- @field outputs table[] -- {kind=, path=} entries describing emit files
--- @field errors table[] -- {line=, msg=} entries; build-stops
--- @field warnings table[] -- {line=, msg=} entries; build-succeeds
-- SourceFile, PassCtx, PassResult: see ps1_meta.lua
-- DuffleExport: see duffle.lua
-- SourceScan, AtomEntry, CorpusCollision, CollisionSite: see scan_source.lua
-- BodyToken: see emission_model.lua
-- WordCounts: see word_count_eval.lua
-- ComponentBodyEntry: see duffle_emit.lua
-- InstructionRow, GteCommandRow: see duffle_isa.lua
--- @class Component
--- @field name string -- Atom name (without `ac_` prefix)
--- @field body string -- Brace-delimited body (without the braces)
--- @field args string|nil -- Function-args string (function form only)
--- @field line integer -- Source line of the declaration
--- @field comment string|nil -- Scanner-owned `declaration_comment`; the components pass reads it from the scanner record
--- @field kind string -- "comp_bare" | "comp_proc" (atom_proc is NOT a component — see `project_components`)
--- @field debug_skip boolean -- Mirror of `a.debug_skip` (scanner-owned); true iff a bare `atom_dbg_skip` marker immediately preceded the declaration
--- @field name string -- Atom name (without `ac_` prefix)
--- @field body string -- Brace-delimited body (without the braces)
--- @field body_off integer|nil -- Byte offset of body[1] in source
--- @field body_tokens BodyToken[]|nil
--- @field args string|nil -- Function-args string (function form only)
--- @field arg_names string[]|nil -- Formal names with leading `ab` dropped
--- @field line integer -- Source line of the declaration
--- @field comment string|nil -- Scanner-owned `declaration_comment`; the components pass reads it from the scanner record
--- @field kind string -- "comp_bare" | "comp_proc" (atom_proc is NOT a component — see `project_components`)
--- @field debug_skip boolean -- Mirror of `a.debug_skip` (scanner-owned); true iff a bare `atom_dbg_skip` marker immediately preceded the declaration
--- @class ComponentMeta
--- @field cycle_cost integer
--- @field gp0_contrib integer
--- @class ComponentMetaMap
--- @field [string] ComponentMeta -- bag: bare component name -> meta
--- @class MacsOutput
--- @field macs_h string
--- @class ComponentsPass
--- @field run fun(ctx: PassCtx): PassResult
-- ════════════════════════════════════════════════════════════════════════════
-- Local helpers (file I/O + path normalization)
-- ════════════════════════════════════════════════════════════════════════════
--- @type ComponentsPass
local M = {}
-- ════════════════════════════════════════════════════════════════════════════
@@ -108,6 +123,7 @@ local M = {}
--- @param before_pos integer
--- @return string|nil
local function find_function_args_for(source, name, before_pos)
--- @type string|nil, string|nil
local _, args_inner = duffle.find_function_decl_for(source, before_pos, #MIPS_ATOM)
return args_inner
end
@@ -124,9 +140,13 @@ end
--- @return string[]|nil
local function extract_arg_names(args_str)
if not args_str or args_str == "" then return nil end
--- @type string[]
local names = {}
--- @type string[]
local tokens = duffle.split_top_level_commas(args_str)
--- @type integer, string
for _, tok in ipairs(tokens) do
--- @type string
local trimmed = duffle.trim(tok)
if trimmed ~= "" then
-- Strip trailing block comment (/* ... */) from the token, if present.
@@ -134,13 +154,16 @@ local function extract_arg_names(args_str)
-- not block comments embedded WITHIN a token between a parameter and a trailing comma.
-- Without this strip, the identifier-walk below stops at the `/` of `*/` and returns
-- the wrong name (or nothing). See `test_extract_arg_names_handles_trailing_block_comments`.
--- @type integer
local trimmed_end = #trimmed
if trimmed_end >= 2 and trimmed:sub(trimmed_end - 1, trimmed_end) == "*/" then
-- Find the matching `/*` that opens the trailing comment.
-- Walk back from the `*/` looking for `/*` (whitespace + `/*`).
--- @type integer
local close_pos = trimmed_end - 1 -- position of the second-to-last char
-- Walk back: skip trailing whitespace, then look for the `/*` opener.
while close_pos > 1 do
--- @type string
local ch = trimmed:sub(close_pos, close_pos)
if ch == " " or ch == "\t" or ch == "\n" or ch == "\r" then
close_pos = close_pos - 1
@@ -149,7 +172,9 @@ local function extract_arg_names(args_str)
end
end
-- Now scan back from close_pos for the `/*` opener (slashes are at close_pos-1 and close_pos-2).
--- @type integer|nil
local opener_pos = nil
--- @type integer
local scan = close_pos - 3
while scan >= 1 do
if trimmed:sub(scan, scan + 1) == "/*" then
@@ -169,8 +194,10 @@ local function extract_arg_names(args_str)
trimmed_end = #trimmed
if trimmed_end >= 4 and trimmed:sub(trimmed_end, trimmed_end) == "]" then
-- Walk back: skip digits, expect `[`.
--- @type integer
local bracket_pos = trimmed_end - 1
while bracket_pos > 1 do
--- @type string
local ch = trimmed:sub(bracket_pos, bracket_pos)
if ch >= "0" and ch <= "9" then
bracket_pos = bracket_pos - 1
@@ -185,8 +212,10 @@ local function extract_arg_names(args_str)
if trimmed == "" then goto continue end
-- Find the identifier at the end: walk back over trailers (whitespace + `*` + `[]`),
-- then walk back over the identifier chars (alnum + `_`).
--- @type integer
local ident_end = #trimmed
while ident_end > 0 do
--- @type string
local ch = trimmed:sub(ident_end, ident_end)
if ch == " " or ch == "\t" or ch == "*" or ch == "]" or ch == "[" then
ident_end = ident_end - 1
@@ -194,8 +223,10 @@ local function extract_arg_names(args_str)
break
end
end
--- @type integer
local ident_start = ident_end
while ident_start > 0 do
--- @type string
local ch = trimmed:sub(ident_start, ident_start)
if duffle.is_alnum_byte(string.byte(ch)) or ch == "_" then
ident_start = ident_start - 1
@@ -204,6 +235,7 @@ local function extract_arg_names(args_str)
end
end
ident_start = ident_start + 1
--- @type string
local name = trimmed:sub(ident_start, ident_end)
if name ~= "" then names[#names + 1] = name end
::continue::
@@ -213,7 +245,10 @@ local function extract_arg_names(args_str)
return names
end
--- @param args_str string|nil
--- @return string[]|nil
local function formal_arg_names(args_str)
--- @type string[]|nil
local names = extract_arg_names(args_str)
if not names then return nil end
if names[1] == "ab" then table.remove(names, 1) end
@@ -233,10 +268,12 @@ end
--- Carries the scanner-owned `debug_skip` flag forward so the generated projection can emit `/* atom_dbg_skip */`
--- before the authored comment and so `update_canonical_components` can mirror the same field onto `corpus.components[name]`.
--- @param source string -- the full source text (needed for backward lookups)
--- @param scan table -- SourceScan from duffle.scan_source
--- @param scan SourceScan
--- @return Component[]
local function project_components(source, scan)
--- @type Component[]
local out = {}
--- @type integer, AtomEntry
for _, a in ipairs(scan.atoms) do
-- Only `MipsAtomComp_(ac_X)` (kind="comp_bare") and `MipsAtomComp_Proc_(ac_X, ...)` (kind="comp_proc")
-- are COMPONENTS — they get inlined via `mac_<name>` aliases inside atom bodies.
@@ -248,9 +285,11 @@ local function project_components(source, scan)
-- Function-args lookup is meaningful for `MipsAtomComp_Proc_` components
-- (the macro sits inside `FI_ Slice_MipsCode ac_X(...)`); the alias expansion
-- discards the `ab` (atom-builder) arg the same way both forms do.
--- @type string|nil
local args = find_function_args_for(source, a.raw_name, a.ident_pos)
-- Comment ownership: scan_source.lua stamps `declaration_comment` on the record by walking backward past any associated bare marker.
-- The pass reads `declaration_comment` directly.
--- @type string
local comment = a.declaration_comment or ""
out[#out + 1] = {
line = a.line,
@@ -282,22 +321,30 @@ end
--- @param s string
--- @return string
local function convert_line_comments_to_block(s)
--- @type string
local result = s
--- @type integer
local pos = 1
--- @type integer
local len = #result
while pos <= len do
--- @type boolean
local is_double_slash = result:byte(pos) == BYTE_SLASH
and pos + 1 <= len and result:byte(pos + 1) == BYTE_SLASH
if not is_double_slash then
pos = pos + 1
else
-- Find end of line.
--- @type integer
local eol = pos
while eol <= len and result:byte(eol) ~= BYTE_NEWLINE do
eol = eol + 1
end
--- @type string
local before = result:sub(1, pos - 1)
--- @type string
local comment = result:sub(pos + 2, eol - 1) -- skip the `//`
--- @type string
local after
if eol <= len and result:byte(eol) == BYTE_NEWLINE then
after = " */" .. result:sub(eol) -- keep the newline
@@ -335,10 +382,13 @@ end
--- @param tok string
--- @return string
local function strip_leading_delay_marker(tok)
--- @type string|nil
local ident = duffle.read_ident(tok, 1)
if not ident or not duffle.DELAY_MARKERS[ident] then return tok end
--- @type string
local rest = tok:sub(#ident + 1):match("^%s*(.*)$") or ""
while rest:sub(1, 2) == "/*" do
--- @type integer|nil
local close = rest:find("*/", 3, true)
if not close then return "" end
rest = rest:sub(close + 2):match("^%s*(.*)$") or ""
@@ -350,22 +400,29 @@ end
--- in a single source's `count_all_components` pass; the in-progress -1 sentinel detects cycles (A -> B -> A).
--- @param name string -- the component name (without `mac_`)
--- @param comp_by_name table<string, Component>
--- @param wc table<string, integer>
--- @param cache table<string, integer>
--- @param wc WordCounts
--- @param cache table<string, integer> -- bag: name -> count; -1 in-progress sentinel
--- @return integer
local function word_count_rec(name, comp_by_name, wc, cache)
if cache[name] ~= nil then return cache[name] end
cache[name] = -1 -- mark in-progress (cycle detection)
--- @type Component|nil
local cc = comp_by_name[name]
--- @type integer
local n
if cc then
n = 0
--- @type BodyToken[]
local tokens = cc.body_tokens
--- @type integer, BodyToken
for _, t in ipairs(tokens) do
--- @type string
local trimmed = t.tok
if trimmed ~= "" then
--- @type string
local work = trimmed
while true do
--- @type string|nil
local marker = duffle.read_ident(work, 1)
if marker and duffle.DELAY_MARKERS[marker] then
work = strip_leading_delay_marker(work)
@@ -375,6 +432,7 @@ local function word_count_rec(name, comp_by_name, wc, cache)
end
end
if work ~= "" then
--- @type string|nil
local lookup = strip_mac_prefix(duffle.read_ident(work, 1))
if lookup == "atom_label" or lookup == "atom_offset" then
-- Pure metaprogram anchors; emit zero words.
@@ -405,13 +463,18 @@ end
--- references hit memoized values instead of re-walking the body.
--- Cycle detection (A -> B -> A) is preserved via the in-progress `-1` sentinel in `cache`.
--- @param components Component[]
--- @param wc table<string, integer>
--- @return table<string, integer> -- map of component name (without `mac_`) -> word count
--- @param wc WordCounts
--- @return table<string, integer> -- bag: bare component name -> word count
local function count_all_components(components, wc)
--- @type table<string, Component>
local comp_by_name = {}
--- @type integer, Component
for _, cc in ipairs(components) do comp_by_name[cc.name] = cc end
--- @type table<string, integer> -- bag: memo; -1 in-progress sentinel
local cache = {}
--- @type table<string, integer> -- bag: bare name -> word count
local counts = {}
--- @type integer, Component
for _, c in ipairs(components) do
counts[c.name] = word_count_rec(c.name, comp_by_name, wc, cache)
end
@@ -435,28 +498,39 @@ end
--- Missing component: cycle 1, gp0 0.
--- @param name string -- component bare name (e.g. "yield", "pack_color_word")
--- @param comp_by_name table<string, Component>
--- @param latency table<string, integer>
--- @param cache table<string, {cycle_cost=integer, gp0_contrib=integer}>
--- @return {cycle_cost=integer, gp0_contrib=integer}
--- @param latency table<string, integer> -- bag: ident -> cycle cost
--- @param cache ComponentMetaMap
--- @return ComponentMeta
local function component_meta_rec(name, comp_by_name, latency, cache)
if cache[name] ~= nil then return cache[name] end
cache[name] = { cycle_cost = -1, gp0_contrib = -1 }
--- @type Component|nil
local cc = comp_by_name[name]
--- @type integer
local cycle_cost
--- @type integer
local gp0_contrib
if cc then
--- @type boolean
local skip_cycle = (name == "yield")
--- @type boolean
local skip_gp0 = name:match("^insert_ot_tag") ~= nil
cycle_cost = 0
gp0_contrib = 0
if not skip_cycle or not skip_gp0 then
--- @type BodyToken[]
local tokens = cc.body_tokens
--- @type integer, BodyToken
for _, t in ipairs(tokens) do
--- @type string
local trimmed = t.tok
if trimmed ~= "" then
--- @type string|nil
local ident = duffle.read_ident(trimmed, 1)
if ident and ident:sub(1, MAC_PREFIX_LEN) == MAC_PREFIX then
--- @type string
local nested = ident:sub(MAC_PREFIX_LEN + 1)
--- @type ComponentMeta
local nested_meta = component_meta_rec(nested, comp_by_name, latency, cache)
if not skip_cycle then
cycle_cost = cycle_cost + nested_meta.cycle_cost
@@ -466,7 +540,9 @@ local function component_meta_rec(name, comp_by_name, latency, cache)
end
else
if not skip_cycle then
--- @type InstructionRow|nil
local isa = duffle.instr(ident)
--- @type GteCommandRow|nil
local gte = duffle.gte(ident)
cycle_cost = cycle_cost + ((isa and isa.cycles) or (gte and gte.cycles) or latency[ident] or 1)
end
@@ -499,13 +575,18 @@ end
--- Compute `cycle_cost` + `gp0_contrib` for every component in `components` in a single pass.
--- One memoization cache; a nested `mac_Y` inside a `mac_X` body computes both fields once.
--- @param components Component[]
--- @param latency table<string, integer>
--- @return table<string, {cycle_cost=integer, gp0_contrib=integer}>
--- @param latency table<string, integer> -- bag: ident -> cycle cost
--- @return ComponentMetaMap
local function compute_components_metadata(components, latency)
--- @type table<string, Component>
local comp_by_name = {}
--- @type integer, Component
for _, cc in ipairs(components) do comp_by_name[cc.name] = cc end
--- @type ComponentMetaMap
local cache = {}
--- @type ComponentMetaMap
local out = {}
--- @type integer, Component
for _, c in ipairs(components) do
out[c.name] = component_meta_rec(c.name, comp_by_name, latency, cache)
end
@@ -521,10 +602,14 @@ end
--- @param s string
--- @return string[]
local function split_comment_lines(s)
--- @type string[]
local out = {}
--- @type integer
local pos = 1
--- @type integer
local s_len = #s
while pos <= s_len do
--- @type integer|nil
local nl = s:find("\n", pos, true)
if not nl then
out[#out + 1] = s:sub(pos)
@@ -544,6 +629,7 @@ end
--- @param args_str string|nil
--- @return string
local function signature_from_args(args_str)
--- @type string[]|nil
local names = formal_arg_names(args_str)
if names then
return table.concat(names, ", ")
@@ -553,7 +639,10 @@ end
--- Strip the trailing `" \"` (space + backslash) line continuation from the last body line.
--- The last 2 chars are always that pair.
--- @param lines string[]
--- @return nil
local function strip_trailing_continuation(lines)
--- @type string
local last = lines[#lines]
if last:sub(-2) == " \\" then
lines[#lines] = last:sub(1, -3)
@@ -580,12 +669,15 @@ end
--- @param tok string -- a single token from split_top_level_commas (already trimmed at the start, may contain trailing whitespace + block comment)
--- @return boolean
local function is_pure_delay_marker_token(tok)
--- @type table<string, boolean> -- bag: delay-marker ident -> true
local markers = duffle.DELAY_MARKERS
if type(markers) ~= "table" then return false end
-- Identify a leading delay-marker identifier (e.g. `GteDelay_`).
--- @type integer
local ident_end = 1
while ident_end <= #tok do
--- @type string
local ch = tok:sub(ident_end, ident_end)
if ch:match("[%w_]") then
ident_end = ident_end + 1
@@ -593,16 +685,20 @@ local function is_pure_delay_marker_token(tok)
break
end
end
--- @type string
local ident = tok:sub(1, ident_end - 1)
if not markers[ident] then return false end
-- Walk the remainder: only whitespace and block comments are allowed.
--- @type integer
local scan = ident_end
while scan <= #tok do
--- @type string
local ch = tok:sub(scan, scan)
if ch:match("%s") then
scan = scan + 1
elseif ch == "/" and tok:sub(scan + 1, scan + 1) == "*" then
--- @type integer|nil
local close = tok:find("*/", scan + 2, true)
if not close then return false end
scan = close + 2
@@ -641,14 +737,22 @@ end
--- the annotation IS preserved in the generated header
--- (so the comment + marker remain visible to anyone reading `gen/macs.h`), but the C preprocessor expands the marker to empty, so leaving the `,`
--- separator out is what stops the `,,` syntax error. See `token_skips_leading_comma` for the contract.
--- @param lines string[]
--- @param c Component
--- @param sig string
--- @param tokens string[]
--- @return nil
local function emit_macro_body(lines, c, sig, tokens)
--- @type integer
for tok_idx = 1, #tokens do
tokens[tok_idx] = convert_line_comments_to_block(tokens[tok_idx])
end
if #tokens == 0 then return end
lines[#lines + 1] = "#define mac_" .. c.name .. "(" .. sig .. ") \\"
lines[#lines + 1] = "\t" .. tokens[1] .. " \\"
--- @type integer
for tok_idx = 2, #tokens do
--- @type string
local sep = token_skips_leading_comma(tokens[tok_idx]) and "\t" or ",\t"
lines[#lines + 1] = sep .. tokens[tok_idx] .. " \\"
end
@@ -660,11 +764,11 @@ end
--- For skipped components, a `/* atom_dbg_skip */` marker comment is emitted immediately before the authored comment block.
--- The marker is a single line, the comment comes next, and the `#define` line follows. The `debug_skip` stamp is scanner-owned
--- (`a.debug_skip == true` on the declaration record); the components pass projects it directly.
--- @param c Component
--- @param components Component[]
--- @param wc table<string, integer>
--- @param c Component
--- @param counts table<string, integer> -- bag: bare component name -> word count
--- @return string[] -- list of lines for this component
local function build_component_lines(c, counts)
--- @type string[]
local lines = {}
-- Marker comment: emitted once for every skipped component.
@@ -675,15 +779,20 @@ local function build_component_lines(c, counts)
end
if c.comment and c.comment ~= "" then
--- @type integer, string
for _, line in ipairs(split_comment_lines(c.comment)) do
lines[#lines + 1] = line
end
end
--- @type string[]
local tokens = duffle.split_top_level_commas(c.body)
--- @type integer
for i = 1, #tokens do tokens[i] = duffle.trim(tokens[i]) end
--- @type string
local sig = signature_from_args(c.args)
-- Direct lookup against the per-source precomputed `counts` table (built once by count_all_components).
--- @type integer
local n = counts[c.name]
if n > 0 then
@@ -707,10 +816,13 @@ end
--- @param sources SourceFile[] -- Sources contributing to this directory (for the header comment)
--- @return string[]
local function header_boilerplate(dir, sources)
--- @type string[]
local source_lines = { "// Directory: " .. duffle.to_absolute_path(dir) .. "/" }
--- @type integer, SourceFile
for _, src in ipairs(sources) do
source_lines[#source_lines + 1] = "// source: " .. duffle.to_absolute_path(src.path)
end
--- @type string
local source_blob = table.concat(source_lines, "\n")
return {
-- #pragma once wrapped in #ifdef INTELLISENSE_DIRECTIVES, matching the convention in lottes_tape.h.
@@ -739,7 +851,9 @@ end
--- @return string -- Output directory
--- @return string -- Full output path
local function compute_macs_h_path(dir)
--- @type string
local out_dir = dir .. "/" .. GEN_SUBDIR
--- @type string
local out_path = out_dir .. "/" .. MACS_FILENAME
return out_dir, out_path
end
@@ -750,19 +864,24 @@ end
--- @param dir string -- Absolute source directory
--- @param sources SourceFile[] -- Sources contributing to this directory (for the header comment)
--- @param components Component[] -- Aggregated components from all sources in this directory
--- @param counts table<string, integer> -- Precomputed word counts (from count_all_components)
--- @param counts table<string, integer> -- bag: bare component name -> word count
--- @return string|nil -- Path to the written file (nil if no components)
local function emit_component_macros_h(ctx, dir, sources, components, counts)
if #components == 0 then return nil end
--- @type string, string
local out_dir, out_path = compute_macs_h_path(dir)
--- @type string[]
local lines = header_boilerplate(dir, sources)
--- @type integer, Component
for _, c in ipairs(components) do
--- @type integer, string
for _, l in ipairs(build_component_lines(c, counts)) do
lines[#lines + 1] = l
end
end
--- @type string
local content = table.concat(lines, "\n") .. "\n"
duffle.ensure_dir(out_dir)
duffle.write_file_lf(out_path, content)
@@ -776,12 +895,16 @@ end
--- (internal) Extend `corpus.word_counts` with this source's component macros so offsets sees them without re-reading the file.
--- First declaration wins: a later caller's count is dropped (the existing entry from the first source is preserved).
--- @param corpus table -- the corpus
--- @param corpus Corpus
--- @param components Component[]
--- @param counts table<string, integer> -- precomputed word counts (from count_all_components)
--- @param counts table<string, integer> -- bag: bare component name -> word count
--- @return nil
local function update_canonical_word_counts(corpus, components, counts)
--- @type WordCounts
local wc = corpus.word_counts
--- @type integer, Component
for _, c in ipairs(components) do
--- @type string
local key = "mac_" .. c.name
if wc[key] == nil then
wc[key] = counts[c.name]
@@ -790,27 +913,33 @@ local function update_canonical_word_counts(corpus, components, counts)
end
--- @class ComponentDef
--- @field name string -- Bare name (without ac_/mac_ prefix)
--- @field line integer -- Definition source line (line of `MipsAtomComp_(ac_X)` / `MipsAtomComp_Proc_(ac_X, ...)`)
--- @field path string -- Absolute source path of the definition
--- @field kind string -- "comp_bare" | "comp_proc" (atom_proc is NOT a component)
--- @field debug_skip boolean -- Mirror of the scanner-owned `a.debug_skip`; consumers read this directly
--- @field name string -- Bare name (without ac_/mac_ prefix)
--- @field line integer -- Definition source line (line of `MipsAtomComp_(ac_X)` / `MipsAtomComp_Proc_(ac_X, ...)`)
--- @field path string -- Absolute source path of the definition
--- @field kind string -- "comp_bare" | "comp_proc" (atom_proc is NOT a component)
--- @field debug_skip boolean -- Mirror of the scanner-owned `a.debug_skip`; consumers read this directly
--- @field cycle_cost integer|nil -- From metadata[c.name]; nil when the body was not costed
--- @field gp0_contrib integer|nil -- From metadata[c.name]; nil when the body was not costed
--- (internal) Populate `corpus.components` with this source's components-by-name map.
--- First declaration wins; later declarations of the same bare name are dropped and recorded as a collision via `corpus.collisions` (kind = "component").
--- The pass does NOT write to `ctx.shared.components`.
--- No parallel skip map is built here; consumers that need the per-component skip state read `corpus.components[name].debug_skip` directly.
--- The `cycle_cost` + `gp0_contrib` fields are populated from `metadata[c.name]` (computed by `compute_components_metadata` against the original `MipsAtomComp_` body).
--- @param corpus table -- the corpus
--- @param corpus Corpus
--- @param src SourceFile
--- @param components Component[]
--- @param metadata table<string, {cycle_cost=integer, gp0_contrib=integer}>
--- @param metadata ComponentMetaMap
--- @return nil
local function update_canonical_components(corpus, src, components, metadata)
--- @type string
local rel_path = src.path:gsub("\\", "/")
--- @type integer, Component
for _, c in ipairs(components) do
-- Keyed by bare name (e.g. `yield`, `load_tri_indices`).
-- The atoms_source_map pass looks up components by bare name from the corpus;
-- `mac_` prefix lives at the call-site identifier and is stripped before lookup.
--- @type ComponentMeta|nil
local m = metadata and metadata[c.name] or nil
if corpus.components[c.name] == nil then
corpus.components[c.name] = {
@@ -825,9 +954,12 @@ local function update_canonical_components(corpus, src, components, metadata)
else
-- A second declaration of the same bare name: record a typed collision so static-analysis + the report can surface it.
-- Identical-shape declarations (same path + line) reuse the first-wins entry without a collision record.
--- @type ComponentDef
local existing = corpus.components[c.name]
if existing.path ~= rel_path or existing.line ~= c.line then
--- @type string
local kind = c.kind or "comp_bare"
--- @type string
local first_kind = existing.kind or "comp_bare"
corpus.collisions[#corpus.collisions + 1] = {
kind = "component",
@@ -845,12 +977,15 @@ end
--- (internal) Populate `corpus.component_body_index` with this source's body index entries.
--- First declaration wins; later declarations are dropped (no separate collision record: the components collision is already surfaced by `update_canonical_components`).
--- The pass writes to `corpus.component_body_index` only (the corpus owns this projection).
--- @param corpus table -- the corpus
--- @param corpus Corpus
--- @param src SourceFile
--- @param components Component[]
--- @param scan table -- the SourceScan payload (for line_of)
--- @param scan SourceScan
--- @return nil
local function update_canonical_component_body_index(corpus, src, components, scan)
--- @type (fun(pos: integer): integer)|nil
local line_of = scan and scan.line_of
--- @type integer, Component
for _, c in ipairs(components) do
if corpus.component_body_index[c.name] == nil then
corpus.component_body_index[c.name] = {
@@ -869,11 +1004,15 @@ end
--- @param ctx PassCtx
--- @return PassResult
function M.run(ctx)
--- @type MacsOutput[]
local outputs = {}
--- @type PassFinding[]
local errors = {}
--- @type PassFinding[]
local warnings = {}
-- Corpus ownership gate.
--- @type Corpus|nil
local corpus = ctx.shared and ctx.shared.corpus
if type(corpus) ~= "table" then
error("components.run requires ctx.shared.corpus.", 0)
@@ -895,14 +1034,21 @@ function M.run(ctx)
-- Per-directory aggregation: every source in the same directory contributes to one `gen/macs.h`.
-- The directory itself is the namespace. `corpus.sources_by_dir` preserves source-order within each bucket (matches `corpus.source_order`).
--- @type table<string, SourceFile[]>
local sources_by_dir = corpus.sources_by_dir or duffle.group_sources_by_dir(corpus.source_order)
--- @type string, SourceFile[]
for dir, sources in pairs(sources_by_dir) do
-- Aggregate components from every source in this directory.
-- `project_components` returns nil for sources with no `MipsAtomComp_` declarations; we skip those.
--- @type Component[]
local aggregated_components = {}
--- @type table<SourceFile, ComponentMetaMap>
local metadata_per_source = {}
--- @type integer, SourceFile
for _, src in ipairs(sources) do
--- @type Component[]
local per_source = project_components(src.text, src.scan) or {}
--- @type integer, Component
for _, c in ipairs(per_source) do
aggregated_components[#aggregated_components + 1] = c
end
@@ -913,13 +1059,17 @@ function M.run(ctx)
if #aggregated_components > 0 then
-- Compute word counts across the aggregated set. `corpus.word_counts` carries the
-- same-source + prior-directory entries so the recursive lookup sees both.
--- @type table<string, integer> -- bag: bare name -> word count
local counts = count_all_components(aggregated_components, corpus.word_counts)
--- @type string|nil
local macs_path = emit_component_macros_h(ctx, dir, sources, aggregated_components, counts)
if macs_path then
outputs[#outputs + 1] = { macs_h = macs_path }
-- Populate the projections AFTER disk emission (byte-identical `.macs.h` contract).
update_canonical_word_counts(corpus, aggregated_components, counts)
--- @type integer, SourceFile
for _, src in ipairs(sources) do
--- @type Component[]
local per_source = project_components(src.text, src.scan) or {}
if #per_source > 0 then
update_canonical_components(corpus, src, per_source, metadata_per_source[src])