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