RFC-009 — Set-of-Scopes Macro Hygiene¶
- Status: Accepted (v0.13)
- Owner: macros track
- Companion:
macros-v0.5.md,MACRO_HYGIENE_V0_13_NOTES.md - Reference: Matthew Flatt, "Bindings as Sets of Scopes", POPL 2016.
Cross-reference Rust's hygiene model (
rustc_expand::mbe,SyntaxContext).
Implementation Status¶
SHIPPED in v0.13, wired into HIR in v0.14, ratified through the v0.24 dashboard. This RFC has been implemented and was accepted during the v0.13 swarm. The artefacts at the time of v0.24 freeze prep:
crates/mty-macros/src/scopes.rs—ScopeId,Scopes,ScopeGen,resolve,ResolveAmbiguityper §3 of this RFC.crates/mty-macros/src/hygiene.rs—ScopedTok,HygieneEnv.crates/mty-macros/src/expand.rs—expand_scopedentry point;tests/sets_of_scopes.rscovers the 8 acceptance scenarios.- HIR consumes the scope-aware resolver as of v0.14 (the v0.13 RFC deferred this — that follow-up has shipped).
The public comment window opened 2026-05-26 (dashboard)
is a procedural ratification of the already-accepted design; the
window must complete before v1.0 freeze per
COMMENT_WINDOWS.md §3.
1. Motivation¶
Up through v0.12 Mighty's declarative macros (macro foo($x) => …) use
Racket-style single-mark hygiene: each macro expansion is assigned a
fresh integer "mark"; the expander mangles every macro-introduced
binding to __mac_<mark>_<name>; the resulting program is re-parsed.
This handles the textbook unhygienic-capture case: if a macro
introduces let y = x; y + y and the caller already has a y, the
mangled name disambiguates them so the caller's y is not shadowed.
Single marks fail for macro composition. Consider:
After both expansions, the two macro-introduced ts should be
distinguishable from each other AND from the caller's t. With
single marks the inner expansion's t and the outer expansion's t
get different marks (good), but if one references the other's body
(e.g. via a token re-injected through a parameter) the mark-flip rule
can resolve to the wrong binding. Flatt 2016 walks through the
canonical "swap macro" failure where mark-equality is insufficient.
The fix, per Flatt, is to track a set of scope identifiers per name, with resolution defined as "pick the binding whose scope set is the largest subset of the reference's scope set". This gives a sound, total ordering on competing bindings; the swap-macro case falls out for free.
2. Specification¶
2.1 Scope IDs¶
A scope identifier (ScopeId) is an opaque u32 allocated by a
monotonic ScopeGen allocator (one per translation unit). Scope IDs
0 is reserved; allocation starts at 1.
2.2 Scope sets¶
Every name occurrence (binding or reference) is associated with a
Scopes value:
Top-level user source carries Scopes::empty(); macro expansions
extend the set as described below.
2.3 Expansion rules¶
For each macro invocation:
- Allocate
intro = ScopeGen::fresh(). - Let
def_scopesbe the scope set inherited from the macro's definition site (empty for top-level macros; non-empty when the macro itself was introduced by another expansion). - Let
body_scopes = def_scopes ∪ {intro}. - Assign
body_scopesto every token originating from the macro body. - For substituted-in arguments (call-site tokens), use the caller's
pre-existing scope set unchanged. Do NOT add
intro— the argument was not introduced by this expansion (Flatt §3, "Add or Flip"; the "flip" arm is only needed for the bind/expand operator, which Mighty does not expose).
2.4 Resolution¶
Given a reference with scope set R and a set of candidate bindings,
each with scope set Bᵢ:
candidates = { Bᵢ | Bᵢ ⊆ R }
if candidates is empty:
error MT6201 — unresolved name
let max_size = max(|Bᵢ| for Bᵢ in candidates)
let winners = { Bᵢ in candidates | |Bᵢ| = max_size }
if |winners| > 1:
error MT5901 — ambiguous binding (see §6)
return the unique winner
This is implemented as mty_macros::resolve and is reused at every
name-lookup site once scope sets are wired in.
2.5 Diagnostics¶
- MT5901 — ambiguous binding under set-of-scopes. Two distinct bindings tied at the maximum subset score. Today this is reachable only via pathological macros; reserving the code now keeps callers from inventing a private one later.
- MT5902 (reserved) — scope-set internal invariant violation. Asserted at debug, surfaced as ICE at release.
Existing macro codes (MT6001–MT6006) are unchanged.
3. Worked examples¶
3.1 Identity macro¶
v(caller) carries scope set{}.idallocatesintro = 1. The body tokenxis the parameter, so it gets replaced with the argument: tokenvwith scope set{}.- No body-introduced bindings; nothing to resolve. The
vreference resolves to the caller'sv.
3.2 let-introducing macro¶
- Caller's
tmpbinding scope:{}. doubleallocatesintro = 1. Body bindings:tmpat scope set{1}. Body references totmpcarry scope set{1}.- When the front-end resolves the body's
tmpreferences, the caller'stmp(scope{}) and the macro'stmp(scope{1}) are both subsets of{1}. Maximum is{1}(the macro's). The caller'stmpis unaffected; the macro'stmp + tmpreferences its own binding.
3.3 Swap macros (the Flatt motivating case)¶
setBallocatesintro = 1. Body bindingtat scope{1}; referencetat scope{1}. Resolves to its ownt. The expansion result includes thetreference carrying{1}.setAallocatesintro = 2. Its parameterxis substituted withsetB's expansion: the innertreference still carries{1}(caller-side, NOT contaminated bysetA).setA's own bodyt(binding and reference) carries scope{2}.- Final resolution: the inner
t(scope{1}) resolves tosetB's binding (scope{1}, subset of{1}⇒ subset-size 1).setA's bodyt(scope{2}) resolves tosetA's binding (scope{2}). No collision.
3.4 Recursive macro¶
A self-recursive macro allocates a fresh intro on each nested call;
the inner call's def_scopes is the outer call's body scope, so the
inner binding's scope set is a proper superset of the outer's. The
"largest subset" rule then correctly picks the innermost binding.
3.5 def-macro inside let-macro¶
outerallocatesintro = 1.- The textual
innerdeclaration appears insideouter's body, soinner's definition site carries scope{1}. Wheninneris later expanded, its body tokens carrydef_scopes ∪ {intro_inner} = {1, 2}. - References from elsewhere with scope set
{}cannot resolve toinner'sq(scope{1, 2}is not a subset of{}); this reproduces lexical scoping for the nested macro.
4. Migration¶
- Existing programs. Source-level unchanged. The new layer is
additive: every name in user source defaults to
Scopes::empty(), which behaves identically to the empty mark set. Programs that worked under single-mark hygiene continue to work. - Expander API. The legacy
expand(def, args, ctx)is retained unchanged. A newexpand_scoped(def, args, &mut gen, def_scopes, caller_scopes)returnsScopedExpansion { tokens, bindings, intro }. Front-ends opt in by switching consumers; the wire-through is intentionally local tomty-macrosin v0.13. - Diagnostic numbering. MT5901 is new; MT5902 is reserved. Existing macro diagnostics (MT6001–MT6006) are unchanged.
5. Performance¶
Scope sets are BTreeSet<u32>. Realistic macro bodies have one to
five active scopes; subset/intersection are O(n+m) on small n, m.
Profiling the v0.13 test suite shows scope-set ops add < 1 % to
expansion wall time. If a deployment shows different numbers, the
straightforward escape hatch is to swap BTreeSet for a SmallVec-
backed sorted-vec representation behind the same Scopes API.
6. Out of scope (deferred)¶
- Wiring the scope-aware resolver into mty-hir. The v0.13 RFC
ships the data layer + the
expand_scopedentry point; HIR consumes only the mangled token stream for backward compatibility. Wiring HIR to the scope-aware resolver is tracked for v0.14. - Cross-package scope sharing. Macros imported via
pub macrocurrently re-mint scope IDs at the importing TU. Sharing scope identity across package boundaries (so the same imported macro always carries the samedef_scopes) is deferred to v0.14. - The "flip" rule for the bind/expand operator. Mighty does not
yet expose a user-level
bindprimitive (Racket'slocal-expand). When it does, the flip semantics from Flatt §3 will need to be added; until thenexpand_scopedonly implements "add".
7. Acceptance criteria (v0.13)¶
crates/mty-macros/src/scopes.rsdefinesScopeId,Scopes,ScopeGen,resolve,ResolveAmbiguity.crates/mty-macros/src/hygiene.rsdefinesScopedTok,HygieneEnv.crates/mty-macros/src/expand.rsexposesexpand_scopedreturningScopedExpansion.cargo test -p mty-macrospasses including the newtests/sets_of_scopes.rssuite (≥ 8 scenarios).- Workspace test baseline preserved.
cargo clippy --workspace --all-targets -- -D warningsclean.