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}