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
+53
View File
@@ -1,10 +1,52 @@
--- duffle.lua — facade over duffle_scan / duffle_isa / duffle_emit. --- duffle.lua — facade over duffle_scan / duffle_isa / duffle_emit.
--- @class DuffleExport
--- bag: open module-export keys from duffle_scan / duffle_isa / duffle_emit
--- @type DuffleExport
local scan = require("duffle_scan") local scan = require("duffle_scan")
--- @type DuffleExport
local isa = require("duffle_isa") local isa = require("duffle_isa")
--- @type DuffleExport
local emit = require("duffle_emit") local emit = require("duffle_emit")
--- @type DuffleExport
local M = {} local M = {}
--- @alias Path string
--- @alias LineNum integer
--- @alias ByteOff integer
--- @alias MacroName string
--- @alias AtomName string
--- @alias Severity string
--- @class SourceFile
--- @field path Path
--- @field text string
--- @field dir string
--- @field basename string
--- @field scan SourceScan|nil
--- @class CorpusView
--- @field register_alias_registry table<string, AliasEntry>
--- @field type_name_registry table<string, TypeNameEntry>
--- @field atom_views table<AtomName, AtomViewEntry>
--- @field atom_ctxs table<AtomName, AtomCtxEntry>
--- @field atom_phases table<string, AtomPhaseGroup>
--- @field binds_by_name table<string, BindsEntry>
--- @field atoms_by_name table<AtomName, AtomEntry>
--- @field atom_infos AtomInfoEntry[]
--- @field components table<string, ComponentDef>
--- @field component_atom_infos AtomInfoEntry[]|nil
--- @field component_body_index table<string, ComponentBodyEntry>
--- @field tape_chains table<string, TapeChain>|nil
--- @field source_order SourceFile[]
--- @field collisions CorpusCollision[]
--- @param src DuffleExport
--- @param label string
--- @return nil
local function merge(src, label) local function merge(src, label)
--- @type string, any
for k, v in pairs(src) do for k, v in pairs(src) do
if M[k] ~= nil and M[k] ~= v then if M[k] ~= nil and M[k] ~= v then
error("duffle facade name collision on " .. tostring(k) .. " from " .. label, 0) error("duffle facade name collision on " .. tostring(k) .. " from " .. label, 0)
@@ -17,7 +59,10 @@ merge(scan, "duffle_scan")
merge(isa, "duffle_isa") merge(isa, "duffle_isa")
merge(emit, "duffle_emit") merge(emit, "duffle_emit")
--- @param ctx PassCtx
--- @return CorpusView
function M.corpus_view(ctx) function M.corpus_view(ctx)
--- @type Corpus
local corpus = ctx and ctx.shared and ctx.shared.corpus local corpus = ctx and ctx.shared and ctx.shared.corpus
if not corpus then error("requires ctx.shared.corpus", 0) end if not corpus then error("requires ctx.shared.corpus", 0) end
return { return {
@@ -38,8 +83,16 @@ function M.corpus_view(ctx)
} }
end end
--- @param rules CheckRule[]
--- @param phase string
--- @param item AtomEntry|SourceFile
--- @param pipe_ctx PipeCtx
--- @param findings CheckFinding[]
--- @return nil
function M.run_check_rules(rules, phase, item, pipe_ctx, findings) function M.run_check_rules(rules, phase, item, pipe_ctx, findings)
--- @type integer, CheckRule
for _, rule in ipairs(rules) do for _, rule in ipairs(rules) do
--- @type (fun(item: AtomEntry|SourceFile, pipe_ctx: PipeCtx, findings: CheckFinding[]): nil)|nil
local fn = rule[phase] local fn = rule[phase]
if fn then fn(item, pipe_ctx, findings) end if fn then fn(item, pipe_ctx, findings) end
end end
+250 -14
View File
@@ -1,8 +1,13 @@
--- duffle_emit.lua — project_emission + decl finders. --- duffle_emit.lua — project_emission + decl finders.
--- @type DuffleScan
local scan = require("duffle_scan") local scan = require("duffle_scan")
--- @type DuffleIsa
local isa = require("duffle_isa") local isa = require("duffle_isa")
--- @type DuffleEmit
local M = {} local M = {}
--- @type string, any
for k, v in pairs(scan) do M[k] = v end for k, v in pairs(scan) do M[k] = v end
--- @type string, any
for k, v in pairs(isa) do M[k] = v end for k, v in pairs(isa) do M[k] = v end
-- Section 8: Cross-source component-body index + word-event expansion -- Section 8: Cross-source component-body index + word-event expansion
@@ -12,25 +17,55 @@ for k, v in pairs(isa) do M[k] = v end
-- built once from the pre-tokenized bodies. -- built once from the pre-tokenized bodies.
--- @class ComponentBodyEntry --- @class ComponentBodyEntry
--- @field body_tokens table -- pre-tokenized {{tok=string, rel=integer}, ...} --- @field body_tokens BodyToken[]
--- @field body_off integer -- byte offset of body[1] in `source` --- @field body_off integer -- byte offset of body[1] in `source`
--- @field line_of fun(pos:integer):integer -- byte-offset → 1-based line number in `source` --- @field line_of fun(pos:integer):integer -- byte-offset → 1-based line number in `source`
--- @field source string -- absolute path of the source containing the declaration --- @field source string -- absolute path of the source containing the declaration
--- @field declaration integer -- 1-based line number of the MipsAtomComp_(ac_X) declaration --- @field declaration integer -- 1-based line number of the MipsAtomComp_(ac_X) declaration
--- @field kind string -- "comp_bare" | "comp_proc" --- @field kind string -- "comp_bare" | "comp_proc"
--- @field arg_names string[]|nil
--- @field sub_map table<string, string>|nil -- bag: formal -> substituted operand
--- @class EmissionWalkCtx
--- @field component_index table<string, ComponentBodyEntry>
--- @field word_counts WordCounts
--- @field components table<string, ComponentDef>
--- @field reg_use_schema RegUseSchema|nil
--- @field reg_use_param string|nil
--- @field atom_name AtomName|nil
--- @field schema_name string|nil
--- @field visiting table<string, boolean>|nil -- bag: component name on DFS stack
--- @field root_call_path string|nil
--- @field root_call_line integer|nil
--- @class RegUseCtx
--- @field reg_use_schema RegUseSchema|nil
--- @field reg_use_param string|nil
--- @field atom_name AtomName|nil
--- @field schema_name string|nil
--- @class DuffleEmit
-- The cross-source component-body index is owned by the corpus (`corpus.component_body_index`, populated by `passes/components.lua`). -- The cross-source component-body index is owned by the corpus (`corpus.component_body_index`, populated by `passes/components.lua`).
-- Consumers (`passes/static_analysis.lua`, `passes/emission_model.lua`) read it directly; per-pass memoization helpers stay out of scope. -- Consumers (`passes/static_analysis.lua`, `passes/emission_model.lua`) read it directly; per-pass memoization helpers stay out of scope.
-- ASCII byte constants used by split_call_args (kept local to keep Section 8 self-contained). -- ASCII byte constants used by split_call_args (kept local to keep Section 8 self-contained).
--- @type integer
local E_BYTE_OPEN_PAREN = 0x28 local E_BYTE_OPEN_PAREN = 0x28
--- @type integer
local E_BYTE_OPEN_BRACE = 0x7B local E_BYTE_OPEN_BRACE = 0x7B
--- @type integer
local E_BYTE_OPEN_BRACK = 0x5B local E_BYTE_OPEN_BRACK = 0x5B
--- @type integer
local E_BYTE_DQUOTE = 0x22 local E_BYTE_DQUOTE = 0x22
--- @type integer
local E_BYTE_SQUOTE = 0x27 local E_BYTE_SQUOTE = 0x27
--- @type integer
local E_BYTE_COMMA = 0x2C local E_BYTE_COMMA = 0x2C
-- Map an open-delimiter byte to its matching close string for read_balanced. -- Map an open-delimiter byte to its matching close string for read_balanced.
--- @type table<integer, string> -- bag: open-delimiter byte -> close string
local E_OPEN_CLOSE = { local E_OPEN_CLOSE = {
[E_BYTE_OPEN_PAREN] = ")", [E_BYTE_OPEN_PAREN] = ")",
[E_BYTE_OPEN_BRACE] = "}", [E_BYTE_OPEN_BRACE] = "}",
@@ -44,15 +79,22 @@ local E_OPEN_CLOSE = {
--- @param inner string --- @param inner string
--- @return string[] --- @return string[]
local function split_call_args(inner) local function split_call_args(inner)
--- @type string[]
local args = {} local args = {}
if not inner or inner == "" then return args end if not inner or inner == "" then return args end
--- @type integer
local pos = 1 local pos = 1
--- @type integer
local len = #inner local len = #inner
--- @type integer
local start = 1 local start = 1
while pos <= len do while pos <= len do
--- @type integer
local c = inner:byte(pos) local c = inner:byte(pos)
--- @type string|nil
local close = E_OPEN_CLOSE[c] local close = E_OPEN_CLOSE[c]
if close then if close then
--- @type integer, integer
local _, after = M.read_balanced(inner, string.char(c), close, pos) local _, after = M.read_balanced(inner, string.char(c), close, pos)
pos = after pos = after
elseif c == E_BYTE_DQUOTE or c == E_BYTE_SQUOTE then elseif c == E_BYTE_DQUOTE or c == E_BYTE_SQUOTE then
@@ -74,17 +116,22 @@ end
--- @param tok string --- @param tok string
--- @return string, string[] --- @return string, string[]
local function token_ident_and_args(tok) local function token_ident_and_args(tok)
--- @type string|nil, integer
local ident, after = M.read_ident(tok, 1) local ident, after = M.read_ident(tok, 1)
if not ident then return "?", {} end if not ident then return "?", {} end
--- @type integer
local paren_pos = M.skip_ws_and_cmt(tok, after) local paren_pos = M.skip_ws_and_cmt(tok, after)
if tok:sub(paren_pos, paren_pos) ~= "(" then return ident, {} end if tok:sub(paren_pos, paren_pos) ~= "(" then return ident, {} end
--- @type string|nil
local inner = M.read_parens(tok, paren_pos) local inner = M.read_parens(tok, paren_pos)
if not inner then return ident, {} end if not inner then return ident, {} end
return ident, split_call_args(inner) return ident, split_call_args(inner)
end end
-- The macro-name prefix that marks a `mac_X(...)` component invocation. -- The macro-name prefix that marks a `mac_X(...)` component invocation.
--- @type string
local E_MAC_PREFIX = "mac_" local E_MAC_PREFIX = "mac_"
--- @type integer
local E_MAC_PREFIX_LEN = 4 local E_MAC_PREFIX_LEN = 4
--- Expand a body entry into the flat sequence of emitted machine-word events. --- Expand a body entry into the flat sequence of emitted machine-word events.
@@ -104,10 +151,10 @@ local E_MAC_PREFIX_LEN = 4
--- ---
--- Pure: reads `body_entry` / `component_index` / `word_counts`. Memoization is the caller's responsibility. --- Pure: reads `body_entry` / `component_index` / `word_counts`. Memoization is the caller's responsibility.
--- Callers wanting `word_events` / `word_event_errors` precomputed for many atoms should memoize them per atom. --- Callers wanting `word_events` / `word_event_errors` precomputed for many atoms should memoize them per atom.
--- @param body_entry table -- `{body_tokens, body_off, line_of, source, declaration}` (declaration = root atom's atom.line) --- @param body_entry ComponentBodyEntry
--- @param component_index table -- the bare-name → ComponentBodyEntry map from M.get_component_body_index --- @param component_index table<string, ComponentBodyEntry>
--- @param word_counts table -- macro name → emitted-word count (from `ctx.shared.word_counts`) --- @param word_counts WordCounts
--- @return WordEvent[], WordEventError[] --- @return WordEvent[], EmitError[]
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Section 11: project_emission (per-atom emission projection) -- Section 11: project_emission (per-atom emission projection)
@@ -122,12 +169,12 @@ local E_MAC_PREFIX_LEN = 4
-- word_counts table is authored-metadata + current-component count table. -- word_counts table is authored-metadata + current-component count table.
--- @class EmissionProjection --- @class EmissionProjection
--- @field items table[] -- Ordered stream of word|label|offset|invoke_begin|invoke_end --- @field items EmissionItem[]
--- @field word_events table[] -- Dense view of items where kind == "word" --- @field word_events WordEvent[]
--- @field markers table[] -- Dense view of items where kind == "label"|"offset" --- @field markers EmissionMarker[]
--- @field invocations InvocationRecord[] -- dense view of items where kind == "invoke_begin"|"invoke_end" --- @field invocations InvocationRecord[] -- dense view of items where kind == "invoke_begin"|"invoke_end"
--- @field errors table[] -- Token-resolution failures surfaced without fail-loud --- @field errors EmitError[]
--- @field warnings table[] -- Opaque warnings (e.g. unknown uncounted macro) --- @field warnings EmitWarning[]
--- @class InvocationRecord --- @class InvocationRecord
--- Lives at `atom.paths.invocations[*]`. Constructed once at the single invocation-construction site --- Lives at `atom.paths.invocations[*]`. Constructed once at the single invocation-construction site
@@ -148,7 +195,7 @@ local E_MAC_PREFIX_LEN = 4
--- @field end_word integer -- 1-based items index of the `invoke_end` item (set by `emit_invoke_end`) --- @field end_word integer -- 1-based items index of the `invoke_end` item (set by `emit_invoke_end`)
--- @field word_count integer -- Number of `word` items emitted between `start_word` and `end_word` (inclusive) --- @field word_count integer -- Number of `word` items emitted between `start_word` and `end_word` (inclusive)
--- @field debug_skip boolean -- `debug_skip` stamp; true iff `corpus.components[name].debug_skip` is true at construction. Always boolean (never `nil`). --- @field debug_skip boolean -- `debug_skip` stamp; true iff `corpus.components[name].debug_skip` is true at construction. Always boolean (never `nil`).
--- @field errors table[] -- Per-invocation construction errors (cycle / count_mismatch); does not include pass-level errors --- @field errors EmitError[]
-- Internal recursive walker. The items stream holds every emitted event in order; `word_events`, `markers`, -- Internal recursive walker. The items stream holds every emitted event in order; `word_events`, `markers`,
-- `invocations`, `errors`, `warnings` are dense views / side outputs appended alongside. -- `invocations`, `errors`, `warnings` are dense views / side outputs appended alongside.
@@ -166,35 +213,58 @@ local E_MAC_PREFIX_LEN = 4
-- then breaks out without recursing (the cycle entry still receives an invocation ID + paired `invoke_begin` / `invoke_end` items, so the boundary invariant is preserved). -- then breaks out without recursing (the cycle entry still receives an invocation ID + paired `invoke_begin` / `invoke_end` items, so the boundary invariant is preserved).
-- * Component declared-count mismatch (declared vs. measured) is a construction error (kind = "count_mismatch"); recorded on the invocation record and pass-level errors list. -- * Component declared-count mismatch (declared vs. measured) is a construction error (kind = "count_mismatch"); recorded on the invocation record and pass-level errors list.
-- * Final boundary check: if any invocation is still open at end of walk, surface a "unbalanced" construction error. -- * Final boundary check: if any invocation is still open at end of walk, surface a "unbalanced" construction error.
--- @param root_body_entry ComponentBodyEntry
--- @param ctx_table EmissionWalkCtx
--- @return EmissionProjection
local function _project_emission_inner(root_body_entry, ctx_table) local function _project_emission_inner(root_body_entry, ctx_table)
--- @type EmissionItem[]
local items = {} local items = {}
--- @type WordEvent[]
local word_events = {} local word_events = {}
--- @type EmissionMarker[]
local markers = {} local markers = {}
--- @type InvocationRecord[]
local invocations = {} local invocations = {}
--- @type EmitError[]
local errors = {} local errors = {}
--- @type EmitWarning[]
local warnings = {} local warnings = {}
--- @type integer
local word_idx = 0 local word_idx = 0
--- @type InvocationRecord[]
local invocation_stack = {} -- stack of currently-open invocation records local invocation_stack = {} -- stack of currently-open invocation records
--- @type integer
local next_inv_id = 0 local next_inv_id = 0
--- @type RegUseSchema|nil
local reg_use_schema = ctx_table.reg_use_schema local reg_use_schema = ctx_table.reg_use_schema
--- @type string|nil
local reg_use_param = ctx_table.reg_use_param local reg_use_param = ctx_table.reg_use_param
--- @type AtomName|nil
local atom_name = ctx_table.atom_name local atom_name = ctx_table.atom_name
--- @type table<string, boolean> -- bag: slot name -> readonly
local slot_readonly = {} local slot_readonly = {}
if reg_use_schema then if reg_use_schema then
--- @type integer, RegUseSlot
for _, slot in ipairs(reg_use_schema.slots or {}) do for _, slot in ipairs(reg_use_schema.slots or {}) do
slot_readonly[slot.name] = slot.readonly == true slot_readonly[slot.name] = slot.readonly == true
end end
end end
--- @param sub_map table<string, string>|nil
--- @param operand string|any
--- @return any
local function apply_sub(sub_map, operand) local function apply_sub(sub_map, operand)
if not (sub_map and type(operand) == "string") then return operand end if not (sub_map and type(operand) == "string") then return operand end
if sub_map[operand] then return sub_map[operand] end if sub_map[operand] then return sub_map[operand] end
--- @type integer|nil
local dot = operand:find(".", 1, true) local dot = operand:find(".", 1, true)
if dot then if dot then
--- @type string
local head = operand:sub(1, dot - 1) local head = operand:sub(1, dot - 1)
--- @type string|nil
local mapped = sub_map[head] local mapped = sub_map[head]
if type(mapped) == "string" then if type(mapped) == "string" then
return mapped .. operand:sub(dot) return mapped .. operand:sub(dot)
@@ -203,39 +273,65 @@ local function _project_emission_inner(root_body_entry, ctx_table)
return operand return operand
end end
--- @param operand string|any
--- @return string|nil, string|nil, string|nil
local function resolve_gpr_key(operand) local function resolve_gpr_key(operand)
if type(operand) ~= "string" then return nil end if type(operand) ~= "string" then return nil end
if operand:sub(1, 2) == "R_" then return operand end if operand:sub(1, 2) == "R_" then return operand end
if not (reg_use_schema and reg_use_param) then return nil end if not (reg_use_schema and reg_use_param) then return nil end
--- @type string
local prefix = reg_use_param .. "." local prefix = reg_use_param .. "."
if operand:sub(1, #prefix) ~= prefix then return nil end if operand:sub(1, #prefix) ~= prefix then return nil end
--- @type string
local member_path = operand:sub(#prefix + 1) local member_path = operand:sub(#prefix + 1)
--- @type string|nil
local slot = reg_use_schema.alias_to_slot[member_path] local slot = reg_use_schema.alias_to_slot[member_path]
if not slot then return nil, member_path end if not slot then return nil, member_path end
return "reguse:" .. atom_name .. ":" .. slot, nil, slot return "reguse:" .. atom_name .. ":" .. slot, nil, slot
end end
--- @return integer[]
local function open_invocation_ids_snapshot() local function open_invocation_ids_snapshot()
--- @type integer[]
local ids = {} local ids = {}
--- @type integer, InvocationRecord
for _, inv in ipairs(invocation_stack) do for _, inv in ipairs(invocation_stack) do
ids[#ids + 1] = inv.id ids[#ids + 1] = inv.id
end end
return ids return ids
end end
--- @param encoder string
--- @param args string[]|nil
--- @param line integer
--- @param word_call_text string|nil
--- @param def_source_now string|nil
--- @param def_line_now integer|nil
--- @param immediate_call_text string|nil
--- @param root_call_text_w string|nil
--- @param sub_map table<string, string>|nil
--- @return nil
local function emit_word(encoder, args, line, word_call_text, def_source_now, def_line_now, immediate_call_text, root_call_text_w, sub_map) local function emit_word(encoder, args, line, word_call_text, def_source_now, def_line_now, immediate_call_text, root_call_text_w, sub_map)
--- @type integer[]
local inv_ids = open_invocation_ids_snapshot() local inv_ids = open_invocation_ids_snapshot()
--- @type integer
local outermost = inv_ids[1] or 0 local outermost = inv_ids[1] or 0
-- For words emitted at the root atom body, `immediate_call_text` is nil and the walker's `word_call_text` (the word's own token, e.g. "nop") becomes the effective call_text. -- For words emitted at the root atom body, `immediate_call_text` is nil and the walker's `word_call_text` (the word's own token, e.g. "nop") becomes the effective call_text.
-- For words emitted inside a component expansion, `immediate_call_text` is the immediate outer `mac_X(...)` token text; -- For words emitted inside a component expansion, `immediate_call_text` is the immediate outer `mac_X(...)` token text;
-- The call that triggered the body expansion we're currently walking. -- The call that triggered the body expansion we're currently walking.
--- @type string|nil
local eff_call_text = immediate_call_text or word_call_text local eff_call_text = immediate_call_text or word_call_text
--- @type string|nil
local eff_root_call_text = root_call_text_w local eff_root_call_text = root_call_text_w
--- @type string[]|nil
local gpr_keys = nil local gpr_keys = nil
if reg_use_schema or sub_map then if reg_use_schema or sub_map then
gpr_keys = {} gpr_keys = {}
--- @type integer, string
for pos, arg in ipairs(args or {}) do for pos, arg in ipairs(args or {}) do
--- @type any
local effective = apply_sub(sub_map, arg) local effective = apply_sub(sub_map, arg)
--- @type string|nil, string|nil, string|nil
local key, unresolved, slot = resolve_gpr_key(effective) local key, unresolved, slot = resolve_gpr_key(effective)
gpr_keys[pos] = key gpr_keys[pos] = key
if unresolved then if unresolved then
@@ -247,8 +343,10 @@ local function _project_emission_inner(root_body_entry, ctx_table)
} }
end end
if key and slot and slot_readonly[slot] then if key and slot and slot_readonly[slot] then
--- @type InstructionRow|nil
local row = M.instr(encoder) local row = M.instr(encoder)
if row and row.writes then if row and row.writes then
--- @type integer, integer
for _, wpos in ipairs(row.writes) do for _, wpos in ipairs(row.writes) do
if wpos == pos then if wpos == pos then
errors[#errors + 1] = { errors[#errors + 1] = {
@@ -266,16 +364,25 @@ local function _project_emission_inner(root_body_entry, ctx_table)
if not reg_use_schema then if not reg_use_schema then
gpr_keys = nil gpr_keys = nil
end end
--- @type InstructionRow|nil
local isa = M.instr(encoder) local isa = M.instr(encoder)
--- @type string
local isa_kind = isa and isa.kind or "unknown" local isa_kind = isa and isa.kind or "unknown"
--- @type integer
local nop_words = (encoder == "nop" and 1) or (encoder == "nop2" and 2) or 0 local nop_words = (encoder == "nop" and 1) or (encoder == "nop2" and 2) or 0
--- @type boolean
local is_yield = (encoder == "mac_yield" or encoder == "mac_yield_tail") local is_yield = (encoder == "mac_yield" or encoder == "mac_yield_tail")
--- @type string|nil
local gp0_shape = type(encoder) == "string" local gp0_shape = type(encoder) == "string"
and encoder:match("^mac_format_([%w_]+)_color$") and encoder:match("^mac_format_([%w_]+)_color$")
or nil or nil
--- @type boolean
local is_load = (isa_kind == "load") local is_load = (isa_kind == "load")
--- @type boolean
local is_branch = (isa_kind == "branch") local is_branch = (isa_kind == "branch")
--- @type boolean
local is_unconditional_jump = (encoder == "jump" or encoder == "call_addr") local is_unconditional_jump = (encoder == "jump" or encoder == "call_addr")
--- @type boolean
local is_terminal_jump = (encoder == "jump_reg" or encoder == "call_reg" or encoder == "jump_link") local is_terminal_jump = (encoder == "jump_reg" or encoder == "call_reg" or encoder == "jump_link")
items[#items + 1] = { items[#items + 1] = {
kind = "word", kind = "word",
@@ -324,10 +431,21 @@ local function _project_emission_inner(root_body_entry, ctx_table)
word_idx = word_idx + 1 word_idx = word_idx + 1
end end
--- @param kind string
--- @param name string
--- @param target string|nil
--- @param line integer
--- @param immediate_call_text string|nil
--- @param root_call_text_w string|nil
--- @param consuming_encoder string|nil
--- @param consuming_arg_pos integer|nil
--- @return nil
local function emit_marker(kind, name, target, line, local function emit_marker(kind, name, target, line,
immediate_call_text, root_call_text_w, immediate_call_text, root_call_text_w,
consuming_encoder, consuming_arg_pos) consuming_encoder, consuming_arg_pos)
--- @type integer[]
local inv_ids = open_invocation_ids_snapshot() local inv_ids = open_invocation_ids_snapshot()
--- @type integer
local outermost = inv_ids[1] or 0 local outermost = inv_ids[1] or 0
-- Markers carry the open invocation stack snapshot. `call_text` / `root_call_text` belong to words, not markers — markers are zero-width and skip per-word call-site attribution. -- Markers carry the open invocation stack snapshot. `call_text` / `root_call_text` belong to words, not markers — markers are zero-width and skip per-word call-site attribution.
-- `consuming_encoder` + `consuming_arg_pos` carry the surrounding control-transfer instruction context -- `consuming_encoder` + `consuming_arg_pos` carry the surrounding control-transfer instruction context
@@ -338,6 +456,7 @@ local function _project_emission_inner(root_body_entry, ctx_table)
if kind == "offset" and (consuming_encoder == nil or consuming_encoder == "") then if kind == "offset" and (consuming_encoder == nil or consuming_encoder == "") then
return return
end end
--- @type EmissionItem
local it = { local it = {
kind = kind, kind = kind,
name = name, name = name,
@@ -364,21 +483,32 @@ local function _project_emission_inner(root_body_entry, ctx_table)
-- Count top-level commas in `tok` between position `from_pos` (inclusive) and `to_pos` (exclusive). -- Count top-level commas in `tok` between position `from_pos` (inclusive) and `to_pos` (exclusive).
-- Tracks paren depth so commas inside nested () don't count. Skips string literals + comments. -- Tracks paren depth so commas inside nested () don't count. Skips string literals + comments.
-- Used by `emit_embedded_markers` to compute `consuming_arg_pos` for each embedded marker. -- Used by `emit_embedded_markers` to compute `consuming_arg_pos` for each embedded marker.
--- @param tok string
--- @param from_pos integer
--- @param to_pos integer
--- @return integer
local function count_top_level_commas(tok, from_pos, to_pos) local function count_top_level_commas(tok, from_pos, to_pos)
--- @type integer
local depth = 0 local depth = 0
--- @type integer
local count = 0 local count = 0
--- @type integer
local i = from_pos local i = from_pos
while i < to_pos do while i < to_pos do
--- @type string
local c = tok:sub(i, i) local c = tok:sub(i, i)
if c == "'" or c == '"' then if c == "'" or c == '"' then
--- @type integer
local next_pos = M.skip_str_or_cmt(tok, i) local next_pos = M.skip_str_or_cmt(tok, i)
i = (next_pos > i) and next_pos or (i + 1) i = (next_pos > i) and next_pos or (i + 1)
elseif c == "/" and tok:sub(i + 1, i + 1) == "/" then elseif c == "/" and tok:sub(i + 1, i + 1) == "/" then
-- line comment: skip to end of line -- line comment: skip to end of line
--- @type integer|nil
local nl = tok:find("\n", i, true) local nl = tok:find("\n", i, true)
i = (nl and nl + 1) or (#tok + 1) i = (nl and nl + 1) or (#tok + 1)
elseif c == "/" and tok:sub(i + 1, i + 1) == "*" then elseif c == "/" and tok:sub(i + 1, i + 1) == "*" then
-- block comment: skip to matching */ -- block comment: skip to matching */
--- @type integer|nil
local close = tok:find("*/", i + 2, true) local close = tok:find("*/", i + 2, true)
i = (close and close + 2) or (#tok + 1) i = (close and close + 2) or (#tok + 1)
elseif c == "(" then elseif c == "(" then
@@ -399,9 +529,13 @@ local function _project_emission_inner(root_body_entry, ctx_table)
-- Find the position of the consuming instruction's open paren (the `(` that starts the consuming instruction's argument list). -- Find the position of the consuming instruction's open paren (the `(` that starts the consuming instruction's argument list).
-- Returns nil if the token's leading text isn't an ident followed by `(` (e.g. the ident is at the start of a non-instruction token). -- Returns nil if the token's leading text isn't an ident followed by `(` (e.g. the ident is at the start of a non-instruction token).
--- @param tok string
--- @return integer|nil
local function find_consuming_paren(tok) local function find_consuming_paren(tok)
--- @type integer
local i = 1 local i = 1
while i <= #tok do while i <= #tok do
--- @type string
local c = tok:sub(i, i) local c = tok:sub(i, i)
if c == "(" then return i end if c == "(" then return i end
if not c:match("[%w_]") and c ~= " " then return nil end if not c:match("[%w_]") and c ~= " " then return nil end
@@ -410,24 +544,33 @@ local function _project_emission_inner(root_body_entry, ctx_table)
return nil return nil
end end
--- @param tok string
--- @param tok_line integer
--- @param consuming_encoder string|nil
--- @return nil
local function emit_embedded_markers(tok, tok_line, consuming_encoder) local function emit_embedded_markers(tok, tok_line, consuming_encoder)
-- When called with a non-nil `consuming_encoder`, the marker is nested inside that instruction's argument list. -- When called with a non-nil `consuming_encoder`, the marker is nested inside that instruction's argument list.
-- We compute each marker's arg position by counting top-level commas between the consuming instruction's `(` and the marker's start. -- We compute each marker's arg position by counting top-level commas between the consuming instruction's `(` and the marker's start.
--- @type integer|nil
local consuming_paren = nil local consuming_paren = nil
if consuming_encoder then consuming_paren = find_consuming_paren(tok) end if consuming_encoder then consuming_paren = find_consuming_paren(tok) end
--- @type integer
local pos = 1 local pos = 1
while pos <= #tok do while pos <= #tok do
-- Trim leading whitespace and comments before each scan. -- Trim leading whitespace and comments before each scan.
pos = M.skip_ws_and_cmt(tok, pos) pos = M.skip_ws_and_cmt(tok, pos)
if pos > #tok then break end if pos > #tok then break end
--- @type string|nil, integer
local ident, after = M.read_ident(tok, pos) local ident, after = M.read_ident(tok, pos)
if not ident then if not ident then
-- Not an ident: token is a string or comment; skip or one-step. -- Not an ident: token is a string or comment; skip or one-step.
--- @type integer
local next_pos = M.skip_str_or_cmt(tok, pos) local next_pos = M.skip_str_or_cmt(tok, pos)
pos = (next_pos > pos) and next_pos or (pos + 1) pos = (next_pos > pos) and next_pos or (pos + 1)
goto continue_loop goto continue_loop
end end
if M.DELAY_MARKERS[ident] then if M.DELAY_MARKERS[ident] then
--- @type integer|nil
local arg_pos = nil local arg_pos = nil
if consuming_encoder and consuming_paren then if consuming_encoder and consuming_paren then
arg_pos = count_top_level_commas(tok, consuming_paren + 1, pos) + 1 arg_pos = count_top_level_commas(tok, consuming_paren + 1, pos) + 1
@@ -442,7 +585,9 @@ local function _project_emission_inner(root_body_entry, ctx_table)
goto continue_loop goto continue_loop
end end
-- Marker ident: parse the (...) arguments. -- Marker ident: parse the (...) arguments.
--- @type integer
local open = M.skip_ws_and_cmt(tok, after) local open = M.skip_ws_and_cmt(tok, after)
--- @type string|nil, integer
local inner, after_paren = M.read_parens(tok, open) local inner, after_paren = M.read_parens(tok, open)
if not inner then if not inner then
-- (...) Unreadable: fall back to non-marker behavior. -- (...) Unreadable: fall back to non-marker behavior.
@@ -453,10 +598,12 @@ local function _project_emission_inner(root_body_entry, ctx_table)
-- For embedded markers, propagate the consuming_encoder + the marker's arg position -- For embedded markers, propagate the consuming_encoder + the marker's arg position
-- (1-based) so `passes/offsets.lua` can dispatch per-consuming-instruction offset encoding. -- (1-based) so `passes/offsets.lua` can dispatch per-consuming-instruction offset encoding.
-- Offset markers are emitted only when a consuming encoder is present. -- Offset markers are emitted only when a consuming encoder is present.
--- @type integer|nil
local arg_pos = nil local arg_pos = nil
if consuming_encoder and consuming_paren then if consuming_encoder and consuming_paren then
arg_pos = count_top_level_commas(tok, consuming_paren + 1, pos) + 1 arg_pos = count_top_level_commas(tok, consuming_paren + 1, pos) + 1
end end
--- @type string[]
local args = split_call_args(inner) local args = split_call_args(inner)
if ident == "atom_label" then emit_marker("label", args[1] or "", nil, tok_line, nil, nil, consuming_encoder, arg_pos) if ident == "atom_label" then emit_marker("label", args[1] or "", nil, tok_line, nil, nil, consuming_encoder, arg_pos)
elseif consuming_encoder then emit_marker("offset", args[1] or "", args[2] or "", tok_line, nil, nil, consuming_encoder, arg_pos) elseif consuming_encoder then emit_marker("offset", args[1] or "", args[2] or "", tok_line, nil, nil, consuming_encoder, arg_pos)
@@ -466,6 +613,13 @@ local function _project_emission_inner(root_body_entry, ctx_table)
end end
end end
--- @param inv_kind string
--- @param component_name string
--- @param call_text string
--- @param root_call_text string|nil
--- @param call_path string
--- @param call_line integer
--- @return InvocationRecord
local function emit_invoke_begin(inv_kind, component_name, call_text, local function emit_invoke_begin(inv_kind, component_name, call_text,
root_call_text, call_path, call_line) root_call_text, call_path, call_line)
next_inv_id = next_inv_id + 1 next_inv_id = next_inv_id + 1
@@ -476,7 +630,9 @@ local function _project_emission_inner(root_body_entry, ctx_table)
-- The walker has already found the component body in `ctx_table.component_index[component_name]`, so the matching entry MUST exist in `ctx_table.components[component_name]` -- The walker has already found the component body in `ctx_table.component_index[component_name]`, so the matching entry MUST exist in `ctx_table.components[component_name]`
-- (both registries are populated from the same source by the components pass). -- (both registries are populated from the same source by the components pass).
-- A missing entry is a corpus-plumbing bug; we fail loudly here rather than silently stamp `false` and mask the regression. -- A missing entry is a corpus-plumbing bug; we fail loudly here rather than silently stamp `false` and mask the regression.
--- @type table<string, ComponentDef>|nil
local components = ctx_table.components local components = ctx_table.components
--- @type ComponentDef|nil
local component_def = components and components[component_name] or nil local component_def = components and components[component_name] or nil
if not component_def then if not component_def then
error("duffle.emit_invoke_begin: component " .. string.format("%q", component_name) error("duffle.emit_invoke_begin: component " .. string.format("%q", component_name)
@@ -486,7 +642,9 @@ local function _project_emission_inner(root_body_entry, ctx_table)
, 0 , 0
) )
end end
--- @type boolean
local debug_skip_stamp = component_def.debug_skip == true local debug_skip_stamp = component_def.debug_skip == true
--- @type InvocationRecord
local inv = { local inv = {
id = next_inv_id, id = next_inv_id,
parent_id = 0, -- patched below by caller parent_id = 0, -- patched below by caller
@@ -521,6 +679,8 @@ local function _project_emission_inner(root_body_entry, ctx_table)
return inv return inv
end end
--- @param inv InvocationRecord
--- @return nil
local function emit_invoke_end(inv) local function emit_invoke_end(inv)
-- 0-based emitted-word position of the LAST word inside this invocation. -- 0-based emitted-word position of the LAST word inside this invocation.
-- After the last body word was emitted, `word_idx` was incremented past it, so `word_idx - 1` is the 0-based position of the last word. -- After the last body word was emitted, `word_idx` was incremented past it, so `word_idx - 1` is the 0-based position of the last word.
@@ -532,6 +692,7 @@ local function _project_emission_inner(root_body_entry, ctx_table)
word_index = word_idx, word_index = word_idx,
invocation_ids = open_invocation_ids_snapshot(), invocation_ids = open_invocation_ids_snapshot(),
} }
--- @type integer
for i = #invocation_stack, 1, -1 do for i = #invocation_stack, 1, -1 do
if invocation_stack[i] == inv then if invocation_stack[i] == inv then
table.remove(invocation_stack, i) table.remove(invocation_stack, i)
@@ -542,9 +703,14 @@ local function _project_emission_inner(root_body_entry, ctx_table)
-- Resolve the per-token word count. If unresolved, surface ONE warning -- Resolve the per-token word count. If unresolved, surface ONE warning
-- and fall back to 1 opaque word so the cycle budget still accounts for the slot. -- and fall back to 1 opaque word so the cycle budget still accounts for the slot.
--- @param ident string
--- @param tok_line integer
--- @return integer
local function resolve_count(ident, tok_line) local function resolve_count(ident, tok_line)
--- @type WordCounts|nil
local wc = ctx_table.word_counts local wc = ctx_table.word_counts
if wc and wc[ident] then return wc[ident] end if wc and wc[ident] then return wc[ident] end
--- @type string
local canon = M.gte_canon(ident) local canon = M.gte_canon(ident)
if canon ~= ident and wc and wc[canon] then return wc[canon] end if canon ~= ident and wc and wc[canon] then return wc[canon] end
warnings[#warnings + 1] = { warnings[#warnings + 1] = {
@@ -561,24 +727,40 @@ local function _project_emission_inner(root_body_entry, ctx_table)
-- walk_root_call_text: Outermost `mac_X(...)` token text (preserved across recursion). -- walk_root_call_text: Outermost `mac_X(...)` token text (preserved across recursion).
-- walk_immediate_call_text: IMMEDIATE outer `mac_X(...)` token text for words emitted in this body — nil for the root atom body. -- walk_immediate_call_text: IMMEDIATE outer `mac_X(...)` token text for words emitted in this body — nil for the root atom body.
-- Two trackers are propagated as separate parameters so words deep inside nested expansions correctly identify both their immediate call site and the outermost call site. -- Two trackers are propagated as separate parameters so words deep inside nested expansions correctly identify both their immediate call site and the outermost call site.
--- @param body_entry ComponentBodyEntry
--- @param walk_parent_inv_id integer
--- @param walk_root_call_text string|nil
--- @param walk_immediate_call_text string|nil
--- @return nil
local function walk_body_entry(body_entry, walk_parent_inv_id, local function walk_body_entry(body_entry, walk_parent_inv_id,
walk_root_call_text, walk_immediate_call_text) walk_root_call_text, walk_immediate_call_text)
--- @type BodyToken[]
local tokens = body_entry.body_tokens or {} local tokens = body_entry.body_tokens or {}
--- @type integer
local body_off = body_entry.body_off or 0 local body_off = body_entry.body_off or 0
--- @type LineIndexFn
local line_of = body_entry.line_of or M.LineIndex("") local line_of = body_entry.line_of or M.LineIndex("")
--- @type string
local def_source = body_entry.source or "" local def_source = body_entry.source or ""
--- @type integer
local def_line = body_entry.declaration or 0 local def_line = body_entry.declaration or 0
--- @type table<string, string>|nil
local sub_map = body_entry.sub_map local sub_map = body_entry.sub_map
-- Per-token dispatch: each matched branch returns; only the fall-through -- Per-token dispatch: each matched branch returns; only the fall-through
-- "opaque word" emit handles direct encoders + mac_X-without-component. -- "opaque word" emit handles direct encoders + mac_X-without-component.
--- @param bt BodyToken
--- @return nil
local function process_token(bt) local function process_token(bt)
--- @type string
local tok = M.trim(bt.tok or "") local tok = M.trim(bt.tok or "")
-- Substituted MipsCode args can carry // comments from the call site. -- Substituted MipsCode args can carry // comments from the call site.
while tok ~= "" do while tok ~= "" do
if tok:sub(1, 2) == "//" then if tok:sub(1, 2) == "//" then
--- @type integer|nil
local nl = tok:find("\n") local nl = tok:find("\n")
tok = M.trim(nl and tok:sub(nl + 1) or "") tok = M.trim(nl and tok:sub(nl + 1) or "")
elseif tok:sub(1, 2) == "/*" then elseif tok:sub(1, 2) == "/*" then
--- @type integer|nil
local close = tok:find("*/", 3, true) local close = tok:find("*/", 3, true)
tok = M.trim(close and tok:sub(close + 2) or "") tok = M.trim(close and tok:sub(close + 2) or "")
else else
@@ -586,19 +768,25 @@ local function _project_emission_inner(root_body_entry, ctx_table)
end end
end end
if tok == "" then return end if tok == "" then return end
--- @type string|nil, integer
local ident, after = M.read_ident(tok, 1) local ident, after = M.read_ident(tok, 1)
if not ident then ident = "?" end if not ident then ident = "?" end
--- @type string, string[]
local _, args = token_ident_and_args(tok) local _, args = token_ident_and_args(tok)
--- @type integer
local tok_line = line_of(body_off + bt.rel) or 0 local tok_line = line_of(body_off + bt.rel) or 0
if M.DELAY_MARKERS[ident] then if M.DELAY_MARKERS[ident] then
emit_marker("delay", ident, nil, tok_line) emit_marker("delay", ident, nil, tok_line)
--- @type string
local rest = tok:sub(after or (#tok + 1)) local rest = tok:sub(after or (#tok + 1))
while true do while true do
rest = M.trim(rest) rest = M.trim(rest)
if rest:sub(1, 2) == "//" then if rest:sub(1, 2) == "//" then
--- @type integer|nil
local nl = rest:find("\n") local nl = rest:find("\n")
rest = nl and rest:sub(nl + 1) or "" rest = nl and rest:sub(nl + 1) or ""
elseif rest:sub(1, 2) == "/*" then elseif rest:sub(1, 2) == "/*" then
--- @type integer|nil
local close = rest:find("*/", 3, true) local close = rest:find("*/", 3, true)
if not close then rest = ""; break end if not close then rest = ""; break end
rest = rest:sub(close + 2) rest = rest:sub(close + 2)
@@ -615,6 +803,7 @@ local function _project_emission_inner(root_body_entry, ctx_table)
-- Pass `ident` as the consuming instruction so `emit_embedded_markers` can compute each marker's arg position + record the consuming_encoder for the offsets pass. -- Pass `ident` as the consuming instruction so `emit_embedded_markers` can compute each marker's arg position + record the consuming_encoder for the offsets pass.
-- Canonicalize `jump_rel` to `branch_equal` (its preprocessor-expanded form) so the `consuming_encoder` metadata in marker records is canonical. -- Canonicalize `jump_rel` to `branch_equal` (its preprocessor-expanded form) so the `consuming_encoder` metadata in marker records is canonical.
-- `jump_rel`: unconditional jump alias from `code/duffle/mips.h`. -- `jump_rel`: unconditional jump alias from `code/duffle/mips.h`.
--- @type string
local consuming_encoder_for_markers = (ident == "jump_rel") and "branch_equal" or ident local consuming_encoder_for_markers = (ident == "jump_rel") and "branch_equal" or ident
if ident ~= "atom_label" and ident ~= "atom_offset" then if ident ~= "atom_label" and ident ~= "atom_offset" then
emit_embedded_markers(tok, tok_line, consuming_encoder_for_markers) emit_embedded_markers(tok, tok_line, consuming_encoder_for_markers)
@@ -628,6 +817,7 @@ local function _project_emission_inner(root_body_entry, ctx_table)
-- MipsCode formals (nop_slot1, …): the ident is a sub_map key. -- MipsCode formals (nop_slot1, …): the ident is a sub_map key.
-- Re-process the replacement token so load_word(...) becomes a real encoder. -- Re-process the replacement token so load_word(...) becomes a real encoder.
if sub_map and type(sub_map[ident]) == "string" and sub_map[ident] ~= ident then if sub_map and type(sub_map[ident]) == "string" and sub_map[ident] ~= ident then
--- @type string
local repl = M.trim(sub_map[ident]) local repl = M.trim(sub_map[ident])
if repl ~= "" then if repl ~= "" then
process_token({ tok = repl, rel = bt.rel }) process_token({ tok = repl, rel = bt.rel })
@@ -635,15 +825,20 @@ local function _project_emission_inner(root_body_entry, ctx_table)
end end
end end
if ident:sub(1, 4) == "mac_" then if ident:sub(1, 4) == "mac_" then
--- @type string
local bare = ident:sub(5) local bare = ident:sub(5)
--- @type ComponentBodyEntry|nil
local comp = ctx_table.component_index[bare] local comp = ctx_table.component_index[bare]
if comp then if comp then
--- @type string
local invocation_root_call_text = walk_root_call_text or tok local invocation_root_call_text = walk_root_call_text or tok
if ctx_table.visiting[bare] then if ctx_table.visiting[bare] then
-- Cycle: still allocate inv_id, emit zero-width begin/end, record the cycle error; do NOT recurse. -- Cycle: still allocate inv_id, emit zero-width begin/end, record the cycle error; do NOT recurse.
--- @type InvocationRecord
local inv = emit_invoke_begin(comp.kind or "comp_bare", bare, tok, invocation_root_call_text, def_source, tok_line) local inv = emit_invoke_begin(comp.kind or "comp_bare", bare, tok, invocation_root_call_text, def_source, tok_line)
inv.parent_id = walk_parent_inv_id inv.parent_id = walk_parent_inv_id
inv.call_text = tok inv.call_text = tok
--- @type EmitError
local err = { local err = {
kind = "cycle", kind = "cycle",
msg = string.format("project_emission: component cycle detected: %q", bare), msg = string.format("project_emission: component cycle detected: %q", bare),
@@ -657,6 +852,7 @@ local function _project_emission_inner(root_body_entry, ctx_table)
end end
-- First visit: descend + count + count_mismatch-check below. -- First visit: descend + count + count_mismatch-check below.
ctx_table.visiting[bare] = true ctx_table.visiting[bare] = true
--- @type InvocationRecord
local inv = emit_invoke_begin(comp.kind or "comp_bare", bare, tok, invocation_root_call_text, def_source, tok_line) local inv = emit_invoke_begin(comp.kind or "comp_bare", bare, tok, invocation_root_call_text, def_source, tok_line)
inv.parent_id = walk_parent_inv_id inv.parent_id = walk_parent_inv_id
inv.call_text = tok inv.call_text = tok
@@ -665,11 +861,14 @@ local function _project_emission_inner(root_body_entry, ctx_table)
-- Propagate trackers into the recursive walk: -- Propagate trackers into the recursive walk:
-- immediate_call_text = this call's tok (the IMMEDIATE outer call for words emitted in this body) -- immediate_call_text = this call's tok (the IMMEDIATE outer call for words emitted in this body)
-- root_call_text = the OUTERMOST call (immutable across the recursion) -- root_call_text = the OUTERMOST call (immutable across the recursion)
--- @type string[]|nil
local formal_names = ctx_table.component_index[bare] local formal_names = ctx_table.component_index[bare]
and ctx_table.component_index[bare].arg_names and ctx_table.component_index[bare].arg_names
--- @type table<string, string>|nil
local child_map = nil local child_map = nil
if formal_names then if formal_names then
child_map = {} child_map = {}
--- @type integer, string
for i, fname in ipairs(formal_names) do for i, fname in ipairs(formal_names) do
child_map[fname] = apply_sub(sub_map, args[i]) child_map[fname] = apply_sub(sub_map, args[i])
end end
@@ -688,8 +887,11 @@ local function _project_emission_inner(root_body_entry, ctx_table)
ctx_table.visiting[bare] = nil ctx_table.visiting[bare] = nil
emit_invoke_end(inv) emit_invoke_end(inv)
-- Count `word` items inside [start_word, end_word]. -- Count `word` items inside [start_word, end_word].
--- @type integer
local wc_inside = 0 local wc_inside = 0
--- @type integer
for i = inv.start_word, inv.end_word do for i = inv.start_word, inv.end_word do
--- @type EmissionItem|nil
local it = items[i] local it = items[i]
if it and it.kind == "word" then if it and it.kind == "word" then
wc_inside = wc_inside + 1 wc_inside = wc_inside + 1
@@ -698,8 +900,10 @@ local function _project_emission_inner(root_body_entry, ctx_table)
inv.word_count = wc_inside inv.word_count = wc_inside
-- count_mismatch is a construction error: word_counts["mac_X"] is the declared count populated by the components pass; -- count_mismatch is a construction error: word_counts["mac_X"] is the declared count populated by the components pass;
-- We compare against the measured word count. -- We compare against the measured word count.
--- @type integer|nil
local declared = ctx_table.word_counts["mac_" .. bare] local declared = ctx_table.word_counts["mac_" .. bare]
if declared and wc_inside ~= declared then if declared and wc_inside ~= declared then
--- @type EmitError
local err = { local err = {
kind = "count_mismatch", kind = "count_mismatch",
msg = string.format("project_emission: mac_%s declared=%d measured=%d", bare, declared, wc_inside), msg = string.format("project_emission: mac_%s declared=%d measured=%d", bare, declared, wc_inside),
@@ -715,13 +919,17 @@ local function _project_emission_inner(root_body_entry, ctx_table)
end end
-- Direct encoder, or mac_X-without-component: resolve count + emit n words. -- Direct encoder, or mac_X-without-component: resolve count + emit n words.
-- Resolve_count may emit a warning if the count is unresolved. -- Resolve_count may emit a warning if the count is unresolved.
--- @type integer
local n = resolve_count(ident, tok_line) local n = resolve_count(ident, tok_line)
--- @type string
local out_ident = (ident == "nop2") and "nop" or ident local out_ident = (ident == "nop2") and "nop" or ident
--- @type integer
for _ = 1, n do for _ = 1, n do
emit_word(out_ident, args, tok_line, tok, def_source, def_line, walk_immediate_call_text, walk_root_call_text, sub_map) emit_word(out_ident, args, tok_line, tok, def_source, def_line, walk_immediate_call_text, walk_root_call_text, sub_map)
end end
end end
--- @type integer, BodyToken
for _, bt in ipairs(tokens) do for _, bt in ipairs(tokens) do
process_token(bt) process_token(bt)
end end
@@ -780,11 +988,12 @@ end
--- for the open invocation stack at that word. --- for the open invocation stack at that word.
--- ---
--- @param body_text string -- the raw atom body string --- @param body_text string -- the raw atom body string
--- @param component_index table -- bare-name → component record (corpus.component_body_index) --- @param component_index table<string, ComponentBodyEntry>
--- @param word_counts table -- macro name → emitted word count --- @param word_counts WordCounts
--- @param components table -- bare-name → component definition (corpus.components); REQUIRED — consumed at the invocation-construction site to stamp --- @param components table<string, ComponentDef>
--- `invocation.debug_skip`. A missing or non-table `components` raises a fail-loud error rather than silently falling back. --- `invocation.debug_skip`. A missing or non-table `components` raises a fail-loud error rather than silently falling back.
--- @return EmissionProjection --- @return EmissionProjection
--- @param reg_use_ctx RegUseCtx|nil
function M.project_emission(body_text, component_index, word_counts, components, reg_use_ctx) function M.project_emission(body_text, component_index, word_counts, components, reg_use_ctx)
-- The recursive walk delegates to `_project_emission_inner` so component bodies (which arrive as -- The recursive walk delegates to `_project_emission_inner` so component bodies (which arrive as
-- `{body_tokens, body_off, line_of, source, declaration}` records from `corpus.component_body_index`) -- `{body_tokens, body_off, line_of, source, declaration}` records from `corpus.component_body_index`)
@@ -816,6 +1025,7 @@ function M.project_emission(body_text, component_index, word_counts, components,
} }
end end
--- @type BodyToken[]
local tokens = M.tokenize_body(body_text) local tokens = M.tokenize_body(body_text)
return _project_emission_inner({ return _project_emission_inner({
body_tokens = tokens, body_tokens = tokens,
@@ -849,10 +1059,17 @@ end
-- (FI_, atom_dbg_skip, comments) until it finds an ident followed by "(". -- (FI_, atom_dbg_skip, comments) until it finds an ident followed by "(".
-- That ident is the function name; the parens contents are the args. -- That ident is the function name; the parens contents are the args.
------------------------------------------------------------------------------- -------------------------------------------------------------------------------
--- @param source string
--- @param before_pos integer
--- @param slice_mips_code_len integer
--- @return string|nil, string|nil
function M.find_function_decl_for(source, before_pos, slice_mips_code_len) function M.find_function_decl_for(source, before_pos, slice_mips_code_len)
--- @type integer
local search_pos = 1 local search_pos = 1
--- @type integer|nil
local last_match = nil local last_match = nil
while true do while true do
--- @type integer|nil
local found = source:find("Slice_MipsCode", search_pos, true) local found = source:find("Slice_MipsCode", search_pos, true)
if not found or found >= before_pos then break end if not found or found >= before_pos then break end
last_match = found last_match = found
@@ -860,10 +1077,12 @@ function M.find_function_decl_for(source, before_pos, slice_mips_code_len)
end end
if not last_match then return nil, nil end if not last_match then return nil, nil end
--- @type integer
local pos = last_match + slice_mips_code_len local pos = last_match + slice_mips_code_len
while pos < before_pos do while pos < before_pos do
-- skip whitespace -- skip whitespace
while pos <= #source do while pos <= #source do
--- @type string
local c = source:sub(pos, pos) local c = source:sub(pos, pos)
if c == " " or c == "\t" or c == "\n" or c == "\r" then if c == " " or c == "\t" or c == "\n" or c == "\r" then
pos = pos + 1 pos = pos + 1
@@ -880,17 +1099,21 @@ function M.find_function_decl_for(source, before_pos, slice_mips_code_len)
end end
-- skip block comments -- skip block comments
if source:sub(pos, pos + 1) == "/*" then if source:sub(pos, pos + 1) == "/*" then
--- @type integer|nil
local close = source:find("*/", pos + 2, true) local close = source:find("*/", pos + 2, true)
if not close then break end if not close then break end
pos = close + 2 pos = close + 2
goto continue goto continue
end end
-- try to read an ident -- try to read an ident
--- @type string|nil, integer
local ident, ident_end = M.read_ident(source, pos) local ident, ident_end = M.read_ident(source, pos)
if not ident then break end if not ident then break end
-- check if the next non-ws char after ident is "(" -- check if the next non-ws char after ident is "("
--- @type integer
local next_pos = M.skip_ws_and_cmt(source, ident_end) local next_pos = M.skip_ws_and_cmt(source, ident_end)
if source:sub(next_pos, next_pos) == "(" then if source:sub(next_pos, next_pos) == "(" then
--- @type string|nil
local inner = M.read_parens(source, next_pos) local inner = M.read_parens(source, next_pos)
if inner then if inner then
return ident, inner return ident, inner
@@ -917,11 +1140,18 @@ end
-- The walk finds the LAST "MipsAtom*" before before_pos, then skips whitespace + qualifiers (internal, I_, FI_, comments) -- The walk finds the LAST "MipsAtom*" before before_pos, then skips whitespace + qualifiers (internal, I_, FI_, comments)
-- until it finds an ident followed by "(". -- until it finds an ident followed by "(".
------------------------------------------------------------------------------- -------------------------------------------------------------------------------
--- @param source string
--- @param before_pos integer
--- @param mips_atom_ptr_len integer
--- @return string|nil, string|nil, string|nil, integer|nil
function M.find_atom_proc_decl_for(source, before_pos, mips_atom_ptr_len) function M.find_atom_proc_decl_for(source, before_pos, mips_atom_ptr_len)
--- @type integer
local search_pos = 1 local search_pos = 1
--- @type integer|nil
local last_match = nil local last_match = nil
while true do while true do
-- plain=true: "*" is literal, no escaping needed -- plain=true: "*" is literal, no escaping needed
--- @type integer|nil
local found = source:find("MipsAtom*", search_pos, true) local found = source:find("MipsAtom*", search_pos, true)
if not found or found >= before_pos then break end if not found or found >= before_pos then break end
last_match = found last_match = found
@@ -929,10 +1159,12 @@ function M.find_atom_proc_decl_for(source, before_pos, mips_atom_ptr_len)
end end
if not last_match then return nil, nil end if not last_match then return nil, nil end
--- @type integer
local pos = last_match + mips_atom_ptr_len local pos = last_match + mips_atom_ptr_len
while pos < before_pos do while pos < before_pos do
-- skip whitespace -- skip whitespace
while pos <= #source do while pos <= #source do
--- @type string
local c = source:sub(pos, pos) local c = source:sub(pos, pos)
if c == " " or c == "\t" or c == "\n" or c == "\r" then if c == " " or c == "\t" or c == "\n" or c == "\r" then
pos = pos + 1 pos = pos + 1
@@ -949,17 +1181,21 @@ function M.find_atom_proc_decl_for(source, before_pos, mips_atom_ptr_len)
end end
-- skip block comments -- skip block comments
if source:sub(pos, pos + 1) == "/*" then if source:sub(pos, pos + 1) == "/*" then
--- @type integer|nil
local close = source:find("*/", pos + 2, true) local close = source:find("*/", pos + 2, true)
if not close then break end if not close then break end
pos = close + 2 pos = close + 2
goto continue goto continue
end end
-- try to read an ident -- try to read an ident
--- @type string|nil, integer
local ident, ident_end = M.read_ident(source, pos) local ident, ident_end = M.read_ident(source, pos)
if not ident then break end if not ident then break end
-- check if the next non-ws char after ident is "(" -- check if the next non-ws char after ident is "("
--- @type integer
local next_pos = M.skip_ws_and_cmt(source, ident_end) local next_pos = M.skip_ws_and_cmt(source, ident_end)
if source:sub(next_pos, next_pos) == "(" then if source:sub(next_pos, next_pos) == "(" then
--- @type string|nil, integer
local inner, after_paren = M.read_parens(source, next_pos) local inner, after_paren = M.read_parens(source, next_pos)
if inner then if inner then
return ident, inner, ident, after_paren return ident, inner, ident, after_paren
+130
View File
@@ -1,16 +1,126 @@
--- duffle_isa.lua — encoder / GTE / hardware tables. --- duffle_isa.lua — encoder / GTE / hardware tables.
--- @class InstructionImm
--- @field arg integer
--- @field signed boolean|nil
--- @field width integer
--- @class InstructionValue
--- @field dest integer
--- @field op string
--- @field sources integer[]|nil
--- @field immediate integer|nil
--- @field source integer|nil
--- @class InstructionRow
--- @field cycles integer
--- @field kind string
--- @field reads integer[]|nil
--- @field writes integer[]|nil
--- @field imm InstructionImm[]|nil
--- @field value InstructionValue|nil
--- @field delay_slot boolean|nil
--- @field suppress_arg1 table<string, string>|nil -- bag: GPR ident -> reason
--- @class TapeAtomMacroRow
--- @field kind string
--- @field binds boolean
--- @class GteCommandPort
--- @field register string
--- @field role string
--- @class GteCommandLatch
--- @field register string
--- @field required integer
--- @class GteCommandRow
--- @field aliases string[]
--- @field cycles integer
--- @field inputs string[]
--- @field outputs GteCommandPort[]
--- @field latch GteCommandLatch[]
--- @class GteCrAliasGroup
--- @field [1] integer -- C2 control-register slot
--- @field [2] string[] -- aliases that share that slot
--- @class GtePackedSlotRelation
--- @field slot integer
--- @field first string
--- @field second string
--- @class HardwareRelationPort
--- @field domain string
--- @field arg integer
--- @class HardwareRelationVisibility
--- @field kind string
--- @field required integer
--- @class HardwareRelationEvidence
--- @field confidence string
--- @field source string
--- @class HardwareRelationRow
--- @field id string
--- @field semantic string
--- @field consumer string
--- @field token string
--- @field direction string
--- @field reads HardwareRelationPort
--- @field writes HardwareRelationPort
--- @field visibility HardwareRelationVisibility|nil
--- @field evidence HardwareRelationEvidence
--- @field violation_kind string
--- @field destination_match string|nil
--- @field fanout_to string[]|nil
--- @field required integer|nil
--- @field clear_on_consumer boolean|nil
--- @field stage boolean|nil
--- @field cu2_transition boolean|nil
--- @field status_register integer|nil
--- @class Cu2TransitionPolicy
--- @field status_register integer
--- @field enable_bit integer
--- @field required integer
--- @field visibility_kind string
--- @field evidence HardwareRelationEvidence
--- @class DuffleIsa
--- @field TAPE_ATOM_MACROS table<string, TapeAtomMacroRow>
--- @field DELAY_MARKERS table<string, boolean>
--- @field INSTRUCTION table<string, InstructionRow>
--- @field GTE_COMMAND table<string, GteCommandRow>
--- @field ALIAS_TO_CANONICAL table<string, string>
--- @field instr fun(ident: string): InstructionRow|nil
--- @field gte_canon fun(ident: string): string
--- @field gte fun(ident: string): GteCommandRow|nil
--- @field GTE_CR_ALIAS_GROUPS GteCrAliasGroup[]
--- @field GTE_PACKED_SLOT_RELATIONS GtePackedSlotRelation[]
--- @field OPERAND_READ_POSITIONS table<string, integer[]>
--- @field GP0_CMD_SIZE table<integer, integer>
--- @field GP0_CMD_BY_SHAPE table<string, integer>
--- @field UNKNOWN_INSTRUCTION_CYCLES integer
--- @field HARDWARE_RELATIONS HardwareRelationRow[]
--- @field CU2_TRANSITION_POLICY Cu2TransitionPolicy
--- @type DuffleIsa
local M = {} local M = {}
-- Section 7: domain tables -- Section 7: domain tables
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- atom_info sub-calls: atom_bind, atom_reads, atom_writes, atom_view, atom_reg_types, atom_ctx, atom_phase. -- atom_info sub-calls: atom_bind, atom_reads, atom_writes, atom_view, atom_reg_types, atom_ctx, atom_phase.
--- @type table<string, TapeAtomMacroRow>
M.TAPE_ATOM_MACROS = { M.TAPE_ATOM_MACROS = {
["atom_info"] = { kind = "info", binds = false }, ["atom_info"] = { kind = "info", binds = false },
} }
-- Empty C macros that prefix the next encoder. Zero words. -- Empty C macros that prefix the next encoder. Zero words.
-- BdSlot_ nop is one nop word. The marker is not the BD instruction. -- BdSlot_ nop is one nop word. The marker is not the BD instruction.
--- @type table<string, boolean> -- bag: marker prefix -> true
M.DELAY_MARKERS = { M.DELAY_MARKERS = {
["GteDelay_"] = true, ["GteDelay_"] = true,
["LdSlot_"] = true, ["LdSlot_"] = true,
@@ -19,6 +129,7 @@ M.DELAY_MARKERS = {
} }
-- One row per encoder. Read through duffle.instr. -- One row per encoder. Read through duffle.instr.
--- @type table<string, InstructionRow>
M.INSTRUCTION = { M.INSTRUCTION = {
["BdSlot_"] = { cycles = 0, kind = "marker", }, ["BdSlot_"] = { cycles = 0, kind = "marker", },
["LdSlot_"] = { cycles = 0, kind = "marker", }, ["LdSlot_"] = { cycles = 0, kind = "marker", },
@@ -115,6 +226,7 @@ M.INSTRUCTION = {
} }
-- One row per GTE command. Alias cycle numbers live here, not on INSTRUCTION. -- One row per GTE command. Alias cycle numbers live here, not on INSTRUCTION.
--- @type table<string, GteCommandRow>
M.GTE_COMMAND = { M.GTE_COMMAND = {
["gte_cmdw_avsz3"] = { ["gte_cmdw_avsz3"] = {
aliases = { "gte_avg_sort_z3", "gte_avsz3", "gte_cmdw_avg_sort_z3" }, aliases = { "gte_avg_sort_z3", "gte_avsz3", "gte_cmdw_avg_sort_z3" },
@@ -296,14 +408,24 @@ M.GTE_COMMAND = {
}, },
} }
--- @param ident string
--- @return InstructionRow|nil
function M.instr (ident) return M.INSTRUCTION [ident] end function M.instr (ident) return M.INSTRUCTION [ident] end
--- @param ident string
--- @return string
function M.gte_canon(ident) return M.ALIAS_TO_CANONICAL [ident] or ident end function M.gte_canon(ident) return M.ALIAS_TO_CANONICAL [ident] or ident end
--- @param ident string
--- @return GteCommandRow|nil
function M.gte (ident) return M.GTE_COMMAND[M.gte_canon(ident)] end function M.gte (ident) return M.GTE_COMMAND[M.gte_canon(ident)] end
--- @return nil
local function build_alias_map() local function build_alias_map()
--- @type table<string, string> -- bag: alias or canon -> canon
M.ALIAS_TO_CANONICAL = {} M.ALIAS_TO_CANONICAL = {}
--- @type string, GteCommandRow
for canon, row in pairs(M.GTE_COMMAND) do for canon, row in pairs(M.GTE_COMMAND) do
M.ALIAS_TO_CANONICAL[canon] = canon M.ALIAS_TO_CANONICAL[canon] = canon
--- @type integer, string
for _, alias in ipairs(row.aliases or {}) do for _, alias in ipairs(row.aliases or {}) do
M.ALIAS_TO_CANONICAL[alias] = canon M.ALIAS_TO_CANONICAL[alias] = canon
end end
@@ -319,6 +441,7 @@ build_alias_map()
--- Cross-alias writes inside one atom body, or across the wave-context boundary, silently clobber each other. --- Cross-alias writes inside one atom body, or across the wave-context boundary, silently clobber each other.
--- The `check_gte_cr_alias_writes` check warns about each pair per source. See `docs/gte_reference.md` §"Control-register alias table" --- The `check_gte_cr_alias_writes` check warns about each pair per source. See `docs/gte_reference.md` §"Control-register alias table"
--- for the HW rationale and the libgte outer-product convention. --- for the HW rationale and the libgte outer-product convention.
--- @type GteCrAliasGroup[]
M.GTE_CR_ALIAS_GROUPS = { M.GTE_CR_ALIAS_GROUPS = {
{ 24, { "gte_cr_RBK", "gte_cr_OFX" } }, -- background R vs screen offset X { 24, { "gte_cr_RBK", "gte_cr_OFX" } }, -- background R vs screen offset X
{ 25, { "gte_cr_GBK", "gte_cr_OFY" } }, -- background G vs screen offset Y { 25, { "gte_cr_GBK", "gte_cr_OFY" } }, -- background G vs screen offset Y
@@ -326,6 +449,7 @@ M.GTE_CR_ALIAS_GROUPS = {
} }
-- Packed RT slots named by the gte.h packed-slot comment. First must be written before second. -- Packed RT slots named by the gte.h packed-slot comment. First must be written before second.
--- @type GtePackedSlotRelation[]
M.GTE_PACKED_SLOT_RELATIONS = { M.GTE_PACKED_SLOT_RELATIONS = {
{ slot = 2, first = "gte_cr_RT13", second = "gte_cr_RT22" }, { slot = 2, first = "gte_cr_RT13", second = "gte_cr_RT22" },
} }
@@ -340,6 +464,7 @@ M.GTE_PACKED_SLOT_RELATIONS = {
-- * The check tracks one entry per destination GPR per MFC2 / CFC2 event. -- * The check tracks one entry per destination GPR per MFC2 / CFC2 event.
-- A subsequent event counts as a "use" iff any of its read operand positions reference that destination GPR's ident (e.g. `R_T0`). -- A subsequent event counts as a "use" iff any of its read operand positions reference that destination GPR's ident (e.g. `R_T0`).
-- * Branch delay slots are out of scope (MIPS control-flow; tracked separately). -- * Branch delay slots are out of scope (MIPS control-flow; tracked separately).
--- @type table<string, integer[]> -- bag: encoder ident -> GPR operand positions
M.OPERAND_READ_POSITIONS = { M.OPERAND_READ_POSITIONS = {
-- CPU ALU with one or two GPR operands. Reads every GPR operand. -- CPU ALU with one or two GPR operands. Reads every GPR operand.
["add_ui"] = {1, 2}, ["add_ui"] = {1, 2},
@@ -439,6 +564,7 @@ M.OPERAND_READ_POSITIONS = {
-- set_poly_gt3(p) -> set_len(p, 9) -> 10 total GP0 0x34 -- set_poly_gt3(p) -> set_len(p, 9) -> 10 total GP0 0x34
-- set_poly_g4(p) -> set_len(p, 8) -> 9 total GP0 0x38 -- set_poly_g4(p) -> set_len(p, 8) -> 9 total GP0 0x38
-- set_poly_gt4(p) -> set_len(p, 12) -> 13 total GP0 0x3C -- set_poly_gt4(p) -> set_len(p, 12) -> 13 total GP0 0x3C
--- @type table<integer, integer> -- bag: GP0 cmd byte -> word count
M.GP0_CMD_SIZE = { M.GP0_CMD_SIZE = {
[0x20] = 5, -- Poly_F3 [0x20] = 5, -- Poly_F3
[0x24] = 8, -- Poly_FT3 [0x24] = 8, -- Poly_FT3
@@ -452,6 +578,7 @@ M.GP0_CMD_SIZE = {
-- Shape suffix (after `ac_format_` / `mac_format_` prefix) -> GP0 cmd byte. -- Shape suffix (after `ac_format_` / `mac_format_` prefix) -> GP0 cmd byte.
-- Lets the static-analysis check derive the cmd byte from a macro name like `mac_format_g4_color` -> `g4` -> 0x38 -> 9 expected words. -- Lets the static-analysis check derive the cmd byte from a macro name like `mac_format_g4_color` -> `g4` -> 0x38 -> 9 expected words.
--- @type table<string, integer> -- bag: shape suffix -> GP0 cmd byte
M.GP0_CMD_BY_SHAPE = { M.GP0_CMD_BY_SHAPE = {
["f3"] = 0x20, ["ft3"] = 0x24, ["f3"] = 0x20, ["ft3"] = 0x24,
["f4"] = 0x28, ["ft4"] = 0x2C, ["f4"] = 0x28, ["ft4"] = 0x2C,
@@ -459,6 +586,7 @@ M.GP0_CMD_BY_SHAPE = {
["g4"] = 0x38, ["gt4"] = 0x3C, ["g4"] = 0x38, ["gt4"] = 0x3C,
} }
--- @type integer
M.UNKNOWN_INSTRUCTION_CYCLES = 1 M.UNKNOWN_INSTRUCTION_CYCLES = 1
-- Hardware-relation policy table. -- Hardware-relation policy table.
@@ -491,6 +619,7 @@ M.UNKNOWN_INSTRUCTION_CYCLES = 1
-- * passes/static_analysis.lua::analyze_hardware_relations (forward walker). -- * passes/static_analysis.lua::analyze_hardware_relations (forward walker).
-- * passes/static_analysis.lua::transfer_hazards CHECK_RULES reader (renders hazards onto `findings`). -- * passes/static_analysis.lua::transfer_hazards CHECK_RULES reader (renders hazards onto `findings`).
-- This table is consumed by the hardware-relation analyzer and hazard renderer. -- This table is consumed by the hardware-relation analyzer and hazard renderer.
--- @type HardwareRelationRow[]
M.HARDWARE_RELATIONS = { M.HARDWARE_RELATIONS = {
-- CPU → COP2 data register (MTC2). The ordinary default is 2 cached words between producer and consumer (cpuspecifications.md:407-419). -- CPU → COP2 data register (MTC2). The ordinary default is 2 cached words between producer and consumer (cpuspecifications.md:407-419).
{ {
@@ -681,6 +810,7 @@ M.HARDWARE_RELATIONS = {
-- Bounded Status/SR.CU2 transition policy. -- Bounded Status/SR.CU2 transition policy.
-- The value lattice and the transition consumer both read this immutable row; no second value pass is permitted. -- The value lattice and the transition consumer both read this immutable row; no second value pass is permitted.
-- The source says the enable/disable transition takes "2 clock cycles or so", so the boundary is conservative rather than exact. -- The source says the enable/disable transition takes "2 clock cycles or so", so the boundary is conservative rather than exact.
--- @type Cu2TransitionPolicy
M.CU2_TRANSITION_POLICY = { M.CU2_TRANSITION_POLICY = {
status_register = 12, status_register = 12,
enable_bit = 0x40000000, enable_bit = 0x40000000,
+14
View File
@@ -16,9 +16,14 @@
--- Net effect: the caller gets the duffle module in one statement; no separate `dofile(...)` + `require("duffle")` dance. --- Net effect: the caller gets the duffle module in one statement; no separate `dofile(...)` + `require("duffle")` dance.
--- ---
--- @class DufflePaths
--- @field setup fun(): nil
--- @type DufflePaths
local M = {} local M = {}
-- Cache key for the repo root. Stored in `package.loaded` (process-global) so all 8 entry scripts + passes scripts share one resolution. -- Cache key for the repo root. Stored in `package.loaded` (process-global) so all 8 entry scripts + passes scripts share one resolution.
--- @type string
local CACHE_KEY = "__duffle_repo_root__" local CACHE_KEY = "__duffle_repo_root__"
--- Resolve the repo root from this script's own path. Zero shell spawn. --- Resolve the repo root from this script's own path. Zero shell spawn.
@@ -30,13 +35,16 @@ local CACHE_KEY = "__duffle_repo_root__"
local function find_repo_root() local function find_repo_root()
if package.loaded[CACHE_KEY] then return package.loaded[CACHE_KEY] end if package.loaded[CACHE_KEY] then return package.loaded[CACHE_KEY] end
--- @type string
local source = debug.getinfo(1, "S").source local source = debug.getinfo(1, "S").source
-- Strip the leading `@` (Lua's dofile marker) and the trailing `/duffle_paths.lua` filename. -- 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/`. -- What remains is the directory containing this script, i.e. `<repo>/scripts/`.
--- @type string|nil
local scripts_dir = source and source:match("^@?(.*)[/\\]duffle_paths%.lua$") local scripts_dir = source and source:match("^@?(.*)[/\\]duffle_paths%.lua$")
if not scripts_dir then return nil end if not scripts_dir then return nil end
-- The repo root is the parent of `scripts/`. Strip the trailing `scripts/` (with or without trailing slash). -- The repo root is the parent of `scripts/`. Strip the trailing `scripts/` (with or without trailing slash).
--- @type string
local root = scripts_dir:gsub("scripts[\\/]?$", "") local root = scripts_dir:gsub("scripts[\\/]?$", "")
root = root:gsub("\\", "/") root = root:gsub("\\", "/")
if root == "" then root = "./" end if root == "" then root = "./" end
@@ -50,7 +58,9 @@ end
--- This script does NOT touch the OS environment: no `os.setenv`, no `os.putenv`, no `$PATH` mods. --- This script does NOT touch the OS environment: no `os.setenv`, no `os.putenv`, no `$PATH` mods.
--- It just sets `package.path` and `package.cpath` (the standard Lua way to register module search dirs). --- It just sets `package.path` and `package.cpath` (the standard Lua way to register module search dirs).
--- lpeg is built by `update_deps.ps1` to `toolchain/lpeg/`, which we wire into `package.cpath` here (so `require("lpeg")` from `duffle.lua` resolves without any global state). --- lpeg is built by `update_deps.ps1` to `toolchain/lpeg/`, which we wire into `package.cpath` here (so `require("lpeg")` from `duffle.lua` resolves without any global state).
--- @return nil
function M.setup() function M.setup()
--- @type string|nil
local repo_root = find_repo_root() local repo_root = find_repo_root()
if not repo_root then if not repo_root then
-- Unreachable in practice: find_repo_root() derives the repo root from this script's own source path via debug.getinfo(1, "S").source (no subprocess, no git CLI, <1ms). -- Unreachable in practice: find_repo_root() derives the repo root from this script's own source path via debug.getinfo(1, "S").source (no subprocess, no git CLI, <1ms).
@@ -59,7 +69,9 @@ function M.setup()
os.exit(2) os.exit(2)
end end
--- @type string
local scripts_dir = repo_root .. "scripts/" local scripts_dir = repo_root .. "scripts/"
--- @type string
local passes_dir = repo_root .. "scripts/passes/" local passes_dir = repo_root .. "scripts/passes/"
package.path = scripts_dir .. "?.lua;" package.path = scripts_dir .. "?.lua;"
.. scripts_dir .. "?/init.lua;" .. scripts_dir .. "?/init.lua;"
@@ -70,7 +82,9 @@ function M.setup()
-- lpeg: built by `update_deps.ps1` to `toolchain/lpeg/lpeg.dll`. -- lpeg: built by `update_deps.ps1` to `toolchain/lpeg/lpeg.dll`.
-- lfs: compiled from pcsx-redux's vendored luafilesystem source to `toolchain/lfs/lfs.dll`. -- lfs: compiled from pcsx-redux's vendored luafilesystem source to `toolchain/lfs/lfs.dll`.
-- Wire both directories into cpath so `require("lpeg")` and `require("lfs")` resolve. -- Wire both directories into cpath so `require("lpeg")` and `require("lfs")` resolve.
--- @type string
local lpeg_dir = repo_root .. "toolchain/lpeg/" local lpeg_dir = repo_root .. "toolchain/lpeg/"
--- @type string
local lfs_dir = repo_root .. "toolchain/lfs/" local lfs_dir = repo_root .. "toolchain/lfs/"
package.cpath = lpeg_dir .. "?.dll;" package.cpath = lpeg_dir .. "?.dll;"
.. lfs_dir .. "?.dll;" .. lfs_dir .. "?.dll;"
+349 -25
View File
File diff suppressed because it is too large Load Diff
+159 -18
View File
@@ -22,6 +22,94 @@
-- spec: System V ABI gABI v1.2 §"ELF Header" (Table 1) + §"Section Header Table" -- spec: System V ABI gABI v1.2 §"ELF Header" (Table 1) + §"Section Header Table"
-- spec: System V ABI gABI v1.2 §"Symbol Table" (Elf32_Sym layout) -- spec: System V ABI gABI v1.2 §"Symbol Table" (Elf32_Sym layout)
--- @class Elf32Adapter
--- @field read_u8_at fun(off: integer): integer|nil
--- @field read_u16_at fun(off: integer): integer|nil
--- @field read_u32_at fun(off: integer): integer|nil
--- @field read_size fun(): integer
--- @class Elf32Header
--- @field e_entry integer
--- @field e_shoff integer
--- @field e_shentsize integer
--- @field e_shnum integer
--- @field e_shstrndx integer
--- @field error string|nil
--- @class Elf32Section
--- @field sh_name integer
--- @field sh_type integer
--- @field sh_flags integer
--- @field sh_addr integer
--- @field sh_offset integer
--- @field sh_size integer
--- @field sh_link integer
--- @field name string
--- @class Elf32Sym
--- @field value integer
--- @field size integer
--- @field info integer
--- @field shndx integer
--- @class Elf32HeaderLayout
--- @field magic_offset integer
--- @field magic string
--- @field class_offset integer
--- @field endian_offset integer
--- @field header_bytes integer
--- @field e_entry_offset integer
--- @field e_shoff_offset integer
--- @field e_shentsize_offset integer
--- @field e_shnum_offset integer
--- @field e_shstrndx_offset integer
--- @class Elf32SectionLayout
--- @field sh_name_offset integer
--- @field sh_type_offset integer
--- @field sh_flags_offset integer
--- @field sh_addr_offset integer
--- @field sh_offset_offset integer
--- @field sh_size_offset integer
--- @field sh_link_offset integer
--- @field sh_entsize_bytes integer
--- @class Elf32SymLayout
--- @field st_name integer
--- @field st_value integer
--- @field st_size integer
--- @field st_info integer
--- @field sym_entry_bytes integer
--- @class Elf32Mod
--- @field ELFCLASS32 integer
--- @field ELFDATA2LSB integer
--- @field EM_MIPS integer
--- @field SHT_SYMTAB integer
--- @field SHT_STRTAB integer
--- @field SHT_NOBITS integer
--- @field SHF_WRITE integer
--- @field SHF_ALLOC integer
--- @field SHF_EXECINSTR integer
--- @field ELF32_HEADER Elf32HeaderLayout
--- @field ELF32_SECTION Elf32SectionLayout
--- @field ELF32_SYM Elf32SymLayout
--- @field dw_dwarf32_terminator integer
--- @field read_u32 fun(adapter: Elf32Adapter, off: integer): integer|nil
--- @field read_u16 fun(adapter: Elf32Adapter, off: integer): integer|nil
--- @field read_u8 fun(adapter: Elf32Adapter, off: integer): integer|nil
--- @field size fun(adapter: Elf32Adapter): integer
--- @field read_u32_le fun(buf: string, off: integer): integer
--- @field read_u16_le fun(buf: string, off: integer): integer
--- @field validate_adapter fun(adapter: any): boolean, string|nil
--- @field get_str fun(strtab: string, off: integer): string|nil
--- @field parse_elf32_headers fun(adapter: Elf32Adapter): Elf32Header|nil, string|nil
--- @field walk_sections fun(adapter: Elf32Adapter, hdr: Elf32Header): Elf32Section[]|nil, string|nil
--- @field read_section_bytes fun(adapter: Elf32Adapter, section: Elf32Section): string|nil
--- @field read_named_section fun(adapter: Elf32Adapter, sections: Elf32Section[], name: string): string|nil, string|nil
--- @field collect_symbols fun(adapter: Elf32Adapter, sections: Elf32Section[]): table<string, Elf32Sym>|nil, string|nil
--- @type Elf32Mod
local M = {} local M = {}
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -39,7 +127,7 @@ local M = {}
--- **Call form:** explicit-pass. The reader receives `adapter` as the first positional argument and the offset as the second; no `self` is passed. --- **Call form:** explicit-pass. The reader receives `adapter` as the first positional argument and the offset as the second; no `self` is passed.
--- Test fixtures declare `function(offset) ... end` and the parsers call them via dot syntax `adapter.read_u8_at(off)`. --- Test fixtures declare `function(offset) ... end` and the parsers call them via dot syntax `adapter.read_u8_at(off)`.
--- The colon form `adapter:read_u8_at(off)` would prepend the adapter table as `offset` and break the contract. --- The colon form `adapter:read_u8_at(off)` would prepend the adapter table as `offset` and break the contract.
--- @param adapter table --- @param adapter Elf32Adapter
--- @param off integer -- zero-based wire offset --- @param off integer -- zero-based wire offset
--- @return integer|nil --- @return integer|nil
function M.read_u32(adapter, off) function M.read_u32(adapter, off)
@@ -50,7 +138,7 @@ function M.read_u32(adapter, off)
end end
--- Read a 2-byte little-endian unsigned integer from `adapter` at zero-based wire offset `off`. --- Read a 2-byte little-endian unsigned integer from `adapter` at zero-based wire offset `off`.
--- @param adapter table --- @param adapter Elf32Adapter
--- @param off integer -- zero-based wire offset --- @param off integer -- zero-based wire offset
--- @return integer|nil --- @return integer|nil
function M.read_u16(adapter, off) function M.read_u16(adapter, off)
@@ -59,7 +147,7 @@ function M.read_u16(adapter, off)
end end
--- Read a 1-byte unsigned integer from `adapter` at zero-based wire offset `off`. --- Read a 1-byte unsigned integer from `adapter` at zero-based wire offset `off`.
--- @param adapter table --- @param adapter Elf32Adapter
--- @param off integer -- zero-based wire offset --- @param off integer -- zero-based wire offset
--- @return integer|nil --- @return integer|nil
function M.read_u8(adapter, off) function M.read_u8(adapter, off)
@@ -67,7 +155,7 @@ function M.read_u8(adapter, off)
end end
--- Total adapter byte length. --- Total adapter byte length.
--- @param adapter table --- @param adapter Elf32Adapter
--- @return integer --- @return integer
function M.size(adapter) function M.size(adapter)
return adapter.read_size() return adapter.read_size()
@@ -76,7 +164,11 @@ end
--- Forwarders kept for backward compat with scripts/elf_dwarf.lua. --- Forwarders kept for backward compat with scripts/elf_dwarf.lua.
--- The metaprogram side keeps `read_u32_le` / `read_u16_le`; --- The metaprogram side keeps `read_u32_le` / `read_u16_le`;
--- both layers now use the same byte-level helpers under the hood. --- both layers now use the same byte-level helpers under the hood.
--- @param buf string
--- @param off integer
--- @return integer
function M.read_u32_le(buf, off) function M.read_u32_le(buf, off)
--- @type integer
local byte_off = off + 1 local byte_off = off + 1
return buf:byte(byte_off) return buf:byte(byte_off)
+ buf:byte(byte_off + 0x01) * 0x00000100 + buf:byte(byte_off + 0x01) * 0x00000100
@@ -89,6 +181,7 @@ end
--- @param off integer -- zero-based wire offset --- @param off integer -- zero-based wire offset
--- @return integer --- @return integer
function M.read_u16_le(buf, off) function M.read_u16_le(buf, off)
--- @type integer
local byte_off = off + 1 local byte_off = off + 1
return buf:byte(byte_off) + buf:byte(byte_off + 0x01) * 0x00000100 return buf:byte(byte_off) + buf:byte(byte_off + 0x01) * 0x00000100
end end
@@ -116,6 +209,7 @@ M.SHF_EXECINSTR = 0x4 -- spec: gABI v1.2 §"Section Attributes" — executable
-- ELF32 header layout (System V ABI gABI v1.2 §"ELF Header" Table 1) -- ELF32 header layout (System V ABI gABI v1.2 §"ELF Header" Table 1)
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- All offsets are zero-based wire offsets. The header is 52 bytes total (header_bytes = 0x34 = 52). -- All offsets are zero-based wire offsets. The header is 52 bytes total (header_bytes = 0x34 = 52).
--- @type Elf32HeaderLayout
M.ELF32_HEADER = { M.ELF32_HEADER = {
magic_offset = 0x00, -- 4 bytes; expected "\127ELF" magic_offset = 0x00, -- 4 bytes; expected "\127ELF"
magic = "\127ELF", magic = "\127ELF",
@@ -134,6 +228,7 @@ M.ELF32_HEADER = {
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Each entry is 40 bytes (sh_entsize_bytes = 0x28 = 40); -- Each entry is 40 bytes (sh_entsize_bytes = 0x28 = 40);
-- zero-based, field offsets relative to the start of the entry. -- zero-based, field offsets relative to the start of the entry.
--- @type Elf32SectionLayout
M.ELF32_SECTION = { M.ELF32_SECTION = {
sh_name_offset = 0x00, -- 4-byte LE; offset into .shstrtab sh_name_offset = 0x00, -- 4-byte LE; offset into .shstrtab
sh_type_offset = 0x04, -- 4-byte LE; section type (SHT_*) sh_type_offset = 0x04, -- 4-byte LE; section type (SHT_*)
@@ -150,6 +245,7 @@ M.ELF32_SECTION = {
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Each entry is 16 bytes (sym_entry_bytes = 0x10 = 16); -- Each entry is 16 bytes (sym_entry_bytes = 0x10 = 16);
-- zero-based, field offsets relative to the start of the entry. -- zero-based, field offsets relative to the start of the entry.
--- @type Elf32SymLayout
M.ELF32_SYM = { M.ELF32_SYM = {
st_name = 0x00, -- 4-byte LE; offset into the linked string table st_name = 0x00, -- 4-byte LE; offset into the linked string table
st_value = 0x04, -- 4-byte LE; symbol value (address / absolute) st_value = 0x04, -- 4-byte LE; symbol value (address / absolute)
@@ -190,6 +286,7 @@ end
--- @return string|nil --- @return string|nil
function M.get_str(strtab, off) function M.get_str(strtab, off)
if off < 0 or off >= #strtab then return nil end if off < 0 or off >= #strtab then return nil end
--- @type integer|nil
local end_pos = strtab:find("\0", off + 1, true) local end_pos = strtab:find("\0", off + 1, true)
if not end_pos then return nil end if not end_pos then return nil end
return strtab:sub(off + 1, end_pos - 1) return strtab:sub(off + 1, end_pos - 1)
@@ -205,38 +302,50 @@ end
--- On failure returns nil + a stable error code: --- On failure returns nil + a stable error code:
--- bad_magic, unsupported_elf_class, unsupported_elf_data, truncated_header --- bad_magic, unsupported_elf_class, unsupported_elf_data, truncated_header
--- The header's machine field is NOT validated here — callers (e.g. the helper's prime path) decide whether to require EM_MIPS before symbol reads. --- The header's machine field is NOT validated here — callers (e.g. the helper's prime path) decide whether to require EM_MIPS before symbol reads.
--- @param adapter table --- @param adapter Elf32Adapter
--- @return table|nil, string|nil --- @return Elf32Header|nil, string|nil
function M.parse_elf32_headers(adapter) function M.parse_elf32_headers(adapter)
--- @type boolean, string|nil
local ok, err = M.validate_adapter(adapter) local ok, err = M.validate_adapter(adapter)
if not ok then return nil, err end if not ok then return nil, err end
-- 4-byte magic: 0x7F 'E' 'L' 'F'. -- 4-byte magic: 0x7F 'E' 'L' 'F'.
-- The byte readers take the adapter explicitly. -- The byte readers take the adapter explicitly.
-- The production `Support.File` adapter is wrapped by the caller to drop its implicit `self` so the parser shape is flat pass-style. -- The production `Support.File` adapter is wrapped by the caller to drop its implicit `self` so the parser shape is flat pass-style.
--- @type integer|nil
local b1 = M.read_u8(adapter, 0) local b1 = M.read_u8(adapter, 0)
--- @type integer|nil
local b2 = M.read_u8(adapter, 1) local b2 = M.read_u8(adapter, 1)
--- @type integer|nil
local b3 = M.read_u8(adapter, 2) local b3 = M.read_u8(adapter, 2)
--- @type integer|nil
local b4 = M.read_u8(adapter, 3) local b4 = M.read_u8(adapter, 3)
if not (b1 and b2 and b3 and b4) if not (b1 and b2 and b3 and b4)
or not (b1 == 0x7f and b2 == 0x45 and b3 == 0x4c and b4 == 0x46) then or not (b1 == 0x7f and b2 == 0x45 and b3 == 0x4c and b4 == 0x46) then
return nil, "bad_magic" return nil, "bad_magic"
end end
--- @type integer|nil
local class = M.read_u8(adapter, M.ELF32_HEADER.class_offset) local class = M.read_u8(adapter, M.ELF32_HEADER.class_offset)
if class ~= M.ELFCLASS32 then if class ~= M.ELFCLASS32 then
return nil, "unsupported_elf_class" return nil, "unsupported_elf_class"
end end
--- @type integer|nil
local data = M.read_u8(adapter, M.ELF32_HEADER.endian_offset) local data = M.read_u8(adapter, M.ELF32_HEADER.endian_offset)
if data ~= M.ELFDATA2LSB then if data ~= M.ELFDATA2LSB then
return nil, "unsupported_elf_data" return nil, "unsupported_elf_data"
end end
--- @type integer|nil
local e_entry = M.read_u32(adapter, M.ELF32_HEADER.e_entry_offset) local e_entry = M.read_u32(adapter, M.ELF32_HEADER.e_entry_offset)
--- @type integer|nil
local e_shoff = M.read_u32(adapter, M.ELF32_HEADER.e_shoff_offset) local e_shoff = M.read_u32(adapter, M.ELF32_HEADER.e_shoff_offset)
--- @type integer|nil
local e_shentsize = M.read_u16(adapter, M.ELF32_HEADER.e_shentsize_offset) local e_shentsize = M.read_u16(adapter, M.ELF32_HEADER.e_shentsize_offset)
--- @type integer|nil
local e_shnum = M.read_u16(adapter, M.ELF32_HEADER.e_shnum_offset) local e_shnum = M.read_u16(adapter, M.ELF32_HEADER.e_shnum_offset)
--- @type integer|nil
local e_shstrndx = M.read_u16(adapter, M.ELF32_HEADER.e_shstrndx_offset) local e_shstrndx = M.read_u16(adapter, M.ELF32_HEADER.e_shstrndx_offset)
if not (e_entry and e_shoff and e_shentsize and e_shnum and e_shstrndx) then if not (e_entry and e_shoff and e_shentsize and e_shnum and e_shstrndx) then
return nil, "truncated_header" return nil, "truncated_header"
@@ -254,10 +363,11 @@ end
--- Read one section-header entry from `adapter` at `sh_off`. --- Read one section-header entry from `adapter` at `sh_off`.
--- Returns a table with the wire fields plus a (yet-unresolved) `name` field. --- Returns a table with the wire fields plus a (yet-unresolved) `name` field.
--- @param adapter table --- @param adapter Elf32Adapter
--- @param sh_off integer --- @param sh_off integer
--- @return table|nil, string|nil -- entry, error --- @return Elf32Section|nil, string|nil
local function read_section_entry(adapter, sh_off) local function read_section_entry(adapter, sh_off)
--- @type Elf32Section
local entry = { local entry = {
sh_name = M.read_u32(adapter, sh_off + M.ELF32_SECTION.sh_name_offset), sh_name = M.read_u32(adapter, sh_off + M.ELF32_SECTION.sh_name_offset),
sh_type = M.read_u32(adapter, sh_off + M.ELF32_SECTION.sh_type_offset), sh_type = M.read_u32(adapter, sh_off + M.ELF32_SECTION.sh_type_offset),
@@ -279,21 +389,26 @@ end
--- (the section at logical index 0 is at array position 1, etc.). --- (the section at logical index 0 is at array position 1, etc.).
--- Each entry has the wire fields plus a resolved `name` derived from `.shstrtab`. --- Each entry has the wire fields plus a resolved `name` derived from `.shstrtab`.
--- Returns nil + a stable error code on failure: truncated_section_headers, missing_shstrtab, truncated_strtab --- Returns nil + a stable error code on failure: truncated_section_headers, missing_shstrtab, truncated_strtab
--- @param adapter table --- @param adapter Elf32Adapter
--- @param hdr table -- the table returned by parse_elf32_headers --- @param hdr Elf32Header
--- @return table|nil, string|nil --- @return Elf32Section[]|nil, string|nil
function M.walk_sections(adapter, hdr) function M.walk_sections(adapter, hdr)
if not hdr or hdr.error then return nil, hdr and hdr.error or "truncated_section_headers" end if not hdr or hdr.error then return nil, hdr and hdr.error or "truncated_section_headers" end
--- @type integer
local file_size = M.size(adapter) local file_size = M.size(adapter)
if hdr.e_shoff + hdr.e_shnum * hdr.e_shentsize > file_size then if hdr.e_shoff + hdr.e_shnum * hdr.e_shentsize > file_size then
return nil, "truncated_section_headers" return nil, "truncated_section_headers"
end end
-- Read every section header first; we need .shstrtab to resolve names. -- Read every section header first; we need .shstrtab to resolve names.
--- @type Elf32Section[]
local sections = {} local sections = {}
--- @type integer
for i = 0, hdr.e_shnum - 1 do for i = 0, hdr.e_shnum - 1 do
--- @type integer
local sh_off = hdr.e_shoff + i * hdr.e_shentsize local sh_off = hdr.e_shoff + i * hdr.e_shentsize
--- @type Elf32Section|nil, string|nil
local entry, err = read_section_entry(adapter, sh_off) local entry, err = read_section_entry(adapter, sh_off)
if not entry then return nil, err end if not entry then return nil, err end
sections[i + 1] = entry sections[i + 1] = entry
@@ -303,6 +418,7 @@ function M.walk_sections(adapter, hdr)
return nil, "missing_shstrtab" return nil, "missing_shstrtab"
end end
--- @type Elf32Section|nil
local shstrtab = sections[hdr.e_shstrndx + 1] local shstrtab = sections[hdr.e_shstrndx + 1]
if not shstrtab or shstrtab.sh_type ~= M.SHT_STRTAB then if not shstrtab or shstrtab.sh_type ~= M.SHT_STRTAB then
return nil, "missing_shstrtab" return nil, "missing_shstrtab"
@@ -310,9 +426,11 @@ function M.walk_sections(adapter, hdr)
if shstrtab.sh_offset + shstrtab.sh_size > file_size then if shstrtab.sh_offset + shstrtab.sh_size > file_size then
return nil, "truncated_section_headers" return nil, "truncated_section_headers"
end end
--- @type string|nil
local shstrtab_bytes = M.read_section_bytes(adapter, shstrtab) local shstrtab_bytes = M.read_section_bytes(adapter, shstrtab)
if not shstrtab_bytes then return nil, "truncated_section_headers" end if not shstrtab_bytes then return nil, "truncated_section_headers" end
--- @type integer, Elf32Section
for _, s in ipairs(sections) do for _, s in ipairs(sections) do
s.name = M.get_str(shstrtab_bytes, s.sh_name) or "" s.name = M.get_str(shstrtab_bytes, s.sh_name) or ""
end end
@@ -322,14 +440,18 @@ end
--- Read the bytes of one section. Returns a string, or nil if the adapter returns nil for any byte (out-of-bounds). --- Read the bytes of one section. Returns a string, or nil if the adapter returns nil for any byte (out-of-bounds).
--- The caller is responsible fors sizing the buffer (the section's sh_offset + sh_size must fit in adapter.size). --- The caller is responsible fors sizing the buffer (the section's sh_offset + sh_size must fit in adapter.size).
--- @param adapter table --- @param adapter Elf32Adapter
--- @param section table -- one entry from walk_sections --- @param section Elf32Section
--- @return string|nil --- @return string|nil
function M.read_section_bytes(adapter, section) function M.read_section_bytes(adapter, section)
--- @type integer
local size = section.sh_size local size = section.sh_size
if size == 0 then return "" end if size == 0 then return "" end
--- @type string[]
local out = {} local out = {}
--- @type integer
for i = 0, size - 1 do for i = 0, size - 1 do
--- @type integer|nil
local b = M.read_u8(adapter, section.sh_offset + i) local b = M.read_u8(adapter, section.sh_offset + i)
if b == nil then return nil end if b == nil then return nil end
out[#out + 1] = string.char(b) out[#out + 1] = string.char(b)
@@ -339,14 +461,16 @@ end
--- Convenience: walk sections, then look up the named section, then read its bytes. --- Convenience: walk sections, then look up the named section, then read its bytes.
--- Returns nil + a stable error code if the section is absent or out-of-bounds. --- Returns nil + a stable error code if the section is absent or out-of-bounds.
--- @param adapter table --- @param adapter Elf32Adapter
--- @param sections table -- 1-based array from walk_sections --- @param sections Elf32Section[]
--- @param name string --- @param name string
--- @return string|nil, string|nil --- @return string|nil, string|nil
function M.read_named_section(adapter, sections, name) function M.read_named_section(adapter, sections, name)
if not sections then return nil, "missing_section" end if not sections then return nil, "missing_section" end
--- @type integer, Elf32Section
for _, s in ipairs(sections) do for _, s in ipairs(sections) do
if s.name == name then if s.name == name then
--- @type string|nil
local bytes = M.read_section_bytes(adapter, s) local bytes = M.read_section_bytes(adapter, s)
if not bytes then return nil, "truncated_section_data" end if not bytes then return nil, "truncated_section_data" end
return bytes, nil return bytes, nil
@@ -359,15 +483,19 @@ end
--- Each stored entry is `{ value = st_value, size = st_size, info = st_info, shndx = st_shndx }`. --- Each stored entry is `{ value = st_value, size = st_size, info = st_info, shndx = st_shndx }`.
--- Both STB_LOCAL and STB_GLOBAL symbols are included; the live ELF stores `smem` as a local symbol. --- Both STB_LOCAL and STB_GLOBAL symbols are included; the live ELF stores `smem` as a local symbol.
--- Returns nil + a stable error code on failure: missing_symtab_strtab, truncated_section_headers --- Returns nil + a stable error code on failure: missing_symtab_strtab, truncated_section_headers
--- @param adapter table --- @param adapter Elf32Adapter
--- @param sections table --- @param sections Elf32Section[]
--- @return table|nil, string|nil --- @return table<string, Elf32Sym>|nil, string|nil
function M.collect_symbols(adapter, sections) function M.collect_symbols(adapter, sections)
if not sections then return nil, "missing_sections" end if not sections then return nil, "missing_sections" end
--- @type table<string, Elf32Sym> -- bag: symbol name -> Elf32Sym
local symbols = {} local symbols = {}
--- @type integer
local file_size = M.size(adapter) local file_size = M.size(adapter)
--- @type integer, Elf32Section
for _, s in ipairs(sections) do for _, s in ipairs(sections) do
if s.sh_type == M.SHT_SYMTAB then if s.sh_type == M.SHT_SYMTAB then
--- @type Elf32Section|nil
local strtab = sections[s.sh_link + 1] local strtab = sections[s.sh_link + 1]
if not strtab or strtab.sh_type ~= M.SHT_STRTAB then if not strtab or strtab.sh_type ~= M.SHT_STRTAB then
return nil, "missing_symtab_strtab" return nil, "missing_symtab_strtab"
@@ -375,30 +503,43 @@ function M.collect_symbols(adapter, sections)
if strtab.sh_offset + strtab.sh_size > file_size then if strtab.sh_offset + strtab.sh_size > file_size then
return nil, "truncated_section_headers" return nil, "truncated_section_headers"
end end
--- @type string|nil
local strtab_bytes = M.read_section_bytes(adapter, strtab) local strtab_bytes = M.read_section_bytes(adapter, strtab)
if not strtab_bytes then return nil, "truncated_section_headers" end if not strtab_bytes then return nil, "truncated_section_headers" end
if s.sh_offset + s.sh_size > file_size then if s.sh_offset + s.sh_size > file_size then
return nil, "truncated_section_headers" return nil, "truncated_section_headers"
end end
--- @type string|nil
local symtab_bytes = M.read_section_bytes(adapter, s) local symtab_bytes = M.read_section_bytes(adapter, s)
if not symtab_bytes then return nil, "truncated_section_headers" end if not symtab_bytes then return nil, "truncated_section_headers" end
--- @type number
local n = #symtab_bytes / M.ELF32_SYM.sym_entry_bytes local n = #symtab_bytes / M.ELF32_SYM.sym_entry_bytes
--- @type integer
for j = 0, n - 1 do for j = 0, n - 1 do
--- @type integer
local e = s.sh_offset + j * M.ELF32_SYM.sym_entry_bytes local e = s.sh_offset + j * M.ELF32_SYM.sym_entry_bytes
--- @type integer|nil
local st_name = M.read_u32(adapter, e + M.ELF32_SYM.st_name) local st_name = M.read_u32(adapter, e + M.ELF32_SYM.st_name)
if st_name then if st_name then
--- @type integer|nil
local st_value = M.read_u32(adapter, e + M.ELF32_SYM.st_value) local st_value = M.read_u32(adapter, e + M.ELF32_SYM.st_value)
--- @type integer|nil
local st_size = M.read_u32(adapter, e + M.ELF32_SYM.st_size) local st_size = M.read_u32(adapter, e + M.ELF32_SYM.st_size)
--- @type integer|nil
local st_info = M.read_u8(adapter, e + M.ELF32_SYM.st_info) local st_info = M.read_u8(adapter, e + M.ELF32_SYM.st_info)
-- st_shndx is at offset 14 (2 bytes) — derived from the layout -- st_shndx is at offset 14 (2 bytes) — derived from the layout
-- the metaprogram reads too. Inline the read to keep the -- the metaprogram reads too. Inline the read to keep the
-- adapter as the only I/O surface. -- adapter as the only I/O surface.
--- @type integer|nil
local b1 = M.read_u8(adapter, e + 14) local b1 = M.read_u8(adapter, e + 14)
--- @type integer|nil
local b2 = M.read_u8(adapter, e + 15) local b2 = M.read_u8(adapter, e + 15)
if not (b1 and b2) then if not (b1 and b2) then
return nil, "truncated_section_headers" return nil, "truncated_section_headers"
end end
--- @type integer
local st_shndx = b1 + b2 * 0x100 local st_shndx = b1 + b2 * 0x100
--- @type string
local name = M.get_str(strtab_bytes, st_name) or "" local name = M.get_str(strtab_bytes, st_name) or ""
if name ~= "" then if name ~= "" then
symbols[name] = { symbols[name] = {
+390 -10
View File
@@ -8,14 +8,119 @@
-- Native dependencies -- Native dependencies
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- lfs is wired into package.cpath by `duffle_paths.lua` (vendored under `toolchain/lfs/lfs.dll`). -- lfs is wired into package.cpath by `duffle_paths.lua` (vendored under `toolchain/lfs/lfs.dll`).
-- Elf32Adapter / Elf32Header / Elf32Section / Elf32Sym / Elf32Mod: see elf32.lua.
-- Path: see duffle.lua. NmAddr: see passes/atoms_source_map.lua.
--- @class LfsMod
--- @field attributes fun(path: string, request: string): string|nil
--- @class Dwarf4Aranges
--- @field unit_length_offset integer
--- @field version_offset integer
--- @field cu_offset_offset integer
--- @field addr_size_offset integer
--- @field seg_size_offset integer
--- @field entry_size integer
--- @field terminator_size integer
--- @field version_expected integer
--- @field addr_size_expected integer
--- @field seg_size_expected integer
--- @class Dwarf5Rnglists
--- @field unit_length_offset integer
--- @field version_offset integer
--- @field addr_size_offset integer
--- @field seg_size_offset integer
--- @field offset_count_offset integer
--- @field first_entry_offset integer
--- @field end_of_list integer
--- @field start_length integer
--- @field version_expected integer
--- @field addr_size_expected integer
--- @field seg_size_expected integer
--- @field offset_count_expected integer
--- @class DwarfLineOps
--- @field DW_LNS_extended integer
--- @field DW_LNS_copy integer
--- @field DW_LNS_advance_pc integer
--- @field DW_LNS_advance_line integer
--- @field DW_LNS_set_file integer
--- @field DW_LNS_negate_stmt integer
--- @field DW_LNE_end_sequence integer
--- @field DW_LNE_set_address integer
--- @field opcode_base integer
--- @field line_base integer
--- @field line_range integer
--- @field end_sequence_payload_size integer
--- @field set_address_payload_size integer
--- @class Dwarf5DebugLine
--- @field version_offset_post_il integer
--- @field addr_size_offset integer
--- @field seg_size_offset integer
--- @field header_length_offset integer
--- @field program_header_start integer
--- @field form_addr_bytes integer
--- @field form_strp_bytes integer
--- @field form_data16_bytes integer
--- @field form_line_strp integer
--- @field form_string integer
--- @field form_udata integer
--- @field form_data16 integer
--- @field lnct_path integer
--- @field lnct_directory_index integer
--- @field lnct_md5 integer
--- @class AbbrevAttr
--- @field name integer
--- @field form integer
--- @class AbbrevDecl
--- @field code integer
--- @field tag integer
--- @field has_children integer
--- @field attrs AbbrevAttr[]
--- @class ElfDwarf
--- @field DW_TAG table<string, integer> -- bag: tag name -> encoding
--- @field DW_AT table<string, integer> -- bag: attr name -> encoding
--- @field DW_FORM table<string, integer> -- bag: form name -> encoding
--- @field DW_ATE table<string, integer> -- bag: base-type encoding name -> code
--- @field MIPS_BYTES_PER_WORD integer
--- @field dw_dwarf32_terminator integer
--- @field DWARF4_ARANGES Dwarf4Aranges
--- @field DWARF5_RNGLISTS Dwarf5Rnglists
--- @field DWARF_LINE_OPS DwarfLineOps
--- @field DWARF5_DEBUG_LINE Dwarf5DebugLine
--- @field read_u32_le fun(buf: string, off: integer): integer
--- @field read_u16_le fun(buf: string, off: integer): integer
--- @field read_uleb128_at fun(buf: string, pos: integer): integer|nil, integer
--- @field read_sleb128_at fun(buf: string, pos: integer): integer|nil, integer
--- @field find_abbrev_table_end fun(table_bytes: string, table_start: integer): integer|nil
--- @field read_ref_sig8 fun(buf: string, pos: integer): integer, integer, integer
--- @field find_type_unit_by_signature fun(info: string, target_sig_lo: integer, target_sig_hi: integer): integer|nil, integer|nil
--- @field write_u32_le fun(value: integer): string
--- @field write_u16_le fun(value: integer): string
--- @field read_elf_sections fun(elf_path: Path, section_names: string[]): table<string, string>
--- @field read_nm fun(elf_path: Path): table<string, NmAddr>
--- @field uleb128 fun(n: integer): string
--- @field sleb128 fun(n: integer): string
--- @field uleb128_size fun(n: integer): integer
--- @field sleb128_size fun(n: integer): integer
--- @field read_line_unit_file_table fun(elf_path: string): table<string, integer>|nil, table<integer, string>|nil, table<integer, string>|nil
--- @type LfsMod
local lfs = require("lfs") local lfs = require("lfs")
-- scripts/elf32.lua contains format-constant tables + the byte-level walker. -- scripts/elf32.lua contains format-constant tables + the byte-level walker.
-- The this file re-exports `read_u32_le` / `read_u16_le` (and the DWARF32 terminator). -- The this file re-exports `read_u32_le` / `read_u16_le` (and the DWARF32 terminator).
-- read_u32_le is this module's reader; implementation in elf32.lua. -- read_u32_le is this module's reader; implementation in elf32.lua.
--- @type Elf32Mod
local E = require("elf32") local E = require("elf32")
--- @type ElfDwarf
local M = {} local M = {}
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -23,6 +128,8 @@ local M = {}
-- ════════════════════════════════════════════ -- ════════════════════════════════════════════
-- (DWARF5 §7.5.5 "Tag Encodings" + Table 7.1; gcc emits these exact values for the DWARF3-extension and DWARF5 line units.) -- (DWARF5 §7.5.5 "Tag Encodings" + Table 7.1; gcc emits these exact values for the DWARF3-extension and DWARF5 line units.)
--- bag: DWARF tag name -> encoding
--- @type table<string, integer>
M.DW_TAG = { M.DW_TAG = {
compile_unit = 0x11, compile_unit = 0x11,
subprogram = 0x2E, subprogram = 0x2E,
@@ -38,6 +145,8 @@ M.DW_TAG = {
-- We index the canonical gcc-emitted tags. Anything else falls through. -- We index the canonical gcc-emitted tags. Anything else falls through.
} }
--- bag: DWARF attr name -> encoding
--- @type table<string, integer>
M.DW_AT = { M.DW_AT = {
name = 0x03, name = 0x03,
low_pc = 0x11, low_pc = 0x11,
@@ -59,6 +168,8 @@ M.DW_AT = {
decl_line = 0x3B, decl_line = 0x3B,
} }
--- bag: DWARF form name -> encoding
--- @type table<string, integer>
M.DW_FORM = { M.DW_FORM = {
addr = 0x01, addr = 0x01,
data1 = 0x0B, data1 = 0x0B,
@@ -75,6 +186,8 @@ M.DW_FORM = {
sec_offset = 0x17, sec_offset = 0x17,
} }
--- bag: DWARF base-type encoding name -> code
--- @type table<string, integer>
M.DW_ATE = { M.DW_ATE = {
address = 0x01, address = 0x01,
boolean = 0x02, boolean = 0x02,
@@ -87,6 +200,7 @@ M.DW_ATE = {
} }
-- DWARF5 §7.5.6 DW_FORM_implicit_const -- DWARF5 §7.5.6 DW_FORM_implicit_const
--- @type integer
local DW_FORM_implicit_const = 0x21 local DW_FORM_implicit_const = 0x21
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -120,6 +234,7 @@ M.dw_dwarf32_terminator = E.dw_dwarf32_terminator
-- All offsets are zero-based wire offsets. -- All offsets are zero-based wire offsets.
--- spec: DWARF5 spec §7.4 (Address Range Table) — 32-bit DWARF form --- spec: DWARF5 spec §7.4 (Address Range Table) — 32-bit DWARF form
--- @type Dwarf4Aranges
M.DWARF4_ARANGES = { M.DWARF4_ARANGES = {
unit_length_offset = 0x00, -- 4-byte LE; length of unit body (excludes these 4 bytes) unit_length_offset = 0x00, -- 4-byte LE; length of unit body (excludes these 4 bytes)
version_offset = 0x04, -- 2-byte LE; expected = 2 version_offset = 0x04, -- 2-byte LE; expected = 2
@@ -139,6 +254,7 @@ M.DWARF4_ARANGES = {
-- All offsets are zero-based wire offsets. -- All offsets are zero-based wire offsets.
--- spec: DWARF5 spec §2.17 + §7.21 (Range List Table) — 32-bit DWARF form --- spec: DWARF5 spec §2.17 + §7.21 (Range List Table) — 32-bit DWARF form
--- @type Dwarf5Rnglists
M.DWARF5_RNGLISTS = { M.DWARF5_RNGLISTS = {
unit_length_offset = 0x00, -- 4-byte LE unit_length_offset = 0x00, -- 4-byte LE
version_offset = 0x04, -- 2-byte LE; expected = 5 version_offset = 0x04, -- 2-byte LE; expected = 5
@@ -161,6 +277,7 @@ M.DWARF5_RNGLISTS = {
-- Compare to the *_offset fields above which are hex. -- Compare to the *_offset fields above which are hex.
--- spec: DWARF5 spec §6.2.5 (Line Number Program Opcodes) --- spec: DWARF5 spec §6.2.5 (Line Number Program Opcodes)
--- @type DwarfLineOps
M.DWARF_LINE_OPS = { M.DWARF_LINE_OPS = {
-- Standard opcodes (§6.2.5.2) -- Standard opcodes (§6.2.5.2)
DW_LNS_extended = 0, -- spec: §6.2.5.2 — extended opcode marker byte DW_LNS_extended = 0, -- spec: §6.2.5.2 — extended opcode marker byte
@@ -197,6 +314,7 @@ M.DWARF_LINE_OPS = {
-- These are documented inline at each parse site in read_line_unit_file_table below. -- These are documented inline at each parse site in read_line_unit_file_table below.
--- spec: DWARF5 spec §6.2.4 (Line Number Program Header — version >= 5) --- spec: DWARF5 spec §6.2.4 (Line Number Program Header — version >= 5)
--- @type Dwarf5DebugLine
M.DWARF5_DEBUG_LINE = { M.DWARF5_DEBUG_LINE = {
-- Header fields (zero-based, AFTER unit_length has been read). -- Header fields (zero-based, AFTER unit_length has been read).
version_offset_post_il = 0x00, -- 2-byte LE; expected = 5 version_offset_post_il = 0x00, -- 2-byte LE; expected = 5
@@ -255,10 +373,17 @@ end
-- Offsets are 0-based; returns (value, next_pos). -- Offsets are 0-based; returns (value, next_pos).
-- Promoted from `local function` to M.* exports so passes/dwarf_injection.lua can import them as file-scope locals per the 2nd-caller lift precedent -- Promoted from `local function` to M.* exports so passes/dwarf_injection.lua can import them as file-scope locals per the 2nd-caller lift precedent
-- (the uleb128 + sleb128 encoders were promoted the same way). -- (the uleb128 + sleb128 encoders were promoted the same way).
--- @param buf string
--- @param pos integer
--- @return integer|nil
--- @return integer
function M.read_uleb128_at(buf, pos) function M.read_uleb128_at(buf, pos)
--- @type integer, integer
local value, shift = 0, 0 local value, shift = 0, 0
--- @type integer
local len = #buf local len = #buf
while pos < len do while pos < len do
--- @type integer
local b = buf:byte(pos + 1) local b = buf:byte(pos + 1)
value = value + (b % 0x80) * (2 ^ shift) value = value + (b % 0x80) * (2 ^ shift)
shift = shift + 7 shift = shift + 7
@@ -268,10 +393,17 @@ function M.read_uleb128_at(buf, pos)
return nil, pos return nil, pos
end end
--- @param buf string
--- @param pos integer
--- @return integer|nil
--- @return integer
function M.read_sleb128_at(buf, pos) function M.read_sleb128_at(buf, pos)
--- @type integer, integer
local value, shift = 0, 0 local value, shift = 0, 0
--- @type integer
local len = #buf local len = #buf
while pos < len do while pos < len do
--- @type integer
local b = buf:byte(pos + 1) local b = buf:byte(pos + 1)
value = value + (b % 0x80) * (2 ^ shift) value = value + (b % 0x80) * (2 ^ shift)
shift = shift + 7 shift = shift + 7
@@ -287,27 +419,36 @@ end
-- Find the 0-based offset of the table-terminator byte (a single 0) for the abbrev table starting at `table_start`. -- Find the 0-based offset of the table-terminator byte (a single 0) for the abbrev table starting at `table_start`.
-- Returns nil on truncated input. Walks declaration headers -- Returns nil on truncated input. Walks declaration headers
-- (code, tag, has_children, attr/form pairs, DW_FORM_implicit_const constant) until it finds a 0 byte that follows a complete declaration. -- (code, tag, has_children, attr/form pairs, DW_FORM_implicit_const constant) until it finds a 0 byte that follows a complete declaration.
--- @param table_bytes string
--- @param table_start integer
--- @return integer|nil
function M.find_abbrev_table_end(table_bytes, table_start) function M.find_abbrev_table_end(table_bytes, table_start)
--- @type integer, integer
local pos, len = table_start, #table_bytes local pos, len = table_start, #table_bytes
if pos >= len or table_bytes:byte(pos + 1) == 0 then return pos end if pos >= len or table_bytes:byte(pos + 1) == 0 then return pos end
while pos < len do while pos < len do
--- @type integer|nil, integer
local _code, code_end = M.read_uleb128_at(table_bytes, pos) local _code, code_end = M.read_uleb128_at(table_bytes, pos)
if not _code then return nil end if not _code then return nil end
pos = code_end pos = code_end
--- @type integer|nil, integer
local _tag, tag_end = M.read_uleb128_at(table_bytes, pos) local _tag, tag_end = M.read_uleb128_at(table_bytes, pos)
if not _tag then return nil end if not _tag then return nil end
pos = tag_end pos = tag_end
if pos >= len then return nil end if pos >= len then return nil end
pos = pos + 1 -- has_children byte pos = pos + 1 -- has_children byte
while pos < len do while pos < len do
--- @type integer|nil, integer
local attr, attr_end = M.read_uleb128_at(table_bytes, pos) local attr, attr_end = M.read_uleb128_at(table_bytes, pos)
if not attr then return nil end if not attr then return nil end
pos = attr_end pos = attr_end
--- @type integer|nil, integer
local form, form_end = M.read_uleb128_at(table_bytes, pos) local form, form_end = M.read_uleb128_at(table_bytes, pos)
if not form then return nil end if not form then return nil end
pos = form_end pos = form_end
if attr == 0 and form == 0 then break end if attr == 0 and form == 0 then break end
if form == DW_FORM_implicit_const then if form == DW_FORM_implicit_const then
--- @type integer|nil, integer
local _c, ce = M.read_sleb128_at(table_bytes, pos) local _c, ce = M.read_sleb128_at(table_bytes, pos)
if not _c then return nil end if not _c then return nil end
pos = ce pos = ce
@@ -321,8 +462,13 @@ end
-- Read the null-terminated C string at 0-based offset `off` in `buf`. -- Read the null-terminated C string at 0-based offset `off` in `buf`.
-- Stops at the first 0 byte or end of buffer. -- Stops at the first 0 byte or end of buffer.
--- @param buf string
--- @param off integer
--- @return string
local function read_c_string_at(buf, off) local function read_c_string_at(buf, off)
--- @type integer
local len = #buf local len = #buf
--- @type integer
local start = off local start = off
while off < len and buf:byte(off + 1) ~= 0 do off = off + 1 end while off < len and buf:byte(off + 1) ~= 0 do off = off + 1 end
return buf:sub(start + 1, off) return buf:sub(start + 1, off)
@@ -331,31 +477,45 @@ end
-- Walk the .debug_abbrev table starting at 0-based offset `table_start` and return a list of declarations: -- Walk the .debug_abbrev table starting at 0-based offset `table_start` and return a list of declarations:
-- {code, tag, has_children, attrs={ {name, form}, ... }}. -- {code, tag, has_children, attrs={ {name, form}, ... }}.
-- Stops at the table terminator. -- Stops at the table terminator.
--- @param table_bytes string
--- @param table_start integer
--- @return AbbrevDecl[]|nil
--- @return string|nil
local function parse_abbrev_table(table_bytes, table_start) local function parse_abbrev_table(table_bytes, table_start)
--- @type integer|nil
local table_end = M.find_abbrev_table_end(table_bytes, table_start) local table_end = M.find_abbrev_table_end(table_bytes, table_start)
if not table_end then return nil, "no terminator" end if not table_end then return nil, "no terminator" end
--- @type AbbrevDecl[]
local decls = {} local decls = {}
--- @type integer
local pos = table_start local pos = table_start
while pos < table_end do while pos < table_end do
--- @type integer|nil, integer
local code, code_end = M.read_uleb128_at(table_bytes, pos) local code, code_end = M.read_uleb128_at(table_bytes, pos)
if not code then return nil, "truncated code" end if not code then return nil, "truncated code" end
pos = code_end pos = code_end
--- @type integer|nil, integer
local tag, tag_end = M.read_uleb128_at(table_bytes, pos) local tag, tag_end = M.read_uleb128_at(table_bytes, pos)
if not tag then return nil, "truncated tag" end if not tag then return nil, "truncated tag" end
pos = tag_end pos = tag_end
--- @type integer
local has_children = table_bytes:byte(pos + 1) local has_children = table_bytes:byte(pos + 1)
pos = pos + 1 pos = pos + 1
--- @type AbbrevAttr[]
local attrs = {} local attrs = {}
while true do while true do
--- @type integer|nil, integer
local attr, attr_end = M.read_uleb128_at(table_bytes, pos) local attr, attr_end = M.read_uleb128_at(table_bytes, pos)
if not attr then return nil, "truncated attr" end if not attr then return nil, "truncated attr" end
pos = attr_end pos = attr_end
--- @type integer|nil, integer
local form, form_end = M.read_uleb128_at(table_bytes, pos) local form, form_end = M.read_uleb128_at(table_bytes, pos)
if not form then return nil, "truncated form" end if not form then return nil, "truncated form" end
pos = form_end pos = form_end
if attr == 0 and form == 0 then break end if attr == 0 and form == 0 then break end
attrs[#attrs + 1] = { name = attr, form = form } attrs[#attrs + 1] = { name = attr, form = form }
if form == DW_FORM_implicit_const then if form == DW_FORM_implicit_const then
--- @type integer|nil, integer
local _c, ce = M.read_sleb128_at(table_bytes, pos) local _c, ce = M.read_sleb128_at(table_bytes, pos)
if not _c then return nil, "truncated const" end if not _c then return nil, "truncated const" end
pos = ce pos = ce
@@ -371,65 +531,142 @@ end
-- For DW_FORM_strp we return the inline string resolved from `str_buf`. -- For DW_FORM_strp we return the inline string resolved from `str_buf`.
-- For DW_FORM_ref4 we return the absolute CU-relative offset. -- For DW_FORM_ref4 we return the absolute CU-relative offset.
-- The caller decides whether to interpret that as a section offset. -- The caller decides whether to interpret that as a section offset.
--- @type table<integer, fun(buf: string, str_buf: string, pos: integer): (string|integer|nil, integer)>
local FORM_READERS = { local FORM_READERS = {
--- @param buf string
--- @param _ string
--- @param pos integer
--- @return integer
--- @return integer
[M.DW_FORM.addr] = function(buf, _, pos) [M.DW_FORM.addr] = function(buf, _, pos)
return M.read_u32_le(buf, pos), pos + 4 return M.read_u32_le(buf, pos), pos + 4
end, end,
--- @param buf string
--- @param _ string
--- @param pos integer
--- @return string
--- @return integer
[M.DW_FORM.string] = function(buf, _, pos) [M.DW_FORM.string] = function(buf, _, pos)
--- @type string
local s = read_c_string_at(buf, pos) local s = read_c_string_at(buf, pos)
return s, pos + #s + 1 return s, pos + #s + 1
end, end,
--- @param buf string
--- @param str_buf string
--- @param pos integer
--- @return string
--- @return integer
[M.DW_FORM.strp] = function(buf, str_buf, pos) [M.DW_FORM.strp] = function(buf, str_buf, pos)
-- DW_FORM_strp: 4-byte offset into .debug_str. -- DW_FORM_strp: 4-byte offset into .debug_str.
--- @type integer
local strp_off = M.read_u32_le(buf, pos) local strp_off = M.read_u32_le(buf, pos)
return read_c_string_at(str_buf, strp_off), pos + 4 return read_c_string_at(str_buf, strp_off), pos + 4
end, end,
--- @param buf string
--- @param _ string
--- @param pos integer
--- @return integer|nil
--- @return integer
[M.DW_FORM.udata] = function(buf, _, pos) [M.DW_FORM.udata] = function(buf, _, pos)
return M.read_uleb128_at(buf, pos) return M.read_uleb128_at(buf, pos)
end, end,
--- @param buf string
--- @param _ string
--- @param pos integer
--- @return integer
--- @return integer
[M.DW_FORM.data1] = function(buf, _, pos) [M.DW_FORM.data1] = function(buf, _, pos)
return buf:byte(pos + 1), pos + 1 return buf:byte(pos + 1), pos + 1
end, end,
--- @param buf string
--- @param _ string
--- @param pos integer
--- @return integer
--- @return integer
[M.DW_FORM.data2] = function(buf, _, pos) [M.DW_FORM.data2] = function(buf, _, pos)
return M.read_u16_le(buf, pos), pos + 2 return M.read_u16_le(buf, pos), pos + 2
end, end,
--- @param buf string
--- @param _ string
--- @param pos integer
--- @return integer
--- @return integer
[M.DW_FORM.data4] = function(buf, _, pos) [M.DW_FORM.data4] = function(buf, _, pos)
return M.read_u32_le(buf, pos), pos + 4 return M.read_u32_le(buf, pos), pos + 4
end, end,
--- @param buf string
--- @param _ string
--- @param pos integer
--- @return integer
--- @return integer
[M.DW_FORM.ref4] = function(buf, _, pos) [M.DW_FORM.ref4] = function(buf, _, pos)
return M.read_u32_le(buf, pos), pos + 4 return M.read_u32_le(buf, pos), pos + 4
end, end,
--- @param buf string
--- @param _ string
--- @param pos integer
--- @return integer
--- @return integer
[M.DW_FORM.sec_offset] = function(buf, _, pos) [M.DW_FORM.sec_offset] = function(buf, _, pos)
-- DW_FORM_sec_offset: 4-byte offset (size depends on DWARF version; -- DW_FORM_sec_offset: 4-byte offset (size depends on DWARF version;
-- on DWARF5 32-bit it's always 4 bytes). -- on DWARF5 32-bit it's always 4 bytes).
return M.read_u32_le(buf, pos), pos + 4 return M.read_u32_le(buf, pos), pos + 4
end, end,
--- @param _ string
--- @param _ string
--- @param pos integer
--- @return integer
--- @return integer
[M.DW_FORM.flag_present] = function(_, _, pos) [M.DW_FORM.flag_present] = function(_, _, pos)
return 1, pos return 1, pos
end, end,
--- @param buf string
--- @param _ string
--- @param pos integer
--- @return nil
--- @return integer
[M.DW_FORM.exprloc] = function(buf, _, pos) [M.DW_FORM.exprloc] = function(buf, _, pos)
-- DW_FORM_exprloc: ULEB byte count + that many bytes of DW_OP_*. -- DW_FORM_exprloc: ULEB byte count + that many bytes of DW_OP_*.
--- @type integer|nil, integer
local len, ne = M.read_uleb128_at(buf, pos) local len, ne = M.read_uleb128_at(buf, pos)
if not len then return nil, pos end if not len then return nil, pos end
return nil, ne + len return nil, ne + len
end, end,
--- @param _ string
--- @param _ string
--- @param pos integer
--- @return nil
--- @return integer
[DW_FORM_implicit_const] = function(_, _, pos) [DW_FORM_implicit_const] = function(_, _, pos)
-- The constant is declared in the abbrev; no value bytes in the DIE. -- The constant is declared in the abbrev; no value bytes in the DIE.
return nil, pos return nil, pos
end, end,
--- @param buf string
--- @param _ string
--- @param pos integer
--- @return integer
--- @return integer
[M.DW_FORM.ref_sig8] = function(buf, _, pos) [M.DW_FORM.ref_sig8] = function(buf, _, pos)
-- DW_FORM_ref_sig8 (DWARF5 §7.4.2): An 8-byte value identifying a type by signature. -- DW_FORM_ref_sig8 (DWARF5 §7.4.2): An 8-byte value identifying a type by signature.
-- The low 4 bytes (LE) are the type signature (content hash); -- The low 4 bytes (LE) are the type signature (content hash);
-- The high 4 bytes (LE) are a CU-relative offset into the matching type unit. -- The high 4 bytes (LE) are a CU-relative offset into the matching type unit.
-- Consumers use the low 4 to look up the type unit (see M.find_type_unit_by_signature) -- Consumers use the low 4 to look up the type unit (see M.find_type_unit_by_signature)
-- then the high 4 to resolve the specific type within it. -- then the high 4 to resolve the specific type within it.
-- Return the low 4 as the primary value to preserve the (value, next_pos) shape; -- Return the low 4 as the primary value to preserve the (value, next_pos) shape;
-- the high 4 is exposed via M.read_ref_sig8 (which returns both halves). -- the high 4 is exposed via M.read_ref_sig8 (which returns both halves).
--- @type integer, integer, integer
local _, _, next_pos = M.read_ref_sig8(buf, pos) local _, _, next_pos = M.read_ref_sig8(buf, pos)
return M.read_u32_le(buf, pos), next_pos return M.read_u32_le(buf, pos), next_pos
end, end,
} }
--- @param buf string
--- @param str_buf string
--- @param pos integer
--- @param form integer
--- @return string|integer|nil
--- @return integer
local function read_form_value(buf, str_buf, pos, form) local function read_form_value(buf, str_buf, pos, form)
--- @type (fun(buf: string, str_buf: string, pos: integer): (string|integer|nil, integer))|nil
local r = FORM_READERS[form] local r = FORM_READERS[form]
if not r then if not r then
return nil, pos return nil, pos
@@ -464,15 +701,20 @@ function M.read_ref_sig8(buf, pos) return M.read_u32_le(buf, pos), M.read_u32_le
--- @param target_sig_hi integer -- high 4 bytes (LE) of the desired signature --- @param target_sig_hi integer -- high 4 bytes (LE) of the desired signature
--- @return integer|nil, integer|nil -- unit offset, type_offset within the unit --- @return integer|nil, integer|nil -- unit offset, type_offset within the unit
function M.find_type_unit_by_signature(info, target_sig_lo, target_sig_hi) function M.find_type_unit_by_signature(info, target_sig_lo, target_sig_hi)
--- @type integer
local pos = 0 local pos = 0
--- @type integer
local section_len = #info local section_len = #info
while pos + 4 < section_len do while pos + 4 < section_len do
--- @type integer
local unit_length = M.read_u32_le(info, pos) local unit_length = M.read_u32_le(info, pos)
if unit_length == 0xFFFFFFFF then if unit_length == 0xFFFFFFFF then
return nil, nil -- DWARF64 not supported return nil, nil -- DWARF64 not supported
end end
-- unit_length is the body size, NOT including the 4-byte unit_length field itself. -- unit_length is the body size, NOT including the 4-byte unit_length field itself.
--- @type integer
local body_start = pos + 4 local body_start = pos + 4
--- @type integer
local body_end = body_start + unit_length local body_end = body_start + unit_length
if body_end > section_len then if body_end > section_len then
return nil, nil -- malformed return nil, nil -- malformed
@@ -495,10 +737,13 @@ function M.find_type_unit_by_signature(info, target_sig_lo, target_sig_hi)
-- byte 4-7: debug_abbrev_offset (4) -- byte 4-7: debug_abbrev_offset (4)
-- byte 8-15: type_signature (8) -- byte 8-15: type_signature (8)
-- byte 16-19: type_offset (4) -- byte 16-19: type_offset (4)
--- @type integer
local unit_type = info:byte(body_start + 2 + 1) -- 0-based +2 = unit_type in 1-indexed local unit_type = info:byte(body_start + 2 + 1) -- 0-based +2 = unit_type in 1-indexed
if unit_type == 0x02 then -- DW_UT_type if unit_type == 0x02 then -- DW_UT_type
--- @type integer, integer, integer
local sig_lo, sig_hi, _ = M.read_ref_sig8(info, body_start + 8) -- 0-based +8 = type_signature in 1-indexed local sig_lo, sig_hi, _ = M.read_ref_sig8(info, body_start + 8) -- 0-based +8 = type_signature in 1-indexed
if sig_lo == target_sig_lo and sig_hi == target_sig_hi then if sig_lo == target_sig_lo and sig_hi == target_sig_hi then
--- @type integer
local type_offset = M.read_u32_le(info, body_start + 16) -- 0-based +16 = type_offset in 1-indexed local type_offset = M.read_u32_le(info, body_start + 16) -- 0-based +16 = type_offset in 1-indexed
return pos, type_offset return pos, type_offset
end end
@@ -551,11 +796,15 @@ end
function M.read_elf_sections(elf_path, section_names) function M.read_elf_sections(elf_path, section_names)
-- Initialize result with all requested names set to "" so callers can do `sections[X] -- Initialize result with all requested names set to "" so callers can do `sections[X]
-- or ""` for missing sections without nil-checks. -- or ""` for missing sections without nil-checks.
--- @type table<string, string>
local result = {} local result = {}
--- @type integer, string
for _, name in ipairs(section_names) do result[name] = "" end for _, name in ipairs(section_names) do result[name] = "" end
-- O(1) lookup set. -- O(1) lookup set.
--- @type table<string, boolean> -- bag: requested section name -> true
local wanted = {} local wanted = {}
--- @type integer, string
for _, name in ipairs(section_names) do wanted[name] = true end for _, name in ipairs(section_names) do wanted[name] = true end
-- Existence check (lfs.attributes avoids an io.open-vs-fail race). -- Existence check (lfs.attributes avoids an io.open-vs-fail race).
@@ -564,45 +813,63 @@ function M.read_elf_sections(elf_path, section_names)
return result return result
end end
--- @type file*|nil
local f = io.open(elf_path, "rb") local f = io.open(elf_path, "rb")
if not f then if not f then
io.stderr:write(string.format("[elf_dwarf.read_elf_sections] io.open failed: %s\n", elf_path)) io.stderr:write(string.format("[elf_dwarf.read_elf_sections] io.open failed: %s\n", elf_path))
return result return result
end end
--- @type integer
local file_size local file_size
do do
f:seek("end", 0) f:seek("end", 0)
file_size = f:seek("cur", 0) file_size = f:seek("cur", 0)
end end
--- @type Elf32Adapter
local adapter = { local adapter = {
--- @param offset integer
--- @return integer|nil
read_u8_at = function(offset) read_u8_at = function(offset)
f:seek("set", offset) f:seek("set", offset)
--- @type string|nil
local b = f:read(1) local b = f:read(1)
if not b then return nil end if not b then return nil end
return b:byte() return b:byte()
end, end,
--- @param offset integer
--- @return integer|nil
read_u16_at = function(offset) read_u16_at = function(offset)
f:seek("set", offset) f:seek("set", offset)
--- @type string|nil
local b1 = f:read(1) local b1 = f:read(1)
--- @type string|nil
local b2 = f:read(1) local b2 = f:read(1)
if not b1 or not b2 then return nil end if not b1 or not b2 then return nil end
return b1:byte() + b2:byte() * 0x100 return b1:byte() + b2:byte() * 0x100
end, end,
--- @param offset integer
--- @return integer|nil
read_u32_at = function(offset) read_u32_at = function(offset)
f:seek("set", offset) f:seek("set", offset)
--- @type string|nil
local b1 = f:read(1) local b1 = f:read(1)
--- @type string|nil
local b2 = f:read(1) local b2 = f:read(1)
--- @type string|nil
local b3 = f:read(1) local b3 = f:read(1)
--- @type string|nil
local b4 = f:read(1) local b4 = f:read(1)
if not b1 or not b2 or not b3 or not b4 then return nil end if not b1 or not b2 or not b3 or not b4 then return nil end
return b1:byte() + b2:byte() * 0x100 return b1:byte() + b2:byte() * 0x100
+ b3:byte() * 0x10000 + b4:byte() * 0x1000000 + b3:byte() * 0x10000 + b4:byte() * 0x1000000
end, end,
--- @return integer
read_size = function() return file_size end, read_size = function() return file_size end,
} }
-- Delegate the header parse + section walk to E.*. -- Delegate the header parse + section walk to E.*.
--- @type Elf32Header|nil, string|nil
local hdr, hdr_err = E.parse_elf32_headers(adapter) local hdr, hdr_err = E.parse_elf32_headers(adapter)
if not hdr then if not hdr then
io.stderr:write(string.format("[elf_dwarf.read_elf_sections] header parse failed: %s\n", tostring(hdr_err))) io.stderr:write(string.format("[elf_dwarf.read_elf_sections] header parse failed: %s\n", tostring(hdr_err)))
@@ -610,6 +877,7 @@ function M.read_elf_sections(elf_path, section_names)
return result return result
end end
--- @type Elf32Section[]|nil, string|nil
local sections, walk_err = E.walk_sections(adapter, hdr) local sections, walk_err = E.walk_sections(adapter, hdr)
if not sections then if not sections then
io.stderr:write(string.format("[elf_dwarf.read_elf_sections] section walk failed: %s\n", tostring(walk_err))) io.stderr:write(string.format("[elf_dwarf.read_elf_sections] section walk failed: %s\n", tostring(walk_err)))
@@ -618,8 +886,10 @@ function M.read_elf_sections(elf_path, section_names)
end end
-- Resolve the requested sections. -- Resolve the requested sections.
--- @type integer, Elf32Section
for _, s in ipairs(sections) do for _, s in ipairs(sections) do
if wanted[s.name] then if wanted[s.name] then
--- @type string|nil
local bytes = E.read_section_bytes(adapter, s) local bytes = E.read_section_bytes(adapter, s)
if bytes then result[s.name] = bytes end if bytes then result[s.name] = bytes end
end end
@@ -640,50 +910,69 @@ end
--- - `st_size > 0` filter excludes undefined/imported symbols. --- - `st_size > 0` filter excludes undefined/imported symbols.
--- ---
--- @param elf_path Path --- @param elf_path Path
--- @return table<string, {integer, integer}> --- @return table<string, NmAddr>
function M.read_nm(elf_path) function M.read_nm(elf_path)
--- @type table<string, NmAddr>
local addrs = {} local addrs = {}
-- Existence check first; an empty or missing ELF returns an empty map. -- Existence check first; an empty or missing ELF returns an empty map.
if lfs.attributes(elf_path, "mode") ~= "file" then return addrs end if lfs.attributes(elf_path, "mode") ~= "file" then return addrs end
--- @type file*|nil
local f = io.open(elf_path, "rb") local f = io.open(elf_path, "rb")
if not f then return addrs end if not f then return addrs end
-- Build the file adapter for E.*. -- Build the file adapter for E.*.
--- @type integer
local file_size local file_size
do do
f:seek("end", 0) f:seek("end", 0)
file_size = f:seek("cur", 0) file_size = f:seek("cur", 0)
end end
--- @type Elf32Adapter
local adapter = { local adapter = {
--- @param offset integer
--- @return integer|nil
read_u8_at = function(offset) read_u8_at = function(offset)
f:seek("set", offset) f:seek("set", offset)
--- @type string|nil
local b = f:read(1) local b = f:read(1)
if not b then return nil end if not b then return nil end
return b:byte() return b:byte()
end, end,
--- @param offset integer
--- @return integer|nil
read_u16_at = function(offset) read_u16_at = function(offset)
f:seek("set", offset) f:seek("set", offset)
--- @type string|nil
local b1 = f:read(1) local b1 = f:read(1)
--- @type string|nil
local b2 = f:read(1) local b2 = f:read(1)
if not b1 or not b2 then return nil end if not b1 or not b2 then return nil end
return b1:byte() + b2:byte() * 0x100 return b1:byte() + b2:byte() * 0x100
end, end,
--- @param offset integer
--- @return integer|nil
read_u32_at = function(offset) read_u32_at = function(offset)
f:seek("set", offset) f:seek("set", offset)
--- @type string|nil
local b1 = f:read(1) local b1 = f:read(1)
--- @type string|nil
local b2 = f:read(1) local b2 = f:read(1)
--- @type string|nil
local b3 = f:read(1) local b3 = f:read(1)
--- @type string|nil
local b4 = f:read(1) local b4 = f:read(1)
if not b1 or not b2 or not b3 or not b4 then return nil end if not b1 or not b2 or not b3 or not b4 then return nil end
return b1:byte() + b2:byte() * 0x100 return b1:byte() + b2:byte() * 0x100
+ b3:byte() * 0x10000 + b4:byte() * 0x1000000 + b3:byte() * 0x10000 + b4:byte() * 0x1000000
end, end,
--- @return integer
read_size = function() return file_size end, read_size = function() return file_size end,
} }
-- Delegate the header + section walk to E.*. -- Delegate the header + section walk to E.*.
--- @type Elf32Header|nil, string|nil
local hdr, hdr_err = E.parse_elf32_headers(adapter) local hdr, hdr_err = E.parse_elf32_headers(adapter)
if not hdr then if not hdr then
io.stderr:write(string.format("[elf_dwarf.read_nm] header parse failed: %s\n", tostring(hdr_err))) io.stderr:write(string.format("[elf_dwarf.read_nm] header parse failed: %s\n", tostring(hdr_err)))
@@ -691,6 +980,7 @@ function M.read_nm(elf_path)
return addrs return addrs
end end
--- @type Elf32Section[]|nil, string|nil
local sections, walk_err = E.walk_sections(adapter, hdr) local sections, walk_err = E.walk_sections(adapter, hdr)
if not sections then if not sections then
io.stderr:write(string.format("[elf_dwarf.read_nm] section walk failed: %s\n", tostring(walk_err))) io.stderr:write(string.format("[elf_dwarf.read_nm] section walk failed: %s\n", tostring(walk_err)))
@@ -700,6 +990,7 @@ function M.read_nm(elf_path)
-- E.collect_symbols returns every defined symbol (no binding filter). -- E.collect_symbols returns every defined symbol (no binding filter).
-- The metaprogram then applies its STB_LOCAL / STB_GLOBAL + size>0 filter, matching `nm`'s default (external symbols only). -- The metaprogram then applies its STB_LOCAL / STB_GLOBAL + size>0 filter, matching `nm`'s default (external symbols only).
--- @type table<string, Elf32Sym>|nil, string|nil
local symbols, sym_err = E.collect_symbols(adapter, sections) local symbols, sym_err = E.collect_symbols(adapter, sections)
if not symbols then if not symbols then
io.stderr:write(string.format("[elf_dwarf.read_nm] symbol collection failed: %s\n", tostring(sym_err))) io.stderr:write(string.format("[elf_dwarf.read_nm] symbol collection failed: %s\n", tostring(sym_err)))
@@ -709,9 +1000,11 @@ function M.read_nm(elf_path)
f:close() f:close()
--- @type string, Elf32Sym
for name, entry in pairs(symbols) do for name, entry in pairs(symbols) do
-- High nibble of st_info = binding (STB_LOCAL=0, STB_GLOBAL=1, STB_WEAK=2). -- High nibble of st_info = binding (STB_LOCAL=0, STB_GLOBAL=1, STB_WEAK=2).
-- math.floor(/16) is portable across LuaJIT 2.0/2.1 and plain Lua 5.x. -- math.floor(/16) is portable across LuaJIT 2.0/2.1 and plain Lua 5.x.
--- @type integer
local binding = math.floor(entry.info / 16) local binding = math.floor(entry.info / 16)
if (binding == 0 or binding == 1) and entry.size > 0 then if (binding == 0 or binding == 1) and entry.size > 0 then
addrs[name] = { entry.value, entry.size } addrs[name] = { entry.value, entry.size }
@@ -744,13 +1037,16 @@ end
-- Spec: DWARF5 §7.6 "Variable-Length Data" / Appendix C. -- Spec: DWARF5 §7.6 "Variable-Length Data" / Appendix C.
-- Top bit of each LEB128 byte. Set if more bytes follow in the encoding. -- Top bit of each LEB128 byte. Set if more bytes follow in the encoding.
--- @type integer
local LEB_CONT_BIT = 0x80 local LEB_CONT_BIT = 0x80
-- Low 7 bits of each LEB128 byte. The actual data payload. -- Low 7 bits of each LEB128 byte. The actual data payload.
--- @type integer
local LEB_DATA_MASK = 0x7F local LEB_DATA_MASK = 0x7F
-- Bit 6 of the 7-bit data (i.e. 0x40). For SLEB128: the sign-bit position used by the decoder for sign extension. -- Bit 6 of the 7-bit data (i.e. 0x40). For SLEB128: the sign-bit position used by the decoder for sign extension.
-- Encoders MUST stop when the next byte would be redundant AND the sign bit in the last byte matches the value's sign. -- Encoders MUST stop when the next byte would be redundant AND the sign bit in the last byte matches the value's sign.
--- @type integer
local SLEB_SIGN_BIT = 0x40 local SLEB_SIGN_BIT = 0x40
--- ULEB128 (Unsigned Little-Endian Base 128) encoder. Returns the byte string for the non-negative integer `n`. --- ULEB128 (Unsigned Little-Endian Base 128) encoder. Returns the byte string for the non-negative integer `n`.
@@ -768,8 +1064,10 @@ function M.uleb128(n)
error("uleb128 requires non-negative number") error("uleb128 requires non-negative number")
end end
assert(n >= 0, "uleb128 requires non-negative input") assert(n >= 0, "uleb128 requires non-negative input")
--- @type string[]
local bytes = {} local bytes = {}
repeat repeat
--- @type integer
local b = n % (LEB_DATA_MASK + 1) -- extract low 7 bits local b = n % (LEB_DATA_MASK + 1) -- extract low 7 bits
n = (n - b) / (LEB_DATA_MASK + 1) -- shift right by 7 bits n = (n - b) / (LEB_DATA_MASK + 1) -- shift right by 7 bits
if n > 0 then b = b + LEB_CONT_BIT end -- set continuation bit if more bytes follow if n > 0 then b = b + LEB_CONT_BIT end -- set continuation bit if more bytes follow
@@ -789,9 +1087,12 @@ end
--- @param n integer -- any integer (negative allowed) --- @param n integer -- any integer (negative allowed)
--- @return string --- @return string
function M.sleb128(n) function M.sleb128(n)
--- @type string[]
local bytes = {} local bytes = {}
--- @type boolean
local more = true local more = true
while more do while more do
--- @type integer
local b = n % (LEB_DATA_MASK + 1) -- extract low 7 bits local b = n % (LEB_DATA_MASK + 1) -- extract low 7 bits
n = (n - b) / (LEB_DATA_MASK + 1) -- arithmetic shift right by 7 n = (n - b) / (LEB_DATA_MASK + 1) -- arithmetic shift right by 7
-- Termination: remaining value bits fit in the sign bit of the last byte. -- Termination: remaining value bits fit in the sign bit of the last byte.
@@ -811,6 +1112,7 @@ end
function M.uleb128_size(n) function M.uleb128_size(n)
assert(n >= 0, "uleb128_size requires non-negative input") assert(n >= 0, "uleb128_size requires non-negative input")
if n == 0 then return 1 end if n == 0 then return 1 end
--- @type integer
local bytes = 1 local bytes = 1
while n >= 0x80 do while n >= 0x80 do
n = (n - (n % (LEB_DATA_MASK + 1))) / (LEB_DATA_MASK + 1) -- arithmetic shift right by 7 n = (n - (n % (LEB_DATA_MASK + 1))) / (LEB_DATA_MASK + 1) -- arithmetic shift right by 7
@@ -827,10 +1129,14 @@ end
--- @param n integer -- any integer (negative allowed) --- @param n integer -- any integer (negative allowed)
--- @return integer --- @return integer
function M.sleb128_size(n) function M.sleb128_size(n)
--- @type boolean
local more = true local more = true
--- @type integer
local bytes = 0 local bytes = 0
--- @type integer
local v = n local v = n
while more do while more do
--- @type integer
local b = v % (LEB_DATA_MASK + 1) -- extract low 7 bits local b = v % (LEB_DATA_MASK + 1) -- extract low 7 bits
v = (v - b) / (LEB_DATA_MASK + 1) -- arithmetic shift right by 7 v = (v - b) / (LEB_DATA_MASK + 1) -- arithmetic shift right by 7
if v == 0 and b < SLEB_SIGN_BIT then more = false end -- positive terminator if v == 0 and b < SLEB_SIGN_BIT then more = false end -- positive terminator
@@ -869,21 +1175,26 @@ end
--- downstream `resolve_provenance_file_index(path)` consumers consult the map directly. --- downstream `resolve_provenance_file_index(path)` consumers consult the map directly.
--- ---
--- @param elf_path string -- absolute path to the post-link ELF (typically the gcc-emitted `.elf` BEFORE dwarf_injector's splice; both shapes work since the splice preserves `.debug_line`) --- @param elf_path string -- absolute path to the post-link ELF (typically the gcc-emitted `.elf` BEFORE dwarf_injector's splice; both shapes work since the splice preserves `.debug_line`)
--- @return table|nil, table|nil, table|nil --- @return table<string, integer>|nil
--- basename_to_index: { [basename] = 1-based-per-unit-file-index, ... } --- @return table<integer, string>|nil
--- basenames: { [1-based-per-unit-file-index] = basename, ... } --- @return table<integer, string>|nil
--- paths: { [1-based-per-unit-file-index] = full path (mixed slashes), ... }
function M.read_line_unit_file_table(elf_path) function M.read_line_unit_file_table(elf_path)
--- @type table<string, string>
local sections = M.read_elf_sections(elf_path, { ".debug_line", ".debug_line_str" }) local sections = M.read_elf_sections(elf_path, { ".debug_line", ".debug_line_str" })
--- @type string
local line = sections[".debug_line"] local line = sections[".debug_line"]
--- @type string
local lstr = sections[".debug_line_str"] or "" local lstr = sections[".debug_line_str"] or ""
if not line or line == "" then if not line or line == "" then
io.stderr:write("[elf_dwarf.read_line_unit_file_table] no .debug_line section in: " .. tostring(elf_path) .. "\n") io.stderr:write("[elf_dwarf.read_line_unit_file_table] no .debug_line section in: " .. tostring(elf_path) .. "\n")
return nil return nil
end end
--- @type table<integer, string> -- bag: 1-based file index -> basename
local basenames = {} local basenames = {}
--- @type table<string, integer> -- bag: basename -> 1-based file index
local basename_to_index = {} local basename_to_index = {}
--- @type table<integer, string> -- bag: 1-based file index -> full path
local paths = {} local paths = {}
--- Read one form-code's bytes from `buf` at position `p` according to `form`. --- Read one form-code's bytes from `buf` at position `p` according to `form`.
@@ -891,15 +1202,25 @@ function M.read_line_unit_file_table(elf_path)
--- * the resolved string (DW_FORM_line_strp / DW_FORM_string) --- * the resolved string (DW_FORM_line_strp / DW_FORM_string)
--- * the ULEB128 number (DW_FORM_udata) --- * the ULEB128 number (DW_FORM_udata)
--- * nil + skip-bytes (DW_FORM_data16; we don't surface the MD5) --- * nil + skip-bytes (DW_FORM_data16; we don't surface the MD5)
--- @param buf string
--- @param lstr_buf string
--- @param p integer
--- @param form integer
--- @return string|integer|nil
--- @return integer
local function read_form(buf, lstr_buf, p, form) local function read_form(buf, lstr_buf, p, form)
if form == M.DWARF5_DEBUG_LINE.form_line_strp then if form == M.DWARF5_DEBUG_LINE.form_line_strp then
--- @type integer
local strp = M.read_u32_le(buf, p) local strp = M.read_u32_le(buf, p)
--- @type integer
local end_pos = lstr_buf:find("\0", strp + 1, true) or (#lstr_buf + 1) local end_pos = lstr_buf:find("\0", strp + 1, true) or (#lstr_buf + 1)
return lstr_buf:sub(strp + 1, end_pos - 1), p + M.DWARF5_DEBUG_LINE.form_strp_bytes return lstr_buf:sub(strp + 1, end_pos - 1), p + M.DWARF5_DEBUG_LINE.form_strp_bytes
elseif form == M.DWARF5_DEBUG_LINE.form_string then elseif form == M.DWARF5_DEBUG_LINE.form_string then
--- @type integer
local nul = buf:find("\0", p + 1, true) or (#buf + 1) local nul = buf:find("\0", p + 1, true) or (#buf + 1)
return buf:sub(p + 1, nul - 1), nul return buf:sub(p + 1, nul - 1), nul
elseif form == M.DWARF5_DEBUG_LINE.form_udata then elseif form == M.DWARF5_DEBUG_LINE.form_udata then
--- @type integer|nil, integer
local v, after = M.read_uleb128_at(buf, p) local v, after = M.read_uleb128_at(buf, p)
return v, after return v, after
elseif form == M.DWARF5_DEBUG_LINE.form_data16 then elseif form == M.DWARF5_DEBUG_LINE.form_data16 then
@@ -916,34 +1237,51 @@ function M.read_line_unit_file_table(elf_path)
--- Parse one DWARF-version-3-style unit (DWARF3/4 line program; gcc default in the PS1 toolchain still emits DWARF3 for line programs in `-g` mode). --- Parse one DWARF-version-3-style unit (DWARF3/4 line program; gcc default in the PS1 toolchain still emits DWARF3 for line programs in `-g` mode).
--- Layout: null-terminated directory list, then path(null) + dir_idx(ULEB) + time(ULEB) + size(ULEB) file entries terminated by an empty null. --- Layout: null-terminated directory list, then path(null) + dir_idx(ULEB) + time(ULEB) + size(ULEB) file entries terminated by an empty null.
--- `content_start` = zero-based wire offset of the first byte of program-header content (after version + header_length fields). --- `content_start` = zero-based wire offset of the first byte of program-header content (after version + header_length fields).
--- @return unit_basenames { [idx_in_unit_1_based] = basename } --- @param buf string
--- @return unit_paths { [idx_in_unit_1_based] = full path } --- @param content_start integer
--- @param body_end integer
--- @return table<integer, string>
--- @return table<integer, string>
local function parse_dwarf3_unit(buf, content_start, body_end) local function parse_dwarf3_unit(buf, content_start, body_end)
--- @type integer
local up = content_start local up = content_start
-- 5 fixed bytes: min_insn, default_is, line_base (signed), line_range, opcode_base -- 5 fixed bytes: min_insn, default_is, line_base (signed), line_range, opcode_base
up = up + 5 up = up + 5
--- @type integer
local opcode_base = buf:byte(content_start + 5) local opcode_base = buf:byte(content_start + 5)
up = up + (opcode_base - 1) -- std_opcode_lengths up = up + (opcode_base - 1) -- std_opcode_lengths
--- @type string[]
local dirs = {} local dirs = {}
while up < body_end do while up < body_end do
--- @type integer
local nul = buf:find("\0", up + 1, true) or (body_end + 1) local nul = buf:find("\0", up + 1, true) or (body_end + 1)
if nul > body_end then break end if nul > body_end then break end
--- @type integer
local len = nul - up - 1 local len = nul - up - 1
if len == 0 then up = nul break end if len == 0 then up = nul break end
dirs[#dirs + 1] = buf:sub(up + 1, nul - 1) dirs[#dirs + 1] = buf:sub(up + 1, nul - 1)
up = nul up = nul
end end
--- @type table<integer, string> -- bag: 1-based unit file index -> basename
local unit_basenames = {} local unit_basenames = {}
--- @type table<integer, string> -- bag: 1-based unit file index -> full path
local unit_paths = {} local unit_paths = {}
while up < body_end do while up < body_end do
--- @type integer
local nul = buf:find("\0", up + 1, true) or (body_end + 1) local nul = buf:find("\0", up + 1, true) or (body_end + 1)
if nul > body_end or nul == up + 1 then up = nul break end if nul > body_end or nul == up + 1 then up = nul break end
--- @type string
local path = buf:sub(up + 1, nul - 1) local path = buf:sub(up + 1, nul - 1)
up = nul up = nul
--- @type integer|nil, integer
local didx, up_next = M.read_uleb128_at(buf, up); up = up_next local didx, up_next = M.read_uleb128_at(buf, up); up = up_next
--- @type integer|nil, integer
local _time, up_next2 = M.read_uleb128_at(buf, up); up = up_next2 local _time, up_next2 = M.read_uleb128_at(buf, up); up = up_next2
--- @type integer|nil, integer
local _size, up_next3 = M.read_uleb128_at(buf, up); up = up_next3 local _size, up_next3 = M.read_uleb128_at(buf, up); up = up_next3
--- @type integer
local idx = #unit_basenames + 1 local idx = #unit_basenames + 1
--- @type string
local bs = path:match("[^/\\]+$") or path local bs = path:match("[^/\\]+$") or path
unit_paths[idx] = path unit_paths[idx] = path
unit_basenames[idx] = bs unit_basenames[idx] = bs
@@ -957,25 +1295,42 @@ function M.read_line_unit_file_table(elf_path)
--- Parse one DWARF-version-5-style unit (DWARF5 line program; used by modern gcc with `-gdwarf-5`). --- Parse one DWARF-version-5-style unit (DWARF5 line program; used by modern gcc with `-gdwarf-5`).
--- `content_start` is the first byte of program-header content (after the 8 fixed bytes version+addr_size+seg_size+header_length). --- `content_start` is the first byte of program-header content (after the 8 fixed bytes version+addr_size+seg_size+header_length).
--- @return same shape as parse_dwarf3_unit --- @param buf string
--- @param lstr_buf string
--- @param content_start integer
--- @param body_end integer
--- @return table<integer, string>
--- @return table<integer, string>
local function parse_dwarf5_unit(buf, lstr_buf, content_start, body_end) local function parse_dwarf5_unit(buf, lstr_buf, content_start, body_end)
--- @type integer
local up = content_start local up = content_start
-- 6 fixed bytes: min_insn, max_ops_per_insn, default_is, line_base, line_range, opcode_base -- 6 fixed bytes: min_insn, max_ops_per_insn, default_is, line_base, line_range, opcode_base
up = up + 6 up = up + 6
--- @type integer
local opcode_base = buf:byte(content_start + 6) local opcode_base = buf:byte(content_start + 6)
up = up + (opcode_base - 1) -- std_opcode_lengths up = up + (opcode_base - 1) -- std_opcode_lengths
-- directories -- directories
--- @type integer|nil, integer
local dir_format_count, after = M.read_uleb128_at(buf, up); up = after local dir_format_count, after = M.read_uleb128_at(buf, up); up = after
--- @type integer[]
local dir_formats = {} local dir_formats = {}
--- @type integer
for i = 1, dir_format_count do for i = 1, dir_format_count do
--- @type integer|nil, integer
local f, a2 = M.read_uleb128_at(buf, up); up = a2 local f, a2 = M.read_uleb128_at(buf, up); up = a2
dir_formats[i] = f dir_formats[i] = f
end end
--- @type integer|nil, integer
local dir_count, a3 = M.read_uleb128_at(buf, up); up = a3 local dir_count, a3 = M.read_uleb128_at(buf, up); up = a3
--- @type string[]
local dirs = {} local dirs = {}
--- @type integer
for i = 1, dir_count do for i = 1, dir_count do
--- @type string
local combined = "" local combined = ""
--- @type integer
for j = 1, dir_format_count do for j = 1, dir_format_count do
--- @type string|integer|nil, integer
local v, a4 = read_form(buf, lstr_buf, up, dir_formats[j]) local v, a4 = read_form(buf, lstr_buf, up, dir_formats[j])
up = a4 up = a4
if j == 1 and type(v) == "string" then combined = v end if j == 1 and type(v) == "string" then combined = v end
@@ -983,25 +1338,39 @@ function M.read_line_unit_file_table(elf_path)
dirs[i] = combined dirs[i] = combined
end end
-- file names -- file names
--- @type integer|nil, integer
local file_format_count, after2 = M.read_uleb128_at(buf, up); up = after2 local file_format_count, after2 = M.read_uleb128_at(buf, up); up = after2
--- @type integer[]
local file_formats = {} local file_formats = {}
--- @type integer
for i = 1, file_format_count do for i = 1, file_format_count do
--- @type integer|nil, integer
local f, a2 = M.read_uleb128_at(buf, up); up = a2 local f, a2 = M.read_uleb128_at(buf, up); up = a2
file_formats[i] = f file_formats[i] = f
end end
--- @type integer|nil, integer
local file_count, a3 = M.read_uleb128_at(buf, up); up = a3 local file_count, a3 = M.read_uleb128_at(buf, up); up = a3
--- @type table<integer, string> -- bag: 1-based unit file index -> basename
local unit_basenames = {} local unit_basenames = {}
--- @type table<integer, string> -- bag: 1-based unit file index -> full path
local unit_paths = {} local unit_paths = {}
--- @type integer
for i = 1, file_count do for i = 1, file_count do
--- @type string
local combined = "" local combined = ""
--- @type integer
local didx = 0 local didx = 0
--- @type integer
for j = 1, file_format_count do for j = 1, file_format_count do
--- @type string|integer|nil, integer
local v, a4 = read_form(buf, lstr_buf, up, file_formats[j]) local v, a4 = read_form(buf, lstr_buf, up, file_formats[j])
up = a4 up = a4
if j == 1 and type(v) == "string" then combined = v end if j == 1 and type(v) == "string" then combined = v end
if j == 2 and type(v) == "number" then didx = v end if j == 2 and type(v) == "number" then didx = v end
end end
--- @type integer
local idx = #unit_basenames + 1 local idx = #unit_basenames + 1
--- @type string
local bs = combined:match("[^/\\]+$") or combined local bs = combined:match("[^/\\]+$") or combined
unit_paths[idx] = combined unit_paths[idx] = combined
unit_basenames[idx] = bs unit_basenames[idx] = bs
@@ -1013,26 +1382,36 @@ function M.read_line_unit_file_table(elf_path)
end end
--- Walk every line-program unit in the section. --- Walk every line-program unit in the section.
--- @type integer
local p = 0 local p = 0
--- @type integer
local section_end = #line local section_end = #line
while p + 4 <= section_end do while p + 4 <= section_end do
--- @type integer
local unit_length = M.read_u32_le(line, p) local unit_length = M.read_u32_le(line, p)
if unit_length == 0xFFFFFFFF then if unit_length == 0xFFFFFFFF then
io.stderr:write("[elf_dwarf.read_line_unit_file_table] 64-bit DWARF (initial-length 0xFFFFFFFF); not supported\n") io.stderr:write("[elf_dwarf.read_line_unit_file_table] 64-bit DWARF (initial-length 0xFFFFFFFF); not supported\n")
return nil return nil
end end
--- @type integer
local body_start = p + 4 local body_start = p + 4
--- @type integer
local body_end = p + 4 + unit_length local body_end = p + 4 + unit_length
if body_end > section_end then break end if body_end > section_end then break end
--- @type integer
local version = M.read_u16_le(line, body_start) local version = M.read_u16_le(line, body_start)
--- @type table<integer, string>|nil, table<integer, string>|nil
local unit_basenames, unit_paths local unit_basenames, unit_paths
if version >= 5 then if version >= 5 then
-- DWARF5 header: version(2) + addr_size(1) + seg_size(1) + header_length(4) + content -- DWARF5 header: version(2) + addr_size(1) + seg_size(1) + header_length(4) + content
--- @type integer
local header_length_offset = body_start + 6 -- past version(2) + addr_size(1) + seg_size(1) - wait that's wrong; past hdr len is at +6 local header_length_offset = body_start + 6 -- past version(2) + addr_size(1) + seg_size(1) - wait that's wrong; past hdr len is at +6
--- @type integer
local content_start = body_start + 8 -- past version(2) + addr_size(1) + seg_size(1) + header_length(4) local content_start = body_start + 8 -- past version(2) + addr_size(1) + seg_size(1) + header_length(4)
unit_basenames, unit_paths = parse_dwarf5_unit(line, lstr, content_start, body_end) unit_basenames, unit_paths = parse_dwarf5_unit(line, lstr, content_start, body_end)
elseif version >= 2 then elseif version >= 2 then
-- DWARF2/3/4 header: version(2) + header_length(4) + content -- DWARF2/3/4 header: version(2) + header_length(4) + content
--- @type integer
local content_start = body_start + 6 -- past version(2) + header_length(4) local content_start = body_start + 6 -- past version(2) + header_length(4)
unit_basenames, unit_paths = parse_dwarf3_unit(line, content_start, body_end) unit_basenames, unit_paths = parse_dwarf3_unit(line, content_start, body_end)
else else
@@ -1047,6 +1426,7 @@ function M.read_line_unit_file_table(elf_path)
-- For DWARF5 (crt0.s + C unit), each carries its own per-unit file-table map; -- For DWARF5 (crt0.s + C unit), each carries its own per-unit file-table map;
-- the atom-side DW_LNS_set_file(N) refers to the C unit's indices, NOT crt0.s's. -- the atom-side DW_LNS_set_file(N) refers to the C unit's indices, NOT crt0.s's.
-- Since the C unit is the one with full include_directories + 12 entries, we can use it directly. -- Since the C unit is the one with full include_directories + 12 entries, we can use it directly.
--- @type integer, string
for idx, bs in pairs(unit_basenames) do for idx, bs in pairs(unit_basenames) do
basenames[idx] = bs basenames[idx] = bs
paths[idx] = unit_paths[idx] paths[idx] = unit_paths[idx]
+112 -56
View File
@@ -10,7 +10,9 @@
-- Bootstrap follows the entry scripts; `scripts/duffle_paths.lua` sets package.path and package.cpath. See `ps1_meta.lua` for the rationale. -- Bootstrap follows the entry scripts; `scripts/duffle_paths.lua` sets package.path and package.cpath. See `ps1_meta.lua` for the rationale.
-- `debug.getinfo(1, "S").source` locates this file for standalone and orchestrated runs, then `duffle_paths.lua` returns the loaded `duffle` module. -- `debug.getinfo(1, "S").source` locates this file for standalone and orchestrated runs, then `duffle_paths.lua` returns the loaded `duffle` module.
--- @type string
local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./" local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./"
--- @type DuffleExport
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
-- The annotation pass reads the source-derived registries from scan_source: -- The annotation pass reads the source-derived registries from scan_source:
@@ -21,28 +23,8 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
-- Type declarations -- Type declarations
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
--- @class SourceFile -- SourceFile, PassCtx, PassResult, PassShared, Corpus: see ps1_meta.lua
--- @field path string -- Absolute path to the source file -- SourceScan, AtomEntry, BindsEntry, RegTypeDefault, AtomViewEntry: see scan_source.lua
--- @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[]
--- @field metadata_path string
--- @field shared table
--- @field shared.word_counts table<string, integer>
--- @field out_root string
--- @field project_root string
--- @field upstream table<string, table>
--- @field flags table
--- @field verbose boolean
--- @class PassResult
--- @field outputs table[]
--- @field errors table[]
--- @field warnings table[]
--- @class AtomAnnotation --- @class AtomAnnotation
--- @field atom_name string -- Atom name (scan.atom_infos row) --- @field atom_name string -- Atom name (scan.atom_infos row)
@@ -52,43 +34,51 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
--- @field writes string[] -- R_* names (write targets) --- @field writes string[] -- R_* names (write targets)
--- @field errors string[]|nil -- Parse-time errors from scan_source (atom_info body malformed) --- @field errors string[]|nil -- Parse-time errors from scan_source (atom_info body malformed)
--- @class DebugSkipMarker -- Sub-shape of scan_source.lua's @class DebugSkipMarker --- @class RegTypeOccurrence
--- @field marker_kind string -- Exact marker ident read from source. Only "atom_dbg_skip" (bare) is positive. --- @field reg string
--- @field marker_line integer --- @field type_name string
--- @field args string|nil -- Trimmed text inside the parens (nil when has_parens is false) --- @field source_line integer
--- @field has_parens boolean
--- @field is_bare boolean -- true iff marker_kind == "atom_dbg_skip" AND has_parens == false (the only positive form)
--- @field pending boolean -- true while awaiting the following declaration
--- @field superseded_by_marker_line integer|nil -- Set on a marker that was bumped out of the pending slot
--- @field target_kind string|nil -- "atom" | "comp_bare" | "comp_proc" | "unrelated" once observed
--- @class Finding
--- @field line integer -- Source line (or 0 for pass-level)
--- @field msg string -- Finding message
--- @class Findings --- @class Findings
--- @field errors Finding[] --- @field errors PassFinding[]
--- @field warnings Finding[] --- @field warnings PassFinding[]
--- @field info Finding[] --- @field info PassFinding[]
--- @class PipeCtx --- @class PipeCtx
--- @field atom_index table<string, AtomEntry> -- raw_name or name -> scan.atoms row (kind atom/atom_proc) --- @field atom_index table<string, AtomEntry> -- raw_name or name -> scan.atoms row (kind atom/atom_proc)
--- @field binds_index table<string, BindsStruct> -- Name -> BindsStruct --- @field binds_index table<string, BindsEntry>
--- @field annot_counts table<string, integer> -- Name -> annotation count (for unique_annotation check) --- @field annot_counts table<string, integer> -- bag: atom name -> annotation count
--- @field types table<string, RegTypeDefault> -- From scan_source --- @field types table<string, RegTypeDefault>
--- @field atom_views table<string, AtomViewEntry> -- From scan_source --- @field atom_views table<string, AtomViewEntry>
--- @field seen_defaults table<string, integer> -- Duplicate atom_dbg_reg_default detection --- @field seen_defaults table<string, integer> -- bag: register ident -> occurrence count
--- @field seen_field table<string, integer> -- Binds_* -> count of fields (set/checked by check_binds_no_duplicate_fields) --- @field seen_field table<string, integer> -- bag: leftover field-count slot
--- @field _scan SourceScan -- Full scan payload (typed-view sub-calls live here) --- @field _scan SourceScan
--- @field word_counts WordCounts|nil
--- @field register_alias_registry table<string, AliasEntry>|nil
--- @field type_name_registry table<string, TypeNameEntry>|nil
--- @field type_occurrences RegTypeOccurrence[]|nil
--- @field atom_infos_list AtomInfoEntry[]|nil
--- @field binds_list BindsEntry[]|nil
--- @class AnnotatedResult --- @class AnnotatedResult
--- @field atoms AtomEntry[] --- @field atoms AtomEntry[]
--- @field annots AtomAnnotation[] --- @field annots AtomAnnotation[]
--- @field macros MacroEntry[] --- @field macros MacroEntry[]
--- @field binds BindsEntry[] --- @field binds BindsEntry[]
--- @field errors Finding[] --- @field errors PassFinding[]
--- @field warnings Finding[] --- @field warnings PassFinding[]
--- @field info Finding[] --- @field info PassFinding[]
--- @field source string|nil
--- @class CheckRule
--- @field per_annot (fun(item: AtomAnnotation, pipe_ctx: PipeCtx, findings: Findings): nil)|nil
--- @class SourceScan
--- @field type_occurrences RegTypeOccurrence[]|nil
--- @class AnnotationPass
--- @field validate fun(ctx: PassCtx, src: SourceFile, corpus_pipe_ctx: PipeCtx|nil): AnnotatedResult
--- @field run fun(ctx: PassCtx): PassResult
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Per-check functions (the CHECK_RULES table's payload) -- Per-check functions (the CHECK_RULES table's payload)
@@ -100,6 +90,7 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
--- @param info AtomAnnotation --- @param info AtomAnnotation
--- @param pipe_ctx PipeCtx --- @param pipe_ctx PipeCtx
--- @param findings Findings --- @param findings Findings
--- @return nil
local function check_atom_decl_exists(info, pipe_ctx, findings) local function check_atom_decl_exists(info, pipe_ctx, findings)
if not pipe_ctx.atom_index[info.atom_name] then if not pipe_ctx.atom_index[info.atom_name] then
findings.errors[#findings.errors + 1] = { findings.errors[#findings.errors + 1] = {
@@ -111,9 +102,12 @@ end
--- Check: Every atom may have AT MOST ONE annotation. --- Check: Every atom may have AT MOST ONE annotation.
--- Post-loop: Needs full-corpus `annot_counts` from pipe_ctx. --- Post-loop: Needs full-corpus `annot_counts` from pipe_ctx.
--- @param _item AtomAnnotation|nil
--- @param pipe_ctx PipeCtx --- @param pipe_ctx PipeCtx
--- @param findings Findings --- @param findings Findings
--- @return nil
local function check_unique_annotation(_item, pipe_ctx, findings) local function check_unique_annotation(_item, pipe_ctx, findings)
--- @type string, integer
for name, n in pairs(pipe_ctx.annot_counts) do for name, n in pairs(pipe_ctx.annot_counts) do
if n > 1 then if n > 1 then
findings.errors[#findings.errors + 1] = { findings.errors[#findings.errors + 1] = {
@@ -129,6 +123,7 @@ end
--- @param info AtomAnnotation --- @param info AtomAnnotation
--- @param pipe_ctx PipeCtx --- @param pipe_ctx PipeCtx
--- @param findings Findings --- @param findings Findings
--- @return nil
local function check_binds_struct_exists(info, pipe_ctx, findings) local function check_binds_struct_exists(info, pipe_ctx, findings)
if not info.binds then return end if not info.binds then return end
if pipe_ctx.binds_index[info.binds] then return end if pipe_ctx.binds_index[info.binds] then return end
@@ -142,11 +137,14 @@ end
--- Check: TAPE_WORDS(mac_X, N) ↔ WORD_COUNT(mac_X, N) drift. --- Check: TAPE_WORDS(mac_X, N) ↔ WORD_COUNT(mac_X, N) drift.
--- Three outcomes: missing (error), mismatch (error), match (info). --- Three outcomes: missing (error), mismatch (error), match (info).
--- @param m MacroEntry --- @param m MacroEntry
--- @param wc table<string, integer> -- Shared word-count table (from ctx.shared.word_counts) --- @param pipe_ctx PipeCtx
--- @param findings Findings --- @param findings Findings
--- @return nil
local function check_macro_word_drift(m, pipe_ctx, findings) local function check_macro_word_drift(m, pipe_ctx, findings)
--- @type WordCounts
local wc = (pipe_ctx and pipe_ctx.word_counts) or {} local wc = (pipe_ctx and pipe_ctx.word_counts) or {}
--- @type integer|nil
local declared = wc[m.name] local declared = wc[m.name]
if not declared then if not declared then
findings.errors[#findings.errors + 1] = { findings.errors[#findings.errors + 1] = {
@@ -173,9 +171,12 @@ end
--- @param _src SourceFile -- unused (kept for the per_source shape) --- @param _src SourceFile -- unused (kept for the per_source shape)
--- @param pipe_ctx PipeCtx --- @param pipe_ctx PipeCtx
--- @param findings Findings --- @param findings Findings
--- @return nil
local function check_semantic_reg_defaults(_src, pipe_ctx, findings) local function check_semantic_reg_defaults(_src, pipe_ctx, findings)
-- Detect duplicate defaults using the ordered occurrence list (the out.types hash only retains the last declaration). -- Detect duplicate defaults using the ordered occurrence list (the out.types hash only retains the last declaration).
--- @type table<string, integer> -- bag: register ident -> first source line
local seen_first_line = {} local seen_first_line = {}
--- @type integer, RegTypeOccurrence
for _, occ in ipairs(pipe_ctx.type_occurrences or {}) do for _, occ in ipairs(pipe_ctx.type_occurrences or {}) do
if seen_first_line[occ.reg] == nil then if seen_first_line[occ.reg] == nil then
seen_first_line[occ.reg] = occ.source_line seen_first_line[occ.reg] = occ.source_line
@@ -188,8 +189,11 @@ local function check_semantic_reg_defaults(_src, pipe_ctx, findings)
} }
end end
end end
--- @type table<string, AliasEntry>
local reg_registry = pipe_ctx.register_alias_registry or {} local reg_registry = pipe_ctx.register_alias_registry or {}
--- @type table<string, TypeNameEntry>
local type_registry = pipe_ctx.type_name_registry or {} local type_registry = pipe_ctx.type_name_registry or {}
--- @type string, RegTypeDefault
for reg, def in pairs(pipe_ctx.types or {}) do for reg, def in pairs(pipe_ctx.types or {}) do
if not reg_registry[reg] then if not reg_registry[reg] then
findings.errors[#findings.errors + 1] = { findings.errors[#findings.errors + 1] = {
@@ -223,11 +227,16 @@ end
--- @param _src SourceFile --- @param _src SourceFile
--- @param pipe_ctx PipeCtx --- @param pipe_ctx PipeCtx
--- @param findings Findings --- @param findings Findings
--- @return nil
local function check_atom_reg_types(_src, pipe_ctx, findings) local function check_atom_reg_types(_src, pipe_ctx, findings)
--- @type table<string, AliasEntry>
local reg_registry = pipe_ctx.register_alias_registry or {} local reg_registry = pipe_ctx.register_alias_registry or {}
--- @type table<string, TypeNameEntry>
local type_registry = pipe_ctx.type_name_registry or {} local type_registry = pipe_ctx.type_name_registry or {}
--- @type integer, AtomInfoEntry
for _, ai in ipairs(pipe_ctx.atom_infos_list or {}) do for _, ai in ipairs(pipe_ctx.atom_infos_list or {}) do
if ai.reg_type_overrides then if ai.reg_type_overrides then
--- @type string, RegTypeOverride
for reg, ov in pairs(ai.reg_type_overrides) do for reg, ov in pairs(ai.reg_type_overrides) do
if not reg_registry[reg] then if not reg_registry[reg] then
findings.errors[#findings.errors + 1] = { findings.errors[#findings.errors + 1] = {
@@ -254,11 +263,14 @@ end
--- @param _src SourceFile --- @param _src SourceFile
--- @param pipe_ctx PipeCtx --- @param pipe_ctx PipeCtx
--- @param findings Findings --- @param findings Findings
--- @return nil
local function check_atom_view_layout(_src, pipe_ctx, findings) local function check_atom_view_layout(_src, pipe_ctx, findings)
--- @type string, AtomViewEntry
for atom_name, view in pairs(pipe_ctx.atom_views or {}) do for atom_name, view in pairs(pipe_ctx.atom_views or {}) do
if not view.binds_name then if not view.binds_name then
-- The atom had atom_reg_types but no atom_view; no layout check needed. -- The atom had atom_reg_types but no atom_view; no layout check needed.
else else
--- @type BindsEntry|nil
local bs = pipe_ctx.binds_index[view.binds_name] local bs = pipe_ctx.binds_index[view.binds_name]
if not bs then if not bs then
findings.errors[#findings.errors + 1] = { findings.errors[#findings.errors + 1] = {
@@ -280,15 +292,20 @@ local function check_atom_view_layout(_src, pipe_ctx, findings)
end end
--- Check: Binds_* structs require unique field names because atom_view uses those names for typed-field lookup in gdb. --- Check: Binds_* structs require unique field names because atom_view uses those names for typed-field lookup in gdb.
--- @param _src SourceFile --- @param _src SourceFile
--- @param pipe_ctx PipeCtx --- @param pipe_ctx PipeCtx
--- @param findings Findings --- @param findings Findings
--- @return nil
local function check_binds_no_duplicate_fields(_src, pipe_ctx, findings) local function check_binds_no_duplicate_fields(_src, pipe_ctx, findings)
--- @type integer, BindsEntry
for _, bs in ipairs(pipe_ctx.binds_list or {}) do for _, bs in ipairs(pipe_ctx.binds_list or {}) do
--- @type table<string, integer> -- bag: field name -> occurrence count
local seen = {} local seen = {}
--- @type integer, TypeField
for _, f in ipairs(bs.fields or {}) do for _, f in ipairs(bs.fields or {}) do
seen[f.name] = (seen[f.name] or 0) + 1 seen[f.name] = (seen[f.name] or 0) + 1
end end
--- @type string, integer
for name, count in pairs(seen) do for name, count in pairs(seen) do
if count > 1 then if count > 1 then
findings.errors[#findings.errors + 1] = { findings.errors[#findings.errors + 1] = {
@@ -312,11 +329,14 @@ end
--- 5. pending + no target_kind -> dangling (no following declaration) --- 5. pending + no target_kind -> dangling (no following declaration)
--- 6. unsupported target_kind -> marker precedes an unrelated declaration --- 6. unsupported target_kind -> marker precedes an unrelated declaration
--- Valid markers stamp `debug_skip` on whole-atom, bare-component, and proc-component declaration records in scan_source.lua. --- Valid markers stamp `debug_skip` on whole-atom, bare-component, and proc-component declaration records in scan_source.lua.
--- @param marker DebugSkipMarker --- @param marker DebugSkipMarker
--- @param _pipe_ctx PipeCtx -- Unused; kept for consistency with per_annot --- @param _pipe_ctx PipeCtx -- Unused; kept for consistency with per_annot
--- @param findings Findings --- @param findings Findings
--- @return nil
local function check_skip_marker(marker, _pipe_ctx, findings) local function check_skip_marker(marker, _pipe_ctx, findings)
--- @type string
local kind = marker.marker_kind local kind = marker.marker_kind
--- @type integer
local line = marker.marker_line local line = marker.marker_line
-- Left `scan.debug_skip_markers` with production records for `atom_dbg_skip` only; other identifiers take the walker's unrelated branch. -- Left `scan.debug_skip_markers` with production records for `atom_dbg_skip` only; other identifiers take the walker's unrelated branch.
@@ -372,12 +392,16 @@ end
--- @param _src SourceFile --- @param _src SourceFile
--- @param pipe_ctx PipeCtx --- @param pipe_ctx PipeCtx
--- @param findings Findings --- @param findings Findings
--- @return nil
local function check_wave_context_migration(_src, pipe_ctx, findings) local function check_wave_context_migration(_src, pipe_ctx, findings)
if not (pipe_ctx.types and next(pipe_ctx.types)) then return end if not (pipe_ctx.types and next(pipe_ctx.types)) then return end
if not (pipe_ctx.atom_infos_list) then return end if not (pipe_ctx.atom_infos_list) then return end
--- @type table<string, AliasEntry>
local reg_registry = pipe_ctx.register_alias_registry or {} local reg_registry = pipe_ctx.register_alias_registry or {}
--- @type integer, AtomInfoEntry
for _, ai in ipairs(pipe_ctx.atom_infos_list) do for _, ai in ipairs(pipe_ctx.atom_infos_list) do
if ai.reg_type_overrides then if ai.reg_type_overrides then
--- @type string, RegTypeOverride
for reg, _ in pairs(ai.reg_type_overrides) do for reg, _ in pairs(ai.reg_type_overrides) do
if not reg_registry[reg] then if not reg_registry[reg] then
findings.warnings[#findings.warnings + 1] = { findings.warnings[#findings.warnings + 1] = {
@@ -405,6 +429,7 @@ end
-- --
-- Adding a new check = 1 row here + 1 function above. The `validate()` dispatch loop never needs editing. -- Adding a new check = 1 row here + 1 function above. The `validate()` dispatch loop never needs editing.
--- @type CheckRule[]
local CHECK_RULES = { local CHECK_RULES = {
{ name = "atom_decl_exists", per_annot = check_atom_decl_exists }, { name = "atom_decl_exists", per_annot = check_atom_decl_exists },
{ name = "binds_struct_exists", per_annot = check_binds_struct_exists }, { name = "binds_struct_exists", per_annot = check_binds_struct_exists },
@@ -428,8 +453,11 @@ local CHECK_RULES = {
--- @param ctx PassCtx --- @param ctx PassCtx
--- @return PipeCtx --- @return PipeCtx
local function build_corpus_pipe_ctx(ctx) local function build_corpus_pipe_ctx(ctx)
--- @type PipeCtx
local view = duffle.corpus_view(ctx) local view = duffle.corpus_view(ctx)
--- @type table<string, integer> -- bag: atom name -> annotation count
local annot_counts = {} local annot_counts = {}
--- @type integer, AtomInfoEntry
for _, info in ipairs(view.atom_infos) do for _, info in ipairs(view.atom_infos) do
if info and info.atom_name then if info and info.atom_name then
annot_counts[info.atom_name] = (annot_counts[info.atom_name] or 0) + 1 annot_counts[info.atom_name] = (annot_counts[info.atom_name] or 0) + 1
@@ -448,12 +476,16 @@ end
--- @return AnnotatedResult --- @return AnnotatedResult
local function validate(ctx, src, corpus_pipe_ctx) local function validate(ctx, src, corpus_pipe_ctx)
corpus_pipe_ctx = corpus_pipe_ctx or build_corpus_pipe_ctx(ctx) corpus_pipe_ctx = corpus_pipe_ctx or build_corpus_pipe_ctx(ctx)
--- @type SourceScan
local scan = src.scan local scan = src.scan
-- Build a per-source pipe_ctx: shared lookups come from `corpus_pipe_ctx`, while declarations, bodies, types, views, defaults, and occurrences come from `src.scan`. -- Build a per-source pipe_ctx: shared lookups come from `corpus_pipe_ctx`, while declarations, bodies, types, views, defaults, and occurrences come from `src.scan`.
--- @type table<string, integer> -- bag: register ident -> occurrence count
local seen_defaults = {}; for reg, _ in pairs (scan.types or {}) do seen_defaults[reg] = (seen_defaults[reg] or 0) + 1 end local seen_defaults = {}; for reg, _ in pairs (scan.types or {}) do seen_defaults[reg] = (seen_defaults[reg] or 0) + 1 end
--- @type AtomInfoEntry[]
local atom_infos_list = {}; for _, ai in ipairs(scan.atom_infos or {}) do atom_infos_list[#atom_infos_list + 1] = ai end local atom_infos_list = {}; for _, ai in ipairs(scan.atom_infos or {}) do atom_infos_list[#atom_infos_list + 1] = ai end
--- @type PipeCtx
local pipe_ctx = { local pipe_ctx = {
atom_index = {}, atom_index = {},
binds_index = {}, binds_index = {},
@@ -468,22 +500,28 @@ local function validate(ctx, src, corpus_pipe_ctx)
register_alias_registry = corpus_pipe_ctx.register_alias_registry, register_alias_registry = corpus_pipe_ctx.register_alias_registry,
type_name_registry = corpus_pipe_ctx.type_name_registry, type_name_registry = corpus_pipe_ctx.type_name_registry,
} }
--- @type AtomEntry[]
local atoms = {} local atoms = {}
--- @type integer, AtomEntry
for _, a in ipairs(scan.atoms) do for _, a in ipairs(scan.atoms) do
if a.kind == "atom" or a.kind == "atom_proc" then if a.kind == "atom" or a.kind == "atom_proc" then
atoms[#atoms + 1] = a atoms[#atoms + 1] = a
pipe_ctx.atom_index[a.raw_name or a.name] = a pipe_ctx.atom_index[a.raw_name or a.name] = a
end end
end end
--- @type integer, BindsEntry
for _, b in ipairs(scan.binds) do pipe_ctx.binds_index[b.name] = b end for _, b in ipairs(scan.binds) do pipe_ctx.binds_index[b.name] = b end
-- Findings live in a single struct with three lists (errors / warnings / info). -- Findings live in a single struct with three lists (errors / warnings / info).
-- Each check writes to the list appropriate for its severity. -- Each check writes to the list appropriate for its severity.
--- @type Findings
local findings = { errors = {}, warnings = {}, info = {} } local findings = { errors = {}, warnings = {}, info = {} }
-- Lift parse-time errors already recorded in scan_source's atom_info payload into this pass's findings list. -- Lift parse-time errors already recorded in scan_source's atom_info payload into this pass's findings list.
--- @type integer, AtomInfoEntry
for _, info in ipairs(scan.atom_infos) do for _, info in ipairs(scan.atom_infos) do
if info.errors then if info.errors then
--- @type integer, string
for _, msg in ipairs(info.errors) do for _, msg in ipairs(info.errors) do
findings.errors[#findings.errors + 1] = { findings.errors[#findings.errors + 1] = {
line = info.info_line, line = info.info_line,
@@ -494,6 +532,7 @@ local function validate(ctx, src, corpus_pipe_ctx)
end end
-- THE per-annotation pipeline. ONE loop. CHECK_RULES dispatches per_annot rules. -- THE per-annotation pipeline. ONE loop. CHECK_RULES dispatches per_annot rules.
--- @type integer, AtomInfoEntry
for _, info in ipairs(scan.atom_infos) do for _, info in ipairs(scan.atom_infos) do
duffle.run_check_rules(CHECK_RULES, "per_annot", info, pipe_ctx, findings) duffle.run_check_rules(CHECK_RULES, "per_annot", info, pipe_ctx, findings)
end end
@@ -503,13 +542,16 @@ local function validate(ctx, src, corpus_pipe_ctx)
-- scan_source records each marker in scan.debug_skip_markers; this loop validates each record independently and emits at most one error per marker. -- scan_source records each marker in scan.debug_skip_markers; this loop validates each record independently and emits at most one error per marker.
-- Valid markers stamp `debug_skip = true` on the following atom or component declaration, which downstream consumers read directly. -- Valid markers stamp `debug_skip = true` on the following atom or component declaration, which downstream consumers read directly.
--- @type DebugSkipMarker[]
local skip_markers = scan.debug_skip_markers or {} local skip_markers = scan.debug_skip_markers or {}
--- @type integer, DebugSkipMarker
for _, marker in ipairs(skip_markers) do for _, marker in ipairs(skip_markers) do
duffle.run_check_rules(CHECK_RULES, "per_skip_marker", marker, pipe_ctx, findings) duffle.run_check_rules(CHECK_RULES, "per_skip_marker", marker, pipe_ctx, findings)
end end
-- Per-macro rules (TAPE_WORDS vs WORD_COUNT drift). -- Per-macro rules (TAPE_WORDS vs WORD_COUNT drift).
pipe_ctx.word_counts = corpus_pipe_ctx.word_counts pipe_ctx.word_counts = corpus_pipe_ctx.word_counts
--- @type integer, MacroEntry
for _, m in ipairs(scan.macros) do for _, m in ipairs(scan.macros) do
duffle.run_check_rules(CHECK_RULES, "per_macro", m, pipe_ctx, findings) duffle.run_check_rules(CHECK_RULES, "per_macro", m, pipe_ctx, findings)
end end
@@ -540,8 +582,7 @@ end
-- M.run — orchestrator entry -- M.run — orchestrator entry
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
--- @class M --- @type AnnotationPass
local M = {} local M = {}
-- Expose `validate` for downstream passes (e.g. report.lua) that need to re-render the per-source results into a per-MODULE report. -- Expose `validate` for downstream passes (e.g. report.lua) that need to re-render the per-source results into a per-MODULE report.
@@ -550,31 +591,46 @@ M.validate = validate
--- @param ctx PassCtx --- @param ctx PassCtx
--- @return PassResult --- @return PassResult
function M.run(ctx) function M.run(ctx)
--- @type PassOutputEntry[]
local outputs = {} local outputs = {}
--- @type PassFinding[]
local errors = {} local errors = {}
--- @type PassFinding[]
local warnings = {} local warnings = {}
-- Build the shared pipe_ctx once for this run; every validate() call sees the same cross-source registries. -- Build the shared pipe_ctx once for this run; every validate() call sees the same cross-source registries.
-- The corpus owns the canonical cross-source registries; per-source scans retain body / declaration ownership. -- The corpus owns the canonical cross-source registries; per-source scans retain body / declaration ownership.
--- @type PipeCtx
local corpus_pipe_ctx = build_corpus_pipe_ctx(ctx) local corpus_pipe_ctx = build_corpus_pipe_ctx(ctx)
--- @type Corpus
local corpus = ctx.shared.corpus local corpus = ctx.shared.corpus
-- Group `corpus.sources_by_dir` by module, validate every source in each bucket, and emit one errors.h per directory. -- Group `corpus.sources_by_dir` by module, validate every source in each bucket, and emit one errors.h per directory.
--- @type table<string, SourceFile[]>
local by_dir = (corpus and corpus.sources_by_dir) or {} local by_dir = (corpus and corpus.sources_by_dir) or {}
--- @type string, SourceFile[]
for dir, dir_sources in pairs(by_dir) do for dir, dir_sources in pairs(by_dir) do
--- @type string
local dir_basename = dir:match("([^/\\]+)$") or dir local dir_basename = dir:match("([^/\\]+)$") or dir
--- @type integer
local dir_atoms = 0 local dir_atoms = 0
--- @type PassFinding[]
local dir_errors = {} local dir_errors = {}
--- @type PassFinding[]
local dir_warnings = {} local dir_warnings = {}
--- @type integer, SourceFile
for _, src in ipairs(dir_sources) do for _, src in ipairs(dir_sources) do
--- @type AnnotatedResult
local result = validate(ctx, src, corpus_pipe_ctx) local result = validate(ctx, src, corpus_pipe_ctx)
result.source = src.path -- tag for downstream rendering result.source = src.path -- tag for downstream rendering
dir_atoms = dir_atoms + #result.atoms dir_atoms = dir_atoms + #result.atoms
--- @type integer, PassFinding
for _, e in ipairs(result.errors) do for _, e in ipairs(result.errors) do
dir_errors[#dir_errors + 1] = { line = e.line, msg = e.msg, source = src.path } 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 end
--- @type integer, PassFinding
for _, w in ipairs(result.warnings) do for _, w in ipairs(result.warnings) do
dir_warnings[#dir_warnings + 1] = { line = w.line, msg = w.msg } 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 }
+149 -26
View File
@@ -37,8 +37,11 @@
-- Bootstrap: load `duffle_paths.lua` via `debug.getinfo(1, "S").source` -- 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")` -- (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. -- at the bottom, so the dofile value IS the duffle module.
--- @type string
local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./" local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./"
--- @type DuffleExport
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
--- @type ElfDwarfMod
local elf_dwarf = require("elf_dwarf") local elf_dwarf = require("elf_dwarf")
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -47,6 +50,7 @@ local elf_dwarf = require("elf_dwarf")
-- Format version emitted as the first line. Bump + add a migration test if the format changes; -- Format version emitted as the first line. Bump + add a migration test if the format changes;
-- the gdb runtime loader rejects mismatches (E2). -- the gdb runtime loader rejects mismatches (E2).
--- @type integer
local FORMAT_VERSION = 1 local FORMAT_VERSION = 1
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -54,29 +58,71 @@ local FORMAT_VERSION = 1
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
--- @class AtomSourceMapCtx --- @class AtomSourceMapCtx
--- @field shared table -- `ctx.shared` --- @field shared PassShared
--- @field shared.corpus table -- source-order registry; single writer is build_ctx --- @field out_root string
--- @field shared.word_counts table --- @field flags PassFlags
--- @field out_root string -- output root (e.g. "build/gen") --- @field project_root string|nil
--- @field flags table -- `ctx.flags`; reads `flags.gdb_runtime` + `flags.elf_path`
--- @class WordMapEntry
--- @field pos integer
--- @field line integer
--- @field text string
--- @field body_line integer
--- @field gpr_keys string[]|nil
--- @field invocation InvocationRecord|nil
--- @class NmAddr
--- @field [1] integer -- st_value
--- @field [2] integer -- st_size
--- @class GdbAtomRecord
--- @field idx integer|nil
--- @field name string
--- @field src_path string
--- @field file_base string
--- @field addr integer
--- @field size_bytes integer
--- @field words integer
--- @field entries WordMapEntry[]
--- @class ElfDwarfMod
--- @field read_nm fun(elf_path: Path): table<string, NmAddr>
--- @class AtomSourceMapPass
--- @field render_source_map fun(src: SourceFile): string
--- @field render_provenance fun(src: SourceFile, wc: WordCounts): string
--- @field render_atom_source_map fun(atom: AtomEntry): string
--- @field render_atom_provenance fun(atom: AtomEntry, wc: WordCounts, rel_path: string): string
--- @field run fun(ctx: PassCtx): PassResult
--- @class AtomEntry
--- @field paths AtomPaths|nil
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Atom-path renderers -- Atom-path renderers
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
--- Join word boundaries (from `items`) to per-word call text + source lines (from `word_events`). --- Join word boundaries (from `items`) to per-word call text + source lines (from `word_events`).
--- @param atom table --- @param atom AtomEntry
--- @return table[], integer --- @return WordMapEntry[]
--- @return integer
local function canonical_word_entries(atom) local function canonical_word_entries(atom)
--- @type AtomPaths
local paths = atom.paths or {} local paths = atom.paths or {}
--- @type WordEvent[]
local events = paths.word_events or {} local events = paths.word_events or {}
--- @type EmissionItem[]
local word_items = {} local word_items = {}
--- @type integer, EmissionItem
for _, item in ipairs(paths.items or {}) do for _, item in ipairs(paths.items or {}) do
if item.kind == "word" then word_items[#word_items + 1] = item end if item.kind == "word" then word_items[#word_items + 1] = item end
end end
--- @type WordMapEntry[]
local entries = {} local entries = {}
--- @type integer, WordEvent
for index, event in ipairs(events) do for index, event in ipairs(events) do
--- @type EmissionItem
local item = word_items[index] or {} local item = word_items[index] or {}
entries[#entries + 1] = { entries[#entries + 1] = {
pos = event.i or (index - 1), pos = event.i or (index - 1),
@@ -97,18 +143,25 @@ end
--- `WORD N CALL <src-path>:<src-line> RAW` (raw `.word` outside any mac_* component) --- `WORD N CALL <src-path>:<src-line> RAW` (raw `.word` outside any mac_* component)
--- Component identity comes from the outermost invocation record; the count-table lookup confirms the component was declared in `corpus.word_counts` --- Component identity comes from the outermost invocation record; the count-table lookup confirms the component was declared in `corpus.word_counts`
--- (populated by word_count_eval + components passes). --- (populated by word_count_eval + components passes).
--- @param src table --- @param src SourceFile
--- @param atom table --- @param atom AtomEntry
--- @param wc table -- identity alias of corpus.word_counts --- @param wc WordCounts
--- @return string[], integer --- @return string[]
--- @return integer
local function emit_provenance_stanza(src, atom, wc) local function emit_provenance_stanza(src, atom, wc)
--- @type string[]
local lines = {} local lines = {}
--- @type string
local rel_path = src.path:gsub("\\\\", "/") local rel_path = src.path:gsub("\\\\", "/")
--- @type WordMapEntry[], integer
local entries, total = canonical_word_entries(atom) local entries, total = canonical_word_entries(atom)
lines[#lines + 1] = string.format('ATOM %s "%s" 0', atom.raw_name or atom.name, rel_path) lines[#lines + 1] = string.format('ATOM %s "%s" 0', atom.raw_name or atom.name, rel_path)
--- @type integer, WordMapEntry
for _, entry in ipairs(entries) do for _, entry in ipairs(entries) do
--- @type InvocationRecord|nil
local inv = entry.invocation local inv = entry.invocation
--- @type integer|nil
local macro_count = inv and wc["mac_" .. inv.component_name] local macro_count = inv and wc["mac_" .. inv.component_name]
if inv and macro_count ~= nil then if inv and macro_count ~= nil then
lines[#lines + 1] = string.format('WORD %d CALL %s:%d MACRO %s "%s:%d" BODY %d' lines[#lines + 1] = string.format('WORD %d CALL %s:%d MACRO %s "%s:%d" BODY %d'
@@ -125,10 +178,11 @@ local function emit_provenance_stanza(src, atom, wc)
end end
--- Render the full provenance file content for one source. --- Render the full provenance file content for one source.
--- @param src table --- @param src SourceFile
--- @param wc table --- @param wc WordCounts
--- @return string --- @return string
local function render_provenance(src, wc) local function render_provenance(src, wc)
--- @type string[]
local lines = {} local lines = {}
lines[#lines + 1] = "# FORMAT_VERSION 1" lines[#lines + 1] = "# FORMAT_VERSION 1"
lines[#lines + 1] = "# auto-generated by ps1_meta.lua (passes/atoms_source_map.lua) — DO NOT EDIT" lines[#lines + 1] = "# auto-generated by ps1_meta.lua (passes/atoms_source_map.lua) — DO NOT EDIT"
@@ -138,13 +192,19 @@ local function render_provenance(src, wc)
lines[#lines + 1] = "# dwarf_injection to synthesize DW_TAG_inlined_subroutine instances + per-word" lines[#lines + 1] = "# dwarf_injection to synthesize DW_TAG_inlined_subroutine instances + per-word"
lines[#lines + 1] = "# line program rows for native source-level step into component bodies." lines[#lines + 1] = "# line program rows for native source-level step into component bodies."
--- @param atom AtomEntry
--- @return nil
local function append(atom) local function append(atom)
--- @type string[]
local stanza = emit_provenance_stanza(src, atom, wc) local stanza = emit_provenance_stanza(src, atom, wc)
--- @type integer, string
for _, line in ipairs(stanza) do lines[#lines + 1] = line end for _, line in ipairs(stanza) do lines[#lines + 1] = line end
end end
--- @type integer, AtomEntry
for _, atom in ipairs(src.scan.atoms or {}) do for _, atom in ipairs(src.scan.atoms or {}) do
if atom.paths then append(atom) end if atom.paths then append(atom) end
end end
--- @type integer, AtomEntry
for _, atom in ipairs(src.scan.raw_atoms or {}) do for _, atom in ipairs(src.scan.raw_atoms or {}) do
if atom.paths then append(atom) end if atom.paths then append(atom) end
end end
@@ -154,16 +214,20 @@ end
--- Render one atom's stanza for the sourcemap.txt form (ATOM header line, N WORD lines, ENDATOM marker). --- Render one atom's stanza for the sourcemap.txt form (ATOM header line, N WORD lines, ENDATOM marker).
--- Returns (lines, total_words). --- Returns (lines, total_words).
--- @param src table --- @param src SourceFile
--- @param atom table --- @param atom AtomEntry
--- @param wc table --- @return string[]
--- @return string[], integer --- @return integer
local function emit_atom_stanza(src, atom) local function emit_atom_stanza(src, atom)
--- @type string[]
local lines = {} local lines = {}
--- @type string
local rel_path = src.path:gsub("\\\\", "/") local rel_path = src.path:gsub("\\\\", "/")
--- @type WordMapEntry[], integer
local entries, total = canonical_word_entries(atom) local entries, total = canonical_word_entries(atom)
lines[#lines + 1] = string.format('ATOM %s "%s" 0', atom.raw_name or atom.name, rel_path) lines[#lines + 1] = string.format('ATOM %s "%s" 0', atom.raw_name or atom.name, rel_path)
--- @type integer, WordMapEntry
for _, entry in ipairs(entries) do for _, entry in ipairs(entries) do
lines[#lines + 1] = string.format("WORD %d LINE %d TEXT %s", lines[#lines + 1] = string.format("WORD %d LINE %d TEXT %s",
entry.pos, entry.line, entry.text) entry.pos, entry.line, entry.text)
@@ -175,21 +239,27 @@ end
--- Render the full source map file content for one source (one .atoms.sourcemap.txt per source). Mirrors offsets.lua's --- Render the full source map file content for one source (one .atoms.sourcemap.txt per source). Mirrors offsets.lua's
--- `project_atoms` shape: scan.atoms + scan.raw_atoms, no kind filter. --- `project_atoms` shape: scan.atoms + scan.raw_atoms, no kind filter.
--- @param src table --- @param src SourceFile
--- @param wc table
--- @return string --- @return string
local function render_source_map(src) local function render_source_map(src)
--- @type string[]
local lines = {} local lines = {}
lines[#lines + 1] = "# FORMAT_VERSION " .. FORMAT_VERSION lines[#lines + 1] = "# FORMAT_VERSION " .. FORMAT_VERSION
lines[#lines + 1] = "# auto-generated by ps1_meta.lua (passes/atoms_source_map.lua) — DO NOT EDIT" lines[#lines + 1] = "# auto-generated by ps1_meta.lua (passes/atoms_source_map.lua) — DO NOT EDIT"
--- @param atom AtomEntry
--- @return nil
local function append(atom) local function append(atom)
--- @type string[]
local stanza = emit_atom_stanza(src, atom) local stanza = emit_atom_stanza(src, atom)
--- @type integer, string
for _, line in ipairs(stanza) do lines[#lines + 1] = line end for _, line in ipairs(stanza) do lines[#lines + 1] = line end
end end
--- @type integer, AtomEntry
for _, atom in ipairs(src.scan.atoms or {}) do for _, atom in ipairs(src.scan.atoms or {}) do
if atom.paths then append(atom) end if atom.paths then append(atom) end
end end
--- @type integer, AtomEntry
for _, atom in ipairs(src.scan.raw_atoms or {}) do for _, atom in ipairs(src.scan.raw_atoms or {}) do
if atom.paths then append(atom) end if atom.paths then append(atom) end
end end
@@ -211,19 +281,29 @@ end
--- Build the list of atoms with addresses + word entries. Shared helper for the gdb-runtime file emission. --- Build the list of atoms with addresses + word entries. Shared helper for the gdb-runtime file emission.
--- @param ctx PassCtx --- @param ctx PassCtx
--- @return table[] -- list of {idx, name, src_path, file_base, addr, size_bytes, words, entries} --- @return GdbAtomRecord[]
local function build_atom_table(ctx) local function build_atom_table(ctx)
--- @type table<string, NmAddr>
local addrs = elf_dwarf.read_nm(ctx.flags.elf_path) local addrs = elf_dwarf.read_nm(ctx.flags.elf_path)
--- @type Corpus|nil
local corpus = ctx.shared and ctx.shared.corpus local corpus = ctx.shared and ctx.shared.corpus
--- @type GdbAtomRecord[]
local matched = {} local matched = {}
--- @type integer, SourceFile
for _, src in ipairs(corpus.source_order or {}) do for _, src in ipairs(corpus.source_order or {}) do
--- @type string
local file_base = src.path:match("([^/\\\\]+)$") or src.path local file_base = src.path:match("([^/\\\\]+)$") or src.path
--- @param atom AtomEntry
--- @return nil
local function append(atom) local function append(atom)
if not atom.paths then return end if not atom.paths then return end
--- @type string
local name = atom.raw_name or atom.name local name = atom.raw_name or atom.name
--- @type NmAddr|nil
local info = addrs[name] local info = addrs[name]
if not info then return end if not info then return end
--- @type WordMapEntry[], integer
local entries, total = canonical_word_entries(atom) local entries, total = canonical_word_entries(atom)
matched[#matched + 1] = { matched[#matched + 1] = {
name = name, name = name,
@@ -235,12 +315,18 @@ local function build_atom_table(ctx)
entries = entries, entries = entries,
} }
end end
--- @type integer, AtomEntry
for _, atom in ipairs((src.scan or {}).atoms or {}) do append(atom) end for _, atom in ipairs((src.scan or {}).atoms or {}) do append(atom) end
--- @type integer, AtomEntry
for _, atom in ipairs((src.scan or {}).raw_atoms or {}) do append(atom) end for _, atom in ipairs((src.scan or {}).raw_atoms or {}) do append(atom) end
end end
-- Deterministic order: sort by address (matches `nm` output ordering). -- Deterministic order: sort by address (matches `nm` output ordering).
--- @param a GdbAtomRecord
--- @param b GdbAtomRecord
--- @return boolean
table.sort(matched, function(a, b) return a.addr < b.addr end) table.sort(matched, function(a, b) return a.addr < b.addr end)
--- @type integer, GdbAtomRecord
for i, a in ipairs(matched) do a.idx = i - 1 end for i, a in ipairs(matched) do a.idx = i - 1 end
return matched return matched
end end
@@ -252,12 +338,14 @@ end
--- ---
--- Why hardcoded per-atom: gdb's `$` substitution doesn't concat inside var names — `$__atom_name_$__i` in a `while` --- Why hardcoded per-atom: gdb's `$` substitution doesn't concat inside var names — `$__atom_name_$__i` in a `while`
--- loop resolves to one literal identifier, not `name_i`. Compile-time emission is the only path. --- loop resolves to one literal identifier, not `name_i`. Compile-time emission is the only path.
--- @param lines table -- output line buffer (mutated in place) --- @param lines string[]
--- @param matched table -- list of atom records from `build_atom_table` --- @param matched GdbAtomRecord[]
--- @return nil
local function append_gdb_commands(lines, matched) local function append_gdb_commands(lines, matched)
-- ── tape_atoms ── -- ── tape_atoms ──
-- Hardcoded one printf per atom. No loop. -- Hardcoded one printf per atom. No loop.
lines[#lines + 1] = "define tape_atoms" lines[#lines + 1] = "define tape_atoms"
--- @type integer, GdbAtomRecord
for _, a in ipairs(matched) do for _, a in ipairs(matched) do
-- gdb 12.1 quirk: literals in printf args require an attached target. -- gdb 12.1 quirk: literals in printf args require an attached target.
-- Use the per-atom convenience vars set above as printf args. -- Use the per-atom convenience vars set above as printf args.
@@ -273,6 +361,7 @@ local function append_gdb_commands(lines, matched)
-- ── break_atom (generic) + per-atom break_atom_X ── -- ── break_atom (generic) + per-atom break_atom_X ──
lines[#lines + 1] = "define break_atom" lines[#lines + 1] = "define break_atom"
lines[#lines + 1] = ' echo "Usage: break_atom_<exact_name> (pick from the list below)"' lines[#lines + 1] = ' echo "Usage: break_atom_<exact_name> (pick from the list below)"'
--- @type integer, GdbAtomRecord
for _, a in ipairs(matched) do for _, a in ipairs(matched) do
lines[#lines + 1] = string.format(' printf " break_atom_%%-32s\\n", $__atom_name_%d', a.idx) lines[#lines + 1] = string.format(' printf " break_atom_%%-32s\\n", $__atom_name_%d', a.idx)
end end
@@ -282,6 +371,7 @@ local function append_gdb_commands(lines, matched)
lines[#lines + 1] = "end" lines[#lines + 1] = "end"
lines[#lines + 1] = "" lines[#lines + 1] = ""
--- @type integer, GdbAtomRecord
for _, a in ipairs(matched) do for _, a in ipairs(matched) do
lines[#lines + 1] = string.format("define break_atom_%s", a.name) lines[#lines + 1] = string.format("define break_atom_%s", a.name)
lines[#lines + 1] = string.format(" break *$__atom_addr_%d", a.idx) lines[#lines + 1] = string.format(" break *$__atom_addr_%d", a.idx)
@@ -296,6 +386,7 @@ local function append_gdb_commands(lines, matched)
-- ── step_atom / next_atom ── -- ── step_atom / next_atom ──
-- Hardcoded one tbreak per atom. No loop. -- Hardcoded one tbreak per atom. No loop.
lines[#lines + 1] = "define step_atom" lines[#lines + 1] = "define step_atom"
--- @type integer, GdbAtomRecord
for _, a in ipairs(matched) do for _, a in ipairs(matched) do
lines[#lines + 1] = string.format(" tbreak *$__atom_addr_%d", a.idx) lines[#lines + 1] = string.format(" tbreak *$__atom_addr_%d", a.idx)
end end
@@ -319,6 +410,7 @@ local function append_gdb_commands(lines, matched)
lines[#lines + 1] = "define where_in_atom" lines[#lines + 1] = "define where_in_atom"
lines[#lines + 1] = " set $__pc = (unsigned int)$pc" lines[#lines + 1] = " set $__pc = (unsigned int)$pc"
lines[#lines + 1] = " set $__matched = 0" lines[#lines + 1] = " set $__matched = 0"
--- @type integer, GdbAtomRecord
for _, a in ipairs(matched) do for _, a in ipairs(matched) do
-- Precompute end_addr (gdb 12.1's expression evaluator chokes on `addr + words*4`). -- Precompute end_addr (gdb 12.1's expression evaluator chokes on `addr + words*4`).
lines[#lines + 1] = string.format(" set $__end_%d = $__atom_addr_%d + $__atom_words_%d * 4", a.idx, a.idx, a.idx) lines[#lines + 1] = string.format(" set $__end_%d = $__atom_addr_%d + $__atom_words_%d * 4", a.idx, a.idx, a.idx)
@@ -328,14 +420,17 @@ local function append_gdb_commands(lines, matched)
lines[#lines + 1] = string.format(" set $__word = ($__pc - $__atom_addr_%d) / 4", a.idx) lines[#lines + 1] = string.format(" set $__word = ($__pc - $__atom_addr_%d) / 4", a.idx)
lines[#lines + 1] = string.format(' printf "word: %%d/%%d\\n", $__word, $__atom_words_%d', a.idx) lines[#lines + 1] = string.format(' printf "word: %%d/%%d\\n", $__word, $__atom_words_%d', a.idx)
-- One inner-if per WORD entry. Each word's line + text hardcoded. -- One inner-if per WORD entry. Each word's line + text hardcoded.
--- @type integer, WordMapEntry
for _, we in ipairs(a.entries) do for _, we in ipairs(a.entries) do
lines[#lines + 1] = string.format(" if $__word == %d", we.pos) lines[#lines + 1] = string.format(" if $__word == %d", we.pos)
-- Escape TEXT for printf format string. -- Escape TEXT for printf format string.
--- @type string
local escaped_text = we.text:gsub("%%", "%%%%"):gsub('"', '\\"') local escaped_text = we.text:gsub("%%", "%%%%"):gsub('"', '\\"')
lines[#lines + 1] = string.format(' printf "source: %%s:%%d %%s\\n", $__atom_file_%d, %d, "%s"', a.idx, we.line, escaped_text) lines[#lines + 1] = string.format(' printf "source: %%s:%%d %%s\\n", $__atom_file_%d, %d, "%s"', a.idx, we.line, escaped_text)
lines[#lines + 1] = " end" lines[#lines + 1] = " end"
end end
-- Fallback for words beyond the source map (shouldn't happen if nm matches). -- Fallback for words beyond the source map (shouldn't happen if nm matches).
--- @type integer
local max_word = 0 local max_word = 0
if #a.entries > 0 then max_word = a.entries[#a.entries].pos end if #a.entries > 0 then max_word = a.entries[#a.entries].pos end
lines[#lines + 1] = string.format(' if $__word > %d', max_word) lines[#lines + 1] = string.format(' if $__word > %d', max_word)
@@ -361,6 +456,7 @@ local function append_gdb_commands(lines, matched)
lines[#lines + 1] = " set $__in_atom = 0" lines[#lines + 1] = " set $__in_atom = 0"
lines[#lines + 1] = " set $__did_step = 0" lines[#lines + 1] = " set $__did_step = 0"
lines[#lines + 1] = " set $__pc = (unsigned int)$pc" lines[#lines + 1] = " set $__pc = (unsigned int)$pc"
--- @type integer, GdbAtomRecord
for _, a in ipairs(matched) do for _, a in ipairs(matched) do
-- Precompute end_addr in the convenience var (single expression gdb handles). -- Precompute end_addr in the convenience var (single expression gdb handles).
lines[#lines + 1] = string.format(" set $__end_%d = $__atom_addr_%d + $__atom_words_%d * 4", a.idx, a.idx, a.idx) lines[#lines + 1] = string.format(" set $__end_%d = $__atom_addr_%d + $__atom_words_%d * 4", a.idx, a.idx, a.idx)
@@ -395,8 +491,10 @@ end
--- Emit the gdb-runtime file (post-link). Pure gdb scripting — addresses come from `mipsel-none-elf-nm -S`, get embedded --- Emit the gdb-runtime file (post-link). Pure gdb scripting — addresses come from `mipsel-none-elf-nm -S`, get embedded
--- in `<ctx.out_root>/gdb_tape_atoms_runtime.gdb`, and load via `set $var = ...` + `define ... end` blocks at gdb source-time. --- in `<ctx.out_root>/gdb_tape_atoms_runtime.gdb`, and load via `set $var = ...` + `define ... end` blocks at gdb source-time.
--- @param ctx PassCtx --- @param ctx PassCtx
--- @return nil
local function emit_gdb_runtime(ctx) local function emit_gdb_runtime(ctx)
if not (ctx.flags and ctx.flags.gdb_runtime) then return end if not (ctx.flags and ctx.flags.gdb_runtime) then return end
--- @type string|nil
local elf_path = ctx.flags.elf_path local elf_path = ctx.flags.elf_path
if not elf_path or elf_path == "" then if not elf_path or elf_path == "" then
io.stderr:write("[atoms_source_map] --gdb-runtime requires --elf <elf>\n") io.stderr:write("[atoms_source_map] --gdb-runtime requires --elf <elf>\n")
@@ -408,12 +506,14 @@ local function emit_gdb_runtime(ctx)
return return
end end
--- @type GdbAtomRecord[]
local matched = build_atom_table(ctx) local matched = build_atom_table(ctx)
if #matched == 0 then if #matched == 0 then
io.stderr:write("[atoms_source_map] --gdb-runtime: no atoms matched against nm symbols (stale scan?).\n") io.stderr:write("[atoms_source_map] --gdb-runtime: no atoms matched against nm symbols (stale scan?).\n")
return return
end end
--- @type string[]
local lines = {} local lines = {}
lines[#lines + 1] = "# Auto-generated by ps1_meta.lua (passes/atoms_source_map.lua)" lines[#lines + 1] = "# Auto-generated by ps1_meta.lua (passes/atoms_source_map.lua)"
lines[#lines + 1] = "# DO NOT EDIT — re-run ps1_meta.lua --atoms-source-map --gdb-runtime to regenerate" lines[#lines + 1] = "# DO NOT EDIT — re-run ps1_meta.lua --atoms-source-map --gdb-runtime to regenerate"
@@ -435,6 +535,7 @@ local function emit_gdb_runtime(ctx)
-- Per-atom convenience vars (used as printf args; literals aren't accepted -- Per-atom convenience vars (used as printf args; literals aren't accepted
-- without an attached target on gdb 12.1). -- without an attached target on gdb 12.1).
--- @type integer, GdbAtomRecord
for _, a in ipairs(matched) do for _, a in ipairs(matched) do
lines[#lines + 1] = string.format('set $__atom_name_%d = "%s"', a.idx, gdb_escape(a.name)) lines[#lines + 1] = string.format('set $__atom_name_%d = "%s"', a.idx, gdb_escape(a.name))
lines[#lines + 1] = string.format("set $__atom_addr_%d = 0x%x", a.idx, a.addr) lines[#lines + 1] = string.format("set $__atom_addr_%d = 0x%x", a.idx, a.addr)
@@ -451,10 +552,13 @@ local function emit_gdb_runtime(ctx)
-- Confirmation line for the source operator. -- Confirmation line for the source operator.
lines[#lines + 1] = 'printf "[gdb_tape_atoms] runtime loaded %d atoms from %s\\n", $__atom_count, $__elf_path' lines[#lines + 1] = 'printf "[gdb_tape_atoms] runtime loaded %d atoms from %s\\n", $__atom_count, $__elf_path'
--- @type string
local out_path local out_path
-- Move out of `<out_root>/gdb_tape_atoms_runtime.gdb` to `<out_root>/../gdb_tape_atoms_runtime.gdb` when the conventional `<out_root>` is `<build>/gen` -- Move out of `<out_root>/gdb_tape_atoms_runtime.gdb` to `<out_root>/../gdb_tape_atoms_runtime.gdb` when the conventional `<out_root>` is `<build>/gen`
-- (any equivalent spelling — relative, absolute backslash, absolute forward-slash, trailing-separator variants). -- (any equivalent spelling — relative, absolute backslash, absolute forward-slash, trailing-separator variants).
-- This puts the gdb runtime alongside the ELF at `build/` rather than under the report subdir. -- This puts the gdb runtime alongside the ELF at `build/` rather than under the report subdir.
--- @param p string
--- @return boolean
local function ends_with_gen_dir(p) local function ends_with_gen_dir(p)
if type(p) ~= "string" then return false end if type(p) ~= "string" then return false end
return p:match("[/\\]gen[/\\]?$") ~= nil or p == "build/gen" or p == "build\\gen" return p:match("[/\\]gen[/\\]?$") ~= nil or p == "build/gen" or p == "build\\gen"
@@ -462,6 +566,7 @@ local function emit_gdb_runtime(ctx)
if ends_with_gen_dir(ctx.out_root) then if ends_with_gen_dir(ctx.out_root) then
-- Strip the trailing `/gen` segment, then write the runtime script under `build/`. -- Strip the trailing `/gen` segment, then write the runtime script under `build/`.
-- e.g. "C:/projects/Pikuma/ps1/build/gen" -> "C:/projects/Pikuma/ps1/build". -- e.g. "C:/projects/Pikuma/ps1/build/gen" -> "C:/projects/Pikuma/ps1/build".
--- @type string
local parent = ctx.out_root:gsub("[/\\]gen[/\\]?$", "") local parent = ctx.out_root:gsub("[/\\]gen[/\\]?$", "")
out_path = parent .. "/gdb_tape_atoms_runtime.gdb" out_path = parent .. "/gdb_tape_atoms_runtime.gdb"
else else
@@ -476,6 +581,7 @@ end
-- M — module exports -- M — module exports
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
--- @type AtomSourceMapPass
local M = {} local M = {}
-- Expose the pure render functions so `report.lua` and the focused tests can call them directly without triggering the file-emit path. -- Expose the pure render functions so `report.lua` and the focused tests can call them directly without triggering the file-emit path.
@@ -483,19 +589,26 @@ M.render_source_map = render_source_map
M.render_provenance = render_provenance M.render_provenance = render_provenance
--- Render ONE atom's sourcemap stanza. --- Render ONE atom's sourcemap stanza.
--- @param atom table -- atom record (must have `atom.paths` populated) --- @param atom AtomEntry
--- @return string --- @return string
function M.render_atom_source_map(atom) function M.render_atom_source_map(atom)
assert(type(atom) == "table", "render_atom_source_map: atom must be a table") assert(type(atom) == "table", "render_atom_source_map: atom must be a table")
assert(type(atom.paths) == "table", "render_atom_source_map: atom.paths must be a table") assert(type(atom.paths) == "table", "render_atom_source_map: atom.paths must be a table")
--- @type WordMapEntry[], integer
local entries, total = canonical_word_entries(atom) local entries, total = canonical_word_entries(atom)
--- @type string[]
local lines = {} local lines = {}
lines[#lines + 1] = string.format("ATOM %s %d", (atom.raw_name or atom.name), total) lines[#lines + 1] = string.format("ATOM %s %d", (atom.raw_name or atom.name), total)
--- @type integer, WordMapEntry
for _, entry in ipairs(entries) do for _, entry in ipairs(entries) do
--- @type string
local word_line = string.format("WORD %d LINE %d TEXT %s", local word_line = string.format("WORD %d LINE %d TEXT %s",
entry.pos, entry.line, entry.text) entry.pos, entry.line, entry.text)
--- @type string[]
local keys = {} local keys = {}
--- @type integer
for pos = 1, 16 do for pos = 1, 16 do
--- @type string|nil
local k = entry.gpr_keys and entry.gpr_keys[pos] local k = entry.gpr_keys and entry.gpr_keys[pos]
if type(k) == "string" and k:sub(1, 7) == "reguse:" then if type(k) == "string" and k:sub(1, 7) == "reguse:" then
keys[#keys + 1] = k keys[#keys + 1] = k
@@ -514,19 +627,24 @@ end
--- ---
--- `rel_path` is the source path (forward-slashes) embedded in every `CALL` line. --- `rel_path` is the source path (forward-slashes) embedded in every `CALL` line.
--- The .md caller (report.lua) is expected to derive this once per `## <source>` heading and pass it down for each atom in that source. --- The .md caller (report.lua) is expected to derive this once per `## <source>` heading and pass it down for each atom in that source.
--- @param atom table -- atom record (must have `atom.paths` populated) --- @param atom AtomEntry
--- @param wc table -- identity alias of `corpus.word_counts` --- @param wc WordCounts
--- @param rel_path string -- source path (forward-slashes) for `CALL` fields --- @param rel_path string
--- @return string --- @return string
function M.render_atom_provenance(atom, wc, rel_path) function M.render_atom_provenance(atom, wc, rel_path)
assert(type(atom) == "table", "render_atom_provenance: atom must be a table") assert(type(atom) == "table", "render_atom_provenance: atom must be a table")
assert(type(atom.paths) == "table", "render_atom_provenance: atom.paths must be a table") assert(type(atom.paths) == "table", "render_atom_provenance: atom.paths must be a table")
assert(type(rel_path) == "string", "render_atom_provenance: rel_path must be a string") assert(type(rel_path) == "string", "render_atom_provenance: rel_path must be a string")
--- @type WordMapEntry[], integer
local entries, total = canonical_word_entries(atom) local entries, total = canonical_word_entries(atom)
--- @type string[]
local lines = {} local lines = {}
lines[#lines + 1] = string.format("ATOM %s %d", (atom.raw_name or atom.name), total) lines[#lines + 1] = string.format("ATOM %s %d", (atom.raw_name or atom.name), total)
--- @type integer, WordMapEntry
for _, entry in ipairs(entries) do for _, entry in ipairs(entries) do
--- @type InvocationRecord|nil
local inv = entry.invocation local inv = entry.invocation
--- @type integer|nil
local macro_count = inv and wc and wc["mac_" .. inv.component_name] local macro_count = inv and wc and wc["mac_" .. inv.component_name]
if inv and macro_count ~= nil then if inv and macro_count ~= nil then
lines[#lines + 1] = string.format('WORD %d CALL %s:%d MACRO %s "%s:%d" BODY %d' lines[#lines + 1] = string.format('WORD %d CALL %s:%d MACRO %s "%s:%d" BODY %d'
@@ -546,16 +664,21 @@ end
--- @param ctx PassCtx --- @param ctx PassCtx
--- @return PassResult --- @return PassResult
function M.run(ctx) function M.run(ctx)
--- @type PassOutputEntry[]
local outputs = {} local outputs = {}
--- @type PassFinding[]
local errors = {} local errors = {}
--- @type PassFinding[]
local warnings = {} local warnings = {}
--- @type Corpus|nil
local corpus = ctx.shared and ctx.shared.corpus local corpus = ctx.shared and ctx.shared.corpus
if type(corpus) ~= "table" or type(corpus.source_order) ~= "table" then if type(corpus) ~= "table" or type(corpus.source_order) ~= "table" then
error("atoms_source_map.run requires ctx.shared.corpus.source_order (canonical corpus).", 0) error("atoms_source_map.run requires ctx.shared.corpus.source_order (canonical corpus).", 0)
end end
-- Word counts come from `corpus.word_counts` (populated by word_count_eval + components passes). -- Word counts come from `corpus.word_counts` (populated by word_count_eval + components passes).
--- @type WordCounts
local wc = corpus.word_counts or {} local wc = corpus.word_counts or {}
if not next(wc) then if not next(wc) then
warnings[#warnings + 1] = { warnings[#warnings + 1] = {
+104 -4
View File
@@ -18,12 +18,26 @@
--- Pool exhaustion: If a phase declares more `R_<Sym>` mappings than the 10-register pool can hold, --- Pool exhaustion: If a phase declares more `R_<Sym>` mappings than the 10-register pool can hold,
--- emit `phase_register_pool_exhausted` as a build-stopping error. --- emit `phase_register_pool_exhausted` as a build-stopping error.
--- @class AutoRegResult --- @alias GprIdent string
--- @field outputs table[] -- {kind=, path=} entries
--- @field errors table[] -- {line=, msg=} entries (build-stops)
--- @field warnings table[] -- {line=, msg=} entries (build-continues)
--- @class GprAllocMap
--- @field [string] GprIdent -- bag: auto-reg symbol -> physical GPR
--- @class AutoRegOutput
--- @field auto_reg_h string
--- @class AutoRegResult
--- @field outputs AutoRegOutput[]
--- @field errors PassFinding[]
--- @field warnings PassFinding[]
--- @class AutoRegPass
--- @field run fun(ctx: PassCtx): AutoRegResult
--- @field POOL GprIdent[]
--- @type string
local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./" local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./"
--- @type DuffleExport
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
--- ════════════════════════════════════════════════════════════════════════════ --- ════════════════════════════════════════════════════════════════════════════
@@ -42,6 +56,7 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
--- R_K0/K1 (codes 26-27) — Kernel / interrupt handler reserves. Never touched by user code. --- R_K0/K1 (codes 26-27) — Kernel / interrupt handler reserves. Never touched by user code.
--- R_GP/SP/FP/RA (codes 28-31) — R_SP/R_FP/R_RA are tape-runtime carriers between tape_enter and tape_exit; R_GP stays the host global pointer. --- R_GP/SP/FP/RA (codes 28-31) — R_SP/R_FP/R_RA are tape-runtime carriers between tape_enter and tape_exit; R_GP stays the host global pointer.
--- ---
--- @type GprIdent[]
local POOL = { local POOL = {
"R_V0", "R_V1", "R_V0", "R_V1",
"R_T0", "R_T1", "R_T2", "R_T3", "R_T0", "R_T1", "R_T2", "R_T3",
@@ -57,6 +72,7 @@ local POOL = {
-- Only the POOL entries matter for auto_reg — non-pool aliases -- Only the POOL entries matter for auto_reg — non-pool aliases
-- (R_AT=1, R_A0..A3=4..7, R_T8=24, R_T9=25, R_K0/K1=26..27, R_GP/SP/FP/RA=28..31) -- (R_AT=1, R_A0..A3=4..7, R_T8=24, R_T9=25, R_K0/K1=26..27, R_GP/SP/FP/RA=28..31)
-- are deliberately omitted — see the comment block above for the WHY of each exclusion. -- are deliberately omitted — see the comment block above for the WHY of each exclusion.
--- @type table<integer, GprIdent> -- bag: MIPS GPR code -> POOL ident
local INT_CODE_TO_POOL_GPR = { local INT_CODE_TO_POOL_GPR = {
[2] = "R_V0", [3] = "R_V1", [2] = "R_V0", [3] = "R_V1",
[4] = "R_A0", [5] = "R_A1", [6] = "R_A2", [7] = "R_A3", [4] = "R_A0", [5] = "R_A1", [6] = "R_A2", [7] = "R_A3",
@@ -68,8 +84,12 @@ local INT_CODE_TO_POOL_GPR = {
} }
-- Stable sort for deterministic allocation order. -- Stable sort for deterministic allocation order.
--- @param tbl table<string, string> -- bag: key set only; values unused
--- @return string[]
local function stable_sort_keys(tbl) local function stable_sort_keys(tbl)
--- @type string[]
local keys = {} local keys = {}
--- @type string
for k in pairs(tbl) do keys[#keys + 1] = k end for k in pairs(tbl) do keys[#keys + 1] = k end
table.sort(keys) table.sort(keys)
return keys return keys
@@ -77,15 +97,25 @@ end
-- Allocate one phase's auto-reg mappings. -- Allocate one phase's auto-reg mappings.
-- Returns (allocated_map, errors). On pool exhaustion, errors is populated and the function halts. -- Returns (allocated_map, errors). On pool exhaustion, errors is populated and the function halts.
--- @param phase_label string
--- @param decls table<string, string> -- bag: auto-reg symbol -> decl payload
--- @return GprAllocMap
--- @return PassFinding[]
local function allocate_phase(phase_label, decls) local function allocate_phase(phase_label, decls)
-- Deep-copy POOL into a fresh sequence table. The original `table.unpack and table.unpack(POOL) or { unpack(POOL) }` -- Deep-copy POOL into a fresh sequence table. The original `table.unpack and table.unpack(POOL) or { unpack(POOL) }`
-- idiom wraps the unpacked values in a single inner table under LuaJIT 5.1 (`table.unpack` is nil; the `or` returns one value), -- idiom wraps the unpacked values in a single inner table under LuaJIT 5.1 (`table.unpack` is nil; the `or` returns one value),
-- which corrupts the pool into `{ {R_T0, R_T1, ...} }` — making `table.remove(pool, 1)` return the inner table on iteration. -- which corrupts the pool into `{ {R_T0, R_T1, ...} }` — making `table.remove(pool, 1)` return the inner table on iteration.
--- @type GprIdent[]
local pool = {} local pool = {}
--- @type integer
for i = 1, #POOL do pool[i] = POOL[i] end for i = 1, #POOL do pool[i] = POOL[i] end
--- @type GprAllocMap
local result = {} local result = {}
--- @type PassFinding[]
local errors = {} local errors = {}
--- @type integer, string
for _, sym in ipairs(stable_sort_keys(decls)) do for _, sym in ipairs(stable_sort_keys(decls)) do
--- @type GprIdent|nil
local next_gpr = table.remove(pool, 1) local next_gpr = table.remove(pool, 1)
if not next_gpr then if not next_gpr then
errors[#errors + 1] = { errors[#errors + 1] = {
@@ -110,12 +140,19 @@ end
-- Each entry's `code` is the integer MIPS GPR number (0..31); INT_CODE_TO_POOL_GPR translates it back to the physical GPR ident. -- Each entry's `code` is the integer MIPS GPR number (0..31); INT_CODE_TO_POOL_GPR translates it back to the physical GPR ident.
-- Aliases whose `code` points to a non-POOL GPR (e.g. R_S0, R_T8, R_K1) are ignored — -- Aliases whose `code` points to a non-POOL GPR (e.g. R_S0, R_T8, R_K1) are ignored —
-- they don't affect the auto_reg pool, and they're already excluded from POOL above. -- they don't affect the auto_reg pool, and they're already excluded from POOL above.
--- @param corpus Corpus
--- @return table<GprIdent, boolean>
--- @return table<string, GprIdent>
local function build_user_pins(corpus) local function build_user_pins(corpus)
--- @type table<GprIdent, boolean> -- bag: pinned physical GPR -> true
local user_pinned = {} local user_pinned = {}
--- @type table<string, GprIdent> -- bag: alias ident -> physical GPR
local alias_to_gpr = {} local alias_to_gpr = {}
if not corpus.register_alias_registry then return user_pinned, alias_to_gpr end if not corpus.register_alias_registry then return user_pinned, alias_to_gpr end
--- @type string, AliasEntry
for alias_name, alias_entry in pairs(corpus.register_alias_registry) do for alias_name, alias_entry in pairs(corpus.register_alias_registry) do
if alias_entry.has_atom_reg and alias_entry.code then if alias_entry.has_atom_reg and alias_entry.code then
--- @type GprIdent|nil
local gpr = INT_CODE_TO_POOL_GPR[alias_entry.code] local gpr = INT_CODE_TO_POOL_GPR[alias_entry.code]
if gpr then if gpr then
user_pinned[gpr] = true user_pinned[gpr] = true
@@ -133,22 +170,32 @@ end
--- Clash-detection and source-pool-exclusion logic only needs the presence of each GPR (boolean test), --- Clash-detection and source-pool-exclusion logic only needs the presence of each GPR (boolean test),
--- but keeping count preserves the original find_hardcoded_rn shape so callers can switch without churn. --- but keeping count preserves the original find_hardcoded_rn shape so callers can switch without churn.
--- The alias pattern is sorted lexicographically to keep the regex deterministic. --- The alias pattern is sorted lexicographically to keep the regex deterministic.
--- @param body_text string
--- @param alias_to_gpr table<string, GprIdent> -- bag: alias ident -> physical GPR
--- @return table<GprIdent, integer>
local function find_used_gprs(body_text, alias_to_gpr) local function find_used_gprs(body_text, alias_to_gpr)
--- @type table<GprIdent, integer> -- bag: physical GPR -> hit count
local found = {} local found = {}
-- (a) Hardcoded physical GPRs (R_T0..R_T7, R_V0..R_V1, R_A0..R_A3, R_S0..R_S7). -- (a) Hardcoded physical GPRs (R_T0..R_T7, R_V0..R_V1, R_A0..R_A3, R_S0..R_S7).
--- @type GprIdent
for gpr in body_text:gmatch("(R_T%d+|R_V%d+|R_A%d+|R_S%d+)") do for gpr in body_text:gmatch("(R_T%d+|R_V%d+|R_A%d+|R_S%d+)") do
found[gpr] = (found[gpr] or 0) + 1 found[gpr] = (found[gpr] or 0) + 1
end end
-- (b) Alias references (R_<Alias>) resolved to physical GPRs via the registry. -- (b) Alias references (R_<Alias>) resolved to physical GPRs via the registry.
-- Sorted by name so the regex is byte-stable across runs. -- Sorted by name so the regex is byte-stable across runs.
if alias_to_gpr and next(alias_to_gpr) then if alias_to_gpr and next(alias_to_gpr) then
--- @type string[]
local aliases = {} local aliases = {}
--- @type string
for alias_name in pairs(alias_to_gpr) do for alias_name in pairs(alias_to_gpr) do
aliases[#aliases + 1] = alias_name aliases[#aliases + 1] = alias_name
end end
table.sort(aliases) table.sort(aliases)
--- @type string
local pattern = "(" .. table.concat(aliases, "|") .. ")" local pattern = "(" .. table.concat(aliases, "|") .. ")"
--- @type string
for alias_name in body_text:gmatch(pattern) do for alias_name in body_text:gmatch(pattern) do
--- @type GprIdent|nil
local gpr = alias_to_gpr[alias_name] local gpr = alias_to_gpr[alias_name]
if gpr and not found[gpr] then if gpr and not found[gpr] then
found[gpr] = 1 found[gpr] = 1
@@ -159,10 +206,17 @@ local function find_used_gprs(body_text, alias_to_gpr)
end end
-- Emit one gen/auto_reg.h header per directory. -- Emit one gen/auto_reg.h header per directory.
--- @param out_dir string
--- @param dir string
--- @param sources SourceFile[]
--- @param mappings GprAllocMap
--- @return string|nil
local function emit_auto_reg_h(out_dir, dir, sources, mappings) local function emit_auto_reg_h(out_dir, dir, sources, mappings)
if not mappings or next(mappings) == nil then return end if not mappings or next(mappings) == nil then return end
--- @type string
local out_path = out_dir .. "/" .. "auto_reg.h" local out_path = out_dir .. "/" .. "auto_reg.h"
duffle.ensure_dir(out_dir) duffle.ensure_dir(out_dir)
--- @type string[]
local lines = { local lines = {
"#ifdef INTELLISENSE_DIRECTIVES", "#ifdef INTELLISENSE_DIRECTIVES",
"#pragma once", "#pragma once",
@@ -170,14 +224,18 @@ local function emit_auto_reg_h(out_dir, dir, sources, mappings)
"// Auto-generated by ps1_meta.lua (passes/auto_reg.lua) — DO NOT EDIT", "// Auto-generated by ps1_meta.lua (passes/auto_reg.lua) — DO NOT EDIT",
"// Directory: " .. dir:gsub("/", "\\"), "// Directory: " .. dir:gsub("/", "\\"),
} }
--- @type integer, SourceFile
for _, src in ipairs(sources) do for _, src in ipairs(sources) do
lines[#lines + 1] = "// source: " .. src.path lines[#lines + 1] = "// source: " .. src.path
end end
lines[#lines + 1] = "// Per-phase register allocations resolved by the lua pass." lines[#lines + 1] = "// Per-phase register allocations resolved by the lua pass."
lines[#lines + 1] = "// R_<Sym>_Code = <chosen GPR's _Code constant> for every marker in this directory." lines[#lines + 1] = "// R_<Sym>_Code = <chosen GPR's _Code constant> for every marker in this directory."
lines[#lines + 1] = "" lines[#lines + 1] = ""
--- @type integer, string
for _, sym in ipairs(stable_sort_keys(mappings)) do for _, sym in ipairs(stable_sort_keys(mappings)) do
--- @type GprIdent
local gpr = mappings[sym] local gpr = mappings[sym]
--- @type string
local gpr_code = gpr .. "_Code" local gpr_code = gpr .. "_Code"
lines[#lines + 1] = "#define " .. sym .. "_Code " .. gpr_code lines[#lines + 1] = "#define " .. sym .. "_Code " .. gpr_code
end end
@@ -191,15 +249,20 @@ end
-- Pass entry -- Pass entry
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
--- @type AutoRegPass
local M = {} local M = {}
--- @param ctx PassCtx --- @param ctx PassCtx
--- @return AutoRegResult --- @return AutoRegResult
function M.run(ctx) function M.run(ctx)
--- @type AutoRegOutput[]
local outputs = {} local outputs = {}
--- @type PassFinding[]
local errors = {} local errors = {}
--- @type PassFinding[]
local warnings = {} local warnings = {}
--- @type Corpus|nil
local corpus = ctx.shared and ctx.shared.corpus local corpus = ctx.shared and ctx.shared.corpus
if type(corpus) ~= "table" then if type(corpus) ~= "table" then
error("auto_reg.run requires ctx.shared.corpus", 0) error("auto_reg.run requires ctx.shared.corpus", 0)
@@ -210,16 +273,22 @@ function M.run(ctx)
-- MUST NOT be allocated to any auto-reg marker — they're preserved across atoms by the wave-context discipline. -- MUST NOT be allocated to any auto-reg marker — they're preserved across atoms by the wave-context discipline.
-- The corpus's register_alias_registry is the source of truth for these opt-in pins. -- The corpus's register_alias_registry is the source of truth for these opt-in pins.
-- Body references to those aliases (via alias_to_gpr) are also excluded on a per-atom basis in step 2 below. -- Body references to those aliases (via alias_to_gpr) are also excluded on a per-atom basis in step 2 below.
--- @type table<GprIdent, boolean>, table<string, GprIdent>
local user_pinned, alias_to_gpr = build_user_pins(corpus) local user_pinned, alias_to_gpr = build_user_pins(corpus)
-- 1. Allocate phase pools first (phase declarations take precedence over per-atom declarations). -- 1. Allocate phase pools first (phase declarations take precedence over per-atom declarations).
--- @type table<string, GprAllocMap> -- bag: phase_label -> alloc map
local phase_allocations = {} local phase_allocations = {}
--- @type string, table<string, string>
for phase_label, decls in pairs(corpus.phase_auto_regs or {}) do for phase_label, decls in pairs(corpus.phase_auto_regs or {}) do
--- @type GprAllocMap, PassFinding[]
local mapping, errs = allocate_phase(phase_label, decls) local mapping, errs = allocate_phase(phase_label, decls)
--- @type string, GprIdent
for sym, gpr in pairs(mapping) do for sym, gpr in pairs(mapping) do
phase_allocations[phase_label] = phase_allocations[phase_label] or {} phase_allocations[phase_label] = phase_allocations[phase_label] or {}
phase_allocations[phase_label][sym] = gpr phase_allocations[phase_label][sym] = gpr
end end
--- @type integer, PassFinding
for _, e in ipairs(errs) do for _, e in ipairs(errs) do
errors[#errors + 1] = e errors[#errors + 1] = e
end end
@@ -229,15 +298,21 @@ function M.run(ctx)
-- Otherwise, allocate a private pool for the atom. -- Otherwise, allocate a private pool for the atom.
-- The phase membership is in `corpus.atom_phases[phase_label].atoms` (an array of atom names declared via `atom_phase(<phase>)` -- The phase membership is in `corpus.atom_phases[phase_label].atoms` (an array of atom names declared via `atom_phase(<phase>)`
-- in the atom's `atom_info` line). Build a reverse map `atom_name -> phase_label` so the lookup is O(1) per atom scope. -- in the atom's `atom_info` line). Build a reverse map `atom_name -> phase_label` so the lookup is O(1) per atom scope.
--- @type table<AtomName, string> -- bag: atom name -> phase label
local atom_name_to_phase = {} local atom_name_to_phase = {}
--- @type string, AtomPhaseGroup
for phase_label, entry in pairs(corpus.atom_phases or {}) do for phase_label, entry in pairs(corpus.atom_phases or {}) do
--- @type integer, AtomName
for _, atom_name in ipairs(entry.atoms or {}) do for _, atom_name in ipairs(entry.atoms or {}) do
atom_name_to_phase[atom_name] = phase_label atom_name_to_phase[atom_name] = phase_label
end end
end end
--- @type table<AtomName, GprAllocMap> -- bag: atom scope -> alloc map
local atom_allocations = {} local atom_allocations = {}
--- @type AtomName, table<string, string>
for atom_scope, decls in pairs(corpus.atom_auto_regs or {}) do for atom_scope, decls in pairs(corpus.atom_auto_regs or {}) do
--- @type string|nil
local phase_label = atom_name_to_phase[atom_scope] local phase_label = atom_name_to_phase[atom_scope]
-- Build the atom's source pool: start with the full POOL, subtract: -- Build the atom's source pool: start with the full POOL, subtract:
-- (a) every GPR already committed (phase allocations + prior atom allocations) -- (a) every GPR already committed (phase allocations + prior atom allocations)
@@ -248,25 +323,36 @@ function M.run(ctx)
-- the original `source_pool = phase_allocations[phase_label]` form used the phase -- the original `source_pool = phase_allocations[phase_label]` form used the phase
-- allocation MAP as a pool, but that map has no array part, so `table.remove(source_pool, 1)` -- allocation MAP as a pool, but that map has no array part, so `table.remove(source_pool, 1)`
-- returned nil and every atom-with-phase marker errored with `phase_register_pool_exhausted`. -- returned nil and every atom-with-phase marker errored with `phase_register_pool_exhausted`.
--- @type table<GprIdent, boolean> -- bag: committed or body-referenced GPR -> true
local used = {} local used = {}
--- @type integer, GprAllocMap
for _, m in pairs(phase_allocations) do for _, gpr in pairs(m) do used[gpr] = true end end for _, m in pairs(phase_allocations) do for _, gpr in pairs(m) do used[gpr] = true end end
--- @type integer, GprAllocMap
for _, m in pairs(atom_allocations) do for _, gpr in pairs(m) do used[gpr] = true end end for _, m in pairs(atom_allocations) do for _, gpr in pairs(m) do used[gpr] = true end end
-- (c) Body references — scan the atom body for hardcoded + alias-resolved GPRs. -- (c) Body references — scan the atom body for hardcoded + alias-resolved GPRs.
-- Folded into `used` so the source_pool exclusion is a single check. -- Folded into `used` so the source_pool exclusion is a single check.
--- @type AtomEntry|nil
local atom = corpus.atoms_by_name and corpus.atoms_by_name[atom_scope] local atom = corpus.atoms_by_name and corpus.atoms_by_name[atom_scope]
if atom and atom.body then if atom and atom.body then
--- @type table<GprIdent, integer>
local body_used = find_used_gprs(atom.body, alias_to_gpr) local body_used = find_used_gprs(atom.body, alias_to_gpr)
--- @type GprIdent
for gpr in pairs(body_used) do used[gpr] = true end for gpr in pairs(body_used) do used[gpr] = true end
end end
--- @type GprIdent[]
local source_pool = {} local source_pool = {}
--- @type integer, GprIdent
for _, gpr in ipairs(POOL) do for _, gpr in ipairs(POOL) do
-- Exclude (a) prior commitments, (b) USER-PINNED GPRs (wave-context carriers declared via atom_reg + _Code defs, preserved across atoms globally). -- Exclude (a) prior commitments, (b) USER-PINNED GPRs (wave-context carriers declared via atom_reg + _Code defs, preserved across atoms globally).
if not used[gpr] and not user_pinned[gpr] then if not used[gpr] and not user_pinned[gpr] then
source_pool[#source_pool + 1] = gpr source_pool[#source_pool + 1] = gpr
end end
end end
--- @type GprAllocMap
local result = {} local result = {}
--- @type integer, string
for _, sym in ipairs(stable_sort_keys(decls)) do for _, sym in ipairs(stable_sort_keys(decls)) do
--- @type GprIdent|nil
local next_gpr = table.remove(source_pool, 1) local next_gpr = table.remove(source_pool, 1)
if not next_gpr then if not next_gpr then
errors[#errors + 1] = { errors[#errors + 1] = {
@@ -289,10 +375,14 @@ function M.run(ctx)
-- This warning is kept as a defensive safety net for cases the body scanner might miss -- This warning is kept as a defensive safety net for cases the body scanner might miss
-- (e.g. macros that expand to register references the scanner cannot resolve). -- (e.g. macros that expand to register references the scanner cannot resolve).
-- For each resolved (scope, sym) -> R_Tn mapping, scan the atom body source for used GPRs. -- For each resolved (scope, sym) -> R_Tn mapping, scan the atom body source for used GPRs.
--- @type AtomName, GprAllocMap
for atom_scope, decls in pairs(atom_allocations) do for atom_scope, decls in pairs(atom_allocations) do
--- @type AtomEntry|nil
local atom = corpus.atoms_by_name and corpus.atoms_by_name[atom_scope] local atom = corpus.atoms_by_name and corpus.atoms_by_name[atom_scope]
if atom and atom.body then if atom and atom.body then
--- @type table<GprIdent, integer>
local used_in_body = find_used_gprs(atom.body, alias_to_gpr) local used_in_body = find_used_gprs(atom.body, alias_to_gpr)
--- @type string, GprIdent
for sym, allocated_gpr in pairs(decls) do for sym, allocated_gpr in pairs(decls) do
if used_in_body[allocated_gpr] and used_in_body[allocated_gpr] > 0 then if used_in_body[allocated_gpr] and used_in_body[allocated_gpr] > 0 then
warnings[#warnings + 1] = { warnings[#warnings + 1] = {
@@ -308,26 +398,36 @@ function M.run(ctx)
-- 4. Emit per-directory gen/auto_reg.h. -- 4. Emit per-directory gen/auto_reg.h.
-- For each source directory that has atom_auto_regs or phase_auto_regs entries, emit one header. -- For each source directory that has atom_auto_regs or phase_auto_regs entries, emit one header.
--- @type table<string, SourceFile[]>
local sources_by_dir = corpus.sources_by_dir or {} local sources_by_dir = corpus.sources_by_dir or {}
--- @type string, SourceFile[]
for dir, sources in pairs(sources_by_dir) do for dir, sources in pairs(sources_by_dir) do
--- @type GprAllocMap
local per_dir_mappings = {} local per_dir_mappings = {}
--- @type integer, SourceFile
for _, src in ipairs(sources) do for _, src in ipairs(sources) do
-- Collect every (sym -> gpr) entry that originated from a source in this directory. -- Collect every (sym -> gpr) entry that originated from a source in this directory.
-- `src.scan.atom_auto_regs` is keyed by ATOM SCOPE NAME; `pairs(t)` iterates KEYS so `scope_name` here is the scope ident (e.g. "cube_g4_face"). -- `src.scan.atom_auto_regs` is keyed by ATOM SCOPE NAME; `pairs(t)` iterates KEYS so `scope_name` here is the scope ident (e.g. "cube_g4_face").
-- The previous `for _, scan_atom_auto` form silently assigned the VALUE (a `{sym = sym}` table) to the variable, -- The previous `for _, scan_atom_auto` form silently assigned the VALUE (a `{sym = sym}` table) to the variable,
-- which made `atom_allocations[scan_atom_auto]` a table-indexed lookup that never resolved. -- which made `atom_allocations[scan_atom_auto]` a table-indexed lookup that never resolved.
--- @type string
for scope_name in pairs(src.scan and src.scan.atom_auto_regs or {}) do for scope_name in pairs(src.scan and src.scan.atom_auto_regs or {}) do
--- @type string, GprIdent
for sym, gpr in pairs(atom_allocations[scope_name] or {}) do for sym, gpr in pairs(atom_allocations[scope_name] or {}) do
per_dir_mappings[sym] = gpr per_dir_mappings[sym] = gpr
end end
end end
--- @type string
for scope_name in pairs(src.scan and src.scan.phase_auto_regs or {}) do for scope_name in pairs(src.scan and src.scan.phase_auto_regs or {}) do
--- @type string, GprIdent
for sym, gpr in pairs(phase_allocations[scope_name] or {}) do for sym, gpr in pairs(phase_allocations[scope_name] or {}) do
per_dir_mappings[sym] = gpr per_dir_mappings[sym] = gpr
end end
end end
end end
--- @type string
local out_dir = dir .. "/gen" local out_dir = dir .. "/gen"
--- @type string|nil
local out_path = emit_auto_reg_h(out_dir, dir, sources, per_dir_mappings) local out_path = emit_auto_reg_h(out_dir, dir, sources, per_dir_mappings)
if out_path then outputs[#outputs + 1] = { auto_reg_h = out_path } end if out_path then outputs[#outputs + 1] = { auto_reg_h = out_path } end
end end
+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. -- 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). -- 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. -- 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 "./" local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./"
--- @type DuffleExport
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -30,62 +32,75 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Atom component declaration identifiers. -- Atom component declaration identifiers.
--- @type string
local ATOM_COMP_PROC = "MipsAtomComp_Proc_" local ATOM_COMP_PROC = "MipsAtomComp_Proc_"
--- @type string
local MIPS_ATOM = "Slice_MipsCode" -- prefix on the function declaration that wraps an AtomComp_Proc_ local MIPS_ATOM = "Slice_MipsCode" -- prefix on the function declaration that wraps an AtomComp_Proc_
-- Component-name prefixes. -- Component-name prefixes.
--- @type string
local AC_PREFIX = "ac_" -- arg to MipsAtomComp_(ac_X); the X is the atom name local AC_PREFIX = "ac_" -- arg to MipsAtomComp_(ac_X); the X is the atom name
--- @type integer
local AC_PREFIX_LEN = 3 local AC_PREFIX_LEN = 3
--- @type string
local MAC_PREFIX = "mac_" -- prefix on generated macros; the rest is the atom name local MAC_PREFIX = "mac_" -- prefix on generated macros; the rest is the atom name
--- @type integer
local MAC_PREFIX_LEN = 4 local MAC_PREFIX_LEN = 4
-- ASCII byte values used in tokenization. -- ASCII byte values used in tokenization.
--- @type integer
local BYTE_NEWLINE = 10 local BYTE_NEWLINE = 10
--- @type integer
local BYTE_SLASH = 47 local BYTE_SLASH = 47
-- Output gen subdirectory + filename (per-directory aggregation; the directory name is the namespace). -- Output gen subdirectory + filename (per-directory aggregation; the directory name is the namespace).
--- @type string
local GEN_SUBDIR = "gen" local GEN_SUBDIR = "gen"
--- @type string
local MACS_FILENAME = "macs.h" local MACS_FILENAME = "macs.h"
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Type declarations -- Type declarations
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
--- @class SourceFile -- SourceFile, PassCtx, PassResult: see ps1_meta.lua
--- @field path string -- Absolute path to the source file -- DuffleExport: see duffle.lua
--- @field text string -- Full source text -- SourceScan, AtomEntry, CorpusCollision, CollisionSite: see scan_source.lua
--- @field dir string -- Directory containing the source -- BodyToken: see emission_model.lua
--- @field basename string -- Filename without extension -- WordCounts: see word_count_eval.lua
--- @field scan table -- Pre-scanned SourceScan payload (from duffle.scan_source) -- ComponentBodyEntry: see duffle_emit.lua
-- InstructionRow, GteCommandRow: see duffle_isa.lua
--- @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
--- @class Component --- @class Component
--- @field name string -- Atom name (without `ac_` prefix) --- @field name string -- Atom name (without `ac_` prefix)
--- @field body string -- Brace-delimited body (without the braces) --- @field body string -- Brace-delimited body (without the braces)
--- @field args string|nil -- Function-args string (function form only) --- @field body_off integer|nil -- Byte offset of body[1] in source
--- @field line integer -- Source line of the declaration --- @field body_tokens BodyToken[]|nil
--- @field comment string|nil -- Scanner-owned `declaration_comment`; the components pass reads it from the scanner record --- @field args string|nil -- Function-args string (function form only)
--- @field kind string -- "comp_bare" | "comp_proc" (atom_proc is NOT a component — see `project_components`) --- @field arg_names string[]|nil -- Formal names with leading `ab` dropped
--- @field debug_skip boolean -- Mirror of `a.debug_skip` (scanner-owned); true iff a bare `atom_dbg_skip` marker immediately preceded the declaration --- @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) -- Local helpers (file I/O + path normalization)
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
--- @type ComponentsPass
local M = {} local M = {}
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -108,6 +123,7 @@ local M = {}
--- @param before_pos integer --- @param before_pos integer
--- @return string|nil --- @return string|nil
local function find_function_args_for(source, name, before_pos) 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) local _, args_inner = duffle.find_function_decl_for(source, before_pos, #MIPS_ATOM)
return args_inner return args_inner
end end
@@ -124,9 +140,13 @@ end
--- @return string[]|nil --- @return string[]|nil
local function extract_arg_names(args_str) local function extract_arg_names(args_str)
if not args_str or args_str == "" then return nil end if not args_str or args_str == "" then return nil end
--- @type string[]
local names = {} local names = {}
--- @type string[]
local tokens = duffle.split_top_level_commas(args_str) local tokens = duffle.split_top_level_commas(args_str)
--- @type integer, string
for _, tok in ipairs(tokens) do for _, tok in ipairs(tokens) do
--- @type string
local trimmed = duffle.trim(tok) local trimmed = duffle.trim(tok)
if trimmed ~= "" then if trimmed ~= "" then
-- Strip trailing block comment (/* ... */) from the token, if present. -- 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. -- 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 -- 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`. -- the wrong name (or nothing). See `test_extract_arg_names_handles_trailing_block_comments`.
--- @type integer
local trimmed_end = #trimmed local trimmed_end = #trimmed
if trimmed_end >= 2 and trimmed:sub(trimmed_end - 1, trimmed_end) == "*/" then if trimmed_end >= 2 and trimmed:sub(trimmed_end - 1, trimmed_end) == "*/" then
-- Find the matching `/*` that opens the trailing comment. -- Find the matching `/*` that opens the trailing comment.
-- Walk back from the `*/` looking for `/*` (whitespace + `/*`). -- Walk back from the `*/` looking for `/*` (whitespace + `/*`).
--- @type integer
local close_pos = trimmed_end - 1 -- position of the second-to-last char local close_pos = trimmed_end - 1 -- position of the second-to-last char
-- Walk back: skip trailing whitespace, then look for the `/*` opener. -- Walk back: skip trailing whitespace, then look for the `/*` opener.
while close_pos > 1 do while close_pos > 1 do
--- @type string
local ch = trimmed:sub(close_pos, close_pos) local ch = trimmed:sub(close_pos, close_pos)
if ch == " " or ch == "\t" or ch == "\n" or ch == "\r" then if ch == " " or ch == "\t" or ch == "\n" or ch == "\r" then
close_pos = close_pos - 1 close_pos = close_pos - 1
@@ -149,7 +172,9 @@ local function extract_arg_names(args_str)
end end
end end
-- Now scan back from close_pos for the `/*` opener (slashes are at close_pos-1 and close_pos-2). -- 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 local opener_pos = nil
--- @type integer
local scan = close_pos - 3 local scan = close_pos - 3
while scan >= 1 do while scan >= 1 do
if trimmed:sub(scan, scan + 1) == "/*" then if trimmed:sub(scan, scan + 1) == "/*" then
@@ -169,8 +194,10 @@ local function extract_arg_names(args_str)
trimmed_end = #trimmed trimmed_end = #trimmed
if trimmed_end >= 4 and trimmed:sub(trimmed_end, trimmed_end) == "]" then if trimmed_end >= 4 and trimmed:sub(trimmed_end, trimmed_end) == "]" then
-- Walk back: skip digits, expect `[`. -- Walk back: skip digits, expect `[`.
--- @type integer
local bracket_pos = trimmed_end - 1 local bracket_pos = trimmed_end - 1
while bracket_pos > 1 do while bracket_pos > 1 do
--- @type string
local ch = trimmed:sub(bracket_pos, bracket_pos) local ch = trimmed:sub(bracket_pos, bracket_pos)
if ch >= "0" and ch <= "9" then if ch >= "0" and ch <= "9" then
bracket_pos = bracket_pos - 1 bracket_pos = bracket_pos - 1
@@ -185,8 +212,10 @@ local function extract_arg_names(args_str)
if trimmed == "" then goto continue end if trimmed == "" then goto continue end
-- Find the identifier at the end: walk back over trailers (whitespace + `*` + `[]`), -- Find the identifier at the end: walk back over trailers (whitespace + `*` + `[]`),
-- then walk back over the identifier chars (alnum + `_`). -- then walk back over the identifier chars (alnum + `_`).
--- @type integer
local ident_end = #trimmed local ident_end = #trimmed
while ident_end > 0 do while ident_end > 0 do
--- @type string
local ch = trimmed:sub(ident_end, ident_end) local ch = trimmed:sub(ident_end, ident_end)
if ch == " " or ch == "\t" or ch == "*" or ch == "]" or ch == "[" then if ch == " " or ch == "\t" or ch == "*" or ch == "]" or ch == "[" then
ident_end = ident_end - 1 ident_end = ident_end - 1
@@ -194,8 +223,10 @@ local function extract_arg_names(args_str)
break break
end end
end end
--- @type integer
local ident_start = ident_end local ident_start = ident_end
while ident_start > 0 do while ident_start > 0 do
--- @type string
local ch = trimmed:sub(ident_start, ident_start) local ch = trimmed:sub(ident_start, ident_start)
if duffle.is_alnum_byte(string.byte(ch)) or ch == "_" then if duffle.is_alnum_byte(string.byte(ch)) or ch == "_" then
ident_start = ident_start - 1 ident_start = ident_start - 1
@@ -204,6 +235,7 @@ local function extract_arg_names(args_str)
end end
end end
ident_start = ident_start + 1 ident_start = ident_start + 1
--- @type string
local name = trimmed:sub(ident_start, ident_end) local name = trimmed:sub(ident_start, ident_end)
if name ~= "" then names[#names + 1] = name end if name ~= "" then names[#names + 1] = name end
::continue:: ::continue::
@@ -213,7 +245,10 @@ local function extract_arg_names(args_str)
return names return names
end end
--- @param args_str string|nil
--- @return string[]|nil
local function formal_arg_names(args_str) local function formal_arg_names(args_str)
--- @type string[]|nil
local names = extract_arg_names(args_str) local names = extract_arg_names(args_str)
if not names then return nil end if not names then return nil end
if names[1] == "ab" then table.remove(names, 1) 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 */` --- 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]`. --- 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 source string -- the full source text (needed for backward lookups)
--- @param scan table -- SourceScan from duffle.scan_source --- @param scan SourceScan
--- @return Component[] --- @return Component[]
local function project_components(source, scan) local function project_components(source, scan)
--- @type Component[]
local out = {} local out = {}
--- @type integer, AtomEntry
for _, a in ipairs(scan.atoms) do for _, a in ipairs(scan.atoms) do
-- Only `MipsAtomComp_(ac_X)` (kind="comp_bare") and `MipsAtomComp_Proc_(ac_X, ...)` (kind="comp_proc") -- 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. -- 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 -- Function-args lookup is meaningful for `MipsAtomComp_Proc_` components
-- (the macro sits inside `FI_ Slice_MipsCode ac_X(...)`); the alias expansion -- (the macro sits inside `FI_ Slice_MipsCode ac_X(...)`); the alias expansion
-- discards the `ab` (atom-builder) arg the same way both forms do. -- 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) 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. -- 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. -- The pass reads `declaration_comment` directly.
--- @type string
local comment = a.declaration_comment or "" local comment = a.declaration_comment or ""
out[#out + 1] = { out[#out + 1] = {
line = a.line, line = a.line,
@@ -282,22 +321,30 @@ end
--- @param s string --- @param s string
--- @return string --- @return string
local function convert_line_comments_to_block(s) local function convert_line_comments_to_block(s)
--- @type string
local result = s local result = s
--- @type integer
local pos = 1 local pos = 1
--- @type integer
local len = #result local len = #result
while pos <= len do while pos <= len do
--- @type boolean
local is_double_slash = result:byte(pos) == BYTE_SLASH local is_double_slash = result:byte(pos) == BYTE_SLASH
and pos + 1 <= len and result:byte(pos + 1) == BYTE_SLASH and pos + 1 <= len and result:byte(pos + 1) == BYTE_SLASH
if not is_double_slash then if not is_double_slash then
pos = pos + 1 pos = pos + 1
else else
-- Find end of line. -- Find end of line.
--- @type integer
local eol = pos local eol = pos
while eol <= len and result:byte(eol) ~= BYTE_NEWLINE do while eol <= len and result:byte(eol) ~= BYTE_NEWLINE do
eol = eol + 1 eol = eol + 1
end end
--- @type string
local before = result:sub(1, pos - 1) local before = result:sub(1, pos - 1)
--- @type string
local comment = result:sub(pos + 2, eol - 1) -- skip the `//` local comment = result:sub(pos + 2, eol - 1) -- skip the `//`
--- @type string
local after local after
if eol <= len and result:byte(eol) == BYTE_NEWLINE then if eol <= len and result:byte(eol) == BYTE_NEWLINE then
after = " */" .. result:sub(eol) -- keep the newline after = " */" .. result:sub(eol) -- keep the newline
@@ -335,10 +382,13 @@ end
--- @param tok string --- @param tok string
--- @return string --- @return string
local function strip_leading_delay_marker(tok) local function strip_leading_delay_marker(tok)
--- @type string|nil
local ident = duffle.read_ident(tok, 1) local ident = duffle.read_ident(tok, 1)
if not ident or not duffle.DELAY_MARKERS[ident] then return tok end if not ident or not duffle.DELAY_MARKERS[ident] then return tok end
--- @type string
local rest = tok:sub(#ident + 1):match("^%s*(.*)$") or "" local rest = tok:sub(#ident + 1):match("^%s*(.*)$") or ""
while rest:sub(1, 2) == "/*" do while rest:sub(1, 2) == "/*" do
--- @type integer|nil
local close = rest:find("*/", 3, true) local close = rest:find("*/", 3, true)
if not close then return "" end if not close then return "" end
rest = rest:sub(close + 2):match("^%s*(.*)$") or "" 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). --- 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 name string -- the component name (without `mac_`)
--- @param comp_by_name table<string, Component> --- @param comp_by_name table<string, Component>
--- @param wc table<string, integer> --- @param wc WordCounts
--- @param cache table<string, integer> --- @param cache table<string, integer> -- bag: name -> count; -1 in-progress sentinel
--- @return integer --- @return integer
local function word_count_rec(name, comp_by_name, wc, cache) local function word_count_rec(name, comp_by_name, wc, cache)
if cache[name] ~= nil then return cache[name] end if cache[name] ~= nil then return cache[name] end
cache[name] = -1 -- mark in-progress (cycle detection) cache[name] = -1 -- mark in-progress (cycle detection)
--- @type Component|nil
local cc = comp_by_name[name] local cc = comp_by_name[name]
--- @type integer
local n local n
if cc then if cc then
n = 0 n = 0
--- @type BodyToken[]
local tokens = cc.body_tokens local tokens = cc.body_tokens
--- @type integer, BodyToken
for _, t in ipairs(tokens) do for _, t in ipairs(tokens) do
--- @type string
local trimmed = t.tok local trimmed = t.tok
if trimmed ~= "" then if trimmed ~= "" then
--- @type string
local work = trimmed local work = trimmed
while true do while true do
--- @type string|nil
local marker = duffle.read_ident(work, 1) local marker = duffle.read_ident(work, 1)
if marker and duffle.DELAY_MARKERS[marker] then if marker and duffle.DELAY_MARKERS[marker] then
work = strip_leading_delay_marker(work) work = strip_leading_delay_marker(work)
@@ -375,6 +432,7 @@ local function word_count_rec(name, comp_by_name, wc, cache)
end end
end end
if work ~= "" then if work ~= "" then
--- @type string|nil
local lookup = strip_mac_prefix(duffle.read_ident(work, 1)) local lookup = strip_mac_prefix(duffle.read_ident(work, 1))
if lookup == "atom_label" or lookup == "atom_offset" then if lookup == "atom_label" or lookup == "atom_offset" then
-- Pure metaprogram anchors; emit zero words. -- Pure metaprogram anchors; emit zero words.
@@ -405,13 +463,18 @@ end
--- references hit memoized values instead of re-walking the body. --- 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`. --- Cycle detection (A -> B -> A) is preserved via the in-progress `-1` sentinel in `cache`.
--- @param components Component[] --- @param components Component[]
--- @param wc table<string, integer> --- @param wc WordCounts
--- @return table<string, integer> -- map of component name (without `mac_`) -> word count --- @return table<string, integer> -- bag: bare component name -> word count
local function count_all_components(components, wc) local function count_all_components(components, wc)
--- @type table<string, Component>
local comp_by_name = {} local comp_by_name = {}
--- @type integer, Component
for _, cc in ipairs(components) do comp_by_name[cc.name] = cc end 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 = {} local cache = {}
--- @type table<string, integer> -- bag: bare name -> word count
local counts = {} local counts = {}
--- @type integer, Component
for _, c in ipairs(components) do for _, c in ipairs(components) do
counts[c.name] = word_count_rec(c.name, comp_by_name, wc, cache) counts[c.name] = word_count_rec(c.name, comp_by_name, wc, cache)
end end
@@ -435,28 +498,39 @@ end
--- Missing component: cycle 1, gp0 0. --- Missing component: cycle 1, gp0 0.
--- @param name string -- component bare name (e.g. "yield", "pack_color_word") --- @param name string -- component bare name (e.g. "yield", "pack_color_word")
--- @param comp_by_name table<string, Component> --- @param comp_by_name table<string, Component>
--- @param latency table<string, integer> --- @param latency table<string, integer> -- bag: ident -> cycle cost
--- @param cache table<string, {cycle_cost=integer, gp0_contrib=integer}> --- @param cache ComponentMetaMap
--- @return {cycle_cost=integer, gp0_contrib=integer} --- @return ComponentMeta
local function component_meta_rec(name, comp_by_name, latency, cache) local function component_meta_rec(name, comp_by_name, latency, cache)
if cache[name] ~= nil then return cache[name] end if cache[name] ~= nil then return cache[name] end
cache[name] = { cycle_cost = -1, gp0_contrib = -1 } cache[name] = { cycle_cost = -1, gp0_contrib = -1 }
--- @type Component|nil
local cc = comp_by_name[name] local cc = comp_by_name[name]
--- @type integer
local cycle_cost local cycle_cost
--- @type integer
local gp0_contrib local gp0_contrib
if cc then if cc then
--- @type boolean
local skip_cycle = (name == "yield") local skip_cycle = (name == "yield")
--- @type boolean
local skip_gp0 = name:match("^insert_ot_tag") ~= nil local skip_gp0 = name:match("^insert_ot_tag") ~= nil
cycle_cost = 0 cycle_cost = 0
gp0_contrib = 0 gp0_contrib = 0
if not skip_cycle or not skip_gp0 then if not skip_cycle or not skip_gp0 then
--- @type BodyToken[]
local tokens = cc.body_tokens local tokens = cc.body_tokens
--- @type integer, BodyToken
for _, t in ipairs(tokens) do for _, t in ipairs(tokens) do
--- @type string
local trimmed = t.tok local trimmed = t.tok
if trimmed ~= "" then if trimmed ~= "" then
--- @type string|nil
local ident = duffle.read_ident(trimmed, 1) local ident = duffle.read_ident(trimmed, 1)
if ident and ident:sub(1, MAC_PREFIX_LEN) == MAC_PREFIX then if ident and ident:sub(1, MAC_PREFIX_LEN) == MAC_PREFIX then
--- @type string
local nested = ident:sub(MAC_PREFIX_LEN + 1) local nested = ident:sub(MAC_PREFIX_LEN + 1)
--- @type ComponentMeta
local nested_meta = component_meta_rec(nested, comp_by_name, latency, cache) local nested_meta = component_meta_rec(nested, comp_by_name, latency, cache)
if not skip_cycle then if not skip_cycle then
cycle_cost = cycle_cost + nested_meta.cycle_cost cycle_cost = cycle_cost + nested_meta.cycle_cost
@@ -466,7 +540,9 @@ local function component_meta_rec(name, comp_by_name, latency, cache)
end end
else else
if not skip_cycle then if not skip_cycle then
--- @type InstructionRow|nil
local isa = duffle.instr(ident) local isa = duffle.instr(ident)
--- @type GteCommandRow|nil
local gte = duffle.gte(ident) local gte = duffle.gte(ident)
cycle_cost = cycle_cost + ((isa and isa.cycles) or (gte and gte.cycles) or latency[ident] or 1) cycle_cost = cycle_cost + ((isa and isa.cycles) or (gte and gte.cycles) or latency[ident] or 1)
end end
@@ -499,13 +575,18 @@ end
--- Compute `cycle_cost` + `gp0_contrib` for every component in `components` in a single pass. --- 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. --- One memoization cache; a nested `mac_Y` inside a `mac_X` body computes both fields once.
--- @param components Component[] --- @param components Component[]
--- @param latency table<string, integer> --- @param latency table<string, integer> -- bag: ident -> cycle cost
--- @return table<string, {cycle_cost=integer, gp0_contrib=integer}> --- @return ComponentMetaMap
local function compute_components_metadata(components, latency) local function compute_components_metadata(components, latency)
--- @type table<string, Component>
local comp_by_name = {} local comp_by_name = {}
--- @type integer, Component
for _, cc in ipairs(components) do comp_by_name[cc.name] = cc end for _, cc in ipairs(components) do comp_by_name[cc.name] = cc end
--- @type ComponentMetaMap
local cache = {} local cache = {}
--- @type ComponentMetaMap
local out = {} local out = {}
--- @type integer, Component
for _, c in ipairs(components) do for _, c in ipairs(components) do
out[c.name] = component_meta_rec(c.name, comp_by_name, latency, cache) out[c.name] = component_meta_rec(c.name, comp_by_name, latency, cache)
end end
@@ -521,10 +602,14 @@ end
--- @param s string --- @param s string
--- @return string[] --- @return string[]
local function split_comment_lines(s) local function split_comment_lines(s)
--- @type string[]
local out = {} local out = {}
--- @type integer
local pos = 1 local pos = 1
--- @type integer
local s_len = #s local s_len = #s
while pos <= s_len do while pos <= s_len do
--- @type integer|nil
local nl = s:find("\n", pos, true) local nl = s:find("\n", pos, true)
if not nl then if not nl then
out[#out + 1] = s:sub(pos) out[#out + 1] = s:sub(pos)
@@ -544,6 +629,7 @@ end
--- @param args_str string|nil --- @param args_str string|nil
--- @return string --- @return string
local function signature_from_args(args_str) local function signature_from_args(args_str)
--- @type string[]|nil
local names = formal_arg_names(args_str) local names = formal_arg_names(args_str)
if names then if names then
return table.concat(names, ", ") return table.concat(names, ", ")
@@ -553,7 +639,10 @@ end
--- Strip the trailing `" \"` (space + backslash) line continuation from the last body line. --- Strip the trailing `" \"` (space + backslash) line continuation from the last body line.
--- The last 2 chars are always that pair. --- The last 2 chars are always that pair.
--- @param lines string[]
--- @return nil
local function strip_trailing_continuation(lines) local function strip_trailing_continuation(lines)
--- @type string
local last = lines[#lines] local last = lines[#lines]
if last:sub(-2) == " \\" then if last:sub(-2) == " \\" then
lines[#lines] = last:sub(1, -3) 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) --- @param tok string -- a single token from split_top_level_commas (already trimmed at the start, may contain trailing whitespace + block comment)
--- @return boolean --- @return boolean
local function is_pure_delay_marker_token(tok) local function is_pure_delay_marker_token(tok)
--- @type table<string, boolean> -- bag: delay-marker ident -> true
local markers = duffle.DELAY_MARKERS local markers = duffle.DELAY_MARKERS
if type(markers) ~= "table" then return false end if type(markers) ~= "table" then return false end
-- Identify a leading delay-marker identifier (e.g. `GteDelay_`). -- Identify a leading delay-marker identifier (e.g. `GteDelay_`).
--- @type integer
local ident_end = 1 local ident_end = 1
while ident_end <= #tok do while ident_end <= #tok do
--- @type string
local ch = tok:sub(ident_end, ident_end) local ch = tok:sub(ident_end, ident_end)
if ch:match("[%w_]") then if ch:match("[%w_]") then
ident_end = ident_end + 1 ident_end = ident_end + 1
@@ -593,16 +685,20 @@ local function is_pure_delay_marker_token(tok)
break break
end end
end end
--- @type string
local ident = tok:sub(1, ident_end - 1) local ident = tok:sub(1, ident_end - 1)
if not markers[ident] then return false end if not markers[ident] then return false end
-- Walk the remainder: only whitespace and block comments are allowed. -- Walk the remainder: only whitespace and block comments are allowed.
--- @type integer
local scan = ident_end local scan = ident_end
while scan <= #tok do while scan <= #tok do
--- @type string
local ch = tok:sub(scan, scan) local ch = tok:sub(scan, scan)
if ch:match("%s") then if ch:match("%s") then
scan = scan + 1 scan = scan + 1
elseif ch == "/" and tok:sub(scan + 1, scan + 1) == "*" then elseif ch == "/" and tok:sub(scan + 1, scan + 1) == "*" then
--- @type integer|nil
local close = tok:find("*/", scan + 2, true) local close = tok:find("*/", scan + 2, true)
if not close then return false end if not close then return false end
scan = close + 2 scan = close + 2
@@ -641,14 +737,22 @@ end
--- the annotation IS preserved in the generated header --- 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 `,` --- (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. --- 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) local function emit_macro_body(lines, c, sig, tokens)
--- @type integer
for tok_idx = 1, #tokens do for tok_idx = 1, #tokens do
tokens[tok_idx] = convert_line_comments_to_block(tokens[tok_idx]) tokens[tok_idx] = convert_line_comments_to_block(tokens[tok_idx])
end end
if #tokens == 0 then return end if #tokens == 0 then return end
lines[#lines + 1] = "#define mac_" .. c.name .. "(" .. sig .. ") \\" lines[#lines + 1] = "#define mac_" .. c.name .. "(" .. sig .. ") \\"
lines[#lines + 1] = "\t" .. tokens[1] .. " \\" lines[#lines + 1] = "\t" .. tokens[1] .. " \\"
--- @type integer
for tok_idx = 2, #tokens do for tok_idx = 2, #tokens do
--- @type string
local sep = token_skips_leading_comma(tokens[tok_idx]) and "\t" or ",\t" local sep = token_skips_leading_comma(tokens[tok_idx]) and "\t" or ",\t"
lines[#lines + 1] = sep .. tokens[tok_idx] .. " \\" lines[#lines + 1] = sep .. tokens[tok_idx] .. " \\"
end end
@@ -660,11 +764,11 @@ end
--- For skipped components, a `/* atom_dbg_skip */` marker comment is emitted immediately before the authored comment block. --- 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 --- 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. --- (`a.debug_skip == true` on the declaration record); the components pass projects it directly.
--- @param c Component --- @param c Component
--- @param components Component[] --- @param counts table<string, integer> -- bag: bare component name -> word count
--- @param wc table<string, integer>
--- @return string[] -- list of lines for this component --- @return string[] -- list of lines for this component
local function build_component_lines(c, counts) local function build_component_lines(c, counts)
--- @type string[]
local lines = {} local lines = {}
-- Marker comment: emitted once for every skipped component. -- Marker comment: emitted once for every skipped component.
@@ -675,15 +779,20 @@ local function build_component_lines(c, counts)
end end
if c.comment and c.comment ~= "" then if c.comment and c.comment ~= "" then
--- @type integer, string
for _, line in ipairs(split_comment_lines(c.comment)) do for _, line in ipairs(split_comment_lines(c.comment)) do
lines[#lines + 1] = line lines[#lines + 1] = line
end end
end end
--- @type string[]
local tokens = duffle.split_top_level_commas(c.body) local tokens = duffle.split_top_level_commas(c.body)
--- @type integer
for i = 1, #tokens do tokens[i] = duffle.trim(tokens[i]) end for i = 1, #tokens do tokens[i] = duffle.trim(tokens[i]) end
--- @type string
local sig = signature_from_args(c.args) local sig = signature_from_args(c.args)
-- Direct lookup against the per-source precomputed `counts` table (built once by count_all_components). -- Direct lookup against the per-source precomputed `counts` table (built once by count_all_components).
--- @type integer
local n = counts[c.name] local n = counts[c.name]
if n > 0 then if n > 0 then
@@ -707,10 +816,13 @@ end
--- @param sources SourceFile[] -- Sources contributing to this directory (for the header comment) --- @param sources SourceFile[] -- Sources contributing to this directory (for the header comment)
--- @return string[] --- @return string[]
local function header_boilerplate(dir, sources) local function header_boilerplate(dir, sources)
--- @type string[]
local source_lines = { "// Directory: " .. duffle.to_absolute_path(dir) .. "/" } local source_lines = { "// Directory: " .. duffle.to_absolute_path(dir) .. "/" }
--- @type integer, SourceFile
for _, src in ipairs(sources) do for _, src in ipairs(sources) do
source_lines[#source_lines + 1] = "// source: " .. duffle.to_absolute_path(src.path) source_lines[#source_lines + 1] = "// source: " .. duffle.to_absolute_path(src.path)
end end
--- @type string
local source_blob = table.concat(source_lines, "\n") local source_blob = table.concat(source_lines, "\n")
return { return {
-- #pragma once wrapped in #ifdef INTELLISENSE_DIRECTIVES, matching the convention in lottes_tape.h. -- #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 -- Output directory
--- @return string -- Full output path --- @return string -- Full output path
local function compute_macs_h_path(dir) local function compute_macs_h_path(dir)
--- @type string
local out_dir = dir .. "/" .. GEN_SUBDIR local out_dir = dir .. "/" .. GEN_SUBDIR
--- @type string
local out_path = out_dir .. "/" .. MACS_FILENAME local out_path = out_dir .. "/" .. MACS_FILENAME
return out_dir, out_path return out_dir, out_path
end end
@@ -750,19 +864,24 @@ end
--- @param dir string -- Absolute source directory --- @param dir string -- Absolute source directory
--- @param sources SourceFile[] -- Sources contributing to this directory (for the header comment) --- @param sources SourceFile[] -- Sources contributing to this directory (for the header comment)
--- @param components Component[] -- Aggregated components from all sources in this directory --- @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) --- @return string|nil -- Path to the written file (nil if no components)
local function emit_component_macros_h(ctx, dir, sources, components, counts) local function emit_component_macros_h(ctx, dir, sources, components, counts)
if #components == 0 then return nil end if #components == 0 then return nil end
--- @type string, string
local out_dir, out_path = compute_macs_h_path(dir) local out_dir, out_path = compute_macs_h_path(dir)
--- @type string[]
local lines = header_boilerplate(dir, sources) local lines = header_boilerplate(dir, sources)
--- @type integer, Component
for _, c in ipairs(components) do for _, c in ipairs(components) do
--- @type integer, string
for _, l in ipairs(build_component_lines(c, counts)) do for _, l in ipairs(build_component_lines(c, counts)) do
lines[#lines + 1] = l lines[#lines + 1] = l
end end
end end
--- @type string
local content = table.concat(lines, "\n") .. "\n" local content = table.concat(lines, "\n") .. "\n"
duffle.ensure_dir(out_dir) duffle.ensure_dir(out_dir)
duffle.write_file_lf(out_path, content) 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. --- (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). --- 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 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) local function update_canonical_word_counts(corpus, components, counts)
--- @type WordCounts
local wc = corpus.word_counts local wc = corpus.word_counts
--- @type integer, Component
for _, c in ipairs(components) do for _, c in ipairs(components) do
--- @type string
local key = "mac_" .. c.name local key = "mac_" .. c.name
if wc[key] == nil then if wc[key] == nil then
wc[key] = counts[c.name] wc[key] = counts[c.name]
@@ -790,27 +913,33 @@ local function update_canonical_word_counts(corpus, components, counts)
end end
--- @class ComponentDef --- @class ComponentDef
--- @field name string -- Bare name (without ac_/mac_ prefix) --- @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 line integer -- Definition source line (line of `MipsAtomComp_(ac_X)` / `MipsAtomComp_Proc_(ac_X, ...)`)
--- @field path string -- Absolute source path of the definition --- @field path string -- Absolute source path of the definition
--- @field kind string -- "comp_bare" | "comp_proc" (atom_proc is NOT a component) --- @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 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. --- (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"). --- 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`. --- 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. --- 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). --- 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 src SourceFile
--- @param components Component[] --- @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) local function update_canonical_components(corpus, src, components, metadata)
--- @type string
local rel_path = src.path:gsub("\\", "/") local rel_path = src.path:gsub("\\", "/")
--- @type integer, Component
for _, c in ipairs(components) do for _, c in ipairs(components) do
-- Keyed by bare name (e.g. `yield`, `load_tri_indices`). -- Keyed by bare name (e.g. `yield`, `load_tri_indices`).
-- The atoms_source_map pass looks up components by bare name from the corpus; -- 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. -- `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 local m = metadata and metadata[c.name] or nil
if corpus.components[c.name] == nil then if corpus.components[c.name] == nil then
corpus.components[c.name] = { corpus.components[c.name] = {
@@ -825,9 +954,12 @@ local function update_canonical_components(corpus, src, components, metadata)
else else
-- A second declaration of the same bare name: record a typed collision so static-analysis + the report can surface it. -- 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. -- Identical-shape declarations (same path + line) reuse the first-wins entry without a collision record.
--- @type ComponentDef
local existing = corpus.components[c.name] local existing = corpus.components[c.name]
if existing.path ~= rel_path or existing.line ~= c.line then if existing.path ~= rel_path or existing.line ~= c.line then
--- @type string
local kind = c.kind or "comp_bare" local kind = c.kind or "comp_bare"
--- @type string
local first_kind = existing.kind or "comp_bare" local first_kind = existing.kind or "comp_bare"
corpus.collisions[#corpus.collisions + 1] = { corpus.collisions[#corpus.collisions + 1] = {
kind = "component", kind = "component",
@@ -845,12 +977,15 @@ end
--- (internal) Populate `corpus.component_body_index` with this source's body index entries. --- (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`). --- 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). --- 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 src SourceFile
--- @param components Component[] --- @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) 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 local line_of = scan and scan.line_of
--- @type integer, Component
for _, c in ipairs(components) do for _, c in ipairs(components) do
if corpus.component_body_index[c.name] == nil then if corpus.component_body_index[c.name] == nil then
corpus.component_body_index[c.name] = { corpus.component_body_index[c.name] = {
@@ -869,11 +1004,15 @@ end
--- @param ctx PassCtx --- @param ctx PassCtx
--- @return PassResult --- @return PassResult
function M.run(ctx) function M.run(ctx)
--- @type MacsOutput[]
local outputs = {} local outputs = {}
--- @type PassFinding[]
local errors = {} local errors = {}
--- @type PassFinding[]
local warnings = {} local warnings = {}
-- Corpus ownership gate. -- Corpus ownership gate.
--- @type Corpus|nil
local corpus = ctx.shared and ctx.shared.corpus local corpus = ctx.shared and ctx.shared.corpus
if type(corpus) ~= "table" then if type(corpus) ~= "table" then
error("components.run requires ctx.shared.corpus.", 0) 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`. -- 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`). -- 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) 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 for dir, sources in pairs(sources_by_dir) do
-- Aggregate components from every source in this directory. -- Aggregate components from every source in this directory.
-- `project_components` returns nil for sources with no `MipsAtomComp_` declarations; we skip those. -- `project_components` returns nil for sources with no `MipsAtomComp_` declarations; we skip those.
--- @type Component[]
local aggregated_components = {} local aggregated_components = {}
--- @type table<SourceFile, ComponentMetaMap>
local metadata_per_source = {} local metadata_per_source = {}
--- @type integer, SourceFile
for _, src in ipairs(sources) do for _, src in ipairs(sources) do
--- @type Component[]
local per_source = project_components(src.text, src.scan) or {} local per_source = project_components(src.text, src.scan) or {}
--- @type integer, Component
for _, c in ipairs(per_source) do for _, c in ipairs(per_source) do
aggregated_components[#aggregated_components + 1] = c aggregated_components[#aggregated_components + 1] = c
end end
@@ -913,13 +1059,17 @@ function M.run(ctx)
if #aggregated_components > 0 then if #aggregated_components > 0 then
-- Compute word counts across the aggregated set. `corpus.word_counts` carries the -- Compute word counts across the aggregated set. `corpus.word_counts` carries the
-- same-source + prior-directory entries so the recursive lookup sees both. -- 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) 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) local macs_path = emit_component_macros_h(ctx, dir, sources, aggregated_components, counts)
if macs_path then if macs_path then
outputs[#outputs + 1] = { macs_h = macs_path } outputs[#outputs + 1] = { macs_h = macs_path }
-- Populate the projections AFTER disk emission (byte-identical `.macs.h` contract). -- Populate the projections AFTER disk emission (byte-identical `.macs.h` contract).
update_canonical_word_counts(corpus, aggregated_components, counts) update_canonical_word_counts(corpus, aggregated_components, counts)
--- @type integer, SourceFile
for _, src in ipairs(sources) do for _, src in ipairs(sources) do
--- @type Component[]
local per_source = project_components(src.text, src.scan) or {} local per_source = project_components(src.text, src.scan) or {}
if #per_source > 0 then if #per_source > 0 then
update_canonical_components(corpus, src, per_source, metadata_per_source[src]) update_canonical_components(corpus, src, per_source, metadata_per_source[src])
File diff suppressed because it is too large Load Diff
+153
View File
@@ -26,12 +26,106 @@
--- ---
--- `passes.scan_source` strips its private `_code_macros` / `_code_macro_bodies` tables before this pass runs. --- `passes.scan_source` strips its private `_code_macros` / `_code_macro_bodies` tables before this pass runs.
--- @class BodyToken
--- @field tok string
--- @field rel integer
--- @class EmissionItem
--- @field kind string
--- @field encoder string|nil
--- @field args string[]|nil
--- @field i integer|nil
--- @field word_count integer|nil
--- @field line integer|nil
--- @field call_text string|nil
--- @field root_call_text string|nil
--- @field invocation_ids integer[]|nil
--- @field outermost_invocation_id integer|nil
--- @field gpr_keys string[]|nil
--- @field ident string|nil
--- @field isa_kind string|nil
--- @field nop_words integer|nil
--- @field is_yield boolean|nil
--- @field is_load boolean|nil
--- @field is_branch boolean|nil
--- @field is_unconditional_jump boolean|nil
--- @field is_terminal_jump boolean|nil
--- @field gp0_shape string|nil
--- @field name string|nil
--- @field target string|nil
--- @field word_index integer|nil
--- @field consuming_encoder string|nil
--- @field consuming_arg_pos integer|nil
--- @field invocation_id integer|nil
--- @class WordEvent
--- @field i integer
--- @field encoder string
--- @field args string[]
--- @field def_path string
--- @field def_line integer
--- @field call_text string|nil
--- @field root_call_text string|nil
--- @field invocation_ids integer[]
--- @field outermost_invocation_id integer
--- @field word_count integer
--- @field gpr_keys string[]|nil
--- @field ident string
--- @field kind string
--- @field nop_words integer
--- @field is_yield boolean
--- @field is_load boolean
--- @field is_branch boolean
--- @field is_unconditional_jump boolean
--- @field is_terminal_jump boolean
--- @field gp0_shape string|nil
--- @field body_line integer|nil
--- @field call_line integer|nil
--- @field call_path string|nil
--- @class EmissionMarker
--- @field kind string
--- @field name string
--- @field line integer
--- @field word_index integer
--- @field target string|nil
--- @field consuming_encoder string|nil
--- @field consuming_arg_pos integer|nil
--- @class EmitError
--- @field kind string
--- @field line integer|nil
--- @field msg string
--- @field source string|nil
--- @field schema_name string|nil
--- @class EmitWarning
--- @field kind string
--- @field line integer|nil
--- @field msg string
--- @class AtomPaths
--- @field tokens BodyToken[]
--- @field line_in_body table<integer, integer> -- bag: body byte offset -> 1-based line
--- @field items EmissionItem[]
--- @field word_events WordEvent[]
--- @field markers EmissionMarker[]
--- @field invocations InvocationRecord[]
--- @field errors EmitError[]
--- @field warnings EmitWarning[]
--- @class EmissionModelPass
--- @field run fun(ctx: PassCtx): PassResult
--- @type EmissionModelPass
local M = {} local M = {}
-- ───────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────
-- Bootstrap: load `duffle_paths.lua` via debug.getinfo so the module works standalone (run as `luajit passes/emission_model.lua`) and when require'd from the orchestrator. -- Bootstrap: load `duffle_paths.lua` via debug.getinfo so the module works standalone (run as `luajit passes/emission_model.lua`) and when require'd from the orchestrator.
-- ───────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────
--- @type string
local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./" local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./"
--- @type DuffleExport
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
-- ───────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────
@@ -49,7 +143,13 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
-- --
-- After this function, every `inv.call_line` is physical. DWARF and provenance output read it directly. -- After this function, every `inv.call_line` is physical. DWARF and provenance output read it directly.
-- The word-event loop forwards the already-physical `outer_inv.call_line` into `we.call_line` for words inside an invocation. -- The word-event loop forwards the already-physical `outer_inv.call_line` into `we.call_line` for words inside an invocation.
--- @param projection EmissionProjection
--- @param atom_record AtomEntry
--- @param src SourceFile
--- @param corpus Corpus
--- @return nil
local function stamp_root_provenance(projection, atom_record, src, corpus) local function stamp_root_provenance(projection, atom_record, src, corpus)
--- @type (fun(pos: integer): integer)|nil
local root_line_of = src.scan and src.scan.line_of local root_line_of = src.scan and src.scan.line_of
assert(type(root_line_of) == "function" assert(type(root_line_of) == "function"
, "emission_model: src.scan.line_of is required (canonical LineIndex closure over the source text) to stamp physical provenance") , "emission_model: src.scan.line_of is required (canonical LineIndex closure over the source text) to stamp physical provenance")
@@ -58,10 +158,14 @@ local function stamp_root_provenance(projection, atom_record, src, corpus)
-- `root_body_line` is the physical source line of the ATOM HEADER byte containing the opening `{`; that byte is one byte BEFORE `atom_record.body_off`. -- `root_body_line` is the physical source line of the ATOM HEADER byte containing the opening `{`; that byte is one byte BEFORE `atom_record.body_off`.
-- The walker assigns line 2 to the body's first content line because line 1 is the trailing `\n` after `{`. Body-text line k therefore maps to `root_body_line + (k - 1)`. -- The walker assigns line 2 to the body's first content line because line 1 is the trailing `\n` after `{`. Body-text line k therefore maps to `root_body_line + (k - 1)`.
-- `body_off - 1` points at the opening `{`, whose line index identifies the header line. `body_off` points after `{` and would shift every word row forward by one line. -- `body_off - 1` points at the opening `{`, whose line index identifies the header line. `body_off` points after `{` and would shift every word row forward by one line.
--- @type integer
local root_body_line = root_line_of(atom_record.body_off - 1) or atom_record.line or 0 local root_body_line = root_line_of(atom_record.body_off - 1) or atom_record.line or 0
--- @type table<string, ComponentBodyEntry>
local component_index = corpus.component_body_index or {} local component_index = corpus.component_body_index or {}
--- @type EmissionItem[]
local word_items = {} local word_items = {}
--- @type integer, EmissionItem
for _, item in ipairs(projection.items) do for _, item in ipairs(projection.items) do
if item.kind == "word" then word_items[#word_items + 1] = item end if item.kind == "word" then word_items[#word_items + 1] = item end
end end
@@ -69,14 +173,21 @@ local function stamp_root_provenance(projection, atom_record, src, corpus)
-- Resolve one word's physical body line, where the byte containing that word appears in source. -- Resolve one word's physical body line, where the byte containing that word appears in source.
-- * Component expansions carry `invocation_ids`; the component's full-file `line_of` leaves `item.line` physical. -- * Component expansions carry `invocation_ids`; the component's full-file `line_of` leaves `item.line` physical.
-- * Raw tokens in the root atom body carry an empty `invocation_ids` list and a body-relative `item.line`; convert them here. -- * Raw tokens in the root atom body carry an empty `invocation_ids` list and a body-relative `item.line`; convert them here.
--- @param event WordEvent
--- @param item EmissionItem
--- @return integer
local function body_line_for(event, item) local function body_line_for(event, item)
--- @type integer[]
local ids = event.invocation_ids or {} local ids = event.invocation_ids or {}
-- The innermost open invocation identifies which line index the walker used. -- The innermost open invocation identifies which line index the walker used.
-- A component `line_of` makes `item.line` physical; the atom's `body_text` line index makes it body-relative. -- A component `line_of` makes `item.line` physical; the atom's `body_text` line index makes it body-relative.
if ids and #ids > 0 then if ids and #ids > 0 then
--- @type integer
local inner_id = ids[#ids] local inner_id = ids[#ids]
--- @type InvocationRecord|nil
local inner_inv = inner_id and projection.invocations[inner_id] local inner_inv = inner_id and projection.invocations[inner_id]
if inner_inv then if inner_inv then
--- @type ComponentBodyEntry|nil
local component = component_index[inner_inv.component_name] local component = component_index[inner_inv.component_name]
if component and component.line_of then if component and component.line_of then
-- Walker used `comp.line_of`, which is the source's physical LineIndex. item.line is already physical. -- Walker used `comp.line_of`, which is the source's physical LineIndex. item.line is already physical.
@@ -92,7 +203,9 @@ local function stamp_root_provenance(projection, atom_record, src, corpus)
-- Stamp the root source path onto invocation records whose `call_path` the walker left empty. -- Stamp the root source path onto invocation records whose `call_path` the walker left empty.
-- The walker passes `body_entry.source` to `emit_invoke_begin`; `M.project_emission` creates the root `body_entry` with source `""`, leaving its `call_path` empty. -- The walker passes `body_entry.source` to `emit_invoke_begin`; `M.project_emission` creates the root `body_entry` with source `""`, leaving its `call_path` empty.
-- This stamp gives every invocation a physical `call_path` matching `passes/atoms_source_map.lua`'s in-memory provenance projection. -- This stamp gives every invocation a physical `call_path` matching `passes/atoms_source_map.lua`'s in-memory provenance projection.
--- @type string
local root_path = src.path or "" local root_path = src.path or ""
--- @type integer, InvocationRecord
for _, inv in ipairs(projection.invocations) do for _, inv in ipairs(projection.invocations) do
if inv.call_path == nil or inv.call_path == "" then if inv.call_path == nil or inv.call_path == "" then
inv.call_path = root_path inv.call_path = root_path
@@ -102,6 +215,7 @@ local function stamp_root_provenance(projection, atom_record, src, corpus)
-- Normalize `inv.call_line` to a physical source line. -- Normalize `inv.call_line` to a physical source line.
-- * ROOT invocations (`parent_id == 0`) carry body-relative `call_line` values from `M.LineIndex(body_text)`; convert them once with `root_body_line`. -- * ROOT invocations (`parent_id == 0`) carry body-relative `call_line` values from `M.LineIndex(body_text)`; convert them once with `root_body_line`.
-- * INNER invocations (`parent_id ~= 0`) carry physical `call_line` values from the component's `line_of`; retain them unchanged. -- * INNER invocations (`parent_id ~= 0`) carry physical `call_line` values from the component's `line_of`; retain them unchanged.
--- @type integer, InvocationRecord
for _, inv in ipairs(projection.invocations) do for _, inv in ipairs(projection.invocations) do
if inv.parent_id == 0 then if inv.parent_id == 0 then
inv.call_line = (root_body_line or 0) + (inv.call_line or 1) - 1 inv.call_line = (root_body_line or 0) + (inv.call_line or 1) - 1
@@ -111,13 +225,20 @@ local function stamp_root_provenance(projection, atom_record, src, corpus)
-- Build `body_lines` for each invocation. -- Build `body_lines` for each invocation.
-- `atoms_source_map` and `dwarf_injection` read `inv.body_lines[k]` directly from the invocation record created here. -- `atoms_source_map` and `dwarf_injection` read `inv.body_lines[k]` directly from the invocation record created here.
-- Component words already carry physical `item.line` values from the walker's COMPONENT line index, so `body_line_for` returns them unchanged. -- Component words already carry physical `item.line` values from the walker's COMPONENT line index, so `body_line_for` returns them unchanged.
--- @type integer, InvocationRecord
for _, inv in ipairs(projection.invocations) do for _, inv in ipairs(projection.invocations) do
--- @type integer
local sw = inv.start_word local sw = inv.start_word
--- @type integer
local ew = inv.end_word local ew = inv.end_word
--- @type integer[]
local bls = {} local bls = {}
--- @type integer
for i = sw, ew do for i = sw, ew do
--- @type EmissionItem|nil
local it = projection.items and projection.items[i] local it = projection.items and projection.items[i]
if it and it.kind == "word" then if it and it.kind == "word" then
--- @type WordEvent
local fake_event = { invocation_ids = { inv.id } } local fake_event = { invocation_ids = { inv.id } }
bls[#bls + 1] = body_line_for(fake_event, it) or 0 bls[#bls + 1] = body_line_for(fake_event, it) or 0
end end
@@ -128,14 +249,20 @@ local function stamp_root_provenance(projection, atom_record, src, corpus)
-- Resolve each `word_event`'s physical `body_line` and `call_line`. -- Resolve each `word_event`'s physical `body_line` and `call_line`.
-- For words inside an invocation, `we.call_line` identifies the OUTER atom source line containing the `mac_X(...)` token that triggered expansion. -- For words inside an invocation, `we.call_line` identifies the OUTER atom source line containing the `mac_X(...)` token that triggered expansion.
-- The root-invocation conversion above makes every `inv.call_line` physical; forward it directly and use each raw word's `body_line` as the fallback. -- The root-invocation conversion above makes every `inv.call_line` physical; forward it directly and use each raw word's `body_line` as the fallback.
--- @type integer, WordEvent
for index, we in ipairs(projection.word_events) do for index, we in ipairs(projection.word_events) do
--- @type EmissionItem
local item = word_items[index] or {} local item = word_items[index] or {}
--- @type integer
local body_line = body_line_for(we, item) local body_line = body_line_for(we, item)
item.line = body_line item.line = body_line
we.body_line = body_line we.body_line = body_line
--- @type integer
local call_line = body_line local call_line = body_line
--- @type integer
local outer_id = we.outermost_invocation_id or 0 local outer_id = we.outermost_invocation_id or 0
--- @type InvocationRecord|nil
local outer_inv = projection.invocations[outer_id] local outer_inv = projection.invocations[outer_id]
if outer_inv then if outer_inv then
-- `outer_inv.call_line` is physical after the conversion loop above, so use it directly. -- `outer_inv.call_line` is physical after the conversion loop above, so use it directly.
@@ -151,15 +278,24 @@ end
-- Project one atom record into `atom.paths`. -- Project one atom record into `atom.paths`.
-- Mutates the atom record in-place and returns the projection (for pass-level error/warning accumulation). -- Mutates the atom record in-place and returns the projection (for pass-level error/warning accumulation).
--- @param atom_record AtomEntry
--- @param src SourceFile
--- @param corpus Corpus
--- @return EmissionProjection
local function project_atom(atom_record, src, corpus) local function project_atom(atom_record, src, corpus)
--- @type string
local body = atom_record.body or "" local body = atom_record.body or ""
--- @type WordCounts
local wc = corpus.word_counts or {} local wc = corpus.word_counts or {}
--- @type table<string, ComponentBodyEntry>
local cbi = corpus.component_body_index or {} local cbi = corpus.component_body_index or {}
--- @type RegUseSchema|nil
local schema = nil local schema = nil
if atom_record.reg_use_schema_name then if atom_record.reg_use_schema_name then
schema = corpus.reg_use_schemas and corpus.reg_use_schemas[atom_record.reg_use_schema_name] schema = corpus.reg_use_schemas and corpus.reg_use_schemas[atom_record.reg_use_schema_name]
end end
-- That construction site stamps `invocation.debug_skip` while appending each record to `proj.invocations`. -- That construction site stamps `invocation.debug_skip` while appending each record to `proj.invocations`.
--- @type EmissionProjection
local proj = duffle.project_emission(body, cbi, wc, corpus.components, { local proj = duffle.project_emission(body, cbi, wc, corpus.components, {
reg_use_schema = schema, reg_use_schema = schema,
reg_use_param = atom_record.reg_use_param_name, reg_use_param = atom_record.reg_use_param_name,
@@ -172,11 +308,13 @@ local function project_atom(atom_record, src, corpus)
msg = string.format("RegUse schema %q is missing", atom_record.reg_use_schema_name), msg = string.format("RegUse schema %q is missing", atom_record.reg_use_schema_name),
} }
end end
--- @type integer, EmitError
for _, err in ipairs(corpus.reg_use_errors or {}) do for _, err in ipairs(corpus.reg_use_errors or {}) do
if err.schema_name == atom_record.reg_use_schema_name then if err.schema_name == atom_record.reg_use_schema_name then
proj.errors[#proj.errors + 1] = err proj.errors[#proj.errors + 1] = err
end end
end end
--- @type AtomPaths
local paths = { local paths = {
tokens = atom_record.body_tokens or {}, tokens = atom_record.body_tokens or {},
line_in_body = duffle.build_body_line_index(body), line_in_body = duffle.build_body_line_index(body),
@@ -199,23 +337,33 @@ end
--- @param ctx PassCtx -- { shared = { corpus = ... }, out_root, ... } --- @param ctx PassCtx -- { shared = { corpus = ... }, out_root, ... }
--- @return PassResult --- @return PassResult
function M.run(ctx) function M.run(ctx)
--- @type PassOutputEntry[]
local outputs = {} local outputs = {}
--- @type EmitError[]
local errors = {} local errors = {}
--- @type EmitWarning[]
local warnings = {} local warnings = {}
--- @type Corpus|nil
local corpus = ctx and ctx.shared and ctx.shared.corpus local corpus = ctx and ctx.shared and ctx.shared.corpus
if type(corpus) ~= "table" then error("emission_model: ctx.shared.corpus is required (canonical projection)", 0) end if type(corpus) ~= "table" then error("emission_model: ctx.shared.corpus is required (canonical projection)", 0) end
if type(corpus.source_order) ~= "table" then error("emission_model: ctx.shared.corpus.source_order is required", 0) end if type(corpus.source_order) ~= "table" then error("emission_model: ctx.shared.corpus.source_order is required", 0) end
-- Project once, collect errors + warnings for one atom. -- Project once, collect errors + warnings for one atom.
-- Kind must be one of: atom | atom_proc | raw_atom | comp_bare | comp_proc. -- Kind must be one of: atom | atom_proc | raw_atom | comp_bare | comp_proc.
--- @param atom AtomEntry
--- @param src SourceFile
--- @return nil
local function process_atom(atom, src) local function process_atom(atom, src)
if not (atom and atom.body) then return end if not (atom and atom.body) then return end
--- @type string
local kind = atom.kind local kind = atom.kind
if kind ~= "atom" and kind ~= "atom_proc" and kind ~= "raw_atom" and kind ~= "comp_bare" and kind ~= "comp_proc" then if kind ~= "atom" and kind ~= "atom_proc" and kind ~= "raw_atom" and kind ~= "comp_bare" and kind ~= "comp_proc" then
return return
end end
--- @type EmissionProjection
local proj = project_atom(atom, src, corpus) local proj = project_atom(atom, src, corpus)
--- @type integer, EmitError
for _, e in ipairs(proj.errors) do for _, e in ipairs(proj.errors) do
-- Preserve `kind` (cycle / count_mismatch / unbalanced) so readers dispatch on the diagnostic class and leave the message string as display text. -- Preserve `kind` (cycle / count_mismatch / unbalanced) so readers dispatch on the diagnostic class and leave the message string as display text.
errors[#errors + 1] = { errors[#errors + 1] = {
@@ -225,6 +373,7 @@ function M.run(ctx)
source = e.source or src.path, source = e.source or src.path,
} }
end end
--- @type integer, EmitWarning
for _, w in ipairs(proj.warnings) do for _, w in ipairs(proj.warnings) do
warnings[#warnings + 1] = { warnings[#warnings + 1] = {
kind = w.kind, kind = w.kind,
@@ -237,11 +386,15 @@ function M.run(ctx)
-- Walk `corpus.source_order`; within each source, visit atoms followed by raw_atoms. -- Walk `corpus.source_order`; within each source, visit atoms followed by raw_atoms.
-- Recognized kinds (atom | atom_proc | raw_atom | comp_bare | comp_proc) each receive the atom.paths projection via duffle.project_emission. -- Recognized kinds (atom | atom_proc | raw_atom | comp_bare | comp_proc) each receive the atom.paths projection via duffle.project_emission.
-- Components are macros inlined into atom bodies; focused tests and isolated component analyses consume atom.paths directly. -- Components are macros inlined into atom bodies; focused tests and isolated component analyses consume atom.paths directly.
--- @type integer, SourceFile
for _, src in ipairs(corpus.source_order) do for _, src in ipairs(corpus.source_order) do
--- @type SourceScan
local scan = src.scan or {} local scan = src.scan or {}
--- @type integer, AtomEntry
for _, atom in ipairs(scan.atoms or {}) do for _, atom in ipairs(scan.atoms or {}) do
process_atom(atom, src) process_atom(atom, src)
end end
--- @type integer, AtomEntry
for _, atom in ipairs(scan.raw_atoms or {}) do for _, atom in ipairs(scan.raw_atoms or {}) do
process_atom(atom, src) process_atom(atom, src)
end end
+88 -29
View File
@@ -20,7 +20,9 @@
-- Uses `debug.getinfo` to find this file's own directory, so it works both standalone and when require'd from the orchestrator. -- 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). -- 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. -- 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 "./" local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./"
--- @type DuffleExport
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -28,33 +30,20 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Offset macro/enum naming prefixes (the emitted header uses these). -- Offset macro/enum naming prefixes (the emitted header uses these).
--- @type string
local OFFSET_MACRO_PREFIX = "_atom_offset_" local OFFSET_MACRO_PREFIX = "_atom_offset_"
--- @type string
local OFFSET_ENUM_PREFIX = "atom_offset_" local OFFSET_ENUM_PREFIX = "atom_offset_"
-- Column width for the `#define _atom_offset_F_T = N` alignment. -- Column width for the `#define _atom_offset_F_T = N` alignment.
--- @type integer
local OFFSET_MACRO_COL = 44 local OFFSET_MACRO_COL = 44
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Type declarations -- Type declarations
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
--- @class SourceFile -- SourceFile, PassCtx, PassResult: see ps1_meta.lua
--- @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 shared table -- Cross-pass shared state
--- @field shared.corpus table -- Corpus projection
--- @field shared.word_counts table
--- @field out_root string -- Output root (e.g. "build/gen")
--- @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
--- @class BranchOffset --- @class BranchOffset
--- @field tag string -- Marker tag (e.g. "F" in `atom_offset(F, T)`) --- @field tag string -- Marker tag (e.g. "F" in `atom_offset(F, T)`)
@@ -69,6 +58,32 @@ local OFFSET_MACRO_COL = 44
--- @field total_words integer -- Total word count of the atom body --- @field total_words integer -- Total word count of the atom body
--- @field offsets BranchOffset[] -- Per-branch offset list --- @field offsets BranchOffset[] -- Per-branch offset list
--- @class OffsetBranch
--- @field tag string
--- @field target string
--- @field branch_word integer
--- @field consuming_encoder string|nil
--- @field consuming_arg_pos integer|nil
--- @field line integer|nil
--- @class MarkerProjectState
--- @field labels table<string, integer> -- bag: label name -> word index
--- @field branches OffsetBranch[]
--- @class OffsetConst
--- @field macro_name string
--- @field enum_name string
--- @field value integer
--- @class OffsetOutput
--- @field offsets_h string
--- @class OffsetsPass
--- @field run fun(ctx: PassCtx): PassResult
--- @class AtomEntry
--- @field paths AtomPaths|nil
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Canonical marker projection -- Canonical marker projection
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -76,10 +91,17 @@ local OFFSET_MACRO_COL = 44
-- MARKER_PROJECTORS is the marker-kind data table. -- MARKER_PROJECTORS is the marker-kind data table.
-- The emission-model pass already records marker word positions + consuming-instruction context; -- The emission-model pass already records marker word positions + consuming-instruction context;
-- this pass only projects those records into the label/branch lookup shape needed by offset computation. -- this pass only projects those records into the label/branch lookup shape needed by offset computation.
--- @type table<string, fun(state: MarkerProjectState, marker: EmissionMarker): nil>
local MARKER_PROJECTORS = { local MARKER_PROJECTORS = {
--- @param state MarkerProjectState
--- @param marker EmissionMarker
--- @return nil
label = function(state, marker) label = function(state, marker)
state.labels[marker.name] = marker.word_index state.labels[marker.name] = marker.word_index
end, end,
--- @param state MarkerProjectState
--- @param marker EmissionMarker
--- @return nil
offset = function(state, marker) offset = function(state, marker)
state.branches[#state.branches + 1] = { state.branches[#state.branches + 1] = {
tag = marker.name, tag = marker.name,
@@ -93,11 +115,15 @@ local MARKER_PROJECTORS = {
--- Project canonical marker records into the two lookup tables used by the offset renderer. --- Project canonical marker records into the two lookup tables used by the offset renderer.
--- No source text, body text, or body token is inspected. --- No source text, body text, or body token is inspected.
--- @param markers table[] -- atom.paths.markers --- @param markers EmissionMarker[]
--- @return table<string, integer>, table[] --- @return table<string, integer>
--- @return OffsetBranch[]
local function project_markers(markers) local function project_markers(markers)
--- @type MarkerProjectState
local state = { labels = {}, branches = {} } local state = { labels = {}, branches = {} }
--- @type integer, EmissionMarker
for _, marker in ipairs(markers or {}) do for _, marker in ipairs(markers or {}) do
--- @type (fun(state: MarkerProjectState, marker: EmissionMarker): nil)|nil
local project = MARKER_PROJECTORS[marker.kind] local project = MARKER_PROJECTORS[marker.kind]
if project then project(state, marker) end if project then project(state, marker) end
end end
@@ -119,12 +145,15 @@ end
--- `jump_reg` / `call_reg` / `jump_link` -> ERROR. Register-form jumps have no offset field; `atom_offset` is invalid. --- `jump_reg` / `call_reg` / `jump_link` -> ERROR. Register-form jumps have no offset field; `atom_offset` is invalid.
--- missing `consuming_encoder` -> ERROR. A lone top-level `atom_offset` is not a branch. --- missing `consuming_encoder` -> ERROR. A lone top-level `atom_offset` is not a branch.
--- @param labels table<string, integer> --- @param labels table<string, integer>
--- @param branches table[] --- @param branches OffsetBranch[]
--- @param errors table[] --- @param errors PassFinding[]
--- @return BranchOffset[] --- @return BranchOffset[]
local function compute_offsets(labels, branches, errors) local function compute_offsets(labels, branches, errors)
--- @type BranchOffset[]
local results = {} local results = {}
--- @type integer, OffsetBranch
for _, br in ipairs(branches) do for _, br in ipairs(branches) do
--- @type integer|nil
local target = labels[br.target] local target = labels[br.target]
if not target then if not target then
errors[#errors + 1] = { errors[#errors + 1] = {
@@ -132,6 +161,7 @@ local function compute_offsets(labels, branches, errors)
msg = "Branch target '" .. br.target .. "' has no atom_label (at word " .. br.branch_word .. ")", msg = "Branch target '" .. br.target .. "' has no atom_label (at word " .. br.branch_word .. ")",
} }
else else
--- @type string|nil
local consuming = br.consuming_encoder local consuming = br.consuming_encoder
if consuming == nil or consuming == "" then if consuming == nil or consuming == "" then
errors[#errors + 1] = { errors[#errors + 1] = {
@@ -171,7 +201,7 @@ end
--- (internal) Build a constant-table entry `{macro_name, enum_name, value}` from a BranchOffset. --- (internal) Build a constant-table entry `{macro_name, enum_name, value}` from a BranchOffset.
--- @param bo BranchOffset --- @param bo BranchOffset
--- @return table --- @return OffsetConst
local function make_offset_const(bo) local function make_offset_const(bo)
return { return {
macro_name = OFFSET_MACRO_PREFIX .. bo.tag .. "_" .. bo.target, macro_name = OFFSET_MACRO_PREFIX .. bo.tag .. "_" .. bo.target,
@@ -183,19 +213,24 @@ end
--- (internal) Emit one atom's offset constants + enum into the lines buffer. --- (internal) Emit one atom's offset constants + enum into the lines buffer.
--- @param add fun(s: string) --- @param add fun(s: string)
--- @param atom AtomData --- @param atom AtomData
--- @return nil
local function emit_atom_offsets(add, atom) local function emit_atom_offsets(add, atom)
if #atom.offsets == 0 then return end if #atom.offsets == 0 then return end
add("// --- atom: " .. atom.name .. " (" .. atom.total_words .. " words) ---") add("// --- atom: " .. atom.name .. " (" .. atom.total_words .. " words) ---")
add("") add("")
--- @type OffsetConst[]
local consts = {} local consts = {}
--- @type integer, BranchOffset
for _, r in ipairs(atom.offsets) do for _, r in ipairs(atom.offsets) do
consts[#consts + 1] = make_offset_const(r) consts[#consts + 1] = make_offset_const(r)
end end
--- @type integer, OffsetConst
for _, c in ipairs(consts) do for _, c in ipairs(consts) do
add("#define " .. pad_right(c.macro_name, OFFSET_MACRO_COL) .. " " .. c.value) add("#define " .. pad_right(c.macro_name, OFFSET_MACRO_COL) .. " " .. c.value)
end end
add("") add("")
add("enum {") add("enum {")
--- @type integer, OffsetConst
for _, c in ipairs(consts) do for _, c in ipairs(consts) do
add(" " .. c.enum_name .. " = " .. c.macro_name .. ",") add(" " .. c.enum_name .. " = " .. c.macro_name .. ",")
end end
@@ -204,18 +239,23 @@ local function emit_atom_offsets(add, atom)
end end
--- Generate the per-directory .offsets.h header. --- Generate the per-directory .offsets.h header.
--- @param dir string -- the absolute source directory --- @param dir string
--- @param sources table[] -- sources contributing to this directory (for the header comment) --- @param sources SourceFile[]
--- @param atoms_data AtomData[] --- @param atoms_data AtomData[]
--- @return string --- @return string
local function generate_header(dir, sources, atoms_data) local function generate_header(dir, sources, atoms_data)
--- @type string
local dir_basename = duffle.basename_no_ext(dir) local dir_basename = duffle.basename_no_ext(dir)
--- @type string[]
local lines = {} local lines = {}
--- @param s string
--- @return nil
local function add(s) lines[#lines + 1] = s end local function add(s) lines[#lines + 1] = s end
add("// Auto-generated by ps1_meta.lua (passes/offsets.lua) — DO NOT EDIT") add("// Auto-generated by ps1_meta.lua (passes/offsets.lua) — DO NOT EDIT")
add("// Directory: " .. dir:gsub("/", "\\") .. "\\") add("// Directory: " .. dir:gsub("/", "\\") .. "\\")
--- @type integer, SourceFile
for _, src in ipairs(sources) do for _, src in ipairs(sources) do
add("// source: " .. src.path:gsub("/", "\\")) add("// source: " .. src.path:gsub("/", "\\"))
end end
@@ -224,6 +264,7 @@ local function generate_header(dir, sources, atoms_data)
add("#pragma region " .. dir_basename) add("#pragma region " .. dir_basename)
add("") add("")
add("") add("")
--- @type integer, AtomData
for _, atom in ipairs(atoms_data) do for _, atom in ipairs(atoms_data) do
emit_atom_offsets(add, atom) emit_atom_offsets(add, atom)
end end
@@ -232,21 +273,27 @@ local function generate_header(dir, sources, atoms_data)
return table.concat(lines, "\n") .. "\n" return table.concat(lines, "\n") .. "\n"
end end
--- @type OffsetsPass
local M = {} local M = {}
--- (internal) Aggregate atoms from every source in one directory, render the per-directory `offsets.h`. --- (internal) Aggregate atoms from every source in one directory, render the per-directory `offsets.h`.
--- Returns the offsets_h path if a header was written, or nil. --- Returns the offsets_h path if a header was written, or nil.
--- @param ctx PassCtx --- @param ctx PassCtx
--- @param dir string -- the absolute source directory --- @param dir string
--- @param sources SourceFile[] -- sources in this directory --- @param sources SourceFile[]
--- @param errors table[] --- @param errors PassFinding[]
--- @return string|nil -- the offsets_h path --- @return string|nil
local function process_directory(ctx, dir, sources, errors) local function process_directory(ctx, dir, sources, errors)
--- @type AtomData[]
local atoms_data = {} local atoms_data = {}
--- @param atom AtomEntry
--- @return nil
local function append_atom(atom) local function append_atom(atom)
--- @type AtomPaths|nil
local paths = atom and atom.paths local paths = atom and atom.paths
if not paths then return end if not paths then return end
--- @type table<string, integer>, OffsetBranch[]
local labels, branches = project_markers(paths.markers) local labels, branches = project_markers(paths.markers)
atoms_data[#atoms_data + 1] = { atoms_data[#atoms_data + 1] = {
name = atom.raw_name or atom.name, name = atom.raw_name or atom.name,
@@ -255,13 +302,18 @@ local function process_directory(ctx, dir, sources, errors)
} }
end end
--- @type integer, SourceFile
for _, src in ipairs(sources) do for _, src in ipairs(sources) do
--- @type SourceScan
local scan = src.scan or {} local scan = src.scan or {}
--- @type integer, AtomEntry
for _, atom in ipairs(scan.atoms or {}) do append_atom(atom) end for _, atom in ipairs(scan.atoms or {}) do append_atom(atom) end
--- @type integer, AtomEntry
for _, atom in ipairs(scan.raw_atoms or {}) do append_atom(atom) end for _, atom in ipairs(scan.raw_atoms or {}) do append_atom(atom) end
end end
if #atoms_data == 0 then return nil end if #atoms_data == 0 then return nil end
--- @type string
local out_path = dir .. "/gen/offsets.h" local out_path = dir .. "/gen/offsets.h"
duffle.ensure_dir(duffle.dirname(out_path)) duffle.ensure_dir(duffle.dirname(out_path))
duffle.write_file(out_path, generate_header(dir, sources, atoms_data)) duffle.write_file(out_path, generate_header(dir, sources, atoms_data))
@@ -274,10 +326,14 @@ end
--- @param ctx PassCtx --- @param ctx PassCtx
--- @return PassResult --- @return PassResult
function M.run(ctx) function M.run(ctx)
--- @type OffsetOutput[]
local outputs = {} local outputs = {}
--- @type PassFinding[]
local errors = {} local errors = {}
--- @type PassFinding[]
local warnings = {} local warnings = {}
--- @type Corpus|nil
local corpus = ctx.shared and ctx.shared.corpus local corpus = ctx.shared and ctx.shared.corpus
if type(corpus) ~= "table" then if type(corpus) ~= "table" then
error("offsets.run requires ctx.shared.corpus", 0) error("offsets.run requires ctx.shared.corpus", 0)
@@ -287,8 +343,11 @@ function M.run(ctx)
end end
-- Per-directory aggregation: every source in the same directory contributes to one `gen/offsets.h`. -- Per-directory aggregation: every source in the same directory contributes to one `gen/offsets.h`.
--- @type table<string, SourceFile[]>
local sources_by_dir = corpus.sources_by_dir or duffle.group_sources_by_dir(corpus.source_order) 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 for dir, sources in pairs(sources_by_dir) do
--- @type string|nil
local out_path = process_directory(ctx, dir, sources, errors) local out_path = process_directory(ctx, dir, sources, errors)
if out_path then if out_path then
outputs[#outputs + 1] = { offsets_h = out_path } outputs[#outputs + 1] = { offsets_h = out_path }
+401 -43
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14 -22
View File
@@ -23,7 +23,9 @@
-- Bootstrap: load `scripts/duffle_paths.lua` (sets package.path + package.cpath). -- Bootstrap: load `scripts/duffle_paths.lua` (sets package.path + package.cpath).
-- Uses `debug.getinfo` to find this file's own directory, so it works both standalone and when require'd from the orchestrator. -- Uses `debug.getinfo` to find this file's own directory, so it works both standalone and when require'd from the orchestrator.
-- duffle_paths.lua sets package.path then returns `require("duffle")` at the bottom, so the dofile value IS the duffle module. -- 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 "./" local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./"
--- @type DuffleExport
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -31,35 +33,20 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
--- @class WordCounts --- @class WordCounts
--- @field [string] integer -- macro name -> word count --- @field [string] integer -- bag: macro name -> word count
--- @class SourceFile --- @class WordCountEval
--- @field path string -- absolute path to the source file --- @field count_token_words fun(token: string, wc: WordCounts): integer
--- @field text string -- the full source text --- @field run fun(ctx: PassCtx): PassResult
--- @field dir string -- the directory containing the source
--- @field basename string -- filename without extension
--- @class PassCtx -- SourceFile, PassCtx, PassResult: see ps1_meta.lua
--- @field sources SourceFile[] -- all source files in the build -- DuffleExport: see duffle.lua (facade returned by duffle_paths.lua)
--- @field metadata_path string -- path to word_count.metadata.h
--- @field shared table -- cross-pass shared state
--- @field shared.corpus table -- canonical corpus (required)
--- @field shared.corpus.word_counts WordCounts -- canonical count table (populated by this pass)
--- @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 -- if true, 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
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Module exports -- Module exports
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
--- @type WordCountEval
local M = {} local M = {}
-- ┌────────────────────────────────────────────────────────────────────┐ -- ┌────────────────────────────────────────────────────────────────────┐
@@ -74,11 +61,14 @@ local M = {}
--- @param wc WordCounts -- the shared word-count table --- @param wc WordCounts -- the shared word-count table
--- @return integer --- @return integer
function M.count_token_words(token, wc) function M.count_token_words(token, wc)
--- @type string
local s = duffle.trim(token) local s = duffle.trim(token)
if s == "" then return 0 end if s == "" then return 0 end
--- @type string|nil, integer
local name, after = duffle.read_ident(s, 1) local name, after = duffle.read_ident(s, 1)
if not name then return 1 end if not name then return 1 end
if wc[name] then return wc[name] end if wc[name] then return wc[name] end
--- @type integer
local paren_pos = duffle.skip_ws_and_cmt(s, after) local paren_pos = duffle.skip_ws_and_cmt(s, after)
if s:sub(paren_pos, paren_pos) == "(" then if s:sub(paren_pos, paren_pos) == "(" then
io.stderr:write(" warning: unknown macro '" .. name .. "', assuming 1 word\n") io.stderr:write(" warning: unknown macro '" .. name .. "', assuming 1 word\n")
@@ -105,6 +95,7 @@ end
--- @return PassResult --- @return PassResult
function M.run(ctx) function M.run(ctx)
-- 1. Canonical-corpus ownership gate. -- 1. Canonical-corpus ownership gate.
--- @type Corpus|nil
local corpus = ctx.shared and ctx.shared.corpus local corpus = ctx.shared and ctx.shared.corpus
if type(corpus) ~= "table" then if type(corpus) ~= "table" then
error("word_count_eval.run requires ctx.shared.corpus (canonical corpus). The fixture must install the corpus before running this pass.", 0) error("word_count_eval.run requires ctx.shared.corpus (canonical corpus). The fixture must install the corpus before running this pass.", 0)
@@ -117,6 +108,7 @@ function M.run(ctx)
-- 3. Load authored metadata. Generated .macs.h files are NOT scanned -- 3. Load authored metadata. Generated .macs.h files are NOT scanned
-- (the pass computes their counts from the just-built bodies after disk emission; see passes/components.lua). -- (the pass computes their counts from the just-built bodies after disk emission; see passes/components.lua).
--- @type WordCounts
local wc = duffle.load_word_counts(ctx.metadata_path) local wc = duffle.load_word_counts(ctx.metadata_path)
-- 4. Assign the count table. ONE assignment, no copy. The assignment creates no secondary alias. -- 4. Assign the count table. ONE assignment, no copy. The assignment creates no secondary alias.
+209 -23
View File
@@ -19,7 +19,9 @@
-- fall back to `debug.getinfo(1, "S").source` when this file is being dofile()'d or require()'d (in which case `arg[0]` is the *caller's* path). -- fall back to `debug.getinfo(1, "S").source` when this file is being dofile()'d or require()'d (in which case `arg[0]` is the *caller's* path).
-- That single statement: (a) sets `package.path` + `package.cpath`, (b) at the bottom returns `require("duffle")`. -- That single statement: (a) sets `package.path` + `package.cpath`, (b) at the bottom returns `require("duffle")`.
-- So the dofile's return value is the duffle module. -- So the dofile's return value is the duffle module.
--- @type boolean
local _is_entry_script = arg and arg[0] and arg[0]:match("ps1_meta%.lua$") ~= nil local _is_entry_script = arg and arg[0] and arg[0]:match("ps1_meta%.lua$") ~= nil
--- @type string
local _bootstrap_src local _bootstrap_src
if _is_entry_script then if _is_entry_script then
_bootstrap_src = arg[0] _bootstrap_src = arg[0]
@@ -28,6 +30,7 @@ else
-- strip the leading "@" so the directory match works in both cases. -- strip the leading "@" so the directory match works in both cases.
_bootstrap_src = debug.getinfo(1, "S").source:sub(2) _bootstrap_src = debug.getinfo(1, "S").source:sub(2)
end end
--- @type DuffleExport
local duffle = dofile((_bootstrap_src:match("(.*[/\\])") or "./") .. "duffle_paths.lua") local duffle = dofile((_bootstrap_src:match("(.*[/\\])") or "./") .. "duffle_paths.lua")
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -35,18 +38,24 @@ local duffle = dofile((_bootstrap_src:match("(.*[/\\])") or "./") .. "duffle_pat
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Exit codes (per the --help text and the post-build summary convention). -- Exit codes (per the --help text and the post-build summary convention).
--- @type integer
local EXIT_OK = 0 local EXIT_OK = 0
--- @type integer
local EXIT_VALIDATION_ERRORS = 1 local EXIT_VALIDATION_ERRORS = 1
--- @type integer
local EXIT_INTERNAL_ERROR = 2 local EXIT_INTERNAL_ERROR = 2
-- Default --out-root value if not provided. -- Default --out-root value if not provided.
--- @type string
local DEFAULT_OUT_ROOT = "build/gen" local DEFAULT_OUT_ROOT = "build/gen"
-- Sentinel for "all passes" in `PASS_FLAG_TO_NAME`. Distinguishes `--all` from the per-pass flags (which map to individual pass names). -- Sentinel for "all passes" in `PASS_FLAG_TO_NAME`. Distinguishes `--all` from the per-pass flags (which map to individual pass names).
--- @type string
local ALL_PASSES_SENTINEL = "__all__" local ALL_PASSES_SENTINEL = "__all__"
-- Sentinel key for the pass-flag dispatcher in `FLAG_HANDLERS`. -- Sentinel key for the pass-flag dispatcher in `FLAG_HANDLERS`.
-- The actual pass names are looked up via `PASS_FLAG_TO_NAME`, not direct dispatch, so this key never matches a real flag. -- The actual pass names are looked up via `PASS_FLAG_TO_NAME`, not direct dispatch, so this key never matches a real flag.
--- @type string
local PASS_FLAG_DISPATCH_KEY = "__pass__" local PASS_FLAG_DISPATCH_KEY = "__pass__"
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -60,38 +69,84 @@ local PASS_FLAG_DISPATCH_KEY = "__pass__"
--- @field deps string[] -- Names of upstream passes --- @field deps string[] -- Names of upstream passes
--- @field groups string[]? -- OPTIONAL build-phase groups this pass is a root of (e.g. { "pre-link" }, { "post-link" }); absent ⇒ dependency-only --- @field groups string[]? -- OPTIONAL build-phase groups this pass is a root of (e.g. { "pre-link" }, { "post-link" }); absent ⇒ dependency-only
--- @class SourceFile --- @class Corpus
--- @field path string -- Absolute path to the source file --- @field unity_root string|nil
--- @field text string -- Full source text --- @field project_root string
--- @field dir string -- Directory containing the source --- @field code_root string
--- @field basename string -- Filename without extension --- @field source_order SourceFile[]
--- @field sources_by_path table<Path, SourceFile>
--- @field sources_by_dir table<string, SourceFile[]>
--- @field atoms_by_name table<AtomName, AtomEntry>
--- @field binds_by_name table<string, BindsEntry>
--- @field atom_infos AtomInfoEntry[]
--- @field register_alias_registry table<string, AliasEntry>
--- @field type_name_registry table<string, TypeNameEntry>
--- @field atom_views table<AtomName, AtomViewEntry>
--- @field atom_ctxs table<AtomName, AtomCtxEntry>
--- @field atom_phases table<string, AtomPhaseGroup>
--- @field word_counts WordCounts
--- @field components table<string, ComponentDef>
--- @field component_body_index table<string, ComponentBodyEntry>
--- @field collisions CorpusCollision[]
--- @field resolver SourceResolver
--- @field component_atom_infos AtomInfoEntry[]|nil
--- @field atom_auto_regs table<AtomName, table<string, string>>|nil
--- @field phase_auto_regs table<string, table<string, string>>|nil
--- @field reg_use_schemas table<string, RegUseSchema>|nil
--- @field reg_use_errors RegUseError[]|nil
--- @field static_analysis_results table<string, AtomAnalysis>|nil
--- @field tape_chains table<string, TapeChain>|nil
--- @class PassShared
--- @field corpus Corpus
--- @class PassFlags
--- @field gdb_runtime boolean|nil
--- @field dwarf_injection boolean|nil
--- @field elf_path string|nil
--- @class PassCtx --- @class PassCtx
--- @field metadata_path string -- Path to word_count.metadata.h --- @field metadata_path string -- Path to word_count.metadata.h
--- @field shared table -- Cross-pass shared state --- @field shared PassShared -- Cross-pass shared state
--- @field shared.corpus table -- Authored-source/project projection --- @field out_root string -- Output root (e.g. "build/gen")
--- @field out_root string -- Output root (e.g. "build/gen") --- @field project_root string -- PS1 repository root
--- @field project_root string -- PS1 repository root --- @field flags PassFlags -- CLI flags + per-pass stash
--- @field flags table -- CLI flags + per-pass stash --- @field verbose boolean -- If true, log diagnostic info
--- @field verbose boolean -- If true, log diagnostic info
--- @class Finding --- @class PassFinding
--- @field line integer -- Source line (or 0 for pass-level) --- @field line integer -- Source line (or 0 for pass-level)
--- @field msg string -- Finding message --- @field msg string -- Finding message
--- @class PassOutputEntry
--- @field kind string
--- @field path string
--- @class PassResult --- @class PassResult
--- @field outputs PassOutputEntry[] -- Emitted file paths --- @field outputs PassOutputEntry[]
--- @field errors Finding[] -- Build-stops (per-pass kind policy) --- @field errors PassFinding[] -- Build-stops (per-pass kind policy)
--- @field warnings Finding[] -- Informational --- @field warnings PassFinding[] -- Informational
--- @field info CheckFinding[]|nil -- static_analysis only
--- @class ParsedArgs --- @class ParsedArgs
--- @field requested_set string[] -- Pass names to run (explicit --all expanded) --- @field requested_set string[] -- Pass names to run (explicit --all expanded)
--- @field sources string[] -- Exact --source values, retained in CLI order --- @field sources string[] -- Exact --source values, retained in CLI order
--- @field unity_root string|nil -- --unity-root value; mutually exclusive with sources --- @field unity_root string|nil -- --unity-root value; mutually exclusive with sources
--- @field metadata string -- --metadata value --- @field metadata string -- --metadata value
--- @field out_root string -- --out-root value (default "build/gen") --- @field out_root string -- --out-root value (default "build/gen")
--- @field project_root string -- PS1 repository root (derived from metadata by default) --- @field project_root string -- PS1 repository root (derived from metadata by default)
--- @field verbose boolean -- If true, log diagnostic info --- @field verbose boolean -- If true, log diagnostic info
--- @field flags PassFlags|nil -- Per-pass stash; copied onto PassCtx.flags
--- @alias FlagHandler fun(args: ParsedArgs, argv: string[]|nil, arg_idx: integer|nil): integer|nil
--- @class PassModule
--- @field run fun(ctx: PassCtx): PassResult
--- @class Ps1MetaMod
--- @field PASSES table<string, PassDescriptor>
--- @field PASS_KIND_STOP_ON_ERROR table<string, boolean>
--- @field parse_args fun(argv: string[]): ParsedArgs
--- @field build_ctx fun(args: ParsedArgs): PassCtx
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- PASSES Table -- PASSES Table
@@ -104,6 +159,7 @@ local PASS_FLAG_DISPATCH_KEY = "__pass__"
-- A row without a `groups` entry is dependency-only: it runs only when a transitive dep requests it, -- A row without a `groups` entry is dependency-only: it runs only when a transitive dep requests it,
-- but it remains directly requestable through its explicit CLI flag (e.g. --atoms-source-map, --scan-source). -- but it remains directly requestable through its explicit CLI flag (e.g. --atoms-source-map, --scan-source).
--- @type table<string, PassDescriptor>
local PASSES = { local PASSES = {
["scan-source"] = { ["scan-source"] = {
module = "passes.scan_source", module = "passes.scan_source",
@@ -174,9 +230,12 @@ local PASSES = {
--- @param group_name string -- Build-phase group ("pre-link" | "post-link") --- @param group_name string -- Build-phase group ("pre-link" | "post-link")
--- @return string[] -- Sorted root pass names belonging to that group --- @return string[] -- Sorted root pass names belonging to that group
local function roots_for_group(group_name) local function roots_for_group(group_name)
--- @type string[]
local names = {} local names = {}
--- @type string, PassDescriptor
for name, pass in pairs(PASSES) do for name, pass in pairs(PASSES) do
if pass.groups then if pass.groups then
--- @type integer, string
for _, g in ipairs(pass.groups) do for _, g in ipairs(pass.groups) do
if g == group_name then if g == group_name then
names[#names + 1] = name names[#names + 1] = name
@@ -194,12 +253,15 @@ end
--- cannot silently fall through to pre-link (or any other default) and dispatch nothing. --- cannot silently fall through to pre-link (or any other default) and dispatch nothing.
--- @param args ParsedArgs --- @param args ParsedArgs
--- @param group_name string --- @param group_name string
--- @return nil
local function request_roots_for_group(args, group_name) local function request_roots_for_group(args, group_name)
--- @type string[]
local roots = roots_for_group(group_name) local roots = roots_for_group(group_name)
if #roots == 0 then if #roots == 0 then
error(string.format("ps1_meta: build-phase group %q has zero roots in PASSES; check PASSES rows for a `groups = { %q }` field" error(string.format("ps1_meta: build-phase group %q has zero roots in PASSES; check PASSES rows for a `groups = { %q }` field"
, group_name, group_name)) , group_name, group_name))
end end
--- @type integer, string
for _, name in ipairs(roots) do for _, name in ipairs(roots) do
args.requested_set[#args.requested_set + 1] = name args.requested_set[#args.requested_set + 1] = name
end end
@@ -208,6 +270,7 @@ end
-- Pass-kind taxonomy: findings always print. No pass kind stops the build. -- Pass-kind taxonomy: findings always print. No pass kind stops the build.
-- Report severity is independent from process exit policy. -- Report severity is independent from process exit policy.
-- Adding a new pass kind requires listing it here explicitly; an unknown kind must not silently fall back to "true". -- Adding a new pass kind requires listing it here explicitly; an unknown kind must not silently fall back to "true".
--- @type table<string, boolean> -- bag: pass kind -> stop-on-error
local PASS_KIND_STOP_ON_ERROR = { local PASS_KIND_STOP_ON_ERROR = {
["shared"] = false, ["shared"] = false,
["header-output"] = false, ["header-output"] = false,
@@ -220,6 +283,7 @@ local PASS_KIND_STOP_ON_ERROR = {
-- Per-pass flags (e.g. --word-counts); phase flags (--pre-link, --post-link, --all) are within FLAG_HANDLERS because they own side effects or invoke group-derivation logic. -- Per-pass flags (e.g. --word-counts); phase flags (--pre-link, --post-link, --all) are within FLAG_HANDLERS because they own side effects or invoke group-derivation logic.
-- dwarf-injection is *also* a per-pass opt-in flag, but its selection + opt-in state are both owned by the explicit FLAG_HANDLERS entry below -- dwarf-injection is *also* a per-pass opt-in flag, but its selection + opt-in state are both owned by the explicit FLAG_HANDLERS entry below
-- (it sets args.flags.dwarf_injection and appends "dwarf-injection" to requested_set), so it is intentionally absent from this table. -- (it sets args.flags.dwarf_injection and appends "dwarf-injection" to requested_set), so it is intentionally absent from this table.
--- @type table<string, string> -- bag: CLI flag -> pass name or ALL_PASSES_SENTINEL
local PASS_FLAG_TO_NAME = { local PASS_FLAG_TO_NAME = {
["--word-counts"] = "word-counts", ["--word-counts"] = "word-counts",
["--components"] = "components", ["--components"] = "components",
@@ -235,10 +299,14 @@ local PASS_FLAG_TO_NAME = {
--- Append every pass name to args.requested_set. --- Append every pass name to args.requested_set.
--- Names are derived from PASSES (no parallel name list); used by --all and by any caller that wants the full closure. --- Names are derived from PASSES (no parallel name list); used by --all and by any caller that wants the full closure.
--- @param args ParsedArgs --- @param args ParsedArgs
--- @return nil
local function request_all_passes(args) local function request_all_passes(args)
--- @type string[]
local names = {} local names = {}
--- @type string
for name in pairs(PASSES) do names[#names + 1] = name end for name in pairs(PASSES) do names[#names + 1] = name end
table.sort(names) table.sort(names)
--- @type integer, string
for _, n in ipairs(names) do for _, n in ipairs(names) do
args.requested_set[#args.requested_set + 1] = n args.requested_set[#args.requested_set + 1] = n
end end
@@ -246,6 +314,7 @@ end
-- Per-flag handlers. Each handler takes (args, argv, arg_idx) and returns the new arg_idx (so multi-arg flags like --source FILE advance it). -- Per-flag handlers. Each handler takes (args, argv, arg_idx) and returns the new arg_idx (so multi-arg flags like --source FILE advance it).
-- Returning nil + os.exit() handles termination flags (--help). -- Returning nil + os.exit() handles termination flags (--help).
--- @type table<string, FlagHandler>
local FLAG_HANDLERS = {} local FLAG_HANDLERS = {}
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -253,6 +322,7 @@ local FLAG_HANDLERS = {}
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
--- Print the CLI usage to stdout and exit 0. --- Print the CLI usage to stdout and exit 0.
--- @return nil
local function print_help() local function print_help()
io.write([[ io.write([[
ps1_meta.lua - Tape-atom metaprogram orchestrator ps1_meta.lua - Tape-atom metaprogram orchestrator
@@ -302,6 +372,7 @@ EXAMPLES:
]]) ]])
end end
--- @type table<string, string> -- bag: flag -> value metavar
local FLAG_VALUE_NAMES = { local FLAG_VALUE_NAMES = {
["--source"] = "FILE", ["--source"] = "FILE",
["--unity-root"] = "FILE", ["--unity-root"] = "FILE",
@@ -311,8 +382,15 @@ local FLAG_VALUE_NAMES = {
["--elf"] = "PATH", ["--elf"] = "PATH",
} }
--- @param argv string[]
--- @param arg_idx integer
--- @param flag string
--- @return string
--- @return integer
local function require_flag_value(argv, arg_idx, flag) local function require_flag_value(argv, arg_idx, flag)
--- @type string|nil
local value = argv[arg_idx + 1] local value = argv[arg_idx + 1]
--- @type boolean
local next_known = type(value) == "string" local next_known = type(value) == "string"
and (FLAG_HANDLERS[value] ~= nil or PASS_FLAG_TO_NAME[value] ~= nil) and (FLAG_HANDLERS[value] ~= nil or PASS_FLAG_TO_NAME[value] ~= nil)
if value == nil or next_known then if value == nil or next_known then
@@ -327,30 +405,59 @@ end
-- Populated AFTER print_help so the --help handler can reference it as an upvalue (Lua resolves locals at closure-call time, -- Populated AFTER print_help so the --help handler can reference it as an upvalue (Lua resolves locals at closure-call time,
-- but if the closure is defined before the local, it falls back to _G). -- but if the closure is defined before the local, it falls back to _G).
--- @param args ParsedArgs
--- @return nil
FLAG_HANDLERS["--help"] = function(args) print_help(); os.exit(0) end FLAG_HANDLERS["--help"] = function(args) print_help(); os.exit(0) end
--- @param args ParsedArgs
--- @return nil
FLAG_HANDLERS["--verbose"] = function(args) args.verbose = true end FLAG_HANDLERS["--verbose"] = function(args) args.verbose = true end
--- @param args ParsedArgs
--- @param argv string[]
--- @param arg_idx integer
--- @return integer
FLAG_HANDLERS["--source"] = function(args, argv, arg_idx) FLAG_HANDLERS["--source"] = function(args, argv, arg_idx)
--- @type string, integer
local value, value_idx = require_flag_value(argv, arg_idx, "--source") local value, value_idx = require_flag_value(argv, arg_idx, "--source")
args.sources[#args.sources + 1] = value args.sources[#args.sources + 1] = value
return value_idx return value_idx
end end
--- @param args ParsedArgs
--- @param argv string[]
--- @param arg_idx integer
--- @return integer
FLAG_HANDLERS["--unity-root"] = function(args, argv, arg_idx) FLAG_HANDLERS["--unity-root"] = function(args, argv, arg_idx)
--- @type string, integer
local value, value_idx = require_flag_value(argv, arg_idx, "--unity-root") local value, value_idx = require_flag_value(argv, arg_idx, "--unity-root")
args.unity_root = value args.unity_root = value
return value_idx return value_idx
end end
--- @param args ParsedArgs
--- @param argv string[]
--- @param arg_idx integer
--- @return integer
FLAG_HANDLERS["--metadata"] = function(args, argv, arg_idx) FLAG_HANDLERS["--metadata"] = function(args, argv, arg_idx)
--- @type string, integer
local value, value_idx = require_flag_value(argv, arg_idx, "--metadata") local value, value_idx = require_flag_value(argv, arg_idx, "--metadata")
args.metadata = value args.metadata = value
return value_idx return value_idx
end end
--- @param args ParsedArgs
--- @param argv string[]
--- @param arg_idx integer
--- @return integer
FLAG_HANDLERS["--out-root"] = function(args, argv, arg_idx) FLAG_HANDLERS["--out-root"] = function(args, argv, arg_idx)
--- @type string, integer
local value, value_idx = require_flag_value(argv, arg_idx, "--out-root") local value, value_idx = require_flag_value(argv, arg_idx, "--out-root")
args.out_root = value args.out_root = value
return value_idx return value_idx
end end
--- @param args ParsedArgs
--- @param argv string[]
--- @param arg_idx integer
--- @return integer
FLAG_HANDLERS["--project-root"] = function(args, argv, arg_idx) FLAG_HANDLERS["--project-root"] = function(args, argv, arg_idx)
--- @type string, integer
local value, value_idx = require_flag_value(argv, arg_idx, "--project-root") local value, value_idx = require_flag_value(argv, arg_idx, "--project-root")
args.project_root = value args.project_root = value
return value_idx return value_idx
@@ -358,11 +465,18 @@ end
-- Per-pass stash flags. Read by `passes/atoms_source_map.lua` to opt into the post-link gdb-runtime emission. -- Per-pass stash flags. Read by `passes/atoms_source_map.lua` to opt into the post-link gdb-runtime emission.
-- Same shape as the existing per-flag handlers. mutates `args.flags` (which propagates into `ctx.flags`). -- Same shape as the existing per-flag handlers. mutates `args.flags` (which propagates into `ctx.flags`).
--- @param args ParsedArgs
--- @return nil
FLAG_HANDLERS["--gdb-runtime"] = function(args) FLAG_HANDLERS["--gdb-runtime"] = function(args)
args.flags = args.flags or {} args.flags = args.flags or {}
args.flags.gdb_runtime = true args.flags.gdb_runtime = true
end end
--- @param args ParsedArgs
--- @param argv string[]
--- @param arg_idx integer
--- @return integer
FLAG_HANDLERS["--elf"] = function(args, argv, arg_idx) FLAG_HANDLERS["--elf"] = function(args, argv, arg_idx)
--- @type string, integer
local value, value_idx = require_flag_value(argv, arg_idx, "--elf") local value, value_idx = require_flag_value(argv, arg_idx, "--elf")
args.flags = args.flags or {} args.flags = args.flags or {}
args.flags.elf_path = value args.flags.elf_path = value
@@ -370,6 +484,8 @@ FLAG_HANDLERS["--elf"] = function(args, argv, arg_idx)
end end
-- Enable DWARF injection (default OFF). Opts in to the post-link pass and sets the flag in one shot. -- Enable DWARF injection (default OFF). Opts in to the post-link pass and sets the flag in one shot.
-- The explicit handler below owns both selection and opt-in state, so --dwarf-injection is intentionally absent from PASS_FLAG_TO_NAME. -- The explicit handler below owns both selection and opt-in state, so --dwarf-injection is intentionally absent from PASS_FLAG_TO_NAME.
--- @param args ParsedArgs
--- @return nil
FLAG_HANDLERS["--dwarf-injection"] = function(args) FLAG_HANDLERS["--dwarf-injection"] = function(args)
args.flags = args.flags or {} args.flags = args.flags or {}
args.flags.dwarf_injection = true args.flags.dwarf_injection = true
@@ -377,12 +493,16 @@ FLAG_HANDLERS["--dwarf-injection"] = function(args)
end end
-- Build-phase flags: --pre-link and --post-link request the roots of their declared groups (see roots_for_group). -- Build-phase flags: --pre-link and --post-link request the roots of their declared groups (see roots_for_group).
-- topo_sort closes transitive deps from those roots; dispatch_passes runs every pass in the resolved closure without phase-filtering. -- topo_sort closes transitive deps from those roots; dispatch_passes runs every pass in the resolved closure without phase-filtering.
--- @param args ParsedArgs
--- @return nil
FLAG_HANDLERS["--pre-link"] = function(args) FLAG_HANDLERS["--pre-link"] = function(args)
request_roots_for_group(args, "pre-link") request_roots_for_group(args, "pre-link")
end end
-- Batch post-link phase: gdb-runtime + dwarf-injection in one luajit cold start. -- Batch post-link phase: gdb-runtime + dwarf-injection in one luajit cold start.
-- Sets the same opt-in flags as --gdb-runtime + --dwarf-injection and selects the post-link build-phase group. -- Sets the same opt-in flags as --gdb-runtime + --dwarf-injection and selects the post-link build-phase group.
-- elf is required; parse_args enforces it after all flags are parsed. -- elf is required; parse_args enforces it after all flags are parsed.
--- @param args ParsedArgs
--- @return nil
FLAG_HANDLERS["--post-link"] = function(args) FLAG_HANDLERS["--post-link"] = function(args)
args.flags = args.flags or {} args.flags = args.flags or {}
args.flags.gdb_runtime = true args.flags.gdb_runtime = true
@@ -393,7 +513,11 @@ end
-- `--dwarf-injection` also emits atom-local debug data. -- `--dwarf-injection` also emits atom-local debug data.
-- Pass-flag handler. Reads the closed-set table, expands --all, appends to requested_set. -- Pass-flag handler. Reads the closed-set table, expands --all, appends to requested_set.
--- @param args ParsedArgs
--- @param a string
--- @return nil
FLAG_HANDLERS[PASS_FLAG_DISPATCH_KEY] = function(args, a) FLAG_HANDLERS[PASS_FLAG_DISPATCH_KEY] = function(args, a)
--- @type string|nil
local name = PASS_FLAG_TO_NAME[a] local name = PASS_FLAG_TO_NAME[a]
if name == ALL_PASSES_SENTINEL then if name == ALL_PASSES_SENTINEL then
request_all_passes(args) request_all_passes(args)
@@ -406,6 +530,7 @@ end
--- @param argv string[] --- @param argv string[]
--- @return ParsedArgs --- @return ParsedArgs
local function parse_args(argv) local function parse_args(argv)
--- @type ParsedArgs
local args = { local args = {
requested_set = {}, requested_set = {},
sources = {}, sources = {},
@@ -416,9 +541,12 @@ local function parse_args(argv)
verbose = false, verbose = false,
} }
--- @type integer
local pos = 1 local pos = 1
while pos <= #argv do while pos <= #argv do
--- @type string
local a = argv[pos] local a = argv[pos]
--- @type FlagHandler|nil
local handler = FLAG_HANDLERS[a] local handler = FLAG_HANDLERS[a]
if handler then if handler then
pos = handler(args, argv, pos) or pos pos = handler(args, argv, pos) or pos
@@ -444,13 +572,16 @@ local function parse_args(argv)
-- `<repo>/code/duffle/word_count.metadata.h` is the canonical metadata location. -- `<repo>/code/duffle/word_count.metadata.h` is the canonical metadata location.
-- `project_root` names `<repo>`; the resolver derives `<project_root>/code` separately. -- `project_root` names `<repo>`; the resolver derives `<project_root>/code` separately.
if not args.project_root then if not args.project_root then
--- @type string
local metadata_dir = duffle.dirname(duffle.normalize_path(args.metadata)) local metadata_dir = duffle.dirname(duffle.normalize_path(args.metadata))
--- @type string
local code_root = duffle.dirname(metadata_dir) local code_root = duffle.dirname(metadata_dir)
args.project_root = duffle.dirname(code_root) args.project_root = duffle.dirname(code_root)
else else
args.project_root = duffle.normalize_path(args.project_root) args.project_root = duffle.normalize_path(args.project_root)
end end
--- @type boolean
local has_unity = type(args.unity_root) == "string" and args.unity_root ~= "" local has_unity = type(args.unity_root) == "string" and args.unity_root ~= ""
if has_unity and #args.sources > 0 then if has_unity and #args.sources > 0 then
io.stderr:write("ps1_meta: --unity-root FILE and --source FILE are mutually exclusive\n") io.stderr:write("ps1_meta: --unity-root FILE and --source FILE are mutually exclusive\n")
@@ -464,9 +595,13 @@ local function parse_args(argv)
-- Post-link opt-ins (--gdb-runtime, --dwarf-injection) write output that depends on the linked ELF. -- Post-link opt-ins (--gdb-runtime, --dwarf-injection) write output that depends on the linked ELF.
-- Without --elf the metaprogram can't satisfy those requests, so refuse loud and early. -- Without --elf the metaprogram can't satisfy those requests, so refuse loud and early.
-- This covers the explicit --post-link batch, --dwarf-injection by itself, and --gdb-runtime by itself. -- This covers the explicit --post-link batch, --dwarf-injection by itself, and --gdb-runtime by itself.
--- @type PassFlags
local flags = args.flags or {} local flags = args.flags or {}
--- @type string|nil
local elf_path = flags.elf_path local elf_path = flags.elf_path
--- @type boolean
local has_elf = type(elf_path) == "string" and #elf_path > 0 local has_elf = type(elf_path) == "string" and #elf_path > 0
--- @type boolean
local post_links = flags.gdb_runtime or flags.dwarf_injection local post_links = flags.gdb_runtime or flags.dwarf_injection
if post_links and not has_elf then if post_links and not has_elf then
io.stderr:write("ps1_meta: --elf PATH is required for post-link output\n") io.stderr:write("ps1_meta: --elf PATH is required for post-link output\n")
@@ -486,8 +621,11 @@ end
--- @param args ParsedArgs --- @param args ParsedArgs
--- @return PassCtx --- @return PassCtx
local function build_ctx(args) local function build_ctx(args)
--- @type string
local normalized_project_root = duffle.normalize_path(args.project_root) local normalized_project_root = duffle.normalize_path(args.project_root)
--- @type string
local project_root = normalized_project_root local project_root = normalized_project_root
--- @type boolean
local project_root_is_absolute = normalized_project_root:match("^%a:/") local project_root_is_absolute = normalized_project_root:match("^%a:/")
or normalized_project_root:sub(1, 2) == "//" or normalized_project_root:sub(1, 2) == "//"
or normalized_project_root:sub(1, 1) == "/" or normalized_project_root:sub(1, 1) == "/"
@@ -499,8 +637,10 @@ local function build_ctx(args)
-- Do not route POSIX/UNC/drive-absolute paths through to_absolute_path. -- Do not route POSIX/UNC/drive-absolute paths through to_absolute_path.
duffle.canonical_path_key(project_root) duffle.canonical_path_key(project_root)
end end
--- @type Corpus
local resolution local resolution
if args.unity_root then if args.unity_root then
--- @type boolean, Corpus|string
local ok_resolve, resolved = pcall(duffle.resolve_source_corpus, { local ok_resolve, resolved = pcall(duffle.resolve_source_corpus, {
unity_root = args.unity_root, unity_root = args.unity_root,
project_root = project_root, project_root = project_root,
@@ -511,27 +651,36 @@ local function build_ctx(args)
end end
resolution = resolved resolution = resolved
else else
--- @type SourceFile[]
local source_order = {} local source_order = {}
--- @type table<Path, SourceFile>
local sources_by_path = {} local sources_by_path = {}
--- @type SourceResolver
local resolver = { local resolver = {
resolved = {}, resolved = {},
skipped = {}, skipped = {},
shadowed = {}, shadowed = {},
} }
--- @type integer, string
for _, input_path in ipairs(args.sources) do for _, input_path in ipairs(args.sources) do
--- @type string
local path = duffle.normalize_path(input_path) local path = duffle.normalize_path(input_path)
--- @type boolean, string
local key_ok, key_or_error = pcall(duffle.canonical_path_key, path) local key_ok, key_or_error = pcall(duffle.canonical_path_key, path)
if not key_ok then if not key_ok then
error("ps1_meta: invalid --source " .. input_path .. ": " .. tostring(key_or_error), 0) error("ps1_meta: invalid --source " .. input_path .. ": " .. tostring(key_or_error), 0)
end end
--- @type file*|nil
local file = io.open(path, "r") local file = io.open(path, "r")
if not file then if not file then
io.stderr:write("ps1_meta: cannot open --source " .. input_path .. "\n") io.stderr:write("ps1_meta: cannot open --source " .. input_path .. "\n")
os.exit(EXIT_INTERNAL_ERROR) os.exit(EXIT_INTERNAL_ERROR)
end end
--- @type string
local text = file:read("*a") local text = file:read("*a")
file:close() file:close()
--- @type SourceFile
local source = { local source = {
path = path, path = path,
text = text, text = text,
@@ -539,6 +688,7 @@ local function build_ctx(args)
basename = duffle.basename_no_ext(path), basename = duffle.basename_no_ext(path),
} }
source_order[#source_order + 1] = source source_order[#source_order + 1] = source
--- @type string
local key = key_or_error local key = key_or_error
if not sources_by_path[key] then sources_by_path[key] = source end if not sources_by_path[key] then sources_by_path[key] = source end
resolver.resolved[#resolver.resolved + 1] = { resolver.resolved[#resolver.resolved + 1] = {
@@ -563,6 +713,7 @@ local function build_ctx(args)
} }
end end
--- @type Corpus
local corpus = { local corpus = {
unity_root = resolution.unity_root, unity_root = resolution.unity_root,
project_root = resolution.project_root, project_root = resolution.project_root,
@@ -584,6 +735,7 @@ local function build_ctx(args)
collisions = {}, collisions = {},
resolver = resolution.resolver, resolver = resolution.resolver,
} }
--- @type PassCtx
local ctx = { local ctx = {
metadata_path = args.metadata, metadata_path = args.metadata,
shared = { corpus = corpus }, shared = { corpus = corpus },
@@ -613,14 +765,20 @@ end
--- Keeping these blocks local makes the topological sort self-contained. --- Keeping these blocks local makes the topological sort self-contained.
local function topo_sort(passes, requested_set) local function topo_sort(passes, requested_set)
-- Dependency closure: include every pass transitively required by `requested_set`. -- Dependency closure: include every pass transitively required by `requested_set`.
--- @type table<string, boolean> -- bag: pass name -> needed
local needed = {} local needed = {}
--- @type integer, string
for _, name in ipairs(requested_set) do needed[name] = true end for _, name in ipairs(requested_set) do needed[name] = true end
--- @type boolean
local changed = true local changed = true
while changed do while changed do
changed = false changed = false
--- @type string, boolean
for name, _ in pairs(needed) do for name, _ in pairs(needed) do
--- @type PassDescriptor
local pass = passes[name] local pass = passes[name]
if not pass then error("unknown pass '" .. name .. "' requested") end if not pass then error("unknown pass '" .. name .. "' requested") end
--- @type integer, string
for _, dep in ipairs(pass.deps) do for _, dep in ipairs(pass.deps) do
if not needed[dep] then if not needed[dep] then
needed[dep] = true needed[dep] = true
@@ -631,9 +789,13 @@ local function topo_sort(passes, requested_set)
end end
-- In-degree calculation: count each needed pass's needed dependencies. -- In-degree calculation: count each needed pass's needed dependencies.
--- @type table<string, integer> -- bag: pass name -> in-degree
local in_degree = {} local in_degree = {}
--- @type string, boolean
for name, _ in pairs(needed) do in_degree[name] = 0 end for name, _ in pairs(needed) do in_degree[name] = 0 end
--- @type string, boolean
for name, _ in pairs(needed) do for name, _ in pairs(needed) do
--- @type integer, string
for _, dep in ipairs(passes[name].deps) do for _, dep in ipairs(passes[name].deps) do
if needed[dep] then if needed[dep] then
in_degree[name] = in_degree[name] + 1 in_degree[name] = in_degree[name] + 1
@@ -642,7 +804,9 @@ local function topo_sort(passes, requested_set)
end end
-- Ready-queue seeding: add zero-in-degree passes in deterministic order. -- Ready-queue seeding: add zero-in-degree passes in deterministic order.
--- @type string[]
local ready = {} local ready = {}
--- @type string, integer
for name, deg in pairs(in_degree) do for name, deg in pairs(in_degree) do
if deg == 0 then ready[#ready + 1] = name end if deg == 0 then ready[#ready + 1] = name end
end end
@@ -650,12 +814,16 @@ local function topo_sort(passes, requested_set)
-- Ready-queue drain: decrement dependents when each pass is emitted. -- Ready-queue drain: decrement dependents when each pass is emitted.
-- Newly-zero-degree passes are inserted back into the ready queue (kept sorted). -- Newly-zero-degree passes are inserted back into the ready queue (kept sorted).
--- @type string[]
local order = {} local order = {}
while #ready > 0 do while #ready > 0 do
--- @type string
local just_finished = table.remove(ready, 1) local just_finished = table.remove(ready, 1)
order[#order + 1] = just_finished order[#order + 1] = just_finished
--- @type string, boolean
for name, _ in pairs(needed) do for name, _ in pairs(needed) do
if name ~= just_finished then if name ~= just_finished then
--- @type integer, string
for _, dep in ipairs(passes[name].deps) do for _, dep in ipairs(passes[name].deps) do
if dep == just_finished then if dep == just_finished then
in_degree[name] = in_degree[name] - 1 in_degree[name] = in_degree[name] - 1
@@ -672,9 +840,12 @@ local function topo_sort(passes, requested_set)
-- Cycle detection: if `order` doesn't include all needed passes, some are stuck with in_degree > 0 -- Cycle detection: if `order` doesn't include all needed passes, some are stuck with in_degree > 0
-- (the cycle closed on itself before Kahn could process them). -- (the cycle closed on itself before Kahn could process them).
-- Without this check, a fully-closed cycle (e.g. A -> B -> A) would silently return an empty order list, leaving the orchestrator to dispatch nothing. -- Without this check, a fully-closed cycle (e.g. A -> B -> A) would silently return an empty order list, leaving the orchestrator to dispatch nothing.
--- @type integer
local needed_count = 0 local needed_count = 0
--- @type string
for _ in pairs(needed) do needed_count = needed_count + 1 end -- count hash entries; Lua's #t doesn't work for _ in pairs(needed) do needed_count = needed_count + 1 end -- count hash entries; Lua's #t doesn't work
if #order ~= needed_count then if #order ~= needed_count then
--- @type string, integer
for name, deg in pairs(in_degree) do for name, deg in pairs(in_degree) do
if deg > 0 then if deg > 0 then
error("dependency cycle detected involving pass '" .. name .. "'") error("dependency cycle detected involving pass '" .. name .. "'")
@@ -696,8 +867,10 @@ end
--- @param result PassResult --- @param result PassResult
--- @return boolean --- @return boolean
local function report_validation_errors(pass_name, pass, result) local function report_validation_errors(pass_name, pass, result)
--- @type boolean
local has_errors = result.errors and #result.errors > 0 local has_errors = result.errors and #result.errors > 0
if not has_errors then return false end if not has_errors then return false end
--- @type integer, PassFinding
for _, e in ipairs(result.errors) do for _, e in ipairs(result.errors) do
io.stderr:write(string.format("[%s] line %d: %s\n", pass_name, e.line or 0, e.msg or "")) io.stderr:write(string.format("[%s] line %d: %s\n", pass_name, e.line or 0, e.msg or ""))
end end
@@ -709,10 +882,15 @@ end
--- @param order string[] --- @param order string[]
--- @return boolean -- true if any validation errors were reported --- @return boolean -- true if any validation errors were reported
local function dispatch_passes(ctx, order) local function dispatch_passes(ctx, order)
--- @type boolean
local had_errors = false local had_errors = false
--- @type integer, string
for _, pass_name in ipairs(order) do for _, pass_name in ipairs(order) do
--- @type PassDescriptor
local pass = PASSES[pass_name] local pass = PASSES[pass_name]
--- @type PassModule
local mod = require(pass.module) local mod = require(pass.module)
--- @type PassResult
local result = mod.run(ctx) local result = mod.run(ctx)
if report_validation_errors(pass_name, pass, result) then if report_validation_errors(pass_name, pass, result) then
had_errors = true had_errors = true
@@ -723,14 +901,21 @@ end
--- Main entry point. Runs the requested passes in dep-topological order. --- Main entry point. Runs the requested passes in dep-topological order.
--- @param argv string[] --- @param argv string[]
--- @return nil
local function main(argv) local function main(argv)
--- @type boolean, string|nil
local ok, err = pcall(function() local ok, err = pcall(function()
--- @type ParsedArgs
local args = parse_args(argv) local args = parse_args(argv)
--- @type PassCtx
local ctx = build_ctx(args) local ctx = build_ctx(args)
--- @type string[]
local requested = args.requested_set local requested = args.requested_set
--- @type string[]
local closed = topo_sort(PASSES, requested) local closed = topo_sort(PASSES, requested)
--- @type boolean
local had_errors = dispatch_passes(ctx, closed) local had_errors = dispatch_passes(ctx, closed)
if had_errors then os.exit(EXIT_VALIDATION_ERRORS) end if had_errors then os.exit(EXIT_VALIDATION_ERRORS) end
end) end)
@@ -746,6 +931,7 @@ end
-- Module export for in-process consumers (tests that dofile this script). -- Module export for in-process consumers (tests that dofile this script).
-- The conditional `main(...)` call below only fires when this file is invoked as the entry script (arg[0] ends in "ps1_meta.lua"); -- The conditional `main(...)` call below only fires when this file is invoked as the entry script (arg[0] ends in "ps1_meta.lua");
-- in dofile() mode (test's arg[0] does not match), main() is skipped and the chunk returns `_M` to the caller. -- in dofile() mode (test's arg[0] does not match), main() is skipped and the chunk returns `_M` to the caller.
--- @type Ps1MetaMod
local _M = { local _M = {
PASSES = PASSES, PASSES = PASSES,
PASS_KIND_STOP_ON_ERROR = PASS_KIND_STOP_ON_ERROR, PASS_KIND_STOP_ON_ERROR = PASS_KIND_STOP_ON_ERROR,