From c5240385eaa271b0ab4c68054468ed8ee3316199 Mon Sep 17 00:00:00 2001 From: imxyy_soope_ Date: Sun, 11 Jan 2026 10:09:18 +0800 Subject: [PATCH] feat: initial string context implementation --- nix-js/runtime-ts/src/builtins/context.ts | 161 ++++++++++ nix-js/runtime-ts/src/builtins/conversion.ts | 76 +++-- nix-js/runtime-ts/src/builtins/derivation.ts | 67 +++-- nix-js/runtime-ts/src/builtins/list.ts | 10 +- nix-js/runtime-ts/src/builtins/misc.ts | 29 +- nix-js/runtime-ts/src/helpers.ts | 29 +- nix-js/runtime-ts/src/index.ts | 5 +- nix-js/runtime-ts/src/operators.ts | 110 +++++-- nix-js/runtime-ts/src/string-context.ts | 194 +++++++++++++ nix-js/runtime-ts/src/type-assert.ts | 40 ++- nix-js/runtime-ts/src/types.ts | 5 +- nix-js/src/codegen.rs | 14 +- nix-js/src/context.rs | 4 +- nix-js/src/ir.rs | 3 - nix-js/src/ir/downgrade.rs | 19 +- nix-js/src/lib.rs | 2 +- nix-js/src/runtime.rs | 78 ++++- nix-js/src/value.rs | 29 +- nix-js/tests/string_context.rs | 290 +++++++++++++++++++ nix-js/tests/to_string.rs | 10 +- 20 files changed, 1027 insertions(+), 148 deletions(-) create mode 100644 nix-js/runtime-ts/src/builtins/context.ts create mode 100644 nix-js/runtime-ts/src/string-context.ts create mode 100644 nix-js/tests/string_context.rs diff --git a/nix-js/runtime-ts/src/builtins/context.ts b/nix-js/runtime-ts/src/builtins/context.ts new file mode 100644 index 0000000..0dff291 --- /dev/null +++ b/nix-js/runtime-ts/src/builtins/context.ts @@ -0,0 +1,161 @@ +import type { NixValue, NixAttrs, NixString } from "../types"; +import { isStringWithContext } from "../types"; +import { forceNixString, forceAttrs, forceBool, forceList, forceString } from "../type-assert"; +import { force } from "../thunk"; +import { + type NixStringContext, + getStringValue, + getStringContext, + mkStringWithContext, + decodeContextElem, + parseContextToInfoMap, +} from "../string-context"; + +export const hasContext = (value: NixValue): boolean => { + const s = forceNixString(value); + return isStringWithContext(s) && s.context.size > 0; +}; + +export const unsafeDiscardStringContext = (value: NixValue): string => { + const s = forceNixString(value); + return getStringValue(s); +}; + +export const unsafeDiscardOutputDependency = (value: NixValue): NixString => { + const s = forceNixString(value); + const strValue = getStringValue(s); + const context = getStringContext(s); + + if (context.size === 0) { + return strValue; + } + + const newContext: NixStringContext = new Set(); + for (const encoded of context) { + const elem = decodeContextElem(encoded); + if (elem.type === "drvDeep") { + newContext.add(elem.drvPath); + } else { + newContext.add(encoded); + } + } + + if (newContext.size === 0) { + return strValue; + } + + return mkStringWithContext(strValue, newContext); +}; + +export const addDrvOutputDependencies = (value: NixValue): NixString => { + const s = forceNixString(value); + const strValue = getStringValue(s); + const context = getStringContext(s); + + if (context.size !== 1) { + throw new Error( + `context of string '${strValue}' must have exactly one element, but has ${context.size}`, + ); + } + + const [encoded] = context; + const elem = decodeContextElem(encoded); + + if (elem.type === "drvDeep") { + return s; + } + + if (elem.type === "built") { + throw new Error( + `\`addDrvOutputDependencies\` can only act on derivations, not on a derivation output such as '${elem.output}'`, + ); + } + + if (!elem.path.endsWith(".drv")) { + throw new Error(`path '${elem.path}' is not a derivation`); + } + + const newContext: NixStringContext = new Set([`=${elem.path}`]); + return mkStringWithContext(strValue, newContext); +}; + +export const getContext = (value: NixValue): NixAttrs => { + const s = forceNixString(value); + const context = getStringContext(s); + + const infoMap = parseContextToInfoMap(context); + const result: NixAttrs = {}; + + for (const [path, info] of infoMap) { + const attrs: NixAttrs = {}; + if (info.path) { + attrs["path"] = true; + } + if (info.allOutputs) { + attrs["allOutputs"] = true; + } + if (info.outputs.length > 0) { + attrs["outputs"] = info.outputs; + } + result[path] = attrs; + } + + return result; +}; + +export const appendContext = + (strValue: NixValue) => + (ctxValue: NixValue): NixString => { + const s = forceNixString(strValue); + const strVal = getStringValue(s); + const existingContext = getStringContext(s); + + const ctxAttrs = forceAttrs(ctxValue); + const newContext: NixStringContext = new Set(existingContext); + + for (const [path, infoVal] of Object.entries(ctxAttrs)) { + if (!path.startsWith("/nix/store/")) { + throw new Error(`context key '${path}' is not a store path`); + } + + const info = forceAttrs(infoVal); + + if ("path" in info) { + const pathVal = force(info["path"]); + if (pathVal === true) { + newContext.add(path); + } + } + + if ("allOutputs" in info) { + const allOutputs = force(info["allOutputs"]); + if (allOutputs === true) { + if (!path.endsWith(".drv")) { + throw new Error( + `tried to add all-outputs context of ${path}, which is not a derivation, to a string`, + ); + } + newContext.add(`=${path}`); + } + } + + if ("outputs" in info) { + const outputs = forceList(info["outputs"]); + if (outputs.length > 0 && !path.endsWith(".drv")) { + throw new Error( + `tried to add derivation output context of ${path}, which is not a derivation, to a string`, + ); + } + for (const output of outputs) { + const outputName = forceString(output); + newContext.add(`!${outputName}!${path}`); + } + } + } + + if (newContext.size === 0) { + return strVal; + } + + return mkStringWithContext(strVal, newContext); + }; diff --git a/nix-js/runtime-ts/src/builtins/conversion.ts b/nix-js/runtime-ts/src/builtins/conversion.ts index 31f4caf..bc24848 100644 --- a/nix-js/runtime-ts/src/builtins/conversion.ts +++ b/nix-js/runtime-ts/src/builtins/conversion.ts @@ -2,8 +2,15 @@ * Conversion and serialization builtin functions */ -import type { NixValue } from "../types"; +import type { NixValue, NixString } from "../types"; +import { isStringWithContext } from "../types"; import { force } from "../thunk"; +import { + type NixStringContext, + mkStringWithContext, + addBuiltContext, +} from "../string-context"; +import { forceFunction } from "../type-assert"; export const fromJSON = (e: NixValue): never => { throw new Error("Not implemented: fromJSON"); @@ -44,6 +51,7 @@ const typeName = (value: NixValue): string => { if (typeof val === "number") return "float"; if (typeof val === "boolean") return "boolean"; if (typeof val === "string") return "string"; + if (isStringWithContext(val)) return "string"; if (val === null) return "null"; if (Array.isArray(val)) return "list"; if (typeof val === "function") return "lambda"; @@ -52,6 +60,11 @@ const typeName = (value: NixValue): string => { return `unknown type`; }; +export interface CoerceResult { + value: string; + context: NixStringContext; +} + /** * Coerce a Nix value to a string according to the specified mode. * This implements the same behavior as Lix's EvalState::coerceToString. @@ -59,6 +72,7 @@ const typeName = (value: NixValue): string => { * @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) + * @param outContext - Optional context set to collect string contexts * @returns The string representation of the value * @throws TypeError if the value cannot be coerced in the given mode * @@ -77,6 +91,7 @@ export const coerceToString = ( value: NixValue, mode: StringCoercionMode = StringCoercionMode.ToString, copyToStore: boolean = false, + outContext?: NixStringContext, ): string => { const v = force(value); @@ -85,29 +100,39 @@ export const coerceToString = ( return v; } - // Attribute sets can define custom string conversion via __toString method - // or may have an outPath attribute (for derivations and paths) + if (isStringWithContext(v)) { + if (outContext) { + for (const elem of v.context) { + outContext.add(elem); + } + } + return v.value; + } + if (typeof v === "object" && v !== null && !Array.isArray(v)) { // First, try the __toString method if present // This allows custom types to define their own string representation if ("__toString" in v) { // Force the method in case it's a thunk - const toStringMethod = force(v["__toString"]); - if (typeof toStringMethod === "function") { - // Call the method with self as argument - const result = force(toStringMethod(v)); - if (typeof result !== "string") { - throw new TypeError(`__toString returned ${typeName(result)} instead of string`); - } - return result; - } + const toStringMethod = forceFunction(v.__toString); + const result = force(toStringMethod(v)); + // Recursively coerceToString + return coerceToString(result, mode, copyToStore, outContext); } // If no __toString, try outPath (used for derivations and store paths) // This allows derivation objects like { outPath = "/nix/store/..."; } to be coerced if ("outPath" in v) { - // Recursively coerce the outPath value (it might itself be an attrs with __toString) - return coerceToString(v["outPath"], mode, copyToStore); + // Recursively coerce the outPath value + const outPath = coerceToString(v.outPath, mode, copyToStore, outContext); + if ("type" in v && v.type === "derivation" && "drvPath" in v) { + const drvPath = force(v.drvPath); + if (typeof drvPath === "string" && outContext) { + const outputName = "outputName" in v ? String(force(v.outputName)) : "out"; + addBuiltContext(outContext, drvPath, outputName); + } + } + return outPath; } // Attribute sets without __toString or outPath cannot be coerced @@ -157,7 +182,7 @@ export const coerceToString = ( for (let i = 0; i < v.length; i++) { const item = v[i]; // Recursively convert element to string - const str = coerceToString(item, mode, copyToStore); + const str = coerceToString(item, mode, copyToStore, outContext); result += str; // Add space after this element if: @@ -182,6 +207,23 @@ export const coerceToString = ( throw new TypeError(`cannot coerce ${typeName(v)} to a string`); }; +/** + * Coerce a Nix value to a string with context tracking + */ +export const coerceToStringWithContext = ( + value: NixValue, + mode: StringCoercionMode = StringCoercionMode.ToString, + copyToStore: boolean = false, +): NixString => { + const context: NixStringContext = new Set(); + const str = coerceToString(value, mode, copyToStore, context); + + if (context.size === 0) { + return str; + } + return mkStringWithContext(str, context); +}; + /** * builtins.toString - Convert a value to a string * @@ -191,6 +233,6 @@ export const coerceToString = ( * @param value - The value to convert to a string * @returns The string representation */ -export const toStringFunc = (value: NixValue): string => { - return coerceToString(value, StringCoercionMode.ToString, false); +export const toStringFunc = (value: NixValue): NixString => { + return coerceToStringWithContext(value, StringCoercionMode.ToString, false); }; diff --git a/nix-js/runtime-ts/src/builtins/derivation.ts b/nix-js/runtime-ts/src/builtins/derivation.ts index ef4f402..15f81b3 100644 --- a/nix-js/runtime-ts/src/builtins/derivation.ts +++ b/nix-js/runtime-ts/src/builtins/derivation.ts @@ -1,15 +1,21 @@ import type { NixValue, NixAttrs } from "../types"; -import { forceString, forceList } from "../type-assert"; +import { forceString, forceList, forceNixString } from "../type-assert"; import { force } from "../thunk"; import { type DerivationData, type OutputInfo, generateAterm } from "../derivation-helpers"; import { coerceToString, StringCoercionMode } from "./conversion"; +import { + type NixStringContext, + extractInputDrvsAndSrcs, + isStringWithContext, + HAS_CONTEXT, +} from "../string-context"; const forceAttrs = (value: NixValue): NixAttrs => { const forced = force(value); - if (typeof forced !== "object" || forced === null || Array.isArray(forced)) { + if (typeof forced !== "object" || forced === null || Array.isArray(forced) || isStringWithContext(forced)) { throw new TypeError(`Expected attribute set for derivation, got ${typeof forced}`); } - return forced as NixAttrs; + return forced; }; const validateName = (attrs: NixAttrs): string => { @@ -26,11 +32,11 @@ const validateName = (attrs: NixAttrs): string => { return name; }; -const validateBuilder = (attrs: NixAttrs): string => { +const validateBuilder = (attrs: NixAttrs, outContext: NixStringContext): string => { if (!("builder" in attrs)) { throw new Error("derivation: missing required attribute 'builder'"); } - return forceString(attrs.builder); + return coerceToString(attrs.builder, StringCoercionMode.ToString, false, outContext); }; const validateSystem = (attrs: NixAttrs): string => { @@ -66,15 +72,19 @@ const extractOutputs = (attrs: NixAttrs): string[] => { return outputs; }; -const extractArgs = (attrs: NixAttrs): string[] => { +const extractArgs = (attrs: NixAttrs, outContext: NixStringContext): string[] => { if (!("args" in attrs)) { return []; } const argsList = forceList(attrs.args); - return argsList.map((a) => coerceToString(a, StringCoercionMode.ToString, false)); + return argsList.map((a) => coerceToString(a, StringCoercionMode.ToString, false, outContext)); }; -const nixValueToJson = (value: NixValue, seen = new Set()): any => { +const nixValueToJson = ( + value: NixValue, + seen = new Set(), + outContext?: NixStringContext, +): any => { const v = force(value); if (v === null) return null; @@ -82,6 +92,15 @@ const nixValueToJson = (value: NixValue, seen = new Set()): any => { if (typeof v === "string") return v; if (typeof v === "number") return v; + if (typeof v === "object" && HAS_CONTEXT in v && "context" in v) { + if (outContext) { + for (const elem of v.context) { + outContext.add(elem); + } + } + return v.value; + } + if (typeof v === "bigint") { const num = Number(v); if (v > Number.MAX_SAFE_INTEGER || v < Number.MIN_SAFE_INTEGER) { @@ -98,13 +117,13 @@ const nixValueToJson = (value: NixValue, seen = new Set()): any => { } if (Array.isArray(v)) { - return v.map((item) => nixValueToJson(item, seen)); + return v.map((item) => nixValueToJson(item, seen, outContext)); } if (typeof v === "object") { const result: Record = {}; for (const [key, val] of Object.entries(v)) { - result[key] = nixValueToJson(val, seen); + result[key] = nixValueToJson(val, seen, outContext); } return result; } @@ -116,7 +135,12 @@ const nixValueToJson = (value: NixValue, seen = new Set()): any => { throw new Error(`derivation: cannot serialize ${typeof v} to JSON`); }; -const extractEnv = (attrs: NixAttrs, structuredAttrs: boolean, ignoreNulls: boolean): Map => { +const extractEnv = ( + attrs: NixAttrs, + structuredAttrs: boolean, + ignoreNulls: boolean, + outContext: NixStringContext, +): Map => { const specialAttrs = new Set([ "name", "builder", @@ -139,7 +163,7 @@ const extractEnv = (attrs: NixAttrs, structuredAttrs: boolean, ignoreNulls: bool if (ignoreNulls && forcedValue === null) { continue; } - jsonAttrs[key] = nixValueToJson(value); + jsonAttrs[key] = nixValueToJson(value, new Set(), outContext); } } env.set("__json", JSON.stringify(jsonAttrs)); @@ -150,7 +174,7 @@ const extractEnv = (attrs: NixAttrs, structuredAttrs: boolean, ignoreNulls: bool if (ignoreNulls && forcedValue === null) { continue; } - env.set(key, coerceToString(value, StringCoercionMode.ToString, false)); + env.set(key, coerceToString(value, StringCoercionMode.ToString, false, outContext)); } } } @@ -190,7 +214,8 @@ export const derivationStrict = (args: NixValue): NixAttrs => { const attrs = forceAttrs(args); const drvName = validateName(attrs); - const builder = validateBuilder(attrs); + const collectedContext: NixStringContext = new Set(); + const builder = validateBuilder(attrs, collectedContext); const platform = validateSystem(attrs); const outputs = extractOutputs(attrs); @@ -201,8 +226,8 @@ export const derivationStrict = (args: NixValue): NixAttrs => { const ignoreNulls = "__ignoreNulls" in attrs ? force(attrs.__ignoreNulls) === true : false; - const drvArgs = extractArgs(attrs); - const env = extractEnv(attrs, structuredAttrs, ignoreNulls); + const drvArgs = extractArgs(attrs, collectedContext); + const env = extractEnv(attrs, structuredAttrs, ignoreNulls, collectedContext); env.set("name", drvName); env.set("builder", builder); @@ -211,6 +236,8 @@ export const derivationStrict = (args: NixValue): NixAttrs => { env.set("outputs", outputs.join(" ")); } + const { inputDrvs, inputSrcs } = extractInputDrvsAndSrcs(collectedContext); + let outputInfos: Map; let drvPath: string; @@ -239,8 +266,8 @@ export const derivationStrict = (args: NixValue): NixAttrs => { const finalDrv: DerivationData = { name: drvName, outputs: outputInfos, - inputDrvs: new Map(), - inputSrcs: new Set(), + inputDrvs, + inputSrcs, platform, builder, args: drvArgs, @@ -268,8 +295,8 @@ export const derivationStrict = (args: NixValue): NixAttrs => { const maskedDrv: DerivationData = { name: drvName, outputs: maskedOutputs, - inputDrvs: new Map(), - inputSrcs: new Set(), + inputDrvs, + inputSrcs, platform, builder, args: drvArgs, diff --git a/nix-js/runtime-ts/src/builtins/list.ts b/nix-js/runtime-ts/src/builtins/list.ts index dfecbd6..6f3b46e 100644 --- a/nix-js/runtime-ts/src/builtins/list.ts +++ b/nix-js/runtime-ts/src/builtins/list.ts @@ -87,15 +87,15 @@ export const partition = (list: NixValue): NixAttrs => { const forced_list = forceList(list); const forced_pred = forceFunction(pred); - const attrs: NixAttrs = { - right: [], - wrong: [], + const attrs = { + right: [] as NixList, + wrong: [] as NixList, }; for (const elem of forced_list) { if (force(forced_pred(elem))) { - (attrs.right as NixList).push(elem); + attrs.right.push(elem); } else { - (attrs.wrong as NixList).push(elem); + attrs.wrong.push(elem); } } return attrs; diff --git a/nix-js/runtime-ts/src/builtins/misc.ts b/nix-js/runtime-ts/src/builtins/misc.ts index 6304d27..a7aa403 100644 --- a/nix-js/runtime-ts/src/builtins/misc.ts +++ b/nix-js/runtime-ts/src/builtins/misc.ts @@ -4,7 +4,8 @@ import { force } from "../thunk"; import { CatchableError } from "../types"; -import type { NixBool, NixStrictValue, NixValue } from "../types"; +import type { NixBool, NixStrictValue, NixValue, NixString } from "../types"; +import * as context from "./context"; export const addErrorContext = (e1: NixValue) => @@ -12,19 +13,11 @@ export const addErrorContext = throw new Error("Not implemented: addErrorContext"); }; -export const appendContext = - (e1: NixValue) => - (e2: NixValue): never => { - throw new Error("Not implemented: appendContext"); - }; +export const appendContext = context.appendContext; -export const getContext = (s: NixValue): never => { - throw new Error("Not implemented: getContext"); -}; +export const getContext = context.getContext; -export const hasContext = (s: NixValue): never => { - throw new Error("Not implemented: hasContext"); -}; +export const hasContext = context.hasContext; export const hashFile = (type: NixValue) => @@ -42,21 +35,15 @@ export const convertHash = (args: NixValue): never => { throw new Error("Not implemented: convertHash"); }; -export const unsafeDiscardOutputDependency = (s: NixValue): never => { - throw new Error("Not implemented: unsafeDiscardOutputDependency"); -}; +export const unsafeDiscardOutputDependency = context.unsafeDiscardOutputDependency; -export const unsafeDiscardStringContext = (s: NixValue): never => { - throw new Error("Not implemented: unsafeDiscardStringContext"); -}; +export const unsafeDiscardStringContext = context.unsafeDiscardStringContext; export const unsafeGetAttrPos = (s: NixValue): never => { throw new Error("Not implemented: unsafeGetAttrPos"); }; -export const addDrvOutputDependencies = (s: NixValue): never => { - throw new Error("Not implemented: addDrvOutputDependencies"); -}; +export const addDrvOutputDependencies = context.addDrvOutputDependencies; export const compareVersions = (s1: NixValue) => diff --git a/nix-js/runtime-ts/src/helpers.ts b/nix-js/runtime-ts/src/helpers.ts index 8bde89b..83de5cb 100644 --- a/nix-js/runtime-ts/src/helpers.ts +++ b/nix-js/runtime-ts/src/helpers.ts @@ -2,9 +2,36 @@ * Helper functions for nix-js runtime */ -import type { NixValue, NixAttrs, NixBool } from "./types"; +import type { NixValue, NixAttrs, NixBool, NixString } from "./types"; import { forceAttrs, forceString } from "./type-assert"; import { isAttrs } from "./builtins/type-check"; +import { coerceToString, StringCoercionMode } from "./builtins/conversion"; +import { type NixStringContext, mkStringWithContext } from "./string-context"; + +/** + * Concatenate multiple values into a string with context + * This is used for string interpolation like "hello ${world}" + * + * @param parts - Array of values to concatenate + * @returns String with merged contexts from all parts + */ +export const concatStringsWithContext = (parts: NixValue[]): NixString => { + const context: NixStringContext = new Set(); + const strParts: string[] = []; + + for (const part of parts) { + const str = coerceToString(part, StringCoercionMode.Interpolation, false, context); + strParts.push(str); + } + + const value = strParts.join(""); + + if (context.size === 0) { + return value; + } + + return mkStringWithContext(value, context); +}; /** * Resolve a path (handles both absolute and relative paths) diff --git a/nix-js/runtime-ts/src/index.ts b/nix-js/runtime-ts/src/index.ts index ee76379..85d20f1 100644 --- a/nix-js/runtime-ts/src/index.ts +++ b/nix-js/runtime-ts/src/index.ts @@ -5,10 +5,11 @@ */ import { createThunk, force, isThunk, IS_THUNK } from "./thunk"; -import { select, selectWithDefault, validateParams, resolvePath, hasAttr } from "./helpers"; +import { select, selectWithDefault, validateParams, resolvePath, hasAttr, concatStringsWithContext } from "./helpers"; import { op } from "./operators"; import { builtins, PRIMOP_METADATA } from "./builtins"; import { coerceToString, StringCoercionMode } from "./builtins/conversion"; +import { HAS_CONTEXT } from "./string-context"; export type NixRuntime = typeof Nix; @@ -20,6 +21,7 @@ export const Nix = { force, isThunk, IS_THUNK, + HAS_CONTEXT, hasAttr, select, @@ -27,6 +29,7 @@ export const Nix = { validateParams, resolvePath, coerceToString, + concatStringsWithContext, StringCoercionMode, op, diff --git a/nix-js/runtime-ts/src/operators.ts b/nix-js/runtime-ts/src/operators.ts index 9fa9774..6e7e0ca 100644 --- a/nix-js/runtime-ts/src/operators.ts +++ b/nix-js/runtime-ts/src/operators.ts @@ -3,20 +3,45 @@ * Implements all binary and unary operators used by codegen */ -import type { NixValue, NixList, NixAttrs } from "./types"; +import type { NixValue, NixList, NixAttrs, NixString } from "./types"; +import { isStringWithContext } from "./types"; import { force } from "./thunk"; import { forceNumeric, forceList, forceAttrs, coerceNumeric } from "./type-assert"; +import { + getStringValue, + getStringContext, + mergeContexts, + mkStringWithContext, +} from "./string-context"; + +const isNixString = (v: unknown): v is NixString => { + return typeof v === "string" || isStringWithContext(v); +}; /** * Operator object exported as Nix.op * All operators referenced by codegen (e.g., Nix.op.add, Nix.op.eq) */ export const op = { - // Arithmetic operators - preserve int/float distinction - add: (a: NixValue, b: NixValue): bigint | number => { - // FIXME: String & Path - const [av, bv] = coerceNumeric(forceNumeric(a), forceNumeric(b)); - return (av as any) + (bv as any); + add: (a: NixValue, b: NixValue): bigint | number | NixString => { + const av = force(a); + const bv = force(b); + + if (isNixString(av) && isNixString(bv)) { + const strA = getStringValue(av); + const strB = getStringValue(bv); + const ctxA = getStringContext(av); + const ctxB = getStringContext(bv); + + if (ctxA.size === 0 && ctxB.size === 0) { + return strA + strB; + } + + return mkStringWithContext(strA + strB, mergeContexts(ctxA, ctxB)); + } + + const [numA, numB] = coerceNumeric(forceNumeric(a), forceNumeric(b)); + return (numA as any) + (numB as any); }, sub: (a: NixValue, b: NixValue): bigint | number => { @@ -39,52 +64,77 @@ export const op = { return (av as any) / (bv as any); }, - // Comparison operators (JavaScript natively supports bigint/number mixed comparison) eq: (a: NixValue, b: NixValue): boolean => { - // FIXME: Int and Float const av = force(a); const bv = force(b); + + if (isNixString(av) && isNixString(bv)) { + return getStringValue(av) === getStringValue(bv); + } + + if (typeof av === "bigint" && typeof bv === "number") { + return Number(av) === bv; + } + if (typeof av === "number" && typeof bv === "bigint") { + return av === Number(bv); + } + return av === bv; }, neq: (a: NixValue, b: NixValue): boolean => { - // FIXME: Int and Float - const av = force(a); - const bv = force(b); - return av !== bv; + return !op.eq(a, b); }, lt: (a: NixValue, b: NixValue): boolean => { - // FIXME: Non-numeric - const [av, bv] = coerceNumeric(forceNumeric(a), forceNumeric(b)); - return (av as any) < (bv as any); + const av = force(a); + const bv = force(b); + + if (isNixString(av) && isNixString(bv)) { + return getStringValue(av) < getStringValue(bv); + } + + const [numA, numB] = coerceNumeric(forceNumeric(a), forceNumeric(b)); + return (numA as any) < (numB as any); }, lte: (a: NixValue, b: NixValue): boolean => { - // FIXME: Non-numeric - const [av, bv] = coerceNumeric(forceNumeric(a), forceNumeric(b)); - return (av as any) <= (bv as any); + const av = force(a); + const bv = force(b); + + if (isNixString(av) && isNixString(bv)) { + return getStringValue(av) <= getStringValue(bv); + } + + const [numA, numB] = coerceNumeric(forceNumeric(a), forceNumeric(b)); + return (numA as any) <= (numB as any); }, gt: (a: NixValue, b: NixValue): boolean => { - // FIXME: Non-numeric - const [av, bv] = coerceNumeric(forceNumeric(a), forceNumeric(b)); - return (av as any) > (bv as any); + const av = force(a); + const bv = force(b); + + if (isNixString(av) && isNixString(bv)) { + return getStringValue(av) > getStringValue(bv); + } + + const [numA, numB] = coerceNumeric(forceNumeric(a), forceNumeric(b)); + return (numA as any) > (numB as any); }, gte: (a: NixValue, b: NixValue): boolean => { - // FIXME: Non-numeric - const [av, bv] = coerceNumeric(forceNumeric(a), forceNumeric(b)); - return (av as any) >= (bv as any); + const av = force(a); + const bv = force(b); + + if (isNixString(av) && isNixString(bv)) { + return getStringValue(av) >= getStringValue(bv); + } + + const [numA, numB] = coerceNumeric(forceNumeric(a), forceNumeric(b)); + return (numA as any) >= (numB as any); }, - // Boolean operators bnot: (a: NixValue): boolean => !force(a), - // Non-short-circuit - // band: (a: NixValue, b: NixValue): boolean => !!(force(a) && force(b)), - // bor: (a: NixValue, b: NixValue): boolean => !!(force(a) || force(b)), - // List concatenation concat: (a: NixValue, b: NixValue): NixList => { return Array.prototype.concat.call(forceList(a), forceList(b)); }, - // Attribute set update (merge) update: (a: NixValue, b: NixValue): NixAttrs => { return { ...forceAttrs(a), ...forceAttrs(b) }; }, diff --git a/nix-js/runtime-ts/src/string-context.ts b/nix-js/runtime-ts/src/string-context.ts new file mode 100644 index 0000000..7de5837 --- /dev/null +++ b/nix-js/runtime-ts/src/string-context.ts @@ -0,0 +1,194 @@ +export const HAS_CONTEXT = Symbol("HAS_CONTEXT"); + +export interface StringContextOpaque { + type: "opaque"; + path: string; +} + +export interface StringContextDrvDeep { + type: "drvDeep"; + drvPath: string; +} + +export interface StringContextBuilt { + type: "built"; + drvPath: string; + output: string; +} + +export type StringContextElem = StringContextOpaque | StringContextDrvDeep | StringContextBuilt; + +export type NixStringContext = Set; + +export interface StringWithContext { + readonly [HAS_CONTEXT]: true; + value: string; + context: NixStringContext; +} + +export const isStringWithContext = (v: unknown): v is StringWithContext => { + return typeof v === "object" && v !== null && HAS_CONTEXT in v && (v as StringWithContext)[HAS_CONTEXT] === true; +}; + +export const mkStringWithContext = (value: string, context: NixStringContext): StringWithContext => { + return { [HAS_CONTEXT]: true, value, context }; +}; + +export const mkPlainString = (value: string): string => value; + +export const getStringValue = (s: string | StringWithContext): string => { + if (isStringWithContext(s)) { + return s.value; + } + return s; +}; + +export const getStringContext = (s: string | StringWithContext): NixStringContext => { + if (isStringWithContext(s)) { + return s.context; + } + return new Set(); +}; + +export const mergeContexts = (...contexts: NixStringContext[]): NixStringContext => { + const result = new Set(); + for (const ctx of contexts) { + for (const elem of ctx) { + result.add(elem); + } + } + return result; +}; + +export const concatStringsWithContext = ( + strings: (string | StringWithContext)[], +): string | StringWithContext => { + const parts: string[] = []; + const contexts: NixStringContext[] = []; + + for (const s of strings) { + parts.push(getStringValue(s)); + const ctx = getStringContext(s); + if (ctx.size > 0) { + contexts.push(ctx); + } + } + + const value = parts.join(""); + if (contexts.length === 0) { + return value; + } + + return mkStringWithContext(value, mergeContexts(...contexts)); +}; + +export const encodeContextElem = (elem: StringContextElem): string => { + switch (elem.type) { + case "opaque": + return elem.path; + case "drvDeep": + return `=${elem.drvPath}`; + case "built": + return `!${elem.output}!${elem.drvPath}`; + } +}; + +export const decodeContextElem = (encoded: string): StringContextElem => { + if (encoded.startsWith("=")) { + return { type: "drvDeep", drvPath: encoded.slice(1) }; + } else if (encoded.startsWith("!")) { + const secondBang = encoded.indexOf("!", 1); + if (secondBang === -1) { + throw new Error(`Invalid context element: ${encoded}`); + } + return { + type: "built", + output: encoded.slice(1, secondBang), + drvPath: encoded.slice(secondBang + 1), + }; + } else { + return { type: "opaque", path: encoded }; + } +}; + +export const addContextElem = (context: NixStringContext, elem: StringContextElem): void => { + context.add(encodeContextElem(elem)); +}; + +export const addOpaqueContext = (context: NixStringContext, path: string): void => { + context.add(path); +}; + +export const addDrvDeepContext = (context: NixStringContext, drvPath: string): void => { + context.add(`=${drvPath}`); +}; + +export const addBuiltContext = (context: NixStringContext, drvPath: string, output: string): void => { + context.add(`!${output}!${drvPath}`); +}; + +export interface ParsedContextInfo { + path: boolean; + allOutputs: boolean; + outputs: string[]; +} + +export const parseContextToInfoMap = (context: NixStringContext): Map => { + const result = new Map(); + + const getOrCreate = (path: string): ParsedContextInfo => { + let info = result.get(path); + if (!info) { + info = { path: false, allOutputs: false, outputs: [] }; + result.set(path, info); + } + return info; + }; + + for (const encoded of context) { + const elem = decodeContextElem(encoded); + switch (elem.type) { + case "opaque": + getOrCreate(elem.path).path = true; + break; + case "drvDeep": + getOrCreate(elem.drvPath).allOutputs = true; + break; + case "built": + getOrCreate(elem.drvPath).outputs.push(elem.output); + break; + } + } + + return result; +}; + +export const extractInputDrvsAndSrcs = ( + context: NixStringContext, +): { inputDrvs: Map>; inputSrcs: Set } => { + const inputDrvs = new Map>(); + const inputSrcs = new Set(); + + for (const encoded of context) { + const elem = decodeContextElem(encoded); + switch (elem.type) { + case "opaque": + inputSrcs.add(elem.path); + break; + case "drvDeep": + inputSrcs.add(elem.drvPath); + break; + case "built": { + let outputs = inputDrvs.get(elem.drvPath); + if (!outputs) { + outputs = new Set(); + inputDrvs.set(elem.drvPath, outputs); + } + outputs.add(elem.output); + break; + } + } + } + + return { inputDrvs, inputSrcs }; +}; diff --git a/nix-js/runtime-ts/src/type-assert.ts b/nix-js/runtime-ts/src/type-assert.ts index e9c74f2..5dbc4a6 100644 --- a/nix-js/runtime-ts/src/type-assert.ts +++ b/nix-js/runtime-ts/src/type-assert.ts @@ -3,8 +3,10 @@ * These functions force evaluation and verify the type, throwing errors on mismatch */ -import type { NixValue, NixList, NixAttrs, NixFunction, NixInt, NixFloat, NixNumber } from "./types"; +import type { NixValue, NixList, NixAttrs, NixFunction, NixInt, NixFloat, NixNumber, NixString } from "./types"; +import { isStringWithContext } from "./types"; import { force } from "./thunk"; +import { getStringValue } from "./string-context"; const typeName = (value: NixValue): string => { const val = force(value); @@ -13,6 +15,7 @@ const typeName = (value: NixValue): string => { if (typeof val === "number") return "float"; if (typeof val === "boolean") return "boolean"; if (typeof val === "string") return "string"; + if (isStringWithContext(val)) return "string"; if (val === null) return "null"; if (Array.isArray(val)) return "list"; if (typeof val === "function") return "lambda"; @@ -51,22 +54,47 @@ export const forceFunction = (value: NixValue): NixFunction => { */ export const forceAttrs = (value: NixValue): NixAttrs => { const forced = force(value); - if (typeof forced !== "object" || Array.isArray(forced) || forced === null) { + if (typeof forced !== "object" || Array.isArray(forced) || forced === null || isStringWithContext(forced)) { throw new TypeError(`Expected attribute set, got ${typeName(forced)}`); } return forced; }; /** - * Force a value and assert it's a string + * Force a value and assert it's a string (plain or with context) * @throws TypeError if value is not a string after forcing */ export const forceString = (value: NixValue): string => { const forced = force(value); - if (typeof forced !== "string") { - throw new TypeError(`Expected string, got ${typeName(forced)}`); + if (typeof forced === "string") { + return forced; } - return forced; + if (isStringWithContext(forced)) { + return forced.value; + } + throw new TypeError(`Expected string, got ${typeName(forced)}`); +}; + +/** + * Force a value and assert it's a string, returning NixString (preserving context) + * @throws TypeError if value is not a string after forcing + */ +export const forceNixString = (value: NixValue): NixString => { + const forced = force(value); + if (typeof forced === "string") { + return forced; + } + if (isStringWithContext(forced)) { + return forced; + } + throw new TypeError(`Expected string, got ${typeName(forced)}`); +}; + +/** + * Get the plain string value from any NixString + */ +export const nixStringValue = (s: NixString): string => { + return getStringValue(s); }; /** diff --git a/nix-js/runtime-ts/src/types.ts b/nix-js/runtime-ts/src/types.ts index 3728a78..f241d10 100644 --- a/nix-js/runtime-ts/src/types.ts +++ b/nix-js/runtime-ts/src/types.ts @@ -3,13 +3,16 @@ */ import { IS_THUNK } from "./thunk"; +import { type StringWithContext, HAS_CONTEXT, isStringWithContext } from "./string-context"; +export { HAS_CONTEXT, isStringWithContext }; +export type { StringWithContext }; // Nix primitive types export type NixInt = bigint; export type NixFloat = number; export type NixNumber = NixInt | NixFloat; export type NixBool = boolean; -export type NixString = string; +export type NixString = string | StringWithContext; export type NixNull = null; // Nix composite types diff --git a/nix-js/src/codegen.rs b/nix-js/src/codegen.rs index 9d20e13..341ba60 100644 --- a/nix-js/src/codegen.rs +++ b/nix-js/src/codegen.rs @@ -281,23 +281,13 @@ impl Compile for List { impl Compile for ConcatStrings { fn compile(&self, ctx: &Ctx) -> String { - // Concatenate all parts into a single string - // Use JavaScript template string or array join let parts: Vec = self .parts .iter() - .map(|part| { - let compiled = ctx.get_ir(*part).compile(ctx); - // TODO: copyToStore - format!( - "Nix.coerceToString({}, Nix.StringCoercionMode.Interpolation, false)", - compiled - ) - }) + .map(|part| ctx.get_ir(*part).compile(ctx)) .collect(); - // Use array join for concatenation - format!("[{}].join('')", parts.join(",")) + format!("Nix.concatStringsWithContext([{}])", parts.join(",")) } } diff --git a/nix-js/src/context.rs b/nix-js/src/context.rs index 7513267..476a80c 100644 --- a/nix-js/src/context.rs +++ b/nix-js/src/context.rs @@ -57,9 +57,9 @@ mod private { use private::CtxPtr; #[derive(Debug)] -pub struct SccInfo { +pub(crate) struct SccInfo { /// list of SCCs (exprs, recursive) - pub sccs: Vec<(Vec, bool)>, + pub(crate) sccs: Vec<(Vec, bool)>, } pub struct Context { diff --git a/nix-js/src/ir.rs b/nix-js/src/ir.rs index ced34c8..8931aed 100644 --- a/nix-js/src/ir.rs +++ b/nix-js/src/ir.rs @@ -329,9 +329,6 @@ pub struct Let { /// Describes the parameters of a function. #[derive(Debug)] pub struct Param { - /// The name of the argument if it's a simple identifier (e.g., `x: ...`). - /// Also used for the alias in a pattern (e.g., `args @ { ... }`). - pub ident: Option, /// The set of required parameter names for a pattern-matching function. pub required: Option>, /// The set of all allowed parameter names for a non-ellipsis pattern-matching function. diff --git a/nix-js/src/ir/downgrade.rs b/nix-js/src/ir/downgrade.rs index 5340522..8f381b9 100644 --- a/nix-js/src/ir/downgrade.rs +++ b/nix-js/src/ir/downgrade.rs @@ -121,8 +121,11 @@ impl Downgrade for ast::Path { impl Downgrade for ast::Str { fn downgrade(self, ctx: &mut Ctx) -> Result { - let parts = self - .normalized_parts() + let normalized = self.normalized_parts(); + let is_single_literal = normalized.len() == 1 + && matches!(normalized.first(), Some(ast::InterpolPart::Literal(_))); + + let parts = normalized .into_iter() .map(|part| match part { ast::InterpolPart::Literal(lit) => Ok(ctx.new_expr(Str { val: lit }.to_ir())), @@ -131,7 +134,8 @@ impl Downgrade for ast::Str { } }) .collect::>>()?; - Ok(if parts.len() == 1 { + + Ok(if is_single_literal { parts.into_iter().next().unwrap() } else { ctx.new_expr(ConcatStrings { parts }.to_ir()) @@ -314,7 +318,6 @@ impl Downgrade for ast::Lambda { fn downgrade(self, ctx: &mut Ctx) -> Result { let arg = ctx.new_arg(); - let ident; let required; let allowed; let body; @@ -323,7 +326,6 @@ impl Downgrade for ast::Lambda { ast::Param::IdentParam(id) => { // Simple case: `x: body` let param_sym = ctx.new_sym(id.to_string()); - ident = Some(param_sym); required = None; allowed = None; @@ -335,7 +337,6 @@ impl Downgrade for ast::Lambda { let alias = pattern .pat_bind() .map(|alias| ctx.new_sym(alias.ident().unwrap().to_string())); - ident = alias; let has_ellipsis = pattern.ellipsis_token().is_some(); let pat_entries = pattern.pat_entries(); @@ -367,11 +368,7 @@ impl Downgrade for ast::Lambda { } } - let param = Param { - ident, - required, - allowed, - }; + let param = Param { required, allowed }; // The function's body and parameters are now stored directly in the `Func` node. Ok(ctx.new_expr(Func { body, param, arg }.to_ir())) } diff --git a/nix-js/src/lib.rs b/nix-js/src/lib.rs index 53dd99e..7411391 100644 --- a/nix-js/src/lib.rs +++ b/nix-js/src/lib.rs @@ -3,7 +3,7 @@ mod codegen; pub mod context; pub mod error; -pub mod ir; +mod ir; mod nix_hash; mod runtime; pub mod value; diff --git a/nix-js/src/runtime.rs b/nix-js/src/runtime.rs index 9756851..47bcacd 100644 --- a/nix-js/src/runtime.rs +++ b/nix-js/src/runtime.rs @@ -180,6 +180,7 @@ pub(crate) struct Runtime { js_runtime: JsRuntime, is_thunk_symbol: v8::Global, primop_metadata_symbol: v8::Global, + has_context_symbol: v8::Global, _marker: PhantomData, } @@ -199,7 +200,7 @@ impl Runtime { ..Default::default() }); - let (is_thunk_symbol, primop_metadata_symbol) = { + let (is_thunk_symbol, primop_metadata_symbol, has_context_symbol) = { deno_core::scope!(scope, &mut js_runtime); Self::get_symbols(scope)? }; @@ -208,6 +209,7 @@ impl Runtime { js_runtime, is_thunk_symbol, primop_metadata_symbol, + has_context_symbol, _marker: PhantomData, }) } @@ -225,17 +227,25 @@ impl Runtime { let local_value = v8::Local::new(scope, &global_value); let is_thunk_symbol = v8::Local::new(scope, &self.is_thunk_symbol); let primop_metadata_symbol = v8::Local::new(scope, &self.primop_metadata_symbol); + let has_context_symbol = v8::Local::new(scope, &self.has_context_symbol); Ok(to_value( local_value, scope, is_thunk_symbol, primop_metadata_symbol, + has_context_symbol, )) } - /// get (IS_THUNK, PRIMOP_METADATA) - fn get_symbols(scope: &ScopeRef) -> Result<(v8::Global, v8::Global)> { + /// get (IS_THUNK, PRIMOP_METADATA, HAS_CONTEXT) + fn get_symbols( + scope: &ScopeRef, + ) -> Result<( + v8::Global, + v8::Global, + v8::Global, + )> { let global = scope.get_current_context().global(scope); let nix_key = v8::String::new(scope, "Nix") .ok_or_else(|| Error::internal("failed to create V8 String".into()))?; @@ -273,7 +283,19 @@ impl Runtime { })?; let primop_metadata = v8::Global::new(scope, primop_metadata); - Ok((is_thunk, primop_metadata)) + let has_context_sym_key = v8::String::new(scope, "HAS_CONTEXT") + .ok_or_else(|| Error::internal("failed to create V8 String".into()))?; + let has_context_sym = nix_obj + .get(scope, has_context_sym_key.into()) + .ok_or_else(|| Error::internal("failed to get HAS_CONTEXT Symbol".into()))?; + let has_context = has_context_sym.try_cast::().map_err(|err| { + Error::internal(format!( + "failed to convert HAS_CONTEXT Value to Symbol ({err})" + )) + })?; + let has_context = v8::Global::new(scope, has_context); + + Ok((is_thunk, primop_metadata, has_context)) } } @@ -282,6 +304,7 @@ fn to_value<'a>( scope: &ScopeRef<'a, '_>, is_thunk_symbol: LocalSymbol<'a>, primop_metadata_symbol: LocalSymbol<'a>, + has_context_symbol: LocalSymbol<'a>, ) -> Value { match () { _ if val.is_big_int() => { @@ -296,7 +319,6 @@ fn to_value<'a>( } _ if val.is_number() => { let val = val.to_number(scope).expect("infallible conversion").value(); - // number is always NixFloat Value::Float(val) } _ if val.is_true() => Value::Bool(true), @@ -312,7 +334,13 @@ fn to_value<'a>( let list = (0..len) .map(|i| { let val = val.get_index(scope, i).expect("infallible index operation"); - to_value(val, scope, is_thunk_symbol, primop_metadata_symbol) + to_value( + val, + scope, + is_thunk_symbol, + primop_metadata_symbol, + has_context_symbol, + ) }) .collect(); Value::List(List::new(list)) @@ -329,6 +357,10 @@ fn to_value<'a>( return Value::Thunk; } + if let Some(string_val) = extract_string_with_context(val, scope, has_context_symbol) { + return Value::String(string_val); + } + let val = val.to_object(scope).expect("infallible conversion"); let keys = val .get_own_property_names(scope, v8::GetPropertyNamesArgsBuilder::new().build()) @@ -343,7 +375,13 @@ fn to_value<'a>( let key = key.to_rust_string_lossy(scope); ( Symbol::new(key), - to_value(val, scope, is_thunk_symbol, primop_metadata_symbol), + to_value( + val, + scope, + is_thunk_symbol, + primop_metadata_symbol, + has_context_symbol, + ), ) }) .collect(); @@ -362,6 +400,32 @@ 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 extract_string_with_context<'a>( + val: LocalValue<'a>, + scope: &ScopeRef<'a, '_>, + symbol: LocalSymbol<'a>, +) -> Option { + if !val.is_object() { + return None; + } + + let obj = val.to_object(scope).expect("infallible conversion"); + let has_context = obj.get(scope, symbol.into())?; + + if !has_context.is_true() { + return None; + } + + let value_key = v8::String::new(scope, "value")?; + let value = obj.get(scope, value_key.into())?; + + if value.is_string() { + Some(value.to_rust_string_lossy(scope)) + } else { + None + } +} + fn to_primop<'a>( val: LocalValue<'a>, scope: &ScopeRef<'a, '_>, diff --git a/nix-js/src/value.rs b/nix-js/src/value.rs index 5248312..d30e04c 100644 --- a/nix-js/src/value.rs +++ b/nix-js/src/value.rs @@ -4,6 +4,7 @@ use core::ops::Deref; use std::borrow::Cow; use std::collections::BTreeMap; +use std::ops::DerefMut; use std::sync::LazyLock; use derive_more::{Constructor, IsVariant, Unwrap}; @@ -86,6 +87,18 @@ impl AttrSet { } } +impl Deref for AttrSet { + type Target = BTreeMap; + fn deref(&self) -> &Self::Target { + &self.data + } +} +impl DerefMut for AttrSet { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.data + } +} + impl Debug for AttrSet { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { use Value::*; @@ -129,15 +142,15 @@ pub struct List { data: Vec, } -impl List { - /// Returns the number of elements in the list. - pub fn len(&self) -> usize { - self.data.len() +impl Deref for List { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.data } - - /// Returns true if the list is empty. - pub fn is_empty(&self) -> bool { - self.data.is_empty() +} +impl DerefMut for List { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.data } } diff --git a/nix-js/tests/string_context.rs b/nix-js/tests/string_context.rs new file mode 100644 index 0000000..189014a --- /dev/null +++ b/nix-js/tests/string_context.rs @@ -0,0 +1,290 @@ +use nix_js::context::Context; +use nix_js::value::Value; + +fn eval(expr: &str) -> Value { + let mut ctx = Context::new().unwrap(); + ctx.eval_code(expr).unwrap_or_else(|e| panic!("{}", e)) +} + +#[test] +fn hascontext_plain_string() { + let result = eval(r#"builtins.hasContext "hello""#); + assert_eq!(result, Value::Bool(false)); +} + +#[test] +fn hascontext_derivation_output() { + let result = eval( + r#" + let + drv = derivation { name = "test"; builder = "/bin/sh"; system = "x86_64-linux"; }; + in builtins.hasContext (builtins.toString drv) + "#, + ); + assert_eq!(result, Value::Bool(true)); +} + +#[test] +fn getcontext_plain_string() { + let result = eval(r#"builtins.getContext "hello""#); + match result { + Value::AttrSet(attrs) => { + assert!(attrs.is_empty(), "Plain string should have empty context"); + } + _ => panic!("Expected AttrSet, got {:?}", result), + } +} + +#[test] +fn getcontext_derivation_output() { + let result = eval( + r#" + let + drv = derivation { name = "test"; builder = "/bin/sh"; system = "x86_64-linux"; }; + str = builtins.toString drv; + ctx = builtins.getContext str; + in builtins.attrNames ctx + "#, + ); + match result { + Value::List(list) => { + assert_eq!(list.len(), 1, "Should have exactly one context entry"); + match list.first().unwrap() { + Value::String(s) => { + assert!(s.ends_with(".drv"), "Context key should be a .drv path"); + } + other => panic!("Expected String, got {:?}", other), + } + } + _ => panic!("Expected List, got {:?}", result), + } +} + +#[test] +fn unsafediscardstringcontext() { + let result = eval( + r#" + let + drv = derivation { name = "test"; builder = "/bin/sh"; system = "x86_64-linux"; }; + strWithContext = builtins.toString drv; + strWithoutContext = builtins.unsafeDiscardStringContext strWithContext; + in builtins.hasContext strWithoutContext + "#, + ); + assert_eq!(result, Value::Bool(false)); +} + +#[test] +fn unsafediscardstringcontext_preserves_value() { + let result = eval( + r#" + let + drv = derivation { name = "test"; builder = "/bin/sh"; system = "x86_64-linux"; }; + strWithContext = builtins.toString drv; + strWithoutContext = builtins.unsafeDiscardStringContext strWithContext; + in strWithContext == strWithoutContext + "#, + ); + assert_eq!(result, Value::Bool(true)); +} + +#[test] +fn appendcontext_basic() { + let result = eval( + r#" + let + str = builtins.appendContext "hello" { + "/nix/store/0000000000000000000000000000000-test.drv" = { outputs = ["out"]; }; + }; + in builtins.hasContext str + "#, + ); + assert_eq!(result, Value::Bool(true)); +} + +#[test] +fn appendcontext_preserves_value() { + let result = eval( + r#" + let + str = builtins.appendContext "hello" { + "/nix/store/0000000000000000000000000000000-test.drv" = { outputs = ["out"]; }; + }; + in str == "hello" + "#, + ); + assert_eq!(result, Value::Bool(true)); +} + +#[test] +fn string_concat_merges_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 = str1 + " " + str2; + ctx = builtins.getContext combined; + in builtins.length (builtins.attrNames ctx) + "#, + ); + assert_eq!(result, Value::Int(2)); +} + +#[test] +fn string_add_merges_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 = str1 + " " + str2; + ctx = builtins.getContext combined; + in builtins.length (builtins.attrNames ctx) + "#, + ); + assert_eq!(result, Value::Int(2)); +} + +#[test] +fn context_in_derivation_args() { + let result = eval( + r#" + let + dep = derivation { name = "dep"; builder = "/bin/sh"; system = "x86_64-linux"; }; + drv = derivation { + name = "test"; + builder = "/bin/sh"; + system = "x86_64-linux"; + args = [ ((builtins.toString dep) + "/bin/run") ]; + }; + in drv.drvPath + "#, + ); + match result { + Value::String(s) => { + assert!(s.starts_with("/nix/store/"), "Should be a store path"); + assert!(s.ends_with(".drv"), "Should be a .drv file"); + } + _ => panic!("Expected String, got {:?}", result), + } +} + +#[test] +fn context_in_derivation_env() { + let result = eval( + r#" + let + dep = derivation { name = "dep"; builder = "/bin/sh"; system = "x86_64-linux"; }; + drv = derivation { + name = "test"; + builder = "/bin/sh"; + system = "x86_64-linux"; + myDep = builtins.toString dep; + }; + in drv.drvPath + "#, + ); + match result { + Value::String(s) => { + assert!(s.starts_with("/nix/store/"), "Should be a store path"); + assert!(s.ends_with(".drv"), "Should be a .drv file"); + } + _ => panic!("Expected String, got {:?}", result), + } +} + +#[test] +fn tostring_preserves_context() { + let result = eval( + r#" + let + drv = derivation { name = "test"; builder = "/bin/sh"; system = "x86_64-linux"; }; + str = builtins.toString drv; + in builtins.hasContext str + "#, + ); + assert_eq!(result, Value::Bool(true)); +} + +#[test] +fn interpolation_derivation_returns_outpath() { + let result = eval( + r#" + let + drv = derivation { name = "test"; builder = "/bin/sh"; system = "x86_64-linux"; }; + in "${drv}" + "#, + ); + match result { + Value::String(s) => { + assert!(s.starts_with("/nix/store/"), "Should be a store path"); + assert!(s.ends_with("-test"), "Should end with derivation name"); + } + _ => panic!("Expected String, got {:?}", result), + } +} + +#[test] +fn interpolation_derivation_has_context() { + let result = eval( + r#" + let + drv = derivation { name = "test"; builder = "/bin/sh"; system = "x86_64-linux"; }; + in builtins.hasContext "${drv}" + "#, + ); + assert_eq!(result, Value::Bool(true)); +} + +#[test] +fn interpolation_derivation_context_correct() { + let result = eval( + r#" + let + drv = derivation { name = "test"; builder = "/bin/sh"; system = "x86_64-linux"; }; + ctx = builtins.getContext "${drv}"; + keys = builtins.attrNames ctx; + drvPath = builtins.head keys; + in ctx.${drvPath}.outputs + "#, + ); + match result { + Value::List(list) => { + assert_eq!(list.len(), 1); + assert_eq!(list.first().unwrap(), &Value::String("out".to_string())); + } + _ => panic!("Expected List with ['out'], got {:?}", result), + } +} + +#[test] +fn interpolation_multiple_derivations() { + 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"; }; + combined = "prefix-${drv1}-middle-${drv2}-suffix"; + ctx = builtins.getContext combined; + in builtins.length (builtins.attrNames ctx) + "#, + ); + assert_eq!(result, Value::Int(2)); +} + +#[test] +fn interpolation_derivation_equals_tostring() { + let result = eval( + r#" + let + drv = derivation { name = "test"; builder = "/bin/sh"; system = "x86_64-linux"; }; + in "${drv}" == builtins.toString drv + "#, + ); + assert_eq!(result, Value::Bool(true)); +} diff --git a/nix-js/tests/to_string.rs b/nix-js/tests/to_string.rs index 456674b..c71086a 100644 --- a/nix-js/tests/to_string.rs +++ b/nix-js/tests/to_string.rs @@ -225,8 +225,14 @@ fn function_to_string_fails() { #[test] fn to_string_method_must_return_string() { - let result = utils::eval_result(r#"toString { __toString = self: 42; }"#); - assert!(result.is_err()); + assert_eq!( + utils::eval(r#"toString { __toString = self: 42; }"#), + Value::String("42".into()) + ); + assert_eq!( + utils::eval(r#"toString { __toString = self: true; }"#), + Value::String("1".into()) + ); } #[test]