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/// String interning for attribute names and identifiers.
63pub mod intern;
64/// NaN-boxed value representation for the VM stack.
65pub mod nanbox;
66/// Bytecode instruction set.
67pub mod opcode;
68/// VM-specific value representation.
69pub mod value;
70/// Bytecode interpreter / execution engine.
71pub mod vm;
72
73// Re-exports for ergonomic use.
74pub use bridge::{
75 BuiltinBridgeFn, BuiltinBridgeGuard, PathMaterializerFn, PathMaterializerGuard,
76 call_builtin_bridge, materialize, materialize_path, set_builtin_bridge,
77 set_path_materializer,
78};
79pub use builtins::BuiltinRegistry;
80pub use chunk::Chunk;
81pub use compiler::Compiler;
82pub use error::{CompileError, VMError};
83pub use intern::{Interner, Symbol};
84pub use opcode::OpCode;
85pub use value::{StringKeyedValue, VMBuiltin, VMThunk, VMValue};
86pub use vm::{FlakeResolverGuard, set_flake_resolver, vm_fallback_count, VM};
87
88use std::cell::RefCell;
89use std::collections::HashMap;
90use std::collections::hash_map::DefaultHasher;
91use std::hash::{Hash, Hasher};
92use std::rc::Rc;
93
94/// A cached compilation result: the chunk (shared via Rc) and a cloned interner.
95struct CachedCompile {
96 chunk: Rc<Chunk>,
97 interner: Interner,
98}
99
100thread_local! {
101 /// Per-thread compilation cache keyed by expression string hash.
102 ///
103 /// Benchmarks show that compilation takes 85-92% of total eval time,
104 /// so caching compiled chunks provides a dramatic speedup on repeated
105 /// evaluations of the same expression (the common case in benchmarks
106 /// and in real evaluation loops like `builtins.map` over many items).
107 static COMPILE_CACHE: RefCell<HashMap<u64, CachedCompile>> =
108 RefCell::new(HashMap::new());
109}
110
111/// Hash an expression string for the compile cache.
112fn hash_expr(input: &str) -> u64 {
113 let mut hasher = DefaultHasher::new();
114 input.hash(&mut hasher);
115 hasher.finish()
116}
117
118/// Result of bytecode evaluation: the value plus the interner needed
119/// to resolve symbol-keyed attrsets.
120pub struct EvalResult {
121 /// The evaluated value (may contain `Symbol`-keyed attrsets).
122 pub value: VMValue,
123 /// The interner used during compilation and execution.
124 pub interner: Interner,
125}
126
127impl EvalResult {
128 /// Convert the result to a fully string-keyed value.
129 #[must_use]
130 pub fn to_string_keyed(&self) -> StringKeyedValue {
131 self.value.to_string_keyed(&self.interner)
132 }
133}
134
135/// Compile and execute a Nix expression string via the bytecode VM.
136///
137/// Returns the raw [`VMValue`] (which may contain `Symbol`-keyed attrsets).
138/// For a fully resolved result, use [`eval_full`] instead.
139pub fn eval(input: &str) -> Result<VMValue, EvalError> {
140 let result = eval_full(input)?;
141 Ok(result.value)
142}
143
144/// Compile and execute a Nix expression, returning the value and interner.
145///
146/// Use this when you need to inspect attrset keys or display results.
147///
148/// Uses a thread-local compilation cache: if the same expression string
149/// has been compiled before, the cached bytecode is reused (avoiding the
150/// rnix parse + compile overhead which benchmarks show is 85-92% of total
151/// eval time).
152pub fn eval_full(input: &str) -> Result<EvalResult, EvalError> {
153 let key = hash_expr(input);
154
155 // Try the cache first.
156 let cached = COMPILE_CACHE.with(|cache| {
157 cache.borrow().get(&key).map(|entry| {
158 (entry.chunk.clone(), entry.interner.clone())
159 })
160 });
161
162 let (chunk, mut interner) = if let Some((rc_chunk, interner)) = cached {
163 // Cache hit: use the Rc<Chunk> directly. The VM needs an owned Chunk,
164 // so we clone from the Rc (the Rc makes this cheap for re-use).
165 ((*rc_chunk).clone(), interner)
166 } else {
167 // Cache miss: compile, cache, and return.
168 let (chunk, interner) = Compiler::compile(input).map_err(EvalError::Compile)?;
169 let rc_chunk = Rc::new(chunk.clone());
170 COMPILE_CACHE.with(|cache| {
171 cache.borrow_mut().insert(key, CachedCompile {
172 chunk: rc_chunk,
173 interner: interner.clone(),
174 });
175 });
176 (chunk, interner)
177 };
178
179 let value = VM::execute(chunk, &mut interner).map_err(EvalError::Runtime)?;
180 Ok(EvalResult { value, interner })
181}
182
183/// Clear the thread-local compilation cache.
184///
185/// Useful in tests or when memory pressure is a concern.
186pub fn clear_compile_cache() {
187 COMPILE_CACHE.with(|cache| cache.borrow_mut().clear());
188}
189
190/// Unified error type wrapping both compile and runtime errors.
191#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
192pub enum EvalError {
193 /// A compilation error.
194 #[error("compile error: {0}")]
195 Compile(CompileError),
196 /// A runtime error.
197 #[error("runtime error: {0}")]
198 Runtime(VMError),
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn eval_simple_addition() {
207 assert_eq!(eval("1 + 2").unwrap(), VMValue::Int(3));
208 }
209
210 #[test]
211 fn eval_null_literal() {
212 assert_eq!(eval("null").unwrap(), VMValue::Null);
213 }
214
215 #[test]
216 fn eval_bool_logic() {
217 assert_eq!(eval("true && false").unwrap(), VMValue::Bool(false));
218 assert_eq!(eval("true || false").unwrap(), VMValue::Bool(true));
219 }
220
221 #[test]
222 fn eval_let_binding() {
223 assert_eq!(eval("let x = 10; in x").unwrap(), VMValue::Int(10));
224 }
225
226 #[test]
227 fn eval_lambda_call() {
228 assert_eq!(eval("(x: x + 1) 5").unwrap(), VMValue::Int(6));
229 }
230
231 #[test]
232 fn eval_compile_error() {
233 let result = eval("let in");
234 assert!(result.is_err());
235 assert!(matches!(result, Err(EvalError::Compile(_))));
236 }
237
238 #[test]
239 fn eval_runtime_error_div_zero() {
240 let result = eval("1 / 0");
241 assert!(result.is_err());
242 assert!(matches!(
243 result,
244 Err(EvalError::Runtime(VMError::DivisionByZero))
245 ));
246 }
247
248 #[test]
249 fn eval_lazy_let_thunk() {
250 // Non-trivial let binding should be lazily evaluated.
251 assert_eq!(eval("let x = 2 * 3; in x").unwrap(), VMValue::Int(6));
252 }
253
254 #[test]
255 fn eval_lazy_let_cross_ref() {
256 // Let-binding thunks can reference other bindings from the same block.
257 clear_compile_cache();
258 assert_eq!(
259 eval("let f = x: x + 1; g = f 10; in g").unwrap(),
260 VMValue::Int(11)
261 );
262 }
263
264 #[test]
265 fn eval_fixpoint_via_intermediate() {
266 // The fixpoint pattern works when accessed through an intermediate variable.
267 clear_compile_cache();
268 let result = eval(
269 "let fix = f: let x = f x; in x; r = fix (self: { a = 1; }); s = r.a; in s",
270 );
271 assert_eq!(result.unwrap(), VMValue::Int(1));
272 }
273}