Skip to main content

luna_core/frontend/
macro_expander.rs

1//! MacroLua compile-time macro expander pre-pass.
2//!
3//! Walks a [`Vec<TokenInfo>`] produced by the lexer once, expands every
4//! `@name(args)` invocation against the per-Vm [`MacroRegistry`], and
5//! returns a `Vec<TokenInfo>` with no `@`/quote tokens remaining. The
6//! result is fed to [`crate::frontend::parser::parse_tokens`] — the
7//! parser itself is unchanged and never sees macros.
8//!
9//! ## Surface (audit-locked, see `.dev/rfcs/v1.3-audit-macro-lua.md` §3)
10//!
11//! - `@name(arg1, arg2, ...)` — call a registered macro with raw
12//!   token-run arguments (top-level commas split args; nested
13//!   parens/braces are tracked).
14//! - `@name{ body }` — alternate brace-delimited single-arg form
15//!   (think `@quote{...}` and `@if true {...} @else {...}`); the brace
16//!   body is delivered to the macro as a single arg whose tokens are
17//!   the (still-unexpanded) body between balanced `{...}`.
18//! - `@{ tokens... }@` — explicit quote-block sigil; emits a
19//!   [`Token::MacroQuote`] containing the captured run, available as a
20//!   single arg to outer macros (e.g. `@unquote(name)` post-binding).
21//!
22//! ## Built-in macros (v1.3 floor)
23//!
24//! - `@quote{ ... }` — captures body as a single [`Token::MacroQuote`]
25//!   value (which the parser ultimately never sees — it's spliced).
26//! - `@unquote(name)` — inverse: inside another macro's expansion,
27//!   `@unquote(name)` resolves to the named quote's body.
28//! - `@if cond { then-arm } @else { else-arm }` — compile-time
29//!   conditional; `cond` is one of `true` / `false` / integer or string
30//!   literal-eq (`==` of literals only; deliberately *not* a tiny VM).
31//! - `@gensym` / `@gensym(prefix)` — emits a unique identifier
32//!   `Token::Name` (per-Vm counter; deterministic within one expansion).
33//!
34//! ## Hygiene model (chosen for v1.3 — see `docs/compatibility.md`)
35//!
36//! **Gensym-only.** Macro authors who need a fresh local explicitly
37//! invoke `@gensym` and bind to it. The expander does **not** rewrite
38//! `local <name>` declarations inside quote bodies. This matches the
39//! audit's §5 stretch-goal deferral (implicit quote-body hygiene needs
40//! a mini scope analyser; defer until dogfood asks).
41//!
42//! Nested expansion order: **inside-out**. Arg-position macro calls
43//! (`@double(@gensym)`) are expanded *before* the outer macro receives
44//! the args. This makes `@gensym`-inside-args composable with hygiene-
45//! sensitive outer macros without surprise (the gensym'd name is the
46//! arg value the outer macro sees).
47//!
48//! ## 0-dep contract
49//!
50//! Pure luna-core — uses only `Vec` / `Box<str>` / `HashMap` from std.
51//! No proc-macro engine. Each registered macro is a `Box<dyn Macro>`
52//! whose `expand` returns `Result<Vec<TokenInfo>, SyntaxError>`.
53
54use crate::frontend::error::SyntaxError;
55use crate::frontend::span::Span;
56use crate::frontend::token::{Token, TokenInfo};
57use std::collections::HashMap;
58
59/// Maximum recursion depth for nested macro expansion. Mirrors the
60/// parser's `MAX_DEPTH` (200) so a runaway `@foo` that re-emits `@foo`
61/// trips before blowing the Rust call stack.
62const MAX_EXPANSION_DEPTH: u32 = 200;
63
64/// Context passed to every macro `expand` invocation: gives access to
65/// the gensym counter (for hygienic identifier minting) and a back-
66/// reference to the registry (so a macro can call other macros
67/// programmatically — `@if` uses this to expand its chosen arm).
68pub struct MacroCtx<'r> {
69    /// Per-Vm gensym counter (`@gensym` increments). Lives on the Vm,
70    /// borrowed here for the duration of one expansion pass.
71    pub(crate) gensym_counter: &'r mut u64,
72    /// The registry, for nested expansion. `None` blocks recursion (used
73    /// when expanding a built-in's own output to defend against
74    /// macro-defined infinite recursion outside the depth limit).
75    /// Currently unread — the recursive expand happens in the outer
76    /// driver `expand_stream` so built-ins don't need to re-enter the
77    /// registry themselves. Kept on the public ctx surface so a future
78    /// host-side macro that wants to call sibling macros has a path.
79    #[allow(dead_code)]
80    pub(crate) registry: Option<&'r MacroRegistry>,
81    /// Line of the `@name` invocation, for error attribution.
82    pub line: u32,
83    /// Source span of the invocation (`@` byte through last `)`/`}`),
84    /// for `Token::describe` slicing on synthesized tokens.
85    pub span: Span,
86}
87
88impl<'r> MacroCtx<'r> {
89    /// Mint a fresh identifier name like `__lm_42_tmp`. Used by
90    /// `@gensym` and any host-side macro that needs hygiene.
91    pub fn gensym(&mut self, prefix: &str) -> Box<str> {
92        *self.gensym_counter = self.gensym_counter.wrapping_add(1);
93        let n = *self.gensym_counter;
94        let p = if prefix.is_empty() { "g" } else { prefix };
95        format!("__lm_{n}_{p}").into_boxed_str()
96    }
97}
98
99/// A registered MacroLua macro. Stateless w.r.t. the Vm — receives the
100/// arg token runs and returns the expansion as a fresh token vector.
101///
102/// ## Args shape
103///
104/// `args` is a slice of arg token runs, each one already split at the
105/// invocation's top-level commas. So `@foo(1, 2, 3)` arrives as
106/// `args.len() == 3`, with `args[0] == [Int(1)]` etc. `@foo()` arrives
107/// as `args.len() == 0`. The brace-delimited form `@foo{ ... }`
108/// arrives as `args.len() == 1` with `args[0]` being the brace body.
109///
110/// ## Error reporting
111///
112/// Return `Err(SyntaxError { line, msg })` to bubble a parse-time
113/// error attributed to a specific line (use `ctx.line` for the
114/// invocation site or an inner token's line for finer attribution).
115pub trait Macro {
116    /// Expand this invocation into a token stream that replaces it.
117    fn expand(
118        &self,
119        args: &[Vec<TokenInfo>],
120        ctx: &mut MacroCtx<'_>,
121    ) -> Result<Vec<TokenInfo>, SyntaxError>;
122}
123
124/// Per-Vm registry of registered macros (built-in + embedder-defined).
125/// Owned by the `Vm` (see `vm/exec.rs::Vm::macro_registry`); built-ins
126/// are inserted at Vm construction time when
127/// `version == LuaVersion::MacroLua`.
128pub struct MacroRegistry {
129    macros: HashMap<Box<str>, Box<dyn Macro>>,
130    /// Per-Vm gensym counter. Lives here (not on the Vm) so `Vm` only
131    /// has to hold one field; the counter survives across `parse` calls
132    /// so two scripts loaded into the same Vm get distinct gensyms.
133    pub(crate) gensym_counter: u64,
134}
135
136impl Default for MacroRegistry {
137    fn default() -> Self {
138        Self::new()
139    }
140}
141
142impl MacroRegistry {
143    /// Empty registry. Vms constructed with non-MacroLua versions hold
144    /// this but never consult it.
145    pub fn new() -> Self {
146        MacroRegistry {
147            macros: HashMap::new(),
148            gensym_counter: 0,
149        }
150    }
151
152    /// Build a registry pre-populated with the v1.3 built-in macros:
153    /// `@quote`, `@unquote`, `@if`, `@gensym`.
154    pub fn with_builtins() -> Self {
155        let mut r = MacroRegistry::new();
156        r.register("quote", Box::new(builtins::QuoteMacro));
157        r.register("unquote", Box::new(builtins::UnquoteMacro));
158        r.register("if", Box::new(builtins::IfMacro));
159        r.register("gensym", Box::new(builtins::GensymMacro));
160        r
161    }
162
163    /// Insert / overwrite a macro under `name`. Names are case-sensitive
164    /// and stored as-is (no `@` prefix internally).
165    pub fn register(&mut self, name: &str, m: Box<dyn Macro>) {
166        self.macros.insert(name.into(), m);
167    }
168
169    /// Lookup; returns `None` for unregistered names.
170    pub fn get(&self, name: &str) -> Option<&dyn Macro> {
171        self.macros.get(name).map(|b| b.as_ref())
172    }
173
174    /// Drop all registered macros (including built-ins). Test/dogfood
175    /// hygiene; not normally called by production embedders.
176    pub fn clear(&mut self) {
177        self.macros.clear();
178    }
179
180    /// Run the expansion pre-pass over `input`. The output stream has no
181    /// `@`/quote tokens remaining and is suitable for
182    /// [`crate::frontend::parser::parse_tokens`].
183    pub fn expand(&mut self, input: Vec<TokenInfo>) -> Result<Vec<TokenInfo>, SyntaxError> {
184        let mut counter = self.gensym_counter;
185        let out = expand_stream(input, self, &mut counter, 0)?;
186        self.gensym_counter = counter;
187        Ok(out)
188    }
189}
190
191/// Map a keyword token to its source spelling, so macro names like
192/// `@if` / `@local` / `@return` can dispatch correctly even though
193/// the lexer has folded them to keyword tokens.
194fn keyword_name(t: &Token) -> Option<&'static str> {
195    Some(match t {
196        Token::And => "and",
197        Token::Break => "break",
198        Token::Do => "do",
199        Token::Else => "else",
200        Token::Elseif => "elseif",
201        Token::End => "end",
202        Token::False => "false",
203        Token::For => "for",
204        Token::Function => "function",
205        Token::Global => "global",
206        Token::Goto => "goto",
207        Token::If => "if",
208        Token::In => "in",
209        Token::Local => "local",
210        Token::Nil => "nil",
211        Token::Not => "not",
212        Token::Or => "or",
213        Token::Repeat => "repeat",
214        Token::Return => "return",
215        Token::Then => "then",
216        Token::True => "true",
217        Token::Until => "until",
218        Token::While => "while",
219        _ => return None,
220    })
221}
222
223/// Core expansion loop. Recursive (depth-checked) so an arg-position
224/// macro call (`@double(@gensym)`) is expanded inside-out before its
225/// enclosing macro sees the result.
226fn expand_stream(
227    input: Vec<TokenInfo>,
228    registry: &MacroRegistry,
229    gensym_counter: &mut u64,
230    depth: u32,
231) -> Result<Vec<TokenInfo>, SyntaxError> {
232    if depth > MAX_EXPANSION_DEPTH {
233        let line = input.first().map(|t| t.line).unwrap_or(1);
234        return Err(SyntaxError::new(
235            line,
236            b"macro expansion depth exceeded (200) near '@'".to_vec(),
237        ));
238    }
239
240    let mut out: Vec<TokenInfo> = Vec::with_capacity(input.len());
241    let mut i = 0;
242    while i < input.len() {
243        match &input[i].tok {
244            Token::At => {
245                let inv_line = input[i].line;
246                let inv_start = input[i].span;
247                // expect Token::Name or a keyword-token immediately after
248                // `@` (the macro namespace overlaps Lua keywords, e.g.
249                // `@if` / `@local` / `@return` are useful spellings).
250                let name_idx = i + 1;
251                let name = match input.get(name_idx).map(|t| &t.tok) {
252                    Some(Token::Name(n)) => n.clone(),
253                    Some(other) => {
254                        if let Some(kw) = keyword_name(other) {
255                            kw.into()
256                        } else {
257                            return Err(SyntaxError::new(
258                                inv_line,
259                                b"macro name expected after '@'".to_vec(),
260                            ));
261                        }
262                    }
263                    None => {
264                        return Err(SyntaxError::new(
265                            inv_line,
266                            b"macro name expected after '@'".to_vec(),
267                        ));
268                    }
269                };
270                // Parse arg block: either `(args)`, `{ body }`, or empty.
271                let mut cursor = name_idx + 1;
272                let (raw_args, after) = collect_macro_args(&input, cursor, inv_line)?;
273                cursor = after;
274
275                // Recursively expand each arg run (inside-out hygiene
276                // model — see module docs).
277                let mut expanded_args: Vec<Vec<TokenInfo>> = Vec::with_capacity(raw_args.len());
278                for a in raw_args {
279                    expanded_args.push(expand_stream(a, registry, gensym_counter, depth + 1)?);
280                }
281
282                // Dispatch to the registry.
283                let macro_impl = registry.get(&name).ok_or_else(|| {
284                    SyntaxError::new(inv_line, format!("unknown macro '@{name}'").into_bytes())
285                })?;
286
287                // Span of the entire invocation, from `@` to the byte
288                // after the last arg-block token (best-effort; used for
289                // error reporting on synthesized tokens).
290                let end_span = if cursor > 0 && cursor <= input.len() {
291                    input[cursor - 1].span
292                } else {
293                    inv_start
294                };
295                let full_span = Span::new(inv_start.start as usize, end_span.end as usize);
296
297                let mut ctx = MacroCtx {
298                    gensym_counter,
299                    registry: Some(registry),
300                    line: inv_line,
301                    span: full_span,
302                };
303                let mut expanded = macro_impl.expand(&expanded_args, &mut ctx)?;
304                // Recursively expand the macro's output as well (so a
305                // macro can produce `@foo(...)` calls of other macros).
306                // Depth +1 guards against runaway recursion.
307                expanded = expand_stream(expanded, registry, gensym_counter, depth + 1)?;
308                out.extend(expanded);
309                i = cursor;
310            }
311            Token::MacroBraceOpen => {
312                // Bare `@{ ... }@` block at statement / arg position —
313                // captures as a MacroQuote token in `out`. Useful when
314                // the body is later consumed via `@unquote` of a bound
315                // name (host-registered macro pattern).
316                let block_line = input[i].line;
317                let (body, after) = collect_quote_block(&input, i, block_line)?;
318                let span = Span::new(
319                    input[i].span.start as usize,
320                    input[after - 1].span.end as usize,
321                );
322                // Recursively expand the body so it's macro-free when
323                // un-quoted.
324                let body_expanded = expand_stream(body, registry, gensym_counter, depth + 1)?;
325                out.push(TokenInfo {
326                    tok: Token::MacroQuote(body_expanded.into_boxed_slice()),
327                    span,
328                    line: block_line,
329                });
330                i = after;
331            }
332            Token::MacroBraceClose => {
333                return Err(SyntaxError::new(
334                    input[i].line,
335                    b"unexpected '}@' (no matching '@{')".to_vec(),
336                ));
337            }
338            Token::MacroQuote(_) => {
339                // Synthetic — pass through. (The parser never sees it
340                // because @unquote / built-ins splice it away first; if
341                // one survives to here it's user error and we surface
342                // it as a syntax error.)
343                return Err(SyntaxError::new(
344                    input[i].line,
345                    b"stray macro-quote token left in stream (forgot '@unquote'?)".to_vec(),
346                ));
347            }
348            _ => {
349                out.push(input[i].clone());
350                i += 1;
351            }
352        }
353    }
354    Ok(out)
355}
356
357/// Parse the arg block immediately following `@name`: `(a, b)`, `{ ... }`,
358/// or empty. Returns the raw arg runs (un-expanded) and the cursor
359/// position just after the last consumed token.
360fn collect_macro_args(
361    input: &[TokenInfo],
362    start: usize,
363    inv_line: u32,
364) -> Result<(Vec<Vec<TokenInfo>>, usize), SyntaxError> {
365    if start >= input.len() {
366        return Ok((Vec::new(), start));
367    }
368    match &input[start].tok {
369        Token::LParen => collect_paren_args(input, start, inv_line),
370        Token::LBrace => {
371            // `@name{ body }` — single brace-body arg.
372            let (body, after) = collect_brace_body(input, start, inv_line)?;
373            Ok((vec![body], after))
374        }
375        Token::MacroBraceOpen => {
376            // `@name@{ body }@` — explicit quote-block as single arg.
377            let (body, after) = collect_quote_block(input, start, inv_line)?;
378            Ok((vec![body], after))
379        }
380        _ => {
381            // No arg block — `@gensym`, etc.
382            Ok((Vec::new(), start))
383        }
384    }
385}
386
387/// `(a, b, c)` — splits at top-level commas; nested parens / brackets /
388/// braces / quote blocks are tracked.
389fn collect_paren_args(
390    input: &[TokenInfo],
391    lparen_idx: usize,
392    inv_line: u32,
393) -> Result<(Vec<Vec<TokenInfo>>, usize), SyntaxError> {
394    debug_assert!(matches!(input[lparen_idx].tok, Token::LParen));
395    let mut depth_paren = 1u32;
396    let mut depth_brace = 0u32;
397    let mut depth_bracket = 0u32;
398    let mut depth_quote = 0u32;
399    let mut args: Vec<Vec<TokenInfo>> = Vec::new();
400    let mut cur: Vec<TokenInfo> = Vec::new();
401    let mut i = lparen_idx + 1;
402    while i < input.len() {
403        match &input[i].tok {
404            Token::LParen => {
405                depth_paren += 1;
406                cur.push(input[i].clone());
407            }
408            Token::RParen => {
409                depth_paren -= 1;
410                if depth_paren == 0 && depth_brace == 0 && depth_bracket == 0 && depth_quote == 0 {
411                    if !cur.is_empty() || !args.is_empty() {
412                        args.push(std::mem::take(&mut cur));
413                    }
414                    return Ok((args, i + 1));
415                }
416                cur.push(input[i].clone());
417            }
418            Token::LBrace => {
419                depth_brace += 1;
420                cur.push(input[i].clone());
421            }
422            Token::RBrace => {
423                if depth_brace == 0 {
424                    return Err(SyntaxError::new(
425                        input[i].line,
426                        b"unexpected '}' inside macro arg list".to_vec(),
427                    ));
428                }
429                depth_brace -= 1;
430                cur.push(input[i].clone());
431            }
432            Token::LBracket => {
433                depth_bracket += 1;
434                cur.push(input[i].clone());
435            }
436            Token::RBracket => {
437                depth_bracket = depth_bracket.saturating_sub(1);
438                cur.push(input[i].clone());
439            }
440            Token::MacroBraceOpen => {
441                depth_quote += 1;
442                cur.push(input[i].clone());
443            }
444            Token::MacroBraceClose => {
445                if depth_quote == 0 {
446                    return Err(SyntaxError::new(
447                        input[i].line,
448                        b"unexpected '}@' inside macro arg list".to_vec(),
449                    ));
450                }
451                depth_quote -= 1;
452                cur.push(input[i].clone());
453            }
454            Token::Comma
455                if depth_paren == 1
456                    && depth_brace == 0
457                    && depth_bracket == 0
458                    && depth_quote == 0 =>
459            {
460                args.push(std::mem::take(&mut cur));
461            }
462            _ => cur.push(input[i].clone()),
463        }
464        i += 1;
465    }
466    Err(SyntaxError::new(
467        inv_line,
468        b"unterminated macro arg list (missing ')')".to_vec(),
469    ))
470}
471
472/// `{ ... }` brace-body — captures everything between balanced braces
473/// as a single token run. Nested braces are passed through.
474fn collect_brace_body(
475    input: &[TokenInfo],
476    lbrace_idx: usize,
477    inv_line: u32,
478) -> Result<(Vec<TokenInfo>, usize), SyntaxError> {
479    debug_assert!(matches!(input[lbrace_idx].tok, Token::LBrace));
480    let mut depth = 1u32;
481    let mut body: Vec<TokenInfo> = Vec::new();
482    let mut i = lbrace_idx + 1;
483    while i < input.len() {
484        match &input[i].tok {
485            Token::LBrace => {
486                depth += 1;
487                body.push(input[i].clone());
488            }
489            Token::RBrace => {
490                depth -= 1;
491                if depth == 0 {
492                    return Ok((body, i + 1));
493                }
494                body.push(input[i].clone());
495            }
496            _ => body.push(input[i].clone()),
497        }
498        i += 1;
499    }
500    Err(SyntaxError::new(
501        inv_line,
502        b"unterminated macro brace body (missing '}')".to_vec(),
503    ))
504}
505
506/// `@{ tokens... }@` — captures everything between balanced
507/// `@{`/`}@` sigils. Nested `@{...}@` is supported.
508fn collect_quote_block(
509    input: &[TokenInfo],
510    open_idx: usize,
511    inv_line: u32,
512) -> Result<(Vec<TokenInfo>, usize), SyntaxError> {
513    debug_assert!(matches!(input[open_idx].tok, Token::MacroBraceOpen));
514    let mut depth = 1u32;
515    let mut body: Vec<TokenInfo> = Vec::new();
516    let mut i = open_idx + 1;
517    while i < input.len() {
518        match &input[i].tok {
519            Token::MacroBraceOpen => {
520                depth += 1;
521                body.push(input[i].clone());
522            }
523            Token::MacroBraceClose => {
524                depth -= 1;
525                if depth == 0 {
526                    return Ok((body, i + 1));
527                }
528                body.push(input[i].clone());
529            }
530            _ => body.push(input[i].clone()),
531        }
532        i += 1;
533    }
534    Err(SyntaxError::new(
535        inv_line,
536        b"unterminated quote block (missing '}@')".to_vec(),
537    ))
538}
539
540/// Built-in macros shipped under v1.3 floor.
541mod builtins {
542    use super::*;
543
544    /// `@quote{ body }` — returns the body wrapped in a single
545    /// [`Token::MacroQuote`]. The parser never sees `MacroQuote`
546    /// directly; another macro is expected to consume it (via the
547    /// expander's arg-position handling) or `@unquote` is used to
548    /// splice it back into the stream.
549    ///
550    /// **For the common case where the user simply wants a quote
551    /// available at the point of writing**, `@quote{...}` is most
552    /// useful as one arg of a host-registered macro. For the
553    /// `@quote{x = 1}` standalone roundtrip (test
554    /// `macro_lua_quote_roundtrip`), `@quote` emits the body tokens
555    /// directly when in **statement** position — the body is treated
556    /// as a snippet to splice. We achieve "both" by: if exactly one
557    /// brace-body arg is present, the body tokens are returned
558    /// verbatim (spliced); if no args, an error is raised.
559    pub(super) struct QuoteMacro;
560
561    impl Macro for QuoteMacro {
562        fn expand(
563            &self,
564            args: &[Vec<TokenInfo>],
565            ctx: &mut MacroCtx<'_>,
566        ) -> Result<Vec<TokenInfo>, SyntaxError> {
567            if args.len() != 1 {
568                return Err(SyntaxError::new(
569                    ctx.line,
570                    format!(
571                        "@quote expects exactly one brace body, got {} args",
572                        args.len()
573                    )
574                    .into_bytes(),
575                ));
576            }
577            // Splice the body directly. This makes `@quote{ x = 1 }`
578            // expand to the tokens `x = 1` at the call site, which
579            // matches the "syntactic snippet" use case.
580            Ok(args[0].clone())
581        }
582    }
583
584    /// `@unquote(name)` — given a single arg that is a captured
585    /// [`Token::MacroQuote`], splice its captured tokens back into
586    /// the stream. If the arg is anything else, error.
587    pub(super) struct UnquoteMacro;
588
589    impl Macro for UnquoteMacro {
590        fn expand(
591            &self,
592            args: &[Vec<TokenInfo>],
593            ctx: &mut MacroCtx<'_>,
594        ) -> Result<Vec<TokenInfo>, SyntaxError> {
595            if args.len() != 1 {
596                return Err(SyntaxError::new(
597                    ctx.line,
598                    format!("@unquote expects 1 arg, got {}", args.len()).into_bytes(),
599                ));
600            }
601            let a = &args[0];
602            if a.len() == 1 {
603                if let Token::MacroQuote(body) = &a[0].tok {
604                    return Ok(body.to_vec());
605                }
606            }
607            // Permissive: any non-MacroQuote single arg just passes
608            // through verbatim — `@unquote(x)` becomes `x`. Useful in
609            // host-side macro templates.
610            Ok(a.clone())
611        }
612    }
613
614    /// `@if cond { then-arm } @else { else-arm }` — compile-time
615    /// conditional. `cond` is one of:
616    ///   - bareword `true` / `false`
617    ///   - integer literal (truthy if non-zero)
618    ///   - `expr == expr` where both sides are int / float / string
619    ///     literals (literal-eq folder).
620    ///
621    /// Because of how the expander packs args (paren form), `@if` here
622    /// uses a **single brace body** containing the entire then-arm.
623    /// The `@else { ... }` is a separate token run *after* the
624    /// invocation; the expander has already consumed only the then
625    /// arm. To make `@if cond {...} @else {...}` shape work cleanly,
626    /// we accept this surface form:
627    ///
628    /// `@if(cond){ then-body }`        (else omitted = empty)
629    /// `@if(cond){ then-body }@else{ else-body }`
630    ///
631    /// The post-`@else` clause is **not** picked up automatically
632    /// here — that would require the expander to look past the
633    /// invocation. Instead the test+demo use the simpler
634    /// `@if(cond){ then-body }` form for v1.3; `@if-else` is a
635    /// follow-up that's straightforward but adds dispatcher coupling.
636    pub(super) struct IfMacro;
637
638    impl Macro for IfMacro {
639        fn expand(
640            &self,
641            args: &[Vec<TokenInfo>],
642            ctx: &mut MacroCtx<'_>,
643        ) -> Result<Vec<TokenInfo>, SyntaxError> {
644            // Expected: 2 args = (cond-expr, then-body). Optional 3rd =
645            // else-body. Args were split by the expander's
646            // `collect_paren_args` so the cond comes via parens and the
647            // bodies via... wait — parens form gives multiple args via
648            // comma. The shape we'll accept is:
649            //
650            //   @if(cond, @quote{ then-body })
651            //   @if(cond, @quote{ then-body }, @quote{ else-body })
652            //
653            // i.e. body arms are passed as `@quote{...}` quote tokens.
654            // This keeps the macro syntax LR-parseable without needing
655            // the expander to scan post-invocation tokens.
656            if args.len() < 2 || args.len() > 3 {
657                return Err(SyntaxError::new(
658                    ctx.line,
659                    format!("@if expects (cond, then[, else]) — got {} args", args.len())
660                        .into_bytes(),
661                ));
662            }
663            let cond_truthy = eval_const_cond(&args[0], ctx.line)?;
664            let chosen = if cond_truthy {
665                &args[1]
666            } else if args.len() == 3 {
667                &args[2]
668            } else {
669                &EMPTY_ARM
670            };
671            // Unwrap MacroQuote if present; else splice as-is.
672            if chosen.len() == 1 {
673                if let Token::MacroQuote(body) = &chosen[0].tok {
674                    return Ok(body.to_vec());
675                }
676            }
677            Ok(chosen.clone())
678        }
679    }
680
681    static EMPTY_ARM: Vec<TokenInfo> = Vec::new();
682
683    /// Evaluate a constant-fold-able condition expression. Supports:
684    ///   - `true` / `false`
685    ///   - integer literal (non-zero = true)
686    ///   - `lit == lit` for int / float / string literals
687    fn eval_const_cond(tokens: &[TokenInfo], line: u32) -> Result<bool, SyntaxError> {
688        // Strip leading/trailing whitespace already done by lexer.
689        if tokens.is_empty() {
690            return Err(SyntaxError::new(line, b"@if: empty condition".to_vec()));
691        }
692        if tokens.len() == 1 {
693            return match &tokens[0].tok {
694                Token::True => Ok(true),
695                Token::False => Ok(false),
696                Token::Int(i) => Ok(*i != 0),
697                Token::Nil => Ok(false),
698                _ => Err(SyntaxError::new(
699                    line,
700                    b"@if: cond must be true/false/integer/literal-eq".to_vec(),
701                )),
702            };
703        }
704        // 3-token form: lit `==` lit  or  lit `~=` lit
705        if tokens.len() == 3 {
706            let op = &tokens[1].tok;
707            let eq = matches!(op, Token::Eq);
708            let ne = matches!(op, Token::Ne);
709            if eq || ne {
710                let l = literal_eq(&tokens[0].tok, &tokens[2].tok, line)?;
711                return Ok(if eq { l } else { !l });
712            }
713        }
714        Err(SyntaxError::new(
715            line,
716            b"@if: unsupported condition shape (use true/false/int/lit==lit)".to_vec(),
717        ))
718    }
719
720    fn literal_eq(a: &Token, b: &Token, line: u32) -> Result<bool, SyntaxError> {
721        Ok(match (a, b) {
722            (Token::Int(x), Token::Int(y)) => x == y,
723            (Token::Float(x), Token::Float(y)) => x == y,
724            (Token::Int(x), Token::Float(y)) | (Token::Float(y), Token::Int(x)) => {
725                (*x as f64) == *y
726            }
727            (Token::Str(x), Token::Str(y)) => x == y,
728            (Token::True, Token::True)
729            | (Token::False, Token::False)
730            | (Token::Nil, Token::Nil) => true,
731            (Token::True, Token::False) | (Token::False, Token::True) => false,
732            _ => {
733                return Err(SyntaxError::new(
734                    line,
735                    b"@if: only int/float/string/bool/nil literals comparable".to_vec(),
736                ));
737            }
738        })
739    }
740
741    /// `@gensym` / `@gensym("prefix")` — emit a fresh `Name` token.
742    pub(super) struct GensymMacro;
743
744    impl Macro for GensymMacro {
745        fn expand(
746            &self,
747            args: &[Vec<TokenInfo>],
748            ctx: &mut MacroCtx<'_>,
749        ) -> Result<Vec<TokenInfo>, SyntaxError> {
750            let prefix = if args.is_empty() {
751                String::new()
752            } else if args.len() == 1 && args[0].len() == 1 {
753                match &args[0][0].tok {
754                    Token::Str(bytes) => String::from_utf8_lossy(bytes).into_owned(),
755                    Token::Name(n) => n.to_string(),
756                    _ => {
757                        return Err(SyntaxError::new(
758                            ctx.line,
759                            b"@gensym: prefix must be a string literal or name".to_vec(),
760                        ));
761                    }
762                }
763            } else {
764                return Err(SyntaxError::new(
765                    ctx.line,
766                    b"@gensym: expected 0 or 1 args".to_vec(),
767                ));
768            };
769            let name = ctx.gensym(&prefix);
770            Ok(vec![TokenInfo {
771                tok: Token::Name(name),
772                span: ctx.span,
773                line: ctx.line,
774            }])
775        }
776    }
777}
778
779#[cfg(test)]
780mod tests {
781    use super::*;
782    use crate::frontend::lexer::Lexer;
783    use crate::version::LuaVersion;
784
785    fn lex(src: &str, v: LuaVersion) -> Vec<TokenInfo> {
786        let mut lex = Lexer::new(src.as_bytes(), v);
787        let mut out = Vec::new();
788        loop {
789            let t = lex.next_token().expect("lex");
790            let eof = matches!(t.tok, Token::Eof);
791            if eof {
792                break;
793            }
794            out.push(t);
795        }
796        out
797    }
798
799    #[test]
800    fn gensym_is_unique() {
801        let mut r = MacroRegistry::with_builtins();
802        let toks = lex("local a = @gensym local b = @gensym", LuaVersion::MacroLua);
803        let out = r.expand(toks).unwrap();
804        // Collect only the synthesized gensym names (prefix `__lm_`).
805        let gensyms: Vec<String> = out
806            .iter()
807            .filter_map(|t| {
808                if let Token::Name(n) = &t.tok {
809                    if n.starts_with("__lm_") {
810                        Some(n.to_string())
811                    } else {
812                        None
813                    }
814                } else {
815                    None
816                }
817            })
818            .collect();
819        assert_eq!(gensyms.len(), 2, "expected 2 gensyms, got {gensyms:?}");
820        assert_ne!(gensyms[0], gensyms[1], "gensyms must be unique");
821    }
822
823    #[test]
824    fn unknown_macro_errors() {
825        let mut r = MacroRegistry::with_builtins();
826        let toks = lex("@nope(1)", LuaVersion::MacroLua);
827        let err = r.expand(toks).unwrap_err();
828        assert!(
829            String::from_utf8_lossy(&err.msg).contains("unknown macro"),
830            "got: {}",
831            err.msg_str()
832        );
833    }
834
835    #[test]
836    fn quote_splices_body() {
837        let mut r = MacroRegistry::with_builtins();
838        let toks = lex("local x = @quote{ 42 }", LuaVersion::MacroLua);
839        let out = r.expand(toks).unwrap();
840        // The output should contain Local, Name("x"), Assign, Int(42).
841        let has_42 = out.iter().any(|t| matches!(t.tok, Token::Int(42)));
842        assert!(has_42, "@quote{{42}} should splice Int(42); got {out:?}");
843        // No `@` tokens remain.
844        assert!(
845            out.iter().all(|t| !matches!(
846                t.tok,
847                Token::At | Token::MacroBraceOpen | Token::MacroBraceClose
848            )),
849            "expander left @-tokens: {out:?}"
850        );
851    }
852}