Repository navigation
fix(preprocess): attribute macro-expanded tokens to the use site - #508
Conversation
Expanded text does not appear in authored source, so the authored macro name is the only honest provenance. Offsetting generated tokens by the expansion's own width recorded positions past the end of the authored line, and a multi-line expansion attributed its later tokens to unrelated lines. Those fabricated spans surfaced downstream as invalid-span provenance failures. Give every expanded token the use-site span in both object/function macro expansion and script expansion, and update the multi-line span test to pin the new contract. Include/script/settings span translation keeps using shift_position where positions still correspond to real text. Fixes #506
e54-bot
left a comment
There was a problem hiding this comment.
Independent review of the diff against issue #506. The use-site direction matches the documented contract (docs/opy/provider.md: expanded code maps to the invocation site), all shift_expansion_spans call sites are migrated, and the tests do discriminate against the old behavior. Two findings:
-
Blocking — uniform spans destroy the layout the indentation-sensitive parser requires.
Token.spancarries two roles: provenance and the line/column layout that block parsing reads (parser.rs:519-531block_indent,parser/statements.rs:4-25parse_block,parser/statements.rs:348-368parse_colon_body,parser/definitions.rs:57-72,parser/declarations.rs:100,205). The old shifted spans preserved the expansion's relative layout — which is whyexpand_macro/expand_scriptre-indent continuation lines (macros.rs:284-288,scripts.rs:78-79). Attributing every token touse_sitecollapses all columns to one value, so multi-line#!defineand__script__expansions containing indented blocks regress, verified against base 12f3b5d:#!define check() if A == 1:\+B = 1\+C = 2invoked at rule-body indent: base →ifbody has 2 stmts, 1 action; this PR →ifbody has 1 stmt andC = 2escapes as an unconditional sibling action (silent semantic miscompile).#!define gen() for i in range(0, 3):\+B = i→parse-error: expected an indented block after ':'(base parses through to lowering).#!define makerule() rule "generated":\+@Event global\+A = 1→ same hard parse error (base compiles to a rule).- Same for script macros:
__script__returning"if A == 1:\n B = 1\n C = 2"→ if body losesC = 2.
for/while/do/switch/rule/def/enumbodies hard-fail viaexpect_block_indent;if/elif/elsesilently mis-parse via the same-indent inline-statement path atstatements.rs:353. Multi-line defines preserving statement boundaries are a supported contract (compiler/tests/preprocessing.rs:55-86). Required correction: keep use-site provenance while preserving expansion layout for the parser (e.g., a per-token layout position separate from the provenance span). Note the issue's own 'bound to the use-site span' direction would hit the same collapse, so the representation likely needs an owner/architect decision.
-
Residual same-class site — f-string interpolations inside expanded strings still fabricate positions past the authored line.
parser/expressions.rs:700-717computes interpolation origin/error spans asstring_span.start.col + indexandparser.rs:559-593(parse_expression_fragment) shifts inner expression tokens from there. Withstring_spannow the use-site span,#!define M f"hp: {B +}"invoked at 3:9-3:10 reportsexpected an expression but found ''at 3:17 — outside both the use-site span and the 9-column authored line. Pre-existing under the shifted regime, but it's the same invalid-span class #506 targets and survives this PR; bounding those derived spans tostring_spanwould be a no-op for authored strings and fixes the expanded case.
Uniform use-site spans collapsed the expansion's line/column structure, which the indentation-sensitive parser reads for block decisions: multi-line expansions lost statements or failed to parse (review finding on #508). Split the two roles on Token: `span` is authored provenance (the use-site span for expanded tokens), and a crate-internal `layout` keeps the shifted coordinates the parser uses for indentation and statement-boundary decisions. `layout` anchors at the invocation token's own layout position so nested expansions keep their relative structure. AST spans and diagnostics keep reading `span`, so expanded constructs report the authored use site. Settings-block and f-string fragment tokens keep authored behavior: their layout is the shifted position and provenance clamps into the enclosing string span when there is one, so interpolations inside expanded strings stop fabricating columns past the use site. Coverage: multi-line if/for/rule defines keep their block structure through both macro and script expansion, and a malformed interpolation inside an expanded string reports inside the use-site span.
|
Reworked in
Verified against your repros: |
e54-bot
left a comment
There was a problem hiding this comment.
Re-review of 430547d. The span/layout split is the right shape and both prior findings are verified fixed end-to-end against base 12f3b5d:
#!define check()/gen()/makerule()multi-line defines keep block structure (ifbody keeps both statements;for/rule/elif/else/switch/case/defaultparse and emit identically to base);__script__blockgen output preserves theifbody; nestedINNER()-inside-OUTER()anchors at the invocation's virtual position correctly.- Provenance: expanded tokens'
spanis the use site — AST spans on expanded constructs report the authored invocation (check()nodes all report 11:5–11:10), an error inside an expansion reports the macro name (8:5), and the f-string interpolation error clamps into the use site (8:10) instead of fabricating column 17. - The
layout == spaninvariant for authored tokens holds at every construction/mutation site:Token::new,attribute_expansion_spans(nesting anchors atuse_site.layout— correct),expand_settings,parse_expression_fragment,RulePrefixMarker. All other.spanposition reads are either provenance construction or authored-domain-only (token_is_at_or_after,scope_col,is_multiline,raw_source_text— whose re-lex check correctly falls back for expanded args). - Test discrimination: the span/f-string tests fail on base
12f3b5d, and the block-structure tests fail on the first (uniform-span) approach59e87fa.
One new finding — a residual mixed-domain read of the same class the split was meant to fix:
-
crates/opy-rs/src/parser/expressions.rs:16-17— the conditional-expression guard comparespeek().layout.start.line(layout domain) againstthen_value.span().end.line(provenance domain) and feeds that same provenance line intonewline_after_line, whose entries are layout lines. For an expandedthen_value, the provenance end is the use-site line while an adjacentifsits on the expansion's virtual line, so the guard wrongly fires and theifis reparsed as a statementif. Verified repro:#!define E() A = 0\ B = 0\ y = B if C else D rule "r": @Event global E()
Base
12f3b5dcompiles this toSet Global Variable(y, If-Then-Else(Global.C, Global.B, Global.D));430547dhard-fails withexpected ':' after the if conditionat theE()use site (13:5). Same failure for the nested-tail variant (B TAIL()whereTAIL()expands toif C else D) and for the bare-statement formB if C else Don expansion line ≥3. The trigger is an unparenthesizedX if C else Don a virtual expansion line below the use-site line with an earlier expansion newline already consumed — a plausible multi-line define shape, and a parse regression on input base accepted.Correction: evaluate the guard entirely in layout coordinates.
self.tokens[self.pos - 1].layout.end.lineis exactly the value base compared —then_value.span().end.lineequals the token adjacent toif— for both the!=check and thenewline_after_lineline argument (thefileargument is unaffected sincelayout.file == span.filefor every token). This is also the only remaining mixed-domain site; the audit found no others. Add a regression test for a ternary on a later line of a multi-line#!define— none of the new tests cover this guard path.
The ternary statement-boundary heuristic compared the if keyword's layout line against the then-value's provenance span line, so a conditional expression on a later expansion line collapsed to the use site and the guard misfired (expected ':' after the if condition). Compare against the just-consumed token's layout, which is exactly what the pre-split span carried. Refs #506
|
Fixed in |
e54-bot
left a comment
There was a problem hiding this comment.
Re-review of the rework at head 161e8a0 (430547d + the conditional-guard fix). I audited every .span/ .layout read in the parser and preprocessor and ran ~20 adversarial compiles through opy-cli against both commits. Verdict: the rework is correct; approve-quality (self-approve blocked by identity).
Finding (already resolved at head): at 430547d, parse_expr_inner's statement-boundary guard mixed coordinate spaces — peek().layout.start.line vs then_value.span().end.line. Since expanded AST spans collapse to the use site, any conditional expression on a non-first expansion line misfired: a #!define producing y = B if C else D on its third line failed with expected ':' after the if condition (reproduced on a clean 430547d build). 161e8a0 fixes it correctly by comparing the just-consumed token's .layout on both sides — verified the same case now lowers to If-Then-Else, and the regression test covers it. This was the only span/layout mix I could find in structural code.
Audit coverage:
- Every parser structural read now uses
.layout:block_indent,newline_linesrecording,expect_statement_end's continued-line check, if/elif/else/else-if columns,open_if_indents, switch case/default cols, for/while/do/do-while headers, enum/macro/subroutine/rule body indents, and (post-161e8a0) the conditional guard. - Token construction sites all initialize
layoutcorrectly: lexerToken::new(layout = span),attribute_expansion_spans(layout = shifted coords anchored atuse_site.layout.start, so nested expansions preserve structure),expand_settingsre-lexed tokens (layout = shifted span),parse_expression_fragment(layout shifted, provenance clamped bybounded_span),RulePrefixMarker(inherits). - The
span.file == layout.fileinvariant holds at every site, sonewline_lines.entry(token.span.file)and fragment file keys are safe. - Deliberate
.spanreads are in the right domain:token_is_at_or_afteronly sees authored pre-expansion input;raw_source_textslices authored source and its lexical verification falls back safely for nested-expansion args;optimization_state_atevaluating directive state at the use-site span is semantically the right anchor (arguably better than the pre-PR shifted positions). bounded_spanclamps throughPosition: Ord(line-major); monotone, cannot invert a span.
Falsification evidence at 161e8a0: multi-line if/elif/else/for/while/do/switch defines, rule/enum/macro-decl generation, __script__ macros (including nested inside a text-macro expansion), 2- and 3-level nested defines with ternaries on later expansion lines, mid-line multi-line invocations (x = M + y splits identically to textual substitution), f-string interpolations containing ternaries inside expanded strings, #!include-sourced defines, settings-block define expansion, #!defineMember. Diagnostics inside multi-line expansions report the authored use site — B = nosuchvar on expansion line 3 reports at the invocation (10:5), not fabricated coordinates. 9/13 real-world corpus projects compile; the 4 failures (6v6-adjustments, overpy-meipocalypse, overpy-zencopter, ow1-emulator) are recorded known gaps in fixture.json (nativeStatus: failure) — ow1-emulator's Found 'if', but no 'else' is the designed continued-inline-if rejection, identical pre-fix.
cargo test -p opy-rs all green; fmt/clippy clean. Non-blocking nit: TokenKind::Indent is a dead variant (pre-existing, unrelated to this change). The public contract in docs/opy/tooling-api.md ('expansion stamps expanded tokens with the use-site span') is unchanged; layout is crate-internal, so no doc update is needed.
Summary
Fixes #506.
expand_macrore-lexes replacement text, then shifted every generated token's span by the invocation's start position. A replacement wider than its invocation produced token spans ending past the authored line, and multi-line expansions attributed generated tokens to unrelated authored lines. Those fabricated provenance spans surfaced downstream asinvalid-spanvalidation failures in Wright lint-fix flows on real projects (composition/bootstrap.opy:64,overwatch-ai-pve/utilities/hero_switch.opy:9).Since expanded text does not exist in authored source, the authored macro name is the only honest provenance: every expanded token — in object/function macros and script macros alike — now carries the use-site span.
diag::shift_positionremains in use where positions genuinely correspond to text (includes, scripts, settings blocks) and is untouched.Test plan
expanded_tokens_carry_the_use_site_span— a replacement far wider thanTattributes every expanded token to the authored use-site span3:9-3:10, which stays within the authored line.multiline_define_preserves_relative_expansion_spans→multiline_define_expanded_tokens_attribute_to_the_use_site— the old assertions pinned the buggy shifted coordinates (second expansion token on line 4 for a one-line invocation on line 3); now pins both tokens to the invocation span.cargo fmt --all -- --check,cargo clippy --workspace --all-targets --all-features -- -D warnings,cargo test --workspace --all-targets --all-features(all green),python3 -m unittest discover -s tools/overpy/tests(40 tests OK), differential corpusnative_and_reference_agree_on_the_corpuspasses.