fix(semantic): reject a macro expansion block with nothing repeating at its depth - #10298
Conversation
| /// | ||
| /// Two kinds of errors are reported: | ||
| /// Three kinds of errors are reported: | ||
| /// * Depth mismatch (E2198): placeholder used at fewer expansion levels than its pattern depth. |
There was a problem hiding this comment.
do not write error codes in comments - these are just for exernal consumption.
There was a problem hiding this comment.
Removed across the stack — including the preexisting E2198/E2199 mentions this doc block builds on, and the E2206 one in the pr4a follow-up. Also renamed the juxt test macro to juxtaposed to fix the typos CI.
54de8ab to
e2df0dd
Compare
99a511f to
ea21fb3
Compare
e2df0dd to
0f4a094
Compare
PR SummaryMedium Risk Overview This replaces the previous E2202 expansion-time path for the same class of bugs for normal macro source; Reviewed by Cursor Bugbot for commit ff9e3a9. Bugbot is set up for automated code reviews on this repo. Configure here. |
orizi
left a comment
There was a problem hiding this comment.
@orizi resolved 1 discussion.
Reviewable status: 0 of 3 files reviewed, all discussions resolved.
ea21fb3 to
df6b1f5
Compare
0f4a094 to
86a5bfc
Compare
df6b1f5 to
dfca0ac
Compare
86a5bfc to
af3c023
Compare
…at its depth
`ExpansionCheckCtx` already tracks, for every placeholder, its pattern repetition depth
(`placeholder_paths[name].len()`) and the expansion depth it is used at (`curr_rep_depth`).
It now also validates each `$()` block of the expansion itself: a block nested in
`enclosing_depth` other blocks is legal only if it holds a placeholder whose pattern depth
exceeds `enclosing_depth`, i.e. something that actually repeats at this level and can drive
the block. Otherwise the number of repetitions is undetermined, and the new
declaration-time error E2203 is reported on the offending `MacroRepetition` and sets
`rule.err`, so the rule never expands.
Placeholders nested deeper inside the block count as drivers: in `$($($x),*),*` with `$x` at
pattern depth 1, `$x` sits in the inner block but is consumed by the outer repetition too, so
the outer block is legal and only the inner one is rejected.
E2203 was unallocated (main's general band ended at E2201, the parent commit took E2202;
E2300-E2315 is the `InferenceError` sub-band and is not part of this band). The next free
general semantic code is E2204.
## Semantics mirrored from rustc
Probed with rustc 1.96.0 (ac68faa20 2026-05-25) in this change. rustc's transcriber-side
definition errors are LAZY, so every probe below invokes the macro; rustc then reports the
definition-site error upon invocation. Probe sources are transcribed inline.
REJECTED - all four report
`attempted to repeat an expression containing no syntax variables matched as repeating at this
depth`:
* no metavariable: `macro_rules! m1 { () => { $(foo)* }; }` + `m1!();`
-> caret on `(foo)`.
* depth-0-only metavariable: `macro_rules! m2 { ($a:ident) => { $($a)* }; }` + `m2!(x);`
-> caret on `($a)`, plus `this similarly named macro metavariable is unrepeatable`.
* over-deep inner block: `macro_rules! m3 { ($($x:ident),*) => { $($($x),*),* }; }`
+ `m3!(a, b)` -> caret on the INNER `($x)`, not the outer block.
* offender not first: `macro_rules! m9 { ($($x:ident),*) => { stringify!($($x),* ; $(bar)*) }; }`
+ `m9!(a, b)` -> caret on `(bar)`; the leading well-formed block is silent.
ACCEPTED:
* broadcast: `macro_rules! m4 { ($p:ident, $($x:ident),*) => { stringify!($($p $x),*) }; }`
+ `m4!(f, a, b)` prints `f a, f b`.
* properly nested:
`macro_rules! m5 { ($([$($x:ident),*]),*) => { stringify!($([$($x),*]),*) }; }`
+ `m5!([a, b], [c, d])` prints `[a, b], [c, d]`.
* zero-match calls of those same two rules: `m4!(f,)` and `m5!()` print nothing, `m5!([], [])`
prints `[], []`.
Two rustc behaviours copied deliberately:
* `macro_rules! m7 { ($($x:ident),*) => { stringify!($($(foo),*),*) }; }` + `m7!(a, b)` reports
exactly ONE error, anchored at the OUTER block. A block nested in a failing block always
fails for the same reason (`inner_max <= outer_max <= d < d+1`), so `in_non_repeating_block`
suppresses nested E2203s. E2193/E2198/E2199 are still reported inside a suppressed block.
* `macro_rules! m10 { ($($x:ident),*) => { stringify!($($q)*) }; }` + `m10!(a, b)` reports one
error, not a separate "unbound metavariable" one. So an undefined placeholder counts as a
driver here and `$($undef)*` keeps reporting E2193 alone rather than E2193 + E2203.
STRICTER THAN RUSTC, deliberately: Cairo reports E2203 from `priv_macro_declaration_data`, so a
bad `macro` is rejected with no call anywhere, unlike rustc's lazy transcriber check. This
matches the neighbouring declaration-time checks E2193/E2198/E2199.
`$defsite`/`$callsite` are not placeholders (`extract_placeholder` filters them), so a block
holding only those is now E2203 too; it previously expanded to nothing silently. Grepping every
`$(` under corelib/ and all crate `test_data` found no such block - corelib's only
expansion-side repetitions (macro_test.cairo:239/241/281/321) are all driven by a depth-1
placeholder.
## Goldens
crates/cairo-lang-semantic/src/expr/test_data/inline_macros, re-blessed with a narrow
`CAIRO_FIX_TESTS=1` + `--lib expr::test::expr_diagnostics::inline_macros`. Hunk by hunk:
1. `:3067` title `Test item-position macro call whose expansion repeats a placeholder-free
block.` -> `Test placeholder-free repetition in an expansion, with an item-position call.`
The diagnostic is no longer produced by the call.
2. `:3086` `E2202` -> `E2203` with the new message. The caret is byte-identical: both the old
expansion-time error and the new check anchor on the same `MacroRepetition` node in the
declaration, and nothing else appears or disappears - `m!();` in item position adds no
fallout.
3. `:3093` title renamed for the same reason as hunk 1.
4. `:3112` `E2202` -> `E2203`, same caret. The expression-position call stays silent because
`compute.rs` propagates `rule.err` without reporting again.
5. `:3188` (cross-module `mod helpers`) `E2202` -> `E2203`, same caret in the declaring module,
title unchanged. The location survives for a new reason: the diagnostic used to live on the
root module's `MacroCallData` with a stable ptr into `helpers`; it now lives on `helpers`'s
own macro-declaration diagnostics, which the runner collects via
`get_recursive_module_semantic_diagnostics` (inline submodules).
6. seven new sections appended - three error shapes and three legal controls, plus an
offender-not-first variant modelled on rustc probe m9. Every repetition level with a
repetition carries >= 2 captures (`m!(first, second)`, `m!(base, one, two)`, `m!(1, 2)`,
`m!(10, 1, 2)`, `m!([1, 2], [3, 4])`).
Bless-then-revert: with only this commit's report disabled on this branch (`if false && ...`),
all six error goldens fail as output-tag mismatches, no panics -
* the three re-homed goldens and the offender-not-first golden fall back to E2202 (the
expansion-time path the parent commit added),
* `$($base)*` falls back to `error[E0006]: Identifier not found.` - the block silently
expanded to nothing,
* `$($($x),*),*` falls back to
`error[E2117]: Parser error in macro-expanded code: Skipped tokens. Expected: statement.`
The three legal controls pass with and without the check; a golden that emits no diagnostic
cannot change when the check is removed, so revert-mode evidence does not apply to them.
Those last two reverted outputs are the evidence for `rule.err`: E0006 and E2117 are produced
*by the expansion*, and they are gone once E2203 sets `rule.err`. The rule genuinely stops
expanding rather than expanding to nothing.
## E2202 reachability
`MacroExpansionFailure::RepetitionWithoutPlaceholder` needs `find_first_repetition_param` to
return `None`, i.e. an expansion `$()` block with zero `MacroParam` descendants. Such a block
can never have a driving placeholder, so E2203 now rejects the rule at declaration time and
that arm is unreachable through Cairo source. `MacroExpansionFailure::MissingCapture` is
untouched; per the plan it should still be reachable via duplicate placeholder names in a
pattern (see the `TODO(Dean): Verify uniqueness of param names.`), which a later change rejects
at declaration time. E2202 stays either way as defensive hardening - neither arm may panic.
Gates: `cargo test --profile=ci-dev -p cairo-lang-semantic` (106 passed),
`./scripts/rust_fmt.sh`, `./scripts/clippy.sh --profile=ci-dev`,
`./scripts/validate_error_codes.sh`, `scripts/check_comment_punctuation.py crates`,
`cargo run --profile=ci-dev --bin cairo-test -- corelib/` (737 passed),
`cargo run --profile=ci-dev --bin cairo-test -- tests/bug_samples --starknet` (68 passed), plus
`cargo test --profile=ci-dev` over lowering / doc / starknet / sierra-generator / defs / plugins
as a declaration-time-fallout check.
af3c023 to
ff9e3a9
Compare

ExpansionCheckCtxalready tracks, for every placeholder, its pattern repetition depth(
placeholder_paths[name].len()) and the expansion depth it is used at (curr_rep_depth).It now also validates each
$()block of the expansion itself: a block nested inenclosing_depthother blocks is legal only if it holds a placeholder whose pattern depthexceeds
enclosing_depth, i.e. something that actually repeats at this level and can drivethe block. Otherwise the number of repetitions is undetermined, and the new
declaration-time error E2203 is reported on the offending
MacroRepetitionand setsrule.err, so the rule never expands.Placeholders nested deeper inside the block count as drivers: in
$($($x),*),*with$xatpattern depth 1,
$xsits in the inner block but is consumed by the outer repetition too, sothe outer block is legal and only the inner one is rejected.
E2203 was unallocated (main's general band ended at E2201, the parent commit took E2202;
E2300-E2315 is the
InferenceErrorsub-band and is not part of this band). The next freegeneral semantic code is E2204.
Semantics mirrored from rustc
Probed with rustc 1.96.0 (ac68faa20 2026-05-25) in this change. rustc's transcriber-side
definition errors are LAZY, so every probe below invokes the macro; rustc then reports the
definition-site error upon invocation. Probe sources are transcribed inline.
REJECTED - all four report
attempted to repeat an expression containing no syntax variables matched as repeating at this depth:macro_rules! m1 { () => { $(foo)* }; }+m1!();-> caret on
(foo).macro_rules! m2 { ($a:ident) => { $($a)* }; }+m2!(x);-> caret on
($a), plusthis similarly named macro metavariable is unrepeatable.macro_rules! m3 { ($($x:ident),*) => { $($($x),*),* }; }m3!(a, b)-> caret on the INNER($x), not the outer block.macro_rules! m9 { ($($x:ident),*) => { stringify!($($x),* ; $(bar)*) }; }m9!(a, b)-> caret on(bar); the leading well-formed block is silent.ACCEPTED:
macro_rules! m4 { ($p:ident, $($x:ident),*) => { stringify!($($p $x),*) }; }m4!(f, a, b)printsf a, f b.macro_rules! m5 { ($([$($x:ident),*]),*) => { stringify!($([$($x),*]),*) }; }m5!([a, b], [c, d])prints[a, b], [c, d].m4!(f,)andm5!()print nothing,m5!([], [])prints
[], [].Two rustc behaviours copied deliberately:
macro_rules! m7 { ($($x:ident),*) => { stringify!($($(foo),*),*) }; }+m7!(a, b)reportsexactly ONE error, anchored at the OUTER block. A block nested in a failing block always
fails for the same reason (
inner_max <= outer_max <= d < d+1), soin_non_repeating_blocksuppresses nested E2203s. E2193/E2198/E2199 are still reported inside a suppressed block.
macro_rules! m10 { ($($x:ident),*) => { stringify!($($q)*) }; }+m10!(a, b)reports oneerror, not a separate "unbound metavariable" one. So an undefined placeholder counts as a
driver here and
$($undef)*keeps reporting E2193 alone rather than E2193 + E2203.STRICTER THAN RUSTC, deliberately: Cairo reports E2203 from
priv_macro_declaration_data, so abad
macrois rejected with no call anywhere, unlike rustc's lazy transcriber check. Thismatches the neighbouring declaration-time checks E2193/E2198/E2199.
$defsite/$callsiteare not placeholders (extract_placeholderfilters them), so a blockholding only those is now E2203 too; it previously expanded to nothing silently. Grepping every
$(under corelib/ and all cratetest_datafound no such block - corelib's onlyexpansion-side repetitions (macro_test.cairo:239/241/281/321) are all driven by a depth-1
placeholder.
Goldens
crates/cairo-lang-semantic/src/expr/test_data/inline_macros, re-blessed with a narrow
CAIRO_FIX_TESTS=1+--lib expr::test::expr_diagnostics::inline_macros. Hunk by hunk::3067titleTest item-position macro call whose expansion repeats a placeholder-free block.->Test placeholder-free repetition in an expansion, with an item-position call.The diagnostic is no longer produced by the call.
:3086E2202->E2203with the new message. The caret is byte-identical: both the oldexpansion-time error and the new check anchor on the same
MacroRepetitionnode in thedeclaration, and nothing else appears or disappears -
m!();in item position adds nofallout.
:3093title renamed for the same reason as hunk 1.:3112E2202->E2203, same caret. The expression-position call stays silent becausecompute.rspropagatesrule.errwithout reporting again.:3188(cross-modulemod helpers)E2202->E2203, same caret in the declaring module,title unchanged. The location survives for a new reason: the diagnostic used to live on the
root module's
MacroCallDatawith a stable ptr intohelpers; it now lives onhelpers'sown macro-declaration diagnostics, which the runner collects via
get_recursive_module_semantic_diagnostics(inline submodules).offender-not-first variant modelled on rustc probe m9. Every repetition level with a
repetition carries >= 2 captures (
m!(first, second),m!(base, one, two),m!(1, 2),m!(10, 1, 2),m!([1, 2], [3, 4])).Bless-then-revert: with only this commit's report disabled on this branch (
if false && ...),all six error goldens fail as output-tag mismatches, no panics -
expansion-time path the parent commit added),
$($base)*falls back toerror[E0006]: Identifier not found.- the block silentlyexpanded to nothing,
$($($x),*),*falls back toerror[E2117]: Parser error in macro-expanded code: Skipped tokens. Expected: statement.The three legal controls pass with and without the check; a golden that emits no diagnostic
cannot change when the check is removed, so revert-mode evidence does not apply to them.
Those last two reverted outputs are the evidence for
rule.err: E0006 and E2117 are producedby the expansion, and they are gone once E2203 sets
rule.err. The rule genuinely stopsexpanding rather than expanding to nothing.
E2202 reachability
MacroExpansionFailure::RepetitionWithoutPlaceholderneedsfind_first_repetition_paramtoreturn
None, i.e. an expansion$()block with zeroMacroParamdescendants. Such a blockcan never have a driving placeholder, so E2203 now rejects the rule at declaration time and
that arm is unreachable through Cairo source.
MacroExpansionFailure::MissingCaptureisuntouched; per the plan it should still be reachable via duplicate placeholder names in a
pattern (see the
TODO(Dean): Verify uniqueness of param names.), which a later change rejectsat declaration time. E2202 stays either way as defensive hardening - neither arm may panic.
Gates:
cargo test --profile=ci-dev -p cairo-lang-semantic(106 passed),./scripts/rust_fmt.sh,./scripts/clippy.sh --profile=ci-dev,./scripts/validate_error_codes.sh,scripts/check_comment_punctuation.py crates,cargo run --profile=ci-dev --bin cairo-test -- corelib/(737 passed),cargo run --profile=ci-dev --bin cairo-test -- tests/bug_samples --starknet(68 passed), pluscargo test --profile=ci-devover lowering / doc / starknet / sierra-generator / defs / pluginsas a declaration-time-fallout check.