Skip to main content

sui_bytecode/
compiler.rs

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