mirror of
https://github.com/Ed94/pikuma_ps1.git
synced 2026-07-12 20:31:25 -07:00
Lua Metaprogram: Scan codepaths collapse + more reviews.
This commit is contained in:
+52
-659
@@ -3,6 +3,10 @@
|
||||
--- Validates `MipsAtom_(name) atom_info(atom_bind(Binds_X), atom_reads(...), atom_writes(...)) { ... }` declarations in source files.
|
||||
--- Also reads: `Binds_*` struct declarations (`typedef Struct_(Binds_X) { ... };`)
|
||||
---
|
||||
--- Source scanning: done ONCE upstream by `duffle.scan_source()` (ps1_meta.lua pre-scans each
|
||||
--- source and stashes the result in `src.scan`). This pass is pure: read from the scan, run
|
||||
--- checks, emit findings. No source re-walking.
|
||||
---
|
||||
--- Writes:
|
||||
--- - `<ctx.out_root>/<dir_basename>.errors.h` — one per module, with `#error` directives on findings (the C compile will surface the error)
|
||||
--- - The annotations.txt report is rendered by `passes/report.lua` from the per-module results stashed in `ctx.flags._annot_results`
|
||||
@@ -18,23 +22,8 @@ local _src = debug.getinfo(1, "S").source:sub(2)
|
||||
local _dir = _src:match("(.*[/\\])") or "./"
|
||||
dofile(_dir .. "../duffle_paths.lua")
|
||||
local duffle = require("duffle")
|
||||
local is_space = duffle.is_space
|
||||
local is_alpha = duffle.is_alpha
|
||||
local is_alnum = duffle.is_alnum
|
||||
local trim = duffle.trim
|
||||
local find_byte = duffle.find_byte
|
||||
local read_file = duffle.read_file
|
||||
local write_file = duffle.write_file
|
||||
local ensure_dir = duffle.ensure_dir
|
||||
local dirname = duffle.dirname
|
||||
local basename_no_ext = duffle.basename_no_ext
|
||||
local skip_str_or_cmt = duffle.skip_str_or_cmt
|
||||
local skip_ws_and_cmt = duffle.skip_ws_and_cmt
|
||||
local read_ident = duffle.read_ident
|
||||
local read_parens = duffle.read_parens
|
||||
local read_braces = duffle.read_braces
|
||||
local scan_to_char = duffle.scan_to_char
|
||||
local split_top_level_commas = duffle.split_top_level_commas
|
||||
|
||||
-- Domain tables (single source of truth in duffle.lua).
|
||||
local WAVE_CONTEXT_REGS = duffle.WAVE_CONTEXT_REGS
|
||||
@@ -42,43 +31,6 @@ local TAPE_ATOM_MACROS = duffle.TAPE_ATOM_MACROS
|
||||
|
||||
local function is_wave_context_reg(n) return WAVE_CONTEXT_REGS[n] ~= nil end
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Constants
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Atom declaration + annotation identifiers.
|
||||
local ATOM_DECL = "MipsAtom_"
|
||||
local ATOM_INFO = "atom_info"
|
||||
local STRUCT_TYPE = "Struct_"
|
||||
local PRAGMA_IDENT = "pragma"
|
||||
local PRAGMA_OPERATOR = "_Pragma"
|
||||
|
||||
-- Struct-name prefix + byte size of U4 fields.
|
||||
local BINDS_PREFIX = "Binds_"
|
||||
local BINDS_PREFIX_LEN = 6 -- = #BINDS_PREFIX
|
||||
local U4_TYPE = "U4"
|
||||
local U4_BYTES = 4 -- sizeof(U4)
|
||||
local BINDS_FIELD_PREFIX = "R_" -- wave-context register name prefix
|
||||
|
||||
-- TAPE_WORDS pragma keys (the third token after #pragma).
|
||||
local WORDS_KEY = "words"
|
||||
local WORDS_KEY_PREFIX = "words=" -- the per-macro `words=N` form
|
||||
local WORDS_KEY_PREFIX_LEN = 6 -- = #WORDS_KEY_PREFIX
|
||||
local TAPE_ATOM_WORDS_KEY = "tape_atom words" -- the _Pragma form
|
||||
|
||||
-- ASCII byte values used in tokenization.
|
||||
local BYTE_NEWLINE = 10
|
||||
local BYTE_SPACE = 32
|
||||
local BYTE_DQUOTE = 34
|
||||
local BYTE_EQUALS = 61
|
||||
local BYTE_OPEN_PAREN = 40
|
||||
local BYTE_OPEN_BRACE = 123
|
||||
local BYTE_OPEN_BRACK = 91
|
||||
local BYTE_CLOSE_PAREN = 41
|
||||
local BYTE_CLOSE_BRACE = 125
|
||||
local BYTE_CLOSE_BRACK = 93
|
||||
local BYTE_COMMA = 44
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Type declarations
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -88,6 +40,7 @@ local BYTE_COMMA = 44
|
||||
--- @field text string -- the full source text
|
||||
--- @field dir string -- the 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[]
|
||||
@@ -107,10 +60,6 @@ local BYTE_COMMA = 44
|
||||
--- @field errors table[]
|
||||
--- @field warnings table[]
|
||||
|
||||
--- @class Atom
|
||||
--- @field line integer -- source line of the MipsAtom_ declaration
|
||||
--- @field name string -- atom name (e.g. "cube_g4_face")
|
||||
|
||||
--- @class AtomAnnotation
|
||||
--- @field line integer -- source line of the atom_info call
|
||||
--- @field macro string -- the macro name (always "atom_info" in the new shape)
|
||||
@@ -119,599 +68,64 @@ local BYTE_COMMA = 44
|
||||
--- @field binds string|nil -- Binds_X name if any
|
||||
--- @field reads string[] -- R_* names (read targets)
|
||||
--- @field writes string[] -- R_* names (write targets)
|
||||
--- @field error string|nil -- error message if annotation was malformed
|
||||
--- @field errors string[] -- nested errors from per-arg validation
|
||||
|
||||
--- @class BindsField
|
||||
--- @field name string -- field name
|
||||
--- @field offset integer -- byte offset within the Binds_X struct
|
||||
|
||||
--- @class BindsStruct
|
||||
--- @field name string -- struct name (e.g. "Binds_Floor")
|
||||
--- @field line integer -- source line of the typedef
|
||||
--- @field bytes integer -- total byte size
|
||||
--- @field fields BindsField[] -- the field list
|
||||
|
||||
--- @class MacroEntry
|
||||
--- @field name string -- macro name (e.g. "mac_format_f3_color")
|
||||
--- @field line integer -- source line of the TAPE_WORDS pragma
|
||||
--- @field words integer -- declared word count
|
||||
|
||||
--- @class Finding
|
||||
--- @field line integer -- source line (or 0 for pass-level)
|
||||
--- @field msg string -- finding message
|
||||
|
||||
--- @class AnnotatedResult
|
||||
--- @field atoms Atom[]
|
||||
--- @field atoms AtomEntry[]
|
||||
--- @field annots AtomAnnotation[]
|
||||
--- @field macros MacroEntry[]
|
||||
--- @field binds BindsStruct[]
|
||||
--- @field binds BindsEntry[]
|
||||
--- @field errors Finding[]
|
||||
--- @field warnings Finding[]
|
||||
--- @field info Finding[]
|
||||
|
||||
--- ════════════════════════════════════════════════════════════════════════════
|
||||
-- split helpers
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
--- Split a string at top-level commas. Used inside TAPE_ATOM_* macro
|
||||
--- bodies where nested parens/braces/brackets are possible.
|
||||
--- @param s string
|
||||
--- @return string[]
|
||||
local function split_csv_top(s)
|
||||
local tokens = {}
|
||||
local pos = 1
|
||||
local chunk_a = 1
|
||||
local depth = 0
|
||||
local str_len = #s
|
||||
while pos <= str_len do
|
||||
local ch = s:byte(pos)
|
||||
if ch == BYTE_OPEN_PAREN or ch == BYTE_OPEN_BRACE or ch == BYTE_OPEN_BRACK then
|
||||
depth = depth + 1
|
||||
pos = pos + 1
|
||||
elseif ch == BYTE_CLOSE_PAREN or ch == BYTE_CLOSE_BRACE or ch == BYTE_CLOSE_BRACK then
|
||||
depth = depth - 1
|
||||
pos = pos + 1
|
||||
elseif ch == BYTE_COMMA and depth == 0 then
|
||||
tokens[#tokens + 1] = s:sub(chunk_a, pos - 1)
|
||||
pos = pos + 1
|
||||
chunk_a = pos
|
||||
else
|
||||
pos = pos + 1
|
||||
end
|
||||
end
|
||||
local last = s:sub(chunk_a)
|
||||
if trim(last) ~= "" then tokens[#tokens + 1] = last end
|
||||
return tokens
|
||||
end
|
||||
|
||||
--- Split a string into whitespace-separated tokens.
|
||||
--- @param s string
|
||||
--- @return string[]
|
||||
local function split_ws(s)
|
||||
local tokens = {}
|
||||
local pos = 1
|
||||
local n = 1
|
||||
local len = #s
|
||||
while pos <= len do
|
||||
-- Skip whitespace.
|
||||
while pos <= len and is_space(s:sub(pos, pos)) do pos = pos + 1 end
|
||||
if pos > len then break end
|
||||
local chunk_a = pos
|
||||
-- Take non-whitespace run.
|
||||
while pos <= len and not is_space(s:sub(pos, pos)) do pos = pos + 1 end
|
||||
tokens[n] = s:sub(chunk_a, pos - 1)
|
||||
n = n + 1
|
||||
end
|
||||
return tokens
|
||||
end
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Parse TAPE_ATOM_ANNOT(...) calls
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Recognize a `atom_bind(...)`, `atom_reads(...)`, or `atom_writes(...)` sub-call embedded inside an atom_info arg list.
|
||||
-- Returns the kind ("atom_bind" / "atom_reads" / "atom_writes") and the inner content, or nil if the token isn't a recognized sub-call form.
|
||||
-- Flattened via a prefix lookup instead of a nested if/elseif chain.
|
||||
local REGS_CALL_PREFIX = {
|
||||
["atom_writes("] = { kind = "atom_writes", inner_offset = 13 },
|
||||
["atom_reads("] = { kind = "atom_reads", inner_offset = 12 },
|
||||
["atom_bind("] = { kind = "atom_bind", inner_offset = 11, single_ident = true },
|
||||
}
|
||||
|
||||
local function parse_regs_call(s)
|
||||
if s:sub(-1) ~= ")" then return nil end
|
||||
-- Try longest prefix first so "atom_writes(" wins over "atom_reads(" when both 12-char prefixes would otherwise match.
|
||||
-- Lengths:
|
||||
-- atom_writes( = 12 chars, offset 13
|
||||
-- atom_reads( = 11 chars, offset 12
|
||||
-- atom_bind( = 10 chars, offset 11
|
||||
local spec = REGS_CALL_PREFIX[s:sub(1, 12)]
|
||||
if not spec then spec = REGS_CALL_PREFIX[s:sub(1, 11)] end
|
||||
if not spec then spec = REGS_CALL_PREFIX[s:sub(1, 10)] end
|
||||
if not spec then return nil end
|
||||
local inner = s:sub(spec.inner_offset, -2)
|
||||
if spec.single_ident then
|
||||
-- atom_bind takes a single Binds_* type ident. Trim and pass through.
|
||||
return spec.kind, trim(inner)
|
||||
end
|
||||
return spec.kind, inner
|
||||
end
|
||||
|
||||
-- Parse a comma-separated inner content (e.g. inside atom_reads(...)) into a list of trimmed identifiers.
|
||||
local function parse_regs_list(inner)
|
||||
local out = {}
|
||||
for _, r in ipairs(split_csv_top(inner)) do
|
||||
local trimmed = trim(r)
|
||||
if trimmed ~= "" then out[#out + 1] = trimmed end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Parse a single token (from split_csv_top) into an arg entry.
|
||||
-- Three forms: register-list call, bare identifier, "other" (preserved as text).
|
||||
local function parse_arg_token(s)
|
||||
local kind, inner = parse_regs_call(s)
|
||||
if kind then
|
||||
if kind == "atom_bind" then
|
||||
return { kind = kind, value = inner } -- single ident, not a list
|
||||
end
|
||||
return { kind = kind, value = parse_regs_list(inner) }
|
||||
end
|
||||
local id = read_ident(s, 1)
|
||||
if id and trim(s) == id then
|
||||
return { kind = "ident", value = id }
|
||||
end
|
||||
return { kind = "other", value = s }
|
||||
end
|
||||
|
||||
--- Extract identifier args from a parenthesized group.
|
||||
--- Returns a list of {kind, value} pairs where kind is one of:
|
||||
--- "ident" -- a bare identifier (e.g. a reserved token)
|
||||
--- "atom_reads" -- an atom_reads(...) call: value is the register list
|
||||
--- "atom_writes" -- an atom_writes(...) call: value is the register list
|
||||
--- "other" -- something we can't classify (preserved as text)
|
||||
local function parse_atom_info_args(inner)
|
||||
local args = {}
|
||||
for _, tok in ipairs(split_csv_top(inner)) do
|
||||
local s = trim(tok)
|
||||
if s ~= "" then
|
||||
args[#args + 1] = parse_arg_token(s)
|
||||
end
|
||||
end
|
||||
return args
|
||||
end
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Parse TAPE_WORDS(mac_X, N) pragma directives
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
--- Skip preprocessor directives (lines starting with `#`).
|
||||
--- Returns the position past the newline at the end of the line.
|
||||
--- @param source string
|
||||
--- @param pos integer
|
||||
--- @return integer
|
||||
--- Parse `_Pragma("mac_X tape_atom words=N")` (operator form).
|
||||
--- @param source string
|
||||
--- @param ident_pos integer -- position of the `_Pragma` ident
|
||||
--- @param after_ident integer -- position just past the ident
|
||||
--- @return MacroEntry|nil, integer -- (entry or nil, new source position)
|
||||
local function parse_pragma_operator(source, ident_pos, after_ident)
|
||||
local open_paren = skip_ws_and_cmt(source, after_ident)
|
||||
if source:byte(open_paren) ~= BYTE_OPEN_PAREN then
|
||||
return nil, open_paren + 1
|
||||
end
|
||||
local str, str_end = read_parens(source, open_paren)
|
||||
-- scan: _Pragma(<string>)
|
||||
str = trim(str)
|
||||
if str:sub(1, 1) ~= '"' or str:sub(-1) ~= '"' then
|
||||
return nil, str_end
|
||||
end
|
||||
local inner = str:sub(2, -2)
|
||||
local space = find_byte(inner, BYTE_SPACE, 1)
|
||||
if not space then return nil, str_end end
|
||||
local name = inner:sub(1, space - 1)
|
||||
local rest = inner:sub(space + 1)
|
||||
local eq = find_byte(rest, BYTE_EQUALS, 1)
|
||||
if not eq then return nil, str_end end
|
||||
local key = trim(rest:sub(1, eq - 1))
|
||||
local val = trim(rest:sub(eq + 1))
|
||||
if key ~= TAPE_ATOM_WORDS_KEY and key ~= WORDS_KEY then return nil, str_end end
|
||||
return {
|
||||
line = source:sub(1, ident_pos) and 0 or 0, -- see line_of below
|
||||
name = name,
|
||||
words = tonumber(val) or 0,
|
||||
}, str_end
|
||||
end
|
||||
|
||||
--- Parse `#pragma mac_X tape_atom words=N` (directive form).
|
||||
--- @param source string
|
||||
--- @param ident_pos integer -- position of the `pragma` ident
|
||||
--- @param after_ident integer -- position just past the ident
|
||||
--- @return MacroEntry|nil, integer -- (entry or nil, new source position)
|
||||
local function parse_pragma_directive(source, ident_pos, after_ident)
|
||||
local str_len = #source
|
||||
local rest_start = skip_ws_and_cmt(source, after_ident)
|
||||
local eol = rest_start
|
||||
while eol <= str_len and source:byte(eol) ~= BYTE_NEWLINE do
|
||||
eol = eol + 1
|
||||
end
|
||||
local line_text = trim(source:sub(rest_start, eol - 1))
|
||||
-- scan: #pragma <mac_name> tape_atom words=<N>
|
||||
local tokens = split_ws(line_text)
|
||||
local entry
|
||||
if #tokens >= 3 and tokens[2] == "tape_atom" and tokens[3]:sub(1, WORDS_KEY_PREFIX_LEN) == WORDS_KEY_PREFIX then
|
||||
entry = {
|
||||
name = tokens[1],
|
||||
words = tonumber(tokens[3]:sub(WORDS_KEY_PREFIX_LEN + 1)) or 0,
|
||||
}
|
||||
elseif #tokens >= 2 and tokens[2]:sub(1, WORDS_KEY_PREFIX_LEN) == WORDS_KEY_PREFIX then
|
||||
entry = {
|
||||
name = tokens[1],
|
||||
words = tonumber(tokens[2]:sub(WORDS_KEY_PREFIX_LEN + 1)) or 0,
|
||||
}
|
||||
end
|
||||
if entry then
|
||||
local line_of = duffle.LineIndex(source)
|
||||
entry.line = line_of(ident_pos)
|
||||
end
|
||||
return entry, eol
|
||||
end
|
||||
|
||||
--- Find every `TAPE_WORDS(mac_X, N)` pragma in source.
|
||||
--- Accepts both forms:
|
||||
--- `_Pragma("mac_X tape_atom words=N")` (operator form)
|
||||
--- `#pragma mac_X tape_atom words=N` (directive form)
|
||||
--- @param source string
|
||||
--- @return MacroEntry[]
|
||||
local function find_macro_word_annotations(source)
|
||||
local out = {}
|
||||
local pos = 1
|
||||
local str_len = #source
|
||||
while pos <= str_len do
|
||||
pos = skip_ws_and_cmt(source, pos)
|
||||
if pos > str_len then break end
|
||||
|
||||
-- Skip preprocessor directives (lines starting with #).
|
||||
-- (_Pragma is an operator, not a directive — it doesn't start with #.)
|
||||
if source:byte(pos) == 35 then -- '#'
|
||||
pos = duffle.skip_preprocessor_line(source, pos)
|
||||
goto continue
|
||||
end
|
||||
|
||||
local ident, after_ident = read_ident(source, pos)
|
||||
-- scan: <ident>
|
||||
if not ident then pos = pos + 1; goto continue end
|
||||
|
||||
if ident == PRAGMA_OPERATOR then
|
||||
-- scan: _Pragma(...)
|
||||
local entry, new_pos = parse_pragma_operator(source, pos, after_ident)
|
||||
if entry then
|
||||
local line_of = duffle.LineIndex(source)
|
||||
entry.line = line_of(pos)
|
||||
out[#out + 1] = entry
|
||||
end
|
||||
pos = new_pos
|
||||
elseif ident == PRAGMA_IDENT then
|
||||
-- scan: #pragma <mac_name> tape_atom words=<N>
|
||||
local entry, new_pos = parse_pragma_directive(source, pos, after_ident)
|
||||
if entry then out[#out + 1] = entry end
|
||||
pos = new_pos
|
||||
else
|
||||
pos = after_ident
|
||||
end
|
||||
|
||||
::continue::
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Parse `typedef Struct_(Binds_X) { ... };` declarations
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
--- Walk a `Binds_X` body string and extract U4 fields (name + byte offset).
|
||||
--- Only U4 fields are tracked (Binds_* are always word arrays in this
|
||||
--- codebase -- pointers stored as U4, indices as U4, etc.).
|
||||
--- @param body string -- the brace-delimited body (without the braces)
|
||||
--- @return BindsField[] -- field list
|
||||
--- @return integer -- total byte size
|
||||
local function parse_binds_body(body)
|
||||
local fields = {}
|
||||
local byte_off = 0
|
||||
local pos = 1
|
||||
local body_len = #body
|
||||
while pos <= body_len do
|
||||
pos = skip_ws_and_cmt(body, pos)
|
||||
if pos > body_len then break end
|
||||
local type_ident, type_after = read_ident(body, pos)
|
||||
if not type_ident then
|
||||
pos = pos + 1
|
||||
elseif type_ident == U4_TYPE then
|
||||
local field_after = skip_ws_and_cmt(body, type_after)
|
||||
local fid, fafter = read_ident(body, field_after)
|
||||
if fid then
|
||||
fields[#fields + 1] = { name = fid, offset = byte_off }
|
||||
byte_off = byte_off + U4_BYTES
|
||||
end
|
||||
pos = fafter or (type_after + 1)
|
||||
else
|
||||
pos = type_after + 1
|
||||
end
|
||||
end
|
||||
return fields, byte_off
|
||||
end
|
||||
|
||||
--- Try to parse a `typedef Struct_(Binds_X) { ... };` declaration.
|
||||
--- Returns the parsed BindsStruct (if the form matched) and the new source position.
|
||||
--- If the form didn't match, returns nil + a position to continue scanning from.
|
||||
--- @param source string
|
||||
--- @param ident_pos integer -- position of the `typedef` ident start
|
||||
--- @param after_typedef integer -- position just past `typedef`
|
||||
--- @param line_of fun(pos: integer): integer
|
||||
--- @return BindsStruct|nil, integer
|
||||
local function parse_typedef_binds(source, ident_pos, after_typedef, line_of)
|
||||
local after_type = skip_ws_and_cmt(source, after_typedef)
|
||||
local type_ident, after_type_ident = read_ident(source, after_type)
|
||||
-- scan: typedef <type_ident>
|
||||
if type_ident ~= STRUCT_TYPE then
|
||||
return nil, after_type_ident or (after_type + 1)
|
||||
end
|
||||
|
||||
local open_paren = skip_ws_and_cmt(source, after_type_ident)
|
||||
if source:byte(open_paren) ~= BYTE_OPEN_PAREN then
|
||||
return nil, open_paren + 1
|
||||
end
|
||||
|
||||
local inner, after_paren = read_parens(source, open_paren)
|
||||
-- scan: typedef Struct_(<name>)
|
||||
local name = trim(inner)
|
||||
|
||||
local brace = scan_to_char(source, "{", after_paren)
|
||||
-- scan: typedef Struct_(<name>) {
|
||||
if not brace then return nil, open_paren + 1 end
|
||||
|
||||
local body, after_brace = read_braces(source, brace)
|
||||
-- scan: typedef Struct_(<name>) { <fields> }
|
||||
local fields, bytes = parse_binds_body(body)
|
||||
|
||||
-- Only emit Binds_* structs (other Struct_ typedefs are ignored).
|
||||
if name:sub(1, BINDS_PREFIX_LEN) ~= BINDS_PREFIX then
|
||||
return nil, after_brace
|
||||
end
|
||||
|
||||
return {
|
||||
line = line_of(ident_pos),
|
||||
name = name,
|
||||
fields = fields,
|
||||
bytes = bytes,
|
||||
}, after_brace
|
||||
end
|
||||
|
||||
--- Find every `Binds_*` struct declaration.
|
||||
--- @param source string
|
||||
--- @return BindsStruct[]
|
||||
local function find_binds_structs(source)
|
||||
local line_of = duffle.LineIndex(source)
|
||||
local out = {}
|
||||
local pos = 1
|
||||
local str_len = #source
|
||||
while pos <= str_len do
|
||||
pos = skip_ws_and_cmt(source, pos)
|
||||
if pos > str_len then break end
|
||||
|
||||
if source:byte(pos) == 35 then -- '#'
|
||||
pos = duffle.skip_preprocessor_line(source, pos)
|
||||
goto continue
|
||||
end
|
||||
|
||||
local ident, after_ident = read_ident(source, pos)
|
||||
-- scan: <ident>
|
||||
if not ident then pos = pos + 1; goto continue end
|
||||
if ident ~= "typedef" then pos = after_ident; goto continue end
|
||||
|
||||
-- scan: typedef Struct_(<name>) { <fields> }
|
||||
local binds_struct, new_pos = parse_typedef_binds(source, pos, after_ident, line_of)
|
||||
if binds_struct then out[#out + 1] = binds_struct end
|
||||
pos = new_pos
|
||||
|
||||
::continue::
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Find every MipsAtom_(name) { ... } declaration in source
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
--- Read the next identifier token from `s` starting at `pos`, where the identifier is a contiguous run of `[a-zA-Z0-9_]`
|
||||
--- characters (no underscore-starting alpha-only constraint).
|
||||
--- Returns the ident + the position just past it, or nil + pos if no identifier starts there.
|
||||
--- @param s string
|
||||
--- @param pos integer
|
||||
--- @return string|nil, integer
|
||||
local function read_alnum_ident(s, pos)
|
||||
local str_len = #s; while pos <= str_len and is_space(s:sub(pos, pos)) do pos = pos + 1 end
|
||||
local start = pos; while pos <= str_len and is_alnum(s:sub(pos, pos)) do pos = pos + 1 end
|
||||
if pos == start then return nil, pos end
|
||||
return s:sub(start, pos - 1), pos
|
||||
end
|
||||
|
||||
--- Find every `MipsAtom_(name)` declaration in source.
|
||||
--- (Just the name + source line; the body is parsed separately by `parse_mips_atom`.)
|
||||
--- @param source string
|
||||
--- @return Atom[]
|
||||
local function find_atom_names(source)
|
||||
local line_of = duffle.LineIndex(source)
|
||||
local out = {}
|
||||
local pos = 1
|
||||
local str_len = #source
|
||||
while pos <= str_len do
|
||||
pos = skip_ws_and_cmt(source, pos)
|
||||
if pos > str_len then break end
|
||||
|
||||
local ident, after_ident = read_ident(source, pos)
|
||||
-- scan: <ident>
|
||||
if not ident then pos = pos + 1; goto continue end
|
||||
if ident ~= ATOM_DECL then pos = after_ident; goto continue end
|
||||
|
||||
local open_paren = skip_ws_and_cmt(source, after_ident)
|
||||
if source:byte(open_paren) ~= BYTE_OPEN_PAREN then pos = open_paren + 1; goto continue end
|
||||
|
||||
local inner, after_paren = read_parens(source, open_paren)
|
||||
-- scan: MipsAtom_(<name>)
|
||||
local name, _ = read_alnum_ident(inner, 1)
|
||||
if name and name ~= "" then
|
||||
out[#out + 1] = { line = line_of(pos), name = name }
|
||||
end
|
||||
local brace = scan_to_char(source, "{", after_paren)
|
||||
-- scan: MipsAtom_(<name>) {
|
||||
if brace then
|
||||
local _, after_brace = read_braces(source, brace)
|
||||
-- scan: MipsAtom_(<name>) { <body> }
|
||||
pos = after_brace
|
||||
else
|
||||
pos = open_paren + 1
|
||||
end
|
||||
|
||||
::continue::
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Find atom annotations (atom_info + atom_bind / atom_reads / atom_writes)
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
--- True iff the parsed arg is a register-list call (any recognized form).
|
||||
local function is_regs_arg(a) return a and (a.kind == "atom_reads" or a.kind == "atom_writes" or a.kind == "regs") end
|
||||
|
||||
--- Per-macro arg-shape handlers. Each takes (entry, args) and mutates Per-atom_info sub-call dispatch.
|
||||
--- Each takes (entry, args) and mutates entry.{reads, writes, binds, errors}.
|
||||
--- All sub-calls are order-independent; each is dispatched on its `kind` (atom_bind / atom_reads / atom_writes) when parsed.
|
||||
local ANNOT_ARG_HANDLERS = {}
|
||||
|
||||
-- atom_info(atom_bind(Binds_X), atom_reads(...), atom_writes(...))
|
||||
function ANNOT_ARG_HANDLERS.info(entry, args)
|
||||
for _, arg in ipairs(args) do
|
||||
if arg.kind == "atom_bind" then entry.binds = arg.value
|
||||
elseif arg.kind == "atom_reads" then entry.reads = arg.value
|
||||
elseif arg.kind == "atom_writes" then entry.writes = arg.value
|
||||
elseif arg.kind == "ident" then
|
||||
-- Reserved for future use. Currently ignored.
|
||||
else
|
||||
entry.errors[#entry.errors + 1] = string.format("unexpected atom_info arg kind=%s value=%s", arg.kind, tostring(arg.value))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Build a new annotation entry with the standard shape.
|
||||
local function new_annot_entry(line, ident, name, kind)
|
||||
return {
|
||||
line = line,
|
||||
macro = ident,
|
||||
name = name,
|
||||
kind = kind,
|
||||
binds = nil,
|
||||
reads = {},
|
||||
writes = {},
|
||||
errors = {},
|
||||
}
|
||||
end
|
||||
|
||||
--- Try to parse an `atom_info(...)` call right after the `MipsAtom_(name)` parens.
|
||||
--- Returns the annotation entry (if present) and the new source position past the atom_info call.
|
||||
--- Returns nil if no atom_info follows.
|
||||
--- @param source string
|
||||
--- @param atom_name string
|
||||
--- @param after_mipsatom_paren integer -- position past the MipsAtom_(...) close paren
|
||||
--- @param line_of fun(pos: integer): integer
|
||||
--- @return AtomAnnotation|nil, integer -- (entry or nil, new source position)
|
||||
local function parse_atom_info_call(source, atom_name, after_mipsatom_paren, line_of)
|
||||
local lookahead = skip_ws_and_cmt(source, after_mipsatom_paren)
|
||||
local look_ident, look_after = read_ident(source, lookahead)
|
||||
-- scan: MipsAtom_(<name>) <look_ident>
|
||||
if look_ident ~= ATOM_INFO then return nil, after_mipsatom_paren end
|
||||
|
||||
local info_open = skip_ws_and_cmt(source, look_after)
|
||||
if source:byte(info_open) ~= BYTE_OPEN_PAREN then return nil, info_open + 1 end
|
||||
|
||||
local info_inner, info_after = read_parens(source, info_open)
|
||||
-- scan: MipsAtom_(<name>) atom_info(<binds>, <reads>, <writes>)
|
||||
local args = parse_atom_info_args(info_inner)
|
||||
local entry = new_annot_entry(line_of(lookahead), ATOM_INFO, atom_name, "info")
|
||||
ANNOT_ARG_HANDLERS.info(entry, args)
|
||||
return entry, info_after
|
||||
end
|
||||
|
||||
--- Find every `MipsAtom_(name) atom_info(...) { ... };` annotation in source.
|
||||
--- Returns a list of annotation entries. Atoms without a following `atom_info(...)` call produce NO entry
|
||||
--- (atoms without annotations are valid in the new minimal shape).
|
||||
--- @param source string
|
||||
--- @return AtomAnnotation[]
|
||||
local function find_atom_annotations(source)
|
||||
local line_of = duffle.LineIndex(source)
|
||||
local annots = {}
|
||||
local pos = 1
|
||||
local str_len = #source
|
||||
while pos <= str_len do
|
||||
pos = skip_ws_and_cmt(source, pos)
|
||||
if pos > str_len then break end
|
||||
|
||||
-- Skip preprocessor directives (lines starting with #).
|
||||
if source:byte(pos) == 35 then -- '#'
|
||||
pos = duffle.skip_preprocessor_line(source, pos)
|
||||
goto continue
|
||||
end
|
||||
|
||||
local ident, after_ident = read_ident(source, pos)
|
||||
-- scan: <ident>
|
||||
if not ident then pos = pos + 1; goto continue end
|
||||
if ident ~= ATOM_DECL then pos = after_ident; goto continue end
|
||||
|
||||
local open_paren = skip_ws_and_cmt(source, after_ident)
|
||||
if source:byte(open_paren) ~= BYTE_OPEN_PAREN then pos = open_paren + 1; goto continue end
|
||||
|
||||
local inner, after_paren = read_parens(source, open_paren)
|
||||
-- scan: MipsAtom_(<name>)
|
||||
local name, _ = read_alnum_ident(inner, 1)
|
||||
|
||||
local entry, new_pos = parse_atom_info_call(source, name, after_paren, line_of)
|
||||
-- scan: MipsAtom_(<name>) atom_info(<binds>, <reads>, <writes>)
|
||||
if entry then annots[#annots + 1] = entry end
|
||||
pos = new_pos
|
||||
|
||||
-- Skip past the body { ... } if present.
|
||||
local brace = scan_to_char(source, "{", pos)
|
||||
-- scan: MipsAtom_(<name>) atom_info(...) {
|
||||
if brace then
|
||||
local _, after_brace = read_braces(source, brace)
|
||||
-- scan: MipsAtom_(<name>) atom_info(...) { <body> }
|
||||
pos = after_brace
|
||||
end
|
||||
|
||||
::continue::
|
||||
end
|
||||
return annots
|
||||
end
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Validation
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
--
|
||||
-- Pure check: read from src.scan, run validations, emit findings.
|
||||
-- No source walking; no parsing. The scan was done once upstream.
|
||||
|
||||
--- Validate one source against its pre-scanned SourceScan payload.
|
||||
--- @param ctx PassCtx
|
||||
--- @param src SourceFile
|
||||
--- @return AnnotatedResult
|
||||
local function validate(ctx, src)
|
||||
local source = src.text
|
||||
local scan = src.scan
|
||||
|
||||
local annots = find_atom_annotations(source)
|
||||
local macros = find_macro_word_annotations(source)
|
||||
local binds = find_binds_structs(source)
|
||||
local atoms = find_atom_names(source)
|
||||
-- Project the pre-scanned atoms to the AtomEntry shape this pass needs.
|
||||
local atoms = {}
|
||||
for _, a in ipairs(scan.atoms) do
|
||||
if a.kind == "atom" then
|
||||
atoms[#atoms + 1] = { line = a.line, name = a.raw_name }
|
||||
end
|
||||
end
|
||||
|
||||
-- Project the pre-scanned atom_infos to AtomAnnotation shape.
|
||||
local annots = {}
|
||||
for _, info in ipairs(scan.atom_infos) do
|
||||
annots[#annots + 1] = {
|
||||
line = info.info_line,
|
||||
macro = "atom_info",
|
||||
name = info.atom_name,
|
||||
kind = "info",
|
||||
binds = info.binds,
|
||||
reads = info.reads or {},
|
||||
writes = info.writes or {},
|
||||
errors = {},
|
||||
}
|
||||
end
|
||||
|
||||
-- Index atoms by name for lookup.
|
||||
local atom_index = {}
|
||||
for _, a in ipairs(atoms) do atom_index[a.name] = a end
|
||||
|
||||
-- Index binds by name for lookup.
|
||||
local binds_index = {}
|
||||
for _, b in ipairs(binds) do binds_index[b.name] = b end
|
||||
for _, b in ipairs(scan.binds) do binds_index[b.name] = b end
|
||||
|
||||
local errors = {}
|
||||
local warnings = {}
|
||||
@@ -719,9 +133,7 @@ local function validate(ctx, src)
|
||||
|
||||
-- 1. Every annotated atom must exist as a real MipsAtom_ declaration.
|
||||
for _, a in ipairs(annots) do
|
||||
if a.error then
|
||||
errors[#errors + 1] = {line = a.line, msg = a.error}
|
||||
elseif not atom_index[a.name] then
|
||||
if not atom_index[a.name] then
|
||||
errors[#errors + 1] = {
|
||||
line = a.line,
|
||||
msg = string.format("annotation for '%s' has no matching MipsAtom_(%s) { ... }", a.name, a.name),
|
||||
@@ -734,11 +146,11 @@ local function validate(ctx, src)
|
||||
end
|
||||
end
|
||||
|
||||
-- 2. Every atom may have AT MOST ONE annotation (no duplicates).
|
||||
-- 2. Every atom may have AT MOST ONE annotation (no duplicates).
|
||||
-- (Atoms with ZERO annotations are valid in the new minimal shape.)
|
||||
local count_per_atom = {}
|
||||
for _, a in ipairs(annots) do
|
||||
if a.name and not a.error then
|
||||
if a.name then
|
||||
count_per_atom[a.name] = (count_per_atom[a.name] or 0) + 1
|
||||
end
|
||||
end
|
||||
@@ -751,8 +163,7 @@ local function validate(ctx, src)
|
||||
end
|
||||
end
|
||||
|
||||
-- 3. (Phase validity check DROPPED. Phases were removed from the annotation DSL.
|
||||
-- They may be reintroduced later as sub-calls of atom_info, at which point ordering checks will go here.)
|
||||
-- 3. (Phase validity check DROPPED. Phases were removed from the annotation DSL.)
|
||||
|
||||
-- 4. BIND atoms must reference a real Binds_* struct.
|
||||
for _, a in ipairs(annots) do
|
||||
@@ -760,7 +171,7 @@ local function validate(ctx, src)
|
||||
if not binds_index[a.binds] then
|
||||
-- Demoted from error to warning (2026-07-10): the same condition is now caught by passes/static_analysis.lua's
|
||||
-- check_abi_handoff() as an error. Emitting a warning here keeps the annotation pass from being stop-on-error
|
||||
-- for the common test-fixture case, while still surfacing the issue in the report.
|
||||
-- for the common test-fixture case, while still surfacing the issue in the report.
|
||||
-- The static-analysis report remains the source of truth for build-stopping errors.
|
||||
warnings[#warnings + 1] = {
|
||||
line = a.line,
|
||||
@@ -774,8 +185,6 @@ local function validate(ctx, src)
|
||||
for _, a in ipairs(annots) do
|
||||
if a.binds and binds_index[a.binds] then
|
||||
local bs = binds_index[a.binds]
|
||||
local field_names = {}
|
||||
for _, f in ipairs(bs.fields) do field_names[f.name] = true end
|
||||
|
||||
for _, f in ipairs(bs.fields) do
|
||||
local candidate = "R_" .. f.name
|
||||
@@ -812,7 +221,6 @@ local function validate(ctx, src)
|
||||
|
||||
-- 7. TAPE_WORDS(mac_X, N) ↔ WORD_COUNT(mac_X, N) drift.
|
||||
-- Three outcomes: missing (error), mismatch (error), match (info).
|
||||
-- Flattened via early-return-style helper instead of 3-way elseif.
|
||||
local function check_macro_drift(m, declared)
|
||||
if not declared then
|
||||
errors[#errors + 1] = {
|
||||
@@ -833,7 +241,7 @@ local function validate(ctx, src)
|
||||
msg = string.format("OK: %s = %d words", m.name, m.words),
|
||||
}
|
||||
end
|
||||
for _, m in ipairs(macros) do
|
||||
for _, m in ipairs(scan.macros) do
|
||||
check_macro_drift(m, ctx.shared.word_counts[m.name])
|
||||
end
|
||||
|
||||
@@ -841,14 +249,14 @@ local function validate(ctx, src)
|
||||
info[#info + 1] = {
|
||||
line = 0,
|
||||
msg = string.format("scanned: %d atom(s), %d annotation(s), %d macro-word-decl(s), %d binds struct(s)",
|
||||
#atoms, #annots, #macros, #binds),
|
||||
#atoms, #annots, #scan.macros, #scan.binds),
|
||||
}
|
||||
|
||||
return {
|
||||
atoms = atoms,
|
||||
annots = annots,
|
||||
macros = macros,
|
||||
binds = binds,
|
||||
macros = scan.macros,
|
||||
binds = scan.binds,
|
||||
errors = errors,
|
||||
warnings = warnings,
|
||||
info = info,
|
||||
@@ -858,18 +266,12 @@ end
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Per-DIRECTORY (per-module) output: errors.h + annotations.txt
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
--
|
||||
-- Per-source reports were the old behavior; each source in the same directory produced its own <basename>.errors.h + <basename>.annotations.txt,
|
||||
-- which flooded build/gen/ with one report per header.
|
||||
-- Aggregates per-DIRECTORY (one errors.h + one annotations.txt per module basename).
|
||||
-- Directories with zero atoms/annotations are skipped (no file emitted).
|
||||
|
||||
--- Render `<dir_basename>.errors.h` with `#error` directives for every error found across all sources in the directory.
|
||||
--- Render `<dir_basename>.errors.h` with `#error` directives for every error found across all sources in the directory.
|
||||
--- Empty directories (no errors, no atoms) produce no file.
|
||||
local function emit_module_errors_h(ctx, dir_basename, atoms_count, errors, sources)
|
||||
if ctx.dry_run then return nil end
|
||||
if atoms_count == 0 and #errors == 0 then
|
||||
-- Skip dirs with nothing to report
|
||||
return nil
|
||||
end
|
||||
local out_path = ctx.out_root .. "/" .. dir_basename .. ".errors.h"
|
||||
@@ -882,8 +284,6 @@ local function emit_module_errors_h(ctx, dir_basename, atoms_count, errors, sour
|
||||
if #errors == 0 then
|
||||
lines[#lines + 1] = "// annotation pass OK"
|
||||
else
|
||||
-- Prefix each error with the source basename for traceability
|
||||
-- in the C compile log.
|
||||
for _, e in ipairs(errors) do
|
||||
local src_tag = ""
|
||||
if e.source then
|
||||
@@ -907,7 +307,6 @@ local function emit_module_annotations_stub(ctx, dir, dir_basename, atoms_count)
|
||||
dir_basename = dir_basename,
|
||||
atoms_count = atoms_count,
|
||||
}
|
||||
-- annotations.txt is written by report.lua
|
||||
end
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -919,7 +318,6 @@ end
|
||||
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.
|
||||
-- Keeping it as a single shared function avoids the duplication that an earlier version of report.lua had.
|
||||
M.validate = validate
|
||||
|
||||
--- @param ctx PassCtx
|
||||
@@ -929,9 +327,8 @@ function M.run(ctx)
|
||||
local errors = {}
|
||||
local warnings = {}
|
||||
|
||||
-- Per-DIRECTORY (per-module) aggregation. Group sources by `src.dir`, validate every source in the dir, then emit ONE errors.h per dir
|
||||
-- (skipping dirs with no atoms AND no errors).
|
||||
-- The actual annotations.txt is rendered by passes/report.lua from the stashed per-module results below.
|
||||
-- Per-DIRECTORY (per-module) aggregation. Group sources by `src.dir`,
|
||||
-- validate every source in the dir, then emit ONE errors.h per dir.
|
||||
local by_dir = {}
|
||||
for _, src in ipairs(ctx.sources) do
|
||||
by_dir[src.dir] = by_dir[src.dir] or {}
|
||||
@@ -939,10 +336,8 @@ function M.run(ctx)
|
||||
end
|
||||
|
||||
for dir, dir_sources in pairs(by_dir) do
|
||||
-- Dir basename = last component of `dir` ("code/duffle" -> "duffle").
|
||||
local dir_basename = dir:match("([^/\\]+)$") or dir
|
||||
|
||||
-- Aggregate validate() results across the directory.
|
||||
local dir_atoms = 0
|
||||
local dir_errors = {}
|
||||
local dir_warnings = {}
|
||||
@@ -959,13 +354,11 @@ function M.run(ctx)
|
||||
end
|
||||
end
|
||||
|
||||
-- Emit one errors.h per dir.
|
||||
local err_path = emit_module_errors_h(ctx, dir_basename, dir_atoms, dir_errors, dir_sources)
|
||||
if err_path then
|
||||
table.insert(outputs, { errors_h = err_path })
|
||||
end
|
||||
|
||||
-- Stash for report pass.
|
||||
emit_module_annotations_stub(ctx, dir, dir_basename, dir_atoms)
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user