feat: implement scopedImport & REPL scoping

This commit is contained in:
2026-02-07 21:14:13 +08:00
parent f154010120
commit 6b46e466c2
11 changed files with 224 additions and 68 deletions

View File

@@ -46,8 +46,24 @@ Dependency tracking for imported derivations may be incomplete.`,
export const scopedImport =
(scope: NixValue) =>
(path: NixValue): never => {
throw new Error("Not implemented: scopedImport");
(path: NixValue): NixValue => {
const scopeAttrs = forceAttrs(scope);
const scopeKeys = Object.keys(scopeAttrs);
const context: NixStringContext = new Set();
const pathStr = coerceToPath(path, context);
if (context.size > 0) {
console.warn(
`[WARN] scopedImport: Path has string context which is not yet fully tracked.
Dependency tracking for imported derivations may be incomplete.`,
);
}
const code = Deno.core.ops.op_scoped_import(pathStr, scopeKeys);
const scopedFunc = Function(`return (${code})`)();
return scopedFunc(scopeAttrs);
};
export const storePath = (pathArg: NixValue): StringWithContext => {

View File

@@ -23,11 +23,13 @@ import { op } from "./operators";
import { builtins, PRIMOP_METADATA } from "./builtins";
import { coerceToString, StringCoercionMode } from "./builtins/conversion";
import { HAS_CONTEXT } from "./string-context";
import { IS_PATH, mkAttrs, mkFunction, mkAttrsWithPos, ATTR_POSITIONS } from "./types";
import { IS_PATH, mkAttrs, mkFunction, mkAttrsWithPos, ATTR_POSITIONS, NixValue } from "./types";
import { forceBool } from "./type-assert";
export type NixRuntime = typeof Nix;
const replBindings: Record<string, NixValue> = {};
/**
* The global Nix runtime object
*/
@@ -67,6 +69,12 @@ export const Nix = {
op,
builtins,
PRIMOP_METADATA,
replBindings,
setReplBinding: (name: string, value: NixValue) => {
replBindings[name] = value;
},
getReplBinding: (name: string) => replBindings[name],
};
globalThis.Nix = Nix;

View File

@@ -37,6 +37,7 @@ declare global {
namespace ops {
function op_resolve_path(currentDir: string, path: string): string;
function op_import(path: string): string;
function op_scoped_import(path: string, scopeKeys: string[]): string;
function op_read_file(path: string): string;
function op_read_file_type(path: string): string;
function op_read_dir(path: string): Record<string, string>;

View File

@@ -1,4 +1,5 @@
use anyhow::Result;
use hashbrown::HashSet;
use nix_js::context::Context;
use nix_js::error::Source;
use rustyline::DefaultEditor;
@@ -9,6 +10,7 @@ fn main() -> Result<()> {
let mut rl = DefaultEditor::new()?;
let mut context = Context::new()?;
let mut scope = HashSet::new();
const RE: ere::Regex<3> = ere::compile_regex!("^[ \t]*([a-zA-Z_][a-zA-Z0-9_'-]*)[ \t]*(.*)$");
loop {
let readline = rl.readline("nix-js-repl> ");
@@ -18,16 +20,24 @@ fn main() -> Result<()> {
continue;
}
let _ = rl.add_history_entry(line.as_str());
if let Some([Some(_), Some(_ident), Some(_expr)]) = RE.exec(&line) {
eprintln!("Error: binding not implemented yet");
continue;
// if expr.is_empty() {
// eprintln!("Error: missing expression after '='");
// continue;
// }
// if let Err(err) = context.add_binding(ident, expr) {
// eprintln!("Error: {}", err);
// }
if let Some([Some(_), Some(ident), Some(rest)]) = RE.exec(&line) {
if let Some(expr) = rest.strip_prefix('=') {
let expr = expr.trim_start();
if expr.is_empty() {
eprintln!("Error: missing expression after '='");
continue;
}
match context.add_binding(ident, expr, &mut scope) {
Ok(value) => println!("{} = {}", ident, value),
Err(err) => eprintln!("{:?}", miette::Report::new(*err)),
}
} else {
let src = Source::new_repl(line)?;
match context.eval_repl(src, &scope) {
Ok(value) => println!("{value}"),
Err(err) => eprintln!("{:?}", miette::Report::new(*err)),
}
}
} else {
let src = Source::new_repl(line)?;
match context.eval_shallow(src) {

View File

@@ -116,7 +116,7 @@ pub(crate) fn compile(expr: &Ir, ctx: &impl CodegenContext) -> String {
code!(&mut buf, ctx;
"Nix.builtins.storeDir="
quoted(ctx.get_store_dir())
";const currentDir="
";const __currentDir="
quoted(&ctx.get_current_dir().display().to_string())
";return "
expr
@@ -125,6 +125,28 @@ pub(crate) fn compile(expr: &Ir, ctx: &impl CodegenContext) -> String {
buf.into_string()
}
pub(crate) fn compile_scoped(expr: &Ir, ctx: &impl CodegenContext) -> String {
let mut buf = CodeBuffer::with_capacity(8192);
code!(&mut buf, ctx; "((__scope)=>{");
if std::env::var("NIX_JS_DEBUG_THUNKS").is_ok() {
code!(&mut buf, ctx; "Nix.DEBUG_THUNKS.enabled=true;");
}
code!(&mut buf, ctx;
"Nix.builtins.storeDir="
quoted(ctx.get_store_dir())
";const __currentDir="
quoted(&ctx.get_current_dir().display().to_string())
";return "
expr
"})"
);
buf.into_string()
}
trait Compile<Ctx: CodegenContext> {
fn compile(&self, ctx: &Ctx, buf: &mut CodeBuffer);
}
@@ -216,7 +238,7 @@ impl<Ctx: CodegenContext> Compile<Ctx> for Ir {
code!(buf, ctx; quoted(&s.val));
}
Ir::Path(p) => {
code!(buf, ctx; "Nix.resolvePath(currentDir," ctx.get_ir(p.expr) ")");
code!(buf, ctx; "Nix.resolvePath(__currentDir," ctx.get_ir(p.expr) ")");
}
Ir::If(x) => x.compile(ctx, buf),
Ir::BinOp(x) => x.compile(ctx, buf),
@@ -275,6 +297,20 @@ impl<Ctx: CodegenContext> Compile<Ctx> for Ir {
")"
);
}
&Ir::ReplBinding(ReplBinding { inner: name, .. }) => {
code!(buf, ctx;
"Nix.getReplBinding("
quoted(ctx.get_sym(name))
")"
);
}
&Ir::ScopedImportBinding(ScopedImportBinding { inner: name, .. }) => {
code!(buf, ctx;
"__scope["
quoted(ctx.get_sym(name))
"]"
);
}
}
}
}

View File

@@ -1,7 +1,7 @@
use std::path::Path;
use std::ptr::NonNull;
use hashbrown::HashMap;
use hashbrown::{HashMap, HashSet};
use itertools::Itertools as _;
use rnix::TextRange;
use string_interner::DefaultStringInterner;
@@ -9,8 +9,8 @@ use string_interner::DefaultStringInterner;
use crate::codegen::{CodegenContext, compile};
use crate::error::{Error, Result, Source};
use crate::ir::{
Arg, ArgId, Bool, Builtin, Downgrade as _, DowngradeContext, ExprId, Ir, Null, SymId, Thunk,
ToIr as _,
Arg, ArgId, Bool, Builtin, Downgrade as _, DowngradeContext, ExprId, Ir, Null, ReplBinding,
ScopedImportBinding, SymId, Thunk, ToIr as _,
};
use crate::runtime::{Runtime, RuntimeContext};
use crate::store::{Store, StoreBackend, StoreConfig};
@@ -47,18 +47,38 @@ impl Context {
eval!(eval_shallow, "Nix.forceShallow({})");
eval!(eval_deep, "Nix.forceDeep({})");
pub fn compile(&mut self, source: Source) -> Result<String> {
self.ctx.compile(source)
pub fn eval_repl<'a>(&'a mut self, source: Source, scope: &'a HashSet<SymId>) -> Result<Value> {
tracing::info!("Starting evaluation");
tracing::debug!("Compiling code");
let code = self.ctx.compile(source, Some(Scope::Repl(scope)))?;
tracing::debug!("Executing JavaScript");
self.runtime.eval(format!("Nix.forceShallow({})", code), &mut self.ctx)
}
#[allow(dead_code)]
pub(crate) fn eval_js(&mut self, code: String) -> Result<Value> {
self.runtime.eval(code, &mut self.ctx)
pub fn compile(&mut self, source: Source) -> Result<String> {
self.ctx.compile(source, None)
}
pub fn get_store_dir(&self) -> &str {
self.ctx.get_store_dir()
}
pub fn add_binding<'a>(&'a mut self, name: &str, expr: &str, scope: &'a mut HashSet<SymId>) -> Result<Value> {
let source = Source::new_repl(expr.to_string())?;
let code = self.ctx.compile(source, Some(Scope::Repl(scope)))?;
let sym = self.ctx.symbols.get_or_intern(name);
let eval_and_store = format!(
"(()=>{{const __v=Nix.forceShallow({});Nix.setReplBinding(\"{}\",__v);return __v}})()",
code, name
);
scope.insert(sym);
self.runtime.eval(eval_and_store, &mut self.ctx)
}
}
pub(crate) struct Ctx {
@@ -166,9 +186,12 @@ impl Ctx {
})
}
pub(crate) fn downgrade_ctx<'a>(&'a mut self) -> DowngradeCtx<'a> {
fn downgrade_ctx<'a>(
&'a mut self,
extra_scope: Option<Scope<'a>>,
) -> DowngradeCtx<'a> {
let global_ref = unsafe { self.global.as_ref() };
DowngradeCtx::new(self, global_ref)
DowngradeCtx::new(self, global_ref, extra_scope)
}
pub(crate) fn get_current_dir(&self) -> &Path {
@@ -190,7 +213,7 @@ impl Ctx {
self.sources.get(id).expect("source not found").clone()
}
fn compile(&mut self, source: Source) -> Result<String> {
fn compile<'a>(&'a mut self, source: Source, extra_scope: Option<Scope<'a>>) -> Result<String> {
tracing::debug!("Parsing Nix expression");
self.sources.push(source.clone());
@@ -204,7 +227,7 @@ impl Ctx {
#[allow(clippy::unwrap_used)]
let root = self
.downgrade_ctx()
.downgrade_ctx(extra_scope)
.downgrade(root.tree().expr().unwrap())?;
tracing::debug!("Generating JavaScript code");
@@ -212,6 +235,40 @@ impl Ctx {
tracing::debug!("Generated code: {}", &code);
Ok(code)
}
pub(crate) fn compile_scoped(
&mut self,
source: Source,
scope: Vec<String>,
) -> Result<String> {
use crate::codegen::compile_scoped;
tracing::debug!("Parsing Nix expression for scoped import");
self.sources.push(source.clone());
let root = rnix::Root::parse(&source.src);
if !root.errors().is_empty() {
let error_msg = root.errors().iter().join("; ");
let err = Error::parse_error(error_msg).with_source(source);
return Err(err);
}
let scope = Scope::ScopedImport(
scope
.into_iter()
.map(|k| self.symbols.get_or_intern(k))
.collect(),
);
#[allow(clippy::unwrap_used)]
let root = self.downgrade_ctx(Some(scope)).downgrade(root.tree().expr().unwrap())?;
tracing::debug!("Generating JavaScript code for scoped import");
let code = compile_scoped(self.get_ir(root), self);
tracing::debug!("Generated scoped code: {}", &code);
Ok(code)
}
}
impl CodegenContext for Ctx {
@@ -245,8 +302,11 @@ impl RuntimeContext for Ctx {
fn add_source(&mut self, source: Source) {
self.sources.push(source);
}
fn compile_code(&mut self, source: Source) -> Result<String> {
self.compile(source)
fn compile(&mut self, source: Source) -> Result<String> {
self.compile(source, None)
}
fn compile_scoped(&mut self, source: Source, scope: Vec<String>) -> Result<String> {
self.compile_scoped(source, scope)
}
fn get_source(&self, id: usize) -> Source {
self.get_source(id)
@@ -258,6 +318,8 @@ impl RuntimeContext for Ctx {
enum Scope<'ctx> {
Global(&'ctx HashMap<SymId, ExprId>),
Repl(&'ctx HashSet<SymId>),
ScopedImport(HashSet<SymId>),
Let(HashMap<SymId, ExprId>),
Param(SymId, ExprId),
With(ExprId),
@@ -288,9 +350,15 @@ pub struct DowngradeCtx<'ctx> {
}
impl<'ctx> DowngradeCtx<'ctx> {
fn new(ctx: &'ctx mut Ctx, global: &'ctx HashMap<SymId, ExprId>) -> Self {
fn new(
ctx: &'ctx mut Ctx,
global: &'ctx HashMap<SymId, ExprId>,
extra_scope: Option<Scope<'ctx>>,
) -> Self {
Self {
scopes: vec![Scope::Global(global)],
scopes: std::iter::once(Scope::Global(global))
.chain(extra_scope)
.collect(),
irs: vec![],
arg_id: 0,
thunk_scopes: vec![Vec::new()],
@@ -364,6 +432,16 @@ impl DowngradeContext for DowngradeCtx<'_> {
return Ok(expr);
}
}
&Scope::Repl(repl_bindings) => {
if repl_bindings.contains(&sym) {
return Ok(self.new_expr(ReplBinding { inner: sym, span }.to_ir()));
}
}
Scope::ScopedImport(scoped_bindings) => {
if scoped_bindings.contains(&sym) {
return Ok(self.new_expr(ScopedImportBinding { inner: sym, span }.to_ir()));
}
}
Scope::Let(let_scope) => {
if let Some(&expr) = let_scope.get(&sym) {
return Ok(self.new_expr(Thunk { inner: expr, span }.to_ir()));

View File

@@ -71,6 +71,8 @@ ir! {
Builtins,
Builtin(SymId),
CurPos,
ReplBinding(SymId),
ScopedImportBinding(SymId),
}
#[repr(transparent)]

View File

@@ -18,7 +18,8 @@ type LocalSymbol<'a> = v8::Local<'a, v8::Symbol>;
pub(crate) trait RuntimeContext: 'static {
fn get_current_dir(&self) -> &Path;
fn add_source(&mut self, path: Source);
fn compile_code(&mut self, source: Source) -> Result<String>;
fn compile(&mut self, source: Source) -> Result<String>;
fn compile_scoped(&mut self, source: Source, scope: Vec<String>) -> Result<String>;
fn get_source(&self, id: usize) -> Source;
fn get_store(&self) -> &dyn Store;
}
@@ -44,6 +45,7 @@ fn runtime_extension<Ctx: RuntimeContext>() -> Extension {
&deno_core::include_js_files!(nix_runtime dir "runtime-ts/dist", "runtime.js");
let mut ops = vec![
op_import::<Ctx>(),
op_scoped_import::<Ctx>(),
op_read_file(),
op_read_file_type(),
op_read_dir(),
@@ -446,29 +448,3 @@ fn to_primop<'a>(
Some(Value::PrimOpApp(name))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
use super::*;
use crate::context::Context;
#[test]
fn to_value_working() {
let mut ctx = Context::new().unwrap();
const EXPR: &str = "({ test: [1., 9223372036854775807n, true, false, 'hello world!'] })";
assert_eq!(
ctx.eval_js(EXPR.into()).unwrap(),
Value::AttrSet(AttrSet::new(std::collections::BTreeMap::from([(
Symbol::from("test"),
Value::List(List::new(vec![
Value::Float(1.),
Value::Int(9223372036854775807),
Value::Bool(true),
Value::Bool(false),
Value::String("hello world!".to_string())
]))
)])))
);
}
}

View File

@@ -1,7 +1,7 @@
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use hashbrown::hash_map::{HashMap, Entry};
use hashbrown::hash_map::{Entry, HashMap};
use deno_core::OpState;
use regex::Regex;
@@ -59,7 +59,7 @@ pub(super) fn op_import<Ctx: RuntimeContext>(
.into(),
};
ctx.add_source(source.clone());
return Ok(ctx.compile_code(source).map_err(|err| err.to_string())?);
return Ok(ctx.compile(source).map_err(|err| err.to_string())?);
} else {
return Err(format!("Corepkg not found: {}", corepkg_name).into());
}
@@ -83,7 +83,37 @@ pub(super) fn op_import<Ctx: RuntimeContext>(
tracing::debug!("Compiling file");
ctx.add_source(source.clone());
Ok(ctx.compile_code(source).map_err(|err| err.to_string())?)
Ok(ctx.compile(source).map_err(|err| err.to_string())?)
}
#[deno_core::op2]
#[string]
pub(super) fn op_scoped_import<Ctx: RuntimeContext>(
state: &mut OpState,
#[string] path: String,
#[serde] scope: Vec<String>,
) -> std::result::Result<String, NixRuntimeError> {
let _span = tracing::info_span!("op_scoped_import", path = %path).entered();
let ctx: &mut Ctx = state.get_ctx_mut();
let current_dir = ctx.get_current_dir();
let mut absolute_path = current_dir.join(&path);
if absolute_path.is_dir() {
absolute_path.push("default.nix")
}
tracing::info!("Scoped importing file: {}", absolute_path.display());
let source = Source::new_file(absolute_path.clone())
.map_err(|e| format!("Failed to read {}: {}", absolute_path.display(), e))?;
tracing::debug!("Compiling file for scoped import");
ctx.add_source(source.clone());
Ok(ctx
.compile_scoped(source, scope)
.map_err(|err| err.to_string())?)
}
#[deno_core::op2]

View File

@@ -7,8 +7,9 @@ use utils::{eval_deep, eval_deep_result};
#[test]
fn derivation_minimal() {
let result =
eval_deep(r#"derivation { name = "hello"; builder = "/bin/sh"; system = "x86_64-linux"; }"#);
let result = eval_deep(
r#"derivation { name = "hello"; builder = "/bin/sh"; system = "x86_64-linux"; }"#,
);
match result {
Value::AttrSet(attrs) => {
@@ -78,7 +79,8 @@ fn derivation_to_string() {
#[test]
fn derivation_missing_name() {
let result = eval_deep_result(r#"derivation { builder = "/bin/sh"; system = "x86_64-linux"; }"#);
let result =
eval_deep_result(r#"derivation { builder = "/bin/sh"; system = "x86_64-linux"; }"#);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();

View File

@@ -181,10 +181,7 @@ eval_okay_test!(
);
eval_okay_test!(r#if);
eval_okay_test!(ind_string);
eval_okay_test!(
#[ignore = "not implemented: scopedImport"]
import
);
eval_okay_test!(import);
eval_okay_test!(inherit_attr_pos);
eval_okay_test!(
#[ignore = "__overrides is not supported"]