From 33775092eef390ebb0308bd6041cc9a268038cd9 Mon Sep 17 00:00:00 2001 From: imxyy_soope_ Date: Sat, 24 Jan 2026 00:21:39 +0800 Subject: [PATCH] fix: preserve string context --- nix-js/runtime-ts/src/builtins/misc.ts | 40 ++++++++--- nix-js/runtime-ts/src/builtins/path.ts | 72 ++++++++++++++++--- nix-js/runtime-ts/src/builtins/string.ts | 26 ++----- nix-js/tests/string_context.rs | 92 ++++++++++++++++++++++++ 4 files changed, 190 insertions(+), 40 deletions(-) diff --git a/nix-js/runtime-ts/src/builtins/misc.ts b/nix-js/runtime-ts/src/builtins/misc.ts index 4ba6fac..2e92601 100644 --- a/nix-js/runtime-ts/src/builtins/misc.ts +++ b/nix-js/runtime-ts/src/builtins/misc.ts @@ -5,11 +5,18 @@ import { force } from "../thunk"; import { CatchableError } from "../types"; import type { NixAttrs, NixBool, NixStrictValue, NixValue } from "../types"; -import { forceList, forceAttrs, forceFunction, forceStringValue } from "../type-assert"; +import { forceList, forceAttrs, forceFunction, forceStringValue, forceString } from "../type-assert"; import * as context from "./context"; import { compareValues, op } from "../operators"; import { isBool, isFloat, isInt, isList, isString, typeOf } from "./type-check"; import { OrderedSet } from "js-sdsl"; +import { + type NixStringContext, + getStringValue, + getStringContext, + mkStringWithContext, + mergeContexts, +} from "../string-context"; export const addErrorContext = (e1: NixValue) => @@ -247,36 +254,46 @@ export const replaceStrings = (s: NixValue): NixValue => { const fromList = forceList(from); const toList = forceList(to); - const inputStr = forceStringValue(s); + const inputStr = forceString(s); + const inputStrValue = getStringValue(inputStr); + const resultContext: NixStringContext = getStringContext(inputStr); if (fromList.length !== toList.length) { throw new Error("'from' and 'to' arguments passed to builtins.replaceStrings have different lengths"); } const toCache = new Map(); + const toContextCache = new Map(); let result = ""; let pos = 0; - while (pos <= inputStr.length) { + while (pos <= inputStrValue.length) { let found = false; for (let i = 0; i < fromList.length; i++) { const pattern = forceStringValue(fromList[i]); - if (inputStr.substring(pos).startsWith(pattern)) { + if (inputStrValue.substring(pos).startsWith(pattern)) { found = true; if (!toCache.has(i)) { - toCache.set(i, forceStringValue(toList[i])); + const replacementStr = forceString(toList[i]); + const replacementValue = getStringValue(replacementStr); + const replacementContext = getStringContext(replacementStr); + toCache.set(i, replacementValue); + toContextCache.set(i, replacementContext); + for (const elem of replacementContext) { + resultContext.add(elem); + } } const replacement = toCache.get(i)!; result += replacement; if (pattern.length === 0) { - if (pos < inputStr.length) { - result += inputStr[pos]; + if (pos < inputStrValue.length) { + result += inputStrValue[pos]; } pos++; } else { @@ -287,14 +304,17 @@ export const replaceStrings = } if (!found) { - if (pos < inputStr.length) { - result += inputStr[pos]; + if (pos < inputStrValue.length) { + result += inputStrValue[pos]; } pos++; } } - return result; + if (resultContext.size === 0) { + return result; + } + return mkStringWithContext(result, resultContext); }; export const splitVersion = (s: NixValue): NixValue => { diff --git a/nix-js/runtime-ts/src/builtins/path.ts b/nix-js/runtime-ts/src/builtins/path.ts index d137903..965601d 100644 --- a/nix-js/runtime-ts/src/builtins/path.ts +++ b/nix-js/runtime-ts/src/builtins/path.ts @@ -13,28 +13,82 @@ import { mkStringWithContext, type NixStringContext } from "../string-context"; * builtins.baseNameOf * Get the last component of a path or string * Always returns a string (coerces paths) + * Preserves string context if present + * + * Implements Nix's legacyBaseNameOf logic: + * - If string ends with '/', removes only the final slash + * - Then returns everything after the last remaining '/' * * Examples: * - baseNameOf ./foo/bar → "bar" - * - baseNameOf "/foo/bar/" → "bar" + * - baseNameOf "/foo/bar/" → "bar" (trailing slash removed first) * - baseNameOf "foo" → "foo" */ -export const baseNameOf = (s: NixValue): string => { +export const baseNameOf = (s: NixValue): NixString => { const forced = force(s); - let pathStr: string; + // Path input → string output (no context) if (isNixPath(forced)) { - pathStr = forced.value; + const pathStr = forced.value; + + if (pathStr.length === 0) { + return ""; + } + + let last = pathStr.length - 1; + if (pathStr[last] === "/" && last > 0) { + last -= 1; + } + + let pos = last; + while (pos >= 0 && pathStr[pos] !== "/") { + pos -= 1; + } + + if (pos === -1) { + pos = 0; + } else { + pos += 1; + } + + return pathStr.substring(pos, last + 1); + } + + // String input → string output (preserve context) + const context: NixStringContext = new Set(); + const pathStr = coerceToString(s, StringCoercionMode.Base, false, context); + + if (pathStr.length === 0) { + if (context.size === 0) { + return ""; + } + return mkStringWithContext("", context); + } + + let last = pathStr.length - 1; + if (pathStr[last] === "/" && last > 0) { + last -= 1; + } + + let pos = last; + while (pos >= 0 && pathStr[pos] !== "/") { + pos -= 1; + } + + if (pos === -1) { + pos = 0; } else { - pathStr = coerceToString(s, StringCoercionMode.Base, false) as string; + pos += 1; } - const lastSlash = pathStr.lastIndexOf("/"); - if (lastSlash === -1) { - return pathStr; + const result = pathStr.substring(pos, last + 1); + + // Preserve string context if present + if (context.size > 0) { + return mkStringWithContext(result, context); } - return pathStr.slice(lastSlash + 1); + return result; }; /** diff --git a/nix-js/runtime-ts/src/builtins/string.ts b/nix-js/runtime-ts/src/builtins/string.ts index 96e0000..af32e51 100644 --- a/nix-js/runtime-ts/src/builtins/string.ts +++ b/nix-js/runtime-ts/src/builtins/string.ts @@ -80,23 +80,6 @@ export const concatStringsSep = return mkStringWithContext(result, context); }; -export const baseNameOf = (x: NixValue): string => { - const str = forceStringValue(x); - if (str.length === 0) return ""; - - let last = str.length - 1; - if (str[last] === "/" && last > 0) last -= 1; - - let pos = last; - while (pos >= 0 && str[pos] !== "/") pos -= 1; - - if (pos !== 0 || (pos === 0 && str[pos] === "/")) { - pos += 1; - } - - return str.substring(pos, last + 1); -}; - const POSIX_CLASSES: Record = { alnum: "a-zA-Z0-9", alpha: "a-zA-Z", @@ -178,7 +161,8 @@ export const split = (regex: NixValue) => (str: NixValue): NixValue => { const regexStr = forceStringValue(regex); - const inputStr = forceStringValue(str); + const inputStr = forceString(str); + const inputStrValue = getStringValue(inputStr); try { const re = posixToJsRegex(regexStr); @@ -188,8 +172,8 @@ export const split = let lastIndex = 0; let match: RegExpExecArray | null; - while ((match = reGlobal.exec(inputStr)) !== null) { - result.push(inputStr.substring(lastIndex, match.index)); + while ((match = reGlobal.exec(inputStrValue)) !== null) { + result.push(inputStrValue.substring(lastIndex, match.index)); const groups: NixValue[] = []; for (let i = 1; i < match.length; i++) { @@ -208,7 +192,7 @@ export const split = return [inputStr]; } - result.push(inputStr.substring(lastIndex)); + result.push(inputStrValue.substring(lastIndex)); return result; } catch (e) { throw new Error(`Invalid regular expression '${regexStr}': ${e}`); diff --git a/nix-js/tests/string_context.rs b/nix-js/tests/string_context.rs index 3ae7131..3730958 100644 --- a/nix-js/tests/string_context.rs +++ b/nix-js/tests/string_context.rs @@ -397,3 +397,95 @@ fn concatStringsSep_separator_has_context() { ); assert_eq!(result, Value::Bool(true)); } + +#[test] +#[allow(non_snake_case)] +fn replaceStrings_input_context_preserved() { + let result = eval( + r#" + let + drv = derivation { name = "test"; builder = "/bin/sh"; system = "x86_64-linux"; }; + str = builtins.toString drv; + replaced = builtins.replaceStrings ["x"] ["y"] str; + in builtins.hasContext replaced + "#, + ); + assert_eq!(result, Value::Bool(true)); +} + +#[test] +#[allow(non_snake_case)] +fn replaceStrings_replacement_context_collected() { + let result = eval( + r#" + let + drv = derivation { name = "test"; builder = "/bin/sh"; system = "x86_64-linux"; }; + replacement = builtins.toString drv; + replaced = builtins.replaceStrings ["foo"] [replacement] "hello foo world"; + in builtins.hasContext replaced + "#, + ); + assert_eq!(result, Value::Bool(true)); +} + +#[test] +#[allow(non_snake_case)] +fn replaceStrings_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"; }; + str = builtins.toString drv1; + replacement = builtins.toString drv2; + replaced = builtins.replaceStrings ["x"] [replacement] str; + ctx = builtins.getContext replaced; + in builtins.length (builtins.attrNames ctx) + "#, + ); + assert_eq!(result, Value::Int(2)); +} + +#[test] +#[allow(non_snake_case)] +fn replaceStrings_lazy_evaluation_context() { + let result = eval( + r#" + let + drv = derivation { name = "test"; builder = "/bin/sh"; system = "x86_64-linux"; }; + replacement = builtins.toString drv; + replaced = builtins.replaceStrings ["a" "b"] [replacement "unused"] "hello"; + in builtins.hasContext replaced + "#, + ); + assert_eq!(result, Value::Bool(false)); +} + +#[test] +#[allow(non_snake_case)] +fn baseNameOf_preserves_context() { + let result = eval( + r#" + let + drv = derivation { name = "test"; builder = "/bin/sh"; system = "x86_64-linux"; }; + str = builtins.toString drv; + base = builtins.baseNameOf str; + in builtins.hasContext base + "#, + ); + assert_eq!(result, Value::Bool(true)); +} + +#[test] +fn split_no_match_preserves_context() { + let result = eval( + r#" + let + drv = derivation { name = "test"; builder = "/bin/sh"; system = "x86_64-linux"; }; + str = builtins.toString drv; + result = builtins.split "xyz" str; + in builtins.hasContext (builtins.head result) + "#, + ); + assert_eq!(result, Value::Bool(true)); +}