From 52bf46407aff2d22873b09419757f4763c529b38 Mon Sep 17 00:00:00 2001 From: imxyy_soope_ Date: Sat, 17 Jan 2026 18:13:44 +0800 Subject: [PATCH] feat: string context --- nix-js/runtime-ts/src/builtins/context.ts | 43 ++++++++++ nix-js/runtime-ts/src/builtins/conversion.ts | 6 ++ nix-js/runtime-ts/src/builtins/io.ts | 21 +++-- nix-js/runtime-ts/src/builtins/string.ts | 74 +++++++++++++--- nix-js/runtime-ts/src/helpers.ts | 16 ++-- nix-js/runtime-ts/src/string-context.ts | 39 +++++++++ nix-js/tests/string_context.rs | 89 ++++++++++++++++++++ 7 files changed, 266 insertions(+), 22 deletions(-) diff --git a/nix-js/runtime-ts/src/builtins/context.ts b/nix-js/runtime-ts/src/builtins/context.ts index 00763b6..0778751 100644 --- a/nix-js/runtime-ts/src/builtins/context.ts +++ b/nix-js/runtime-ts/src/builtins/context.ts @@ -11,16 +11,33 @@ import { parseContextToInfoMap, } from "../string-context"; +/** + * builtins.hasContext - Check if string has context + * + * Returns true if the string has any store path references. + */ export const hasContext = (value: NixValue): boolean => { const s = forceNixString(value); return isStringWithContext(s) && s.context.size > 0; }; +/** + * builtins.unsafeDiscardStringContext - Remove all context from string + * + * IMPORTANT: This discards string context, returning only the string value. + * Use with caution as it removes derivation dependencies. + */ export const unsafeDiscardStringContext = (value: NixValue): string => { const s = forceNixString(value); return getStringValue(s); }; +/** + * builtins.unsafeDiscardOutputDependency - Convert DrvDeep to Opaque context + * + * IMPORTANT: Transforms "all outputs" references (=) to plain path references. + * Preserves other context types unchanged. + */ export const unsafeDiscardOutputDependency = (value: NixValue): NixString => { const s = forceNixString(value); const strValue = getStringValue(s); @@ -47,6 +64,12 @@ export const unsafeDiscardOutputDependency = (value: NixValue): NixString => { return mkStringWithContext(strValue, newContext); }; +/** + * builtins.addDrvOutputDependencies - Convert Opaque to DrvDeep context + * + * IMPORTANT: Transforms plain derivation path references to "all outputs" references (=). + * The string must have exactly one context element which must be a .drv path. + */ export const addDrvOutputDependencies = (value: NixValue): NixString => { const s = forceNixString(value); const strValue = getStringValue(s); @@ -77,6 +100,14 @@ export const addDrvOutputDependencies = (value: NixValue): NixString => { return mkStringWithContext(strValue, newContext); }; +/** + * builtins.getContext - Extract context as structured attribute set + * + * Returns an attribute set mapping store paths to their context info: + * - path: true if it's a plain store path reference (opaque) + * - allOutputs: true if it references all derivation outputs (drvDeep, encoded as =path) + * - outputs: list of specific output names (built, encoded as !output!path) + */ export const getContext = (value: NixValue): NixAttrs => { const s = forceNixString(value); const context = getStringContext(s); @@ -101,6 +132,18 @@ export const getContext = (value: NixValue): NixAttrs => { return result; }; +/** + * builtins.appendContext - Add context to a string + * + * IMPORTANT: Merges the provided context attribute set with any existing context + * from the input string. Used to manually construct strings with specific + * derivation dependencies. + * + * Context format matches getContext output: + * - path: boolean - add as opaque reference + * - allOutputs: boolean - add as drvDeep reference (=) + * - outputs: [string] - add as built references (!output!) + */ export const appendContext = (strValue: NixValue) => (ctxValue: NixValue): NixString => { diff --git a/nix-js/runtime-ts/src/builtins/conversion.ts b/nix-js/runtime-ts/src/builtins/conversion.ts index 3e2d91d..f012340 100644 --- a/nix-js/runtime-ts/src/builtins/conversion.ts +++ b/nix-js/runtime-ts/src/builtins/conversion.ts @@ -110,6 +110,12 @@ export interface CoerceResult { * Coerce a Nix value to a string according to the specified mode. * This implements the same behavior as Lix's EvalState::coerceToString. * + * IMPORTANT: String context preservation rules: + * - StringWithContext: Context is collected in outContext parameter + * - Derivations (with outPath): Built context is added for the drvPath/outputName + * - Lists (ToString mode): Context from all elements is merged + * - All other coercions: No context added + * * @param value - The value to coerce * @param mode - The coercion mode (controls which types are allowed) * @param copyToStore - If true, paths should be copied to the Nix store (not implemented yet) diff --git a/nix-js/runtime-ts/src/builtins/io.ts b/nix-js/runtime-ts/src/builtins/io.ts index 675440d..ad476ee 100644 --- a/nix-js/runtime-ts/src/builtins/io.ts +++ b/nix-js/runtime-ts/src/builtins/io.ts @@ -7,14 +7,25 @@ import { forceAttrs, forceBool, forceString } from "../type-assert"; import type { NixValue, NixAttrs } from "../types"; import { isNixPath } from "../types"; import { force } from "../thunk"; -import { coerceToPath, coerceToString, StringCoercionMode } from "./conversion"; +import { coerceToPath } from "./conversion"; import { getPathValue } from "../path"; - -// Declare Deno.core.ops global (provided by deno_core runtime) +import type { NixStringContext } from "../string-context"; export const importFunc = (path: NixValue): NixValue => { - // TODO: context? - const pathStr = coerceToString(path, StringCoercionMode.Base); + const context: NixStringContext = new Set(); + const pathStr = coerceToPath(path, context); + + // FIXME: Context collected but not yet propagated to build system + // This means derivation dependencies from imported paths are not + // currently tracked. This will cause issues when: + // 1. Importing from derivation outputs: import "${drv}/file.nix" + // 2. Building packages that depend on imported configurations + if (context.size > 0) { + console.warn( + `[WARN] import: Path has string context which is not yet fully tracked. +Dependency tracking for imported derivations may be incomplete.`, + ); + } // Call Rust op - returns JS code string const code = Deno.core.ops.op_import(pathStr); diff --git a/nix-js/runtime-ts/src/builtins/string.ts b/nix-js/runtime-ts/src/builtins/string.ts index 4276c67..f6462fa 100644 --- a/nix-js/runtime-ts/src/builtins/string.ts +++ b/nix-js/runtime-ts/src/builtins/string.ts @@ -2,29 +2,83 @@ * String operation builtin functions */ -import type { NixInt, NixValue } from "../types"; -import { forceString, forceList, forceInt } from "../type-assert"; +import type { NixInt, NixValue, NixString } from "../types"; +import { forceString, forceList, forceInt, forceNixString } from "../type-assert"; import { coerceToString, StringCoercionMode } from "./conversion"; +import { + type NixStringContext, + getStringValue, + getStringContext, + mkStringWithContext, +} from "../string-context"; export const stringLength = (e: NixValue): NixInt => BigInt(forceString(e).length); +/** + * builtins.substring - Extract substring while preserving string context + * + * IMPORTANT: String context must be preserved from the source string. + * This matches Lix behavior where substring operations maintain references + * to store paths and derivations. + * + * Special case: substring 0 0 str can be used idiomatically to capture + * string context efficiently without copying the string value. + */ export const substring = (start: NixValue) => (len: NixValue) => - (s: NixValue): string => { - const str = forceString(s); + (s: NixValue): NixString => { const startPos = Number(forceInt(start)); const length = Number(forceInt(len)); - return str.substring(startPos, startPos + length); + + if (startPos < 0) { + throw new Error("negative start position in 'substring'"); + } + + const str = forceNixString(s); + const strValue = getStringValue(str); + const context = getStringContext(str); + + if (length === 0) { + if (context.size === 0) { + return ""; + } + return mkStringWithContext("", context); + } + + const actualLength = length < 0 ? Number.MAX_SAFE_INTEGER : length; + const result = startPos >= strValue.length ? "" : strValue.substring(startPos, startPos + actualLength); + + if (context.size === 0) { + return result; + } + return mkStringWithContext(result, context); }; +/** + * builtins.concatStringsSep - Concatenate strings with separator, merging contexts + * + * IMPORTANT: String context must be collected from both the separator and all + * list elements, then merged into the result. This ensures that store path + * references are preserved when building paths like "/nix/store/xxx/bin:/nix/store/yyy/bin". + */ export const concatStringsSep = (sep: NixValue) => - (list: NixValue): string => - // FIXME: context? - forceList(list) - .map((elem) => coerceToString(elem, StringCoercionMode.Interpolation)) - .join(forceString(sep)); + (list: NixValue): NixString => { + const context: NixStringContext = new Set(); + const separator = coerceToString(sep, StringCoercionMode.Interpolation, false, context); + + const parts = forceList(list).map((elem) => + coerceToString(elem, StringCoercionMode.Interpolation, false, context), + ); + + const result = parts.join(separator); + + if (context.size === 0) { + return result; + } + return mkStringWithContext(result, context); + }; export const baseNameOf = (x: NixValue): string => { const str = forceString(x); diff --git a/nix-js/runtime-ts/src/helpers.ts b/nix-js/runtime-ts/src/helpers.ts index f60b3b5..c78fdf1 100644 --- a/nix-js/runtime-ts/src/helpers.ts +++ b/nix-js/runtime-ts/src/helpers.ts @@ -6,11 +6,7 @@ import type { NixValue, NixAttrs, NixBool, NixString, NixPath } from "./types"; import { forceAttrs, forceBool, forceFunction, forceString, typeName } from "./type-assert"; import { isAttrs } from "./builtins/type-check"; import { coerceToString, StringCoercionMode } from "./builtins/conversion"; -import { - type NixStringContext, - mkStringWithContext, - isStringWithContext, -} from "./string-context"; +import { type NixStringContext, mkStringWithContext, isStringWithContext } from "./string-context"; import { force } from "./thunk"; import { mkPath } from "./path"; import { CatchableError, isNixPath } from "./types"; @@ -18,6 +14,12 @@ import { CatchableError, isNixPath } from "./types"; /** * Concatenate multiple values into a string or path with context * This is used for string interpolation like "hello ${world}" + * + * IMPORTANT: String context handling: + * - All contexts from interpolated values are merged into the result + * - Path mode: Store contexts are forbidden (will throw error) + * - String mode: All contexts are preserved and merged + * * If first element is a path, result is a path (with constraint: no store context allowed) * * @param parts - Array of values to concatenate @@ -230,5 +232,5 @@ export const assert = (assertion: NixValue, expr: NixValue, assertionRaw: string if (forceBool(assertion)) { return expr; } - throw new CatchableError(`assertion '${assertionRaw}' failed`) -} + throw new CatchableError(`assertion '${assertionRaw}' failed`); +}; diff --git a/nix-js/runtime-ts/src/string-context.ts b/nix-js/runtime-ts/src/string-context.ts index ead728b..a3f93b6 100644 --- a/nix-js/runtime-ts/src/string-context.ts +++ b/nix-js/runtime-ts/src/string-context.ts @@ -1,3 +1,28 @@ +/** + * String Context System for Nix + * + * String context tracks references to store paths and derivations within strings. + * This is critical for Nix's dependency tracking - when a string containing a + * store path is used in a derivation, that store path becomes a build dependency. + * + * Context Elements (encoded as strings): + * - Opaque: Plain store path reference + * Format: "/nix/store/..." + * Example: "/nix/store/abc123-hello" + * + * - DrvDeep: Derivation with all outputs + * Format: "=/nix/store/...drv" + * Example: "=/nix/store/xyz789-hello.drv" + * Meaning: All outputs of this derivation and its closure + * + * - Built: Specific derivation output + * Format: "!!/nix/store/...drv" + * Example: "!out!/nix/store/xyz789-hello.drv" + * Meaning: Specific output (e.g., "out", "dev", "lib") of this derivation + * + * This implementation matches Lix's NixStringContext system. + */ + export const HAS_CONTEXT = Symbol("HAS_CONTEXT"); export interface StringContextOpaque { @@ -143,6 +168,20 @@ export const parseContextToInfoMap = (context: NixStringContext): Map>; inputSrcs: Set } => { diff --git a/nix-js/tests/string_context.rs b/nix-js/tests/string_context.rs index 189014a..2321a41 100644 --- a/nix-js/tests/string_context.rs +++ b/nix-js/tests/string_context.rs @@ -288,3 +288,92 @@ fn interpolation_derivation_equals_tostring() { ); assert_eq!(result, Value::Bool(true)); } + +#[test] +fn substring_preserves_context() { + let result = eval( + r#" + let + drv = derivation { name = "test"; builder = "/bin/sh"; system = "x86_64-linux"; }; + str = builtins.toString drv; + sub = builtins.substring 0 10 str; + in builtins.hasContext sub + "#, + ); + assert_eq!(result, Value::Bool(true)); +} + +#[test] +fn substring_zero_length_preserves_context() { + let result = eval( + r#" + let + drv = derivation { name = "test"; builder = "/bin/sh"; system = "x86_64-linux"; }; + str = builtins.toString drv; + empty = builtins.substring 0 0 str; + in builtins.hasContext empty + "#, + ); + assert_eq!(result, Value::Bool(true)); +} + +#[test] +fn substring_zero_length_empty_value() { + let result = eval( + r#" + let + drv = derivation { name = "test"; builder = "/bin/sh"; system = "x86_64-linux"; }; + str = builtins.toString drv; + empty = builtins.substring 0 0 str; + in empty == "" + "#, + ); + assert_eq!(result, Value::Bool(true)); +} + +#[test] +fn concatStringsSep_preserves_context() { + let result = eval( + r#" + let + drv1 = derivation { name = "test1"; builder = "/bin/sh"; system = "x86_64-linux"; }; + drv2 = derivation { name = "test2"; builder = "/bin/sh"; system = "x86_64-linux"; }; + str1 = builtins.toString drv1; + str2 = builtins.toString drv2; + combined = builtins.concatStringsSep ":" [str1 str2]; + in builtins.hasContext combined + "#, + ); + assert_eq!(result, Value::Bool(true)); +} + +#[test] +fn concatStringsSep_merges_contexts() { + let result = eval( + r#" + let + drv1 = derivation { name = "test1"; builder = "/bin/sh"; system = "x86_64-linux"; }; + drv2 = derivation { name = "test2"; builder = "/bin/sh"; system = "x86_64-linux"; }; + str1 = builtins.toString drv1; + str2 = builtins.toString drv2; + combined = builtins.concatStringsSep ":" [str1 str2]; + ctx = builtins.getContext combined; + in builtins.length (builtins.attrNames ctx) + "#, + ); + assert_eq!(result, Value::Int(2)); +} + +#[test] +fn concatStringsSep_separator_has_context() { + let result = eval( + r#" + let + drv = derivation { name = "test"; builder = "/bin/sh"; system = "x86_64-linux"; }; + sep = builtins.toString drv; + combined = builtins.concatStringsSep sep ["a" "b"]; + in builtins.hasContext combined + "#, + ); + assert_eq!(result, Value::Bool(true)); +}