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        // ★ A `let` is a RECURSIVE binder, so the plan is built with
657        // `recursive = true`. This is what gives `let` dotted bindings and
658        // duplicate-key merging, both of which the loop below simply refused
659        // or lost:
660        //
661        //   let a = {b=1;}; a = {c=2;}; in a    was {"c":2}   nix {"b":1,"c":2}
662        //   let a = {b=1;}; a.c = 2;    in a    was a hard `Unsupported("dotted
663        //                                       let bindings")` refusal
664        match sui_normalize::plan_for_group_total(letin, true) {
665            Ok(plan) if plan.dynamics.is_empty() => {
666                let body = letin
667                    .body()
668                    .ok_or_else(|| CompileError::MissingNode("let body".to_string()))?;
669                self.begin_scope();
670                let local_count = self.bind_plan_group_locals(&plan)?;
671                // The body's result lands on top of the local slots.
672                self.compile_expr(&body)?;
673                self.end_scope(local_count);
674                return Ok(());
675            }
676            // A dynamic key cannot name a local slot, and nix rejects
677            // `let ${k} = 1; in k` at parse time for exactly that reason. Left
678            // to the loop below, which refuses it — rather than dropped.
679            Ok(_) => {}
680            // A group nix itself rejects; see `compile_attrset`.
681            Err(_) => {}
682        }
683
684        self.begin_scope();
685
686        // Collect all binding names and value expressions first so we
687        // can allocate all local slots before compiling any values
688        // (enabling mutual references between let-bindings).
689        let mut bindings: Vec<(String, LetBinding)> = Vec::new();
690
691        for entry in letin.entries() {
692            match entry {
693                ast::Entry::AttrpathValue(ref apv) => {
694                    let attrpath = apv.attrpath().ok_or_else(|| {
695                        CompileError::MissingNode("binding attrpath".to_string())
696                    })?;
697                    let keys: Vec<_> = attrpath.attrs().collect();
698                    if keys.len() != 1 {
699                        return Err(CompileError::Unsupported(
700                            "dotted let bindings".to_string(),
701                        ));
702                    }
703                    let key = static_attr_name(&keys[0])?;
704                    let value_expr = apv.value().ok_or_else(|| {
705                        CompileError::MissingNode("binding value".to_string())
706                    })?;
707                    bindings.push((key, LetBinding::Value(value_expr)));
708                }
709                ast::Entry::Inherit(ref inherit) => {
710                    if let Some(from) = inherit.from() {
711                        let source_expr = from.expr().ok_or_else(|| {
712                            CompileError::MissingNode("inherit from expr".to_string())
713                        })?;
714                        for attr in inherit.attrs() {
715                            let name = static_attr_name(&attr)?;
716                            bindings.push((name.clone(), LetBinding::InheritFrom(source_expr.clone(), name)));
717                        }
718                    } else {
719                        for attr in inherit.attrs() {
720                            let name = static_attr_name(&attr)?;
721                            bindings.push((name, LetBinding::Inherit));
722                        }
723                    }
724                }
725            }
726        }
727
728        // Static cycle detection: check for `name = name;` patterns.
729        {
730            let pairs: Vec<(String, &ast::Expr)> = bindings
731                .iter()
732                .filter_map(|(name, binding)| match binding {
733                    LetBinding::Value(expr) => Some((name.clone(), expr as &ast::Expr)),
734                    _ => None,
735                })
736                .collect();
737            for warning in detect_trivial_cycles(&pairs) {
738                eprintln!("{warning}");
739            }
740        }
741
742        let binding_count = u16::try_from(bindings.len())
743            .map_err(|_| CompileError::TooManyLocals)?;
744
745        // Phase 1: Push Null placeholders and register local slots.
746        for (name, _) in &bindings {
747            self.emit(OpCode::Null); // emit() tracks stack_depth
748            self.add_local(name.clone())?;
749        }
750
751        // Phase 2: Compile each binding's value and store into its slot.
752        // Two-pass thunk approach for lazy let-bindings:
753        //   Pass A: Create thunks (0 upvalues), store in slots.
754        //   Pass B: Patch each thunk's upvalues (siblings now exist).
755        let mut thunk_slots: Vec<(u16, Vec<UpvalueDesc>)> = Vec::new();
756
757        for (name, binding) in &bindings {
758            let local_idx = self.resolve_local(name).unwrap();
759            let slot = self.locals[local_idx as usize].slot;
760            match binding {
761                LetBinding::Value(expr) => {
762                    // In let bindings (which are recursive in Nix), lambdas
763                    // must not be inlined as trivial — same issue as rec
764                    // attrsets: MakeClosure captures upvalues eagerly, but
765                    // sibling bindings (especially dotted) may not yet exist.
766                    if Self::is_trivial_value_for_rec(expr) {
767                        self.compile_expr(expr)?;
768                    } else {
769                        let uv_descs = self.compile_thunk_deferred(expr)?;
770                        if !uv_descs.is_empty() {
771                            thunk_slots.push((slot, uv_descs));
772                        }
773                    }
774                    self.emit(OpCode::SetLocal);
775                    self.emit_u16(slot);
776                    self.emit(OpCode::Pop);
777                }
778                LetBinding::Inherit => {
779                    // Temporarily hide this local so lookup finds the outer one.
780                    let saved_depth = self.locals[local_idx as usize].depth;
781                    self.locals[local_idx as usize].depth = u32::MAX;
782                    if let Some(outer_idx) = self.resolve_local(name) {
783                        self.emit(OpCode::GetLocal);
784                        self.emit_u16(self.local_stack_slot(outer_idx));
785                    } else if let Some(uv_idx) = self.resolve_upvalue(name) {
786                        self.emit(OpCode::GetUpvalue);
787                        self.emit_u16(uv_idx as u16);
788                    } else if self.has_with_scope() {
789                        let name_idx = self.chunk.add_constant(VMValue::String(name.clone()))?;
790                        self.emit(OpCode::LookupWith);
791                        self.emit_u16(name_idx);
792                    } else {
793                        self.locals[local_idx as usize].depth = saved_depth;
794                        return Err(CompileError::Unsupported(format!(
795                            "inherit: cannot resolve '{name}' in enclosing scope"
796                        )));
797                    }
798                    self.locals[local_idx as usize].depth = saved_depth;
799                    self.emit(OpCode::SetLocal);
800                    self.emit_u16(slot);
801                    self.emit(OpCode::Pop);
802                }
803                LetBinding::InheritFrom(source_expr, attr_name) => {
804                    // Wrap inherit-from in a thunk to avoid forcing the
805                    // source expression at let-binding time (critical for
806                    // fixpoint patterns like nixpkgs lib's inherit (lib.trivial)).
807                    let uv_descs = self.compile_inherit_from_thunk_deferred(source_expr, attr_name)?;
808                    if !uv_descs.is_empty() {
809                        thunk_slots.push((slot, uv_descs));
810                    }
811                    self.emit(OpCode::SetLocal);
812                    self.emit_u16(slot);
813                    self.emit(OpCode::Pop);
814                }
815            }
816        }
817
818        // Pass B: Patch thunk upvalues now that all siblings exist in slots.
819        for (slot, uv_descs) in &thunk_slots {
820            self.emit(OpCode::PatchThunkUpvalues);
821            self.emit_u16(*slot);
822            self.emit_u16(uv_descs.len() as u16);
823            for uv in uv_descs {
824                self.chunk.write_byte(if uv.is_local { 1 } else { 0 }, self.current_line);
825                self.emit_u16(uv.index);
826            }
827        }
828
829        // Compile the body expression. Its result lands on top of the
830        // local variable slots on the stack.
831        let body = letin
832            .body()
833            .ok_or_else(|| CompileError::MissingNode("let body".to_string()))?;
834        self.compile_expr(&body)?;
835
836        // Clean up: move the body result down past the locals, then pop them.
837        self.end_scope(binding_count);
838
839        Ok(())
840    }
841
842    /// Check if an expression is trivial (compile eagerly, no thunk needed).
843    fn is_trivial_value(expr: &ast::Expr) -> bool {
844        match expr {
845            ast::Expr::Literal(_) => true,
846            ast::Expr::Str(s) => {
847                for part in s.normalized_parts() {
848                    if !matches!(part, InterpolPart::Literal(_)) {
849                        return false;
850                    }
851                }
852                true
853            }
854            ast::Expr::Ident(id) => {
855                let name = ident_text(id);
856                matches!(name.as_str(), "true" | "false" | "null")
857            }
858            ast::Expr::Lambda(_) => true,
859            ast::Expr::Paren(p) => p.expr().map_or(false, |inner| Self::is_trivial_value(&inner)),
860            ast::Expr::List(list) => list.items().next().is_none(),
861            ast::Expr::AttrSet(set) => set.rec_token().is_none() && set.entries().next().is_none(),
862            _ => false,
863        }
864    }
865
866    /// Like `is_trivial_value`, but for use in rec attrsets.
867    /// Lambdas are NOT trivial in rec context because `MakeClosure` captures
868    /// upvalues at emission time.  If a lambda captures a sibling binding
869    /// (especially a dotted entry appended after non-dotted bindings), the
870    /// sibling's slot may still hold the null placeholder, producing a silent
871    /// wrong result.  Wrapping the lambda in a deferred thunk postpones
872    /// `MakeClosure` until the value is accessed, by which time all siblings
873    /// have been populated via `PatchThunkUpvalues`.
874    fn is_trivial_value_for_rec(expr: &ast::Expr) -> bool {
875        match expr {
876            // Lambdas can capture rec-scoped variables — never inline in rec.
877            ast::Expr::Lambda(_) => false,
878            ast::Expr::Paren(p) => p.expr().map_or(false, |inner| Self::is_trivial_value_for_rec(&inner)),
879            _ => Self::is_trivial_value(expr),
880        }
881    }
882
883    /// Compile a thunk with 0 upvalues (deferred patching via PatchThunkUpvalues).
884    /// The shared body of every deferred thunk: a child compiler parented to
885    /// this one, the `with`-scope preamble, `body`, the matching `PopWith`s, a
886    /// `Return`, and the `MakeThunk` whose upvalue count the caller patches
887    /// later via `PatchThunkUpvalues`.
888    ///
889    /// ★ Extracted because this preamble/epilogue was written out THREE times
890    /// verbatim (expression, `inherit (src) name`, nested attrset) and the plan
891    /// path needs a fourth. Three identical copies of an eight-line protocol
892    /// where the only difference is one middle line is a helper that was not
893    /// written; a fourth copy would be the point at which a `with`-scope fix
894    /// starts landing in three places out of four.
895    ///
896    /// `body` receives the CHILD compiler. Nothing it needs comes from `self`,
897    /// which is what makes the closure form work at all — the parent is
898    /// reachable from the child only through the raw `enclosing` pointer, and
899    /// that is set up here.
900    fn compile_deferred_thunk<F>(&mut self, body: F) -> Result<Vec<UpvalueDesc>, CompileError>
901    where
902        F: FnOnce(&mut Compiler) -> Result<(), CompileError>,
903    {
904        let mut tc = Compiler::with_interner(Rc::clone(&self.interner));
905        tc.scope_depth = 1;
906        tc.enclosing = Some(self as *mut Compiler);
907        tc.with_depth = 0;
908        tc.base_dir = self.base_dir.clone();
909        let with_count = self.emit_with_scope_preamble(&mut tc);
910        body(&mut tc)?;
911        for _ in 0..with_count {
912            tc.emit(OpCode::PopWith);
913        }
914        tc.emit(OpCode::Return);
915        let uv_descs: Vec<UpvalueDesc> = tc.upvalues.clone();
916        let closure = VMValue::Closure(VMClosure {
917            chunk: Rc::new(tc.chunk),
918            upvalues: Vec::new(),
919            arity: 0,
920            name: None,
921            formals: Vec::new(),
922        });
923        let idx = self.chunk.add_constant(closure)?;
924        self.emit(OpCode::MakeThunk);
925        self.stack_depth += 1; // MakeThunk pushes one thunk
926        self.emit_u16(idx);
927        self.emit_u16(0); // 0 upvalues, patched later
928        Ok(uv_descs)
929    }
930
931    fn compile_thunk_deferred(&mut self, expr: &ast::Expr) -> Result<Vec<UpvalueDesc>, CompileError> {
932        self.compile_deferred_thunk(|tc| tc.compile_expr(expr))
933    }
934
935    /// Compile a function argument with call-by-need semantics.
936    fn compile_arg_maybe_thunk(&mut self, arg: &ast::Expr) -> Result<(), CompileError> {
937        if Self::is_trivial_arg(arg) {
938            self.compile_expr(arg)
939        } else {
940            self.compile_thunk_immediate(arg)
941        }
942    }
943
944    fn is_trivial_arg(expr: &ast::Expr) -> bool {
945        match expr {
946            ast::Expr::Literal(_) | ast::Expr::Ident(_)
947            | ast::Expr::PathAbs(_) | ast::Expr::PathRel(_)
948            | ast::Expr::PathHome(_) | ast::Expr::Lambda(_) => true,
949            // Paren: check inner expression
950            ast::Expr::Paren(p) => p.expr().map_or(false, |inner| Self::is_trivial_arg(&inner)),
951            // Str without interpolation is trivial
952            ast::Expr::Str(s) => s.normalized_parts().iter().all(|p| matches!(p, InterpolPart::Literal(_))),
953            _ => false,
954        }
955    }
956
957    /// Compile a deferred thunk for `inherit (source) name;` in let bindings.
958    /// Like `compile_thunk_deferred`, but emits source + GetAttr(name) + Return.
959    fn compile_inherit_from_thunk_deferred(
960        &mut self,
961        source_expr: &ast::Expr,
962        attr_name: &str,
963    ) -> Result<Vec<UpvalueDesc>, CompileError> {
964        self.compile_deferred_thunk(|tc| {
965            tc.compile_expr(source_expr)?;
966            let key_idx = tc.add_attr_key(attr_name.to_string())?;
967            tc.emit(OpCode::GetAttr);
968            tc.emit_u16(key_idx);
969            Ok(())
970        })
971    }
972
973    /// Compile a deferred thunk for a dotted binding in rec attrsets.
974    /// Like `compile_thunk_deferred`, but the thunk body is a nested attrset
975    /// rather than a single expression.  Leaf values inside the nested attrset
976    /// are individually wrapped in immediate thunks so that forcing the outer
977    /// thunk doesn't eagerly evaluate all leaves (avoiding infinite recursion
978    /// when dotted bindings cross-reference each other through rec siblings).
979    fn compile_nested_attrset_thunk_deferred(
980        &mut self,
981        sub_bindings: &[(Vec<String>, ast::Expr)],
982    ) -> Result<Vec<UpvalueDesc>, CompileError> {
983        self.compile_deferred_thunk(|tc| tc.compile_nested_attrset_lazy(sub_bindings))
984    }
985
986    /// Emit with-scope preamble in a child compiler: for each with-scope
987    /// local in the parent, capture it as an upvalue and emit
988    /// `GetUpvalue + PushWith` at the start of the thunk body.
989    /// Returns the count of with-scopes pushed (caller must emit PopWith for each).
990    fn emit_with_scope_preamble(&mut self, tc: &mut Compiler) -> usize {
991        let slots: Vec<u16> = self.with_scope_locals.clone();
992        for &slot in &slots {
993            // Find the local index for this slot in the parent.
994            let local_idx = self.locals.iter().rposition(|l| l.slot == slot);
995            if let Some(idx) = local_idx {
996                self.locals[idx].is_captured = true;
997                if let Ok(uv_idx) = tc.add_upvalue(true, slot) {
998                    tc.emit(OpCode::GetUpvalue);
999                    tc.emit_u16(uv_idx as u16);
1000                    tc.emit(OpCode::PushWith);
1001                    tc.with_depth += 1;
1002                }
1003            }
1004        }
1005        slots.len()
1006    }
1007
1008    /// Compile a thunk with upvalues captured immediately (for non-rec attrsets).
1009    ///
1010    /// When the compiler has source text available and the expression has no
1011    /// free variables (no locals, no upvalues, no with-scopes), emit a
1012    /// `MakeLazyThunk` that defers compilation until the thunk is forced.
1013    /// Otherwise, fall through to the eager compilation path.
1014    fn compile_thunk_immediate(&mut self, expr: &ast::Expr) -> Result<(), CompileError> {
1015        // Try lazy thunk: only when source text is available and there are
1016        // no variables in scope that the expression could reference.
1017        if let Some(ref source) = self.source_text {
1018            if self.locals.is_empty() && self.with_depth == 0 && self.upvalues.is_empty() {
1019                let range = AstNode::syntax(expr).text_range();
1020                let offset: usize = range.start().into();
1021                let length: usize = range.len().into();
1022                let base_dir_str = self.base_dir
1023                    .as_ref()
1024                    .map(|p| p.to_string_lossy().to_string())
1025                    .unwrap_or_default();
1026
1027                // Store source text and base_dir in the constant pool.
1028                let src_idx = self.chunk.add_constant(VMValue::String((**source).clone()))?;
1029                let dir_idx = self.chunk.add_constant(VMValue::String(base_dir_str))?;
1030
1031                self.emit(OpCode::MakeLazyThunk);
1032                self.stack_depth += 1;
1033                self.emit_u16(src_idx);
1034                self.chunk.write_u32(offset as u32, self.current_line);
1035                self.chunk.write_u32(length as u32, self.current_line);
1036                self.emit_u16(dir_idx);
1037                self.emit_u16(0); // 0 upvalues
1038                return Ok(());
1039            }
1040        }
1041
1042        // Eager path: compile the thunk body now.
1043        let mut tc = Compiler::with_interner(Rc::clone(&self.interner));
1044        tc.scope_depth = 1;
1045        tc.enclosing = Some(self as *mut Compiler);
1046        tc.with_depth = 0; // Reset: thunk body restores with-scopes via upvalues
1047        tc.base_dir = self.base_dir.clone();
1048
1049        // Capture with-scope locals from parent as upvalues in thunk body.
1050        // Emit PushWith at thunk body start to restore with-scope context.
1051        let with_count = self.emit_with_scope_preamble(&mut tc);
1052
1053        tc.compile_expr(expr)?;
1054
1055        // Pop with-scopes in reverse.
1056        for _ in 0..with_count {
1057            tc.emit(OpCode::PopWith);
1058        }
1059
1060        tc.emit(OpCode::Return);
1061        let uv_descs: Vec<UpvalueDesc> = tc.upvalues.clone();
1062        let closure = VMValue::Closure(VMClosure {
1063            chunk: Rc::new(tc.chunk), upvalues: Vec::new(), arity: 0, name: None, formals: Vec::new(),
1064        });
1065        let idx = self.chunk.add_constant(closure)?;
1066        self.emit(OpCode::MakeThunk);
1067        self.stack_depth += 1; // MakeThunk pushes one thunk
1068        self.emit_u16(idx);
1069        self.emit_u16(uv_descs.len() as u16);
1070        for uv in &uv_descs {
1071            self.chunk.write_byte(if uv.is_local { 1 } else { 0 }, self.current_line);
1072            self.emit_u16(uv.index);
1073        }
1074        Ok(())
1075    }
1076
1077    /// Compile `inherit (source) name;` as a lazy thunk.
1078    /// The thunk evaluates `source` and then does `GetAttr(name)` when forced.
1079    fn compile_inherit_from_thunk(
1080        &mut self,
1081        source_expr: &ast::Expr,
1082        attr_name: &str,
1083    ) -> Result<(), CompileError> {
1084        let mut tc = Compiler::with_interner(Rc::clone(&self.interner));
1085        tc.scope_depth = 1;
1086        tc.enclosing = Some(self as *mut Compiler);
1087        tc.with_depth = 0;
1088        tc.base_dir = self.base_dir.clone();
1089        let with_count = self.emit_with_scope_preamble(&mut tc);
1090        tc.compile_expr(source_expr)?;
1091        let key_idx = tc.add_attr_key(attr_name.to_string())?;
1092        tc.emit(OpCode::GetAttr);
1093        tc.emit_u16(key_idx);
1094        for _ in 0..with_count { tc.emit(OpCode::PopWith); }
1095        tc.emit(OpCode::Return);
1096        let uv_descs: Vec<UpvalueDesc> = tc.upvalues.clone();
1097        let closure = VMValue::Closure(VMClosure {
1098            chunk: Rc::new(tc.chunk),
1099            upvalues: Vec::new(),
1100            arity: 0, formals: Vec::new(),
1101            name: None,
1102        });
1103        let idx = self.chunk.add_constant(closure)?;
1104        self.emit(OpCode::MakeThunk);
1105        self.stack_depth += 1; // MakeThunk pushes one thunk
1106        self.emit_u16(idx);
1107        self.emit_u16(uv_descs.len() as u16);
1108        for uv in &uv_descs {
1109            self.chunk
1110                .write_byte(if uv.is_local { 1 } else { 0 }, self.current_line);
1111            self.emit_u16(uv.index);
1112        }
1113        Ok(())
1114    }
1115
1116    // ── Attribute sets ─────────────────────────────────────────
1117
1118    fn compile_attrset(&mut self, set: &ast::AttrSet) -> Result<(), CompileError> {
1119        let rec = set.rec_token().is_some();
1120
1121        // ★ EVERY group goes through the plan — `plan_for_group_total`, not the
1122        // gated `plan_for_group` the walker uses. The gated one bounds a
1123        // consumer's blast radius by returning `None` for groups whose existing
1124        // path is already correct, which is right when you are KEEPING that
1125        // path; here the entry buckets below are the defect, so routing only
1126        // the broken groups away from them would leave two implementations of
1127        // one rule.
1128        //
1129        // The fallback is reached ONLY for a group nix itself rejects (a
1130        // genuine duplicate like `{ a = 1; a = 2; }`, where `sui-normalize`
1131        // returns a typed `NormalizeError`). sui still accepts those, so
1132        // today's permissive answer is preserved rather than silently becoming
1133        // a compile error on one engine only; the rejection tier owns that
1134        // change, and deletes this fallback with it.
1135        match sui_normalize::plan_for_group_total(set, rec) {
1136            Ok(plan) => return self.compile_plan_group(&plan),
1137            Err(_) => { /* nix rejects this group — see above */ }
1138        }
1139
1140        if rec {
1141            return self.compile_rec_attrset(set);
1142        }
1143
1144        // Collect all entries, handling dotted bindings by merging them.
1145        // We need to group dotted bindings by their top-level key.
1146        let mut flat_entries: Vec<(String, ast::Expr)> = Vec::new();
1147        let mut dotted_entries: std::collections::BTreeMap<String, Vec<(Vec<String>, ast::Expr)>> =
1148            std::collections::BTreeMap::new();
1149        let mut inherit_entries: Vec<(String, Option<ast::Expr>)> = Vec::new();
1150        let mut dynamic_entries: Vec<(ast::Expr, ast::Expr)> = Vec::new();
1151        let mut dynamic_dotted_entries: Vec<(ast::Attr, Vec<String>, ast::Expr)> = Vec::new();
1152
1153        for entry in set.entries() {
1154            match entry {
1155                ast::Entry::AttrpathValue(ref apv) => {
1156                    let attrpath = apv.attrpath().ok_or_else(|| {
1157                        CompileError::MissingNode("attrset attrpath".to_string())
1158                    })?;
1159                    let keys: Vec<_> = attrpath.attrs().collect();
1160                    let value_expr = apv.value().ok_or_else(|| {
1161                        CompileError::MissingNode("attrset value".to_string())
1162                    })?;
1163
1164                    if keys.len() == 1 {
1165                        // Check for dynamic key.
1166                        match &keys[0] {
1167                            ast::Attr::Dynamic(dyn_attr) => {
1168                                let key_expr = dyn_attr.expr().ok_or_else(|| {
1169                                    CompileError::MissingNode("dynamic attr key".to_string())
1170                                })?;
1171                                dynamic_entries.push((key_expr, value_expr));
1172                            }
1173                            ast::Attr::Str(s) => {
1174                                // Try to extract a plain string literal
1175                                // (e.g. `"1" = ...`). These are static keys
1176                                // and must be compiled like flat entries
1177                                // (with lazy thunk-wrapped values) to avoid
1178                                // eagerly evaluating throw expressions in
1179                                // unaccessed attrset branches.
1180                                if let Ok(key) = static_attr_name(&keys[0]) {
1181                                    flat_entries.push((key, value_expr));
1182                                } else {
1183                                    // Interpolated string key — truly dynamic.
1184                                    let key_expr = ast::Expr::Str(s.clone());
1185                                    dynamic_entries.push((key_expr, value_expr));
1186                                }
1187                            }
1188                            _ => {
1189                                let key = static_attr_name(&keys[0])?;
1190                                flat_entries.push((key, value_expr));
1191                            }
1192                        }
1193                    } else {
1194                        // Dotted binding: group by top-level key.
1195                        match static_attr_name(&keys[0]) {
1196                            Ok(top_key) => {
1197                                let rest_keys: Vec<String> = keys[1..]
1198                                    .iter()
1199                                    .map(static_attr_name)
1200                                    .collect::<Result<_, _>>()?;
1201                                dotted_entries
1202                                    .entry(top_key)
1203                                    .or_default()
1204                                    .push((rest_keys, value_expr));
1205                            }
1206                            Err(_) => {
1207                                // Dynamic top-level key in dotted path.
1208                                // Collect rest keys as static names for the
1209                                // nested attrset; push as a dynamic entry.
1210                                let rest_keys: Vec<String> = keys[1..]
1211                                    .iter()
1212                                    .map(static_attr_name)
1213                                    .collect::<Result<_, _>>()?;
1214                                // Store for later compilation as dynamic
1215                                // dotted entry (key_attr, rest_keys, value).
1216                                dynamic_dotted_entries.push((
1217                                    keys[0].clone(),
1218                                    rest_keys,
1219                                    value_expr,
1220                                ));
1221                            }
1222                        }
1223                    }
1224                }
1225                ast::Entry::Inherit(ref inherit) => {
1226                    let source_expr = inherit.from().and_then(|f| f.expr());
1227                    for attr in inherit.attrs() {
1228                        let name = static_attr_name(&attr)?;
1229                        inherit_entries.push((name, source_expr.clone()));
1230                    }
1231                }
1232            }
1233        }
1234
1235        let mut count: u16 = 0;
1236
1237        // Emit flat entries (lazy: wrap non-trivial values in thunks,
1238        // except inside with-scopes where thunks can't capture the
1239        // dynamic scope).
1240        for (key, value_expr) in &flat_entries {
1241            if Self::is_trivial_value(value_expr) {
1242                self.compile_expr(value_expr)?;
1243            } else {
1244                self.compile_thunk_immediate(value_expr)?;
1245            }
1246            self.emit_constant(VMValue::String(key.clone()))?;
1247            count += 1;
1248        }
1249
1250        // Emit dotted entries as nested attrsets.
1251        for (top_key, sub_bindings) in &dotted_entries {
1252            self.compile_nested_attrset(sub_bindings)?;
1253            self.emit_constant(VMValue::String(top_key.clone()))?;
1254            count += 1;
1255        }
1256
1257        // Emit inherit entries (lazy: wrap inherit-from in thunks to avoid
1258        // forcing the source expression at attrset construction time).
1259        for (name, source_expr) in &inherit_entries {
1260            if let Some(src) = source_expr {
1261                // inherit (source) name; — wrap in a thunk that evaluates
1262                // source.name lazily (critical for fixpoint patterns like
1263                // makeExtensible where the source references `self`).
1264                self.compile_inherit_from_thunk(src, name)?;
1265            } else {
1266                // inherit name; — look up in current scope.
1267                self.emit_variable_load(name)?;
1268            }
1269            self.emit_constant(VMValue::String(name.clone()))?;
1270            count += 1;
1271        }
1272
1273        // Emit dynamic entries (lazy: wrap non-trivial values in thunks
1274        // to preserve Nix's lazy evaluation semantics).
1275        for (key_expr, value_expr) in &dynamic_entries {
1276            if Self::is_trivial_value(value_expr) {
1277                self.compile_expr(value_expr)?;
1278            } else {
1279                self.compile_thunk_immediate(value_expr)?;
1280            }
1281            self.compile_expr(key_expr)?;
1282            count += 1;
1283        }
1284
1285        // Emit dynamic dotted entries: dynamic top-level key with static
1286        // nested path. Build the nested attrset from rest_keys, then emit
1287        // the dynamic key expression.
1288        for (key_attr, rest_keys, value_expr) in &dynamic_dotted_entries {
1289            // Build nested attrset: { rest_key1.rest_key2... = value; }
1290            self.compile_nested_attrset(&[(rest_keys.clone(), value_expr.clone())])?;
1291            // Compile the dynamic key expression.
1292            self.compile_dynamic_attr_key(key_attr)?;
1293            count += 1;
1294        }
1295
1296        self.emit(OpCode::MakeAttrs);
1297        self.emit_u16(count);
1298        // MakeAttrs pops 2*count (value+key pairs) and pushes 1 attrset.
1299        self.stack_depth = self.stack_depth.saturating_sub(2 * count) + 1;
1300
1301        // If there were both flat/dotted and we need to merge, the MakeAttrs
1302        // handles it by creating one set. Dotted entries that share top-level
1303        // keys with flat entries need merging. For now, dotted entries that
1304        // share keys with flat entries override. This matches Nix semantics
1305        // where the last definition wins (for simple cases).
1306
1307        Ok(())
1308    }
1309
1310    /// Compile a `rec { ... }` attrset.
1311    fn compile_rec_attrset(&mut self, set: &ast::AttrSet) -> Result<(), CompileError> {
1312        self.begin_scope();
1313
1314        // Collect all binding names and their expressions.
1315        let mut bindings: Vec<(String, RecAttrBinding)> = Vec::new();
1316        let mut dotted_entries: std::collections::BTreeMap<String, Vec<(Vec<String>, ast::Expr)>> =
1317            std::collections::BTreeMap::new();
1318
1319        for entry in set.entries() {
1320            match entry {
1321                ast::Entry::AttrpathValue(ref apv) => {
1322                    let attrpath = apv.attrpath().ok_or_else(|| {
1323                        CompileError::MissingNode("rec attrset attrpath".to_string())
1324                    })?;
1325                    let keys: Vec<_> = attrpath.attrs().collect();
1326                    let value_expr = apv.value().ok_or_else(|| {
1327                        CompileError::MissingNode("rec attrset value".to_string())
1328                    })?;
1329                    if keys.len() == 1 {
1330                        let key = static_attr_name(&keys[0])?;
1331                        bindings.push((key, RecAttrBinding::Value(value_expr)));
1332                    } else {
1333                        let top_key = static_attr_name(&keys[0])?;
1334                        let rest_keys: Vec<String> = keys[1..]
1335                            .iter()
1336                            .map(static_attr_name)
1337                            .collect::<Result<_, _>>()?;
1338                        dotted_entries
1339                            .entry(top_key)
1340                            .or_default()
1341                            .push((rest_keys, value_expr));
1342                    }
1343                }
1344                ast::Entry::Inherit(ref inherit) => {
1345                    if let Some(from) = inherit.from() {
1346                        let source_expr = from.expr().ok_or_else(|| {
1347                            CompileError::MissingNode("inherit from expr".to_string())
1348                        })?;
1349                        for attr in inherit.attrs() {
1350                            let name = static_attr_name(&attr)?;
1351                            bindings.push((name.clone(), RecAttrBinding::InheritFrom(source_expr.clone(), name)));
1352                        }
1353                    } else {
1354                        for attr in inherit.attrs() {
1355                            let name = static_attr_name(&attr)?;
1356                            bindings.push((name, RecAttrBinding::Inherit));
1357                        }
1358                    }
1359                }
1360            }
1361        }
1362
1363        // Add dotted entries as bindings.
1364        for (top_key, sub) in &dotted_entries {
1365            bindings.push((top_key.clone(), RecAttrBinding::Dotted(sub.clone())));
1366        }
1367
1368        // Static cycle detection: check for `name = name;` patterns in rec bindings.
1369        {
1370            let pairs: Vec<(String, &ast::Expr)> = bindings
1371                .iter()
1372                .filter_map(|(name, binding)| match binding {
1373                    RecAttrBinding::Value(expr) => Some((name.clone(), expr as &ast::Expr)),
1374                    _ => None,
1375                })
1376                .collect();
1377            for warning in detect_trivial_cycles(&pairs) {
1378                eprintln!("{warning}");
1379            }
1380        }
1381
1382        let binding_count = u16::try_from(bindings.len())
1383            .map_err(|_| CompileError::TooManyLocals)?;
1384
1385        // Phase 1: Allocate local slots with null placeholders.
1386        for (name, _) in &bindings {
1387            self.emit(OpCode::Null); // emit() tracks stack_depth
1388            self.add_local(name.clone())?;
1389        }
1390
1391        // Phase 2: Compile each binding's value (lazy: use deferred thunks
1392        // so rec attrset values are only evaluated when accessed).
1393        let mut thunk_slots: Vec<(u16, Vec<UpvalueDesc>)> = Vec::new();
1394
1395        for (name, binding) in &bindings {
1396            let local_idx = self.resolve_local(name).unwrap();
1397            let slot = self.locals[local_idx as usize].slot;
1398            match binding {
1399                RecAttrBinding::Value(expr) => {
1400                    // In rec attrsets, lambdas must NOT be treated as trivial
1401                    // because MakeClosure captures upvalues at emission time.
1402                    // If a lambda captures a sibling binding (especially a
1403                    // dotted entry, which is appended last), that slot may still
1404                    // be null.  Wrapping in a deferred thunk delays MakeClosure
1405                    // until the lambda is actually accessed, when all siblings
1406                    // are populated.
1407                    if Self::is_trivial_value_for_rec(expr) {
1408                        self.compile_expr(expr)?;
1409                    } else {
1410                        let uv_descs = self.compile_thunk_deferred(expr)?;
1411                        if !uv_descs.is_empty() {
1412                            thunk_slots.push((slot, uv_descs));
1413                        }
1414                    }
1415                }
1416                RecAttrBinding::Inherit => {
1417                    // Temporarily hide this local so lookup finds the outer one.
1418                    let saved_depth = self.locals[local_idx as usize].depth;
1419                    self.locals[local_idx as usize].depth = u32::MAX;
1420                    self.emit_variable_load_restore(name, local_idx, saved_depth)?;
1421                    self.locals[local_idx as usize].depth = saved_depth;
1422                }
1423                RecAttrBinding::InheritFrom(source_expr, attr_name) => {
1424                    // Wrap inherit-from in deferred thunks for laziness.
1425                    let uv_descs = self.compile_inherit_from_thunk_deferred(source_expr, attr_name)?;
1426                    if !uv_descs.is_empty() {
1427                        thunk_slots.push((slot, uv_descs));
1428                    }
1429                }
1430                RecAttrBinding::Dotted(sub_bindings) => {
1431                    // Wrap dotted bindings in deferred thunks so that leaf
1432                    // expressions referencing rec siblings are only evaluated
1433                    // after PatchThunkUpvalues has populated upvalues.
1434                    // Leaves inside the thunk are also made individually lazy
1435                    // to avoid eagerly forcing siblings (which would cause
1436                    // infinite recursion for cross-referencing dotted bindings).
1437                    let uv_descs = self.compile_nested_attrset_thunk_deferred(sub_bindings)?;
1438                    if !uv_descs.is_empty() {
1439                        thunk_slots.push((slot, uv_descs));
1440                    }
1441                }
1442            }
1443            self.emit(OpCode::SetLocal);
1444            self.emit_u16(slot);
1445            self.emit(OpCode::Pop);
1446        }
1447
1448        // Phase 2b: Patch thunk upvalues now that all siblings exist.
1449        for (slot, uv_descs) in &thunk_slots {
1450            self.emit(OpCode::PatchThunkUpvalues);
1451            self.emit_u16(*slot);
1452            self.emit_u16(uv_descs.len() as u16);
1453            for uv in uv_descs {
1454                self.chunk.write_byte(if uv.is_local { 1 } else { 0 }, self.current_line);
1455                self.emit_u16(uv.index);
1456            }
1457        }
1458
1459        // Build the attrset from the local variables.
1460        for (name, _) in &bindings {
1461            let slot = self.find_local_slot(name);
1462            self.emit(OpCode::GetLocal);
1463            self.emit_u16(slot);
1464            self.emit_constant(VMValue::String(name.clone()))?;
1465        }
1466        self.emit(OpCode::MakeAttrs);
1467        self.emit_u16(binding_count);
1468        // MakeAttrs pops 2*count and pushes 1.
1469        self.stack_depth = self.stack_depth.saturating_sub(2 * binding_count) + 1;
1470
1471        // Clean up scope: move the attrset result down past the locals.
1472        self.end_scope(binding_count);
1473
1474        Ok(())
1475    }
1476
1477    // ── plan-driven binding groups ────────────────────────────────────────
1478    //
1479    // nix decides duplicate-key merge-vs-overwrite at PARSE time from SYNTAX,
1480    // as a destructive splice into the FIRST-declared node whose `rec` flag
1481    // governs and into whose scope the second side is re-scoped.
1482    // `sui-normalize` performs that splice; these functions only emit it.
1483    //
1484    // ★ What this replaces, and why it was wrong. `compile_attrset` sorted
1485    // entries into FIVE buckets (flat / dotted / inherit / dynamic /
1486    // dynamic-dotted) drained by five sequential emission loops. A key
1487    // reaching two buckets — `{ a = {b=1;}; a.c = 2; }` puts `a` in both flat
1488    // and dotted — was therefore emitted TWICE, and `MakeAttrs` kept one of
1489    // them. Not a merge, a coin toss with a fixed outcome: measured, the VM
1490    // answered `{"a":{"b":1}}` where nix says `{"a":{"b":1,"c":2}}`, at exit 0.
1491    //
1492    // A plan's postcondition is that no static name repeats, so there is no
1493    // merge to perform and no collision to resolve. `MakeAttrs` needs no
1494    // change, and in particular its pop order must NOT be reversed: it pops
1495    // LIFO and `BTreeMap::insert`s, so the FIRST-emitted pair wins. Flipping
1496    // that turns first-wins into last-wins, and nix's rule is neither — it is
1497    // a splice, which is why this had to be fixed in the compiler and not in
1498    // the opcode.
1499
1500    /// Emit one binding group from a [`GroupPlan`], leaving one attrset on the
1501    /// stack.
1502    fn compile_plan_group(&mut self, plan: &sui_normalize::GroupPlan) -> Result<(), CompileError> {
1503        if plan.recursive {
1504            self.compile_plan_group_rec(plan)
1505        } else {
1506            self.compile_plan_group_flat(plan)
1507        }
1508    }
1509
1510    /// A non-recursive group: every value is emitted in the ENCLOSING scope.
1511    fn compile_plan_group_flat(
1512        &mut self,
1513        plan: &sui_normalize::GroupPlan,
1514    ) -> Result<(), CompileError> {
1515        let mut count: u16 = 0;
1516        for b in &plan.statics {
1517            let name = sui_intern::resolve(b.name);
1518            self.emit_plan_binding(&b.binding, &name, plan)?;
1519            self.emit_constant(VMValue::String(name))?;
1520            count += 1;
1521        }
1522        count += self.emit_plan_dynamics(plan)?;
1523        self.emit(OpCode::MakeAttrs);
1524        self.emit_u16(count);
1525        // MakeAttrs pops 2*count (value+key pairs) and pushes 1 attrset.
1526        self.stack_depth = self.stack_depth.saturating_sub(2 * count) + 1;
1527        Ok(())
1528    }
1529
1530    /// One binding's VALUE, for a non-recursive group.
1531    fn emit_plan_binding(
1532        &mut self,
1533        binding: &sui_normalize::Binding,
1534        name: &str,
1535        plan: &sui_normalize::GroupPlan,
1536    ) -> Result<(), CompileError> {
1537        use sui_normalize::Binding;
1538        match binding {
1539            Binding::Leaf(expr) => {
1540                if Self::is_trivial_value(expr) {
1541                    self.compile_expr(expr)
1542                } else {
1543                    self.compile_thunk_immediate(expr)
1544                }
1545            }
1546            // A nested group — a merged literal, or one a dotted path invented.
1547            // Emitted in place with lazy leaves, which is what
1548            // `compile_nested_attrset` did for the dotted bucket; the
1549            // difference is that this one can be `rec` and can hold any
1550            // binding kind, neither of which a `(path, value)` list can say.
1551            Binding::Group(sub) => self.compile_plan_group(sub),
1552            // `inherit x` resolves in the ENCLOSING scope, never the group's
1553            // own rec scope — that is what makes it shadow rather than
1554            // self-reference, and why it can never merge.
1555            Binding::Inherit => self.emit_variable_load(name),
1556            Binding::InheritFrom { from } => {
1557                let src = plan.inherit_froms.get(*from).ok_or_else(|| {
1558                    CompileError::Unsupported(format!(
1559                        "inherit-from index {from} out of range for '{name}'"
1560                    ))
1561                })?;
1562                let src = src.clone();
1563                self.compile_inherit_from_thunk(&src, name)
1564            }
1565        }
1566    }
1567
1568    /// `${e}` keys that did not constant-fold, emitted AFTER every static key
1569    /// and in source order — nix's ordering, and the reason a dynamic key can
1570    /// never take part in the parse-time merge.
1571    fn emit_plan_dynamics(
1572        &mut self,
1573        plan: &sui_normalize::GroupPlan,
1574    ) -> Result<u16, CompileError> {
1575        use sui_normalize::Binding;
1576        let mut count: u16 = 0;
1577        for d in &plan.dynamics {
1578            match &d.value {
1579                Binding::Leaf(expr) => {
1580                    if Self::is_trivial_value(expr) {
1581                        self.compile_expr(expr)?;
1582                    } else {
1583                        self.compile_thunk_immediate(expr)?;
1584                    }
1585                }
1586                Binding::Group(sub) => self.compile_plan_group(sub)?,
1587                // Refused rather than guessed: an inherited name is resolved
1588                // BY that name, and a dynamic key has no name until run time.
1589                // nix rejects `inherit` under a dynamic key at parse time, so
1590                // this is unreachable from source — but a silent wrong answer
1591                // here would be indistinguishable from a correct one.
1592                Binding::Inherit | Binding::InheritFrom { .. } => {
1593                    return Err(CompileError::Unsupported(
1594                        "an inherited binding cannot have a dynamic key".to_string(),
1595                    ))
1596                }
1597            }
1598            self.compile_expr(&d.key)?;
1599            count += 1;
1600        }
1601        Ok(count)
1602    }
1603
1604    /// A recursive group: each binding gets a local slot, values are compiled
1605    /// as DEFERRED thunks, and `PatchThunkUpvalues` re-points them once every
1606    /// sibling exists.
1607    ///
1608    /// The two-phase shape is `compile_rec_attrset`'s and is preserved
1609    /// deliberately — it is what makes a lambda that captures a later sibling
1610    /// work, since `MakeClosure` captures upvalues at emission time and the
1611    /// slot is still null then. What changes is only WHAT is emitted: the plan
1612    /// has already collapsed duplicate names, so `add_local` can no longer be
1613    /// called twice with the same name (which produced two locals, of which
1614    /// `resolve_local` found one).
1615    fn compile_plan_group_rec(
1616        &mut self,
1617        plan: &sui_normalize::GroupPlan,
1618    ) -> Result<(), CompileError> {
1619        self.begin_scope();
1620        let local_count = self.bind_plan_group_locals(plan)?;
1621
1622        // Build the attrset from the locals.
1623        let mut count = local_count;
1624        for b in &plan.statics {
1625            let name = sui_intern::resolve(b.name);
1626            let slot = self.find_local_slot(&name);
1627            self.emit(OpCode::GetLocal);
1628            self.emit_u16(slot);
1629            self.emit_constant(VMValue::String(name))?;
1630        }
1631        // Dynamic keys resolve in the group's OWN scope, so they are emitted
1632        // here, while the locals are still live.
1633        count += self.emit_plan_dynamics(plan)?;
1634
1635        self.emit(OpCode::MakeAttrs);
1636        self.emit_u16(count);
1637        self.stack_depth = self.stack_depth.saturating_sub(2 * count) + 1;
1638
1639        // Move the attrset down past the locals.
1640        self.end_scope(local_count);
1641        Ok(())
1642    }
1643
1644    /// Bind a recursive group's names into fresh locals, leaving the scope
1645    /// OPEN — the caller owns `begin_scope`/`end_scope` and decides what to
1646    /// leave on top: an attrset (`rec { … }`), a body (`let … in e`), or one
1647    /// selected member (legacy `let { … body = e; }`). Returns the local count
1648    /// the caller must pass to `end_scope`.
1649    ///
1650    /// That three-way split is exactly why this is separate: all three are the
1651    /// same recursive binder over the same plan and differ only in the last
1652    /// two instructions, and before the plan they were three hand-maintained
1653    /// copies of the two-phase protocol that had already drifted — `let`
1654    /// refused dotted bindings outright (`Unsupported("dotted let bindings")`)
1655    /// while `rec` supported them.
1656    fn bind_plan_group_locals(
1657        &mut self,
1658        plan: &sui_normalize::GroupPlan,
1659    ) -> Result<u16, CompileError> {
1660        use sui_normalize::Binding;
1661
1662        let local_count =
1663            u16::try_from(plan.statics.len()).map_err(|_| CompileError::TooManyLocals)?;
1664
1665        // Phase 1: allocate a local slot per name, null-initialised.
1666        for b in &plan.statics {
1667            self.emit(OpCode::Null); // emit() tracks stack_depth
1668            self.add_local(sui_intern::resolve(b.name))?;
1669        }
1670
1671        // Phase 2: compile each value into its slot.
1672        let mut thunk_slots: Vec<(u16, Vec<UpvalueDesc>)> = Vec::new();
1673        for b in &plan.statics {
1674            let name = sui_intern::resolve(b.name);
1675            let local_idx = self
1676                .resolve_local(&name)
1677                .ok_or_else(|| CompileError::Unsupported(format!("rec local '{name}' vanished")))?;
1678            let slot = self.locals[local_idx as usize].slot;
1679            match &b.binding {
1680                Binding::Leaf(expr) => {
1681                    if Self::is_trivial_value_for_rec(expr) {
1682                        self.compile_expr(expr)?;
1683                    } else {
1684                        let uv = self.compile_thunk_deferred(expr)?;
1685                        if !uv.is_empty() {
1686                            thunk_slots.push((slot, uv));
1687                        }
1688                    }
1689                }
1690                Binding::Group(sub) => {
1691                    // Deferred, like the dotted bucket was: a leaf inside the
1692                    // sub-group may reference a rec sibling, which is only
1693                    // populated after `PatchThunkUpvalues` runs.
1694                    let sub = sub.clone();
1695                    let uv = self.compile_deferred_thunk(|tc| tc.compile_plan_group(&sub))?;
1696                    if !uv.is_empty() {
1697                        thunk_slots.push((slot, uv));
1698                    }
1699                }
1700                Binding::Inherit => {
1701                    // Hide this local so the lookup finds the OUTER binding of
1702                    // the same name — an inherit shadows, it does not recurse.
1703                    let saved_depth = self.locals[local_idx as usize].depth;
1704                    self.locals[local_idx as usize].depth = u32::MAX;
1705                    self.emit_variable_load_restore(&name, local_idx, saved_depth)?;
1706                    self.locals[local_idx as usize].depth = saved_depth;
1707                }
1708                Binding::InheritFrom { from } => {
1709                    let src = plan
1710                        .inherit_froms
1711                        .get(*from)
1712                        .ok_or_else(|| {
1713                            CompileError::Unsupported(format!(
1714                                "inherit-from index {from} out of range for '{name}'"
1715                            ))
1716                        })?
1717                        .clone();
1718                    let uv = self.compile_inherit_from_thunk_deferred(&src, &name)?;
1719                    if !uv.is_empty() {
1720                        thunk_slots.push((slot, uv));
1721                    }
1722                }
1723            }
1724            self.emit(OpCode::SetLocal);
1725            self.emit_u16(slot);
1726            self.emit(OpCode::Pop);
1727        }
1728
1729        // Phase 2b: patch thunk upvalues now that every sibling exists.
1730        for (slot, uv_descs) in &thunk_slots {
1731            self.emit(OpCode::PatchThunkUpvalues);
1732            self.emit_u16(*slot);
1733            self.emit_u16(u16::try_from(uv_descs.len()).map_err(|_| CompileError::TooManyLocals)?);
1734            for uv in uv_descs {
1735                self.chunk
1736                    .write_byte(u8::from(uv.is_local), self.current_line);
1737                self.emit_u16(uv.index);
1738            }
1739        }
1740
1741        Ok(local_count)
1742    }
1743
1744    /// Compile a legacy let expression (`let { x = 1; body = x; }`).
1745    ///
1746    /// This is equivalent to `(rec { x = 1; body = x; }).body`.
1747    /// The entries are recursive (like `rec { ... }`), and the result
1748    /// is the `body` attribute.
1749    fn compile_legacy_let(&mut self, ll: &ast::LegacyLet) -> Result<(), CompileError> {
1750        // `let { … }` IS `(rec { … }).body`, so it is the same recursive
1751        // binder over the same plan, selecting one member instead of building
1752        // an attrset. Same measured defect:
1753        //
1754        //   let { a = {b=1;}; a.c = 2; body = a; }   was {"c":2}
1755        //                                            nix {"b":1,"c":2}
1756        match sui_normalize::plan_for_group_total(ll, true) {
1757            Ok(plan) if plan.dynamics.is_empty() => {
1758                let body_sym = sui_intern::intern("body");
1759                if plan.statics.iter().any(|b| b.name == body_sym) {
1760                    self.begin_scope();
1761                    let local_count = self.bind_plan_group_locals(&plan)?;
1762                    let slot = self.find_local_slot("body");
1763                    self.emit(OpCode::GetLocal);
1764                    self.emit_u16(slot);
1765                    self.end_scope(local_count);
1766                    return Ok(());
1767                }
1768                // No `body` member: fall through so the existing path emits
1769                // its own diagnostic rather than this one inventing a second.
1770            }
1771            Ok(_) | Err(_) => {}
1772        }
1773
1774        self.begin_scope();
1775
1776        // Collect bindings — same logic as compile_rec_attrset but
1777        // operating on a LegacyLet node (which also implements HasEntry).
1778        let mut bindings: Vec<(String, RecAttrBinding)> = Vec::new();
1779        let mut dotted_entries: std::collections::BTreeMap<String, Vec<(Vec<String>, ast::Expr)>> =
1780            std::collections::BTreeMap::new();
1781
1782        for entry in ll.entries() {
1783            match entry {
1784                ast::Entry::AttrpathValue(ref apv) => {
1785                    let attrpath = apv.attrpath().ok_or_else(|| {
1786                        CompileError::MissingNode("legacy let attrpath".to_string())
1787                    })?;
1788                    let keys: Vec<_> = attrpath.attrs().collect();
1789                    let value_expr = apv.value().ok_or_else(|| {
1790                        CompileError::MissingNode("legacy let value".to_string())
1791                    })?;
1792                    if keys.len() == 1 {
1793                        let key = static_attr_name(&keys[0])?;
1794                        bindings.push((key, RecAttrBinding::Value(value_expr)));
1795                    } else {
1796                        let top_key = static_attr_name(&keys[0])?;
1797                        let rest_keys: Vec<String> = keys[1..]
1798                            .iter()
1799                            .map(static_attr_name)
1800                            .collect::<Result<_, _>>()?;
1801                        dotted_entries
1802                            .entry(top_key)
1803                            .or_default()
1804                            .push((rest_keys, value_expr));
1805                    }
1806                }
1807                ast::Entry::Inherit(ref inherit) => {
1808                    if let Some(from) = inherit.from() {
1809                        let source_expr = from.expr().ok_or_else(|| {
1810                            CompileError::MissingNode("inherit from expr".to_string())
1811                        })?;
1812                        for attr in inherit.attrs() {
1813                            let name = static_attr_name(&attr)?;
1814                            bindings.push((name.clone(), RecAttrBinding::InheritFrom(source_expr.clone(), name)));
1815                        }
1816                    } else {
1817                        for attr in inherit.attrs() {
1818                            let name = static_attr_name(&attr)?;
1819                            bindings.push((name, RecAttrBinding::Inherit));
1820                        }
1821                    }
1822                }
1823            }
1824        }
1825
1826        // Add dotted entries as bindings.
1827        for (top_key, sub) in &dotted_entries {
1828            bindings.push((top_key.clone(), RecAttrBinding::Dotted(sub.clone())));
1829        }
1830
1831        let binding_count = u16::try_from(bindings.len())
1832            .map_err(|_| CompileError::TooManyLocals)?;
1833
1834        // Phase 1: Allocate local slots with null placeholders.
1835        for (name, _) in &bindings {
1836            self.emit(OpCode::Null);
1837            self.add_local(name.clone())?;
1838        }
1839
1840        // Phase 2: Compile each binding's value (lazy thunks for non-trivial).
1841        let mut thunk_slots: Vec<(u16, Vec<UpvalueDesc>)> = Vec::new();
1842
1843        for (name, binding) in &bindings {
1844            let local_idx = self.resolve_local(name).unwrap();
1845            let slot = self.locals[local_idx as usize].slot;
1846            match binding {
1847                RecAttrBinding::Value(expr) => {
1848                    // Same rec-aware trivial check as compile_rec_attrset:
1849                    // lambdas must be deferred to avoid capturing null slots.
1850                    if Self::is_trivial_value_for_rec(expr) {
1851                        self.compile_expr(expr)?;
1852                    } else {
1853                        let uv_descs = self.compile_thunk_deferred(expr)?;
1854                        if !uv_descs.is_empty() {
1855                            thunk_slots.push((slot, uv_descs));
1856                        }
1857                    }
1858                }
1859                RecAttrBinding::Inherit => {
1860                    let saved_depth = self.locals[local_idx as usize].depth;
1861                    self.locals[local_idx as usize].depth = u32::MAX;
1862                    self.emit_variable_load_restore(name, local_idx, saved_depth)?;
1863                    self.locals[local_idx as usize].depth = saved_depth;
1864                }
1865                RecAttrBinding::InheritFrom(source_expr, attr_name) => {
1866                    let uv_descs = self.compile_inherit_from_thunk_deferred(source_expr, attr_name)?;
1867                    if !uv_descs.is_empty() {
1868                        thunk_slots.push((slot, uv_descs));
1869                    }
1870                }
1871                RecAttrBinding::Dotted(sub_bindings) => {
1872                    // Wrap dotted bindings in deferred thunks (same as rec attrset).
1873                    let uv_descs = self.compile_nested_attrset_thunk_deferred(sub_bindings)?;
1874                    if !uv_descs.is_empty() {
1875                        thunk_slots.push((slot, uv_descs));
1876                    }
1877                }
1878            }
1879            self.emit(OpCode::SetLocal);
1880            self.emit_u16(slot);
1881            self.emit(OpCode::Pop);
1882        }
1883
1884        // Phase 2b: Patch thunk upvalues now that all siblings exist.
1885        for (slot, uv_descs) in &thunk_slots {
1886            self.emit(OpCode::PatchThunkUpvalues);
1887            self.emit_u16(*slot);
1888            self.emit_u16(uv_descs.len() as u16);
1889            for uv in uv_descs {
1890                self.chunk.write_byte(if uv.is_local { 1 } else { 0 }, self.current_line);
1891                self.emit_u16(uv.index);
1892            }
1893        }
1894
1895        // Instead of building an attrset and selecting "body", directly
1896        // load the local named "body" — this avoids constructing the
1897        // intermediate attrset entirely.
1898        let body_slot = self.find_local_slot_opt("body").ok_or_else(|| {
1899            CompileError::MissingNode("legacy let missing 'body' binding".to_string())
1900        })?;
1901        self.emit(OpCode::GetLocal);
1902        self.emit_u16(body_slot);
1903
1904        // Clean up scope: move the body value down past the locals.
1905        self.end_scope(binding_count);
1906
1907        Ok(())
1908    }
1909
1910    /// Compile a nested attrset from a list of (remaining-path, value) pairs.
1911    /// Used for dotted bindings like `{ a.b = 1; a.c = 2; }`.
1912    ///
1913    /// When `lazy_leaves` is true, non-trivial leaf values are wrapped in
1914    /// immediate thunks (for rec attrsets where leaves may reference siblings
1915    /// that aren't fully initialised until after `PatchThunkUpvalues` runs).
1916    fn compile_nested_attrset(
1917        &mut self,
1918        sub_bindings: &[(Vec<String>, ast::Expr)],
1919    ) -> Result<(), CompileError> {
1920        self.compile_nested_attrset_inner(sub_bindings, false, &[])
1921    }
1922
1923    fn compile_nested_attrset_lazy(
1924        &mut self,
1925        sub_bindings: &[(Vec<String>, ast::Expr)],
1926    ) -> Result<(), CompileError> {
1927        self.compile_nested_attrset_inner(sub_bindings, true, &[])
1928    }
1929
1930    /// `prefix` is the dotted path already consumed by outer recursions. It
1931    /// exists only so a duplicate can be NAMED; it does not affect codegen.
1932    fn compile_nested_attrset_inner(
1933        &mut self,
1934        sub_bindings: &[(Vec<String>, ast::Expr)],
1935        lazy_leaves: bool,
1936        prefix: &[String],
1937    ) -> Result<(), CompileError> {
1938        // Group by next key.
1939        let mut groups: std::collections::BTreeMap<String, Vec<(Vec<String>, ast::Expr)>> =
1940            std::collections::BTreeMap::new();
1941
1942        for (path, expr) in sub_bindings {
1943            // ★ `split_first`, NOT `path[0]`.
1944            //
1945            // Two bindings at the SAME dotted path (`{ a.b = 1; a.b = 2; }`)
1946            // both land in group `a` carrying the remainder `["b"]`, recurse,
1947            // both land in group `b` carrying `[]`, and recurse AGAIN — at
1948            // which point `path` is empty and `path[0]` panicked with
1949            // `index out of bounds: the len is 0 but the index is 0`.
1950            //
1951            // It panicked on the `sui-vm-eval` thread, where the CLI's
1952            // whole-expression fallback then rescued the run and returned the
1953            // walker's answer with exit 0 — so a compiler PANIC was invisible
1954            // in normal use and surfaced only under `SUI_VM_STRICT`. A crash
1955            // that presents as a clean success is the worst available shape.
1956            let Some((head, rest)) = path.split_first() else {
1957                let full = if prefix.is_empty() {
1958                    "<unknown>".to_string()
1959                } else {
1960                    prefix.join(".")
1961                };
1962                return Err(CompileError::Unsupported(format!(
1963                    "attribute '{full}' is defined more than once; CppNix \
1964                     rejects this at parse time and the bytecode compiler \
1965                     cannot represent it"
1966                )));
1967            };
1968            groups
1969                .entry(head.clone())
1970                .or_default()
1971                .push((rest.to_vec(), expr.clone()));
1972        }
1973
1974        let mut count: u16 = 0;
1975        for (key, nested) in &groups {
1976            if nested.len() == 1 && nested[0].0.is_empty() {
1977                // Simple leaf.
1978                if lazy_leaves && !Self::is_trivial_value(&nested[0].1) {
1979                    self.compile_thunk_immediate(&nested[0].1)?;
1980                } else {
1981                    self.compile_expr(&nested[0].1)?;
1982                }
1983            } else {
1984                // Recurse for deeper nesting.
1985                let mut deeper = prefix.to_vec();
1986                deeper.push(key.clone());
1987                self.compile_nested_attrset_inner(nested, lazy_leaves, &deeper)?;
1988            }
1989            self.emit_constant(VMValue::String(key.clone()))?;
1990            count += 1;
1991        }
1992
1993        self.emit(OpCode::MakeAttrs);
1994        self.emit_u16(count);
1995        self.stack_depth = self.stack_depth.saturating_sub(2 * count) + 1;
1996        Ok(())
1997    }
1998
1999    /// Emit a variable load for a name (local, upvalue, or with-scope).
2000    fn emit_variable_load(&mut self, name: &str) -> Result<(), CompileError> {
2001        if let Some(idx) = self.resolve_local(name) {
2002            self.emit(OpCode::GetLocal);
2003            self.emit_u16(self.local_stack_slot(idx));
2004        } else if let Some(uv_idx) = self.resolve_upvalue(name) {
2005            self.emit(OpCode::GetUpvalue);
2006            self.emit_u16(uv_idx as u16);
2007        } else if self.has_with_scope() {
2008            let name_idx = self.chunk.add_constant(VMValue::String(name.to_string()))?;
2009            self.emit(OpCode::LookupWith);
2010            self.emit_u16(name_idx);
2011        } else {
2012            return Err(CompileError::Unsupported(format!(
2013                "inherit: cannot resolve '{name}'"
2014            )));
2015        }
2016        Ok(())
2017    }
2018
2019    /// Emit variable load, restoring local depth on error.
2020    /// `local_idx` is the index into `self.locals` (for error recovery).
2021    fn emit_variable_load_restore(
2022        &mut self,
2023        name: &str,
2024        local_idx: u16,
2025        saved_depth: u32,
2026    ) -> Result<(), CompileError> {
2027        if let Some(outer_idx) = self.resolve_local(name) {
2028            self.emit(OpCode::GetLocal);
2029            self.emit_u16(self.local_stack_slot(outer_idx));
2030        } else if let Some(uv_idx) = self.resolve_upvalue(name) {
2031            self.emit(OpCode::GetUpvalue);
2032            self.emit_u16(uv_idx as u16);
2033        } else if self.has_with_scope() {
2034            let name_idx = self.chunk.add_constant(VMValue::String(name.to_string()))?;
2035            self.emit(OpCode::LookupWith);
2036            self.emit_u16(name_idx);
2037        } else {
2038            self.locals[local_idx as usize].depth = saved_depth;
2039            return Err(CompileError::Unsupported(format!(
2040                "inherit: cannot resolve '{name}' in enclosing scope"
2041            )));
2042        }
2043        Ok(())
2044    }
2045
2046    // ── Select (attrset.key) ───────────────────────────────────
2047
2048    /// Try to resolve an expression as a local variable slot.
2049    fn try_resolve_as_local(&self, expr: &ast::Expr) -> Option<u16> {
2050        if let ast::Expr::Ident(id) = expr {
2051            let name = ident_text(id);
2052            let idx = self.resolve_local(&name)?;
2053            Some(self.local_stack_slot(idx))
2054        } else {
2055            None
2056        }
2057    }
2058
2059    fn compile_select(&mut self, sel: &ast::Select) -> Result<(), CompileError> {
2060        let base = sel
2061            .expr()
2062            .ok_or_else(|| CompileError::MissingNode("select base".to_string()))?;
2063        let attrpath = sel
2064            .attrpath()
2065            .ok_or_else(|| CompileError::MissingNode("select attrpath".to_string()))?;
2066
2067        let segments: Vec<_> = attrpath.attrs().collect();
2068
2069        if let Some(default_expr) = sel.default_expr() {
2070            // `expr.a.b.c or default` — if ANY segment is missing (or the
2071            // intermediate value is not an attrset), evaluate the default.
2072            //
2073            // Strategy: for each segment (including non-last), check with
2074            // HasAttr before accessing.  On miss, jump to a shared default
2075            // path.  HasAttr returns false for non-attrset values, so this
2076            // also handles the "not an attrset" case.
2077            //
2078            // Stack invariant: at each segment, exactly one value (the
2079            // current attrset being traversed) sits on top.
2080            //
2081            //   compile_expr(&base)        ; [val]
2082            //   for each segment:
2083            //     Dup                       ; [val, val]
2084            //     HasAttr key               ; [val, bool]
2085            //     JumpIfFalse miss          ; [val]
2086            //     GetAttr key               ; [next_val]
2087            //   (last segment's GetAttr produces the result)
2088            //   Jump end
2089            //   miss:
2090            //   Pop                         ; []  (discard partial val)
2091            //   <compile default>           ; [default_val]
2092            //   end:
2093            self.compile_expr(&base)?;
2094            let depth_before = self.stack_depth; // D (one extra value: base)
2095            let mut miss_jumps: Vec<usize> = Vec::new();
2096            for (_i, attr) in segments.iter().enumerate() {
2097                if let Ok(key) = static_attr_name(attr) {
2098                    let key_idx = self.add_attr_key(key)?;
2099                    self.emit(OpCode::Dup);             // [val, val]
2100                    self.emit(OpCode::HasAttr);         // [val, bool]
2101                    self.emit_u16(key_idx);
2102                    miss_jumps.push(self.emit_jump(OpCode::JumpIfFalse)); // [val]
2103                    self.emit(OpCode::GetAttr);         // [next_val]
2104                    self.emit_u16(key_idx);
2105                } else {
2106                    self.emit(OpCode::Dup);             // [val, val]
2107                    self.compile_dynamic_attr_key(attr)?; // [val, val, key]
2108                    self.emit(OpCode::DynHasAttr);      // [val, bool]
2109                    miss_jumps.push(self.emit_jump(OpCode::JumpIfFalse)); // [val]
2110                    self.compile_dynamic_attr_key(attr)?; // [val, key]
2111                    self.emit(OpCode::DynGetAttr);      // [next_val]
2112                }
2113            }
2114            // All segments succeeded — result is on stack.
2115            // Stack depth here = depth_before (each Dup+HasAttr+JumpIfFalse+GetAttr is net 0).
2116            let end_jump = self.emit_jump(OpCode::Jump);
2117            // miss path: one value on stack (the partial traversal value)
2118            for mj in miss_jumps {
2119                self.patch_jump(mj)?;
2120            }
2121            // Reset stack depth to depth_before (we have the partial value on stack)
2122            self.stack_depth = depth_before;
2123            self.emit(OpCode::Pop);                    // depth_before - 1
2124            self.compile_expr(&default_expr)?;         // depth_before (default_val)
2125            self.patch_jump(end_jump)?;
2126            // Both paths leave exactly one result on stack: depth = depth_before
2127        } else {
2128            // Superinstruction: if base is a local and first segment is static,
2129            // use GetLocalAttr for the first access (saves one dispatch).
2130            let local_slot = self.try_resolve_as_local(&base);
2131
2132            for (i, attr) in segments.iter().enumerate() {
2133                if let Ok(key) = static_attr_name(attr) {
2134                    let key_idx = self.add_attr_key(key)?;
2135
2136                    if i == 0 {
2137                        if let Some(slot) = local_slot {
2138                            // Fused GetLocal + GetAttr.
2139                            self.emit(OpCode::GetLocalAttr);
2140                            self.emit_u16(slot);
2141                            self.emit_u16(key_idx);
2142                        } else {
2143                            self.compile_expr(&base)?;
2144                            self.emit(OpCode::GetAttr);
2145                            self.emit_u16(key_idx);
2146                        }
2147                    } else {
2148                        self.emit(OpCode::GetAttr);
2149                        self.emit_u16(key_idx);
2150                    }
2151                } else {
2152                    // Dynamic segment: compile base if needed, then key, then DynGetAttr.
2153                    if i == 0 {
2154                        self.compile_expr(&base)?;
2155                    }
2156                    self.compile_dynamic_attr_key(attr)?;
2157                    self.emit(OpCode::DynGetAttr);
2158                }
2159            }
2160        }
2161
2162        Ok(())
2163    }
2164
2165    /// Compile a dynamic attribute key (interpolated string or dynamic expr).
2166    fn compile_dynamic_attr_key(&mut self, attr: &ast::Attr) -> Result<(), CompileError> {
2167        match attr {
2168            ast::Attr::Dynamic(d) => {
2169                let expr = d.expr().ok_or_else(|| {
2170                    CompileError::MissingNode("dynamic attr key expr".to_string())
2171                })?;
2172                self.compile_expr(&expr)
2173            }
2174            ast::Attr::Str(s) => {
2175                let key_expr = ast::Expr::Str(s.clone());
2176                self.compile_expr(&key_expr)
2177            }
2178            ast::Attr::Ident(ident) => {
2179                self.emit_constant(VMValue::String(ident_text(ident)))
2180            }
2181        }
2182    }
2183
2184    // ── HasAttr (expr ? key) ───────────────────────────────────
2185
2186    fn compile_has_attr(&mut self, ha: &ast::HasAttr) -> Result<(), CompileError> {
2187        let base = ha
2188            .expr()
2189            .ok_or_else(|| CompileError::MissingNode("hasattr base".to_string()))?;
2190        let attrpath = ha
2191            .attrpath()
2192            .ok_or_else(|| CompileError::MissingNode("hasattr attrpath".to_string()))?;
2193
2194        let segments: Vec<_> = attrpath.attrs().collect();
2195
2196        if segments.len() == 1 {
2197            // Single-segment: compile base, then HasAttr or DynHasAttr.
2198            self.compile_expr(&base)?;
2199            if let Ok(key) = static_attr_name(&segments[0]) {
2200                let key_idx = self.add_attr_key(key)?;
2201                self.emit(OpCode::HasAttr);
2202                self.emit_u16(key_idx);
2203            } else {
2204                self.compile_dynamic_attr_key(&segments[0])?;
2205                self.emit(OpCode::DynHasAttr);
2206            }
2207            return Ok(());
2208        }
2209
2210        // Multi-segment hasattr: `a ? x.y.z`
2211        // Compiled as a chain of HasAttr checks with short-circuit jumps.
2212        // For each segment except the last, we check HasAttr and GetAttr
2213        // to drill into the nested attrset.
2214        //
2215        // The base expression is re-evaluated for each intermediate step,
2216        // which is correct because Nix is pure and the compiler wraps
2217        // non-trivial expressions in thunks.
2218        let mut false_jumps: Vec<usize> = Vec::new();
2219        // Save stack depth before first segment — all short-circuit
2220        // targets must converge to (depth_before + 1).
2221        let depth_before = self.stack_depth;
2222
2223        for (i, seg) in segments.iter().enumerate() {
2224            // Build the prefix path: base.seg0.seg1...seg(i-1)
2225            self.compile_expr(&base)?;
2226            for prev_seg in &segments[..i] {
2227                if let Ok(prev_key) = static_attr_name(prev_seg) {
2228                    let prev_idx = self.add_attr_key(prev_key)?;
2229                    self.emit(OpCode::GetAttr);
2230                    self.emit_u16(prev_idx);
2231                } else {
2232                    self.compile_dynamic_attr_key(prev_seg)?;
2233                    self.emit(OpCode::DynGetAttr);
2234                }
2235            }
2236            if let Ok(key) = static_attr_name(seg) {
2237                let key_idx = self.add_attr_key(key)?;
2238                self.emit(OpCode::HasAttr);
2239                self.emit_u16(key_idx);
2240            } else {
2241                self.compile_dynamic_attr_key(seg)?;
2242                self.emit(OpCode::DynHasAttr);
2243            }
2244
2245            // For all segments except the last, short-circuit on false.
2246            if i < segments.len() - 1 {
2247                false_jumps.push(self.emit_jump(OpCode::JumpIfFalse));
2248                // Reset depth for next iteration — each JumpIfFalse pops
2249                // the condition, and at the false target the stack is at
2250                // depth_before (no result pushed yet). The next segment
2251                // starts fresh from depth_before.
2252                self.stack_depth = depth_before;
2253            }
2254        }
2255
2256        // Jump over the false path.
2257        let done_jump = self.emit_jump(OpCode::Jump);
2258
2259        // False path: push false for any short-circuit jump.
2260        // All false_jumps target here, where stack is at depth_before.
2261        self.stack_depth = depth_before;
2262        for fj in false_jumps {
2263            self.patch_jump(fj)?;
2264        }
2265        self.emit(OpCode::False);
2266        // Now stack_depth = depth_before + 1 (same as the true path).
2267
2268        self.patch_jump(done_jump)?;
2269        Ok(())
2270    }
2271
2272    // ── If/then/else ───────────────────────────────────────────
2273
2274    fn compile_if(&mut self, ie: &ast::IfElse) -> Result<(), CompileError> {
2275        let cond = ie
2276            .condition()
2277            .ok_or_else(|| CompileError::MissingNode("if condition".to_string()))?;
2278        let then_body = ie
2279            .body()
2280            .ok_or_else(|| CompileError::MissingNode("if then".to_string()))?;
2281        let else_body = ie
2282            .else_body()
2283            .ok_or_else(|| CompileError::MissingNode("if else".to_string()))?;
2284
2285        // Save tail position — both branches inherit it.
2286        let tail = self.tail_position;
2287
2288        // Compile condition (not in tail position).
2289        self.tail_position = false;
2290        self.compile_expr(&cond)?;
2291        // Jump to else if false.
2292        let else_jump = self.emit_jump(OpCode::JumpIfFalse);
2293        // After JumpIfFalse, the condition is popped. Save the depth here —
2294        // this is the stack depth at which both branches start.
2295        let depth_at_branch = self.stack_depth;
2296        // Compile then branch (tail position propagated).
2297        self.tail_position = tail;
2298        self.compile_expr(&then_body)?;
2299        // Jump past else.
2300        let end_jump = self.emit_jump(OpCode::Jump);
2301        // Patch else jump. Reset stack_depth to the branch start —
2302        // the else branch starts with the same stack as the then branch.
2303        self.stack_depth = depth_at_branch;
2304        self.patch_jump(else_jump)?;
2305        // Compile else branch (tail position propagated).
2306        self.tail_position = tail;
2307        self.compile_expr(&else_body)?;
2308        // Both branches push exactly one result value, so stack_depth
2309        // is now depth_at_branch + 1 (correct for the merge point).
2310        // Patch end jump.
2311        self.patch_jump(end_jump)?;
2312        Ok(())
2313    }
2314
2315    // ── Lambda ─────────────────────────────────────────────────
2316
2317    fn compile_lambda(&mut self, lam: &ast::Lambda) -> Result<(), CompileError> {
2318        let param = lam
2319            .param()
2320            .ok_or_else(|| CompileError::MissingNode("lambda param".to_string()))?;
2321        let body = lam
2322            .body()
2323            .ok_or_else(|| CompileError::MissingNode("lambda body".to_string()))?;
2324
2325        // Compile the function body as a separate chunk (sharing the interner).
2326        let mut func_compiler = Compiler::with_interner(Rc::clone(&self.interner));
2327        func_compiler.scope_depth = 1; // function body is its own scope
2328        // Link to enclosing compiler for upvalue resolution.
2329        func_compiler.enclosing = Some(self as *mut Compiler);
2330        // Propagate base directory for relative path resolution.
2331        func_compiler.base_dir = self.base_dir.clone();
2332        // The function argument will be at slot 0 (pushed by VM Call handler).
2333        func_compiler.stack_depth = 1;
2334
2335        let mut formals_metadata: Vec<(String, bool)> = Vec::new();
2336        let (arity, name) = match &param {
2337            ast::Param::IdentParam(ip) => {
2338                let ident = ip
2339                    .ident()
2340                    .ok_or_else(|| CompileError::MissingNode("lambda ident".to_string()))?;
2341                let name = ident_text(&ident);
2342                // The argument occupies slot 0 in the function's local stack.
2343                func_compiler.add_local(name.clone())?;
2344                (1, Some(name))
2345            }
2346            ast::Param::Pattern(pat) => {
2347                // Pattern destructuring: { a, b, c ? default }
2348                // The entire argument attrset occupies slot 0.
2349                // Then we extract individual bindings.
2350                let bind_name = pat
2351                    .pat_bind()
2352                    .and_then(|pb| pb.ident())
2353                    .map(|id| ident_text(&id));
2354
2355                if let Some(ref bname) = bind_name {
2356                    func_compiler.add_local(bname.clone())?;
2357                } else {
2358                    // Anonymous slot 0 for the argument attrset.
2359                    func_compiler.add_local("__arg".to_string())?;
2360                }
2361
2362                // For each pattern entry, extract the field from the arg.
2363                let mut field_names: Vec<(String, Option<ast::Expr>)> = Vec::new();
2364                for entry in pat.pat_entries() {
2365                    let ident = entry
2366                        .ident()
2367                        .ok_or_else(|| CompileError::MissingNode("pattern entry ident".to_string()))?;
2368                    let fname = ident_text(&ident);
2369                    let default = entry.default();
2370                    formals_metadata.push((fname.clone(), default.is_some()));
2371                    field_names.push((fname, default));
2372                }
2373
2374                // Push local slots for each pattern field.
2375                for (fname, _) in &field_names {
2376                    func_compiler.emit(OpCode::Null); // emit() tracks stack_depth
2377                    func_compiler.add_local(fname.clone())?;
2378                }
2379
2380                // Extract each field from slot 0 (the arg attrset).
2381                for (i, (fname, default)) in field_names.iter().enumerate() {
2382                    let key_idx = func_compiler.add_attr_key(fname.clone())?;
2383                    if let Some(default_expr) = default {
2384                        // Lazy default: only evaluate default_expr when the
2385                        // key is absent from the argument attrset AND the
2386                        // parameter is actually forced.  Nix semantics require
2387                        // defaults to be fully lazy — they must not be forced
2388                        // at function entry even when the key is missing.
2389                        //
2390                        // Emit:
2391                        //   GetLocal 0        ; push arg attrset
2392                        //   HasAttr key_idx   ; bool: key present?
2393                        //   JumpIfFalse L1    ; key missing → default path
2394                        //   GetLocal 0        ; key present → fetch value
2395                        //   GetAttr key_idx
2396                        //   Jump L2
2397                        // L1:
2398                        //   MakeThunk(default) ; wrap in thunk — only forced on use
2399                        // L2:
2400                        //   ; result on stack
2401                        func_compiler.emit(OpCode::GetLocal);
2402                        func_compiler.emit_u16(0); // arg attrset at slot 0
2403                        func_compiler.emit(OpCode::HasAttr);
2404                        func_compiler.emit_u16(key_idx);
2405                        let else_jump = func_compiler.emit_jump(OpCode::JumpIfFalse);
2406                        // After JumpIfFalse pops the bool, save depth.
2407                        let depth_at_branch = func_compiler.stack_depth;
2408                        // Key exists — get the value.
2409                        func_compiler.emit(OpCode::GetLocal);
2410                        func_compiler.emit_u16(0);
2411                        func_compiler.emit(OpCode::GetAttr);
2412                        func_compiler.emit_u16(key_idx);
2413                        let end_jump = func_compiler.emit_jump(OpCode::Jump);
2414                        // Key missing — wrap default in a thunk (lazy).
2415                        func_compiler.stack_depth = depth_at_branch;
2416                        func_compiler.patch_jump(else_jump)?;
2417                        func_compiler.compile_thunk_immediate(default_expr)?;
2418                        // Both branches leave exactly one value on the stack.
2419                        func_compiler.patch_jump(end_jump)?;
2420                    } else {
2421                        // Use GetAttr (will error if missing).
2422                        func_compiler.emit(OpCode::GetLocal);
2423                        func_compiler.emit_u16(0); // arg attrset at slot 0
2424                        func_compiler.emit(OpCode::GetAttr);
2425                        func_compiler.emit_u16(key_idx);
2426                    }
2427                    // Store into the field's local slot and pop the value from the stack.
2428                    let field_slot = func_compiler.find_local_slot(fname);
2429                    func_compiler.emit(OpCode::SetLocal);
2430                    func_compiler.emit_u16(field_slot);
2431                    func_compiler.emit(OpCode::Pop);
2432                    let _ = i; // suppress unused
2433                }
2434
2435                (1, bind_name)
2436            }
2437        };
2438
2439        // Compile the body inside the function compiler.
2440        // The lambda body is in tail position — any direct call can be a tail call.
2441        func_compiler.tail_position = true;
2442        func_compiler.compile_expr(&body)?;
2443        func_compiler.emit(OpCode::Return);
2444
2445        // Collect upvalue descriptors from the function compiler.
2446        let upvalue_count = func_compiler.upvalues.len();
2447        let upvalue_descs: Vec<UpvalueDesc> = func_compiler.upvalues.clone();
2448
2449        // Store the compiled function as a constant in the outer chunk.
2450        let closure = VMValue::Closure(VMClosure {
2451            chunk: Rc::new(func_compiler.chunk),
2452            upvalues: Vec::new(), // populated at runtime by MakeClosure
2453            arity,
2454            name,
2455            formals: formals_metadata,
2456        });
2457
2458        if upvalue_count == 0 {
2459            // No upvalues: simple constant closure.
2460            self.emit_constant(closure)
2461        } else {
2462            // Emit MakeClosure with upvalue descriptors.
2463            let idx = self.chunk.add_constant(closure)?;
2464            self.emit(OpCode::MakeClosure);
2465            self.stack_depth += 1; // MakeClosure pushes the closure
2466            self.emit_u16(idx);
2467            // Emit upvalue count as u16.
2468            self.emit_u16(upvalue_count as u16);
2469            // For each upvalue: is_local (u8) + index (u16).
2470            for uv in &upvalue_descs {
2471                self.chunk.write_byte(if uv.is_local { 1 } else { 0 }, self.current_line);
2472                self.emit_u16(uv.index);
2473            }
2474            Ok(())
2475        }
2476    }
2477
2478    // ── Apply (function call) ──────────────────────────────────
2479
2480    fn compile_apply(&mut self, app: &ast::Apply) -> Result<(), CompileError> {
2481        let func = app
2482            .lambda()
2483            .ok_or_else(|| CompileError::MissingNode("apply function".to_string()))?;
2484        let arg = app
2485            .argument()
2486            .ok_or_else(|| CompileError::MissingNode("apply argument".to_string()))?;
2487
2488        // Save tail position — arguments and function are NOT in tail position.
2489        let tail = self.tail_position;
2490        self.tail_position = false;
2491
2492        // Special form: `import <path>` compiles to path + Import opcode.
2493        if let ast::Expr::Ident(ref id) = func {
2494            let name = ident_text(id);
2495            if name == "import" {
2496                self.compile_expr(&arg)?;
2497                self.emit(OpCode::Import);
2498                return Ok(());
2499            }
2500        }
2501
2502        // Choose Call vs TailCall based on whether this apply is in tail position.
2503        let call_op = if tail { OpCode::TailCall } else { OpCode::Call };
2504
2505        // Superinstruction: if the function is a local variable, use
2506        // GetLocalCall to save one dispatch cycle (only for non-tail calls;
2507        // tail calls use the standard TailCall opcode which handles frame reuse).
2508        if !tail {
2509            if let Some(slot) = self.try_resolve_as_local(&func) {
2510                self.compile_arg_maybe_thunk(&arg)?;
2511                self.emit(OpCode::GetLocalCall);
2512                self.emit_u16(slot);
2513                return Ok(());
2514            }
2515        }
2516
2517        // Normal: push function, then argument, then Call/TailCall.
2518        self.compile_expr(&func)?;
2519        self.compile_arg_maybe_thunk(&arg)?;
2520        self.emit(call_op);
2521        Ok(())
2522    }
2523
2524    /// Compile a function argument with call-by-need semantics.
2525    /// Trivial expressions (literals, idents, paths, lambdas) are inlined.
2526    /// Non-trivial expressions are wrapped in thunks for lazy evaluation.
2527    /// This matches CppNix's maybeThunk for function arguments.
2528
2529    // ── Binary operations ──────────────────────────────────────
2530
2531    fn compile_binop(&mut self, binop: &ast::BinOp) -> Result<(), CompileError> {
2532        let lhs = binop
2533            .lhs()
2534            .ok_or_else(|| CompileError::MissingNode("binop lhs".to_string()))?;
2535        let rhs = binop
2536            .rhs()
2537            .ok_or_else(|| CompileError::MissingNode("binop rhs".to_string()))?;
2538        let op = binop
2539            .operator()
2540            .ok_or_else(|| CompileError::MissingNode("binop operator".to_string()))?;
2541
2542        match op {
2543            // Short-circuit: && compiles as if/then/else
2544            ast::BinOpKind::And => {
2545                self.compile_expr(&lhs)?;
2546                let false_jump = self.emit_jump(OpCode::JumpIfFalse);
2547                // After JumpIfFalse pops lhs, save depth at branch start.
2548                let depth_at_branch = self.stack_depth;
2549                self.compile_expr(&rhs)?;
2550                let end_jump = self.emit_jump(OpCode::Jump);
2551                // Reset to branch-start depth for the false path.
2552                self.stack_depth = depth_at_branch;
2553                self.patch_jump(false_jump)?;
2554                self.emit(OpCode::False);
2555                self.patch_jump(end_jump)?;
2556            }
2557            // Short-circuit: || compiles as if/then/else
2558            ast::BinOpKind::Or => {
2559                self.compile_expr(&lhs)?;
2560                let true_jump = self.emit_jump(OpCode::JumpIfTrue);
2561                // After JumpIfTrue pops lhs, save depth at branch start.
2562                let depth_at_branch = self.stack_depth;
2563                self.compile_expr(&rhs)?;
2564                let end_jump = self.emit_jump(OpCode::Jump);
2565                // Reset to branch-start depth for the true path.
2566                self.stack_depth = depth_at_branch;
2567                self.patch_jump(true_jump)?;
2568                self.emit(OpCode::True);
2569                self.patch_jump(end_jump)?;
2570            }
2571            // Short-circuit: -> is !a || b, so if lhs is false => true
2572            ast::BinOpKind::Implication => {
2573                self.compile_expr(&lhs)?;
2574                let false_jump = self.emit_jump(OpCode::JumpIfFalse);
2575                // After JumpIfFalse pops lhs, save depth at branch start.
2576                let depth_at_branch = self.stack_depth;
2577                self.compile_expr(&rhs)?;
2578                let end_jump = self.emit_jump(OpCode::Jump);
2579                // Reset to branch-start depth for the false path.
2580                self.stack_depth = depth_at_branch;
2581                self.patch_jump(false_jump)?;
2582                self.emit(OpCode::True);
2583                self.patch_jump(end_jump)?;
2584            }
2585            // Non-short-circuit: compile both sides, then emit opcode.
2586            _ => {
2587                self.compile_expr(&lhs)?;
2588                self.compile_expr(&rhs)?;
2589                match op {
2590                    ast::BinOpKind::Add => self.emit(OpCode::Add),
2591                    ast::BinOpKind::Sub => self.emit(OpCode::Sub),
2592                    ast::BinOpKind::Mul => self.emit(OpCode::Mul),
2593                    ast::BinOpKind::Div => self.emit(OpCode::Div),
2594                    ast::BinOpKind::Equal => self.emit(OpCode::Equal),
2595                    ast::BinOpKind::NotEqual => self.emit(OpCode::NotEqual),
2596                    ast::BinOpKind::Less => self.emit(OpCode::Less),
2597                    ast::BinOpKind::LessOrEq => self.emit(OpCode::LessEqual),
2598                    ast::BinOpKind::More => self.emit(OpCode::Greater),
2599                    ast::BinOpKind::MoreOrEq => self.emit(OpCode::GreaterEqual),
2600                    ast::BinOpKind::Update => self.emit(OpCode::UpdateAttrs),
2601                    ast::BinOpKind::Concat => self.emit(OpCode::Concat),
2602                    ast::BinOpKind::And
2603                    | ast::BinOpKind::Or
2604                    | ast::BinOpKind::Implication => unreachable!(),
2605                    ast::BinOpKind::PipeRight | ast::BinOpKind::PipeLeft => {
2606                        return Err(CompileError::Unsupported("pipe operators".to_string()));
2607                    }
2608                }
2609            }
2610        }
2611        Ok(())
2612    }
2613
2614    // ── Unary operations ───────────────────────────────────────
2615
2616    fn compile_unary(&mut self, op: &ast::UnaryOp) -> Result<(), CompileError> {
2617        let inner = op
2618            .expr()
2619            .ok_or_else(|| CompileError::MissingNode("unary expr".to_string()))?;
2620        let kind = op
2621            .operator()
2622            .ok_or_else(|| CompileError::MissingNode("unary operator".to_string()))?;
2623        self.compile_expr(&inner)?;
2624        match kind {
2625            ast::UnaryOpKind::Negate => self.emit(OpCode::Negate),
2626            ast::UnaryOpKind::Invert => self.emit(OpCode::Not),
2627        }
2628        Ok(())
2629    }
2630
2631    // ── With ───────────────────────────────────────────────────
2632
2633    fn compile_with(&mut self, with: &ast::With) -> Result<(), CompileError> {
2634        let ns = with
2635            .namespace()
2636            .ok_or_else(|| CompileError::MissingNode("with namespace".to_string()))?;
2637        let body = with
2638            .body()
2639            .ok_or_else(|| CompileError::MissingNode("with body".to_string()))?;
2640
2641        // Compile the namespace expression.
2642        self.compile_expr(&ns)?;
2643
2644        // Dup: one copy goes to PushWith (consumed), the other stays as a
2645        // hidden local so thunks inside the body can capture it as an upvalue.
2646        // Net stack effect of Dup (+1) + PushWith (-1) = 0.
2647        self.emit(OpCode::Dup);
2648        self.emit(OpCode::PushWith);
2649
2650        // Register the remaining copy as a hidden local.
2651        let slot = self.add_local("__with_scope".to_string())?;
2652        self.with_scope_locals.push(slot);
2653        self.with_depth += 1;
2654
2655        // Compile the body.
2656        self.compile_expr(&body)?;
2657
2658        // Pop the with-scope.
2659        self.emit(OpCode::PopWith);
2660        self.with_depth -= 1;
2661        self.with_scope_locals.pop();
2662
2663        // Clean up hidden local: body result is TOS, hidden local is below.
2664        // Stack: [..., __with_scope, body_result]
2665        // Swap them so body_result survives after Pop.
2666        // Use SetLocal to overwrite the hidden local with body_result,
2667        // then Pop to remove the duplicate TOS.
2668        self.emit(OpCode::SetLocal);
2669        self.emit_u16(slot);
2670        self.emit(OpCode::Pop);
2671        // Adjust: one slot removed (the hidden local is now body_result).
2672        self.stack_depth = slot + 1;
2673        self.locals.pop();
2674
2675        Ok(())
2676    }
2677
2678    // ── Assert ─────────────────────────────────────────────────
2679
2680    fn compile_assert(&mut self, assert: &ast::Assert) -> Result<(), CompileError> {
2681        let cond = assert
2682            .condition()
2683            .ok_or_else(|| CompileError::MissingNode("assert condition".to_string()))?;
2684        let body = assert
2685            .body()
2686            .ok_or_else(|| CompileError::MissingNode("assert body".to_string()))?;
2687        // Save tail position — the body inherits it, the condition does not.
2688        let tail = self.tail_position;
2689        self.tail_position = false;
2690        self.compile_expr(&cond)?;
2691        self.emit(OpCode::Assert);
2692        // The assert body is in tail position if the assert itself is.
2693        self.tail_position = tail;
2694        self.compile_expr(&body)?;
2695        Ok(())
2696    }
2697
2698    // ── Lists ──────────────────────────────────────────────────
2699
2700    fn compile_list(&mut self, list: &ast::List) -> Result<(), CompileError> {
2701        let items: Vec<_> = list.items().collect();
2702        let count = u16::try_from(items.len())
2703            .map_err(|_| CompileError::Unsupported("list too large".to_string()))?;
2704        for item in &items {
2705            self.compile_expr(item)?;
2706        }
2707        self.emit(OpCode::MakeList);
2708        self.emit_u16(count);
2709        // MakeList pops count elements, pushes 1 list.
2710        self.stack_depth = self.stack_depth.saturating_sub(count) + 1;
2711        Ok(())
2712    }
2713
2714    // ── Emission helpers ───────────────────────────────────────
2715
2716    fn emit(&mut self, op: OpCode) {
2717        self.chunk.write_op(op, self.current_line);
2718        // Track stack depth for correct local-variable slot assignment.
2719        match op {
2720            // Push one value
2721            OpCode::Null | OpCode::True | OpCode::False
2722            | OpCode::GetLocal | OpCode::GetUpvalue
2723            | OpCode::PushBuiltins | OpCode::LookupWith => {
2724                self.stack_depth += 1;
2725            }
2726            // Dup: push a copy of TOS (net +1)
2727            OpCode::Dup => {
2728                self.stack_depth += 1;
2729            }
2730            // Pop one value
2731            OpCode::Pop | OpCode::PushWith
2732            | OpCode::Assert | OpCode::Throw | OpCode::Return => {
2733                self.stack_depth = self.stack_depth.saturating_sub(1);
2734            }
2735            // Pop 2, push 1 (net -1)
2736            OpCode::Add | OpCode::Sub | OpCode::Mul | OpCode::Div
2737            | OpCode::Equal | OpCode::NotEqual | OpCode::Less
2738            | OpCode::Greater | OpCode::LessEqual | OpCode::GreaterEqual
2739            | OpCode::And | OpCode::Or | OpCode::Implication
2740            | OpCode::Concat | OpCode::UpdateAttrs
2741            | OpCode::Call | OpCode::TailCall | OpCode::DynGetAttr | OpCode::DynHasAttr => {
2742                self.stack_depth = self.stack_depth.saturating_sub(1);
2743            }
2744            // Pop 1, push 1 (net 0)
2745            OpCode::Negate | OpCode::Not | OpCode::Force
2746            | OpCode::GetAttr | OpCode::HasAttr
2747            | OpCode::Import => {}
2748            // SetLocal: no stack change (writes to slot)
2749            OpCode::SetLocal | OpCode::SetUpvalue => {}
2750            // PopWith: removes from with-scope stack, not value stack
2751            OpCode::PopWith => {}
2752            // Jump: no stack change
2753            OpCode::Jump => {}
2754            // JumpIfFalse/JumpIfTrue: pop condition
2755            OpCode::JumpIfFalse | OpCode::JumpIfTrue => {
2756                self.stack_depth = self.stack_depth.saturating_sub(1);
2757            }
2758            // SelectOrDefault: pop 2 (default + attrset), push 1 (net -1)
2759            OpCode::SelectOrDefault => {
2760                self.stack_depth = self.stack_depth.saturating_sub(1);
2761            }
2762            // DynSelectOrDefault: pop 3 (default + key + attrset), push 1 (net -2)
2763            OpCode::DynSelectOrDefault => {
2764                self.stack_depth = self.stack_depth.saturating_sub(2);
2765            }
2766            // GetLocalAttr: push 1 (fused GetLocal+GetAttr: push local, get attr = net +1)
2767            OpCode::GetLocalAttr => {
2768                self.stack_depth += 1;
2769            }
2770            // GetLocalCall: pop 1 arg, get local, call (push local then pop 2 push 1 = net -1 from the arg)
2771            OpCode::GetLocalCall => {
2772                self.stack_depth = self.stack_depth.saturating_sub(1);
2773            }
2774            // CallBuiltin: handled in emit_u16 for arg count
2775            OpCode::CallBuiltin => {
2776                self.stack_depth = self.stack_depth.saturating_sub(1);
2777            }
2778            // Complex opcodes with inline operands: handled by callers
2779            // MakeAttrs: pops 2*count, pushes 1 (handled by caller)
2780            // MakeList: pops count, pushes 1 (handled by caller)
2781            // MakeClosure: pushes 1 (handled by caller)
2782            // MakeThunk: pushes 1 (handled by caller)
2783            // Interpolate: pops count, pushes 1 (handled by caller)
2784            // PatchThunkUpvalues: no stack change
2785            OpCode::Constant | OpCode::MakeAttrs | OpCode::MakeList
2786            | OpCode::MakeClosure | OpCode::MakeThunk | OpCode::MakeLazyThunk
2787            | OpCode::Interpolate | OpCode::PatchThunkUpvalues => {}
2788        }
2789    }
2790
2791
2792    fn emit_u16(&mut self, value: u16) {
2793        self.chunk.write_u16(value, self.current_line);
2794    }
2795
2796    fn emit_constant(&mut self, value: VMValue) -> Result<(), CompileError> {
2797        let idx = self.chunk.add_constant(value)?;
2798        self.emit(OpCode::Constant);
2799        self.stack_depth += 1; // Constant pushes one value
2800        self.emit_u16(idx);
2801        Ok(())
2802    }
2803
2804    /// Add a string constant for an attribute key and pre-intern its symbol.
2805    ///
2806    /// The pre-interned symbol is stored in `chunk.key_symbols` so the VM
2807    /// can skip the `intern()` call on every `GetAttr`/`HasAttr` dispatch.
2808    fn add_attr_key(&mut self, key: String) -> Result<u16, CompileError> {
2809        let sym = self.interner.borrow_mut().intern(&key);
2810        self.chunk.add_key_constant(VMValue::String(key), sym)
2811    }
2812
2813    /// Emit a jump instruction with a placeholder target.
2814    /// Returns the offset of the placeholder (to be patched later).
2815    fn emit_jump(&mut self, op: OpCode) -> usize {
2816        self.emit(op);
2817        let offset = self.chunk.len();
2818        self.emit_u16(0xFFFF); // placeholder
2819        offset
2820    }
2821
2822    /// Patch a previously emitted jump to point to the current position.
2823    fn patch_jump(&mut self, placeholder_offset: usize) -> Result<(), CompileError> {
2824        let target = self.chunk.len();
2825        let target_u16 = u16::try_from(target).map_err(|_| CompileError::JumpOverflow)?;
2826        self.chunk.patch_u16(placeholder_offset, target_u16);
2827        Ok(())
2828    }
2829
2830    // ── Scope management ───────────────────────────────────────
2831
2832    fn begin_scope(&mut self) {
2833        self.scope_depth += 1;
2834    }
2835
2836    fn end_scope(&mut self, binding_count: u16) {
2837        // We need to preserve the top-of-stack (the body result) and
2838        // remove the local variable slots below it. Strategy:
2839        // Store the result in a temporary position, pop locals, restore.
2840        // Since we know exactly how many locals to pop, we emit Pop
2841        // instructions after moving the result.
2842        //
2843        // The value stack looks like: [... locals... body_result]
2844        // We need to get it to: [... body_result]
2845        //
2846        // We use SetLocal to the first local's slot to stash the body result,
2847        // then pop the remaining locals, then the stashed value is in the right place.
2848        //
2849        // Actually, a simpler approach: we know the body result is on top.
2850        // We pop N locals from under it. Since we can't do that directly,
2851        // we use a series of operations:
2852        // For N locals to pop, we need to move the result down.
2853        // The most straightforward: use a "swap-and-pop" sequence.
2854        //
2855        // Simplest correct approach for now: emit Pop for each local
2856        // *under* the result. We do this by emitting SetLocal to slot 0
2857        // of the scope (to stash the result), popping N-1, then GetLocal 0.
2858        // Actually that clobbers the first local.
2859        //
2860        // Even simpler: the VM can interpret end_scope specially, or we
2861        // can stash in a way that doesn't conflict. For Phase 1, since
2862        // the VM knows the locals, we'll use a direct approach:
2863        //
2864        // The result is on the stack top. Below it are `binding_count` locals.
2865        // We want to discard those locals but keep the result.
2866        // Emit: for each local (except we preserve the result on top),
2867        // we swap the result down and pop the old top.
2868        //
2869        // But we don't have a Swap opcode. Let's just do:
2870        // 1. The locals were at known stack positions.
2871        // 2. The body result is above them.
2872        // 3. After removing all locals from self.locals, the VM Pop
2873        //    instructions will maintain the stack.
2874        //
2875        // For correctness: we need the body result on top and locals gone.
2876        // Plan: emit nothing for the locals themselves (they'll be implicitly
2877        // dead). Instead, note: the VM stack still has them. We need to
2878        // actually remove them.
2879        //
2880        // Correct plan for Phase 1:
2881        // The stack is: [... (locals) (body_result)]
2882        // We need: [... (body_result)]
2883        // We can store body_result into the first local's slot,
2884        // then pop (binding_count - 1) times, and the first local slot
2885        // now holds the result.
2886        //
2887        // Wait, we need to be more careful. The locals are at specific
2888        // absolute positions. After the body result, the stack is:
2889        //
2890        // stack_base + 0: local_0
2891        // stack_base + 1: local_1
2892        // ...
2893        // stack_base + N-1: local_N-1
2894        // stack_base + N: body_result  <-- top
2895        //
2896        // We want the stack to be: [... body_result] at stack_base.
2897        // So: set slot (stack_base + 0) = body_result, then pop N times.
2898        // That gives us: [body_result] at stack_base. But we popped N,
2899        // and there are N+1 entries (N locals + result), so we pop N items
2900        // leaving 1.
2901        //
2902        // Hmm, SetLocal doesn't pop. It just writes. So after SetLocal(base+0),
2903        // the stack is: [result local_1 ... local_N-1 body_result]
2904        // Then pop N times: [result]
2905        // Perfect.
2906
2907        if binding_count > 0 {
2908            // Use the first local's actual stack slot (not locals vector index)
2909            // to correctly handle cases where anonymous values sit on the
2910            // stack between the frame base and the scope's locals.
2911            let first_local_idx = self.locals.len() - binding_count as usize;
2912            let base_slot = self.locals[first_local_idx].slot;
2913            self.emit(OpCode::SetLocal);
2914            self.emit_u16(base_slot);
2915            for _ in 0..binding_count {
2916                self.emit(OpCode::Pop);
2917            }
2918            // Update stack_depth: we removed binding_count stack entries
2919            // but the body result now sits at base_slot.
2920            self.stack_depth = base_slot + 1;
2921        }
2922
2923        // Remove locals from the compiler's tracking.
2924        while let Some(local) = self.locals.last() {
2925            if local.depth < self.scope_depth {
2926                break;
2927            }
2928            self.locals.pop();
2929        }
2930        self.scope_depth -= 1;
2931    }
2932
2933    /// Add a local variable to the current scope. Returns its stack slot.
2934    fn add_local(&mut self, name: String) -> Result<u16, CompileError> {
2935        if self.locals.len() >= u16::MAX as usize {
2936            return Err(CompileError::TooManyLocals);
2937        }
2938        // The local's stack slot is the current stack_depth minus 1,
2939        // because the value (e.g. Null placeholder) was already pushed
2940        // onto the stack before add_local is called.
2941        let slot = self.stack_depth - 1;
2942        self.locals.push(Local {
2943            name,
2944            depth: self.scope_depth,
2945            is_captured: false,
2946            slot,
2947        });
2948        Ok(slot)
2949    }
2950
2951    /// Resolve a local variable by name, returning its stack slot index.
2952    /// Searches from innermost scope outward.
2953    fn resolve_local(&self, name: &str) -> Option<u16> {
2954        for (i, local) in self.locals.iter().enumerate().rev() {
2955            if local.name == name && local.depth != u32::MAX {
2956                return Some(i as u16);
2957            }
2958        }
2959        None
2960    }
2961
2962    /// Get the actual VM stack slot for a local at the given locals-vector index.
2963    fn local_stack_slot(&self, locals_idx: u16) -> u16 {
2964        self.locals[locals_idx as usize].slot
2965    }
2966
2967    /// Find the VM stack slot of a local by name (must exist).
2968    /// Returns the actual stack position (relative to frame base),
2969    /// which may differ from the locals-vector index.
2970    fn find_local_slot(&self, name: &str) -> u16 {
2971        let idx = self.resolve_local(name)
2972            .unwrap_or_else(|| panic!("local '{name}' not found"));
2973        self.locals[idx as usize].slot
2974    }
2975
2976    /// Find the VM stack slot of a local by name, returning `None` if not found.
2977    fn find_local_slot_opt(&self, name: &str) -> Option<u16> {
2978        self.resolve_local(name)
2979            .map(|idx| self.locals[idx as usize].slot)
2980    }
2981
2982    /// Add an upvalue to this compiler's upvalue list.
2983    /// Returns the upvalue index. Deduplicates: if the same upvalue
2984    /// (same is_local + index) already exists, returns its index.
2985    fn add_upvalue(&mut self, is_local: bool, index: u16) -> Result<u8, CompileError> {
2986        // Check for existing identical upvalue.
2987        for (i, uv) in self.upvalues.iter().enumerate() {
2988            if uv.is_local == is_local && uv.index == index {
2989                return Ok(i as u8);
2990            }
2991        }
2992        if self.upvalues.len() >= 256 {
2993            return Err(CompileError::Unsupported("too many upvalues (max 256)".to_string()));
2994        }
2995        let idx = self.upvalues.len() as u8;
2996        self.upvalues.push(UpvalueDesc { is_local, index });
2997        Ok(idx)
2998    }
2999
3000    /// Resolve a variable as an upvalue by walking the enclosing compiler chain.
3001    /// Uses Lua 5.x-style upvalue resolution: if the variable is a local in
3002    /// the enclosing scope, capture it directly. If it's an upvalue in the
3003    /// enclosing scope, capture that upvalue.
3004    fn resolve_upvalue(&mut self, name: &str) -> Option<u8> {
3005        let enclosing_ptr = self.enclosing?;
3006        // SAFETY: The enclosing compiler is on the stack and outlives this call.
3007        // We only use raw pointers to avoid Rust's borrow checker issues with
3008        // the recursive compiler hierarchy, which is purely compile-time.
3009        let enclosing = unsafe { &mut *enclosing_ptr };
3010
3011        // Try to find as a local in the enclosing scope.
3012        if let Some(local_idx) = enclosing.resolve_local(name) {
3013            enclosing.locals[local_idx as usize].is_captured = true;
3014            // Store the actual stack slot (not locals index) for the VM.
3015            let stack_slot = enclosing.locals[local_idx as usize].slot;
3016            return Some(self.add_upvalue(true, stack_slot).ok()?);
3017        }
3018
3019        // Try to find as an upvalue in the enclosing scope (recursive).
3020        if let Some(uv_idx) = enclosing.resolve_upvalue(name) {
3021            return Some(self.add_upvalue(false, uv_idx as u16).ok()?);
3022        }
3023
3024        // No need to propagate with_depth here — has_with_scope()
3025        // in compile_ident already walks the enclosing chain to find
3026        // with-scopes transitively. Setting with_depth as a side effect
3027        // would poison all subsequent identifier lookups in this compiler,
3028        // causing names that should be upvalues to be emitted as LookupWith.
3029        None
3030    }
3031
3032    /// Check if this compiler or any enclosing compiler has an active with-scope.
3033    fn has_with_scope(&self) -> bool {
3034        if self.with_depth > 0 {
3035            return true;
3036        }
3037        if let Some(enclosing_ptr) = self.enclosing {
3038            let enclosing = unsafe { &*enclosing_ptr };
3039            return enclosing.has_with_scope();
3040        }
3041        false
3042    }
3043
3044    /// Resolve a relative path against the base directory.
3045    /// Walks the enclosing compiler chain to find a base_dir.
3046    fn resolve_relative_path(&self, rel_path: &str) -> String {
3047        if let Some(ref base) = self.base_dir {
3048            return base.join(rel_path).to_string_lossy().to_string();
3049        }
3050        if let Some(enclosing_ptr) = self.enclosing {
3051            let enclosing = unsafe { &*enclosing_ptr };
3052            return enclosing.resolve_relative_path(rel_path);
3053        }
3054        rel_path.to_string()
3055    }
3056}
3057
3058// ── Helper functions ───────────────────────────────────────────
3059
3060/// Extract the text of an ident node.
3061fn ident_text(ident: &ast::Ident) -> String {
3062    ident
3063        .ident_token()
3064        .map(|t| t.text().to_string())
3065        .unwrap_or_default()
3066}
3067
3068/// Extract a static attribute name (identifier or plain string literal).
3069/// Rejects dynamic/interpolated keys.
3070fn static_attr_name(attr: &ast::Attr) -> Result<String, CompileError> {
3071    match attr {
3072        ast::Attr::Ident(ident) => Ok(ident_text(ident)),
3073        ast::Attr::Str(s) => {
3074            // Handle plain string keys like { "key-with-dashes" = value; }
3075            let parts: Vec<_> = s.normalized_parts().into_iter().collect();
3076            if parts.len() == 1 {
3077                if let InterpolPart::Literal(text) = &parts[0] {
3078                    return Ok(text.to_string());
3079                }
3080            }
3081            Err(CompileError::Unsupported(
3082                "interpolated string attribute keys".to_string(),
3083            ))
3084        }
3085        ast::Attr::Dynamic(_) => Err(CompileError::Unsupported(
3086            "dynamic attribute keys".to_string(),
3087        )),
3088    }
3089}
3090
3091/// Check if a name is a Nix global builtin (available without `builtins.` prefix).
3092///
3093/// ★ THIS LIST IS MEASURED, NOT REMEMBERED — and it is deliberately SHORT.
3094///
3095/// It is consulted at step 4 of `compile_ident`, i.e. ABOVE the `with`-scope
3096/// lookup at step 5. That ordering is correct — in CppNix the base environment
3097/// is the outermost LEXICAL scope, and `with` is only consulted when a name
3098/// fails to resolve lexically — which means every name listed here SHADOWS a
3099/// `with`. So a name that is NOT actually global must not appear, or the VM
3100/// silently answers with its own builtin where nix answers with the `with`.
3101///
3102/// This list previously carried 49 names against nix's real 23. Measured
3103/// 2026-08-17 against nix 2.31.5, one `nix eval --impure --expr '<name>'`
3104/// probe per attribute of `builtins.attrNames builtins` (118 names): exactly
3105/// 23 resolve in the global scope, the other 95 raise `undefined variable`.
3106/// `true` / `false` / `null` are three of the 23 and are handled earlier in
3107/// `compile_ident` as literals; `builtins` is a fourth and is handled at step
3108/// 3 — leaving the 19 below.
3109///
3110/// Two divergence shapes the 30 dropped names caused, both silent:
3111///
3112/// ```text
3113///   with { isFunction = x: "LIB"; }; isFunction 1  nix/walker "LIB"  VM false
3114///   with { typeOf     = x: "LIB"; }; typeOf 1      nix/walker "LIB"  VM "int"
3115/// ```
3116///
3117/// This is nixpkgs-shaped: `with lib;` is everywhere, and `lib.isFunction` /
3118/// `lib.functionArgs` are functor-aware REDEFINITIONS of the same-named
3119/// builtins. Second order: nix ERRORS on a bare `typeOf`, so the VM answering
3120/// it swallowed a genuine undefined-variable bug.
3121///
3122/// To re-measure: `nix eval --impure --expr '<name>'` for each name; exit 0
3123/// means global, `undefined variable` means not.
3124/// Names Nix resolves as bare identifiers, and which therefore may NOT be
3125/// shadowed by a `with`.
3126///
3127/// This used to be a hand-written `matches!` of 19 names — one of THREE
3128/// hand-maintained copies, which had already drifted: this list carried
3129/// `break` and the tree-walker's and `sui-ir`'s did not, so
3130/// `with { break = "LIB"; }; break` evaluated to `"LIB"` on the walker while
3131/// nix and this engine both say `false`. Measured against nix 2.31.5,
3132/// `break` is a real global (`builtins.typeOf break` → `lambda`), so this
3133/// engine was right and the other two were wrong.
3134///
3135/// `true`/`false`/`null` and `builtins` are deliberately absent: they are
3136/// [`sui_compat::scope::STRUCTURAL_GLOBALS`], handled earlier in
3137/// `compile_ident` as literals and as the attrset itself, which is why this
3138/// predicate covers 19 names where the walker's scope list covers 21.
3139fn is_global_builtin(name: &str) -> bool {
3140    sui_compat::scope::CALLABLE_GLOBALS.contains(&name)
3141}
3142
3143/// Get the source line number for an expression (approximate).
3144fn line_of(expr: &ast::Expr) -> u32 {
3145    // rnix doesn't directly expose line numbers; use the text offset
3146    // as an approximation. A real implementation would map offset→line.
3147    let offset = AstNode::syntax(expr).text_range().start();
3148    // Use offset as a rough line proxy.
3149    u32::from(offset)
3150}
3151
3152/// Detect trivial self-referential cycles in let/rec bindings.
3153///
3154/// Checks whether any binding `name = name;` directly references itself
3155/// via a bare identifier. This is always an infinite recursion in `rec`
3156/// blocks and usually one in `let` blocks (since the binding shadows
3157/// any outer definition of the same name).
3158///
3159/// Returns a list of warning messages for each detected cycle.
3160fn detect_trivial_cycles(bindings: &[(String, &ast::Expr)]) -> Vec<String> {
3161    let mut warnings = Vec::new();
3162    for (name, expr) in bindings {
3163        if let ast::Expr::Ident(id) = expr {
3164            if id
3165                .ident_token()
3166                .map(|t| t.text() == name.as_str())
3167                .unwrap_or(false)
3168            {
3169                warnings.push(format!("warning: `{name}` directly references itself"));
3170            }
3171        }
3172    }
3173    warnings
3174}
3175
3176/// Parse a `NIX_PATH` env var value into `(prefix, path)` pairs.
3177///
3178/// The format is `prefix1=path1:prefix2=path2:...`. An entry with
3179/// no `=` is treated as having an empty prefix (CppNix-compatible).
3180/// Empty entries are skipped.
3181fn parse_nix_path(s: &str) -> Vec<(String, String)> {
3182    if s.is_empty() {
3183        return Vec::new();
3184    }
3185    s.split(':')
3186        .filter(|e| !e.is_empty())
3187        .map(|entry| match entry.split_once('=') {
3188            Some((prefix, path)) => (prefix.to_string(), path.to_string()),
3189            None => (String::new(), entry.to_string()),
3190        })
3191        .collect()
3192}
3193
3194/// Resolve a `<name>` search-path token to an absolute filesystem
3195/// path by walking the entries parsed from `NIX_PATH`.
3196fn resolve_search_path(name: &str) -> Option<String> {
3197    let nix_path = std::env::var("NIX_PATH").ok()?;
3198    for (prefix, path) in parse_nix_path(&nix_path) {
3199        if !prefix.is_empty() && name == prefix {
3200            if std::path::Path::new(&path).exists() {
3201                return Some(path);
3202            }
3203            continue;
3204        }
3205        if !prefix.is_empty() {
3206            let needle = format!("{prefix}/");
3207            if let Some(rest) = name.strip_prefix(&needle) {
3208                let full = format!("{path}/{rest}");
3209                if std::path::Path::new(&full).exists() {
3210                    return Some(full);
3211                }
3212                continue;
3213            }
3214        }
3215        if prefix.is_empty() {
3216            let full = format!("{path}/{name}");
3217            if std::path::Path::new(&full).exists() {
3218                return Some(full);
3219            }
3220        }
3221    }
3222    None
3223}
3224
3225#[cfg(test)]
3226mod tests {
3227    use super::*;
3228
3229    fn compile(input: &str) -> Chunk {
3230        let (chunk, _interner) =
3231            Compiler::compile(input).unwrap_or_else(|e| panic!("compile failed for '{input}': {e}"));
3232        chunk
3233    }
3234
3235    /// ★ A duplicate dotted path must return a typed error, NOT panic.
3236    ///
3237    /// `{ a.b = 1; a.b = 2; }` recursed until the remaining path was empty and
3238    /// then indexed `path[0]`:
3239    ///
3240    /// ```text
3241    /// thread 'sui-vm-eval' panicked at compiler.rs:1616:32:
3242    /// index out of bounds: the len is 0 but the index is 0
3243    /// ```
3244    ///
3245    /// The panic fired on the VM's own thread, where the CLI's
3246    /// whole-expression fallback caught the dead thread and returned the
3247    /// tree-walker's answer with **exit 0** — so a compiler crash presented as
3248    /// a clean success and was reachable from a five-token expression. Only
3249    /// `SUI_VM_STRICT=1` exposed it. That is why this is a test and not just a
3250    /// bounds fix: the failure mode was indistinguishable from working.
3251    ///
3252    /// CppNix rejects this input outright (`attribute 'a.b' already defined`),
3253    /// so refusing to compile it is the correct interim behaviour until the
3254    /// AST normalizer rejects it at parse.
3255    #[test]
3256    fn duplicate_dotted_path_errors_instead_of_panicking() {
3257        for src in [
3258            "{ a.b = 1; a.b = 2; }",
3259            "{ a.b.c = 1; a.b.c = 2; }",
3260            "{ a.b.c.d = 1; a.b.c.d = 2; }",
3261        ] {
3262            let err = Compiler::compile(src)
3263                .err()
3264                .unwrap_or_else(|| panic!("{src} compiled; it must be refused, not accepted"));
3265            let msg = err.to_string();
3266            assert!(
3267                msg.contains("defined more than once"),
3268                "{src}: expected a duplicate-attribute refusal, got: {msg}"
3269            );
3270        }
3271    }
3272
3273    /// CALIBRATION for the row above. Legal nested paths — including a merge
3274    /// of a dotted path with a sibling — must STILL compile. A "fix" that
3275    /// rejected any repeated first component would satisfy the test above
3276    /// while breaking ordinary nix.
3277    #[test]
3278    fn legal_nested_paths_still_compile() {
3279        for src in [
3280            "{ a.b = 1; a.c = 2; }",
3281            "{ a.b.c = 1; a.b.d = 2; }",
3282            "{ a.b = 1; a = { c = 2; }; }",
3283            "{ x.y.z = 1; }",
3284            "{ a = { b = 1; }; }",
3285        ] {
3286            assert!(
3287                Compiler::compile(src).is_ok(),
3288                "{src} must still compile — it is legal nix"
3289            );
3290        }
3291    }
3292
3293    #[test]
3294    fn compile_integer() {
3295        let chunk = compile("42");
3296        assert!(!chunk.code.is_empty());
3297        assert_eq!(chunk.constants.len(), 1);
3298        assert_eq!(chunk.constants[0], VMValue::Int(42));
3299    }
3300
3301    #[test]
3302    fn compile_float() {
3303        let chunk = compile("3.14");
3304        assert_eq!(chunk.constants[0], VMValue::Float(3.14));
3305    }
3306
3307    #[test]
3308    fn compile_bool_true() {
3309        let chunk = compile("true");
3310        // Constant-folded: true becomes Constant(Bool(true)), Return.
3311        assert_eq!(chunk.code[0], OpCode::Constant as u8);
3312        assert_eq!(chunk.constants[0], VMValue::Bool(true));
3313    }
3314
3315    #[test]
3316    fn compile_bool_false() {
3317        let chunk = compile("false");
3318        // Constant-folded: false becomes Constant(Bool(false)), Return.
3319        assert_eq!(chunk.code[0], OpCode::Constant as u8);
3320        assert_eq!(chunk.constants[0], VMValue::Bool(false));
3321    }
3322
3323    #[test]
3324    fn compile_null() {
3325        let chunk = compile("null");
3326        // Constant-folded: null becomes Constant(Null), Return.
3327        assert_eq!(chunk.code[0], OpCode::Constant as u8);
3328        assert_eq!(chunk.constants[0], VMValue::Null);
3329    }
3330
3331    #[test]
3332    fn compile_string() {
3333        let chunk = compile(r#""hello""#);
3334        assert_eq!(chunk.constants[0], VMValue::String("hello".to_string()));
3335    }
3336
3337    #[test]
3338    fn compile_addition() {
3339        let chunk = compile("1 + 2");
3340        // Constant-folded: 1 + 2 becomes Constant(3), Return.
3341        assert_eq!(chunk.constants[0], VMValue::Int(3));
3342        assert!(!chunk.code.contains(&(OpCode::Add as u8)));
3343    }
3344
3345    #[test]
3346    fn compile_addition_non_foldable() {
3347        // When variables are involved, no folding occurs.
3348        let chunk = compile("let x = 1; in x + 2");
3349        assert!(chunk.code.contains(&(OpCode::Add as u8)));
3350    }
3351
3352    #[test]
3353    fn compile_if_else() {
3354        let chunk = compile("if true then 1 else 2");
3355        // Constant-folded: `if true then 1 else 2` becomes Constant(1), Return.
3356        assert_eq!(chunk.constants[0], VMValue::Int(1));
3357        assert!(!chunk.code.contains(&(OpCode::JumpIfFalse as u8)));
3358    }
3359
3360    #[test]
3361    fn compile_if_else_non_foldable() {
3362        // When condition is not constant, no folding occurs.
3363        let chunk = compile("let b = true; in if b then 1 else 2");
3364        assert!(chunk.code.contains(&(OpCode::JumpIfFalse as u8)));
3365    }
3366
3367    #[test]
3368    fn compile_list() {
3369        let chunk = compile("[1 2 3]");
3370        assert!(chunk.code.contains(&(OpCode::MakeList as u8)));
3371    }
3372
3373    #[test]
3374    fn compile_attrset() {
3375        let chunk = compile("{ a = 1; b = 2; }");
3376        assert!(chunk.code.contains(&(OpCode::MakeAttrs as u8)));
3377    }
3378
3379    #[test]
3380    fn compile_select() {
3381        let chunk = compile("{ a = 1; }.a");
3382        assert!(chunk.code.contains(&(OpCode::GetAttr as u8)));
3383    }
3384
3385    #[test]
3386    fn compile_lambda() {
3387        let chunk = compile("x: x + 1");
3388        // The lambda body is stored as a closure constant.
3389        assert!(chunk.constants.iter().any(|c| matches!(c, VMValue::Closure(_))));
3390    }
3391
3392    #[test]
3393    fn compile_negate() {
3394        let chunk = compile("-42");
3395        // Constant-folded: -42 becomes Constant(Int(-42)), Return.
3396        assert_eq!(chunk.constants[0], VMValue::Int(-42));
3397        assert!(!chunk.code.contains(&(OpCode::Negate as u8)));
3398    }
3399
3400    #[test]
3401    fn compile_negate_non_foldable() {
3402        let chunk = compile("let x = 42; in -x");
3403        assert!(chunk.code.contains(&(OpCode::Negate as u8)));
3404    }
3405
3406    #[test]
3407    fn compile_not() {
3408        let chunk = compile("!true");
3409        // Constant-folded: !true becomes Constant(Bool(false)), Return.
3410        assert_eq!(chunk.constants[0], VMValue::Bool(false));
3411        assert!(!chunk.code.contains(&(OpCode::Not as u8)));
3412    }
3413
3414    #[test]
3415    fn compile_assert() {
3416        let chunk = compile("assert true; 42");
3417        assert!(chunk.code.contains(&(OpCode::Assert as u8)));
3418    }
3419
3420    #[test]
3421    fn compile_let_in() {
3422        let chunk = compile("let x = 1; y = 2; in x + y");
3423        assert!(chunk.code.contains(&(OpCode::GetLocal as u8)));
3424    }
3425
3426    #[test]
3427    fn compile_parse_error() {
3428        let result = Compiler::compile("let in");
3429        assert!(result.is_err());
3430    }
3431
3432    #[test]
3433    fn compile_comparison() {
3434        let chunk = compile("1 < 2");
3435        // Constant-folded.
3436        assert_eq!(chunk.constants[0], VMValue::Bool(true));
3437    }
3438
3439    #[test]
3440    fn compile_equality() {
3441        let chunk = compile("1 == 1");
3442        // Constant-folded.
3443        assert_eq!(chunk.constants[0], VMValue::Bool(true));
3444    }
3445
3446    #[test]
3447    fn compile_update_attrs() {
3448        let chunk = compile("{ a = 1; } // { b = 2; }");
3449        assert!(chunk.code.contains(&(OpCode::UpdateAttrs as u8)));
3450    }
3451
3452    #[test]
3453    fn compile_list_concat() {
3454        let chunk = compile("[1] ++ [2]");
3455        assert!(chunk.code.contains(&(OpCode::Concat as u8)));
3456    }
3457
3458    #[test]
3459    fn compile_and_short_circuit() {
3460        let chunk = compile("true && false");
3461        // Constant-folded.
3462        assert_eq!(chunk.constants[0], VMValue::Bool(false));
3463    }
3464
3465    #[test]
3466    fn compile_and_short_circuit_non_foldable() {
3467        let chunk = compile("let a = true; in a && false");
3468        assert!(chunk.code.contains(&(OpCode::JumpIfFalse as u8)));
3469    }
3470
3471    #[test]
3472    fn compile_or_short_circuit() {
3473        let chunk = compile("false || true");
3474        // Constant-folded.
3475        assert_eq!(chunk.constants[0], VMValue::Bool(true));
3476    }
3477
3478    #[test]
3479    fn compile_or_short_circuit_non_foldable() {
3480        let chunk = compile("let a = false; in a || true");
3481        assert!(chunk.code.contains(&(OpCode::JumpIfTrue as u8)));
3482    }
3483
3484    #[test]
3485    fn compile_has_attr() {
3486        let chunk = compile("{ a = 1; } ? a");
3487        assert!(chunk.code.contains(&(OpCode::HasAttr as u8)));
3488    }
3489
3490    #[test]
3491    fn compile_select_or_default() {
3492        // `or default` now uses jump-based control flow:
3493        // Dup + HasAttr + JumpIfFalse(miss) + GetAttr + Jump(end) + Pop + default
3494        let chunk = compile("{ a = 1; }.b or 0");
3495        assert!(chunk.code.contains(&(OpCode::Dup as u8)));
3496        assert!(chunk.code.contains(&(OpCode::HasAttr as u8)));
3497        assert!(chunk.code.contains(&(OpCode::JumpIfFalse as u8)));
3498        assert!(chunk.code.contains(&(OpCode::GetAttr as u8)));
3499    }
3500
3501    #[test]
3502    fn compile_dyn_select_or_default() {
3503        // Dynamic `or default` now uses jump-based control flow:
3504        // Dup + DynHasAttr + JumpIfFalse(miss) + DynGetAttr + Jump(end) + Pop + default
3505        let chunk = compile(r#"let x = "a"; in { a = 1; }.${ x } or 0"#);
3506        assert!(chunk.code.contains(&(OpCode::Dup as u8)));
3507        assert!(chunk.code.contains(&(OpCode::DynHasAttr as u8)));
3508        assert!(chunk.code.contains(&(OpCode::JumpIfFalse as u8)));
3509        // The hit path uses DynGetAttr to actually select the value.
3510        assert!(chunk.code.contains(&(OpCode::DynGetAttr as u8)));
3511    }
3512
3513    #[test]
3514    fn compile_multi_segment_select_or_default() {
3515        // `a.b.c or default` — all segments should use HasAttr+JumpIfFalse
3516        let chunk = compile("{ a = { b = 1; }; }.a.b.c or 0");
3517        // Each segment emits Dup + HasAttr + JumpIfFalse + GetAttr
3518        let has_attr_count = chunk.code.iter().filter(|&&b| b == OpCode::HasAttr as u8).count();
3519        assert!(has_attr_count >= 3, "expected >= 3 HasAttr ops for 3 segments, got {has_attr_count}");
3520    }
3521
3522    #[test]
3523    fn compile_pattern_lambda() {
3524        let chunk = compile("{ a, b }: a + b");
3525        assert!(chunk.constants.iter().any(|c| matches!(c, VMValue::Closure(_))));
3526    }
3527
3528    #[test]
3529    fn compile_string_interpolation() {
3530        let chunk = compile(r#"let x = "world"; in "hello ${x}""#);
3531        // Should contain Interpolate opcode.
3532        assert!(chunk.code.contains(&(OpCode::Interpolate as u8)));
3533    }
3534
3535    // ── Static cycle detection ──────────────────────────────
3536
3537    #[test]
3538    fn detect_trivial_self_reference() {
3539        let root = rnix::Root::parse("x");
3540        let expr = root.tree().expr().unwrap();
3541        let bindings = vec![("x".to_string(), &expr)];
3542        let warnings = detect_trivial_cycles(&bindings);
3543        assert_eq!(warnings.len(), 1);
3544        assert!(warnings[0].contains("directly references itself"));
3545    }
3546
3547    #[test]
3548    fn detect_no_false_positive() {
3549        let root = rnix::Root::parse("y");
3550        let expr = root.tree().expr().unwrap();
3551        let bindings = vec![("x".to_string(), &expr)];
3552        let warnings = detect_trivial_cycles(&bindings);
3553        assert!(warnings.is_empty());
3554    }
3555
3556    #[test]
3557    fn detect_non_ident_no_warning() {
3558        let root = rnix::Root::parse("1 + 2");
3559        let expr = root.tree().expr().unwrap();
3560        let bindings = vec![("x".to_string(), &expr)];
3561        let warnings = detect_trivial_cycles(&bindings);
3562        assert!(warnings.is_empty());
3563    }
3564
3565    #[test]
3566    fn detect_trivial_cycles_multiple() {
3567        let root_x = rnix::Root::parse("x");
3568        let expr_x = root_x.tree().expr().unwrap();
3569        let root_y = rnix::Root::parse("y");
3570        let expr_y = root_y.tree().expr().unwrap();
3571        let root_z = rnix::Root::parse("1");
3572        let expr_z = root_z.tree().expr().unwrap();
3573        let bindings = vec![
3574            ("x".to_string(), &expr_x),
3575            ("y".to_string(), &expr_y),
3576            ("z".to_string(), &expr_z),
3577        ];
3578        let warnings = detect_trivial_cycles(&bindings);
3579        assert_eq!(warnings.len(), 2);
3580    }
3581
3582    // -- PathSearch tests -----------------------------------------------
3583
3584    /// Serializes every test that touches `NIX_PATH`.
3585    ///
3586    /// ── ★ THE "SAFETY" COMMENT WAS THE BUG ────────────────────────────
3587    /// These tests carried `// SAFETY: test runs single-threaded; no
3588    /// concurrent env access` above their `set_var`. libtest runs tests in
3589    /// PARALLEL by default, so that justification was false and the three
3590    /// NIX_PATH tests raced each other: one would `remove_var` while another
3591    /// was mid-compile, and the loser saw either no NIX_PATH or the other's
3592    /// value. Measured on the full workspace run: 1 failing suite in 2,
3593    /// naming `path_search_compiles_with_matching_nix_path` and
3594    /// `path_search_with_sub_path`.
3595    ///
3596    /// An env var is process-global; the only fix is to make the access
3597    /// exclusive. This is the same shape as two other flakes found in this
3598    /// fleet today (a `HOME` override and a shared scratch-file path), which
3599    /// is why it is worth naming rather than just silencing.
3600    fn nix_path_lock() -> std::sync::MutexGuard<'static, ()> {
3601        static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3602        LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
3603    }
3604
3605    #[test]
3606    fn path_search_compiles_with_matching_nix_path() {
3607        let _nix_path = nix_path_lock();
3608        // Set NIX_PATH to a directory containing a target, then compile
3609        // a search-path expression.
3610        let dir = tempfile::tempdir().unwrap();
3611        let target = dir.path().join("mypkg");
3612        std::fs::create_dir(&target).unwrap();
3613        // Set NIX_PATH with prefix=path format.
3614        let nix_path_val = format!("mypkg={}", target.display());
3615        // SAFETY: `nix_path_lock` above makes this access exclusive.
3616        unsafe { std::env::set_var("NIX_PATH", &nix_path_val) };
3617        let result = Compiler::compile("<mypkg>");
3618        unsafe { std::env::remove_var("NIX_PATH") };
3619        assert!(result.is_ok(), "expected compile success, got: {result:?}");
3620        let (chunk, _) = result.unwrap();
3621        // The resolved path should be in the constant pool.
3622        assert!(
3623            chunk.constants.iter().any(|c| matches!(c, VMValue::Path(p) if p == &target.display().to_string())),
3624            "expected path constant for {:?}, got: {:?}",
3625            target.display(),
3626            chunk.constants,
3627        );
3628    }
3629
3630    #[test]
3631    fn path_search_fails_when_nix_path_no_match() {
3632        let _nix_path = nix_path_lock();
3633        // Set NIX_PATH to something that doesn't match.
3634        // SAFETY: `nix_path_lock` above makes this access exclusive.
3635        unsafe { std::env::set_var("NIX_PATH", "other=/nonexistent") };
3636        let result = Compiler::compile("<nosuchpkg>");
3637        unsafe { std::env::remove_var("NIX_PATH") };
3638
3639        // ── ★ AN UNRESOLVABLE SEARCH PATH IS DEFERRED, NOT A COMPILE ERROR ──
3640        // This asserted `is_err()`, which the compiler deliberately stopped
3641        // doing: an unresolvable `<…>` is now compiled to a THUNK that throws
3642        // when forced, "to match CppNix: unresolvable search paths are
3643        // deferred and caught by tryEval at force-time" (see the emit site).
3644        // The test pinned the behaviour the change was made to remove, so it
3645        // has failed ever since — invisibly, because a Linux-only compile
3646        // error in `build_levels` kept the test gate from ever running.
3647        //
3648        // Asserting `is_ok()` ALONE would be vacuous: it passes just as well
3649        // if the compiler silently resolved `<nosuchpkg>` to some wrong path.
3650        // So the deferral itself is what gets checked — a closure carrying the
3651        // throw message reaches the constant pool, exactly as the sibling test
3652        // above checks for a resolved `Path` constant.
3653        assert!(
3654            result.is_ok(),
3655            "an unresolvable search path is deferred to force-time, not a \
3656             compile error; got: {result:?}"
3657        );
3658        let (chunk, _) = result.unwrap();
3659        assert!(
3660            chunk.constants.iter().any(|c| matches!(c, VMValue::Closure(_))),
3661            "expected a deferred-throw closure in the constant pool, got: {:?}",
3662            chunk.constants,
3663        );
3664    }
3665
3666    #[test]
3667    fn path_search_with_sub_path() {
3668        let _nix_path = nix_path_lock();
3669        // Test `<nixpkgs/lib>` style — prefix match with sub-path.
3670        let dir = tempfile::tempdir().unwrap();
3671        let nixpkgs = dir.path().join("nixpkgs-src");
3672        let lib_dir = nixpkgs.join("lib");
3673        std::fs::create_dir_all(&lib_dir).unwrap();
3674        let nix_path_val = format!("nixpkgs={}", nixpkgs.display());
3675        // SAFETY: `nix_path_lock` above makes this access exclusive.
3676        unsafe { std::env::set_var("NIX_PATH", &nix_path_val) };
3677        let result = Compiler::compile("<nixpkgs/lib>");
3678        unsafe { std::env::remove_var("NIX_PATH") };
3679        assert!(result.is_ok(), "expected compile success for sub-path, got: {result:?}");
3680        let (chunk, _) = result.unwrap();
3681        let expected_path = lib_dir.display().to_string();
3682        assert!(
3683            chunk.constants.iter().any(|c| matches!(c, VMValue::Path(p) if p == &expected_path)),
3684            "expected path constant for {expected_path}, got: {:?}",
3685            chunk.constants,
3686        );
3687    }
3688
3689    // -- TailCall detection tests ---------------------------------------
3690
3691    #[test]
3692    fn lambda_body_apply_emits_tail_call() {
3693        // A call in the body of a lambda should emit TailCall.
3694        let chunk = compile("x: x 1");
3695        // The outer chunk contains a closure constant; the closure chunk
3696        // should contain TailCall.
3697        let closure_chunk = chunk
3698            .constants
3699            .iter()
3700            .find_map(|c| match c {
3701                VMValue::Closure(cl) => Some(&cl.chunk),
3702                _ => None,
3703            })
3704            .expect("expected a closure constant");
3705        assert!(
3706            closure_chunk.code.contains(&(OpCode::TailCall as u8)),
3707            "lambda body call should emit TailCall, bytecode: {:?}",
3708            closure_chunk.code,
3709        );
3710    }
3711
3712    #[test]
3713    fn if_then_apply_emits_tail_call() {
3714        // A call in the then-branch of an if in a lambda body should be TailCall.
3715        let chunk = compile("x: if true then x 1 else 0");
3716        let closure_chunk = chunk
3717            .constants
3718            .iter()
3719            .find_map(|c| match c {
3720                VMValue::Closure(cl) => Some(&cl.chunk),
3721                _ => None,
3722            })
3723            .expect("expected a closure constant");
3724        assert!(
3725            closure_chunk.code.contains(&(OpCode::TailCall as u8)),
3726            "if-then call should emit TailCall, bytecode: {:?}",
3727            closure_chunk.code,
3728        );
3729    }
3730
3731    #[test]
3732    fn if_else_apply_emits_tail_call() {
3733        // A call in the else-branch of an if in a lambda body should be TailCall.
3734        let chunk = compile("x: if false then 0 else x 1");
3735        let closure_chunk = chunk
3736            .constants
3737            .iter()
3738            .find_map(|c| match c {
3739                VMValue::Closure(cl) => Some(&cl.chunk),
3740                _ => None,
3741            })
3742            .expect("expected a closure constant");
3743        assert!(
3744            closure_chunk.code.contains(&(OpCode::TailCall as u8)),
3745            "if-else call should emit TailCall, bytecode: {:?}",
3746            closure_chunk.code,
3747        );
3748    }
3749
3750    #[test]
3751    fn non_tail_apply_emits_regular_call() {
3752        // A call that is NOT in tail position (e.g. argument to another
3753        // function) should emit Call, not TailCall.
3754        let chunk = compile("let f = x: x; in f (f 1)");
3755        // The top-level chunk should contain Call (for `f (f 1)`).
3756        // The inner `f 1` is an argument, not tail position.
3757        assert!(
3758            chunk.code.contains(&(OpCode::Call as u8))
3759                || chunk.code.contains(&(OpCode::GetLocalCall as u8)),
3760            "non-tail call should emit Call or GetLocalCall, bytecode: {:?}",
3761            chunk.code,
3762        );
3763    }
3764
3765    #[test]
3766    fn assert_body_apply_emits_tail_call() {
3767        // A call in the body of an assert inside a lambda should be TailCall.
3768        let chunk = compile("f: assert true; f 1");
3769        let closure_chunk = chunk
3770            .constants
3771            .iter()
3772            .find_map(|c| match c {
3773                VMValue::Closure(cl) => Some(&cl.chunk),
3774                _ => None,
3775            })
3776            .expect("expected a closure constant");
3777        assert!(
3778            closure_chunk.code.contains(&(OpCode::TailCall as u8)),
3779            "assert body call should emit TailCall, bytecode: {:?}",
3780            closure_chunk.code,
3781        );
3782    }
3783
3784    // -- Multi-segment HasAttr tests ------------------------------------
3785
3786    #[test]
3787    fn multi_segment_hasattr_compiles() {
3788        // `{ a.b = 1; } ? a` should compile and use HasAttr.
3789        let chunk = compile("{ a = { b = 1; }; } ? a");
3790        assert!(chunk.code.contains(&(OpCode::HasAttr as u8)));
3791    }
3792
3793    #[test]
3794    fn single_segment_hasattr_still_works() {
3795        // Single-segment ? should still work.
3796        let chunk = compile("{ x = 1; } ? x");
3797        assert!(chunk.code.contains(&(OpCode::HasAttr as u8)));
3798    }
3799
3800    #[test]
3801    fn multi_segment_hasattr_deep_path() {
3802        // `{ a = { b = 1; }; } ? a.b` — multi-segment hasattr should compile.
3803        let chunk = compile("{ a = { b = 1; }; } ? a.b");
3804        // Should contain HasAttr (used for each segment).
3805        assert!(chunk.code.contains(&(OpCode::HasAttr as u8)));
3806    }
3807}