diff --git a/src/ast/compiler/macros.rs b/src/ast/compiler/macros.rs index b4f0908..13ef96f 100644 --- a/src/ast/compiler/macros.rs +++ b/src/ast/compiler/macros.rs @@ -398,10 +398,21 @@ impl MacroExpander { 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() + ); + } } diff --git a/tests/macros.rs b/tests/macros.rs index a808ba4..17f9a48 100644 --- a/tests/macros.rs +++ b/tests/macros.rs @@ -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#"