fix: preserve string context

This commit is contained in:
2026-01-24 00:21:39 +08:00
parent ef5d8c3b29
commit 33775092ee
4 changed files with 190 additions and 40 deletions

View File

@@ -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<number, string>();
const toContextCache = new Map<number, NixStringContext>();
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 => {

View File

@@ -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;
};
/**

View File

@@ -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<string, string> = {
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}`);

View File

@@ -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));
}