Implement unhygienic macro expansion for ~ident

This commit introduces support for unhygienic macro expansion using the
`~ident` syntax. When an identifier is prefixed with a tilde (`~`)
within a macro template, it is no longer subject to hygienic renaming.
Instead, it directly refers to a binding in the call site's environment.

This allows macros to interact with variables defined outside the
macro's scope, such as:
- Modifying call-site variables.
- Capturing call-site variables as upvalues in closures.
- Defining new variables that are visible in the call site.

A new test case `test_macro_tilde_nonparam_assign` has been added to
verify this functionality. Previously, unhygienic escapes were
implicitly handled by `SyntaxKind::Identifier` if they were not macro
parameters. This change makes this behavior explicit and correctly
handles non-parameter identifiers by returning a bare symbol.
This commit is contained in:
2026-03-27 10:06:50 +01:00
parent adce3a6818
commit 9c5506b83e
2 changed files with 131 additions and 4 deletions
+67 -4
View File
@@ -398,10 +398,21 @@ impl<E: MacroEvaluator> MacroExpander<E> {
SyntaxKind::Placeholder(inner) => {
// Break out of template for substitution/evaluation
if let SyntaxKind::Identifier { symbol: ref sym, .. } = inner.kind
&& let Some(arg) = state.bindings.get(&sym.name)
{
return Ok(arg.clone());
if let SyntaxKind::Identifier { symbol: ref sym, .. } = inner.kind {
if let Some(arg) = state.bindings.get(&sym.name) {
return Ok(arg.clone());
}
// Non-parameter identifier: return bare symbol without hygiene context.
// This is the explicit unhygienic escape — ~x reaches into the call site.
return Ok(SyntaxNode {
comments: node.comments.clone(),
identity: node.identity,
kind: SyntaxKind::Identifier {
symbol: sym.clone(),
binding: (),
},
ty: (),
});
}
let val = self.evaluator.evaluate(&inner, state.bindings)?;
Ok(self.value_to_node(val, node.identity))
@@ -831,4 +842,56 @@ mod tests {
result.err()
);
}
#[test]
fn test_macro_tilde_nonparam_assign() {
// ~x where x is not a parameter should produce a bare identifier,
// allowing it to resolve against the call-site variable.
let source = "
(do
(def x 0)
(macro inc-x [] `(assign ~x (+ ~x 1)))
(inc-x)
x)
";
let mut parser = Parser::new(source);
let syntax = parser.parse_expression();
let mut expander = MacroExpander::new(MacroRegistry::new(), SimpleEvaluator);
let expanded = expander.expand(syntax).unwrap();
let mut diag = Diagnostics::new();
let result = Binder::bind_root(vec![], 0, 0, &expanded, &mut diag);
assert!(
result.is_ok(),
"~nonparam should resolve to call-site variable: {:?}",
result.err()
);
}
#[test]
fn test_macro_hygiene_def_still_isolated() {
// A plain template `def` (without ~) still creates a hygienically
// isolated binding, so the call-site variable is unchanged.
let source = "
(do
(def x 99)
(macro reset [] `(def x 0))
(reset)
x)
";
let mut parser = Parser::new(source);
let syntax = parser.parse_expression();
let mut expander = MacroExpander::new(MacroRegistry::new(), SimpleEvaluator);
let expanded = expander.expand(syntax).unwrap();
let mut diag = Diagnostics::new();
let result = Binder::bind_root(vec![], 0, 0, &expanded, &mut diag);
assert!(
result.is_ok(),
"Hygiene isolation for def should still hold: {:?}",
result.err()
);
}
}