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}