Skip to main content

luna_core/frontend/
ast.rs

1//! Arena AST: nodes live in flat vectors inside [`Chunk`], referenced by
2//! typed 4-byte ids. Dense storage, no per-node boxing.
3
4/// Typed index into [`Chunk::exprs`].
5#[derive(Clone, Copy, PartialEq, Eq, Debug)]
6pub struct ExprId(
7    /// Zero-based offset into the chunk's expression arena.
8    pub u32,
9);
10
11/// Typed index into [`Chunk::stats`].
12#[derive(Clone, Copy, PartialEq, Eq, Debug)]
13pub struct StatId(
14    /// Zero-based offset into the chunk's statement arena.
15    pub u32,
16);
17
18/// An identifier token captured during parsing, together with its source
19/// line for error reporting and debug-info emission.
20#[derive(Clone, Debug)]
21pub struct Name {
22    /// UTF-8 source text of the identifier.
23    pub text: Box<str>,
24    /// 1-based source line where the identifier was lexed.
25    pub line: u32,
26}
27
28/// Lua 5.4+ local-variable attribute (`<const>` / `<close>`).
29#[derive(Clone, Copy, PartialEq, Eq, Debug)]
30pub enum Attrib {
31    /// `<const>` — immutable local binding.
32    Const,
33    /// `<close>` — to-be-closed local; closes on scope exit (5.4).
34    Close,
35}
36
37/// One declared name with its optional `<attrib>`.
38#[derive(Clone, Debug)]
39pub struct AttribName {
40    /// Identifier being declared.
41    pub name: Name,
42    /// Optional attribute (`<const>` / `<close>`).
43    pub attrib: Option<Attrib>,
44}
45
46/// A sequence of statements; the lexical scope unit in Lua.
47#[derive(Clone, Debug)]
48pub struct Block {
49    /// Statements in source order.
50    pub stats: Vec<StatId>,
51}
52
53/// `function a.b.c:m() ...` target path.
54#[derive(Clone, Debug)]
55pub struct FuncName {
56    /// First identifier in the path (`a` in `a.b.c:m`).
57    pub base: Name,
58    /// Dotted sub-keys after the base, in left-to-right order.
59    pub path: Vec<Name>,
60    /// Method name after `:`, if any (adds an implicit `self` parameter).
61    pub method: Option<Name>,
62}
63
64/// Vararg form for a function definition.
65#[derive(Clone, Debug)]
66pub enum Vararg {
67    /// No vararg in the parameter list.
68    None,
69    /// Anonymous `...`; accessible via `...` in the body.
70    Anonymous,
71    /// 5.5 named vararg table: `function f(...t)`.
72    Named(
73        /// Bound name receiving the captured varargs as a sequence.
74        Name,
75    ),
76}
77
78/// A function literal's body — parameters plus the contained block.
79#[derive(Clone, Debug)]
80pub struct FuncBody {
81    /// Fixed parameter list, in declaration order.
82    pub params: Vec<Name>,
83    /// Vararg form, if any.
84    pub vararg: Vararg,
85    /// Body block.
86    pub block: Block,
87    /// Source line of the opening `function` / `(` token.
88    pub line: u32,
89    /// line of the closing `end` (PUC `lastlinedefined`)
90    pub end_line: u32,
91}
92
93/// Top-level statement kinds — every Lua syntactic form except expressions.
94#[derive(Clone, Debug)]
95pub enum Stat {
96    /// `do ... end` block.
97    Do(
98        /// Inner block.
99        Block,
100    ),
101    /// `while cond do ... end`.
102    While {
103        /// Loop condition evaluated each iteration.
104        cond: ExprId,
105        /// Loop body.
106        body: Block,
107    },
108    /// `repeat ... until cond`.
109    Repeat {
110        /// Loop body executed before testing.
111        body: Block,
112        /// Termination condition.
113        cond: ExprId,
114    },
115    /// `if ... elseif ... else ... end`.
116    If {
117        /// `(condition, then_line, body)` for the `if` and each `elseif`. The
118        /// `then_line` is the source line of the `then` keyword for that arm
119        /// — PUC 5.3 attributes the conditional-skip JMP to that line so a
120        /// taken if-then-else fires a line hook for the `then` keyword before
121        /// the body (`for i=1,n do … then … end` traces include the `then`
122        /// keyword line). 5.4 collapsed that back to the body's first line;
123        /// see `if_stat` in the compiler for the version split.
124        arms: Vec<(ExprId, u32, Block)>,
125        /// Optional `else` body.
126        else_body: Option<Block>,
127    },
128    /// `for var = start, limit [, step] do ... end`.
129    NumericFor {
130        /// Induction variable.
131        var: Name,
132        /// Starting value expression.
133        start: ExprId,
134        /// Upper bound expression.
135        limit: ExprId,
136        /// Optional step expression (defaults to `1`).
137        step: Option<ExprId>,
138        /// Loop body.
139        body: Block,
140    },
141    /// `for v1, v2, ... in exprs do ... end`.
142    GenericFor {
143        /// Loop variables receiving each iterator call's results.
144        vars: Vec<Name>,
145        /// Expression list yielding iterator, state, control, and (5.4)
146        /// to-be-closed value.
147        exprs: Vec<ExprId>,
148        /// Loop body.
149        body: Block,
150        /// Line of the first token after `in` (PUC `forlist` `line`); used to
151        /// attribute the per-iteration `TFORCALL` so a non-callable iterator
152        /// (`for k,v in 3 do …`) raises on the EXPR's source line, not the
153        /// `for` line.
154        expr_line: u32,
155    },
156    /// `local [<attrib>] names = exprs`.
157    Local {
158        /// Single attribute applied to every name (5.4 `local <const>`).
159        collective: Option<Attrib>,
160        /// Names being introduced, each with its optional per-name attribute.
161        names: Vec<AttribName>,
162        /// Initializer expressions; missing names get `nil`.
163        exprs: Vec<ExprId>,
164    },
165    /// 5.5 `global` declaration.
166    Global {
167        /// Attribute applied to every name.
168        collective: Option<Attrib>,
169        /// Declared global names.
170        names: Vec<AttribName>,
171        /// Initializer expressions.
172        exprs: Vec<ExprId>,
173    },
174    /// 5.5 `global [attrib] *`.
175    GlobalAll {
176        /// Attribute applied to all subsequently introduced globals.
177        attrib: Option<Attrib>,
178    },
179    /// Multiple assignment `targets = exprs`.
180    Assign {
181        /// Assignment targets — each must be an lvalue (`Name` / `Index`).
182        targets: Vec<ExprId>,
183        /// Right-hand side expressions, evaluated before any target is
184        /// assigned.
185        exprs: Vec<ExprId>,
186    },
187    /// Expression statement (function or method call).
188    Call(
189        /// The call expression.
190        ExprId,
191    ),
192    /// `function a.b.c:m() ... end`.
193    Function {
194        /// Target path of the assignment.
195        name: FuncName,
196        /// Function body.
197        body: FuncBody,
198    },
199    /// `local function name() ... end`.
200    LocalFunction {
201        /// Local name being bound.
202        name: Name,
203        /// Function body.
204        body: FuncBody,
205    },
206    /// 5.5 `global function f() ...`.
207    GlobalFunction {
208        /// Global name being bound.
209        name: Name,
210        /// Function body.
211        body: FuncBody,
212    },
213    /// `return exprs`.
214    Return {
215        /// Returned expressions; empty for a bare `return`.
216        exprs: Vec<ExprId>,
217        /// Source line of the `return` keyword.
218        line: u32,
219    },
220    /// `break`.
221    Break {
222        /// Source line of the `break` keyword.
223        line: u32,
224    },
225    /// `goto label`.
226    Goto(
227        /// Target label.
228        Name,
229    ),
230    /// `::label::` declaration.
231    Label(
232        /// Label name.
233        Name,
234    ),
235}
236
237/// Binary operator kinds.
238#[derive(Clone, Copy, PartialEq, Eq, Debug)]
239pub enum BinOp {
240    /// `+` arithmetic addition.
241    Add,
242    /// `-` arithmetic subtraction.
243    Sub,
244    /// `*` arithmetic multiplication.
245    Mul,
246    /// `/` float division (always returns float).
247    Div,
248    /// `//` floor division.
249    IDiv,
250    /// `%` modulo.
251    Mod,
252    /// `^` exponentiation (always returns float).
253    Pow,
254    /// `..` string concatenation.
255    Concat,
256    /// `==` equality.
257    Eq,
258    /// `~=` inequality.
259    Ne,
260    /// `<` less than.
261    Lt,
262    /// `<=` less than or equal.
263    Le,
264    /// `>` greater than.
265    Gt,
266    /// `>=` greater than or equal.
267    Ge,
268    /// `and` short-circuiting conjunction.
269    And,
270    /// `or` short-circuiting disjunction.
271    Or,
272    /// `&` bitwise AND.
273    BAnd,
274    /// `|` bitwise OR.
275    BOr,
276    /// `~` bitwise XOR.
277    BXor,
278    /// `<<` left shift.
279    Shl,
280    /// `>>` right shift.
281    Shr,
282}
283
284/// Unary operator kinds.
285#[derive(Clone, Copy, PartialEq, Eq, Debug)]
286pub enum UnOp {
287    /// `-` arithmetic negation.
288    Neg,
289    /// `not` logical negation.
290    Not,
291    /// `#` length operator.
292    Len,
293    /// `~` bitwise NOT.
294    BNot,
295}
296
297/// One field in a table constructor literal.
298#[derive(Clone, Debug)]
299pub enum TableField {
300    /// positional `expr`
301    Item(
302        /// Value expression.
303        ExprId,
304    ),
305    /// `name = expr`
306    Named(
307        /// Field name used as a string key.
308        Name,
309        /// Value expression.
310        ExprId,
311    ),
312    /// `[key] = expr`
313    Keyed(
314        /// Key expression.
315        ExprId,
316        /// Value expression.
317        ExprId,
318    ),
319}
320
321/// Expression kinds — produces a Lua value when evaluated.
322#[derive(Clone, Debug)]
323pub enum Expr {
324    /// `nil` literal.
325    Nil,
326    /// `true` literal.
327    True,
328    /// `false` literal.
329    False,
330    /// `...` vararg expression (legal only inside a vararg function).
331    Vararg,
332    /// Integer literal.
333    Int(
334        /// The 64-bit signed integer value.
335        i64,
336    ),
337    /// Floating-point literal.
338    Float(
339        /// The IEEE-754 double value.
340        f64,
341    ),
342    /// String literal (raw bytes — Lua strings are 8-bit clean).
343    Str(
344        /// Raw byte contents (no terminator).
345        Vec<u8>,
346    ),
347    /// Identifier reference (resolved later to local / upvalue / global).
348    Name(
349        /// The identifier.
350        Name,
351    ),
352    /// `obj.key` and `obj[key]` (dot keys become string-literal keys).
353    Index {
354        /// Container expression.
355        obj: ExprId,
356        /// Key expression.
357        key: ExprId,
358    },
359    /// `func(args)` function call.
360    Call {
361        /// Callee expression.
362        func: ExprId,
363        /// Argument expressions in call order.
364        args: Vec<ExprId>,
365        /// Source line of the call site.
366        line: u32,
367    },
368    /// `obj:method(args)` method call (passes `obj` as implicit first arg).
369    MethodCall {
370        /// Receiver expression.
371        obj: ExprId,
372        /// Method name (looked up on `obj`).
373        method: Name,
374        /// Argument expressions after the implicit receiver.
375        args: Vec<ExprId>,
376        /// Source line of the call site.
377        line: u32,
378    },
379    /// `function ... end` function literal.
380    Function(
381        /// Function body.
382        FuncBody,
383    ),
384    /// `{ ... }` table constructor.
385    Table {
386        /// Fields in source order.
387        fields: Vec<TableField>,
388        /// Source line of the opening `{`.
389        line: u32,
390    },
391    /// Binary operator expression.
392    BinOp {
393        /// Operator.
394        op: BinOp,
395        /// Left operand.
396        lhs: ExprId,
397        /// Right operand.
398        rhs: ExprId,
399        /// Source line for error reporting.
400        line: u32,
401    },
402    /// Unary operator expression.
403    UnOp {
404        /// Operator.
405        op: UnOp,
406        /// Operand.
407        operand: ExprId,
408        /// Source line for error reporting.
409        line: u32,
410    },
411    /// Parenthesized expression: truncates multiple results to one.
412    Paren(
413        /// Inner expression.
414        ExprId,
415    ),
416}
417
418/// A parsed chunk: the top-level block plus the node arenas.
419#[derive(Clone, Debug)]
420pub struct Chunk {
421    /// Arena of all expression nodes; index with [`ExprId`].
422    pub exprs: Vec<Expr>,
423    /// Arena of all statement nodes; index with [`StatId`].
424    pub stats: Vec<Stat>,
425    /// starting source line of each statement, indexed by `StatId`
426    pub stat_lines: Vec<u32>,
427    /// Top-level block (the script body).
428    pub block: Block,
429    /// line of the final `<eof>` token (PUC main-chunk `lastlinedefined`); the
430    /// implicit final return is attributed here
431    pub end_line: u32,
432}
433
434impl Chunk {
435    /// Borrow an expression node by id.
436    pub fn expr(&self, id: ExprId) -> &Expr {
437        &self.exprs[id.0 as usize]
438    }
439
440    /// Borrow a statement node by id.
441    pub fn stat(&self, id: StatId) -> &Stat {
442        &self.stats[id.0 as usize]
443    }
444
445    /// Starting source line of statement `id` (0 if unrecorded).
446    pub fn stat_line(&self, id: StatId) -> u32 {
447        self.stat_lines.get(id.0 as usize).copied().unwrap_or(0)
448    }
449}
450
451/// P11-S5d.N — does any expression in `block` (and nested control-flow,
452/// but NOT nested `Expr::Function` bodies) use `Expr::Vararg`?
453///
454/// PUC 5.1 `LUAI_COMPAT_VARARG` heuristic: a `(...)` function gets a
455/// hidden `arg` local UNLESS the body references `...`. The clear of
456/// `VARARG_NEEDSARG` in lparser.c happens at `simpleexp`'s TK_DOTS
457/// branch, which is a body-level decision. luna's compiler now runs
458/// this AST walk before declaring the auto-`arg` local.
459pub fn block_uses_vararg(chunk: &Chunk, block: &Block) -> bool {
460    block
461        .stats
462        .iter()
463        .any(|&sid| stat_uses_vararg(chunk, chunk.stat(sid)))
464}
465
466fn stat_uses_vararg(chunk: &Chunk, stat: &Stat) -> bool {
467    use Stat::*;
468    match stat {
469        Do(b) => block_uses_vararg(chunk, b),
470        While { cond, body } => expr_uses_vararg(chunk, *cond) || block_uses_vararg(chunk, body),
471        Repeat { body, cond } => block_uses_vararg(chunk, body) || expr_uses_vararg(chunk, *cond),
472        If { arms, else_body } => {
473            arms.iter()
474                .any(|(c, _, b)| expr_uses_vararg(chunk, *c) || block_uses_vararg(chunk, b))
475                || else_body
476                    .as_ref()
477                    .is_some_and(|b| block_uses_vararg(chunk, b))
478        }
479        NumericFor {
480            start,
481            limit,
482            step,
483            body,
484            ..
485        } => {
486            expr_uses_vararg(chunk, *start)
487                || expr_uses_vararg(chunk, *limit)
488                || step.is_some_and(|s| expr_uses_vararg(chunk, s))
489                || block_uses_vararg(chunk, body)
490        }
491        GenericFor { exprs, body, .. } => {
492            exprs.iter().any(|&e| expr_uses_vararg(chunk, e)) || block_uses_vararg(chunk, body)
493        }
494        Local { exprs, .. } | Global { exprs, .. } => {
495            exprs.iter().any(|&e| expr_uses_vararg(chunk, e))
496        }
497        GlobalAll { .. } => false,
498        Assign { targets, exprs } => {
499            targets.iter().any(|&e| expr_uses_vararg(chunk, e))
500                || exprs.iter().any(|&e| expr_uses_vararg(chunk, e))
501        }
502        Call(e) => expr_uses_vararg(chunk, *e),
503        // Nested functions own their own vararg context — don't peek
504        // inside them. (PUC's `simpleexp` only clears NEEDSARG on
505        // direct `...` use in the current function's source.)
506        Function { .. } | LocalFunction { .. } | GlobalFunction { .. } => false,
507        Return { exprs, .. } => exprs.iter().any(|&e| expr_uses_vararg(chunk, e)),
508        Break { .. } | Goto(_) | Label(_) => false,
509    }
510}
511
512fn expr_uses_vararg(chunk: &Chunk, eid: ExprId) -> bool {
513    match chunk.expr(eid) {
514        Expr::Vararg => true,
515        // Stop at function literals — their `...` is scoped to them.
516        Expr::Function(_) => false,
517        Expr::Index { obj, key } => expr_uses_vararg(chunk, *obj) || expr_uses_vararg(chunk, *key),
518        Expr::Call { func, args, .. } => {
519            expr_uses_vararg(chunk, *func) || args.iter().any(|&a| expr_uses_vararg(chunk, a))
520        }
521        Expr::MethodCall { obj, args, .. } => {
522            expr_uses_vararg(chunk, *obj) || args.iter().any(|&a| expr_uses_vararg(chunk, a))
523        }
524        Expr::Table { fields, .. } => fields.iter().any(|f| table_field_uses_vararg(chunk, f)),
525        Expr::BinOp { lhs, rhs, .. } => {
526            expr_uses_vararg(chunk, *lhs) || expr_uses_vararg(chunk, *rhs)
527        }
528        Expr::UnOp { operand, .. } => expr_uses_vararg(chunk, *operand),
529        Expr::Paren(inner) => expr_uses_vararg(chunk, *inner),
530        Expr::Nil
531        | Expr::True
532        | Expr::False
533        | Expr::Int(_)
534        | Expr::Float(_)
535        | Expr::Str(_)
536        | Expr::Name(_) => false,
537    }
538}
539
540fn table_field_uses_vararg(chunk: &Chunk, f: &TableField) -> bool {
541    match f {
542        TableField::Item(e) | TableField::Named(_, e) => expr_uses_vararg(chunk, *e),
543        TableField::Keyed(k, v) => expr_uses_vararg(chunk, *k) || expr_uses_vararg(chunk, *v),
544    }
545}
546
547// ---------------------------------------------------------------------------
548// v2.1 Phase 11 — A4' prerequisite: RHS Call walker + metamethod-safety gate.
549// ---------------------------------------------------------------------------
550//
551// A4' (RFC `v2.0-pi-phase11-a4-prime-rfc.md` §2) wants to skip the Index-LHS
552// object snapshot Move at `compiler/mod.rs:2490` when the RHS of an assignment
553// cannot re-bind the LHS local through a `__newindex` closure. The two helpers
554// below model just the AST-side gate; the consumer (a future A4' attack) is
555// expected to combine them with `LocalVar.captured` and target-arity checks.
556//
557// Pure additive in this batch: no current compile path calls these, so the
558// only risk is dead-code warnings, which are silenced via #[allow] on the
559// pub(crate) wrappers below until A4' wires them up.
560
561/// Classification of call sites discovered in an RHS expression tree.
562///
563/// The walker partitions expressions into three buckets; an A4'-style gate
564/// only accepts the bottom two (`None` and `OnlyKnownPure`) because
565/// `UserOrUnknown` call sites can — through `__newindex` metamethod closure
566/// capture — re-bind any local the closure has captured, which would
567/// invalidate the Index-LHS snapshot elision.
568///
569/// This is intentionally an enum rather than a `bool`: a future relaxation
570/// of the gate may distinguish between the two safe variants (e.g. to count
571/// the third bucket for diagnostics).
572#[derive(Debug, Clone, Copy, PartialEq, Eq)]
573#[doc(hidden)]
574pub enum RhsCallScan {
575    /// No `Call` or `MethodCall` AST nodes anywhere in the walked tree.
576    /// Pure arith, literals, names, indices, paren and unary/binary chains.
577    None,
578    /// Calls present, but every callee is a `<stdlib_module>.<field>` shape
579    /// (e.g. `math.min`, `string.byte`, `table.unpack`) where
580    /// `<stdlib_module>` is a known-immutable stdlib root (see
581    /// [`is_known_pure_stdlib_root`]). A known-pure call cannot run
582    /// user-supplied Lua code, so it cannot trigger `__newindex` re-binding
583    /// of an outer local.
584    OnlyKnownPure,
585    /// At least one `Call` or `MethodCall` site whose callee is not a
586    /// known-pure stdlib lookup — could invoke user-defined Lua that
587    /// re-binds locals via captured upvalues. A4' must reject this case.
588    UserOrUnknown,
589}
590
591impl RhsCallScan {
592    fn join(self, other: RhsCallScan) -> RhsCallScan {
593        use RhsCallScan::*;
594        match (self, other) {
595            (UserOrUnknown, _) | (_, UserOrUnknown) => UserOrUnknown,
596            (OnlyKnownPure, _) | (_, OnlyKnownPure) => OnlyKnownPure,
597            (None, None) => None,
598        }
599    }
600}
601
602/// Stdlib module names whose top-level fields the gate treats as
603/// known-pure (no user-Lua callback path through __index / __newindex).
604///
605/// CONSERVATIVE — the list is restricted to modules whose entries (in
606/// luna's stdlib) are direct Rust builtins. `os` / `io` / `debug` /
607/// `package` / `_G` / `_ENV` are excluded: they expose callbacks (`io.read`
608/// can yield, `debug.sethook` invokes Lua, `os.exit` runs `__close`, etc.)
609/// or are user-mutable.
610///
611/// Future-extensible: a Phase 11 attack may grow this list to include
612/// `select` (top-level builtin), `tostring`, `tonumber`, etc. via a flat
613/// names list. Left small intentionally for v2.1 ship.
614fn is_known_pure_stdlib_root(text: &str) -> bool {
615    matches!(text, "math" | "string" | "table")
616}
617
618/// Returns the [`RhsCallScan`] kind of the call sites reachable from `eid`.
619///
620/// Walks the AST tree rooted at `eid`, stopping at `Expr::Function` (its
621/// body is its own scope and its `Call` ops fire only when the closure is
622/// later invoked, not during RHS evaluation of the current statement).
623///
624/// O(n) in expression tree size — n is bounded by the source-character
625/// count of the statement RHS. No allocation.
626#[doc(hidden)]
627#[allow(dead_code)] // wired by the future A4' attack; pure additive in this batch.
628pub fn walk_rhs_for_calls(chunk: &Chunk, eid: ExprId) -> RhsCallScan {
629    use RhsCallScan::*;
630    match chunk.expr(eid) {
631        // Leaves — no calls.
632        Expr::Nil
633        | Expr::True
634        | Expr::False
635        | Expr::Vararg
636        | Expr::Int(_)
637        | Expr::Float(_)
638        | Expr::Str(_)
639        | Expr::Name(_) => None,
640
641        // Function literals don't *invoke* their body during RHS eval; the
642        // value flowing out is the closure itself. (The closure may capture
643        // upvalues but the capture happens at the `Closure` op, after the
644        // snapshot site, and the captured local is rebound via the upvalue
645        // *only* if the closure is later called — which the gate handles
646        // by inspecting RHS Call ops, not Function literals.)
647        Expr::Function(_) => None,
648
649        Expr::Index { obj, key } => {
650            walk_rhs_for_calls(chunk, *obj).join(walk_rhs_for_calls(chunk, *key))
651        }
652        Expr::Paren(inner) => walk_rhs_for_calls(chunk, *inner),
653        Expr::UnOp { operand, .. } => walk_rhs_for_calls(chunk, *operand),
654        Expr::BinOp { lhs, rhs, .. } => {
655            walk_rhs_for_calls(chunk, *lhs).join(walk_rhs_for_calls(chunk, *rhs))
656        }
657        Expr::Table { fields, .. } => {
658            let mut acc = None;
659            for f in fields {
660                let part = match f {
661                    TableField::Item(e) | TableField::Named(_, e) => walk_rhs_for_calls(chunk, *e),
662                    TableField::Keyed(k, v) => {
663                        walk_rhs_for_calls(chunk, *k).join(walk_rhs_for_calls(chunk, *v))
664                    }
665                };
666                acc = acc.join(part);
667                if acc == UserOrUnknown {
668                    return acc;
669                }
670            }
671            acc
672        }
673
674        Expr::Call { func, args, .. } => {
675            let here = classify_callee(chunk, *func);
676            let mut acc = here;
677            for &a in args {
678                acc = acc.join(walk_rhs_for_calls(chunk, a));
679                if acc == UserOrUnknown {
680                    return acc;
681                }
682            }
683            acc
684        }
685        Expr::MethodCall { obj, args, .. } => {
686            // `obj:method(args)` is morally `obj.method(obj, args)`. Even if
687            // `obj` is a known-pure stdlib root the *method dispatch* itself
688            // may hit a __index path, so MethodCall is unconditionally
689            // UserOrUnknown for the gate. Conservative; can be relaxed
690            // later if obj is a literal stdlib lookup.
691            let mut acc = UserOrUnknown;
692            // Still walk for diagnostics / future relaxation, but the result
693            // can only go up from UserOrUnknown.
694            acc = acc.join(walk_rhs_for_calls(chunk, *obj));
695            for &a in args {
696                acc = acc.join(walk_rhs_for_calls(chunk, a));
697            }
698            acc
699        }
700    }
701}
702
703/// Classifies the callee of a `Call` node in isolation (does NOT recurse
704/// into the args, which is the caller's job).
705fn classify_callee(chunk: &Chunk, callee: ExprId) -> RhsCallScan {
706    match chunk.expr(callee) {
707        // `math.min(...)` shape: callee is Index{ Name(known_root), Str(field) }.
708        Expr::Index { obj, key } => {
709            let root_ok = matches!(
710                chunk.expr(*obj),
711                Expr::Name(n) if is_known_pure_stdlib_root(&n.text)
712            );
713            let key_is_str = matches!(chunk.expr(*key), Expr::Str(_));
714            if root_ok && key_is_str {
715                RhsCallScan::OnlyKnownPure
716            } else {
717                RhsCallScan::UserOrUnknown
718            }
719        }
720        // Bare name callee (`f(...)`) or anything else: unknown.
721        // `_ENV.math.min` and similar dotted globals do NOT match — keep
722        // the gate strict, the consumer can opt in later.
723        _ => RhsCallScan::UserOrUnknown,
724    }
725}
726
727/// Metamethod-safety gate for the Index-LHS snapshot elision attack
728/// described in `.dev/rfcs/v2.0-pi-phase11-a4-prime-rfc.md` §2.
729///
730/// Returns `true` only when, based purely on AST shape:
731///
732/// 1. `obj_eid` is a bare local-name reference (`Expr::Name`). A4'
733///    requires the LHS object to be a stable, non-captured local. The
734///    consumer is still expected to verify `captured == false` against
735///    `LocalVar` at the call site — this gate only handles the AST half.
736/// 2. `rhs_eid`'s call sites are all classified as
737///    [`RhsCallScan::None`] or [`RhsCallScan::OnlyKnownPure`].
738///
739/// Returns `false` whenever the gate cannot prove safety (closed-world
740/// pessimism — unknown = unsafe).
741///
742/// ## Conservative gaps (intentional, deferred)
743///
744/// - Closure-capture modeling is NOT performed. Even an
745///   `OnlyKnownPure` RHS could in principle re-bind via a closure
746///   stored on the metatable, but luna's stdlib does not call back
747///   into Lua, so the gate is sound for the v2.1 ship surface.
748/// - `_ENV.math.min` (dotted global through the env upvalue) is treated
749///   as unsafe even though it ultimately resolves to the same builtin.
750/// - Local `f` aliased to `math.min` (e.g. `local m = math.min; m(x)`)
751///   is treated as unsafe. Variable-tracking is a separate subsystem.
752/// - `obj` that is itself an Index (e.g. `t.a.b = v`) is rejected —
753///   only direct local Index-LHS is in scope for A4' v1.
754#[doc(hidden)]
755#[allow(dead_code)] // wired by the future A4' attack; pure additive in this batch.
756pub fn metamethod_safe_for_index_lhs(chunk: &Chunk, obj_eid: ExprId, rhs_eid: ExprId) -> bool {
757    if !matches!(chunk.expr(obj_eid), Expr::Name(_)) {
758        return false;
759    }
760    matches!(
761        walk_rhs_for_calls(chunk, rhs_eid),
762        RhsCallScan::None | RhsCallScan::OnlyKnownPure
763    )
764}