diff --git a/nix-js/runtime-ts/src/builtins/derivation.ts b/nix-js/runtime-ts/src/builtins/derivation.ts index 1641c37..bb9c879 100644 --- a/nix-js/runtime-ts/src/builtins/derivation.ts +++ b/nix-js/runtime-ts/src/builtins/derivation.ts @@ -365,65 +365,52 @@ const specialAttrs = new Set([ export const derivation = (args: NixValue): NixAttrs => { const attrs = forceAttrs(args); - const strict = derivationStrict(args); const outputs: string[] = extractOutputs(attrs); - const drvName = validateName(attrs); - const collectedContext: NixStringContext = new Set(); - const builder = validateBuilder(attrs, collectedContext); - const platform = validateSystem(attrs); - const structuredAttrs = "__structuredAttrs" in attrs ? force(attrs.__structuredAttrs) === true : false; - const ignoreNulls = "__ignoreNulls" in attrs ? force(attrs.__ignoreNulls) === true : false; - const drvArgs = extractArgs(attrs, collectedContext); - const baseAttrs: NixAttrs = { - type: "derivation", - drvPath: strict.drvPath, - name: drvName, - builder, - system: platform, - }; + const strictThunk = createThunk(() => derivationStrict(args), "derivationStrict"); - if (drvArgs.length > 0) { - baseAttrs.args = drvArgs; - } + const commonAttrs: NixAttrs = { ...attrs }; - if (!structuredAttrs) { - for (const [key, value] of Object.entries(attrs)) { - if (!specialAttrs.has(key) && !outputs.includes(key)) { - const forcedValue = force(value); - if (!(ignoreNulls && forcedValue === null)) { - baseAttrs[key] = value; - } - } - } - } - - const outputsList: NixAttrs[] = []; - - for (const outputName of outputs) { - const outputObj: NixAttrs = { - ...baseAttrs, - outPath: strict[outputName], + const outputToAttrListElement = (outputName: string): { name: string; value: NixAttrs } => { + const value: NixAttrs = { + ...commonAttrs, + outPath: createThunk(() => (force(strictThunk) as NixAttrs)[outputName], `outPath_${outputName}`), + drvPath: createThunk(() => (force(strictThunk) as NixAttrs).drvPath, "drvPath"), + type: "derivation", outputName, }; - outputsList.push(outputObj); - } + return { name: outputName, value }; + }; - baseAttrs.drvAttrs = attrs; - for (const [i, outputName] of outputs.entries()) { - baseAttrs[outputName] = createThunk(() => outputsList[i], `output_${outputName}`); - } - baseAttrs.all = createThunk(() => outputsList, "all_outputs"); + const outputsList = outputs.map(outputToAttrListElement); - for (const outputObj of outputsList) { - outputObj.drvAttrs = attrs; - for (const [i, outputName] of outputs.entries()) { - outputObj[outputName] = createThunk(() => outputsList[i], `output_${outputName}`); + for (const { name: outputName, value } of outputsList) { + commonAttrs[outputName] = createThunk( + () => outputsList.find((o) => o.name === outputName)!.value, + `output_${outputName}`, + ); + } + commonAttrs.all = createThunk( + () => outputsList.map((o) => o.value), + "all_outputs", + ); + commonAttrs.drvAttrs = attrs; + + for (const { value: outputObj } of outputsList) { + for (const { name: outputName } of outputsList) { + outputObj[outputName] = createThunk( + () => outputsList.find((o) => o.name === outputName)!.value, + `output_${outputName}`, + ); } - outputObj.all = createThunk(() => outputsList, "all_outputs"); + outputObj.all = createThunk( + () => outputsList.map((o) => o.value), + "all_outputs", + ); + outputObj.drvAttrs = attrs; } - return outputsList[0]; + return outputsList[0].value; }; diff --git a/nix-js/runtime-ts/src/index.ts b/nix-js/runtime-ts/src/index.ts index 1e57f51..1d5706a 100644 --- a/nix-js/runtime-ts/src/index.ts +++ b/nix-js/runtime-ts/src/index.ts @@ -4,7 +4,7 @@ * All functionality is exported via the global `Nix` object */ -import { createThunk, force, isThunk, IS_THUNK, DEBUG_THUNKS } from "./thunk"; +import { createThunk, force, isThunk, IS_THUNK, DEBUG_THUNKS, forceDeepSafe, IS_CYCLE } from "./thunk"; import { select, selectWithDefault, @@ -34,9 +34,11 @@ export type NixRuntime = typeof Nix; export const Nix = { createThunk, force, + forceDeepSafe, forceBool, isThunk, IS_THUNK, + IS_CYCLE, HAS_CONTEXT, IS_PATH, DEBUG_THUNKS, diff --git a/nix-js/runtime-ts/src/thunk.ts b/nix-js/runtime-ts/src/thunk.ts index 681ace5..ff9c8eb 100644 --- a/nix-js/runtime-ts/src/thunk.ts +++ b/nix-js/runtime-ts/src/thunk.ts @@ -4,6 +4,8 @@ */ import type { NixValue, NixThunkInterface, NixStrictValue } from "./types"; +import { HAS_CONTEXT } from "./string-context"; +import { IS_PATH } from "./types"; /** * Symbol used to mark objects as thunks @@ -132,3 +134,50 @@ export const force = (value: NixValue): NixStrictValue => { export const createThunk = (func: () => NixValue, label?: string): NixThunkInterface => { return new NixThunk(func, label); }; + +/** + * Symbol to mark cyclic references detected during deep forcing + */ +export const IS_CYCLE = Symbol("is_cycle"); + +/** + * Marker object for cyclic references + */ +export const CYCLE_MARKER = { [IS_CYCLE]: true }; + +/** + * Deeply force a value, handling cycles by returning a special marker. + * Uses WeakSet to track seen objects and avoid infinite recursion. + * Returns a fully forced value where thunks are replaced with their results. + * Cyclic references are replaced with CYCLE_MARKER. + */ +export const forceDeepSafe = (value: NixValue, seen: WeakSet = new WeakSet()): NixStrictValue => { + const forced = force(value); + + if (forced === null || typeof forced !== "object") { + return forced; + } + + if (seen.has(forced)) { + return CYCLE_MARKER; + } + seen.add(forced); + + if (HAS_CONTEXT in forced || IS_PATH in forced) { + return forced; + } + + if (Array.isArray(forced)) { + return forced.map((item) => forceDeepSafe(item, seen)); + } + + if (typeof forced === "object") { + const result: Record = {}; + for (const [key, val] of Object.entries(forced)) { + result[key] = forceDeepSafe(val, seen); + } + return result; + } + + return forced; +}; diff --git a/nix-js/src/context.rs b/nix-js/src/context.rs index 6bf4752..8f4dda3 100644 --- a/nix-js/src/context.rs +++ b/nix-js/src/context.rs @@ -37,7 +37,7 @@ impl Context { tracing::debug!("Executing JavaScript"); self.runtime - .eval(format!("Nix.force({code})"), &mut self.ctx) + .eval(format!("Nix.forceDeepSafe({code})"), &mut self.ctx) } pub fn compile_code(&mut self, source: Source) -> Result { diff --git a/nix-js/src/runtime.rs b/nix-js/src/runtime.rs index e4ba366..f4bd0b7 100644 --- a/nix-js/src/runtime.rs +++ b/nix-js/src/runtime.rs @@ -552,6 +552,7 @@ pub(crate) struct Runtime { primop_metadata_symbol: v8::Global, has_context_symbol: v8::Global, is_path_symbol: v8::Global, + is_cycle_symbol: v8::Global, _marker: PhantomData, } @@ -571,7 +572,7 @@ impl Runtime { ..Default::default() }); - let (is_thunk_symbol, primop_metadata_symbol, has_context_symbol, is_path_symbol) = { + let (is_thunk_symbol, primop_metadata_symbol, has_context_symbol, is_path_symbol, is_cycle_symbol) = { deno_core::scope!(scope, &mut js_runtime); Self::get_symbols(scope)? }; @@ -582,6 +583,7 @@ impl Runtime { primop_metadata_symbol, has_context_symbol, is_path_symbol, + is_cycle_symbol, _marker: PhantomData, }) } @@ -609,6 +611,7 @@ impl Runtime { let primop_metadata_symbol = v8::Local::new(scope, &self.primop_metadata_symbol); let has_context_symbol = v8::Local::new(scope, &self.has_context_symbol); let is_path_symbol = v8::Local::new(scope, &self.is_path_symbol); + let is_cycle_symbol = v8::Local::new(scope, &self.is_cycle_symbol); Ok(to_value( local_value, @@ -617,10 +620,11 @@ impl Runtime { primop_metadata_symbol, has_context_symbol, is_path_symbol, + is_cycle_symbol, )) } - /// get (IS_THUNK, PRIMOP_METADATA, HAS_CONTEXT, IS_PATH) + /// get (IS_THUNK, PRIMOP_METADATA, HAS_CONTEXT, IS_PATH, IS_CYCLE) #[allow(clippy::type_complexity)] fn get_symbols( scope: &ScopeRef, @@ -629,6 +633,7 @@ impl Runtime { v8::Global, v8::Global, v8::Global, + v8::Global, )> { let global = scope.get_current_context().global(scope); let nix_key = v8::String::new(scope, "Nix") @@ -659,8 +664,9 @@ impl Runtime { let primop_metadata = get_symbol("PRIMOP_METADATA")?; let has_context = get_symbol("HAS_CONTEXT")?; let is_path = get_symbol("IS_PATH")?; + let is_cycle = get_symbol("IS_CYCLE")?; - Ok((is_thunk, primop_metadata, has_context, is_path)) + Ok((is_thunk, primop_metadata, has_context, is_path, is_cycle)) } } @@ -671,6 +677,7 @@ fn to_value<'a>( primop_metadata_symbol: LocalSymbol<'a>, has_context_symbol: LocalSymbol<'a>, is_path_symbol: LocalSymbol<'a>, + is_cycle_symbol: LocalSymbol<'a>, ) -> Value { match () { _ if val.is_big_int() => { @@ -707,6 +714,7 @@ fn to_value<'a>( primop_metadata_symbol, has_context_symbol, is_path_symbol, + is_cycle_symbol, ) }) .collect(); @@ -724,6 +732,10 @@ fn to_value<'a>( return Value::Thunk; } + if is_cycle(val, scope, is_cycle_symbol) { + return Value::Thunk; + } + if let Some(path_val) = extract_path(val, scope, is_path_symbol) { return Value::Path(path_val); } @@ -753,6 +765,7 @@ fn to_value<'a>( primop_metadata_symbol, has_context_symbol, is_path_symbol, + is_cycle_symbol, ), ) }) @@ -772,6 +785,15 @@ fn is_thunk<'a>(val: LocalValue<'a>, scope: &ScopeRef<'a, '_>, symbol: LocalSymb matches!(obj.get(scope, symbol.into()), Some(v) if v.is_true()) } +fn is_cycle<'a>(val: LocalValue<'a>, scope: &ScopeRef<'a, '_>, symbol: LocalSymbol<'a>) -> bool { + if !val.is_object() { + return false; + } + + let obj = val.to_object(scope).expect("infallible conversion"); + matches!(obj.get(scope, symbol.into()), Some(v) if v.is_true()) +} + fn extract_string_with_context<'a>( val: LocalValue<'a>, scope: &ScopeRef<'a, '_>, diff --git a/nix-js/tests/derivation.rs b/nix-js/tests/derivation.rs index 612eb74..b5891ef 100644 --- a/nix-js/tests/derivation.rs +++ b/nix-js/tests/derivation.rs @@ -470,8 +470,8 @@ fn structured_attrs_basic() { Value::AttrSet(attrs) => { assert!(attrs.contains_key("drvPath")); assert!(attrs.contains_key("outPath")); - assert!(!attrs.contains_key("foo")); - assert!(!attrs.contains_key("count")); + assert!(attrs.contains_key("foo")); + assert!(attrs.contains_key("count")); } _ => panic!("Expected AttrSet"), } @@ -492,7 +492,7 @@ fn structured_attrs_nested() { match result { Value::AttrSet(attrs) => { assert!(attrs.contains_key("drvPath")); - assert!(!attrs.contains_key("data")); + assert!(attrs.contains_key("data")); } _ => panic!("Expected AttrSet"), } @@ -554,7 +554,7 @@ fn ignore_nulls_true() { match result { Value::AttrSet(attrs) => { assert!(attrs.contains_key("foo")); - assert!(!attrs.contains_key("nullValue")); + assert!(attrs.contains_key("nullValue")); } _ => panic!("Expected AttrSet"), } @@ -600,8 +600,8 @@ fn ignore_nulls_with_structured_attrs() { match result { Value::AttrSet(attrs) => { assert!(attrs.contains_key("drvPath")); - assert!(!attrs.contains_key("foo")); - assert!(!attrs.contains_key("nullValue")); + assert!(attrs.contains_key("foo")); + assert!(attrs.contains_key("nullValue")); } _ => panic!("Expected AttrSet"), } @@ -627,8 +627,8 @@ fn all_features_combined() { assert!(attrs.contains_key("out")); assert!(attrs.contains_key("dev")); assert!(attrs.contains_key("outPath")); - assert!(!attrs.contains_key("data")); - assert!(!attrs.contains_key("nullValue")); + assert!(attrs.contains_key("data")); + assert!(attrs.contains_key("nullValue")); } _ => panic!("Expected AttrSet"), } @@ -651,7 +651,7 @@ fn fixed_output_with_structured_attrs() { Value::AttrSet(attrs) => { assert!(attrs.contains_key("outPath")); assert!(attrs.contains_key("drvPath")); - assert!(!attrs.contains_key("data")); + assert!(attrs.contains_key("data")); } _ => panic!("Expected AttrSet"), }