Skip to main content

sui_bytecode/
lib.rs

1//! Bytecode compiler and VM for the Nix evaluator.
2//!
3//! This crate provides an alternative evaluation backend for sui-eval.
4//! Instead of tree-walking the rnix AST, expressions are compiled to
5//! a stack-based bytecode and executed by a virtual machine.
6//!
7//! # Architecture
8//!
9//! ```text
10//! Nix source --> rnix parser (CST) --> Compiler --> Chunk (bytecode)
11//!                                                        |
12//!                                                        v
13//!                                                  VM --> VMValue
14//! ```
15//!
16//! # Phase 1 + 2 Coverage
17//!
18//! Currently supports:
19//! - Literals: int, float, bool, null, string, path
20//! - Arithmetic: `+`, `-`, `*`, `/`, unary `-`
21//! - Comparison: `==`, `!=`, `<`, `>`, `<=`, `>=`
22//! - Logical: `!`, `&&`, `||`, `->` (with short-circuit)
23//! - Strings: literals, interpolation
24//! - Variables: `let`/`in` with local binding
25//! - Functions: lambda, apply, pattern destructuring with defaults
26//! - Lists: construction, `++` concatenation
27//! - Attribute sets: construction, `.` selection, `?` has-attr,
28//!   `//` update, `or` default
29//! - Control flow: `if`/`then`/`else`, `assert`
30//! - Upvalue capture: Lua 5.x-style closures over non-local variables
31//! - `with` scopes: dynamic variable lookup via with-scope stack
32//! - `rec` attribute sets: self-referencing bindings
33//! - `inherit` and `inherit (source)`: in both `let` and attrset
34//! - Dotted attribute paths: `{ a.b = 1; a.c = 2; }` merging
35//! - Dynamic attribute keys: `{ ${expr} = value; }`
36//! - Builtins: 50+ functions (type checks, list ops, attrset ops,
37//!   string ops, arithmetic, control flow, conversion)
38//! - `import` with file caching
39//! - Thunks / lazy evaluation (MakeThunk/Force opcodes, blackhole detection)
40//! - Lazy attrset values (non-trivial values wrapped in thunks)
41//! - `derivation` / `derivationStrict` (native implementation via sui-compat)
42//! - `builtins.getFlake` (path-based flake references)
43//! - `builtins.scopedImport` (with-wrapping approach)
44//! - VM-level dispatch for interner-dependent builtins (attrNames,
45//!   listToAttrs, removeAttrs, hasAttr, getAttr, catAttrs)
46//! - Deep-force at VM boundary (recursively forces thunks in attrsets/lists)
47//!
48//! # Not Yet Implemented
49//!
50//! - String interpolation contexts
51
52/// Builtin bridge: tree-walker builtins callable from the VM.
53pub mod bridge;
54/// Built-in function registry for the VM.
55pub mod builtins;
56/// Bytecode container (instructions + constant pool).
57pub mod chunk;
58/// AST-to-bytecode compiler.
59pub mod compiler;
60/// Error types for compiler and VM.
61pub mod error;
62
63/// Fallback accounting + the `SUI_VM_STRICT` latch — see the module docs for
64/// why the per-builtin layer is counted but never fatal.
65pub mod fallback;
66/// String interning for attribute names and identifiers.
67pub mod intern;
68/// NaN-boxed value representation for the VM stack.
69pub mod nanbox;
70/// Bytecode instruction set.
71pub mod opcode;
72/// The VM's normalized differential render — format-locked to
73/// `sui_eval::render::render_tree`, and refusing (never placeholdering) an
74/// unforced thunk.
75pub mod render;
76/// VM-specific value representation.
77pub mod value;
78/// Bytecode interpreter / execution engine.
79pub mod vm;
80
81// Re-exports for ergonomic use.
82pub use bridge::{
83    BuiltinBridgeFn, BuiltinBridgeGuard, PathMaterializerFn, PathMaterializerGuard,
84    call_builtin_bridge, materialize, materialize_path, set_builtin_bridge,
85    set_path_materializer,
86};
87pub use builtins::BuiltinRegistry;
88pub use chunk::Chunk;
89pub use compiler::Compiler;
90pub use error::{CompileError, VMError};
91pub use intern::{Interner, Symbol};
92pub use opcode::OpCode;
93pub use value::{StringKeyedValue, VMBuiltin, VMThunk, VMValue};
94pub use vm::{FlakeResolverGuard, set_flake_resolver, vm_fallback_count, VM};
95
96use std::cell::RefCell;
97use std::collections::HashMap;
98use std::collections::hash_map::DefaultHasher;
99use std::hash::{Hash, Hasher};
100use std::rc::Rc;
101
102/// A cached compilation result: the chunk (shared via Rc) and a cloned interner.
103struct CachedCompile {
104    chunk: Rc<Chunk>,
105    interner: Interner,
106}
107
108thread_local! {
109    /// Per-thread compilation cache keyed by expression string hash.
110    ///
111    /// Benchmarks show that compilation takes 85-92% of total eval time,
112    /// so caching compiled chunks provides a dramatic speedup on repeated
113    /// evaluations of the same expression (the common case in benchmarks
114    /// and in real evaluation loops like `builtins.map` over many items).
115    static COMPILE_CACHE: RefCell<HashMap<u64, CachedCompile>> =
116        RefCell::new(HashMap::new());
117}
118
119/// Hash an expression string for the compile cache.
120fn hash_expr(input: &str) -> u64 {
121    let mut hasher = DefaultHasher::new();
122    input.hash(&mut hasher);
123    hasher.finish()
124}
125
126/// Result of bytecode evaluation: the value plus the interner needed
127/// to resolve symbol-keyed attrsets.
128pub struct EvalResult {
129    /// The evaluated value (may contain `Symbol`-keyed attrsets).
130    pub value: VMValue,
131    /// The interner used during compilation and execution.
132    pub interner: Interner,
133}
134
135impl EvalResult {
136    /// Convert the result to a fully string-keyed value.
137    #[must_use]
138    pub fn to_string_keyed(&self) -> StringKeyedValue {
139        self.value.to_string_keyed(&self.interner)
140    }
141}
142
143/// Compile and execute a Nix expression string via the bytecode VM.
144///
145/// Returns the raw [`VMValue`] (which may contain `Symbol`-keyed attrsets).
146/// For a fully resolved result, use [`eval_full`] instead.
147pub fn eval(input: &str) -> Result<VMValue, EvalError> {
148    let result = eval_full(input)?;
149    Ok(result.value)
150}
151
152/// Compile and execute a Nix expression, returning the value and interner.
153///
154/// Use this when you need to inspect attrset keys or display results.
155///
156/// Uses a thread-local compilation cache: if the same expression string
157/// has been compiled before, the cached bytecode is reused (avoiding the
158/// rnix parse + compile overhead which benchmarks show is 85-92% of total
159/// eval time).
160pub fn eval_full(input: &str) -> Result<EvalResult, EvalError> {
161    let key = hash_expr(input);
162
163    // Try the cache first.
164    let cached = COMPILE_CACHE.with(|cache| {
165        cache.borrow().get(&key).map(|entry| {
166            (entry.chunk.clone(), entry.interner.clone())
167        })
168    });
169
170    let (chunk, mut interner) = if let Some((rc_chunk, interner)) = cached {
171        // Cache hit: use the Rc<Chunk> directly. The VM needs an owned Chunk,
172        // so we clone from the Rc (the Rc makes this cheap for re-use).
173        ((*rc_chunk).clone(), interner)
174    } else {
175        // Cache miss: compile, cache, and return.
176        let (chunk, interner) = Compiler::compile(input).map_err(EvalError::Compile)?;
177        let rc_chunk = Rc::new(chunk.clone());
178        COMPILE_CACHE.with(|cache| {
179            cache.borrow_mut().insert(key, CachedCompile {
180                chunk: rc_chunk,
181                interner: interner.clone(),
182            });
183        });
184        (chunk, interner)
185    };
186
187    let value = VM::execute(chunk, &mut interner).map_err(EvalError::Runtime)?;
188    Ok(EvalResult { value, interner })
189}
190
191/// Clear the thread-local compilation cache.
192///
193/// Useful in tests or when memory pressure is a concern.
194pub fn clear_compile_cache() {
195    COMPILE_CACHE.with(|cache| cache.borrow_mut().clear());
196}
197
198/// Compile and execute a Nix **file** on the bytecode VM, with **no fallback**.
199///
200/// The file-shaped sibling of [`eval_full`], and the entry point the nix
201/// language corpus needs: until now the only way to run the VM over a file was
202/// through the CLI's VM arm, which re-runs the whole expression on the
203/// tree-walker the moment the VM errors — so a corpus driven through it would
204/// have measured the walker on both sides.
205///
206/// Two things make this different from `eval_full(&read_to_string(path))`:
207///
208/// 1. **Base directory.** Relative paths (`./foo.nix`, `import ./lib`) resolve
209///    against the file's own directory via
210///    [`Compiler::compile_with_base_dir`] — which, before this function, had
211///    **no call site anywhere in the workspace**.
212/// 2. **No compile cache.** [`eval_full`] keys its thread-local cache on the
213///    source *text*; two different files with identical text would share a
214///    chunk compiled against the first one's base directory. Corpus fixtures
215///    are small and duplicates are plausible, so this path compiles fresh.
216///
217/// There is deliberately no fallback arm. Fallback still exists *below* this
218/// call — at the imported-file and builtin boundaries inside the VM — and
219/// `SUI_VM_STRICT=1` is what turns the two failure-shaped ones into errors; see
220/// [`fallback`]. A caller measuring VM coverage must set it, or the answer it
221/// reads may be the walker's.
222///
223/// # Errors
224///
225/// [`FileEvalError::Read`] if the file cannot be read, otherwise the compile or
226/// runtime error, unmodified.
227pub fn eval_file(path: &std::path::Path) -> Result<EvalResult, FileEvalError> {
228    let source = std::fs::read_to_string(path).map_err(|e| FileEvalError::Read {
229        path: path.display().to_string(),
230        message: e.to_string(),
231    })?;
232    let base_dir = path
233        .parent()
234        .map_or_else(|| std::path::PathBuf::from("."), std::path::Path::to_path_buf);
235
236    let (chunk, mut interner) =
237        Compiler::compile_with_base_dir(&source, base_dir).map_err(EvalError::Compile)?;
238    let value = VM::execute(chunk, &mut interner).map_err(EvalError::Runtime)?;
239    Ok(EvalResult { value, interner })
240}
241
242/// Failure of [`eval_file`].
243///
244/// A separate type rather than a new [`EvalError`] variant on purpose: reading
245/// a file is not a compile or runtime event, and `EvalError` is matched
246/// exhaustively in `sui-eval` — widening it there would force an arm that has
247/// nothing to say.
248#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
249pub enum FileEvalError {
250    /// The file could not be read.
251    #[error("cannot read {path}: {message}")]
252    Read {
253        /// The path as given.
254        path: String,
255        /// The OS error text.
256        message: String,
257    },
258    /// Compilation or execution failed.
259    #[error(transparent)]
260    Eval(#[from] EvalError),
261}
262
263/// Unified error type wrapping both compile and runtime errors.
264#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
265pub enum EvalError {
266    /// A compilation error.
267    #[error("compile error: {0}")]
268    Compile(CompileError),
269    /// A runtime error.
270    #[error("runtime error: {0}")]
271    Runtime(VMError),
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn eval_simple_addition() {
280        assert_eq!(eval("1 + 2").unwrap(), VMValue::Int(3));
281    }
282
283    #[test]
284    fn eval_null_literal() {
285        assert_eq!(eval("null").unwrap(), VMValue::Null);
286    }
287
288    #[test]
289    fn eval_bool_logic() {
290        assert_eq!(eval("true && false").unwrap(), VMValue::Bool(false));
291        assert_eq!(eval("true || false").unwrap(), VMValue::Bool(true));
292    }
293
294    #[test]
295    fn eval_let_binding() {
296        assert_eq!(eval("let x = 10; in x").unwrap(), VMValue::Int(10));
297    }
298
299    #[test]
300    fn eval_lambda_call() {
301        assert_eq!(eval("(x: x + 1) 5").unwrap(), VMValue::Int(6));
302    }
303
304    #[test]
305    fn eval_compile_error() {
306        let result = eval("let in");
307        assert!(result.is_err());
308        assert!(matches!(result, Err(EvalError::Compile(_))));
309    }
310
311    #[test]
312    fn eval_runtime_error_div_zero() {
313        let result = eval("1 / 0");
314        assert!(result.is_err());
315        assert!(matches!(
316            result,
317            Err(EvalError::Runtime(VMError::DivisionByZero))
318        ));
319    }
320
321    #[test]
322    fn eval_lazy_let_thunk() {
323        // Non-trivial let binding should be lazily evaluated.
324        assert_eq!(eval("let x = 2 * 3; in x").unwrap(), VMValue::Int(6));
325    }
326
327    #[test]
328    fn eval_lazy_let_cross_ref() {
329        // Let-binding thunks can reference other bindings from the same block.
330        clear_compile_cache();
331        assert_eq!(
332            eval("let f = x: x + 1; g = f 10; in g").unwrap(),
333            VMValue::Int(11)
334        );
335    }
336
337    #[test]
338    fn eval_fixpoint_via_intermediate() {
339        // The fixpoint pattern works when accessed through an intermediate variable.
340        clear_compile_cache();
341        let result = eval(
342            "let fix = f: let x = f x; in x; r = fix (self: { a = 1; }); s = r.a; in s",
343        );
344        assert_eq!(result.unwrap(), VMValue::Int(1));
345    }
346}