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
+64
View File
@@ -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#"