feat: persist JsRuntime

This commit is contained in:
2026-01-05 17:58:48 +08:00
parent c43d796dc0
commit c9455bd0a8
6 changed files with 147 additions and 104 deletions

1
Cargo.lock generated
View File

@@ -1053,6 +1053,7 @@ dependencies = [
"itertools 0.14.0", "itertools 0.14.0",
"mimalloc", "mimalloc",
"nix-js-macros", "nix-js-macros",
"pin-project",
"regex", "regex",
"rnix", "rnix",
"rustyline", "rustyline",

View File

@@ -17,6 +17,7 @@ derive_more = { version = "2", features = ["full"] }
thiserror = "2" thiserror = "2"
string-interner = "0.19" string-interner = "0.19"
itertools = "0.14" itertools = "0.14"
pin-project = "1"
deno_core = "0.376" deno_core = "0.376"
deno_error = "0.7" deno_error = "0.7"

View File

@@ -1,6 +1,8 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::pin::Pin;
use std::ptr::NonNull; use std::ptr::NonNull;
use deno_core::JsRuntime;
use hashbrown::HashMap; use hashbrown::HashMap;
use itertools::Itertools as _; use itertools::Itertools as _;
use string_interner::DefaultStringInterner; use string_interner::DefaultStringInterner;
@@ -15,14 +17,55 @@ use downgrade::DowngradeCtx;
mod downgrade; mod downgrade;
pub struct Context { pub struct Context {
ctx: Pin<Box<Ctx>>,
pub(crate) js_runtime: JsRuntime,
}
impl Default for Context {
fn default() -> Self {
Self::new()
}
}
impl Context {
pub fn new() -> Self {
let mut ctx = Box::pin(Ctx::new());
let ptr = unsafe { NonNull::new_unchecked(Pin::get_unchecked_mut(ctx.as_mut())) };
let js_runtime = crate::runtime::new_js_runtime(ptr);
Self { ctx, js_runtime }
}
pub fn eval_code(&mut self, expr: &str) -> Result<Value> {
// Initialize `path_stack` with current directory for relative path resolution
let mut guard = PathDropGuard::new_cwd(self.ctx.as_mut());
let ctx = guard.as_ctx();
let root = rnix::Root::parse(expr);
if !root.errors().is_empty() {
return Err(Error::parse_error(root.errors().iter().join("; ")));
}
let root = ctx.as_mut().downgrade_ctx().downgrade(root.tree().expr().unwrap())?;
let code = ctx.get_ir(root).compile(Pin::get_ref(ctx.as_ref()));
let code = format!("Nix.force({})", code);
println!("[DEBUG] generated code: {}", &code);
crate::runtime::run(code, &mut self.js_runtime)
}
}
#[pin_project::pin_project(PinnedDrop)]
pub struct Ctx {
irs: Vec<Ir>, irs: Vec<Ir>,
symbols: DefaultStringInterner, symbols: DefaultStringInterner,
global: NonNull<HashMap<SymId, ExprId>>, global: NonNull<HashMap<SymId, ExprId>>,
path_stack: Vec<PathBuf>, path_stack: Vec<PathBuf>,
#[pin]
_marker: std::marker::PhantomPinned,
} }
impl Drop for Context { #[pin_project::pinned_drop]
fn drop(&mut self) { impl PinnedDrop for Ctx {
fn drop(self: Pin<&mut Self>) {
unsafe { unsafe {
drop(Box::from_raw(self.global.as_ptr())); drop(Box::from_raw(self.global.as_ptr()));
} }
@@ -30,32 +73,32 @@ impl Drop for Context {
} }
pub struct PathDropGuard<'ctx> { pub struct PathDropGuard<'ctx> {
ctx: &'ctx mut Context, ctx: Pin<&'ctx mut Ctx>,
} }
impl<'ctx> PathDropGuard<'ctx> { impl<'ctx> PathDropGuard<'ctx> {
pub fn new(path: PathBuf, ctx: &'ctx mut Context) -> Self { pub fn new(path: PathBuf, mut ctx: Pin<&'ctx mut Ctx>) -> Self {
ctx.path_stack.push(path); ctx.as_mut().project().path_stack.push(path);
Self { ctx } Self { ctx }
} }
pub fn new_cwd(ctx: &'ctx mut Context) -> Self { pub fn new_cwd(mut ctx: Pin<&'ctx mut Ctx>) -> Self {
let cwd = std::env::current_dir().unwrap(); let cwd = std::env::current_dir().unwrap();
let virtual_file = cwd.join("__eval__.nix"); let virtual_file = cwd.join("__eval__.nix");
ctx.path_stack.push(virtual_file); ctx.as_mut().project().path_stack.push(virtual_file);
Self { ctx } Self { ctx }
} }
pub fn as_ctx(&mut self) -> &mut Context { pub fn as_ctx<'a>(&'a mut self) -> &'a mut Pin<&'ctx mut Ctx> {
self.ctx &mut self.ctx
} }
} }
impl Drop for PathDropGuard<'_> { impl Drop for PathDropGuard<'_> {
fn drop(&mut self) { fn drop(&mut self) {
self.ctx.path_stack.pop(); self.ctx.as_mut().project().path_stack.pop();
} }
} }
impl Default for Context { impl Default for Ctx {
fn default() -> Self { fn default() -> Self {
use crate::ir::{Attr, Builtins, Select, ToIr}; use crate::ir::{Attr, Builtins, Select, ToIr};
@@ -111,45 +154,33 @@ impl Default for Context {
irs, irs,
global: unsafe { NonNull::new_unchecked(Box::leak(Box::new(global))) }, global: unsafe { NonNull::new_unchecked(Box::leak(Box::new(global))) },
path_stack: Vec::new(), path_stack: Vec::new(),
_marker: std::marker::PhantomPinned,
} }
} }
} }
impl Context { impl Ctx {
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
} }
pub fn downgrade_ctx<'a>(&'a mut self) -> DowngradeCtx<'a> { pub fn downgrade_ctx<'a>(self: Pin<&'a mut Self>) -> DowngradeCtx<'a> {
// SAFETY: `global` is readonly // SAFETY: `global` is readonly
let global_ref = unsafe { self.global.as_ref() }; let global_ref = unsafe { self.global.as_ref() };
DowngradeCtx::new(self, global_ref) DowngradeCtx::new(self, global_ref)
} }
pub fn eval_code(&mut self, expr: &str) -> Result<Value> {
// Initialize `path_stack` with current directory for relative path resolution
let mut guard = PathDropGuard::new_cwd(self);
let ctx = guard.as_ctx();
let root = rnix::Root::parse(expr);
if !root.errors().is_empty() {
return Err(Error::parse_error(root.errors().iter().join("; ")));
}
let root = ctx
.downgrade_ctx()
.downgrade(root.tree().expr().unwrap())?;
let code = ctx.get_ir(root).compile(ctx);
let code = format!("Nix.force({})", code);
println!("[DEBUG] generated code: {}", &code);
crate::runtime::run(code, ctx)
}
pub fn get_current_dir(&self) -> PathBuf { pub fn get_current_dir(&self) -> PathBuf {
self.path_stack.last().unwrap().parent().unwrap().to_path_buf() self.path_stack
.last()
.unwrap()
.parent()
.unwrap()
.to_path_buf()
} }
} }
impl CodegenContext for Context { impl CodegenContext for Ctx {
fn get_ir(&self, id: ExprId) -> &Ir { fn get_ir(&self, id: ExprId) -> &Ir {
self.irs.get(id.0).unwrap() self.irs.get(id.0).unwrap()
} }
@@ -436,7 +467,9 @@ mod test {
fn test_builtin_in_with() { fn test_builtin_in_with() {
// Test builtins with 'with' expression // Test builtins with 'with' expression
assert_eq!( assert_eq!(
Context::new().eval_code("with builtins; add 10 20").unwrap(), Context::new()
.eval_code("with builtins; add 10 20")
.unwrap(),
Value::Const(Const::Int(30)) Value::Const(Const::Int(30))
); );
} }
@@ -460,11 +493,15 @@ mod test {
Value::Const(Const::Bool(true)) Value::Const(Const::Bool(true))
); );
assert_eq!( assert_eq!(
Context::new().eval_code("builtins.isAttrs { a = 1; }").unwrap(), Context::new()
.eval_code("builtins.isAttrs { a = 1; }")
.unwrap(),
Value::Const(Const::Bool(true)) Value::Const(Const::Bool(true))
); );
assert_eq!( assert_eq!(
Context::new().eval_code("builtins.isFunction (x: x)").unwrap(), Context::new()
.eval_code("builtins.isFunction (x: x)")
.unwrap(),
Value::Const(Const::Bool(true)) Value::Const(Const::Bool(true))
); );
assert_eq!( assert_eq!(
@@ -553,7 +590,9 @@ mod test {
fn test_free_global_shadowing() { fn test_free_global_shadowing() {
// Test shadowing of free globals // Test shadowing of free globals
assert_eq!( assert_eq!(
Context::new().eval_code("let true = false; in true").unwrap(), Context::new()
.eval_code("let true = false; in true")
.unwrap(),
Value::Const(Const::Bool(false)) Value::Const(Const::Bool(false))
); );
assert_eq!( assert_eq!(
@@ -699,7 +738,10 @@ mod test {
assert_eq!(ctx.eval_code("7 / 3").unwrap(), Value::Const(Const::Int(2))); assert_eq!(ctx.eval_code("7 / 3").unwrap(), Value::Const(Const::Int(2)));
assert_eq!(ctx.eval_code("10 / 3").unwrap(), Value::Const(Const::Int(3))); assert_eq!(
ctx.eval_code("10 / 3").unwrap(),
Value::Const(Const::Int(3))
);
// Float division returns float // Float division returns float
assert_eq!( assert_eq!(
@@ -712,7 +754,10 @@ mod test {
Value::Const(Const::Float(3.5)) Value::Const(Const::Float(3.5))
); );
assert_eq!(ctx.eval_code("(-7) / 3").unwrap(), Value::Const(Const::Int(-2))); assert_eq!(
ctx.eval_code("(-7) / 3").unwrap(),
Value::Const(Const::Int(-2))
);
} }
#[test] #[test]

View File

@@ -1,9 +1,11 @@
use std::pin::Pin;
use hashbrown::HashMap; use hashbrown::HashMap;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::ir::{ArgId, Downgrade, DowngradeContext, ExprId, Ir, SymId, ToIr}; use crate::ir::{ArgId, Downgrade, DowngradeContext, ExprId, Ir, SymId, ToIr};
use super::Context; use super::Ctx;
enum Scope<'ctx> { enum Scope<'ctx> {
Global(&'ctx HashMap<SymId, ExprId>), Global(&'ctx HashMap<SymId, ExprId>),
@@ -29,14 +31,14 @@ impl<'a, 'ctx> ScopeGuard<'a, 'ctx> {
} }
pub struct DowngradeCtx<'ctx> { pub struct DowngradeCtx<'ctx> {
ctx: &'ctx mut Context, ctx: Pin<&'ctx mut Ctx>,
irs: Vec<Option<Ir>>, irs: Vec<Option<Ir>>,
scopes: Vec<Scope<'ctx>>, scopes: Vec<Scope<'ctx>>,
arg_id: usize, arg_id: usize,
} }
impl<'ctx> DowngradeCtx<'ctx> { impl<'ctx> DowngradeCtx<'ctx> {
pub fn new(ctx: &'ctx mut Context, global: &'ctx HashMap<SymId, ExprId>) -> Self { pub fn new(ctx: Pin<&'ctx mut Ctx>, global: &'ctx HashMap<SymId, ExprId>) -> Self {
Self { Self {
scopes: vec![Scope::Global(global)], scopes: vec![Scope::Global(global)],
irs: vec![], irs: vec![],
@@ -59,7 +61,7 @@ impl DowngradeContext for DowngradeCtx<'_> {
} }
fn new_sym(&mut self, sym: String) -> SymId { fn new_sym(&mut self, sym: String) -> SymId {
self.ctx.symbols.get_or_intern(sym) self.ctx.as_mut().project().symbols.get_or_intern(sym)
} }
fn get_sym(&self, id: SymId) -> &str { fn get_sym(&self, id: SymId) -> &str {
@@ -133,6 +135,8 @@ impl DowngradeContext for DowngradeCtx<'_> {
fn downgrade(mut self, root: rnix::ast::Expr) -> Result<ExprId> { fn downgrade(mut self, root: rnix::ast::Expr) -> Result<ExprId> {
let root = root.downgrade(&mut self)?; let root = root.downgrade(&mut self)?;
self.ctx self.ctx
.as_mut()
.project()
.irs .irs
.extend(self.irs.into_iter().map(Option::unwrap)); .extend(self.irs.into_iter().map(Option::unwrap));
Ok(root) Ok(root)

View File

@@ -94,14 +94,12 @@ impl<Ctx: DowngradeContext> Downgrade<Ctx> for ast::Path {
parts_ast parts_ast
.into_iter() .into_iter()
.map(|part| match part { .map(|part| match part {
ast::InterpolPart::Literal(lit) => { ast::InterpolPart::Literal(lit) => Ok(ctx.new_expr(
Ok(ctx.new_expr( Str {
Str { val: lit.to_string(),
val: lit.to_string(), }
} .to_ir(),
.to_ir(), )),
))
}
ast::InterpolPart::Interpolation(interpol) => { ast::InterpolPart::Interpolation(interpol) => {
interpol.expr().unwrap().downgrade(ctx) interpol.expr().unwrap().downgrade(ctx)
} }

View File

@@ -1,15 +1,14 @@
use std::borrow::Cow; use std::borrow::Cow;
use std::cell::RefCell; use std::cell::RefCell;
use std::pin::Pin;
use std::ptr::NonNull; use std::ptr::NonNull;
use std::sync::Once; use std::sync::Once;
use deno_core::{ use deno_core::{Extension, ExtensionFileSource, JsRuntime, OpState, RuntimeOptions, v8};
Extension, ExtensionFileSource, JsRuntime, OpState, RuntimeOptions, v8
};
use deno_error::js_error_wrapper; use deno_error::js_error_wrapper;
use crate::codegen::{CodegenContext, Compile}; use crate::codegen::{CodegenContext, Compile};
use crate::context::{Context, PathDropGuard}; use crate::context::{Ctx, PathDropGuard};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::ir::DowngradeContext; use crate::ir::DowngradeContext;
use crate::value::{AttrSet, Const, List, Symbol, Value}; use crate::value::{AttrSet, Const, List, Symbol, Value};
@@ -18,12 +17,10 @@ pub trait RuntimeContext {
fn split<Ctx: DowngradeContext>(&mut self) -> (&mut JsRuntime, &mut Ctx); fn split<Ctx: DowngradeContext>(&mut self) -> (&mut JsRuntime, &mut Ctx);
} }
fn nix_runtime(ctx: &mut Context) -> Extension { fn nix_runtime(ctx: NonNull<Ctx>) -> Extension {
const ESM: &[ExtensionFileSource] = const ESM: &[ExtensionFileSource] =
&deno_core::include_js_files!(nix_runtime dir "runtime-ts/dist", "runtime.js"); &deno_core::include_js_files!(nix_runtime dir "runtime-ts/dist", "runtime.js");
// TODO: SAFETY
let ptr = unsafe { NonNull::new_unchecked(ctx) };
Extension { Extension {
name: "nix_runtime", name: "nix_runtime",
esm_files: Cow::Borrowed(ESM), esm_files: Cow::Borrowed(ESM),
@@ -35,7 +32,7 @@ fn nix_runtime(ctx: &mut Context) -> Extension {
op_resolve_path(), op_resolve_path(),
]), ]),
op_state_fn: Some(Box::new(move |state| { op_state_fn: Some(Box::new(move |state| {
state.put(RefCell::new(ptr)); state.put(RefCell::new(ctx));
})), })),
enabled: true, enabled: true,
..Default::default() ..Default::default()
@@ -74,28 +71,22 @@ js_error_wrapper!(SimpleErrorWrapper, NixError, "EvalError");
#[deno_core::op2] #[deno_core::op2]
#[string] #[string]
fn op_import(state: &mut OpState, #[string] path: String) -> std::result::Result<String, NixError> { fn op_import(state: &mut OpState, #[string] path: String) -> std::result::Result<String, NixError> {
let mut ptr = state.borrow::<RefCell<NonNull<Context>>>().borrow_mut(); let mut ptr = state.borrow::<RefCell<NonNull<Ctx>>>().borrow_mut();
let ctx = unsafe { ptr.as_mut() }; let ctx = unsafe { Pin::new_unchecked(ptr.as_mut()) };
// 1. Resolve path relative to current file (or CWD if top-level)
let current_dir = ctx.get_current_dir(); let current_dir = ctx.get_current_dir();
let absolute_path = current_dir let absolute_path = current_dir
.join(&path) .join(&path)
.canonicalize() .canonicalize()
.map_err(|e| -> NixError { .map_err(|e| -> NixError { format!("Failed to resolve path {}: {}", path, e).into() })?;
format!("Failed to resolve path {}: {}", path, e).into()
})?;
// 2. Psh to stack for nested imports (RAII guard ensures pop on drop)
let mut guard = PathDropGuard::new(absolute_path.clone(), ctx); let mut guard = PathDropGuard::new(absolute_path.clone(), ctx);
let ctx = guard.as_ctx(); let ctx = guard.as_ctx();
// 3. Read file
let content = std::fs::read_to_string(&absolute_path).map_err(|e| -> NixError { let content = std::fs::read_to_string(&absolute_path).map_err(|e| -> NixError {
format!("Failed to read {}: {}", absolute_path.display(), e).into() format!("Failed to read {}: {}", absolute_path.display(), e).into()
})?; })?;
// 4. Parse
let root = rnix::Root::parse(&content); let root = rnix::Root::parse(&content);
if !root.errors().is_empty() { if !root.errors().is_empty() {
return Err(format!( return Err(format!(
@@ -106,18 +97,17 @@ fn op_import(state: &mut OpState, #[string] path: String) -> std::result::Result
.into()); .into());
} }
// 5. Downgrade to IR
let expr = root let expr = root
.tree() .tree()
.expr() .expr()
.ok_or_else(|| -> NixError { "No expression in file".to_string().into() })?; .ok_or_else(|| -> NixError { "No expression in file".to_string().into() })?;
let expr_id = ctx let expr_id = ctx
.as_mut()
.downgrade_ctx() .downgrade_ctx()
.downgrade(expr) .downgrade(expr)
.map_err(|e| -> NixError { format!("Downgrade error: {}", e).into() })?; .map_err(|e| -> NixError { format!("Downgrade error: {}", e).into() })?;
// 6. Codegen - returns JS code string Ok(ctx.get_ir(expr_id).compile(Pin::get_ref(ctx.as_ref())))
Ok(ctx.get_ir(expr_id).compile(ctx))
} }
#[deno_core::op2] #[deno_core::op2]
@@ -134,8 +124,11 @@ fn op_path_exists(#[string] path: String) -> bool {
#[deno_core::op2] #[deno_core::op2]
#[string] #[string]
fn op_resolve_path(state: &mut OpState, #[string] path: String) -> std::result::Result<String, NixError> { fn op_resolve_path(
let ptr = state.borrow::<RefCell<NonNull<Context>>>().borrow(); state: &mut OpState,
#[string] path: String,
) -> std::result::Result<String, NixError> {
let ptr = state.borrow::<RefCell<NonNull<Ctx>>>().borrow();
let ctx = unsafe { ptr.as_ref() }; let ctx = unsafe { ptr.as_ref() };
// If already absolute, return as-is // If already absolute, return as-is
@@ -206,7 +199,7 @@ impl<'a, 'b> RuntimeCtx<'a, 'b> {
} }
} }
pub fn new_js_runtime(ctx: &mut Context) -> JsRuntime { pub fn new_js_runtime(ctx: NonNull<Ctx>) -> JsRuntime {
// Initialize V8 once // Initialize V8 once
static INIT: Once = Once::new(); static INIT: Once = Once::new();
INIT.call_once(|| { INIT.call_once(|| {
@@ -223,9 +216,7 @@ pub fn new_js_runtime(ctx: &mut Context) -> JsRuntime {
} }
// Main entry point // Main entry point
pub fn run(script: String, ctx: &mut Context) -> Result<Value> { pub fn run(script: String, runtime: &mut JsRuntime) -> Result<Value> {
let mut runtime = new_js_runtime(ctx);
// Execute user script // Execute user script
let global_value = runtime let global_value = runtime
.execute_script("<eval>", script) .execute_script("<eval>", script)
@@ -322,10 +313,7 @@ fn is_thunk<'a, 'b>(val: v8::Local<'a, v8::Value>, ctx: &RuntimeCtx<'a, 'b>) ->
} }
/// Check if a function is a primop /// Check if a function is a primop
fn primop_name<'a, 'b>( fn primop_name<'a, 'b>(val: v8::Local<'a, v8::Value>, ctx: &RuntimeCtx<'a, 'b>) -> Option<String> {
val: v8::Local<'a, v8::Value>,
ctx: &RuntimeCtx<'a, 'b>,
) -> Option<String> {
if !val.is_function() { if !val.is_function() {
return None; return None;
} }
@@ -373,27 +361,33 @@ fn primop_app_name<'a, 'b>(
} }
} }
#[test] #[cfg(test)]
fn to_value_working() { mod test {
let mut ctx = Context::new(); use super::*;
assert_eq!( use crate::context::Context;
run(
"({ #[test]
test: [1., 9223372036854775807n, true, false, 'hello world!'] fn to_value_working() {
})" let mut ctx = Context::new();
.into(), assert_eq!(
&mut ctx run(
) "({
.unwrap(), test: [1., 9223372036854775807n, true, false, 'hello world!']
Value::AttrSet(AttrSet::new(std::collections::BTreeMap::from([( })"
Symbol::from("test"), .into(),
Value::List(List::new(vec![ &mut ctx.js_runtime
Value::Const(Const::Float(1.)), )
Value::Const(Const::Int(9223372036854775807)), .unwrap(),
Value::Const(Const::Bool(true)), Value::AttrSet(AttrSet::new(std::collections::BTreeMap::from([(
Value::Const(Const::Bool(false)), Symbol::from("test"),
Value::String("hello world!".to_string()) Value::List(List::new(vec![
])) Value::Const(Const::Float(1.)),
)]))) Value::Const(Const::Int(9223372036854775807)),
); Value::Const(Const::Bool(true)),
Value::Const(Const::Bool(false)),
Value::String("hello world!".to_string())
]))
)])))
);
}
} }