Skip to main content

sui_bytecode/
compiler.rs

1//! AST-to-bytecode compiler.
2//!
3//! Walks the rnix typed AST and emits a [`Chunk`] of bytecode
4//! instructions. The compiler manages local variable resolution via
5//! a scope stack and emits appropriate `GetLocal`/`SetLocal` instructions.
6
7use std::cell::RefCell;
8use std::rc::Rc;
9
10use rnix::ast::{self, AstToken, HasEntry, InterpolPart};
11use rowan::ast::AstNode;
12
13use crate::chunk::Chunk;
14use crate::error::CompileError;
15use crate::intern::Interner;
16use crate::opcode::OpCode;
17use crate::value::{VMClosure, VMValue};
18
19/// A local variable in the current scope.
20#[derive(Debug, Clone)]
21struct Local {
22    /// The variable name.
23    name: String,
24    /// Scope depth (0 = outermost).
25    depth: u32,
26    /// Whether this local has been captured as an upvalue by a nested function.
27    is_captured: bool,
28    /// The actual stack slot (relative to frame base) where this local lives.
29    /// This may differ from the locals vector index when anonymous values
30    /// are on the stack between locals (e.g., partial application results
31    /// between a function parameter and let-binding locals).
32    slot: u16,
33}
34
35/// An upvalue descriptor: tells a closure how to capture a variable.
36#[derive(Debug, Clone, Copy)]
37struct UpvalueDesc {
38    /// If true, the upvalue captures a local from the immediately enclosing compiler.
39    /// If false, it captures an upvalue from the enclosing compiler's upvalue list.
40    is_local: bool,
41    /// The index: either a local slot (if `is_local`) or an upvalue index.
42    index: u16,
43}
44
45/// A let-binding entry (for the two-pass compilation).
46enum LetBinding {
47    /// A regular `name = expr;` binding.
48    Value(ast::Expr),
49    /// A bare `inherit name;` from the enclosing scope.
50    Inherit,
51    /// An `inherit (source) name;` — copies from source expression.
52    InheritFrom(ast::Expr, String),
53}
54
55/// A rec attrset binding entry.
56enum RecAttrBinding {
57    /// A regular `name = expr;` binding.
58    Value(ast::Expr),
59    /// A bare `inherit name;` from the enclosing scope.
60    Inherit,
61    /// An `inherit (source) name;`.
62    InheritFrom(ast::Expr, String),
63    /// Dotted bindings grouped under this top-level key.
64    Dotted(Vec<(Vec<String>, ast::Expr)>),
65}
66
67/// The bytecode compiler.
68///
69/// Compiles a single expression (which may contain nested lambdas)
70/// into a top-level [`Chunk`]. Nested lambdas produce sub-chunks
71/// stored in the constant pool.
72///
73/// The compiler maintains a shared [`Interner`] that is also passed
74/// to the VM for attribute key resolution.
75pub struct Compiler {
76    /// The chunk being compiled into.
77    chunk: Chunk,
78    /// Local variable stack (simulates the runtime value stack layout).
79    locals: Vec<Local>,
80    /// Upvalue descriptors for this compiler (function scope).
81    upvalues: Vec<UpvalueDesc>,
82    /// Current scope depth.
83    scope_depth: u32,
84    /// Current source line for error reporting.
85    current_line: u32,
86    /// Shared string interner for attribute names and identifiers.
87    interner: Rc<RefCell<Interner>>,
88    /// Reference to the enclosing (parent) compiler, for upvalue resolution.
89    enclosing: Option<*mut Compiler>,
90    /// Whether this compiler has any `with` scopes active (used for variable resolution).
91    with_depth: u32,
92    /// Base directory for resolving relative paths (set when compiling imported files).
93    base_dir: Option<std::path::PathBuf>,
94    /// Tracks the current stack depth relative to frame base.
95    /// Incremented on push/emit operations, decremented on pop.
96    /// Used to assign correct stack slots to local variables when
97    /// anonymous values (partial application results, etc.) sit on the
98    /// stack between named locals.
99    stack_depth: u16,
100    /// Shared source text for lazy thunk compilation.
101    /// When set, thunks can store source spans instead of eagerly compiling.
102    source_text: Option<Rc<String>>,
103    /// Whether the current expression is in tail position (eligible for
104    /// tail-call optimization). Set to `true` in lambda bodies, if-else
105    /// branches, and assert bodies. `compile_apply` checks this to emit
106    /// `TailCall` instead of `Call`.
107    tail_position: bool,
108    /// Stack slots of with-scope values stored as hidden locals.
109    /// When inside `with ns; body`, the namespace is Dup'd and stored as
110    /// a hidden local so thunks compiled inside the body can capture it as
111    /// an upvalue. At thunk force time, the thunk body emits
112    /// `GetUpvalue + PushWith` to restore the with-scope context.
113    with_scope_locals: Vec<u16>,
114}
115
116impl Compiler {
117    /// Create a new compiler with a fresh interner.
118    fn new() -> Self {
119        Self {
120            chunk: Chunk::new(),
121            locals: Vec::new(),
122            upvalues: Vec::new(),
123            scope_depth: 0,
124            current_line: 0,
125            interner: Rc::new(RefCell::new(Interner::new())),
126            enclosing: None,
127            with_depth: 0,
128            base_dir: None,
129            stack_depth: 0,
130            source_text: None,
131            tail_position: false,
132            with_scope_locals: Vec::new(),
133        }
134    }
135
136    /// Create a new compiler sharing an existing interner.
137    fn with_interner(interner: Rc<RefCell<Interner>>) -> Self {
138        Self {
139            chunk: Chunk::new(),
140            locals: Vec::new(),
141            upvalues: Vec::new(),
142            scope_depth: 0,
143            current_line: 0,
144            interner,
145            enclosing: None,
146            with_depth: 0,
147            base_dir: None,
148            stack_depth: 0,
149            source_text: None,
150            tail_position: false,
151            with_scope_locals: Vec::new(),
152        }
153    }
154
155    /// Compile a Nix expression string into bytecode and an interner,
156    /// resolving relative paths against the given base directory.
157    pub fn compile_with_base_dir(
158        input: &str,
159        base_dir: std::path::PathBuf,
160    ) -> Result<(Chunk, Interner), CompileError> {
161        let parse = rnix::Root::parse(input);
162        if !parse.errors().is_empty() {
163            let msgs: Vec<String> = parse.errors().iter().map(|e| e.to_string()).collect();
164            return Err(CompileError::ParseError(msgs.join("; ")));
165        }
166        let root = parse.tree();
167        let expr = root
168            .expr()
169            .ok_or_else(|| CompileError::ParseError("empty expression".to_string()))?;
170        let mut compiler = Self::new();
171        compiler.base_dir = Some(base_dir);
172        compiler.compile_expr(&expr)?;
173        compiler.emit(OpCode::Return);
174        let interner = match Rc::try_unwrap(compiler.interner) {
175            Ok(cell) => cell.into_inner(),
176            Err(rc) => (*rc).borrow().clone(),
177        };
178        Ok((compiler.chunk, interner))
179    }
180
181    /// Compile using a shared interner and base directory.
182    /// Used when importing files from within the VM so that symbol IDs
183    /// are consistent with the VM's interner.
184    pub fn compile_with_shared_interner(
185        input: &str,
186        base_dir: std::path::PathBuf,
187        interner: Rc<RefCell<Interner>>,
188    ) -> Result<Chunk, CompileError> {
189        let parse = rnix::Root::parse(input);
190        if !parse.errors().is_empty() {
191            let msgs: Vec<String> = parse.errors().iter().map(|e| e.to_string()).collect();
192            return Err(CompileError::ParseError(msgs.join("; ")));
193        }
194        let root = parse.tree();
195        let expr = root
196            .expr()
197            .ok_or_else(|| CompileError::ParseError("empty expression".to_string()))?;
198        let mut compiler = Self::with_interner(interner);
199        compiler.base_dir = Some(base_dir);
200        compiler.source_text = Some(Rc::new(input.to_string()));
201        compiler.compile_expr(&expr)?;
202        compiler.emit(OpCode::Return);
203        Ok(compiler.chunk)
204    }
205
206    /// Compile a standalone expression string (used for lazy thunk compilation).
207    /// The expression is parsed and compiled fresh with the given interner and base directory.
208    pub fn compile_expression(
209        input: &str,
210        base_dir: &std::path::Path,
211        interner: Rc<RefCell<Interner>>,
212    ) -> Result<Chunk, CompileError> {
213        let parse = rnix::Root::parse(input);
214        if !parse.errors().is_empty() {
215            let msgs: Vec<String> = parse.errors().iter().map(|e| e.to_string()).collect();
216            return Err(CompileError::ParseError(msgs.join("; ")));
217        }
218        let root = parse.tree();
219        let expr = root
220            .expr()
221            .ok_or_else(|| CompileError::ParseError("empty expression".to_string()))?;
222        let mut compiler = Self::with_interner(interner);
223        compiler.base_dir = Some(base_dir.to_path_buf());
224        compiler.compile_expr(&expr)?;
225        compiler.emit(OpCode::Return);
226        Ok(compiler.chunk)
227    }
228
229    /// Compile a Nix expression string into bytecode and an interner.
230    pub fn compile(input: &str) -> Result<(Chunk, Interner), CompileError> {
231        let parse = rnix::Root::parse(input);
232        if !parse.errors().is_empty() {
233            let msgs: Vec<String> = parse.errors().iter().map(|e| e.to_string()).collect();
234            return Err(CompileError::ParseError(msgs.join("; ")));
235        }
236        let root = parse.tree();
237        let expr = root
238            .expr()
239            .ok_or_else(|| CompileError::ParseError("empty expression".to_string()))?;
240        let mut compiler = Self::new();
241        compiler.compile_expr(&expr)?;
242        compiler.emit(OpCode::Return);
243        let interner = match Rc::try_unwrap(compiler.interner) {
244            Ok(cell) => cell.into_inner(),
245            Err(rc) => (*rc).borrow().clone(),
246        };
247        Ok((compiler.chunk, interner))
248    }
249
250    // ── Constant folding ────────────────────────────────────────
251
252    /// Try to evaluate an expression as a compile-time constant.
253    /// Returns `Some(VMValue)` if the expression can be fully evaluated
254    /// at compile time, `None` otherwise.
255    fn try_eval_const(expr: &ast::Expr) -> Option<VMValue> {
256        match expr {
257            ast::Expr::Literal(lit) => Self::try_eval_literal(lit),
258            ast::Expr::Paren(p) => Self::try_eval_const(&p.expr()?),
259            ast::Expr::UnaryOp(op) => Self::try_fold_unary(op),
260            ast::Expr::BinOp(binop) => Self::try_fold_binop(binop),
261            ast::Expr::IfElse(ie) => Self::try_fold_if(ie),
262            ast::Expr::Ident(id) => {
263                let name = ident_text(id);
264                match name.as_str() {
265                    "true" => Some(VMValue::Bool(true)),
266                    "false" => Some(VMValue::Bool(false)),
267                    "null" => Some(VMValue::Null),
268                    _ => None,
269                }
270            }
271            _ => None,
272        }
273    }
274
275    /// Try to evaluate a literal as a constant.
276    fn try_eval_literal(lit: &ast::Literal) -> Option<VMValue> {
277        match lit.kind() {
278            ast::LiteralKind::Integer(tok) => {
279                Some(VMValue::Int(tok.value().ok()?))
280            }
281            ast::LiteralKind::Float(tok) => {
282                Some(VMValue::Float(tok.value().ok()?))
283            }
284            ast::LiteralKind::Uri(_) => None,
285        }
286    }
287
288    /// Try to fold a unary operation on constants.
289    fn try_fold_unary(op: &ast::UnaryOp) -> Option<VMValue> {
290        let inner = Self::try_eval_const(&op.expr()?)?;
291        let kind = op.operator()?;
292        match kind {
293            ast::UnaryOpKind::Negate => match inner {
294                VMValue::Int(n) => Some(VMValue::Int(-n)),
295                VMValue::Float(f) => Some(VMValue::Float(-f)),
296                _ => None,
297            },
298            ast::UnaryOpKind::Invert => match inner {
299                VMValue::Bool(b) => Some(VMValue::Bool(!b)),
300                _ => None,
301            },
302        }
303    }
304
305    /// Try to fold a binary operation where both sides are constants.
306    fn try_fold_binop(binop: &ast::BinOp) -> Option<VMValue> {
307        let lhs = Self::try_eval_const(&binop.lhs()?)?;
308        let rhs = Self::try_eval_const(&binop.rhs()?)?;
309        let op = binop.operator()?;
310
311        match op {
312            ast::BinOpKind::Add => match (&lhs, &rhs) {
313                (VMValue::Int(a), VMValue::Int(b)) => Some(VMValue::Int(a + b)),
314                (VMValue::Float(a), VMValue::Float(b)) => Some(VMValue::Float(a + b)),
315                (VMValue::Int(a), VMValue::Float(b)) => Some(VMValue::Float(*a as f64 + b)),
316                (VMValue::Float(a), VMValue::Int(b)) => Some(VMValue::Float(a + *b as f64)),
317                (VMValue::String(a), VMValue::String(b)) => {
318                    Some(VMValue::String(format!("{a}{b}")))
319                }
320                _ => None,
321            },
322            ast::BinOpKind::Sub => match (&lhs, &rhs) {
323                (VMValue::Int(a), VMValue::Int(b)) => Some(VMValue::Int(a - b)),
324                (VMValue::Float(a), VMValue::Float(b)) => Some(VMValue::Float(a - b)),
325                (VMValue::Int(a), VMValue::Float(b)) => Some(VMValue::Float(*a as f64 - b)),
326                (VMValue::Float(a), VMValue::Int(b)) => Some(VMValue::Float(a - *b as f64)),
327                _ => None,
328            },
329            ast::BinOpKind::Mul => match (&lhs, &rhs) {
330                (VMValue::Int(a), VMValue::Int(b)) => Some(VMValue::Int(a * b)),
331                (VMValue::Float(a), VMValue::Float(b)) => Some(VMValue::Float(a * b)),
332                (VMValue::Int(a), VMValue::Float(b)) => Some(VMValue::Float(*a as f64 * b)),
333                (VMValue::Float(a), VMValue::Int(b)) => Some(VMValue::Float(a * *b as f64)),
334                _ => None,
335            },
336            ast::BinOpKind::Div => match (&lhs, &rhs) {
337                (VMValue::Int(_), VMValue::Int(0)) => None, // don't fold div by zero
338                (VMValue::Int(a), VMValue::Int(b)) => Some(VMValue::Int(a / b)),
339                (VMValue::Float(a), VMValue::Float(b)) => Some(VMValue::Float(a / b)),
340                (VMValue::Int(a), VMValue::Float(b)) => Some(VMValue::Float(*a as f64 / b)),
341                (VMValue::Float(a), VMValue::Int(b)) => Some(VMValue::Float(a / *b as f64)),
342                _ => None,
343            },
344            ast::BinOpKind::Equal => Some(VMValue::Bool(Self::const_eq(&lhs, &rhs))),
345            ast::BinOpKind::NotEqual => Some(VMValue::Bool(!Self::const_eq(&lhs, &rhs))),
346            ast::BinOpKind::Less => Self::const_cmp(&lhs, &rhs)
347                .map(|o| VMValue::Bool(o == std::cmp::Ordering::Less)),
348            ast::BinOpKind::LessOrEq => Self::const_cmp(&lhs, &rhs)
349                .map(|o| VMValue::Bool(o != std::cmp::Ordering::Greater)),
350            ast::BinOpKind::More => Self::const_cmp(&lhs, &rhs)
351                .map(|o| VMValue::Bool(o == std::cmp::Ordering::Greater)),
352            ast::BinOpKind::MoreOrEq => Self::const_cmp(&lhs, &rhs)
353                .map(|o| VMValue::Bool(o != std::cmp::Ordering::Less)),
354            ast::BinOpKind::And => match (&lhs, &rhs) {
355                (VMValue::Bool(a), VMValue::Bool(b)) => Some(VMValue::Bool(*a && *b)),
356                _ => None,
357            },
358            ast::BinOpKind::Or => match (&lhs, &rhs) {
359                (VMValue::Bool(a), VMValue::Bool(b)) => Some(VMValue::Bool(*a || *b)),
360                _ => None,
361            },
362            ast::BinOpKind::Implication => match (&lhs, &rhs) {
363                (VMValue::Bool(a), VMValue::Bool(b)) => Some(VMValue::Bool(!a || *b)),
364                _ => None,
365            },
366            _ => None,
367        }
368    }
369
370    /// Try to fold `if cond then a else b` when the condition is constant.
371    fn try_fold_if(ie: &ast::IfElse) -> Option<VMValue> {
372        let cond = Self::try_eval_const(&ie.condition()?)?;
373        match cond {
374            VMValue::Bool(true) => Self::try_eval_const(&ie.body()?),
375            VMValue::Bool(false) => Self::try_eval_const(&ie.else_body()?),
376            _ => None,
377        }
378    }
379
380    /// Compile-time equality check.
381    fn const_eq(a: &VMValue, b: &VMValue) -> bool {
382        match (a, b) {
383            (VMValue::Null, VMValue::Null) => true,
384            (VMValue::Bool(a), VMValue::Bool(b)) => a == b,
385            (VMValue::Int(a), VMValue::Int(b)) => a == b,
386            (VMValue::Float(a), VMValue::Float(b)) => a == b,
387            (VMValue::Int(a), VMValue::Float(b)) | (VMValue::Float(b), VMValue::Int(a)) => {
388                (*a as f64) == *b
389            }
390            (VMValue::String(a), VMValue::String(b)) => a == b,
391            _ => false,
392        }
393    }
394
395    /// Compile-time comparison.
396    fn const_cmp(a: &VMValue, b: &VMValue) -> Option<std::cmp::Ordering> {
397        match (a, b) {
398            (VMValue::Int(a), VMValue::Int(b)) => Some(a.cmp(b)),
399            (VMValue::Float(a), VMValue::Float(b)) => a.partial_cmp(b),
400            (VMValue::Int(a), VMValue::Float(b)) => (*a as f64).partial_cmp(b),
401            (VMValue::Float(a), VMValue::Int(b)) => a.partial_cmp(&(*b as f64)),
402            (VMValue::String(a), VMValue::String(b)) => Some(a.cmp(b)),
403            _ => None,
404        }
405    }
406
407    // ── Expression dispatch ────────────────────────────────────
408
409    fn compile_expr(&mut self, expr: &ast::Expr) -> Result<(), CompileError> {
410        self.current_line = line_of(expr);
411
412        // Try constant folding first — if the expression can be fully
413        // evaluated at compile time, emit a single Constant instruction.
414        if let Some(folded) = Self::try_eval_const(expr) {
415            return self.emit_constant(folded);
416        }
417
418        // Save and clear tail_position. Specific branches that propagate
419        // tail position (IfElse, Assert, Paren, Root, Apply) will restore
420        // it themselves. All other branches compile subexpressions with
421        // tail_position = false, which is the correct default.
422        let tail = self.tail_position;
423        self.tail_position = false;
424
425        match expr {
426            ast::Expr::Literal(lit) => self.compile_literal(lit),
427            ast::Expr::Str(s) => self.compile_str(s),
428            ast::Expr::Ident(id) => self.compile_ident(id),
429            ast::Expr::LetIn(letin) => self.compile_let(letin),
430            ast::Expr::AttrSet(set) => self.compile_attrset(set),
431            ast::Expr::Select(sel) => self.compile_select(sel),
432            ast::Expr::HasAttr(ha) => self.compile_has_attr(ha),
433            ast::Expr::IfElse(ie) => {
434                self.tail_position = tail;
435                self.compile_if(ie)
436            }
437            ast::Expr::Lambda(lam) => self.compile_lambda(lam),
438            ast::Expr::Apply(app) => {
439                self.tail_position = tail;
440                self.compile_apply(app)
441            }
442            ast::Expr::BinOp(op) => self.compile_binop(op),
443            ast::Expr::UnaryOp(op) => self.compile_unary(op),
444            ast::Expr::With(w) => self.compile_with(w),
445            ast::Expr::Assert(a) => {
446                self.tail_position = tail;
447                self.compile_assert(a)
448            }
449            ast::Expr::List(l) => self.compile_list(l),
450            ast::Expr::Paren(p) => {
451                self.tail_position = tail;
452                let inner = p
453                    .expr()
454                    .ok_or_else(|| CompileError::MissingNode("paren expr".to_string()))?;
455                self.compile_expr(&inner)
456            }
457            ast::Expr::Root(r) => {
458                self.tail_position = tail;
459                let inner = r
460                    .expr()
461                    .ok_or_else(|| CompileError::MissingNode("root expr".to_string()))?;
462                self.compile_expr(&inner)
463            }
464            ast::Expr::PathAbs(p) => {
465                let text = p.syntax().text().to_string();
466                self.emit_constant(VMValue::Path(text))
467            }
468            ast::Expr::PathRel(p) => {
469                let text = p.syntax().text().to_string();
470                // Resolve relative paths against base_dir when available,
471                // or propagate from enclosing compiler.
472                let resolved = self.resolve_relative_path(&text);
473                self.emit_constant(VMValue::Path(resolved))
474            }
475            ast::Expr::PathHome(p) => {
476                let text = p.syntax().text().to_string();
477                self.emit_constant(VMValue::Path(text))
478            }
479            ast::Expr::PathSearch(p) => {
480                let text = p.syntax().text().to_string();
481                let inner = text
482                    .strip_prefix('<')
483                    .and_then(|s| s.strip_suffix('>'))
484                    .unwrap_or(&text);
485                if let Some(resolved) = resolve_search_path(inner) {
486                    self.emit_constant(VMValue::Path(resolved))
487                } else {
488                    // Wrap the throw in a THUNK so it only fires when forced.
489                    // This matches CppNix: unresolvable search paths are deferred
490                    // and caught by tryEval at force-time, not at eval-time.
491                    let msg = format!("search path '{text}' not in NIX_PATH");
492                    let mut tc = Compiler::with_interner(Rc::clone(&self.interner));
493                    tc.scope_depth = 1;
494                    tc.base_dir = self.base_dir.clone();
495                    tc.emit_constant(VMValue::String(msg))?;
496                    tc.emit(OpCode::Throw);
497                    tc.emit(OpCode::Return);
498                    let closure = VMValue::Closure(VMClosure {
499                        chunk: Rc::new(tc.chunk),
500                        upvalues: Vec::new(),
501                        arity: 0,
502                        name: None,
503                        formals: Vec::new(),
504                    });
505                    let idx = self.chunk.add_constant(closure)?;
506                    self.emit(OpCode::MakeThunk);
507                    self.stack_depth += 1;
508                    self.emit_u16(idx);
509                    self.emit_u16(0); // 0 upvalues
510                    Ok(())
511                }
512            }
513            ast::Expr::LegacyLet(ll) => {
514                // Legacy let is like: let { x = 1; body = x; }
515                // which is equivalent to: rec { x = 1; body = x; }.body
516                // Compile as a recursive attrset, then select "body"
517                self.compile_legacy_let(&ll)
518            }
519            ast::Expr::CurPos(_) => {
520                // __curPos is a debug feature; emit null to avoid CompileError.
521                self.emit_constant(VMValue::Null)
522            }
523            other => Err(CompileError::Unsupported(format!("{other:?}"))),
524        }
525    }
526
527    // ── Literals ───────────────────────────────────────────────
528
529    fn compile_literal(&mut self, lit: &ast::Literal) -> Result<(), CompileError> {
530        match lit.kind() {
531            ast::LiteralKind::Integer(tok) => {
532                let n = tok.value().map_err(|e| {
533                    CompileError::ParseError(format!("invalid integer: {e}"))
534                })?;
535                self.emit_constant(VMValue::Int(n))
536            }
537            ast::LiteralKind::Float(tok) => {
538                let f = tok.value().map_err(|e| {
539                    CompileError::ParseError(format!("invalid float: {e}"))
540                })?;
541                self.emit_constant(VMValue::Float(f))
542            }
543            ast::LiteralKind::Uri(tok) => {
544                let s = tok.syntax().text().to_string();
545                self.emit_constant(VMValue::String(s))
546            }
547        }
548    }
549
550    // ── Strings ────────────────────────────────────────────────
551
552    fn compile_str(&mut self, s: &ast::Str) -> Result<(), CompileError> {
553        let parts: Vec<_> = s.normalized_parts().into_iter().collect();
554
555        // Optimize: single literal part (no interpolation) becomes a constant.
556        if parts.len() == 1 {
557            if let InterpolPart::Literal(text) = &parts[0] {
558                return self.emit_constant(VMValue::String(String::from(text.as_str())));
559            }
560        }
561
562        // General case: compile each part, then Interpolate.
563        let mut count: u16 = 0;
564        for part in &parts {
565            match part {
566                InterpolPart::Literal(text) => {
567                    self.emit_constant(VMValue::String(text.to_string()))?;
568                    count += 1;
569                }
570                InterpolPart::Interpolation(interp) => {
571                    let expr = interp
572                        .expr()
573                        .ok_or_else(|| CompileError::MissingNode("interpolation expr".to_string()))?;
574                    self.compile_expr(&expr)?;
575                    count += 1;
576                }
577            }
578        }
579
580        if count == 0 {
581            // Empty string.
582            self.emit_constant(VMValue::String(String::new()))
583        } else if count == 1 {
584            // Already on stack from the single part above.
585            Ok(())
586        } else {
587            self.emit(OpCode::Interpolate);
588            self.emit_u16(count);
589            // Interpolate pops count parts, pushes 1 string.
590            self.stack_depth = self.stack_depth.saturating_sub(count) + 1;
591            Ok(())
592        }
593    }
594
595    // ── Identifiers (variable lookup) ──────────────────────────
596
597    fn compile_ident(&mut self, ident: &ast::Ident) -> Result<(), CompileError> {
598        let name = ident_text(ident);
599        match name.as_str() {
600            "true" => {
601                self.emit(OpCode::True);
602                Ok(())
603            }
604            "false" => {
605                self.emit(OpCode::False);
606                Ok(())
607            }
608            "null" => {
609                self.emit(OpCode::Null);
610                Ok(())
611            }
612            _ => {
613                // 1. Look up in locals.
614                if let Some(idx) = self.resolve_local(&name) {
615                    self.emit(OpCode::GetLocal);
616                    self.emit_u16(self.local_stack_slot(idx));
617                    return Ok(());
618                }
619                // 2. Look up in upvalues (captures from enclosing scopes).
620                if let Some(idx) = self.resolve_upvalue(&name) {
621                    self.emit(OpCode::GetUpvalue);
622                    self.emit_u16(idx as u16);
623                    return Ok(());
624                }
625                // 3. `builtins` is a global — push the builtins attrset.
626                if name == "builtins" {
627                    self.emit(OpCode::PushBuiltins);
628                    return Ok(());
629                }
630                // 4. Global builtins available without `builtins.` prefix.
631                //    In Nix, these are automatically in scope.
632                if is_global_builtin(&name) {
633                    self.emit(OpCode::PushBuiltins);
634                    let key_idx = self.add_attr_key(name)?;
635                    self.emit(OpCode::GetAttr);
636                    self.emit_u16(key_idx);
637                    return Ok(());
638                }
639                // 5. Look up in with-scope (dynamic scope).
640                if self.has_with_scope() {
641                    let name_idx = self.chunk.add_constant(VMValue::String(name))?;
642                    self.emit(OpCode::LookupWith);
643                    self.emit_u16(name_idx);
644                    return Ok(());
645                }
646                Err(CompileError::Unsupported(format!(
647                    "unresolved variable: {name}"
648                )))
649            }
650        }
651    }
652
653    // ── Let/in ─────────────────────────────────────────────────
654
655    fn compile_let(&mut self, letin: &ast::LetIn) -> Result<(), CompileError> {
656        self.begin_scope();
657
658        // Collect all binding names and value expressions first so we
659        // can allocate all local slots before compiling any values
660        // (enabling mutual references between let-bindings).
661        let mut bindings: Vec<(String, LetBinding)> = Vec::new();
662
663        for entry in letin.entries() {
664            match entry {
665                ast::Entry::AttrpathValue(ref apv) => {
666                    let attrpath = apv.attrpath().ok_or_else(|| {
667                        CompileError::MissingNode("binding attrpath".to_string())
668                    })?;
669                    let keys: Vec<_> = attrpath.attrs().collect();
670                    if keys.len() != 1 {
671                        return Err(CompileError::Unsupported(
672                            "dotted let bindings".to_string(),
673                        ));
674                    }
675                    let key = static_attr_name(&keys[0])?;
676                    let value_expr = apv.value().ok_or_else(|| {
677                        CompileError::MissingNode("binding value".to_string())
678                    })?;
679                    bindings.push((key, LetBinding::Value(value_expr)));
680                }
681                ast::Entry::Inherit(ref inherit) => {
682                    if let Some(from) = inherit.from() {
683                        let source_expr = from.expr().ok_or_else(|| {
684                            CompileError::MissingNode("inherit from expr".to_string())
685                        })?;
686                        for attr in inherit.attrs() {
687                            let name = static_attr_name(&attr)?;
688                            bindings.push((name.clone(), LetBinding::InheritFrom(source_expr.clone(), name)));
689                        }
690                    } else {
691                        for attr in inherit.attrs() {
692                            let name = static_attr_name(&attr)?;
693                            bindings.push((name, LetBinding::Inherit));
694                        }
695                    }
696                }
697            }
698        }
699
700        // Static cycle detection: check for `name = name;` patterns.
701        {
702            let pairs: Vec<(String, &ast::Expr)> = bindings
703                .iter()
704                .filter_map(|(name, binding)| match binding {
705                    LetBinding::Value(expr) => Some((name.clone(), expr as &ast::Expr)),
706                    _ => None,
707                })
708                .collect();
709            for warning in detect_trivial_cycles(&pairs) {
710                eprintln!("{warning}");
711            }
712        }
713
714        let binding_count = u16::try_from(bindings.len())
715            .map_err(|_| CompileError::TooManyLocals)?;
716
717        // Phase 1: Push Null placeholders and register local slots.
718        for (name, _) in &bindings {
719            self.emit(OpCode::Null); // emit() tracks stack_depth
720            self.add_local(name.clone())?;
721        }
722
723        // Phase 2: Compile each binding's value and store into its slot.
724        // Two-pass thunk approach for lazy let-bindings:
725        //   Pass A: Create thunks (0 upvalues), store in slots.
726        //   Pass B: Patch each thunk's upvalues (siblings now exist).
727        let mut thunk_slots: Vec<(u16, Vec<UpvalueDesc>)> = Vec::new();
728
729        for (name, binding) in &bindings {
730            let local_idx = self.resolve_local(name).unwrap();
731            let slot = self.locals[local_idx as usize].slot;
732            match binding {
733                LetBinding::Value(expr) => {
734                    // In let bindings (which are recursive in Nix), lambdas
735                    // must not be inlined as trivial — same issue as rec
736                    // attrsets: MakeClosure captures upvalues eagerly, but
737                    // sibling bindings (especially dotted) may not yet exist.
738                    if Self::is_trivial_value_for_rec(expr) {
739                        self.compile_expr(expr)?;
740                    } else {
741                        let uv_descs = self.compile_thunk_deferred(expr)?;
742                        if !uv_descs.is_empty() {
743                            thunk_slots.push((slot, uv_descs));
744                        }
745                    }
746                    self.emit(OpCode::SetLocal);
747                    self.emit_u16(slot);
748                    self.emit(OpCode::Pop);
749                }
750                LetBinding::Inherit => {
751                    // Temporarily hide this local so lookup finds the outer one.
752                    let saved_depth = self.locals[local_idx as usize].depth;
753                    self.locals[local_idx as usize].depth = u32::MAX;
754                    if let Some(outer_idx) = self.resolve_local(name) {
755                        self.emit(OpCode::GetLocal);
756                        self.emit_u16(self.local_stack_slot(outer_idx));
757                    } else if let Some(uv_idx) = self.resolve_upvalue(name) {
758                        self.emit(OpCode::GetUpvalue);
759                        self.emit_u16(uv_idx as u16);
760                    } else if self.has_with_scope() {
761                        let name_idx = self.chunk.add_constant(VMValue::String(name.clone()))?;
762                        self.emit(OpCode::LookupWith);
763                        self.emit_u16(name_idx);
764                    } else {
765                        self.locals[local_idx as usize].depth = saved_depth;
766                        return Err(CompileError::Unsupported(format!(
767                            "inherit: cannot resolve '{name}' in enclosing scope"
768                        )));
769                    }
770                    self.locals[local_idx as usize].depth = saved_depth;
771                    self.emit(OpCode::SetLocal);
772                    self.emit_u16(slot);
773                    self.emit(OpCode::Pop);
774                }
775                LetBinding::InheritFrom(source_expr, attr_name) => {
776                    // Wrap inherit-from in a thunk to avoid forcing the
777                    // source expression at let-binding time (critical for
778                    // fixpoint patterns like nixpkgs lib's inherit (lib.trivial)).
779                    let uv_descs = self.compile_inherit_from_thunk_deferred(source_expr, attr_name)?;
780                    if !uv_descs.is_empty() {
781                        thunk_slots.push((slot, uv_descs));
782                    }
783                    self.emit(OpCode::SetLocal);
784                    self.emit_u16(slot);
785                    self.emit(OpCode::Pop);
786                }
787            }
788        }
789
790        // Pass B: Patch thunk upvalues now that all siblings exist in slots.
791        for (slot, uv_descs) in &thunk_slots {
792            self.emit(OpCode::PatchThunkUpvalues);
793            self.emit_u16(*slot);
794            self.emit_u16(uv_descs.len() as u16);
795            for uv in uv_descs {
796                self.chunk.write_byte(if uv.is_local { 1 } else { 0 }, self.current_line);
797                self.emit_u16(uv.index);
798            }
799        }
800
801        // Compile the body expression. Its result lands on top of the
802        // local variable slots on the stack.
803        let body = letin
804            .body()
805            .ok_or_else(|| CompileError::MissingNode("let body".to_string()))?;
806        self.compile_expr(&body)?;
807
808        // Clean up: move the body result down past the locals, then pop them.
809        self.end_scope(binding_count);
810
811        Ok(())
812    }
813
814    /// Check if an expression is trivial (compile eagerly, no thunk needed).
815    fn is_trivial_value(expr: &ast::Expr) -> bool {
816        match expr {
817            ast::Expr::Literal(_) => true,
818            ast::Expr::Str(s) => {
819                for part in s.normalized_parts() {
820                    if !matches!(part, InterpolPart::Literal(_)) {
821                        return false;
822                    }
823                }
824                true
825            }
826            ast::Expr::Ident(id) => {
827                let name = ident_text(id);
828                matches!(name.as_str(), "true" | "false" | "null")
829            }
830            ast::Expr::Lambda(_) => true,
831            ast::Expr::Paren(p) => p.expr().map_or(false, |inner| Self::is_trivial_value(&inner)),
832            ast::Expr::List(list) => list.items().next().is_none(),
833            ast::Expr::AttrSet(set) => set.rec_token().is_none() && set.entries().next().is_none(),
834            _ => false,
835        }
836    }
837
838    /// Like `is_trivial_value`, but for use in rec attrsets.
839    /// Lambdas are NOT trivial in rec context because `MakeClosure` captures
840    /// upvalues at emission time.  If a lambda captures a sibling binding
841    /// (especially a dotted entry appended after non-dotted bindings), the
842    /// sibling's slot may still hold the null placeholder, producing a silent
843    /// wrong result.  Wrapping the lambda in a deferred thunk postpones
844    /// `MakeClosure` until the value is accessed, by which time all siblings
845    /// have been populated via `PatchThunkUpvalues`.
846    fn is_trivial_value_for_rec(expr: &ast::Expr) -> bool {
847        match expr {
848            // Lambdas can capture rec-scoped variables — never inline in rec.
849            ast::Expr::Lambda(_) => false,
850            ast::Expr::Paren(p) => p.expr().map_or(false, |inner| Self::is_trivial_value_for_rec(&inner)),
851            _ => Self::is_trivial_value(expr),
852        }
853    }
854
855    /// Compile a thunk with 0 upvalues (deferred patching via PatchThunkUpvalues).
856    fn compile_thunk_deferred(&mut self, expr: &ast::Expr) -> Result<Vec<UpvalueDesc>, CompileError> {
857        let mut tc = Compiler::with_interner(Rc::clone(&self.interner));
858        tc.scope_depth = 1;
859        tc.enclosing = Some(self as *mut Compiler);
860        tc.with_depth = 0;
861        tc.base_dir = self.base_dir.clone();
862        let with_count = self.emit_with_scope_preamble(&mut tc);
863        tc.compile_expr(expr)?;
864        for _ in 0..with_count { tc.emit(OpCode::PopWith); }
865        tc.emit(OpCode::Return);
866        let uv_descs: Vec<UpvalueDesc> = tc.upvalues.clone();
867        let closure = VMValue::Closure(VMClosure {
868            chunk: Rc::new(tc.chunk), upvalues: Vec::new(), arity: 0, name: None, formals: Vec::new(),
869        });
870        let idx = self.chunk.add_constant(closure)?;
871        self.emit(OpCode::MakeThunk);
872        self.stack_depth += 1; // MakeThunk pushes one thunk
873        self.emit_u16(idx);
874        self.emit_u16(0); // 0 upvalues, patched later
875        Ok(uv_descs)
876    }
877
878    /// Compile a function argument with call-by-need semantics.
879    fn compile_arg_maybe_thunk(&mut self, arg: &ast::Expr) -> Result<(), CompileError> {
880        if Self::is_trivial_arg(arg) {
881            self.compile_expr(arg)
882        } else {
883            self.compile_thunk_immediate(arg)
884        }
885    }
886
887    fn is_trivial_arg(expr: &ast::Expr) -> bool {
888        match expr {
889            ast::Expr::Literal(_) | ast::Expr::Ident(_)
890            | ast::Expr::PathAbs(_) | ast::Expr::PathRel(_)
891            | ast::Expr::PathHome(_) | ast::Expr::Lambda(_) => true,
892            // Paren: check inner expression
893            ast::Expr::Paren(p) => p.expr().map_or(false, |inner| Self::is_trivial_arg(&inner)),
894            // Str without interpolation is trivial
895            ast::Expr::Str(s) => s.normalized_parts().iter().all(|p| matches!(p, InterpolPart::Literal(_))),
896            _ => false,
897        }
898    }
899
900    /// Compile a deferred thunk for `inherit (source) name;` in let bindings.
901    /// Like `compile_thunk_deferred`, but emits source + GetAttr(name) + Return.
902    fn compile_inherit_from_thunk_deferred(
903        &mut self,
904        source_expr: &ast::Expr,
905        attr_name: &str,
906    ) -> Result<Vec<UpvalueDesc>, CompileError> {
907        let mut tc = Compiler::with_interner(Rc::clone(&self.interner));
908        tc.scope_depth = 1;
909        tc.enclosing = Some(self as *mut Compiler);
910        tc.with_depth = 0;
911        tc.base_dir = self.base_dir.clone();
912        let with_count = self.emit_with_scope_preamble(&mut tc);
913        tc.compile_expr(source_expr)?;
914        let key_idx = tc.add_attr_key(attr_name.to_string())?;
915        tc.emit(OpCode::GetAttr);
916        tc.emit_u16(key_idx);
917        for _ in 0..with_count { tc.emit(OpCode::PopWith); }
918        tc.emit(OpCode::Return);
919        let uv_descs: Vec<UpvalueDesc> = tc.upvalues.clone();
920        let closure = VMValue::Closure(VMClosure {
921            chunk: Rc::new(tc.chunk),
922            upvalues: Vec::new(),
923            arity: 0, formals: Vec::new(),
924            name: None,
925        });
926        let idx = self.chunk.add_constant(closure)?;
927        self.emit(OpCode::MakeThunk);
928        self.stack_depth += 1; // MakeThunk pushes one thunk
929        self.emit_u16(idx);
930        self.emit_u16(0); // 0 upvalues, patched later
931        Ok(uv_descs)
932    }
933
934    /// Compile a deferred thunk for a dotted binding in rec attrsets.
935    /// Like `compile_thunk_deferred`, but the thunk body is a nested attrset
936    /// rather than a single expression.  Leaf values inside the nested attrset
937    /// are individually wrapped in immediate thunks so that forcing the outer
938    /// thunk doesn't eagerly evaluate all leaves (avoiding infinite recursion
939    /// when dotted bindings cross-reference each other through rec siblings).
940    fn compile_nested_attrset_thunk_deferred(
941        &mut self,
942        sub_bindings: &[(Vec<String>, ast::Expr)],
943    ) -> Result<Vec<UpvalueDesc>, CompileError> {
944        let mut tc = Compiler::with_interner(Rc::clone(&self.interner));
945        tc.scope_depth = 1;
946        tc.enclosing = Some(self as *mut Compiler);
947        tc.with_depth = 0;
948        tc.base_dir = self.base_dir.clone();
949        let with_count = self.emit_with_scope_preamble(&mut tc);
950        tc.compile_nested_attrset_lazy(sub_bindings)?;
951        for _ in 0..with_count { tc.emit(OpCode::PopWith); }
952        tc.emit(OpCode::Return);
953        let uv_descs: Vec<UpvalueDesc> = tc.upvalues.clone();
954        let closure = VMValue::Closure(VMClosure {
955            chunk: Rc::new(tc.chunk), upvalues: Vec::new(), arity: 0, name: None, formals: Vec::new(),
956        });
957        let idx = self.chunk.add_constant(closure)?;
958        self.emit(OpCode::MakeThunk);
959        self.stack_depth += 1; // MakeThunk pushes one thunk
960        self.emit_u16(idx);
961        self.emit_u16(0); // 0 upvalues, patched later
962        Ok(uv_descs)
963    }
964
965    /// Emit with-scope preamble in a child compiler: for each with-scope
966    /// local in the parent, capture it as an upvalue and emit
967    /// `GetUpvalue + PushWith` at the start of the thunk body.
968    /// Returns the count of with-scopes pushed (caller must emit PopWith for each).
969    fn emit_with_scope_preamble(&mut self, tc: &mut Compiler) -> usize {
970        let slots: Vec<u16> = self.with_scope_locals.clone();
971        for &slot in &slots {
972            // Find the local index for this slot in the parent.
973            let local_idx = self.locals.iter().rposition(|l| l.slot == slot);
974            if let Some(idx) = local_idx {
975                self.locals[idx].is_captured = true;
976                if let Ok(uv_idx) = tc.add_upvalue(true, slot) {
977                    tc.emit(OpCode::GetUpvalue);
978                    tc.emit_u16(uv_idx as u16);
979                    tc.emit(OpCode::PushWith);
980                    tc.with_depth += 1;
981                }
982            }
983        }
984        slots.len()
985    }
986
987    /// Compile a thunk with upvalues captured immediately (for non-rec attrsets).
988    ///
989    /// When the compiler has source text available and the expression has no
990    /// free variables (no locals, no upvalues, no with-scopes), emit a
991    /// `MakeLazyThunk` that defers compilation until the thunk is forced.
992    /// Otherwise, fall through to the eager compilation path.
993    fn compile_thunk_immediate(&mut self, expr: &ast::Expr) -> Result<(), CompileError> {
994        // Try lazy thunk: only when source text is available and there are
995        // no variables in scope that the expression could reference.
996        if let Some(ref source) = self.source_text {
997            if self.locals.is_empty() && self.with_depth == 0 && self.upvalues.is_empty() {
998                let range = AstNode::syntax(expr).text_range();
999                let offset: usize = range.start().into();
1000                let length: usize = range.len().into();
1001                let base_dir_str = self.base_dir
1002                    .as_ref()
1003                    .map(|p| p.to_string_lossy().to_string())
1004                    .unwrap_or_default();
1005
1006                // Store source text and base_dir in the constant pool.
1007                let src_idx = self.chunk.add_constant(VMValue::String((**source).clone()))?;
1008                let dir_idx = self.chunk.add_constant(VMValue::String(base_dir_str))?;
1009
1010                self.emit(OpCode::MakeLazyThunk);
1011                self.stack_depth += 1;
1012                self.emit_u16(src_idx);
1013                self.chunk.write_u32(offset as u32, self.current_line);
1014                self.chunk.write_u32(length as u32, self.current_line);
1015                self.emit_u16(dir_idx);
1016                self.emit_u16(0); // 0 upvalues
1017                return Ok(());
1018            }
1019        }
1020
1021        // Eager path: compile the thunk body now.
1022        let mut tc = Compiler::with_interner(Rc::clone(&self.interner));
1023        tc.scope_depth = 1;
1024        tc.enclosing = Some(self as *mut Compiler);
1025        tc.with_depth = 0; // Reset: thunk body restores with-scopes via upvalues
1026        tc.base_dir = self.base_dir.clone();
1027
1028        // Capture with-scope locals from parent as upvalues in thunk body.
1029        // Emit PushWith at thunk body start to restore with-scope context.
1030        let with_count = self.emit_with_scope_preamble(&mut tc);
1031
1032        tc.compile_expr(expr)?;
1033
1034        // Pop with-scopes in reverse.
1035        for _ in 0..with_count {
1036            tc.emit(OpCode::PopWith);
1037        }
1038
1039        tc.emit(OpCode::Return);
1040        let uv_descs: Vec<UpvalueDesc> = tc.upvalues.clone();
1041        let closure = VMValue::Closure(VMClosure {
1042            chunk: Rc::new(tc.chunk), upvalues: Vec::new(), arity: 0, name: None, formals: Vec::new(),
1043        });
1044        let idx = self.chunk.add_constant(closure)?;
1045        self.emit(OpCode::MakeThunk);
1046        self.stack_depth += 1; // MakeThunk pushes one thunk
1047        self.emit_u16(idx);
1048        self.emit_u16(uv_descs.len() as u16);
1049        for uv in &uv_descs {
1050            self.chunk.write_byte(if uv.is_local { 1 } else { 0 }, self.current_line);
1051            self.emit_u16(uv.index);
1052        }
1053        Ok(())
1054    }
1055
1056    /// Compile `inherit (source) name;` as a lazy thunk.
1057    /// The thunk evaluates `source` and then does `GetAttr(name)` when forced.
1058    fn compile_inherit_from_thunk(
1059        &mut self,
1060        source_expr: &ast::Expr,
1061        attr_name: &str,
1062    ) -> Result<(), CompileError> {
1063        let mut tc = Compiler::with_interner(Rc::clone(&self.interner));
1064        tc.scope_depth = 1;
1065        tc.enclosing = Some(self as *mut Compiler);
1066        tc.with_depth = 0;
1067        tc.base_dir = self.base_dir.clone();
1068        let with_count = self.emit_with_scope_preamble(&mut tc);
1069        tc.compile_expr(source_expr)?;
1070        let key_idx = tc.add_attr_key(attr_name.to_string())?;
1071        tc.emit(OpCode::GetAttr);
1072        tc.emit_u16(key_idx);
1073        for _ in 0..with_count { tc.emit(OpCode::PopWith); }
1074        tc.emit(OpCode::Return);
1075        let uv_descs: Vec<UpvalueDesc> = tc.upvalues.clone();
1076        let closure = VMValue::Closure(VMClosure {
1077            chunk: Rc::new(tc.chunk),
1078            upvalues: Vec::new(),
1079            arity: 0, formals: Vec::new(),
1080            name: None,
1081        });
1082        let idx = self.chunk.add_constant(closure)?;
1083        self.emit(OpCode::MakeThunk);
1084        self.stack_depth += 1; // MakeThunk pushes one thunk
1085        self.emit_u16(idx);
1086        self.emit_u16(uv_descs.len() as u16);
1087        for uv in &uv_descs {
1088            self.chunk
1089                .write_byte(if uv.is_local { 1 } else { 0 }, self.current_line);
1090            self.emit_u16(uv.index);
1091        }
1092        Ok(())
1093    }
1094
1095    // ── Attribute sets ─────────────────────────────────────────
1096
1097    fn compile_attrset(&mut self, set: &ast::AttrSet) -> Result<(), CompileError> {
1098        if set.rec_token().is_some() {
1099            return self.compile_rec_attrset(set);
1100        }
1101
1102        // Collect all entries, handling dotted bindings by merging them.
1103        // We need to group dotted bindings by their top-level key.
1104        let mut flat_entries: Vec<(String, ast::Expr)> = Vec::new();
1105        let mut dotted_entries: std::collections::BTreeMap<String, Vec<(Vec<String>, ast::Expr)>> =
1106            std::collections::BTreeMap::new();
1107        let mut inherit_entries: Vec<(String, Option<ast::Expr>)> = Vec::new();
1108        let mut dynamic_entries: Vec<(ast::Expr, ast::Expr)> = Vec::new();
1109        let mut dynamic_dotted_entries: Vec<(ast::Attr, Vec<String>, ast::Expr)> = Vec::new();
1110
1111        for entry in set.entries() {
1112            match entry {
1113                ast::Entry::AttrpathValue(ref apv) => {
1114                    let attrpath = apv.attrpath().ok_or_else(|| {
1115                        CompileError::MissingNode("attrset attrpath".to_string())
1116                    })?;
1117                    let keys: Vec<_> = attrpath.attrs().collect();
1118                    let value_expr = apv.value().ok_or_else(|| {
1119                        CompileError::MissingNode("attrset value".to_string())
1120                    })?;
1121
1122                    if keys.len() == 1 {
1123                        // Check for dynamic key.
1124                        match &keys[0] {
1125                            ast::Attr::Dynamic(dyn_attr) => {
1126                                let key_expr = dyn_attr.expr().ok_or_else(|| {
1127                                    CompileError::MissingNode("dynamic attr key".to_string())
1128                                })?;
1129                                dynamic_entries.push((key_expr, value_expr));
1130                            }
1131                            ast::Attr::Str(s) => {
1132                                // Try to extract a plain string literal
1133                                // (e.g. `"1" = ...`). These are static keys
1134                                // and must be compiled like flat entries
1135                                // (with lazy thunk-wrapped values) to avoid
1136                                // eagerly evaluating throw expressions in
1137                                // unaccessed attrset branches.
1138                                if let Ok(key) = static_attr_name(&keys[0]) {
1139                                    flat_entries.push((key, value_expr));
1140                                } else {
1141                                    // Interpolated string key — truly dynamic.
1142                                    let key_expr = ast::Expr::Str(s.clone());
1143                                    dynamic_entries.push((key_expr, value_expr));
1144                                }
1145                            }
1146                            _ => {
1147                                let key = static_attr_name(&keys[0])?;
1148                                flat_entries.push((key, value_expr));
1149                            }
1150                        }
1151                    } else {
1152                        // Dotted binding: group by top-level key.
1153                        match static_attr_name(&keys[0]) {
1154                            Ok(top_key) => {
1155                                let rest_keys: Vec<String> = keys[1..]
1156                                    .iter()
1157                                    .map(static_attr_name)
1158                                    .collect::<Result<_, _>>()?;
1159                                dotted_entries
1160                                    .entry(top_key)
1161                                    .or_default()
1162                                    .push((rest_keys, value_expr));
1163                            }
1164                            Err(_) => {
1165                                // Dynamic top-level key in dotted path.
1166                                // Collect rest keys as static names for the
1167                                // nested attrset; push as a dynamic entry.
1168                                let rest_keys: Vec<String> = keys[1..]
1169                                    .iter()
1170                                    .map(static_attr_name)
1171                                    .collect::<Result<_, _>>()?;
1172                                // Store for later compilation as dynamic
1173                                // dotted entry (key_attr, rest_keys, value).
1174                                dynamic_dotted_entries.push((
1175                                    keys[0].clone(),
1176                                    rest_keys,
1177                                    value_expr,
1178                                ));
1179                            }
1180                        }
1181                    }
1182                }
1183                ast::Entry::Inherit(ref inherit) => {
1184                    let source_expr = inherit.from().and_then(|f| f.expr());
1185                    for attr in inherit.attrs() {
1186                        let name = static_attr_name(&attr)?;
1187                        inherit_entries.push((name, source_expr.clone()));
1188                    }
1189                }
1190            }
1191        }
1192
1193        let mut count: u16 = 0;
1194
1195        // Emit flat entries (lazy: wrap non-trivial values in thunks,
1196        // except inside with-scopes where thunks can't capture the
1197        // dynamic scope).
1198        for (key, value_expr) in &flat_entries {
1199            if Self::is_trivial_value(value_expr) {
1200                self.compile_expr(value_expr)?;
1201            } else {
1202                self.compile_thunk_immediate(value_expr)?;
1203            }
1204            self.emit_constant(VMValue::String(key.clone()))?;
1205            count += 1;
1206        }
1207
1208        // Emit dotted entries as nested attrsets.
1209        for (top_key, sub_bindings) in &dotted_entries {
1210            self.compile_nested_attrset(sub_bindings)?;
1211            self.emit_constant(VMValue::String(top_key.clone()))?;
1212            count += 1;
1213        }
1214
1215        // Emit inherit entries (lazy: wrap inherit-from in thunks to avoid
1216        // forcing the source expression at attrset construction time).
1217        for (name, source_expr) in &inherit_entries {
1218            if let Some(src) = source_expr {
1219                // inherit (source) name; — wrap in a thunk that evaluates
1220                // source.name lazily (critical for fixpoint patterns like
1221                // makeExtensible where the source references `self`).
1222                self.compile_inherit_from_thunk(src, name)?;
1223            } else {
1224                // inherit name; — look up in current scope.
1225                self.emit_variable_load(name)?;
1226            }
1227            self.emit_constant(VMValue::String(name.clone()))?;
1228            count += 1;
1229        }
1230
1231        // Emit dynamic entries (lazy: wrap non-trivial values in thunks
1232        // to preserve Nix's lazy evaluation semantics).
1233        for (key_expr, value_expr) in &dynamic_entries {
1234            if Self::is_trivial_value(value_expr) {
1235                self.compile_expr(value_expr)?;
1236            } else {
1237                self.compile_thunk_immediate(value_expr)?;
1238            }
1239            self.compile_expr(key_expr)?;
1240            count += 1;
1241        }
1242
1243        // Emit dynamic dotted entries: dynamic top-level key with static
1244        // nested path. Build the nested attrset from rest_keys, then emit
1245        // the dynamic key expression.
1246        for (key_attr, rest_keys, value_expr) in &dynamic_dotted_entries {
1247            // Build nested attrset: { rest_key1.rest_key2... = value; }
1248            self.compile_nested_attrset(&[(rest_keys.clone(), value_expr.clone())])?;
1249            // Compile the dynamic key expression.
1250            self.compile_dynamic_attr_key(key_attr)?;
1251            count += 1;
1252        }
1253
1254        self.emit(OpCode::MakeAttrs);
1255        self.emit_u16(count);
1256        // MakeAttrs pops 2*count (value+key pairs) and pushes 1 attrset.
1257        self.stack_depth = self.stack_depth.saturating_sub(2 * count) + 1;
1258
1259        // If there were both flat/dotted and we need to merge, the MakeAttrs
1260        // handles it by creating one set. Dotted entries that share top-level
1261        // keys with flat entries need merging. For now, dotted entries that
1262        // share keys with flat entries override. This matches Nix semantics
1263        // where the last definition wins (for simple cases).
1264
1265        Ok(())
1266    }
1267
1268    /// Compile a `rec { ... }` attrset.
1269    fn compile_rec_attrset(&mut self, set: &ast::AttrSet) -> Result<(), CompileError> {
1270        self.begin_scope();
1271
1272        // Collect all binding names and their expressions.
1273        let mut bindings: Vec<(String, RecAttrBinding)> = Vec::new();
1274        let mut dotted_entries: std::collections::BTreeMap<String, Vec<(Vec<String>, ast::Expr)>> =
1275            std::collections::BTreeMap::new();
1276
1277        for entry in set.entries() {
1278            match entry {
1279                ast::Entry::AttrpathValue(ref apv) => {
1280                    let attrpath = apv.attrpath().ok_or_else(|| {
1281                        CompileError::MissingNode("rec attrset attrpath".to_string())
1282                    })?;
1283                    let keys: Vec<_> = attrpath.attrs().collect();
1284                    let value_expr = apv.value().ok_or_else(|| {
1285                        CompileError::MissingNode("rec attrset value".to_string())
1286                    })?;
1287                    if keys.len() == 1 {
1288                        let key = static_attr_name(&keys[0])?;
1289                        bindings.push((key, RecAttrBinding::Value(value_expr)));
1290                    } else {
1291                        let top_key = static_attr_name(&keys[0])?;
1292                        let rest_keys: Vec<String> = keys[1..]
1293                            .iter()
1294                            .map(static_attr_name)
1295                            .collect::<Result<_, _>>()?;
1296                        dotted_entries
1297                            .entry(top_key)
1298                            .or_default()
1299                            .push((rest_keys, value_expr));
1300                    }
1301                }
1302                ast::Entry::Inherit(ref inherit) => {
1303                    if let Some(from) = inherit.from() {
1304                        let source_expr = from.expr().ok_or_else(|| {
1305                            CompileError::MissingNode("inherit from expr".to_string())
1306                        })?;
1307                        for attr in inherit.attrs() {
1308                            let name = static_attr_name(&attr)?;
1309                            bindings.push((name.clone(), RecAttrBinding::InheritFrom(source_expr.clone(), name)));
1310                        }
1311                    } else {
1312                        for attr in inherit.attrs() {
1313                            let name = static_attr_name(&attr)?;
1314                            bindings.push((name, RecAttrBinding::Inherit));
1315                        }
1316                    }
1317                }
1318            }
1319        }
1320
1321        // Add dotted entries as bindings.
1322        for (top_key, sub) in &dotted_entries {
1323            bindings.push((top_key.clone(), RecAttrBinding::Dotted(sub.clone())));
1324        }
1325
1326        // Static cycle detection: check for `name = name;` patterns in rec bindings.
1327        {
1328            let pairs: Vec<(String, &ast::Expr)> = bindings
1329                .iter()
1330                .filter_map(|(name, binding)| match binding {
1331                    RecAttrBinding::Value(expr) => Some((name.clone(), expr as &ast::Expr)),
1332                    _ => None,
1333                })
1334                .collect();
1335            for warning in detect_trivial_cycles(&pairs) {
1336                eprintln!("{warning}");
1337            }
1338        }
1339
1340        let binding_count = u16::try_from(bindings.len())
1341            .map_err(|_| CompileError::TooManyLocals)?;
1342
1343        // Phase 1: Allocate local slots with null placeholders.
1344        for (name, _) in &bindings {
1345            self.emit(OpCode::Null); // emit() tracks stack_depth
1346            self.add_local(name.clone())?;
1347        }
1348
1349        // Phase 2: Compile each binding's value (lazy: use deferred thunks
1350        // so rec attrset values are only evaluated when accessed).
1351        let mut thunk_slots: Vec<(u16, Vec<UpvalueDesc>)> = Vec::new();
1352
1353        for (name, binding) in &bindings {
1354            let local_idx = self.resolve_local(name).unwrap();
1355            let slot = self.locals[local_idx as usize].slot;
1356            match binding {
1357                RecAttrBinding::Value(expr) => {
1358                    // In rec attrsets, lambdas must NOT be treated as trivial
1359                    // because MakeClosure captures upvalues at emission time.
1360                    // If a lambda captures a sibling binding (especially a
1361                    // dotted entry, which is appended last), that slot may still
1362                    // be null.  Wrapping in a deferred thunk delays MakeClosure
1363                    // until the lambda is actually accessed, when all siblings
1364                    // are populated.
1365                    if Self::is_trivial_value_for_rec(expr) {
1366                        self.compile_expr(expr)?;
1367                    } else {
1368                        let uv_descs = self.compile_thunk_deferred(expr)?;
1369                        if !uv_descs.is_empty() {
1370                            thunk_slots.push((slot, uv_descs));
1371                        }
1372                    }
1373                }
1374                RecAttrBinding::Inherit => {
1375                    // Temporarily hide this local so lookup finds the outer one.
1376                    let saved_depth = self.locals[local_idx as usize].depth;
1377                    self.locals[local_idx as usize].depth = u32::MAX;
1378                    self.emit_variable_load_restore(name, local_idx, saved_depth)?;
1379                    self.locals[local_idx as usize].depth = saved_depth;
1380                }
1381                RecAttrBinding::InheritFrom(source_expr, attr_name) => {
1382                    // Wrap inherit-from in deferred thunks for laziness.
1383                    let uv_descs = self.compile_inherit_from_thunk_deferred(source_expr, attr_name)?;
1384                    if !uv_descs.is_empty() {
1385                        thunk_slots.push((slot, uv_descs));
1386                    }
1387                }
1388                RecAttrBinding::Dotted(sub_bindings) => {
1389                    // Wrap dotted bindings in deferred thunks so that leaf
1390                    // expressions referencing rec siblings are only evaluated
1391                    // after PatchThunkUpvalues has populated upvalues.
1392                    // Leaves inside the thunk are also made individually lazy
1393                    // to avoid eagerly forcing siblings (which would cause
1394                    // infinite recursion for cross-referencing dotted bindings).
1395                    let uv_descs = self.compile_nested_attrset_thunk_deferred(sub_bindings)?;
1396                    if !uv_descs.is_empty() {
1397                        thunk_slots.push((slot, uv_descs));
1398                    }
1399                }
1400            }
1401            self.emit(OpCode::SetLocal);
1402            self.emit_u16(slot);
1403            self.emit(OpCode::Pop);
1404        }
1405
1406        // Phase 2b: Patch thunk upvalues now that all siblings exist.
1407        for (slot, uv_descs) in &thunk_slots {
1408            self.emit(OpCode::PatchThunkUpvalues);
1409            self.emit_u16(*slot);
1410            self.emit_u16(uv_descs.len() as u16);
1411            for uv in uv_descs {
1412                self.chunk.write_byte(if uv.is_local { 1 } else { 0 }, self.current_line);
1413                self.emit_u16(uv.index);
1414            }
1415        }
1416
1417        // Build the attrset from the local variables.
1418        for (name, _) in &bindings {
1419            let slot = self.find_local_slot(name);
1420            self.emit(OpCode::GetLocal);
1421            self.emit_u16(slot);
1422            self.emit_constant(VMValue::String(name.clone()))?;
1423        }
1424        self.emit(OpCode::MakeAttrs);
1425        self.emit_u16(binding_count);
1426        // MakeAttrs pops 2*count and pushes 1.
1427        self.stack_depth = self.stack_depth.saturating_sub(2 * binding_count) + 1;
1428
1429        // Clean up scope: move the attrset result down past the locals.
1430        self.end_scope(binding_count);
1431
1432        Ok(())
1433    }
1434
1435    /// Compile a legacy let expression (`let { x = 1; body = x; }`).
1436    ///
1437    /// This is equivalent to `(rec { x = 1; body = x; }).body`.
1438    /// The entries are recursive (like `rec { ... }`), and the result
1439    /// is the `body` attribute.
1440    fn compile_legacy_let(&mut self, ll: &ast::LegacyLet) -> Result<(), CompileError> {
1441        self.begin_scope();
1442
1443        // Collect bindings — same logic as compile_rec_attrset but
1444        // operating on a LegacyLet node (which also implements HasEntry).
1445        let mut bindings: Vec<(String, RecAttrBinding)> = Vec::new();
1446        let mut dotted_entries: std::collections::BTreeMap<String, Vec<(Vec<String>, ast::Expr)>> =
1447            std::collections::BTreeMap::new();
1448
1449        for entry in ll.entries() {
1450            match entry {
1451                ast::Entry::AttrpathValue(ref apv) => {
1452                    let attrpath = apv.attrpath().ok_or_else(|| {
1453                        CompileError::MissingNode("legacy let attrpath".to_string())
1454                    })?;
1455                    let keys: Vec<_> = attrpath.attrs().collect();
1456                    let value_expr = apv.value().ok_or_else(|| {
1457                        CompileError::MissingNode("legacy let value".to_string())
1458                    })?;
1459                    if keys.len() == 1 {
1460                        let key = static_attr_name(&keys[0])?;
1461                        bindings.push((key, RecAttrBinding::Value(value_expr)));
1462                    } else {
1463                        let top_key = static_attr_name(&keys[0])?;
1464                        let rest_keys: Vec<String> = keys[1..]
1465                            .iter()
1466                            .map(static_attr_name)
1467                            .collect::<Result<_, _>>()?;
1468                        dotted_entries
1469                            .entry(top_key)
1470                            .or_default()
1471                            .push((rest_keys, value_expr));
1472                    }
1473                }
1474                ast::Entry::Inherit(ref inherit) => {
1475                    if let Some(from) = inherit.from() {
1476                        let source_expr = from.expr().ok_or_else(|| {
1477                            CompileError::MissingNode("inherit from expr".to_string())
1478                        })?;
1479                        for attr in inherit.attrs() {
1480                            let name = static_attr_name(&attr)?;
1481                            bindings.push((name.clone(), RecAttrBinding::InheritFrom(source_expr.clone(), name)));
1482                        }
1483                    } else {
1484                        for attr in inherit.attrs() {
1485                            let name = static_attr_name(&attr)?;
1486                            bindings.push((name, RecAttrBinding::Inherit));
1487                        }
1488                    }
1489                }
1490            }
1491        }
1492
1493        // Add dotted entries as bindings.
1494        for (top_key, sub) in &dotted_entries {
1495            bindings.push((top_key.clone(), RecAttrBinding::Dotted(sub.clone())));
1496        }
1497
1498        let binding_count = u16::try_from(bindings.len())
1499            .map_err(|_| CompileError::TooManyLocals)?;
1500
1501        // Phase 1: Allocate local slots with null placeholders.
1502        for (name, _) in &bindings {
1503            self.emit(OpCode::Null);
1504            self.add_local(name.clone())?;
1505        }
1506
1507        // Phase 2: Compile each binding's value (lazy thunks for non-trivial).
1508        let mut thunk_slots: Vec<(u16, Vec<UpvalueDesc>)> = Vec::new();
1509
1510        for (name, binding) in &bindings {
1511            let local_idx = self.resolve_local(name).unwrap();
1512            let slot = self.locals[local_idx as usize].slot;
1513            match binding {
1514                RecAttrBinding::Value(expr) => {
1515                    // Same rec-aware trivial check as compile_rec_attrset:
1516                    // lambdas must be deferred to avoid capturing null slots.
1517                    if Self::is_trivial_value_for_rec(expr) {
1518                        self.compile_expr(expr)?;
1519                    } else {
1520                        let uv_descs = self.compile_thunk_deferred(expr)?;
1521                        if !uv_descs.is_empty() {
1522                            thunk_slots.push((slot, uv_descs));
1523                        }
1524                    }
1525                }
1526                RecAttrBinding::Inherit => {
1527                    let saved_depth = self.locals[local_idx as usize].depth;
1528                    self.locals[local_idx as usize].depth = u32::MAX;
1529                    self.emit_variable_load_restore(name, local_idx, saved_depth)?;
1530                    self.locals[local_idx as usize].depth = saved_depth;
1531                }
1532                RecAttrBinding::InheritFrom(source_expr, attr_name) => {
1533                    let uv_descs = self.compile_inherit_from_thunk_deferred(source_expr, attr_name)?;
1534                    if !uv_descs.is_empty() {
1535                        thunk_slots.push((slot, uv_descs));
1536                    }
1537                }
1538                RecAttrBinding::Dotted(sub_bindings) => {
1539                    // Wrap dotted bindings in deferred thunks (same as rec attrset).
1540                    let uv_descs = self.compile_nested_attrset_thunk_deferred(sub_bindings)?;
1541                    if !uv_descs.is_empty() {
1542                        thunk_slots.push((slot, uv_descs));
1543                    }
1544                }
1545            }
1546            self.emit(OpCode::SetLocal);
1547            self.emit_u16(slot);
1548            self.emit(OpCode::Pop);
1549        }
1550
1551        // Phase 2b: Patch thunk upvalues now that all siblings exist.
1552        for (slot, uv_descs) in &thunk_slots {
1553            self.emit(OpCode::PatchThunkUpvalues);
1554            self.emit_u16(*slot);
1555            self.emit_u16(uv_descs.len() as u16);
1556            for uv in uv_descs {
1557                self.chunk.write_byte(if uv.is_local { 1 } else { 0 }, self.current_line);
1558                self.emit_u16(uv.index);
1559            }
1560        }
1561
1562        // Instead of building an attrset and selecting "body", directly
1563        // load the local named "body" — this avoids constructing the
1564        // intermediate attrset entirely.
1565        let body_slot = self.find_local_slot_opt("body").ok_or_else(|| {
1566            CompileError::MissingNode("legacy let missing 'body' binding".to_string())
1567        })?;
1568        self.emit(OpCode::GetLocal);
1569        self.emit_u16(body_slot);
1570
1571        // Clean up scope: move the body value down past the locals.
1572        self.end_scope(binding_count);
1573
1574        Ok(())
1575    }
1576
1577    /// Compile a nested attrset from a list of (remaining-path, value) pairs.
1578    /// Used for dotted bindings like `{ a.b = 1; a.c = 2; }`.
1579    ///
1580    /// When `lazy_leaves` is true, non-trivial leaf values are wrapped in
1581    /// immediate thunks (for rec attrsets where leaves may reference siblings
1582    /// that aren't fully initialised until after `PatchThunkUpvalues` runs).
1583    fn compile_nested_attrset(
1584        &mut self,
1585        sub_bindings: &[(Vec<String>, ast::Expr)],
1586    ) -> Result<(), CompileError> {
1587        self.compile_nested_attrset_inner(sub_bindings, false, &[])
1588    }
1589
1590    fn compile_nested_attrset_lazy(
1591        &mut self,
1592        sub_bindings: &[(Vec<String>, ast::Expr)],
1593    ) -> Result<(), CompileError> {
1594        self.compile_nested_attrset_inner(sub_bindings, true, &[])
1595    }
1596
1597    /// `prefix` is the dotted path already consumed by outer recursions. It
1598    /// exists only so a duplicate can be NAMED; it does not affect codegen.
1599    fn compile_nested_attrset_inner(
1600        &mut self,
1601        sub_bindings: &[(Vec<String>, ast::Expr)],
1602        lazy_leaves: bool,
1603        prefix: &[String],
1604    ) -> Result<(), CompileError> {
1605        // Group by next key.
1606        let mut groups: std::collections::BTreeMap<String, Vec<(Vec<String>, ast::Expr)>> =
1607            std::collections::BTreeMap::new();
1608
1609        for (path, expr) in sub_bindings {
1610            // ★ `split_first`, NOT `path[0]`.
1611            //
1612            // Two bindings at the SAME dotted path (`{ a.b = 1; a.b = 2; }`)
1613            // both land in group `a` carrying the remainder `["b"]`, recurse,
1614            // both land in group `b` carrying `[]`, and recurse AGAIN — at
1615            // which point `path` is empty and `path[0]` panicked with
1616            // `index out of bounds: the len is 0 but the index is 0`.
1617            //
1618            // It panicked on the `sui-vm-eval` thread, where the CLI's
1619            // whole-expression fallback then rescued the run and returned the
1620            // walker's answer with exit 0 — so a compiler PANIC was invisible
1621            // in normal use and surfaced only under `SUI_VM_STRICT`. A crash
1622            // that presents as a clean success is the worst available shape.
1623            let Some((head, rest)) = path.split_first() else {
1624                let full = if prefix.is_empty() {
1625                    "<unknown>".to_string()
1626                } else {
1627                    prefix.join(".")
1628                };
1629                return Err(CompileError::Unsupported(format!(
1630                    "attribute '{full}' is defined more than once; CppNix \
1631                     rejects this at parse time and the bytecode compiler \
1632                     cannot represent it"
1633                )));
1634            };
1635            groups
1636                .entry(head.clone())
1637                .or_default()
1638                .push((rest.to_vec(), expr.clone()));
1639        }
1640
1641        let mut count: u16 = 0;
1642        for (key, nested) in &groups {
1643            if nested.len() == 1 && nested[0].0.is_empty() {
1644                // Simple leaf.
1645                if lazy_leaves && !Self::is_trivial_value(&nested[0].1) {
1646                    self.compile_thunk_immediate(&nested[0].1)?;
1647                } else {
1648                    self.compile_expr(&nested[0].1)?;
1649                }
1650            } else {
1651                // Recurse for deeper nesting.
1652                let mut deeper = prefix.to_vec();
1653                deeper.push(key.clone());
1654                self.compile_nested_attrset_inner(nested, lazy_leaves, &deeper)?;
1655            }
1656            self.emit_constant(VMValue::String(key.clone()))?;
1657            count += 1;
1658        }
1659
1660        self.emit(OpCode::MakeAttrs);
1661        self.emit_u16(count);
1662        self.stack_depth = self.stack_depth.saturating_sub(2 * count) + 1;
1663        Ok(())
1664    }
1665
1666    /// Emit a variable load for a name (local, upvalue, or with-scope).
1667    fn emit_variable_load(&mut self, name: &str) -> Result<(), CompileError> {
1668        if let Some(idx) = self.resolve_local(name) {
1669            self.emit(OpCode::GetLocal);
1670            self.emit_u16(self.local_stack_slot(idx));
1671        } else if let Some(uv_idx) = self.resolve_upvalue(name) {
1672            self.emit(OpCode::GetUpvalue);
1673            self.emit_u16(uv_idx as u16);
1674        } else if self.has_with_scope() {
1675            let name_idx = self.chunk.add_constant(VMValue::String(name.to_string()))?;
1676            self.emit(OpCode::LookupWith);
1677            self.emit_u16(name_idx);
1678        } else {
1679            return Err(CompileError::Unsupported(format!(
1680                "inherit: cannot resolve '{name}'"
1681            )));
1682        }
1683        Ok(())
1684    }
1685
1686    /// Emit variable load, restoring local depth on error.
1687    /// `local_idx` is the index into `self.locals` (for error recovery).
1688    fn emit_variable_load_restore(
1689        &mut self,
1690        name: &str,
1691        local_idx: u16,
1692        saved_depth: u32,
1693    ) -> Result<(), CompileError> {
1694        if let Some(outer_idx) = self.resolve_local(name) {
1695            self.emit(OpCode::GetLocal);
1696            self.emit_u16(self.local_stack_slot(outer_idx));
1697        } else if let Some(uv_idx) = self.resolve_upvalue(name) {
1698            self.emit(OpCode::GetUpvalue);
1699            self.emit_u16(uv_idx as u16);
1700        } else if self.has_with_scope() {
1701            let name_idx = self.chunk.add_constant(VMValue::String(name.to_string()))?;
1702            self.emit(OpCode::LookupWith);
1703            self.emit_u16(name_idx);
1704        } else {
1705            self.locals[local_idx as usize].depth = saved_depth;
1706            return Err(CompileError::Unsupported(format!(
1707                "inherit: cannot resolve '{name}' in enclosing scope"
1708            )));
1709        }
1710        Ok(())
1711    }
1712
1713    // ── Select (attrset.key) ───────────────────────────────────
1714
1715    /// Try to resolve an expression as a local variable slot.
1716    fn try_resolve_as_local(&self, expr: &ast::Expr) -> Option<u16> {
1717        if let ast::Expr::Ident(id) = expr {
1718            let name = ident_text(id);
1719            let idx = self.resolve_local(&name)?;
1720            Some(self.local_stack_slot(idx))
1721        } else {
1722            None
1723        }
1724    }
1725
1726    fn compile_select(&mut self, sel: &ast::Select) -> Result<(), CompileError> {
1727        let base = sel
1728            .expr()
1729            .ok_or_else(|| CompileError::MissingNode("select base".to_string()))?;
1730        let attrpath = sel
1731            .attrpath()
1732            .ok_or_else(|| CompileError::MissingNode("select attrpath".to_string()))?;
1733
1734        let segments: Vec<_> = attrpath.attrs().collect();
1735
1736        if let Some(default_expr) = sel.default_expr() {
1737            // `expr.a.b.c or default` — if ANY segment is missing (or the
1738            // intermediate value is not an attrset), evaluate the default.
1739            //
1740            // Strategy: for each segment (including non-last), check with
1741            // HasAttr before accessing.  On miss, jump to a shared default
1742            // path.  HasAttr returns false for non-attrset values, so this
1743            // also handles the "not an attrset" case.
1744            //
1745            // Stack invariant: at each segment, exactly one value (the
1746            // current attrset being traversed) sits on top.
1747            //
1748            //   compile_expr(&base)        ; [val]
1749            //   for each segment:
1750            //     Dup                       ; [val, val]
1751            //     HasAttr key               ; [val, bool]
1752            //     JumpIfFalse miss          ; [val]
1753            //     GetAttr key               ; [next_val]
1754            //   (last segment's GetAttr produces the result)
1755            //   Jump end
1756            //   miss:
1757            //   Pop                         ; []  (discard partial val)
1758            //   <compile default>           ; [default_val]
1759            //   end:
1760            self.compile_expr(&base)?;
1761            let depth_before = self.stack_depth; // D (one extra value: base)
1762            let mut miss_jumps: Vec<usize> = Vec::new();
1763            for (_i, attr) in segments.iter().enumerate() {
1764                if let Ok(key) = static_attr_name(attr) {
1765                    let key_idx = self.add_attr_key(key)?;
1766                    self.emit(OpCode::Dup);             // [val, val]
1767                    self.emit(OpCode::HasAttr);         // [val, bool]
1768                    self.emit_u16(key_idx);
1769                    miss_jumps.push(self.emit_jump(OpCode::JumpIfFalse)); // [val]
1770                    self.emit(OpCode::GetAttr);         // [next_val]
1771                    self.emit_u16(key_idx);
1772                } else {
1773                    self.emit(OpCode::Dup);             // [val, val]
1774                    self.compile_dynamic_attr_key(attr)?; // [val, val, key]
1775                    self.emit(OpCode::DynHasAttr);      // [val, bool]
1776                    miss_jumps.push(self.emit_jump(OpCode::JumpIfFalse)); // [val]
1777                    self.compile_dynamic_attr_key(attr)?; // [val, key]
1778                    self.emit(OpCode::DynGetAttr);      // [next_val]
1779                }
1780            }
1781            // All segments succeeded — result is on stack.
1782            // Stack depth here = depth_before (each Dup+HasAttr+JumpIfFalse+GetAttr is net 0).
1783            let end_jump = self.emit_jump(OpCode::Jump);
1784            // miss path: one value on stack (the partial traversal value)
1785            for mj in miss_jumps {
1786                self.patch_jump(mj)?;
1787            }
1788            // Reset stack depth to depth_before (we have the partial value on stack)
1789            self.stack_depth = depth_before;
1790            self.emit(OpCode::Pop);                    // depth_before - 1
1791            self.compile_expr(&default_expr)?;         // depth_before (default_val)
1792            self.patch_jump(end_jump)?;
1793            // Both paths leave exactly one result on stack: depth = depth_before
1794        } else {
1795            // Superinstruction: if base is a local and first segment is static,
1796            // use GetLocalAttr for the first access (saves one dispatch).
1797            let local_slot = self.try_resolve_as_local(&base);
1798
1799            for (i, attr) in segments.iter().enumerate() {
1800                if let Ok(key) = static_attr_name(attr) {
1801                    let key_idx = self.add_attr_key(key)?;
1802
1803                    if i == 0 {
1804                        if let Some(slot) = local_slot {
1805                            // Fused GetLocal + GetAttr.
1806                            self.emit(OpCode::GetLocalAttr);
1807                            self.emit_u16(slot);
1808                            self.emit_u16(key_idx);
1809                        } else {
1810                            self.compile_expr(&base)?;
1811                            self.emit(OpCode::GetAttr);
1812                            self.emit_u16(key_idx);
1813                        }
1814                    } else {
1815                        self.emit(OpCode::GetAttr);
1816                        self.emit_u16(key_idx);
1817                    }
1818                } else {
1819                    // Dynamic segment: compile base if needed, then key, then DynGetAttr.
1820                    if i == 0 {
1821                        self.compile_expr(&base)?;
1822                    }
1823                    self.compile_dynamic_attr_key(attr)?;
1824                    self.emit(OpCode::DynGetAttr);
1825                }
1826            }
1827        }
1828
1829        Ok(())
1830    }
1831
1832    /// Compile a dynamic attribute key (interpolated string or dynamic expr).
1833    fn compile_dynamic_attr_key(&mut self, attr: &ast::Attr) -> Result<(), CompileError> {
1834        match attr {
1835            ast::Attr::Dynamic(d) => {
1836                let expr = d.expr().ok_or_else(|| {
1837                    CompileError::MissingNode("dynamic attr key expr".to_string())
1838                })?;
1839                self.compile_expr(&expr)
1840            }
1841            ast::Attr::Str(s) => {
1842                let key_expr = ast::Expr::Str(s.clone());
1843                self.compile_expr(&key_expr)
1844            }
1845            ast::Attr::Ident(ident) => {
1846                self.emit_constant(VMValue::String(ident_text(ident)))
1847            }
1848        }
1849    }
1850
1851    // ── HasAttr (expr ? key) ───────────────────────────────────
1852
1853    fn compile_has_attr(&mut self, ha: &ast::HasAttr) -> Result<(), CompileError> {
1854        let base = ha
1855            .expr()
1856            .ok_or_else(|| CompileError::MissingNode("hasattr base".to_string()))?;
1857        let attrpath = ha
1858            .attrpath()
1859            .ok_or_else(|| CompileError::MissingNode("hasattr attrpath".to_string()))?;
1860
1861        let segments: Vec<_> = attrpath.attrs().collect();
1862
1863        if segments.len() == 1 {
1864            // Single-segment: compile base, then HasAttr or DynHasAttr.
1865            self.compile_expr(&base)?;
1866            if let Ok(key) = static_attr_name(&segments[0]) {
1867                let key_idx = self.add_attr_key(key)?;
1868                self.emit(OpCode::HasAttr);
1869                self.emit_u16(key_idx);
1870            } else {
1871                self.compile_dynamic_attr_key(&segments[0])?;
1872                self.emit(OpCode::DynHasAttr);
1873            }
1874            return Ok(());
1875        }
1876
1877        // Multi-segment hasattr: `a ? x.y.z`
1878        // Compiled as a chain of HasAttr checks with short-circuit jumps.
1879        // For each segment except the last, we check HasAttr and GetAttr
1880        // to drill into the nested attrset.
1881        //
1882        // The base expression is re-evaluated for each intermediate step,
1883        // which is correct because Nix is pure and the compiler wraps
1884        // non-trivial expressions in thunks.
1885        let mut false_jumps: Vec<usize> = Vec::new();
1886        // Save stack depth before first segment — all short-circuit
1887        // targets must converge to (depth_before + 1).
1888        let depth_before = self.stack_depth;
1889
1890        for (i, seg) in segments.iter().enumerate() {
1891            // Build the prefix path: base.seg0.seg1...seg(i-1)
1892            self.compile_expr(&base)?;
1893            for prev_seg in &segments[..i] {
1894                if let Ok(prev_key) = static_attr_name(prev_seg) {
1895                    let prev_idx = self.add_attr_key(prev_key)?;
1896                    self.emit(OpCode::GetAttr);
1897                    self.emit_u16(prev_idx);
1898                } else {
1899                    self.compile_dynamic_attr_key(prev_seg)?;
1900                    self.emit(OpCode::DynGetAttr);
1901                }
1902            }
1903            if let Ok(key) = static_attr_name(seg) {
1904                let key_idx = self.add_attr_key(key)?;
1905                self.emit(OpCode::HasAttr);
1906                self.emit_u16(key_idx);
1907            } else {
1908                self.compile_dynamic_attr_key(seg)?;
1909                self.emit(OpCode::DynHasAttr);
1910            }
1911
1912            // For all segments except the last, short-circuit on false.
1913            if i < segments.len() - 1 {
1914                false_jumps.push(self.emit_jump(OpCode::JumpIfFalse));
1915                // Reset depth for next iteration — each JumpIfFalse pops
1916                // the condition, and at the false target the stack is at
1917                // depth_before (no result pushed yet). The next segment
1918                // starts fresh from depth_before.
1919                self.stack_depth = depth_before;
1920            }
1921        }
1922
1923        // Jump over the false path.
1924        let done_jump = self.emit_jump(OpCode::Jump);
1925
1926        // False path: push false for any short-circuit jump.
1927        // All false_jumps target here, where stack is at depth_before.
1928        self.stack_depth = depth_before;
1929        for fj in false_jumps {
1930            self.patch_jump(fj)?;
1931        }
1932        self.emit(OpCode::False);
1933        // Now stack_depth = depth_before + 1 (same as the true path).
1934
1935        self.patch_jump(done_jump)?;
1936        Ok(())
1937    }
1938
1939    // ── If/then/else ───────────────────────────────────────────
1940
1941    fn compile_if(&mut self, ie: &ast::IfElse) -> Result<(), CompileError> {
1942        let cond = ie
1943            .condition()
1944            .ok_or_else(|| CompileError::MissingNode("if condition".to_string()))?;
1945        let then_body = ie
1946            .body()
1947            .ok_or_else(|| CompileError::MissingNode("if then".to_string()))?;
1948        let else_body = ie
1949            .else_body()
1950            .ok_or_else(|| CompileError::MissingNode("if else".to_string()))?;
1951
1952        // Save tail position — both branches inherit it.
1953        let tail = self.tail_position;
1954
1955        // Compile condition (not in tail position).
1956        self.tail_position = false;
1957        self.compile_expr(&cond)?;
1958        // Jump to else if false.
1959        let else_jump = self.emit_jump(OpCode::JumpIfFalse);
1960        // After JumpIfFalse, the condition is popped. Save the depth here —
1961        // this is the stack depth at which both branches start.
1962        let depth_at_branch = self.stack_depth;
1963        // Compile then branch (tail position propagated).
1964        self.tail_position = tail;
1965        self.compile_expr(&then_body)?;
1966        // Jump past else.
1967        let end_jump = self.emit_jump(OpCode::Jump);
1968        // Patch else jump. Reset stack_depth to the branch start —
1969        // the else branch starts with the same stack as the then branch.
1970        self.stack_depth = depth_at_branch;
1971        self.patch_jump(else_jump)?;
1972        // Compile else branch (tail position propagated).
1973        self.tail_position = tail;
1974        self.compile_expr(&else_body)?;
1975        // Both branches push exactly one result value, so stack_depth
1976        // is now depth_at_branch + 1 (correct for the merge point).
1977        // Patch end jump.
1978        self.patch_jump(end_jump)?;
1979        Ok(())
1980    }
1981
1982    // ── Lambda ─────────────────────────────────────────────────
1983
1984    fn compile_lambda(&mut self, lam: &ast::Lambda) -> Result<(), CompileError> {
1985        let param = lam
1986            .param()
1987            .ok_or_else(|| CompileError::MissingNode("lambda param".to_string()))?;
1988        let body = lam
1989            .body()
1990            .ok_or_else(|| CompileError::MissingNode("lambda body".to_string()))?;
1991
1992        // Compile the function body as a separate chunk (sharing the interner).
1993        let mut func_compiler = Compiler::with_interner(Rc::clone(&self.interner));
1994        func_compiler.scope_depth = 1; // function body is its own scope
1995        // Link to enclosing compiler for upvalue resolution.
1996        func_compiler.enclosing = Some(self as *mut Compiler);
1997        // Propagate base directory for relative path resolution.
1998        func_compiler.base_dir = self.base_dir.clone();
1999        // The function argument will be at slot 0 (pushed by VM Call handler).
2000        func_compiler.stack_depth = 1;
2001
2002        let mut formals_metadata: Vec<(String, bool)> = Vec::new();
2003        let (arity, name) = match &param {
2004            ast::Param::IdentParam(ip) => {
2005                let ident = ip
2006                    .ident()
2007                    .ok_or_else(|| CompileError::MissingNode("lambda ident".to_string()))?;
2008                let name = ident_text(&ident);
2009                // The argument occupies slot 0 in the function's local stack.
2010                func_compiler.add_local(name.clone())?;
2011                (1, Some(name))
2012            }
2013            ast::Param::Pattern(pat) => {
2014                // Pattern destructuring: { a, b, c ? default }
2015                // The entire argument attrset occupies slot 0.
2016                // Then we extract individual bindings.
2017                let bind_name = pat
2018                    .pat_bind()
2019                    .and_then(|pb| pb.ident())
2020                    .map(|id| ident_text(&id));
2021
2022                if let Some(ref bname) = bind_name {
2023                    func_compiler.add_local(bname.clone())?;
2024                } else {
2025                    // Anonymous slot 0 for the argument attrset.
2026                    func_compiler.add_local("__arg".to_string())?;
2027                }
2028
2029                // For each pattern entry, extract the field from the arg.
2030                let mut field_names: Vec<(String, Option<ast::Expr>)> = Vec::new();
2031                for entry in pat.pat_entries() {
2032                    let ident = entry
2033                        .ident()
2034                        .ok_or_else(|| CompileError::MissingNode("pattern entry ident".to_string()))?;
2035                    let fname = ident_text(&ident);
2036                    let default = entry.default();
2037                    formals_metadata.push((fname.clone(), default.is_some()));
2038                    field_names.push((fname, default));
2039                }
2040
2041                // Push local slots for each pattern field.
2042                for (fname, _) in &field_names {
2043                    func_compiler.emit(OpCode::Null); // emit() tracks stack_depth
2044                    func_compiler.add_local(fname.clone())?;
2045                }
2046
2047                // Extract each field from slot 0 (the arg attrset).
2048                for (i, (fname, default)) in field_names.iter().enumerate() {
2049                    let key_idx = func_compiler.add_attr_key(fname.clone())?;
2050                    if let Some(default_expr) = default {
2051                        // Lazy default: only evaluate default_expr when the
2052                        // key is absent from the argument attrset AND the
2053                        // parameter is actually forced.  Nix semantics require
2054                        // defaults to be fully lazy — they must not be forced
2055                        // at function entry even when the key is missing.
2056                        //
2057                        // Emit:
2058                        //   GetLocal 0        ; push arg attrset
2059                        //   HasAttr key_idx   ; bool: key present?
2060                        //   JumpIfFalse L1    ; key missing → default path
2061                        //   GetLocal 0        ; key present → fetch value
2062                        //   GetAttr key_idx
2063                        //   Jump L2
2064                        // L1:
2065                        //   MakeThunk(default) ; wrap in thunk — only forced on use
2066                        // L2:
2067                        //   ; result on stack
2068                        func_compiler.emit(OpCode::GetLocal);
2069                        func_compiler.emit_u16(0); // arg attrset at slot 0
2070                        func_compiler.emit(OpCode::HasAttr);
2071                        func_compiler.emit_u16(key_idx);
2072                        let else_jump = func_compiler.emit_jump(OpCode::JumpIfFalse);
2073                        // After JumpIfFalse pops the bool, save depth.
2074                        let depth_at_branch = func_compiler.stack_depth;
2075                        // Key exists — get the value.
2076                        func_compiler.emit(OpCode::GetLocal);
2077                        func_compiler.emit_u16(0);
2078                        func_compiler.emit(OpCode::GetAttr);
2079                        func_compiler.emit_u16(key_idx);
2080                        let end_jump = func_compiler.emit_jump(OpCode::Jump);
2081                        // Key missing — wrap default in a thunk (lazy).
2082                        func_compiler.stack_depth = depth_at_branch;
2083                        func_compiler.patch_jump(else_jump)?;
2084                        func_compiler.compile_thunk_immediate(default_expr)?;
2085                        // Both branches leave exactly one value on the stack.
2086                        func_compiler.patch_jump(end_jump)?;
2087                    } else {
2088                        // Use GetAttr (will error if missing).
2089                        func_compiler.emit(OpCode::GetLocal);
2090                        func_compiler.emit_u16(0); // arg attrset at slot 0
2091                        func_compiler.emit(OpCode::GetAttr);
2092                        func_compiler.emit_u16(key_idx);
2093                    }
2094                    // Store into the field's local slot and pop the value from the stack.
2095                    let field_slot = func_compiler.find_local_slot(fname);
2096                    func_compiler.emit(OpCode::SetLocal);
2097                    func_compiler.emit_u16(field_slot);
2098                    func_compiler.emit(OpCode::Pop);
2099                    let _ = i; // suppress unused
2100                }
2101
2102                (1, bind_name)
2103            }
2104        };
2105
2106        // Compile the body inside the function compiler.
2107        // The lambda body is in tail position — any direct call can be a tail call.
2108        func_compiler.tail_position = true;
2109        func_compiler.compile_expr(&body)?;
2110        func_compiler.emit(OpCode::Return);
2111
2112        // Collect upvalue descriptors from the function compiler.
2113        let upvalue_count = func_compiler.upvalues.len();
2114        let upvalue_descs: Vec<UpvalueDesc> = func_compiler.upvalues.clone();
2115
2116        // Store the compiled function as a constant in the outer chunk.
2117        let closure = VMValue::Closure(VMClosure {
2118            chunk: Rc::new(func_compiler.chunk),
2119            upvalues: Vec::new(), // populated at runtime by MakeClosure
2120            arity,
2121            name,
2122            formals: formals_metadata,
2123        });
2124
2125        if upvalue_count == 0 {
2126            // No upvalues: simple constant closure.
2127            self.emit_constant(closure)
2128        } else {
2129            // Emit MakeClosure with upvalue descriptors.
2130            let idx = self.chunk.add_constant(closure)?;
2131            self.emit(OpCode::MakeClosure);
2132            self.stack_depth += 1; // MakeClosure pushes the closure
2133            self.emit_u16(idx);
2134            // Emit upvalue count as u16.
2135            self.emit_u16(upvalue_count as u16);
2136            // For each upvalue: is_local (u8) + index (u16).
2137            for uv in &upvalue_descs {
2138                self.chunk.write_byte(if uv.is_local { 1 } else { 0 }, self.current_line);
2139                self.emit_u16(uv.index);
2140            }
2141            Ok(())
2142        }
2143    }
2144
2145    // ── Apply (function call) ──────────────────────────────────
2146
2147    fn compile_apply(&mut self, app: &ast::Apply) -> Result<(), CompileError> {
2148        let func = app
2149            .lambda()
2150            .ok_or_else(|| CompileError::MissingNode("apply function".to_string()))?;
2151        let arg = app
2152            .argument()
2153            .ok_or_else(|| CompileError::MissingNode("apply argument".to_string()))?;
2154
2155        // Save tail position — arguments and function are NOT in tail position.
2156        let tail = self.tail_position;
2157        self.tail_position = false;
2158
2159        // Special form: `import <path>` compiles to path + Import opcode.
2160        if let ast::Expr::Ident(ref id) = func {
2161            let name = ident_text(id);
2162            if name == "import" {
2163                self.compile_expr(&arg)?;
2164                self.emit(OpCode::Import);
2165                return Ok(());
2166            }
2167        }
2168
2169        // Choose Call vs TailCall based on whether this apply is in tail position.
2170        let call_op = if tail { OpCode::TailCall } else { OpCode::Call };
2171
2172        // Superinstruction: if the function is a local variable, use
2173        // GetLocalCall to save one dispatch cycle (only for non-tail calls;
2174        // tail calls use the standard TailCall opcode which handles frame reuse).
2175        if !tail {
2176            if let Some(slot) = self.try_resolve_as_local(&func) {
2177                self.compile_arg_maybe_thunk(&arg)?;
2178                self.emit(OpCode::GetLocalCall);
2179                self.emit_u16(slot);
2180                return Ok(());
2181            }
2182        }
2183
2184        // Normal: push function, then argument, then Call/TailCall.
2185        self.compile_expr(&func)?;
2186        self.compile_arg_maybe_thunk(&arg)?;
2187        self.emit(call_op);
2188        Ok(())
2189    }
2190
2191    /// Compile a function argument with call-by-need semantics.
2192    /// Trivial expressions (literals, idents, paths, lambdas) are inlined.
2193    /// Non-trivial expressions are wrapped in thunks for lazy evaluation.
2194    /// This matches CppNix's maybeThunk for function arguments.
2195
2196    // ── Binary operations ──────────────────────────────────────
2197
2198    fn compile_binop(&mut self, binop: &ast::BinOp) -> Result<(), CompileError> {
2199        let lhs = binop
2200            .lhs()
2201            .ok_or_else(|| CompileError::MissingNode("binop lhs".to_string()))?;
2202        let rhs = binop
2203            .rhs()
2204            .ok_or_else(|| CompileError::MissingNode("binop rhs".to_string()))?;
2205        let op = binop
2206            .operator()
2207            .ok_or_else(|| CompileError::MissingNode("binop operator".to_string()))?;
2208
2209        match op {
2210            // Short-circuit: && compiles as if/then/else
2211            ast::BinOpKind::And => {
2212                self.compile_expr(&lhs)?;
2213                let false_jump = self.emit_jump(OpCode::JumpIfFalse);
2214                // After JumpIfFalse pops lhs, save depth at branch start.
2215                let depth_at_branch = self.stack_depth;
2216                self.compile_expr(&rhs)?;
2217                let end_jump = self.emit_jump(OpCode::Jump);
2218                // Reset to branch-start depth for the false path.
2219                self.stack_depth = depth_at_branch;
2220                self.patch_jump(false_jump)?;
2221                self.emit(OpCode::False);
2222                self.patch_jump(end_jump)?;
2223            }
2224            // Short-circuit: || compiles as if/then/else
2225            ast::BinOpKind::Or => {
2226                self.compile_expr(&lhs)?;
2227                let true_jump = self.emit_jump(OpCode::JumpIfTrue);
2228                // After JumpIfTrue pops lhs, save depth at branch start.
2229                let depth_at_branch = self.stack_depth;
2230                self.compile_expr(&rhs)?;
2231                let end_jump = self.emit_jump(OpCode::Jump);
2232                // Reset to branch-start depth for the true path.
2233                self.stack_depth = depth_at_branch;
2234                self.patch_jump(true_jump)?;
2235                self.emit(OpCode::True);
2236                self.patch_jump(end_jump)?;
2237            }
2238            // Short-circuit: -> is !a || b, so if lhs is false => true
2239            ast::BinOpKind::Implication => {
2240                self.compile_expr(&lhs)?;
2241                let false_jump = self.emit_jump(OpCode::JumpIfFalse);
2242                // After JumpIfFalse pops lhs, save depth at branch start.
2243                let depth_at_branch = self.stack_depth;
2244                self.compile_expr(&rhs)?;
2245                let end_jump = self.emit_jump(OpCode::Jump);
2246                // Reset to branch-start depth for the false path.
2247                self.stack_depth = depth_at_branch;
2248                self.patch_jump(false_jump)?;
2249                self.emit(OpCode::True);
2250                self.patch_jump(end_jump)?;
2251            }
2252            // Non-short-circuit: compile both sides, then emit opcode.
2253            _ => {
2254                self.compile_expr(&lhs)?;
2255                self.compile_expr(&rhs)?;
2256                match op {
2257                    ast::BinOpKind::Add => self.emit(OpCode::Add),
2258                    ast::BinOpKind::Sub => self.emit(OpCode::Sub),
2259                    ast::BinOpKind::Mul => self.emit(OpCode::Mul),
2260                    ast::BinOpKind::Div => self.emit(OpCode::Div),
2261                    ast::BinOpKind::Equal => self.emit(OpCode::Equal),
2262                    ast::BinOpKind::NotEqual => self.emit(OpCode::NotEqual),
2263                    ast::BinOpKind::Less => self.emit(OpCode::Less),
2264                    ast::BinOpKind::LessOrEq => self.emit(OpCode::LessEqual),
2265                    ast::BinOpKind::More => self.emit(OpCode::Greater),
2266                    ast::BinOpKind::MoreOrEq => self.emit(OpCode::GreaterEqual),
2267                    ast::BinOpKind::Update => self.emit(OpCode::UpdateAttrs),
2268                    ast::BinOpKind::Concat => self.emit(OpCode::Concat),
2269                    ast::BinOpKind::And
2270                    | ast::BinOpKind::Or
2271                    | ast::BinOpKind::Implication => unreachable!(),
2272                    ast::BinOpKind::PipeRight | ast::BinOpKind::PipeLeft => {
2273                        return Err(CompileError::Unsupported("pipe operators".to_string()));
2274                    }
2275                }
2276            }
2277        }
2278        Ok(())
2279    }
2280
2281    // ── Unary operations ───────────────────────────────────────
2282
2283    fn compile_unary(&mut self, op: &ast::UnaryOp) -> Result<(), CompileError> {
2284        let inner = op
2285            .expr()
2286            .ok_or_else(|| CompileError::MissingNode("unary expr".to_string()))?;
2287        let kind = op
2288            .operator()
2289            .ok_or_else(|| CompileError::MissingNode("unary operator".to_string()))?;
2290        self.compile_expr(&inner)?;
2291        match kind {
2292            ast::UnaryOpKind::Negate => self.emit(OpCode::Negate),
2293            ast::UnaryOpKind::Invert => self.emit(OpCode::Not),
2294        }
2295        Ok(())
2296    }
2297
2298    // ── With ───────────────────────────────────────────────────
2299
2300    fn compile_with(&mut self, with: &ast::With) -> Result<(), CompileError> {
2301        let ns = with
2302            .namespace()
2303            .ok_or_else(|| CompileError::MissingNode("with namespace".to_string()))?;
2304        let body = with
2305            .body()
2306            .ok_or_else(|| CompileError::MissingNode("with body".to_string()))?;
2307
2308        // Compile the namespace expression.
2309        self.compile_expr(&ns)?;
2310
2311        // Dup: one copy goes to PushWith (consumed), the other stays as a
2312        // hidden local so thunks inside the body can capture it as an upvalue.
2313        // Net stack effect of Dup (+1) + PushWith (-1) = 0.
2314        self.emit(OpCode::Dup);
2315        self.emit(OpCode::PushWith);
2316
2317        // Register the remaining copy as a hidden local.
2318        let slot = self.add_local("__with_scope".to_string())?;
2319        self.with_scope_locals.push(slot);
2320        self.with_depth += 1;
2321
2322        // Compile the body.
2323        self.compile_expr(&body)?;
2324
2325        // Pop the with-scope.
2326        self.emit(OpCode::PopWith);
2327        self.with_depth -= 1;
2328        self.with_scope_locals.pop();
2329
2330        // Clean up hidden local: body result is TOS, hidden local is below.
2331        // Stack: [..., __with_scope, body_result]
2332        // Swap them so body_result survives after Pop.
2333        // Use SetLocal to overwrite the hidden local with body_result,
2334        // then Pop to remove the duplicate TOS.
2335        self.emit(OpCode::SetLocal);
2336        self.emit_u16(slot);
2337        self.emit(OpCode::Pop);
2338        // Adjust: one slot removed (the hidden local is now body_result).
2339        self.stack_depth = slot + 1;
2340        self.locals.pop();
2341
2342        Ok(())
2343    }
2344
2345    // ── Assert ─────────────────────────────────────────────────
2346
2347    fn compile_assert(&mut self, assert: &ast::Assert) -> Result<(), CompileError> {
2348        let cond = assert
2349            .condition()
2350            .ok_or_else(|| CompileError::MissingNode("assert condition".to_string()))?;
2351        let body = assert
2352            .body()
2353            .ok_or_else(|| CompileError::MissingNode("assert body".to_string()))?;
2354        // Save tail position — the body inherits it, the condition does not.
2355        let tail = self.tail_position;
2356        self.tail_position = false;
2357        self.compile_expr(&cond)?;
2358        self.emit(OpCode::Assert);
2359        // The assert body is in tail position if the assert itself is.
2360        self.tail_position = tail;
2361        self.compile_expr(&body)?;
2362        Ok(())
2363    }
2364
2365    // ── Lists ──────────────────────────────────────────────────
2366
2367    fn compile_list(&mut self, list: &ast::List) -> Result<(), CompileError> {
2368        let items: Vec<_> = list.items().collect();
2369        let count = u16::try_from(items.len())
2370            .map_err(|_| CompileError::Unsupported("list too large".to_string()))?;
2371        for item in &items {
2372            self.compile_expr(item)?;
2373        }
2374        self.emit(OpCode::MakeList);
2375        self.emit_u16(count);
2376        // MakeList pops count elements, pushes 1 list.
2377        self.stack_depth = self.stack_depth.saturating_sub(count) + 1;
2378        Ok(())
2379    }
2380
2381    // ── Emission helpers ───────────────────────────────────────
2382
2383    fn emit(&mut self, op: OpCode) {
2384        self.chunk.write_op(op, self.current_line);
2385        // Track stack depth for correct local-variable slot assignment.
2386        match op {
2387            // Push one value
2388            OpCode::Null | OpCode::True | OpCode::False
2389            | OpCode::GetLocal | OpCode::GetUpvalue
2390            | OpCode::PushBuiltins | OpCode::LookupWith => {
2391                self.stack_depth += 1;
2392            }
2393            // Dup: push a copy of TOS (net +1)
2394            OpCode::Dup => {
2395                self.stack_depth += 1;
2396            }
2397            // Pop one value
2398            OpCode::Pop | OpCode::PushWith
2399            | OpCode::Assert | OpCode::Throw | OpCode::Return => {
2400                self.stack_depth = self.stack_depth.saturating_sub(1);
2401            }
2402            // Pop 2, push 1 (net -1)
2403            OpCode::Add | OpCode::Sub | OpCode::Mul | OpCode::Div
2404            | OpCode::Equal | OpCode::NotEqual | OpCode::Less
2405            | OpCode::Greater | OpCode::LessEqual | OpCode::GreaterEqual
2406            | OpCode::And | OpCode::Or | OpCode::Implication
2407            | OpCode::Concat | OpCode::UpdateAttrs
2408            | OpCode::Call | OpCode::TailCall | OpCode::DynGetAttr | OpCode::DynHasAttr => {
2409                self.stack_depth = self.stack_depth.saturating_sub(1);
2410            }
2411            // Pop 1, push 1 (net 0)
2412            OpCode::Negate | OpCode::Not | OpCode::Force
2413            | OpCode::GetAttr | OpCode::HasAttr
2414            | OpCode::Import => {}
2415            // SetLocal: no stack change (writes to slot)
2416            OpCode::SetLocal | OpCode::SetUpvalue => {}
2417            // PopWith: removes from with-scope stack, not value stack
2418            OpCode::PopWith => {}
2419            // Jump: no stack change
2420            OpCode::Jump => {}
2421            // JumpIfFalse/JumpIfTrue: pop condition
2422            OpCode::JumpIfFalse | OpCode::JumpIfTrue => {
2423                self.stack_depth = self.stack_depth.saturating_sub(1);
2424            }
2425            // SelectOrDefault: pop 2 (default + attrset), push 1 (net -1)
2426            OpCode::SelectOrDefault => {
2427                self.stack_depth = self.stack_depth.saturating_sub(1);
2428            }
2429            // DynSelectOrDefault: pop 3 (default + key + attrset), push 1 (net -2)
2430            OpCode::DynSelectOrDefault => {
2431                self.stack_depth = self.stack_depth.saturating_sub(2);
2432            }
2433            // GetLocalAttr: push 1 (fused GetLocal+GetAttr: push local, get attr = net +1)
2434            OpCode::GetLocalAttr => {
2435                self.stack_depth += 1;
2436            }
2437            // GetLocalCall: pop 1 arg, get local, call (push local then pop 2 push 1 = net -1 from the arg)
2438            OpCode::GetLocalCall => {
2439                self.stack_depth = self.stack_depth.saturating_sub(1);
2440            }
2441            // CallBuiltin: handled in emit_u16 for arg count
2442            OpCode::CallBuiltin => {
2443                self.stack_depth = self.stack_depth.saturating_sub(1);
2444            }
2445            // Complex opcodes with inline operands: handled by callers
2446            // MakeAttrs: pops 2*count, pushes 1 (handled by caller)
2447            // MakeList: pops count, pushes 1 (handled by caller)
2448            // MakeClosure: pushes 1 (handled by caller)
2449            // MakeThunk: pushes 1 (handled by caller)
2450            // Interpolate: pops count, pushes 1 (handled by caller)
2451            // PatchThunkUpvalues: no stack change
2452            OpCode::Constant | OpCode::MakeAttrs | OpCode::MakeList
2453            | OpCode::MakeClosure | OpCode::MakeThunk | OpCode::MakeLazyThunk
2454            | OpCode::Interpolate | OpCode::PatchThunkUpvalues => {}
2455        }
2456    }
2457
2458
2459    fn emit_u16(&mut self, value: u16) {
2460        self.chunk.write_u16(value, self.current_line);
2461    }
2462
2463    fn emit_constant(&mut self, value: VMValue) -> Result<(), CompileError> {
2464        let idx = self.chunk.add_constant(value)?;
2465        self.emit(OpCode::Constant);
2466        self.stack_depth += 1; // Constant pushes one value
2467        self.emit_u16(idx);
2468        Ok(())
2469    }
2470
2471    /// Add a string constant for an attribute key and pre-intern its symbol.
2472    ///
2473    /// The pre-interned symbol is stored in `chunk.key_symbols` so the VM
2474    /// can skip the `intern()` call on every `GetAttr`/`HasAttr` dispatch.
2475    fn add_attr_key(&mut self, key: String) -> Result<u16, CompileError> {
2476        let sym = self.interner.borrow_mut().intern(&key);
2477        self.chunk.add_key_constant(VMValue::String(key), sym)
2478    }
2479
2480    /// Emit a jump instruction with a placeholder target.
2481    /// Returns the offset of the placeholder (to be patched later).
2482    fn emit_jump(&mut self, op: OpCode) -> usize {
2483        self.emit(op);
2484        let offset = self.chunk.len();
2485        self.emit_u16(0xFFFF); // placeholder
2486        offset
2487    }
2488
2489    /// Patch a previously emitted jump to point to the current position.
2490    fn patch_jump(&mut self, placeholder_offset: usize) -> Result<(), CompileError> {
2491        let target = self.chunk.len();
2492        let target_u16 = u16::try_from(target).map_err(|_| CompileError::JumpOverflow)?;
2493        self.chunk.patch_u16(placeholder_offset, target_u16);
2494        Ok(())
2495    }
2496
2497    // ── Scope management ───────────────────────────────────────
2498
2499    fn begin_scope(&mut self) {
2500        self.scope_depth += 1;
2501    }
2502
2503    fn end_scope(&mut self, binding_count: u16) {
2504        // We need to preserve the top-of-stack (the body result) and
2505        // remove the local variable slots below it. Strategy:
2506        // Store the result in a temporary position, pop locals, restore.
2507        // Since we know exactly how many locals to pop, we emit Pop
2508        // instructions after moving the result.
2509        //
2510        // The value stack looks like: [... locals... body_result]
2511        // We need to get it to: [... body_result]
2512        //
2513        // We use SetLocal to the first local's slot to stash the body result,
2514        // then pop the remaining locals, then the stashed value is in the right place.
2515        //
2516        // Actually, a simpler approach: we know the body result is on top.
2517        // We pop N locals from under it. Since we can't do that directly,
2518        // we use a series of operations:
2519        // For N locals to pop, we need to move the result down.
2520        // The most straightforward: use a "swap-and-pop" sequence.
2521        //
2522        // Simplest correct approach for now: emit Pop for each local
2523        // *under* the result. We do this by emitting SetLocal to slot 0
2524        // of the scope (to stash the result), popping N-1, then GetLocal 0.
2525        // Actually that clobbers the first local.
2526        //
2527        // Even simpler: the VM can interpret end_scope specially, or we
2528        // can stash in a way that doesn't conflict. For Phase 1, since
2529        // the VM knows the locals, we'll use a direct approach:
2530        //
2531        // The result is on the stack top. Below it are `binding_count` locals.
2532        // We want to discard those locals but keep the result.
2533        // Emit: for each local (except we preserve the result on top),
2534        // we swap the result down and pop the old top.
2535        //
2536        // But we don't have a Swap opcode. Let's just do:
2537        // 1. The locals were at known stack positions.
2538        // 2. The body result is above them.
2539        // 3. After removing all locals from self.locals, the VM Pop
2540        //    instructions will maintain the stack.
2541        //
2542        // For correctness: we need the body result on top and locals gone.
2543        // Plan: emit nothing for the locals themselves (they'll be implicitly
2544        // dead). Instead, note: the VM stack still has them. We need to
2545        // actually remove them.
2546        //
2547        // Correct plan for Phase 1:
2548        // The stack is: [... (locals) (body_result)]
2549        // We need: [... (body_result)]
2550        // We can store body_result into the first local's slot,
2551        // then pop (binding_count - 1) times, and the first local slot
2552        // now holds the result.
2553        //
2554        // Wait, we need to be more careful. The locals are at specific
2555        // absolute positions. After the body result, the stack is:
2556        //
2557        // stack_base + 0: local_0
2558        // stack_base + 1: local_1
2559        // ...
2560        // stack_base + N-1: local_N-1
2561        // stack_base + N: body_result  <-- top
2562        //
2563        // We want the stack to be: [... body_result] at stack_base.
2564        // So: set slot (stack_base + 0) = body_result, then pop N times.
2565        // That gives us: [body_result] at stack_base. But we popped N,
2566        // and there are N+1 entries (N locals + result), so we pop N items
2567        // leaving 1.
2568        //
2569        // Hmm, SetLocal doesn't pop. It just writes. So after SetLocal(base+0),
2570        // the stack is: [result local_1 ... local_N-1 body_result]
2571        // Then pop N times: [result]
2572        // Perfect.
2573
2574        if binding_count > 0 {
2575            // Use the first local's actual stack slot (not locals vector index)
2576            // to correctly handle cases where anonymous values sit on the
2577            // stack between the frame base and the scope's locals.
2578            let first_local_idx = self.locals.len() - binding_count as usize;
2579            let base_slot = self.locals[first_local_idx].slot;
2580            self.emit(OpCode::SetLocal);
2581            self.emit_u16(base_slot);
2582            for _ in 0..binding_count {
2583                self.emit(OpCode::Pop);
2584            }
2585            // Update stack_depth: we removed binding_count stack entries
2586            // but the body result now sits at base_slot.
2587            self.stack_depth = base_slot + 1;
2588        }
2589
2590        // Remove locals from the compiler's tracking.
2591        while let Some(local) = self.locals.last() {
2592            if local.depth < self.scope_depth {
2593                break;
2594            }
2595            self.locals.pop();
2596        }
2597        self.scope_depth -= 1;
2598    }
2599
2600    /// Add a local variable to the current scope. Returns its stack slot.
2601    fn add_local(&mut self, name: String) -> Result<u16, CompileError> {
2602        if self.locals.len() >= u16::MAX as usize {
2603            return Err(CompileError::TooManyLocals);
2604        }
2605        // The local's stack slot is the current stack_depth minus 1,
2606        // because the value (e.g. Null placeholder) was already pushed
2607        // onto the stack before add_local is called.
2608        let slot = self.stack_depth - 1;
2609        self.locals.push(Local {
2610            name,
2611            depth: self.scope_depth,
2612            is_captured: false,
2613            slot,
2614        });
2615        Ok(slot)
2616    }
2617
2618    /// Resolve a local variable by name, returning its stack slot index.
2619    /// Searches from innermost scope outward.
2620    fn resolve_local(&self, name: &str) -> Option<u16> {
2621        for (i, local) in self.locals.iter().enumerate().rev() {
2622            if local.name == name && local.depth != u32::MAX {
2623                return Some(i as u16);
2624            }
2625        }
2626        None
2627    }
2628
2629    /// Get the actual VM stack slot for a local at the given locals-vector index.
2630    fn local_stack_slot(&self, locals_idx: u16) -> u16 {
2631        self.locals[locals_idx as usize].slot
2632    }
2633
2634    /// Find the VM stack slot of a local by name (must exist).
2635    /// Returns the actual stack position (relative to frame base),
2636    /// which may differ from the locals-vector index.
2637    fn find_local_slot(&self, name: &str) -> u16 {
2638        let idx = self.resolve_local(name)
2639            .unwrap_or_else(|| panic!("local '{name}' not found"));
2640        self.locals[idx as usize].slot
2641    }
2642
2643    /// Find the VM stack slot of a local by name, returning `None` if not found.
2644    fn find_local_slot_opt(&self, name: &str) -> Option<u16> {
2645        self.resolve_local(name)
2646            .map(|idx| self.locals[idx as usize].slot)
2647    }
2648
2649    /// Add an upvalue to this compiler's upvalue list.
2650    /// Returns the upvalue index. Deduplicates: if the same upvalue
2651    /// (same is_local + index) already exists, returns its index.
2652    fn add_upvalue(&mut self, is_local: bool, index: u16) -> Result<u8, CompileError> {
2653        // Check for existing identical upvalue.
2654        for (i, uv) in self.upvalues.iter().enumerate() {
2655            if uv.is_local == is_local && uv.index == index {
2656                return Ok(i as u8);
2657            }
2658        }
2659        if self.upvalues.len() >= 256 {
2660            return Err(CompileError::Unsupported("too many upvalues (max 256)".to_string()));
2661        }
2662        let idx = self.upvalues.len() as u8;
2663        self.upvalues.push(UpvalueDesc { is_local, index });
2664        Ok(idx)
2665    }
2666
2667    /// Resolve a variable as an upvalue by walking the enclosing compiler chain.
2668    /// Uses Lua 5.x-style upvalue resolution: if the variable is a local in
2669    /// the enclosing scope, capture it directly. If it's an upvalue in the
2670    /// enclosing scope, capture that upvalue.
2671    fn resolve_upvalue(&mut self, name: &str) -> Option<u8> {
2672        let enclosing_ptr = self.enclosing?;
2673        // SAFETY: The enclosing compiler is on the stack and outlives this call.
2674        // We only use raw pointers to avoid Rust's borrow checker issues with
2675        // the recursive compiler hierarchy, which is purely compile-time.
2676        let enclosing = unsafe { &mut *enclosing_ptr };
2677
2678        // Try to find as a local in the enclosing scope.
2679        if let Some(local_idx) = enclosing.resolve_local(name) {
2680            enclosing.locals[local_idx as usize].is_captured = true;
2681            // Store the actual stack slot (not locals index) for the VM.
2682            let stack_slot = enclosing.locals[local_idx as usize].slot;
2683            return Some(self.add_upvalue(true, stack_slot).ok()?);
2684        }
2685
2686        // Try to find as an upvalue in the enclosing scope (recursive).
2687        if let Some(uv_idx) = enclosing.resolve_upvalue(name) {
2688            return Some(self.add_upvalue(false, uv_idx as u16).ok()?);
2689        }
2690
2691        // No need to propagate with_depth here — has_with_scope()
2692        // in compile_ident already walks the enclosing chain to find
2693        // with-scopes transitively. Setting with_depth as a side effect
2694        // would poison all subsequent identifier lookups in this compiler,
2695        // causing names that should be upvalues to be emitted as LookupWith.
2696        None
2697    }
2698
2699    /// Check if this compiler or any enclosing compiler has an active with-scope.
2700    fn has_with_scope(&self) -> bool {
2701        if self.with_depth > 0 {
2702            return true;
2703        }
2704        if let Some(enclosing_ptr) = self.enclosing {
2705            let enclosing = unsafe { &*enclosing_ptr };
2706            return enclosing.has_with_scope();
2707        }
2708        false
2709    }
2710
2711    /// Resolve a relative path against the base directory.
2712    /// Walks the enclosing compiler chain to find a base_dir.
2713    fn resolve_relative_path(&self, rel_path: &str) -> String {
2714        if let Some(ref base) = self.base_dir {
2715            return base.join(rel_path).to_string_lossy().to_string();
2716        }
2717        if let Some(enclosing_ptr) = self.enclosing {
2718            let enclosing = unsafe { &*enclosing_ptr };
2719            return enclosing.resolve_relative_path(rel_path);
2720        }
2721        rel_path.to_string()
2722    }
2723}
2724
2725// ── Helper functions ───────────────────────────────────────────
2726
2727/// Extract the text of an ident node.
2728fn ident_text(ident: &ast::Ident) -> String {
2729    ident
2730        .ident_token()
2731        .map(|t| t.text().to_string())
2732        .unwrap_or_default()
2733}
2734
2735/// Extract a static attribute name (identifier or plain string literal).
2736/// Rejects dynamic/interpolated keys.
2737fn static_attr_name(attr: &ast::Attr) -> Result<String, CompileError> {
2738    match attr {
2739        ast::Attr::Ident(ident) => Ok(ident_text(ident)),
2740        ast::Attr::Str(s) => {
2741            // Handle plain string keys like { "key-with-dashes" = value; }
2742            let parts: Vec<_> = s.normalized_parts().into_iter().collect();
2743            if parts.len() == 1 {
2744                if let InterpolPart::Literal(text) = &parts[0] {
2745                    return Ok(text.to_string());
2746                }
2747            }
2748            Err(CompileError::Unsupported(
2749                "interpolated string attribute keys".to_string(),
2750            ))
2751        }
2752        ast::Attr::Dynamic(_) => Err(CompileError::Unsupported(
2753            "dynamic attribute keys".to_string(),
2754        )),
2755    }
2756}
2757
2758/// Check if a name is a Nix global builtin (available without `builtins.` prefix).
2759///
2760/// ★ THIS LIST IS MEASURED, NOT REMEMBERED — and it is deliberately SHORT.
2761///
2762/// It is consulted at step 4 of `compile_ident`, i.e. ABOVE the `with`-scope
2763/// lookup at step 5. That ordering is correct — in CppNix the base environment
2764/// is the outermost LEXICAL scope, and `with` is only consulted when a name
2765/// fails to resolve lexically — which means every name listed here SHADOWS a
2766/// `with`. So a name that is NOT actually global must not appear, or the VM
2767/// silently answers with its own builtin where nix answers with the `with`.
2768///
2769/// This list previously carried 49 names against nix's real 23. Measured
2770/// 2026-08-17 against nix 2.31.5, one `nix eval --impure --expr '<name>'`
2771/// probe per attribute of `builtins.attrNames builtins` (118 names): exactly
2772/// 23 resolve in the global scope, the other 95 raise `undefined variable`.
2773/// `true` / `false` / `null` are three of the 23 and are handled earlier in
2774/// `compile_ident` as literals; `builtins` is a fourth and is handled at step
2775/// 3 — leaving the 19 below.
2776///
2777/// Two divergence shapes the 30 dropped names caused, both silent:
2778///
2779/// ```text
2780///   with { isFunction = x: "LIB"; }; isFunction 1  nix/walker "LIB"  VM false
2781///   with { typeOf     = x: "LIB"; }; typeOf 1      nix/walker "LIB"  VM "int"
2782/// ```
2783///
2784/// This is nixpkgs-shaped: `with lib;` is everywhere, and `lib.isFunction` /
2785/// `lib.functionArgs` are functor-aware REDEFINITIONS of the same-named
2786/// builtins. Second order: nix ERRORS on a bare `typeOf`, so the VM answering
2787/// it swallowed a genuine undefined-variable bug.
2788///
2789/// To re-measure: `nix eval --impure --expr '<name>'` for each name; exit 0
2790/// means global, `undefined variable` means not.
2791/// Names Nix resolves as bare identifiers, and which therefore may NOT be
2792/// shadowed by a `with`.
2793///
2794/// This used to be a hand-written `matches!` of 19 names — one of THREE
2795/// hand-maintained copies, which had already drifted: this list carried
2796/// `break` and the tree-walker's and `sui-ir`'s did not, so
2797/// `with { break = "LIB"; }; break` evaluated to `"LIB"` on the walker while
2798/// nix and this engine both say `false`. Measured against nix 2.31.5,
2799/// `break` is a real global (`builtins.typeOf break` → `lambda`), so this
2800/// engine was right and the other two were wrong.
2801///
2802/// `true`/`false`/`null` and `builtins` are deliberately absent: they are
2803/// [`sui_compat::scope::STRUCTURAL_GLOBALS`], handled earlier in
2804/// `compile_ident` as literals and as the attrset itself, which is why this
2805/// predicate covers 19 names where the walker's scope list covers 21.
2806fn is_global_builtin(name: &str) -> bool {
2807    sui_compat::scope::CALLABLE_GLOBALS.contains(&name)
2808}
2809
2810/// Get the source line number for an expression (approximate).
2811fn line_of(expr: &ast::Expr) -> u32 {
2812    // rnix doesn't directly expose line numbers; use the text offset
2813    // as an approximation. A real implementation would map offset→line.
2814    let offset = AstNode::syntax(expr).text_range().start();
2815    // Use offset as a rough line proxy.
2816    u32::from(offset)
2817}
2818
2819/// Detect trivial self-referential cycles in let/rec bindings.
2820///
2821/// Checks whether any binding `name = name;` directly references itself
2822/// via a bare identifier. This is always an infinite recursion in `rec`
2823/// blocks and usually one in `let` blocks (since the binding shadows
2824/// any outer definition of the same name).
2825///
2826/// Returns a list of warning messages for each detected cycle.
2827fn detect_trivial_cycles(bindings: &[(String, &ast::Expr)]) -> Vec<String> {
2828    let mut warnings = Vec::new();
2829    for (name, expr) in bindings {
2830        if let ast::Expr::Ident(id) = expr {
2831            if id
2832                .ident_token()
2833                .map(|t| t.text() == name.as_str())
2834                .unwrap_or(false)
2835            {
2836                warnings.push(format!("warning: `{name}` directly references itself"));
2837            }
2838        }
2839    }
2840    warnings
2841}
2842
2843/// Parse a `NIX_PATH` env var value into `(prefix, path)` pairs.
2844///
2845/// The format is `prefix1=path1:prefix2=path2:...`. An entry with
2846/// no `=` is treated as having an empty prefix (CppNix-compatible).
2847/// Empty entries are skipped.
2848fn parse_nix_path(s: &str) -> Vec<(String, String)> {
2849    if s.is_empty() {
2850        return Vec::new();
2851    }
2852    s.split(':')
2853        .filter(|e| !e.is_empty())
2854        .map(|entry| match entry.split_once('=') {
2855            Some((prefix, path)) => (prefix.to_string(), path.to_string()),
2856            None => (String::new(), entry.to_string()),
2857        })
2858        .collect()
2859}
2860
2861/// Resolve a `<name>` search-path token to an absolute filesystem
2862/// path by walking the entries parsed from `NIX_PATH`.
2863fn resolve_search_path(name: &str) -> Option<String> {
2864    let nix_path = std::env::var("NIX_PATH").ok()?;
2865    for (prefix, path) in parse_nix_path(&nix_path) {
2866        if !prefix.is_empty() && name == prefix {
2867            if std::path::Path::new(&path).exists() {
2868                return Some(path);
2869            }
2870            continue;
2871        }
2872        if !prefix.is_empty() {
2873            let needle = format!("{prefix}/");
2874            if let Some(rest) = name.strip_prefix(&needle) {
2875                let full = format!("{path}/{rest}");
2876                if std::path::Path::new(&full).exists() {
2877                    return Some(full);
2878                }
2879                continue;
2880            }
2881        }
2882        if prefix.is_empty() {
2883            let full = format!("{path}/{name}");
2884            if std::path::Path::new(&full).exists() {
2885                return Some(full);
2886            }
2887        }
2888    }
2889    None
2890}
2891
2892#[cfg(test)]
2893mod tests {
2894    use super::*;
2895
2896    fn compile(input: &str) -> Chunk {
2897        let (chunk, _interner) =
2898            Compiler::compile(input).unwrap_or_else(|e| panic!("compile failed for '{input}': {e}"));
2899        chunk
2900    }
2901
2902    /// ★ A duplicate dotted path must return a typed error, NOT panic.
2903    ///
2904    /// `{ a.b = 1; a.b = 2; }` recursed until the remaining path was empty and
2905    /// then indexed `path[0]`:
2906    ///
2907    /// ```text
2908    /// thread 'sui-vm-eval' panicked at compiler.rs:1616:32:
2909    /// index out of bounds: the len is 0 but the index is 0
2910    /// ```
2911    ///
2912    /// The panic fired on the VM's own thread, where the CLI's
2913    /// whole-expression fallback caught the dead thread and returned the
2914    /// tree-walker's answer with **exit 0** — so a compiler crash presented as
2915    /// a clean success and was reachable from a five-token expression. Only
2916    /// `SUI_VM_STRICT=1` exposed it. That is why this is a test and not just a
2917    /// bounds fix: the failure mode was indistinguishable from working.
2918    ///
2919    /// CppNix rejects this input outright (`attribute 'a.b' already defined`),
2920    /// so refusing to compile it is the correct interim behaviour until the
2921    /// AST normalizer rejects it at parse.
2922    #[test]
2923    fn duplicate_dotted_path_errors_instead_of_panicking() {
2924        for src in [
2925            "{ a.b = 1; a.b = 2; }",
2926            "{ a.b.c = 1; a.b.c = 2; }",
2927            "{ a.b.c.d = 1; a.b.c.d = 2; }",
2928        ] {
2929            let err = Compiler::compile(src)
2930                .err()
2931                .unwrap_or_else(|| panic!("{src} compiled; it must be refused, not accepted"));
2932            let msg = err.to_string();
2933            assert!(
2934                msg.contains("defined more than once"),
2935                "{src}: expected a duplicate-attribute refusal, got: {msg}"
2936            );
2937        }
2938    }
2939
2940    /// CALIBRATION for the row above. Legal nested paths — including a merge
2941    /// of a dotted path with a sibling — must STILL compile. A "fix" that
2942    /// rejected any repeated first component would satisfy the test above
2943    /// while breaking ordinary nix.
2944    #[test]
2945    fn legal_nested_paths_still_compile() {
2946        for src in [
2947            "{ a.b = 1; a.c = 2; }",
2948            "{ a.b.c = 1; a.b.d = 2; }",
2949            "{ a.b = 1; a = { c = 2; }; }",
2950            "{ x.y.z = 1; }",
2951            "{ a = { b = 1; }; }",
2952        ] {
2953            assert!(
2954                Compiler::compile(src).is_ok(),
2955                "{src} must still compile — it is legal nix"
2956            );
2957        }
2958    }
2959
2960    #[test]
2961    fn compile_integer() {
2962        let chunk = compile("42");
2963        assert!(!chunk.code.is_empty());
2964        assert_eq!(chunk.constants.len(), 1);
2965        assert_eq!(chunk.constants[0], VMValue::Int(42));
2966    }
2967
2968    #[test]
2969    fn compile_float() {
2970        let chunk = compile("3.14");
2971        assert_eq!(chunk.constants[0], VMValue::Float(3.14));
2972    }
2973
2974    #[test]
2975    fn compile_bool_true() {
2976        let chunk = compile("true");
2977        // Constant-folded: true becomes Constant(Bool(true)), Return.
2978        assert_eq!(chunk.code[0], OpCode::Constant as u8);
2979        assert_eq!(chunk.constants[0], VMValue::Bool(true));
2980    }
2981
2982    #[test]
2983    fn compile_bool_false() {
2984        let chunk = compile("false");
2985        // Constant-folded: false becomes Constant(Bool(false)), Return.
2986        assert_eq!(chunk.code[0], OpCode::Constant as u8);
2987        assert_eq!(chunk.constants[0], VMValue::Bool(false));
2988    }
2989
2990    #[test]
2991    fn compile_null() {
2992        let chunk = compile("null");
2993        // Constant-folded: null becomes Constant(Null), Return.
2994        assert_eq!(chunk.code[0], OpCode::Constant as u8);
2995        assert_eq!(chunk.constants[0], VMValue::Null);
2996    }
2997
2998    #[test]
2999    fn compile_string() {
3000        let chunk = compile(r#""hello""#);
3001        assert_eq!(chunk.constants[0], VMValue::String("hello".to_string()));
3002    }
3003
3004    #[test]
3005    fn compile_addition() {
3006        let chunk = compile("1 + 2");
3007        // Constant-folded: 1 + 2 becomes Constant(3), Return.
3008        assert_eq!(chunk.constants[0], VMValue::Int(3));
3009        assert!(!chunk.code.contains(&(OpCode::Add as u8)));
3010    }
3011
3012    #[test]
3013    fn compile_addition_non_foldable() {
3014        // When variables are involved, no folding occurs.
3015        let chunk = compile("let x = 1; in x + 2");
3016        assert!(chunk.code.contains(&(OpCode::Add as u8)));
3017    }
3018
3019    #[test]
3020    fn compile_if_else() {
3021        let chunk = compile("if true then 1 else 2");
3022        // Constant-folded: `if true then 1 else 2` becomes Constant(1), Return.
3023        assert_eq!(chunk.constants[0], VMValue::Int(1));
3024        assert!(!chunk.code.contains(&(OpCode::JumpIfFalse as u8)));
3025    }
3026
3027    #[test]
3028    fn compile_if_else_non_foldable() {
3029        // When condition is not constant, no folding occurs.
3030        let chunk = compile("let b = true; in if b then 1 else 2");
3031        assert!(chunk.code.contains(&(OpCode::JumpIfFalse as u8)));
3032    }
3033
3034    #[test]
3035    fn compile_list() {
3036        let chunk = compile("[1 2 3]");
3037        assert!(chunk.code.contains(&(OpCode::MakeList as u8)));
3038    }
3039
3040    #[test]
3041    fn compile_attrset() {
3042        let chunk = compile("{ a = 1; b = 2; }");
3043        assert!(chunk.code.contains(&(OpCode::MakeAttrs as u8)));
3044    }
3045
3046    #[test]
3047    fn compile_select() {
3048        let chunk = compile("{ a = 1; }.a");
3049        assert!(chunk.code.contains(&(OpCode::GetAttr as u8)));
3050    }
3051
3052    #[test]
3053    fn compile_lambda() {
3054        let chunk = compile("x: x + 1");
3055        // The lambda body is stored as a closure constant.
3056        assert!(chunk.constants.iter().any(|c| matches!(c, VMValue::Closure(_))));
3057    }
3058
3059    #[test]
3060    fn compile_negate() {
3061        let chunk = compile("-42");
3062        // Constant-folded: -42 becomes Constant(Int(-42)), Return.
3063        assert_eq!(chunk.constants[0], VMValue::Int(-42));
3064        assert!(!chunk.code.contains(&(OpCode::Negate as u8)));
3065    }
3066
3067    #[test]
3068    fn compile_negate_non_foldable() {
3069        let chunk = compile("let x = 42; in -x");
3070        assert!(chunk.code.contains(&(OpCode::Negate as u8)));
3071    }
3072
3073    #[test]
3074    fn compile_not() {
3075        let chunk = compile("!true");
3076        // Constant-folded: !true becomes Constant(Bool(false)), Return.
3077        assert_eq!(chunk.constants[0], VMValue::Bool(false));
3078        assert!(!chunk.code.contains(&(OpCode::Not as u8)));
3079    }
3080
3081    #[test]
3082    fn compile_assert() {
3083        let chunk = compile("assert true; 42");
3084        assert!(chunk.code.contains(&(OpCode::Assert as u8)));
3085    }
3086
3087    #[test]
3088    fn compile_let_in() {
3089        let chunk = compile("let x = 1; y = 2; in x + y");
3090        assert!(chunk.code.contains(&(OpCode::GetLocal as u8)));
3091    }
3092
3093    #[test]
3094    fn compile_parse_error() {
3095        let result = Compiler::compile("let in");
3096        assert!(result.is_err());
3097    }
3098
3099    #[test]
3100    fn compile_comparison() {
3101        let chunk = compile("1 < 2");
3102        // Constant-folded.
3103        assert_eq!(chunk.constants[0], VMValue::Bool(true));
3104    }
3105
3106    #[test]
3107    fn compile_equality() {
3108        let chunk = compile("1 == 1");
3109        // Constant-folded.
3110        assert_eq!(chunk.constants[0], VMValue::Bool(true));
3111    }
3112
3113    #[test]
3114    fn compile_update_attrs() {
3115        let chunk = compile("{ a = 1; } // { b = 2; }");
3116        assert!(chunk.code.contains(&(OpCode::UpdateAttrs as u8)));
3117    }
3118
3119    #[test]
3120    fn compile_list_concat() {
3121        let chunk = compile("[1] ++ [2]");
3122        assert!(chunk.code.contains(&(OpCode::Concat as u8)));
3123    }
3124
3125    #[test]
3126    fn compile_and_short_circuit() {
3127        let chunk = compile("true && false");
3128        // Constant-folded.
3129        assert_eq!(chunk.constants[0], VMValue::Bool(false));
3130    }
3131
3132    #[test]
3133    fn compile_and_short_circuit_non_foldable() {
3134        let chunk = compile("let a = true; in a && false");
3135        assert!(chunk.code.contains(&(OpCode::JumpIfFalse as u8)));
3136    }
3137
3138    #[test]
3139    fn compile_or_short_circuit() {
3140        let chunk = compile("false || true");
3141        // Constant-folded.
3142        assert_eq!(chunk.constants[0], VMValue::Bool(true));
3143    }
3144
3145    #[test]
3146    fn compile_or_short_circuit_non_foldable() {
3147        let chunk = compile("let a = false; in a || true");
3148        assert!(chunk.code.contains(&(OpCode::JumpIfTrue as u8)));
3149    }
3150
3151    #[test]
3152    fn compile_has_attr() {
3153        let chunk = compile("{ a = 1; } ? a");
3154        assert!(chunk.code.contains(&(OpCode::HasAttr as u8)));
3155    }
3156
3157    #[test]
3158    fn compile_select_or_default() {
3159        // `or default` now uses jump-based control flow:
3160        // Dup + HasAttr + JumpIfFalse(miss) + GetAttr + Jump(end) + Pop + default
3161        let chunk = compile("{ a = 1; }.b or 0");
3162        assert!(chunk.code.contains(&(OpCode::Dup as u8)));
3163        assert!(chunk.code.contains(&(OpCode::HasAttr as u8)));
3164        assert!(chunk.code.contains(&(OpCode::JumpIfFalse as u8)));
3165        assert!(chunk.code.contains(&(OpCode::GetAttr as u8)));
3166    }
3167
3168    #[test]
3169    fn compile_dyn_select_or_default() {
3170        // Dynamic `or default` now uses jump-based control flow:
3171        // Dup + DynHasAttr + JumpIfFalse(miss) + DynGetAttr + Jump(end) + Pop + default
3172        let chunk = compile(r#"let x = "a"; in { a = 1; }.${ x } or 0"#);
3173        assert!(chunk.code.contains(&(OpCode::Dup as u8)));
3174        assert!(chunk.code.contains(&(OpCode::DynHasAttr as u8)));
3175        assert!(chunk.code.contains(&(OpCode::JumpIfFalse as u8)));
3176        // The hit path uses DynGetAttr to actually select the value.
3177        assert!(chunk.code.contains(&(OpCode::DynGetAttr as u8)));
3178    }
3179
3180    #[test]
3181    fn compile_multi_segment_select_or_default() {
3182        // `a.b.c or default` — all segments should use HasAttr+JumpIfFalse
3183        let chunk = compile("{ a = { b = 1; }; }.a.b.c or 0");
3184        // Each segment emits Dup + HasAttr + JumpIfFalse + GetAttr
3185        let has_attr_count = chunk.code.iter().filter(|&&b| b == OpCode::HasAttr as u8).count();
3186        assert!(has_attr_count >= 3, "expected >= 3 HasAttr ops for 3 segments, got {has_attr_count}");
3187    }
3188
3189    #[test]
3190    fn compile_pattern_lambda() {
3191        let chunk = compile("{ a, b }: a + b");
3192        assert!(chunk.constants.iter().any(|c| matches!(c, VMValue::Closure(_))));
3193    }
3194
3195    #[test]
3196    fn compile_string_interpolation() {
3197        let chunk = compile(r#"let x = "world"; in "hello ${x}""#);
3198        // Should contain Interpolate opcode.
3199        assert!(chunk.code.contains(&(OpCode::Interpolate as u8)));
3200    }
3201
3202    // ── Static cycle detection ──────────────────────────────
3203
3204    #[test]
3205    fn detect_trivial_self_reference() {
3206        let root = rnix::Root::parse("x");
3207        let expr = root.tree().expr().unwrap();
3208        let bindings = vec![("x".to_string(), &expr)];
3209        let warnings = detect_trivial_cycles(&bindings);
3210        assert_eq!(warnings.len(), 1);
3211        assert!(warnings[0].contains("directly references itself"));
3212    }
3213
3214    #[test]
3215    fn detect_no_false_positive() {
3216        let root = rnix::Root::parse("y");
3217        let expr = root.tree().expr().unwrap();
3218        let bindings = vec![("x".to_string(), &expr)];
3219        let warnings = detect_trivial_cycles(&bindings);
3220        assert!(warnings.is_empty());
3221    }
3222
3223    #[test]
3224    fn detect_non_ident_no_warning() {
3225        let root = rnix::Root::parse("1 + 2");
3226        let expr = root.tree().expr().unwrap();
3227        let bindings = vec![("x".to_string(), &expr)];
3228        let warnings = detect_trivial_cycles(&bindings);
3229        assert!(warnings.is_empty());
3230    }
3231
3232    #[test]
3233    fn detect_trivial_cycles_multiple() {
3234        let root_x = rnix::Root::parse("x");
3235        let expr_x = root_x.tree().expr().unwrap();
3236        let root_y = rnix::Root::parse("y");
3237        let expr_y = root_y.tree().expr().unwrap();
3238        let root_z = rnix::Root::parse("1");
3239        let expr_z = root_z.tree().expr().unwrap();
3240        let bindings = vec![
3241            ("x".to_string(), &expr_x),
3242            ("y".to_string(), &expr_y),
3243            ("z".to_string(), &expr_z),
3244        ];
3245        let warnings = detect_trivial_cycles(&bindings);
3246        assert_eq!(warnings.len(), 2);
3247    }
3248
3249    // -- PathSearch tests -----------------------------------------------
3250
3251    /// Serializes every test that touches `NIX_PATH`.
3252    ///
3253    /// ── ★ THE "SAFETY" COMMENT WAS THE BUG ────────────────────────────
3254    /// These tests carried `// SAFETY: test runs single-threaded; no
3255    /// concurrent env access` above their `set_var`. libtest runs tests in
3256    /// PARALLEL by default, so that justification was false and the three
3257    /// NIX_PATH tests raced each other: one would `remove_var` while another
3258    /// was mid-compile, and the loser saw either no NIX_PATH or the other's
3259    /// value. Measured on the full workspace run: 1 failing suite in 2,
3260    /// naming `path_search_compiles_with_matching_nix_path` and
3261    /// `path_search_with_sub_path`.
3262    ///
3263    /// An env var is process-global; the only fix is to make the access
3264    /// exclusive. This is the same shape as two other flakes found in this
3265    /// fleet today (a `HOME` override and a shared scratch-file path), which
3266    /// is why it is worth naming rather than just silencing.
3267    fn nix_path_lock() -> std::sync::MutexGuard<'static, ()> {
3268        static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3269        LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
3270    }
3271
3272    #[test]
3273    fn path_search_compiles_with_matching_nix_path() {
3274        let _nix_path = nix_path_lock();
3275        // Set NIX_PATH to a directory containing a target, then compile
3276        // a search-path expression.
3277        let dir = tempfile::tempdir().unwrap();
3278        let target = dir.path().join("mypkg");
3279        std::fs::create_dir(&target).unwrap();
3280        // Set NIX_PATH with prefix=path format.
3281        let nix_path_val = format!("mypkg={}", target.display());
3282        // SAFETY: `nix_path_lock` above makes this access exclusive.
3283        unsafe { std::env::set_var("NIX_PATH", &nix_path_val) };
3284        let result = Compiler::compile("<mypkg>");
3285        unsafe { std::env::remove_var("NIX_PATH") };
3286        assert!(result.is_ok(), "expected compile success, got: {result:?}");
3287        let (chunk, _) = result.unwrap();
3288        // The resolved path should be in the constant pool.
3289        assert!(
3290            chunk.constants.iter().any(|c| matches!(c, VMValue::Path(p) if p == &target.display().to_string())),
3291            "expected path constant for {:?}, got: {:?}",
3292            target.display(),
3293            chunk.constants,
3294        );
3295    }
3296
3297    #[test]
3298    fn path_search_fails_when_nix_path_no_match() {
3299        let _nix_path = nix_path_lock();
3300        // Set NIX_PATH to something that doesn't match.
3301        // SAFETY: `nix_path_lock` above makes this access exclusive.
3302        unsafe { std::env::set_var("NIX_PATH", "other=/nonexistent") };
3303        let result = Compiler::compile("<nosuchpkg>");
3304        unsafe { std::env::remove_var("NIX_PATH") };
3305
3306        // ── ★ AN UNRESOLVABLE SEARCH PATH IS DEFERRED, NOT A COMPILE ERROR ──
3307        // This asserted `is_err()`, which the compiler deliberately stopped
3308        // doing: an unresolvable `<…>` is now compiled to a THUNK that throws
3309        // when forced, "to match CppNix: unresolvable search paths are
3310        // deferred and caught by tryEval at force-time" (see the emit site).
3311        // The test pinned the behaviour the change was made to remove, so it
3312        // has failed ever since — invisibly, because a Linux-only compile
3313        // error in `build_levels` kept the test gate from ever running.
3314        //
3315        // Asserting `is_ok()` ALONE would be vacuous: it passes just as well
3316        // if the compiler silently resolved `<nosuchpkg>` to some wrong path.
3317        // So the deferral itself is what gets checked — a closure carrying the
3318        // throw message reaches the constant pool, exactly as the sibling test
3319        // above checks for a resolved `Path` constant.
3320        assert!(
3321            result.is_ok(),
3322            "an unresolvable search path is deferred to force-time, not a \
3323             compile error; got: {result:?}"
3324        );
3325        let (chunk, _) = result.unwrap();
3326        assert!(
3327            chunk.constants.iter().any(|c| matches!(c, VMValue::Closure(_))),
3328            "expected a deferred-throw closure in the constant pool, got: {:?}",
3329            chunk.constants,
3330        );
3331    }
3332
3333    #[test]
3334    fn path_search_with_sub_path() {
3335        let _nix_path = nix_path_lock();
3336        // Test `<nixpkgs/lib>` style — prefix match with sub-path.
3337        let dir = tempfile::tempdir().unwrap();
3338        let nixpkgs = dir.path().join("nixpkgs-src");
3339        let lib_dir = nixpkgs.join("lib");
3340        std::fs::create_dir_all(&lib_dir).unwrap();
3341        let nix_path_val = format!("nixpkgs={}", nixpkgs.display());
3342        // SAFETY: `nix_path_lock` above makes this access exclusive.
3343        unsafe { std::env::set_var("NIX_PATH", &nix_path_val) };
3344        let result = Compiler::compile("<nixpkgs/lib>");
3345        unsafe { std::env::remove_var("NIX_PATH") };
3346        assert!(result.is_ok(), "expected compile success for sub-path, got: {result:?}");
3347        let (chunk, _) = result.unwrap();
3348        let expected_path = lib_dir.display().to_string();
3349        assert!(
3350            chunk.constants.iter().any(|c| matches!(c, VMValue::Path(p) if p == &expected_path)),
3351            "expected path constant for {expected_path}, got: {:?}",
3352            chunk.constants,
3353        );
3354    }
3355
3356    // -- TailCall detection tests ---------------------------------------
3357
3358    #[test]
3359    fn lambda_body_apply_emits_tail_call() {
3360        // A call in the body of a lambda should emit TailCall.
3361        let chunk = compile("x: x 1");
3362        // The outer chunk contains a closure constant; the closure chunk
3363        // should contain TailCall.
3364        let closure_chunk = chunk
3365            .constants
3366            .iter()
3367            .find_map(|c| match c {
3368                VMValue::Closure(cl) => Some(&cl.chunk),
3369                _ => None,
3370            })
3371            .expect("expected a closure constant");
3372        assert!(
3373            closure_chunk.code.contains(&(OpCode::TailCall as u8)),
3374            "lambda body call should emit TailCall, bytecode: {:?}",
3375            closure_chunk.code,
3376        );
3377    }
3378
3379    #[test]
3380    fn if_then_apply_emits_tail_call() {
3381        // A call in the then-branch of an if in a lambda body should be TailCall.
3382        let chunk = compile("x: if true then x 1 else 0");
3383        let closure_chunk = chunk
3384            .constants
3385            .iter()
3386            .find_map(|c| match c {
3387                VMValue::Closure(cl) => Some(&cl.chunk),
3388                _ => None,
3389            })
3390            .expect("expected a closure constant");
3391        assert!(
3392            closure_chunk.code.contains(&(OpCode::TailCall as u8)),
3393            "if-then call should emit TailCall, bytecode: {:?}",
3394            closure_chunk.code,
3395        );
3396    }
3397
3398    #[test]
3399    fn if_else_apply_emits_tail_call() {
3400        // A call in the else-branch of an if in a lambda body should be TailCall.
3401        let chunk = compile("x: if false then 0 else x 1");
3402        let closure_chunk = chunk
3403            .constants
3404            .iter()
3405            .find_map(|c| match c {
3406                VMValue::Closure(cl) => Some(&cl.chunk),
3407                _ => None,
3408            })
3409            .expect("expected a closure constant");
3410        assert!(
3411            closure_chunk.code.contains(&(OpCode::TailCall as u8)),
3412            "if-else call should emit TailCall, bytecode: {:?}",
3413            closure_chunk.code,
3414        );
3415    }
3416
3417    #[test]
3418    fn non_tail_apply_emits_regular_call() {
3419        // A call that is NOT in tail position (e.g. argument to another
3420        // function) should emit Call, not TailCall.
3421        let chunk = compile("let f = x: x; in f (f 1)");
3422        // The top-level chunk should contain Call (for `f (f 1)`).
3423        // The inner `f 1` is an argument, not tail position.
3424        assert!(
3425            chunk.code.contains(&(OpCode::Call as u8))
3426                || chunk.code.contains(&(OpCode::GetLocalCall as u8)),
3427            "non-tail call should emit Call or GetLocalCall, bytecode: {:?}",
3428            chunk.code,
3429        );
3430    }
3431
3432    #[test]
3433    fn assert_body_apply_emits_tail_call() {
3434        // A call in the body of an assert inside a lambda should be TailCall.
3435        let chunk = compile("f: assert true; f 1");
3436        let closure_chunk = chunk
3437            .constants
3438            .iter()
3439            .find_map(|c| match c {
3440                VMValue::Closure(cl) => Some(&cl.chunk),
3441                _ => None,
3442            })
3443            .expect("expected a closure constant");
3444        assert!(
3445            closure_chunk.code.contains(&(OpCode::TailCall as u8)),
3446            "assert body call should emit TailCall, bytecode: {:?}",
3447            closure_chunk.code,
3448        );
3449    }
3450
3451    // -- Multi-segment HasAttr tests ------------------------------------
3452
3453    #[test]
3454    fn multi_segment_hasattr_compiles() {
3455        // `{ a.b = 1; } ? a` should compile and use HasAttr.
3456        let chunk = compile("{ a = { b = 1; }; } ? a");
3457        assert!(chunk.code.contains(&(OpCode::HasAttr as u8)));
3458    }
3459
3460    #[test]
3461    fn single_segment_hasattr_still_works() {
3462        // Single-segment ? should still work.
3463        let chunk = compile("{ x = 1; } ? x");
3464        assert!(chunk.code.contains(&(OpCode::HasAttr as u8)));
3465    }
3466
3467    #[test]
3468    fn multi_segment_hasattr_deep_path() {
3469        // `{ a = { b = 1; }; } ? a.b` — multi-segment hasattr should compile.
3470        let chunk = compile("{ a = { b = 1; }; } ? a.b");
3471        // Should contain HasAttr (used for each segment).
3472        assert!(chunk.code.contains(&(OpCode::HasAttr as u8)));
3473    }
3474}