fix: preserve string context
This commit is contained in:
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user