fix: preserve string context
This commit is contained in:
@@ -5,11 +5,18 @@
|
|||||||
import { force } from "../thunk";
|
import { force } from "../thunk";
|
||||||
import { CatchableError } from "../types";
|
import { CatchableError } from "../types";
|
||||||
import type { NixAttrs, NixBool, NixStrictValue, NixValue } 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 * as context from "./context";
|
||||||
import { compareValues, op } from "../operators";
|
import { compareValues, op } from "../operators";
|
||||||
import { isBool, isFloat, isInt, isList, isString, typeOf } from "./type-check";
|
import { isBool, isFloat, isInt, isList, isString, typeOf } from "./type-check";
|
||||||
import { OrderedSet } from "js-sdsl";
|
import { OrderedSet } from "js-sdsl";
|
||||||
|
import {
|
||||||
|
type NixStringContext,
|
||||||
|
getStringValue,
|
||||||
|
getStringContext,
|
||||||
|
mkStringWithContext,
|
||||||
|
mergeContexts,
|
||||||
|
} from "../string-context";
|
||||||
|
|
||||||
export const addErrorContext =
|
export const addErrorContext =
|
||||||
(e1: NixValue) =>
|
(e1: NixValue) =>
|
||||||
@@ -247,36 +254,46 @@ export const replaceStrings =
|
|||||||
(s: NixValue): NixValue => {
|
(s: NixValue): NixValue => {
|
||||||
const fromList = forceList(from);
|
const fromList = forceList(from);
|
||||||
const toList = forceList(to);
|
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) {
|
if (fromList.length !== toList.length) {
|
||||||
throw new Error("'from' and 'to' arguments passed to builtins.replaceStrings have different lengths");
|
throw new Error("'from' and 'to' arguments passed to builtins.replaceStrings have different lengths");
|
||||||
}
|
}
|
||||||
|
|
||||||
const toCache = new Map<number, string>();
|
const toCache = new Map<number, string>();
|
||||||
|
const toContextCache = new Map<number, NixStringContext>();
|
||||||
|
|
||||||
let result = "";
|
let result = "";
|
||||||
let pos = 0;
|
let pos = 0;
|
||||||
|
|
||||||
while (pos <= inputStr.length) {
|
while (pos <= inputStrValue.length) {
|
||||||
let found = false;
|
let found = false;
|
||||||
|
|
||||||
for (let i = 0; i < fromList.length; i++) {
|
for (let i = 0; i < fromList.length; i++) {
|
||||||
const pattern = forceStringValue(fromList[i]);
|
const pattern = forceStringValue(fromList[i]);
|
||||||
|
|
||||||
if (inputStr.substring(pos).startsWith(pattern)) {
|
if (inputStrValue.substring(pos).startsWith(pattern)) {
|
||||||
found = true;
|
found = true;
|
||||||
|
|
||||||
if (!toCache.has(i)) {
|
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)!;
|
const replacement = toCache.get(i)!;
|
||||||
|
|
||||||
result += replacement;
|
result += replacement;
|
||||||
|
|
||||||
if (pattern.length === 0) {
|
if (pattern.length === 0) {
|
||||||
if (pos < inputStr.length) {
|
if (pos < inputStrValue.length) {
|
||||||
result += inputStr[pos];
|
result += inputStrValue[pos];
|
||||||
}
|
}
|
||||||
pos++;
|
pos++;
|
||||||
} else {
|
} else {
|
||||||
@@ -287,14 +304,17 @@ export const replaceStrings =
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!found) {
|
if (!found) {
|
||||||
if (pos < inputStr.length) {
|
if (pos < inputStrValue.length) {
|
||||||
result += inputStr[pos];
|
result += inputStrValue[pos];
|
||||||
}
|
}
|
||||||
pos++;
|
pos++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resultContext.size === 0) {
|
||||||
return result;
|
return result;
|
||||||
|
}
|
||||||
|
return mkStringWithContext(result, resultContext);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const splitVersion = (s: NixValue): NixValue => {
|
export const splitVersion = (s: NixValue): NixValue => {
|
||||||
|
|||||||
@@ -13,28 +13,82 @@ import { mkStringWithContext, type NixStringContext } from "../string-context";
|
|||||||
* builtins.baseNameOf
|
* builtins.baseNameOf
|
||||||
* Get the last component of a path or string
|
* Get the last component of a path or string
|
||||||
* Always returns a string (coerces paths)
|
* 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:
|
* Examples:
|
||||||
* - baseNameOf ./foo/bar → "bar"
|
* - baseNameOf ./foo/bar → "bar"
|
||||||
* - baseNameOf "/foo/bar/" → "bar"
|
* - baseNameOf "/foo/bar/" → "bar" (trailing slash removed first)
|
||||||
* - baseNameOf "foo" → "foo"
|
* - baseNameOf "foo" → "foo"
|
||||||
*/
|
*/
|
||||||
export const baseNameOf = (s: NixValue): string => {
|
export const baseNameOf = (s: NixValue): NixString => {
|
||||||
const forced = force(s);
|
const forced = force(s);
|
||||||
|
|
||||||
let pathStr: string;
|
// Path input → string output (no context)
|
||||||
if (isNixPath(forced)) {
|
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 {
|
} else {
|
||||||
pathStr = coerceToString(s, StringCoercionMode.Base, false) as string;
|
pos += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastSlash = pathStr.lastIndexOf("/");
|
return pathStr.substring(pos, last + 1);
|
||||||
if (lastSlash === -1) {
|
|
||||||
return pathStr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return pathStr.slice(lastSlash + 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 {
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = pathStr.substring(pos, last + 1);
|
||||||
|
|
||||||
|
// Preserve string context if present
|
||||||
|
if (context.size > 0) {
|
||||||
|
return mkStringWithContext(result, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -80,23 +80,6 @@ export const concatStringsSep =
|
|||||||
return mkStringWithContext(result, context);
|
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> = {
|
const POSIX_CLASSES: Record<string, string> = {
|
||||||
alnum: "a-zA-Z0-9",
|
alnum: "a-zA-Z0-9",
|
||||||
alpha: "a-zA-Z",
|
alpha: "a-zA-Z",
|
||||||
@@ -178,7 +161,8 @@ export const split =
|
|||||||
(regex: NixValue) =>
|
(regex: NixValue) =>
|
||||||
(str: NixValue): NixValue => {
|
(str: NixValue): NixValue => {
|
||||||
const regexStr = forceStringValue(regex);
|
const regexStr = forceStringValue(regex);
|
||||||
const inputStr = forceStringValue(str);
|
const inputStr = forceString(str);
|
||||||
|
const inputStrValue = getStringValue(inputStr);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const re = posixToJsRegex(regexStr);
|
const re = posixToJsRegex(regexStr);
|
||||||
@@ -188,8 +172,8 @@ export const split =
|
|||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
while ((match = reGlobal.exec(inputStr)) !== null) {
|
while ((match = reGlobal.exec(inputStrValue)) !== null) {
|
||||||
result.push(inputStr.substring(lastIndex, match.index));
|
result.push(inputStrValue.substring(lastIndex, match.index));
|
||||||
|
|
||||||
const groups: NixValue[] = [];
|
const groups: NixValue[] = [];
|
||||||
for (let i = 1; i < match.length; i++) {
|
for (let i = 1; i < match.length; i++) {
|
||||||
@@ -208,7 +192,7 @@ export const split =
|
|||||||
return [inputStr];
|
return [inputStr];
|
||||||
}
|
}
|
||||||
|
|
||||||
result.push(inputStr.substring(lastIndex));
|
result.push(inputStrValue.substring(lastIndex));
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`Invalid regular expression '${regexStr}': ${e}`);
|
throw new Error(`Invalid regular expression '${regexStr}': ${e}`);
|
||||||
|
|||||||
@@ -397,3 +397,95 @@ fn concatStringsSep_separator_has_context() {
|
|||||||
);
|
);
|
||||||
assert_eq!(result, Value::Bool(true));
|
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