feat: string context

This commit is contained in:
2026-01-17 18:13:44 +08:00
parent 513b43965c
commit 52bf46407a
7 changed files with 266 additions and 22 deletions

View File

@@ -11,16 +11,33 @@ import {
parseContextToInfoMap,
} from "../string-context";
/**
* builtins.hasContext - Check if string has context
*
* Returns true if the string has any store path references.
*/
export const hasContext = (value: NixValue): boolean => {
const s = forceNixString(value);
return isStringWithContext(s) && s.context.size > 0;
};
/**
* builtins.unsafeDiscardStringContext - Remove all context from string
*
* IMPORTANT: This discards string context, returning only the string value.
* Use with caution as it removes derivation dependencies.
*/
export const unsafeDiscardStringContext = (value: NixValue): string => {
const s = forceNixString(value);
return getStringValue(s);
};
/**
* builtins.unsafeDiscardOutputDependency - Convert DrvDeep to Opaque context
*
* IMPORTANT: Transforms "all outputs" references (=) to plain path references.
* Preserves other context types unchanged.
*/
export const unsafeDiscardOutputDependency = (value: NixValue): NixString => {
const s = forceNixString(value);
const strValue = getStringValue(s);
@@ -47,6 +64,12 @@ export const unsafeDiscardOutputDependency = (value: NixValue): NixString => {
return mkStringWithContext(strValue, newContext);
};
/**
* builtins.addDrvOutputDependencies - Convert Opaque to DrvDeep context
*
* IMPORTANT: Transforms plain derivation path references to "all outputs" references (=).
* The string must have exactly one context element which must be a .drv path.
*/
export const addDrvOutputDependencies = (value: NixValue): NixString => {
const s = forceNixString(value);
const strValue = getStringValue(s);
@@ -77,6 +100,14 @@ export const addDrvOutputDependencies = (value: NixValue): NixString => {
return mkStringWithContext(strValue, newContext);
};
/**
* builtins.getContext - Extract context as structured attribute set
*
* Returns an attribute set mapping store paths to their context info:
* - path: true if it's a plain store path reference (opaque)
* - allOutputs: true if it references all derivation outputs (drvDeep, encoded as =path)
* - outputs: list of specific output names (built, encoded as !output!path)
*/
export const getContext = (value: NixValue): NixAttrs => {
const s = forceNixString(value);
const context = getStringContext(s);
@@ -101,6 +132,18 @@ export const getContext = (value: NixValue): NixAttrs => {
return result;
};
/**
* builtins.appendContext - Add context to a string
*
* IMPORTANT: Merges the provided context attribute set with any existing context
* from the input string. Used to manually construct strings with specific
* derivation dependencies.
*
* Context format matches getContext output:
* - path: boolean - add as opaque reference
* - allOutputs: boolean - add as drvDeep reference (=)
* - outputs: [string] - add as built references (!output!)
*/
export const appendContext =
(strValue: NixValue) =>
(ctxValue: NixValue): NixString => {

View File

@@ -110,6 +110,12 @@ export interface CoerceResult {
* Coerce a Nix value to a string according to the specified mode.
* This implements the same behavior as Lix's EvalState::coerceToString.
*
* IMPORTANT: String context preservation rules:
* - StringWithContext: Context is collected in outContext parameter
* - Derivations (with outPath): Built context is added for the drvPath/outputName
* - Lists (ToString mode): Context from all elements is merged
* - All other coercions: No context added
*
* @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)

View File

@@ -7,14 +7,25 @@ import { forceAttrs, forceBool, forceString } from "../type-assert";
import type { NixValue, NixAttrs } from "../types";
import { isNixPath } from "../types";
import { force } from "../thunk";
import { coerceToPath, coerceToString, StringCoercionMode } from "./conversion";
import { coerceToPath } from "./conversion";
import { getPathValue } from "../path";
// Declare Deno.core.ops global (provided by deno_core runtime)
import type { NixStringContext } from "../string-context";
export const importFunc = (path: NixValue): NixValue => {
// TODO: context?
const pathStr = coerceToString(path, StringCoercionMode.Base);
const context: NixStringContext = new Set();
const pathStr = coerceToPath(path, context);
// FIXME: Context collected but not yet propagated to build system
// This means derivation dependencies from imported paths are not
// currently tracked. This will cause issues when:
// 1. Importing from derivation outputs: import "${drv}/file.nix"
// 2. Building packages that depend on imported configurations
if (context.size > 0) {
console.warn(
`[WARN] import: Path has string context which is not yet fully tracked.
Dependency tracking for imported derivations may be incomplete.`,
);
}
// Call Rust op - returns JS code string
const code = Deno.core.ops.op_import(pathStr);

View File

@@ -2,29 +2,83 @@
* String operation builtin functions
*/
import type { NixInt, NixValue } from "../types";
import { forceString, forceList, forceInt } from "../type-assert";
import type { NixInt, NixValue, NixString } from "../types";
import { forceString, forceList, forceInt, forceNixString } from "../type-assert";
import { coerceToString, StringCoercionMode } from "./conversion";
import {
type NixStringContext,
getStringValue,
getStringContext,
mkStringWithContext,
} from "../string-context";
export const stringLength = (e: NixValue): NixInt => BigInt(forceString(e).length);
/**
* builtins.substring - Extract substring while preserving string context
*
* IMPORTANT: String context must be preserved from the source string.
* This matches Lix behavior where substring operations maintain references
* to store paths and derivations.
*
* Special case: substring 0 0 str can be used idiomatically to capture
* string context efficiently without copying the string value.
*/
export const substring =
(start: NixValue) =>
(len: NixValue) =>
(s: NixValue): string => {
const str = forceString(s);
(s: NixValue): NixString => {
const startPos = Number(forceInt(start));
const length = Number(forceInt(len));
return str.substring(startPos, startPos + length);
if (startPos < 0) {
throw new Error("negative start position in 'substring'");
}
const str = forceNixString(s);
const strValue = getStringValue(str);
const context = getStringContext(str);
if (length === 0) {
if (context.size === 0) {
return "";
}
return mkStringWithContext("", context);
}
const actualLength = length < 0 ? Number.MAX_SAFE_INTEGER : length;
const result = startPos >= strValue.length ? "" : strValue.substring(startPos, startPos + actualLength);
if (context.size === 0) {
return result;
}
return mkStringWithContext(result, context);
};
/**
* builtins.concatStringsSep - Concatenate strings with separator, merging contexts
*
* IMPORTANT: String context must be collected from both the separator and all
* list elements, then merged into the result. This ensures that store path
* references are preserved when building paths like "/nix/store/xxx/bin:/nix/store/yyy/bin".
*/
export const concatStringsSep =
(sep: NixValue) =>
(list: NixValue): string =>
// FIXME: context?
forceList(list)
.map((elem) => coerceToString(elem, StringCoercionMode.Interpolation))
.join(forceString(sep));
(list: NixValue): NixString => {
const context: NixStringContext = new Set();
const separator = coerceToString(sep, StringCoercionMode.Interpolation, false, context);
const parts = forceList(list).map((elem) =>
coerceToString(elem, StringCoercionMode.Interpolation, false, context),
);
const result = parts.join(separator);
if (context.size === 0) {
return result;
}
return mkStringWithContext(result, context);
};
export const baseNameOf = (x: NixValue): string => {
const str = forceString(x);

View File

@@ -6,11 +6,7 @@ import type { NixValue, NixAttrs, NixBool, NixString, NixPath } from "./types";
import { forceAttrs, forceBool, forceFunction, forceString, typeName } from "./type-assert";
import { isAttrs } from "./builtins/type-check";
import { coerceToString, StringCoercionMode } from "./builtins/conversion";
import {
type NixStringContext,
mkStringWithContext,
isStringWithContext,
} from "./string-context";
import { type NixStringContext, mkStringWithContext, isStringWithContext } from "./string-context";
import { force } from "./thunk";
import { mkPath } from "./path";
import { CatchableError, isNixPath } from "./types";
@@ -18,6 +14,12 @@ import { CatchableError, isNixPath } from "./types";
/**
* Concatenate multiple values into a string or path with context
* This is used for string interpolation like "hello ${world}"
*
* IMPORTANT: String context handling:
* - All contexts from interpolated values are merged into the result
* - Path mode: Store contexts are forbidden (will throw error)
* - String mode: All contexts are preserved and merged
*
* If first element is a path, result is a path (with constraint: no store context allowed)
*
* @param parts - Array of values to concatenate
@@ -230,5 +232,5 @@ export const assert = (assertion: NixValue, expr: NixValue, assertionRaw: string
if (forceBool(assertion)) {
return expr;
}
throw new CatchableError(`assertion '${assertionRaw}' failed`)
}
throw new CatchableError(`assertion '${assertionRaw}' failed`);
};

View File

@@ -1,3 +1,28 @@
/**
* String Context System for Nix
*
* String context tracks references to store paths and derivations within strings.
* This is critical for Nix's dependency tracking - when a string containing a
* store path is used in a derivation, that store path becomes a build dependency.
*
* Context Elements (encoded as strings):
* - Opaque: Plain store path reference
* Format: "/nix/store/..."
* Example: "/nix/store/abc123-hello"
*
* - DrvDeep: Derivation with all outputs
* Format: "=/nix/store/...drv"
* Example: "=/nix/store/xyz789-hello.drv"
* Meaning: All outputs of this derivation and its closure
*
* - Built: Specific derivation output
* Format: "!<output>!/nix/store/...drv"
* Example: "!out!/nix/store/xyz789-hello.drv"
* Meaning: Specific output (e.g., "out", "dev", "lib") of this derivation
*
* This implementation matches Lix's NixStringContext system.
*/
export const HAS_CONTEXT = Symbol("HAS_CONTEXT");
export interface StringContextOpaque {
@@ -143,6 +168,20 @@ export const parseContextToInfoMap = (context: NixStringContext): Map<string, Pa
return result;
};
/**
* Extract input derivations and source paths from context
*
* IMPORTANT: Used by derivation builder to determine build dependencies.
*
* Returns:
* - inputDrvs: Map of derivation paths to their required output names
* - inputSrcs: Set of plain store paths (opaque) and drvDeep references
*
* Context type handling:
* - Opaque: Added to inputSrcs
* - DrvDeep: Added to inputSrcs (entire derivation + all outputs)
* - Built: Added to inputDrvs with specific output name
*/
export const extractInputDrvsAndSrcs = (
context: NixStringContext,
): { inputDrvs: Map<string, Set<string>>; inputSrcs: Set<string> } => {

View File

@@ -288,3 +288,92 @@ fn interpolation_derivation_equals_tostring() {
);
assert_eq!(result, Value::Bool(true));
}
#[test]
fn substring_preserves_context() {
let result = eval(
r#"
let
drv = derivation { name = "test"; builder = "/bin/sh"; system = "x86_64-linux"; };
str = builtins.toString drv;
sub = builtins.substring 0 10 str;
in builtins.hasContext sub
"#,
);
assert_eq!(result, Value::Bool(true));
}
#[test]
fn substring_zero_length_preserves_context() {
let result = eval(
r#"
let
drv = derivation { name = "test"; builder = "/bin/sh"; system = "x86_64-linux"; };
str = builtins.toString drv;
empty = builtins.substring 0 0 str;
in builtins.hasContext empty
"#,
);
assert_eq!(result, Value::Bool(true));
}
#[test]
fn substring_zero_length_empty_value() {
let result = eval(
r#"
let
drv = derivation { name = "test"; builder = "/bin/sh"; system = "x86_64-linux"; };
str = builtins.toString drv;
empty = builtins.substring 0 0 str;
in empty == ""
"#,
);
assert_eq!(result, Value::Bool(true));
}
#[test]
fn concatStringsSep_preserves_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 = builtins.concatStringsSep ":" [str1 str2];
in builtins.hasContext combined
"#,
);
assert_eq!(result, Value::Bool(true));
}
#[test]
fn concatStringsSep_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"; };
str1 = builtins.toString drv1;
str2 = builtins.toString drv2;
combined = builtins.concatStringsSep ":" [str1 str2];
ctx = builtins.getContext combined;
in builtins.length (builtins.attrNames ctx)
"#,
);
assert_eq!(result, Value::Int(2));
}
#[test]
fn concatStringsSep_separator_has_context() {
let result = eval(
r#"
let
drv = derivation { name = "test"; builder = "/bin/sh"; system = "x86_64-linux"; };
sep = builtins.toString drv;
combined = builtins.concatStringsSep sep ["a" "b"];
in builtins.hasContext combined
"#,
);
assert_eq!(result, Value::Bool(true));
}