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:
@@ -398,11 +398,22 @@ 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)
|
||||
{
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,69 @@
|
||||
use myc::ast::environment::Environment;
|
||||
|
||||
// --- Tests for ~ident (non-parameter unhygienic escape) ---
|
||||
|
||||
#[test]
|
||||
fn test_tilde_nonparam_assign_single() {
|
||||
// ~x where x is not a parameter reaches the call-site variable.
|
||||
let env = Environment::new();
|
||||
let res = env
|
||||
.run_script("(do (def x 0) (macro inc-x [] `(assign ~x (+ ~x 1))) (inc-x) x)")
|
||||
.expect("should increment x");
|
||||
assert_eq!(format!("{}", res), "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tilde_nonparam_assign_multiple_expansions() {
|
||||
// Multiple expansions of the same macro do not interfere with each other.
|
||||
let env = Environment::new();
|
||||
let res = env
|
||||
.run_script(
|
||||
"(do (def x 0) (macro inc-x [] `(assign ~x (+ ~x 1))) (inc-x) (inc-x) (inc-x) x)",
|
||||
)
|
||||
.expect("should increment x three times");
|
||||
assert_eq!(format!("{}", res), "3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tilde_nonparam_def_creates_visible_binding() {
|
||||
// ~y as a def pattern creates a visible binding at the call site.
|
||||
let env = Environment::new();
|
||||
let res = env
|
||||
.run_script("(do (macro def-y [] `(def ~y 42)) (def-y) y)")
|
||||
.expect("should create visible y");
|
||||
assert_eq!(format!("{}", res), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tilde_nonparam_upvalue_capture() {
|
||||
// ~x inside a lambda captures the call-site variable as an upvalue.
|
||||
let env = Environment::new();
|
||||
let res = env
|
||||
.run_script("(do (def x 10) (macro make-adder [] `(fn [n] (+ n ~x))) (def add-x (make-adder)) (add-x 5))")
|
||||
.expect("should capture x as upvalue");
|
||||
assert_eq!(format!("{}", res), "15");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tilde_nonparam_plain_def_stays_isolated() {
|
||||
// A plain template `(def x ...)` (without ~) remains hygienically isolated
|
||||
// even when ~x is used in the same template for references.
|
||||
let env = Environment::new();
|
||||
let res = env
|
||||
.run_script("(do (def x 1) (macro double-x [] `(do (def x (* ~x 2)) ~x)) (double-x))")
|
||||
.expect("should return call-site x, not macro-internal x");
|
||||
// ~x reads the call-site x=1; the plain `def x` creates an isolated x[ctx]=2.
|
||||
assert_eq!(format!("{}", res), "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tilde_nonparam_missing_variable_is_error() {
|
||||
// ~x where x does not exist at the call site produces a compile error.
|
||||
let env = Environment::new();
|
||||
let res = env.run_script("(do (macro inc-x [] `(assign ~x (+ ~x 1))) (inc-x))");
|
||||
assert!(res.is_err(), "should fail: x is not defined at call site");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_macro_inlining_identity_collision() {
|
||||
let source = r#"
|
||||
|
||||
Reference in New Issue
Block a user