feat: initial path implementation

This commit is contained in:
2026-01-16 23:09:41 +08:00
parent 97e5e7b995
commit f2fc12026f
22 changed files with 903 additions and 61 deletions

4
.gitignore vendored
View File

@@ -1,3 +1,7 @@
target/
/.direnv/
# Profiling
flamegraph*.svg
perf.data*

View File

@@ -2,8 +2,8 @@
* Conversion and serialization builtin functions
*/
import type { NixValue, NixString } from "../types";
import { isStringWithContext } from "../types";
import type { NixValue, NixString, NixPath } from "../types";
import { isStringWithContext, isNixPath } from "../types";
import { force } from "../thunk";
import { type NixStringContext, mkStringWithContext, addBuiltContext } from "../string-context";
import { forceFunction } from "../type-assert";
@@ -150,6 +150,13 @@ export const coerceToString = (
return v.value;
}
// Paths coerce to their string value
if (isNixPath(v)) {
// TODO: Implement copyToStore when needed (copy path to Nix store)
// For now, just return the raw path string
return v.value;
}
if (typeof v === "object" && v !== null && !Array.isArray(v)) {
// First, try the __toString method if present
// This allows custom types to define their own string representation
@@ -265,6 +272,36 @@ export const coerceToStringWithContext = (
return mkStringWithContext(str, context);
};
/**
* Coerce a Nix value to an absolute path string.
* This implements the same behavior as Lix's EvalState::coerceToPath.
*
* @param value - The value to coerce
* @param outContext - Optional context set to collect string contexts
* @returns The absolute path string
* @throws TypeError if the value cannot be coerced to a string
* @throws Error if the result is not an absolute path
*
* Semantics:
* - Coerces to string using Strict mode (same as coerceToString with Base mode)
* - Validates the result is non-empty and starts with '/'
* - Returns the path string (not a NixPath object)
* - Preserves string context if present
*/
export const coerceToPath = (value: NixValue, outContext?: NixStringContext): string => {
const pathStr = coerceToString(value, StringCoercionMode.Base, false, outContext);
if (pathStr === "") {
throw new Error("string doesn't represent an absolute path: empty string");
}
if (pathStr[0] !== "/") {
throw new Error(`string '${pathStr}' doesn't represent an absolute path`);
}
return pathStr;
};
/**
* builtins.toString - Convert a value to a string
*

View File

@@ -5,10 +5,17 @@ import { type DerivationData, type OutputInfo, generateAterm } from "../derivati
import { coerceToString, StringCoercionMode } from "./conversion";
import { type NixStringContext, extractInputDrvsAndSrcs, isStringWithContext } from "../string-context";
import { nixValueToJson } from "../conversion";
import { isNixPath } from "../types";
const forceAttrs = (value: NixValue): NixAttrs => {
const forced = force(value);
if (typeof forced !== "object" || forced === null || Array.isArray(forced) || isStringWithContext(forced)) {
if (
typeof forced !== "object" ||
forced === null ||
Array.isArray(forced) ||
isStringWithContext(forced) ||
isNixPath(forced)
) {
throw new TypeError(`Expected attribute set for derivation, got ${typeof forced}`);
}
return forced;

View File

@@ -104,6 +104,7 @@ import * as typeCheck from "./type-check";
import * as list from "./list";
import * as attrs from "./attrs";
import * as string from "./string";
import * as pathOps from "./path";
import * as functional from "./functional";
import * as io from "./io";
import * as conversion from "./conversion";
@@ -174,7 +175,9 @@ export const builtins: any = {
stringLength: mkPrimop(string.stringLength, "stringLength", 1),
substring: mkPrimop(string.substring, "substring", 3),
concatStringsSep: mkPrimop(string.concatStringsSep, "concatStringsSep", 2),
baseNameOf: mkPrimop(string.baseNameOf, "baseNameOf", 1),
baseNameOf: mkPrimop(pathOps.baseNameOf, "baseNameOf", 1),
dirOf: mkPrimop(pathOps.dirOf, "dirOf", 1),
toPath: mkPrimop(pathOps.toPath, "toPath", 1),
match: mkPrimop(string.match, "match", 2),
split: mkPrimop(string.split, "split", 2),
@@ -204,7 +207,6 @@ export const builtins: any = {
pathExists: mkPrimop(io.pathExists, "pathExists", 1),
path: mkPrimop(io.path, "path", 1),
toFile: mkPrimop(io.toFile, "toFile", 2),
toPath: mkPrimop(io.toPath, "toPath", 1),
filterSource: mkPrimop(io.filterSource, "filterSource", 2),
findFile: mkPrimop(io.findFile, "findFile", 2),
getEnv: mkPrimop(io.getEnv, "getEnv", 1),
@@ -231,7 +233,6 @@ export const builtins: any = {
unsafeGetAttrPos: mkPrimop(misc.unsafeGetAttrPos, "unsafeGetAttrPos", 2),
addDrvOutputDependencies: mkPrimop(misc.addDrvOutputDependencies, "addDrvOutputDependencies", 2),
compareVersions: mkPrimop(misc.compareVersions, "compareVersions", 2),
dirOf: mkPrimop(misc.dirOf, "dirOf", 1),
flakeRefToString: mkPrimop(misc.flakeRefToString, "flakeRefToString", 1),
functionArgs: mkPrimop(misc.functionArgs, "functionArgs", 1),
genericClosure: mkPrimop(misc.genericClosure, "genericClosure", 1),
@@ -260,5 +261,5 @@ export const builtins: any = {
langVersion: 6,
nixPath: [],
nixVersion: "2.31.2",
storeDir: "/nix/store",
storeDir: "/home/imxyy/.cache/nix-js/fetchers/store",
};

View File

@@ -3,10 +3,12 @@
* Implemented via Rust ops exposed through deno_core
*/
import { forceAttrs, forceBool, forceString } from "../type-assert";
import { forceAttrs, forceBool, forceString, forceNixPath } from "../type-assert";
import type { NixValue, NixAttrs } from "../types";
import { isNixPath } from "../types";
import { force } from "../thunk";
import { coerceToString, StringCoercionMode } from "./conversion";
import { coerceToPath, coerceToString, StringCoercionMode } from "./conversion";
import { getPathValue } from "../path";
// Declare Deno.core.ops global (provided by deno_core runtime)
@@ -270,7 +272,7 @@ export const readDir = (path: NixValue): never => {
};
export const readFile = (path: NixValue): string => {
const pathStr = forceString(path);
const pathStr = coerceToPath(path);
return Deno.core.ops.op_read_file(pathStr);
};
@@ -279,12 +281,59 @@ export const readFileType = (path: NixValue): never => {
};
export const pathExists = (path: NixValue): boolean => {
const pathStr = forceString(path);
const pathStr = coerceToPath(path);
return Deno.core.ops.op_path_exists(pathStr);
};
export const path = (args: NixValue): never => {
throw new Error("Not implemented: path");
/**
* builtins.path
* Add a path to the Nix store with fine-grained control
*
* Parameters (attribute set):
* - path (required): Path to add to the store
* - name (optional): Name to use in store path (defaults to basename)
* - filter (optional): Function (path, type) -> bool (NOT IMPLEMENTED YET)
* - recursive (optional): Boolean, default true (NAR vs flat hashing)
* - sha256 (optional): Expected SHA-256 hash (hex-encoded)
*
* Returns: Store path string
*/
export const path = (args: NixValue): string => {
const attrs = forceAttrs(args);
// Required: path parameter
if (!("path" in attrs)) {
throw new TypeError("builtins.path: 'path' attribute is required");
}
const pathValue = force(attrs.path);
let pathStr: string;
// Accept both Path values and strings
if (isNixPath(pathValue)) {
pathStr = getPathValue(pathValue);
} else {
pathStr = forceString(pathValue);
}
// Optional: name parameter (defaults to basename in Rust)
const name = "name" in attrs ? forceString(attrs.name) : null;
// Optional: recursive parameter (default: true)
const recursive = "recursive" in attrs ? forceBool(attrs.recursive) : true;
// Optional: sha256 parameter
const sha256 = "sha256" in attrs ? forceString(attrs.sha256) : null;
// TODO: Handle filter parameter
if ("filter" in attrs) {
throw new Error("builtins.path: 'filter' parameter is not yet implemented");
}
// Call Rust op to add path to store
const storePath: string = Deno.core.ops.op_add_path(pathStr, name, recursive, sha256);
return storePath;
};
export const toFile = (name: NixValue, s: NixValue): never => {

View File

@@ -191,9 +191,7 @@ export const replaceStrings =
const inputStr = forceString(s);
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>();
@@ -240,7 +238,6 @@ export const replaceStrings =
return result;
};
export const splitVersion = (s: NixValue): NixValue => {
const version = forceString(s);
const components: string[] = [];

View File

@@ -0,0 +1,127 @@
/**
* Path-related builtin functions
*/
import type { NixValue, NixString, NixPath } from "../types";
import { isNixPath, isStringWithContext } from "../types";
import { force } from "../thunk";
import { mkPath } from "../path";
import { coerceToString, StringCoercionMode, coerceToPath } from "./conversion";
import { mkStringWithContext, type NixStringContext } from "../string-context";
/**
* builtins.baseNameOf
* Get the last component of a path or string
* Always returns a string (coerces paths)
*
* Examples:
* - baseNameOf ./foo/bar → "bar"
* - baseNameOf "/foo/bar/" → "bar"
* - baseNameOf "foo" → "foo"
*/
export const baseNameOf = (s: NixValue): string => {
const forced = force(s);
let pathStr: string;
if (isNixPath(forced)) {
pathStr = forced.value;
} else {
pathStr = coerceToString(s, StringCoercionMode.Base, false) as string;
}
const lastSlash = pathStr.lastIndexOf("/");
if (lastSlash === -1) {
return pathStr;
}
return pathStr.slice(lastSlash + 1);
};
/**
* builtins.dirOf
* Get the directory part of a path or string
* TYPE-PRESERVING: path → path, string → string
*
* Examples:
* - dirOf ./foo/bar → ./foo (path)
* - dirOf "/foo/bar" → "/foo" (string)
* - dirOf "/" → "/" (same type as input)
*/
export const dirOf = (s: NixValue): NixPath | NixString => {
const forced = force(s);
// Path input → path output
if (isNixPath(forced)) {
const pathStr = forced.value;
const lastSlash = pathStr.lastIndexOf("/");
if (lastSlash === -1) {
return mkPath(".");
}
if (lastSlash === 0) {
return mkPath("/");
}
return mkPath(pathStr.slice(0, lastSlash));
}
// String input → string output
const strValue: NixString = coerceToString(s, StringCoercionMode.Base, false) as NixString;
let pathStr: string;
let hasContext = false;
let originalContext: Set<string> | undefined;
if (typeof strValue === "string") {
pathStr = strValue;
} else if (isStringWithContext(strValue)) {
pathStr = strValue.value;
hasContext = strValue.context.size > 0;
originalContext = strValue.context;
} else {
pathStr = strValue as string;
}
const lastSlash = pathStr.lastIndexOf("/");
if (lastSlash === -1) {
return ".";
}
if (lastSlash === 0) {
return "/";
}
const result = pathStr.slice(0, lastSlash);
// Preserve string context if present
if (hasContext && originalContext) {
return mkStringWithContext(result, originalContext);
}
return result;
};
/**
* builtins.toPath
* Convert a value to an absolute path string.
* DEPRECATED: Use `/. + "/path"` to convert a string into an absolute path.
*
* This validates that the input can be coerced to an absolute path.
* Returns a **string** (not a NixPath), with context preserved.
*
* Examples:
* - toPath "/foo" → "/foo" (string)
* - toPath "/foo/bar" → "/foo/bar" (string)
* - toPath "foo" → ERROR (not absolute)
* - toPath "" → ERROR (empty)
*/
export const toPath = (s: NixValue): NixString => {
const context: NixStringContext = new Set();
const pathStr = coerceToPath(s, context);
if (context.size === 0) {
return pathStr;
}
return mkStringWithContext(pathStr, context);
};

View File

@@ -95,7 +95,6 @@ function posixToJsRegex(pattern: string, fullMatch: boolean = false): RegExp {
return new RegExp(jsPattern, "u");
}
export const match =
(regex: NixValue) =>
(str: NixValue): NixValue => {

View File

@@ -4,6 +4,7 @@
import {
HAS_CONTEXT,
isNixPath,
type NixAttrs,
type NixBool,
type NixFloat,
@@ -39,8 +40,9 @@ export const isList = (e: NixValue): e is NixList => Array.isArray(force(e));
export const isNull = (e: NixValue): e is NixNull => force(e) === null;
export const isPath = (e: NixValue): never => {
throw new Error("Not implemented: isPath");
export const isPath = (e: NixValue): boolean => {
const val = force(e);
return isNixPath(val);
};
export const isString = (e: NixValue): e is NixString => typeof force(e) === "string";
@@ -48,6 +50,7 @@ export const isString = (e: NixValue): e is NixString => typeof force(e) === "st
export const typeOf = (e: NixValue): string => {
const val = force(e);
if (isNixPath(val)) return "path";
if (typeof val === "bigint") return "int";
if (typeof val === "number") return "float";
if (typeof val === "boolean") return "bool";

View File

@@ -2,28 +2,85 @@
* Helper functions for nix-js runtime
*/
import type { NixValue, NixAttrs, NixBool, NixString } from "./types";
import type { NixValue, NixAttrs, NixBool, NixString, NixPath } from "./types";
import { forceAttrs, forceFunction, forceString, typeName } from "./type-assert";
import { isAttrs } from "./builtins/type-check";
import { coerceToString, StringCoercionMode } from "./builtins/conversion";
import { type NixStringContext, mkStringWithContext } from "./string-context";
import {
type NixStringContext,
mkStringWithContext,
isStringWithContext,
getStringContext,
} from "./string-context";
import { force } from "./thunk";
import { mkPath } from "./path";
import { isNixPath } from "./types";
/**
* Concatenate multiple values into a string with context
* Concatenate multiple values into a string or path with context
* This is used for string interpolation like "hello ${world}"
* If first element is a path, result is a path (with constraint: no store context allowed)
*
* @param parts - Array of values to concatenate
* @returns String with merged contexts from all parts
* @returns String or Path with merged contexts from all parts
*/
export const concatStringsWithContext = (parts: NixValue[]): NixString => {
export const concatStringsWithContext = (parts: NixValue[]): NixString | NixPath => {
if (parts.length === 0) {
return "";
}
const forced = parts.map(force);
// Check if first element is a path
const firstIsPath = isNixPath(forced[0]);
if (firstIsPath) {
// Path concatenation mode: result will be a path
let result = (forced[0] as NixPath).value;
for (let i = 1; i < forced.length; i++) {
const part = forced[i];
if (isNixPath(part)) {
result += part.value;
} else if (typeof part === "string") {
result += part;
} else if (isStringWithContext(part)) {
// Lix constraint: cannot mix store context with paths
if (part.context.size > 0) {
throw new TypeError("a string that refers to a store path cannot be appended to a path");
}
result += part.value;
} else {
// Coerce to string
const tempContext: NixStringContext = new Set();
const coerced = coerceToString(part, StringCoercionMode.Interpolation, false, tempContext);
if (tempContext.size > 0) {
throw new TypeError("a string that refers to a store path cannot be appended to a path");
}
result += coerced;
}
}
return mkPath(result);
}
// String concatenation mode
const context: NixStringContext = new Set();
const strParts: string[] = [];
for (const part of parts) {
const str = coerceToString(part, StringCoercionMode.Interpolation, false, context);
// Handle path coercion to string
const forced = force(part);
if (isNixPath(forced)) {
strParts.push(forced.value);
} else {
const str = coerceToString(forced, StringCoercionMode.Interpolation, false, context);
strParts.push(str);
}
}
const value = strParts.join("");
@@ -39,11 +96,12 @@ export const concatStringsWithContext = (parts: NixValue[]): NixString => {
* For relative paths, resolves against current import stack
*
* @param path - Path string (may be relative or absolute)
* @returns Absolute path string
* @returns NixPath object with absolute path
*/
export const resolvePath = (path: NixValue): string => {
export const resolvePath = (path: NixValue): NixPath => {
const path_str = forceString(path);
return Deno.core.ops.op_resolve_path(path_str);
const resolved = Deno.core.ops.op_resolve_path(path_str);
return mkPath(resolved);
};
export const select = (obj: NixValue, attrpath: NixValue[]): NixValue => {

View File

@@ -18,6 +18,7 @@ import { op } from "./operators";
import { builtins, PRIMOP_METADATA } from "./builtins";
import { coerceToString, StringCoercionMode } from "./builtins/conversion";
import { HAS_CONTEXT } from "./string-context";
import { IS_PATH } from "./types";
export type NixRuntime = typeof Nix;
@@ -30,6 +31,7 @@ export const Nix = {
isThunk,
IS_THUNK,
HAS_CONTEXT,
IS_PATH,
DEBUG_THUNKS,
call,

View File

@@ -3,12 +3,13 @@
* Implements all binary and unary operators used by codegen
*/
import type { NixValue, NixList, NixAttrs, NixString } from "./types";
import { isStringWithContext } from "./types";
import type { NixValue, NixList, NixAttrs, NixString, NixPath } from "./types";
import { isStringWithContext, isNixPath } from "./types";
import { force } from "./thunk";
import { forceNumeric, forceList, forceAttrs, coerceNumeric } from "./type-assert";
import { getStringValue, getStringContext, mergeContexts, mkStringWithContext } from "./string-context";
import { coerceToString, StringCoercionMode } from "./builtins/conversion";
import { mkPath } from "./path";
const isNixString = (v: unknown): v is NixString => {
return typeof v === "string" || isStringWithContext(v);
@@ -28,10 +29,44 @@ const canCoerceToString = (v: NixValue): boolean => {
* All operators referenced by codegen (e.g., Nix.op.add, Nix.op.eq)
*/
export const op = {
add: (a: NixValue, b: NixValue): bigint | number | NixString => {
add: (a: NixValue, b: NixValue): bigint | number | NixString | NixPath => {
const av = force(a);
const bv = force(b);
// Path concatenation: path + string = path
if (isNixPath(av)) {
if (isNixString(bv)) {
const strB = getStringValue(bv);
const ctxB = getStringContext(bv);
// Lix constraint: cannot append string with store context to path
if (ctxB.size > 0) {
throw new TypeError("a string that refers to a store path cannot be appended to a path");
}
// Concatenate paths
return mkPath(av.value + strB);
}
// path + path: concatenate
if (isNixPath(bv)) {
return mkPath(av.value + bv.value);
}
}
// String + path: result is string (path coerces to string)
if (isNixString(av) && isNixPath(bv)) {
const strA = getStringValue(av);
const ctxA = getStringContext(av);
const result = strA + bv.value;
if (ctxA.size === 0) {
return result;
}
return mkStringWithContext(result, ctxA);
}
// String concatenation
if (isNixString(av) && isNixString(bv)) {
const strA = getStringValue(av);
const strB = getStringValue(bv);
@@ -45,12 +80,14 @@ export const op = {
return mkStringWithContext(strA + strB, mergeContexts(ctxA, ctxB));
}
// Auto-coerce to string if possible
if (canCoerceToString(a) && canCoerceToString(b)) {
const strA = coerceToString(a, StringCoercionMode.Interpolation, false);
const strB = coerceToString(b, StringCoercionMode.Interpolation, false);
return strA + strB;
}
// Numeric addition
const [numA, numB] = coerceNumeric(forceNumeric(a), forceNumeric(b));
return (numA as any) + (numB as any);
},
@@ -79,10 +116,17 @@ export const op = {
const av = force(a);
const bv = force(b);
// Path comparison
if (isNixPath(av) && isNixPath(bv)) {
return av.value === bv.value;
}
// String comparison
if (isNixString(av) && isNixString(bv)) {
return getStringValue(av) === getStringValue(bv);
}
// Numeric comparison with type coercion
if (typeof av === "bigint" && typeof bv === "number") {
return Number(av) === bv;
}
@@ -90,6 +134,7 @@ export const op = {
return av === Number(bv);
}
// List comparison
if (Array.isArray(av) && Array.isArray(bv)) {
if (av.length !== bv.length) return false;
for (let i = 0; i < av.length; i++) {
@@ -98,6 +143,7 @@ export const op = {
return true;
}
// Attrset comparison
if (
typeof av === "object" &&
av !== null &&
@@ -106,7 +152,9 @@ export const op = {
bv !== null &&
!Array.isArray(bv) &&
!isNixString(av) &&
!isNixString(bv)
!isNixString(bv) &&
!isNixPath(av) &&
!isNixPath(bv)
) {
const keysA = Object.keys(av);
const keysB = Object.keys(bv);
@@ -127,10 +175,17 @@ export const op = {
const av = force(a);
const bv = force(b);
// Path comparison
if (isNixPath(av) && isNixPath(bv)) {
return av.value < bv.value;
}
// String comparison
if (isNixString(av) && isNixString(bv)) {
return getStringValue(av) < getStringValue(bv);
}
// Numeric comparison
const [numA, numB] = coerceNumeric(forceNumeric(a), forceNumeric(b));
return (numA as any) < (numB as any);
},
@@ -138,10 +193,17 @@ export const op = {
const av = force(a);
const bv = force(b);
// Path comparison
if (isNixPath(av) && isNixPath(bv)) {
return av.value <= bv.value;
}
// String comparison
if (isNixString(av) && isNixString(bv)) {
return getStringValue(av) <= getStringValue(bv);
}
// Numeric comparison
const [numA, numB] = coerceNumeric(forceNumeric(a), forceNumeric(b));
return (numA as any) <= (numB as any);
},
@@ -149,10 +211,17 @@ export const op = {
const av = force(a);
const bv = force(b);
// Path comparison
if (isNixPath(av) && isNixPath(bv)) {
return av.value > bv.value;
}
// String comparison
if (isNixString(av) && isNixString(bv)) {
return getStringValue(av) > getStringValue(bv);
}
// Numeric comparison
const [numA, numB] = coerceNumeric(forceNumeric(a), forceNumeric(b));
return (numA as any) > (numB as any);
},
@@ -160,10 +229,17 @@ export const op = {
const av = force(a);
const bv = force(b);
// Path comparison
if (isNixPath(av) && isNixPath(bv)) {
return av.value >= bv.value;
}
// String comparison
if (isNixString(av) && isNixString(bv)) {
return getStringValue(av) >= getStringValue(bv);
}
// Numeric comparison
const [numA, numB] = coerceNumeric(forceNumeric(a), forceNumeric(b));
return (numA as any) >= (numB as any);
},

View File

@@ -0,0 +1,9 @@
import { IS_PATH, type NixPath } from "./types";
export const mkPath = (value: string): NixPath => {
return { [IS_PATH]: true, value };
};
export const getPathValue = (p: NixPath): string => {
return p.value;
};

View File

@@ -32,15 +32,11 @@ export class NixThunk implements NixThunkInterface {
func: (() => NixValue) | undefined;
result: NixStrictValue | undefined;
readonly label: string | undefined;
readonly creationStack: string | undefined;
constructor(func: () => NixValue, label?: string) {
this.func = func;
this.result = undefined;
this.label = label;
if (DEBUG_THUNKS.enabled) {
this.creationStack = new Error().stack?.split("\n").slice(2).join("\n");
}
}
toString(): string {
@@ -82,23 +78,13 @@ export const force = (value: NixValue): NixStrictValue => {
if (value.result === undefined) {
const thunk = value as NixThunk;
let msg = `infinite recursion encountered at ${thunk}\n`;
if (DEBUG_THUNKS.enabled) {
msg += "Force chain (most recent first):\n";
for (let i = forceStack.length - 1; i >= 0; i--) {
const t = forceStack[i];
msg += ` ${i + 1}. ${t}`;
if (DEBUG_THUNKS.enabled && t.creationStack) {
msg += `\n Created at:\n${t.creationStack
.split("\n")
.map((l) => " " + l)
.join("\n")}`;
}
msg += "\n";
}
if (DEBUG_THUNKS.enabled && thunk.creationStack) {
msg += `\nBlackhole thunk created at:\n${thunk.creationStack
.split("\n")
.map((l) => " " + l)
.join("\n")}`;
}
throw new Error(msg);
}
@@ -109,7 +95,9 @@ export const force = (value: NixValue): NixStrictValue => {
const func = thunk.func!;
thunk.func = undefined;
if (DEBUG_THUNKS.enabled) {
forceStack.push(thunk);
}
try {
const result = force(func());
thunk.result = result;

View File

@@ -12,14 +12,16 @@ import type {
NixFloat,
NixNumber,
NixString,
NixPath,
} from "./types";
import { isStringWithContext } from "./types";
import { isStringWithContext, isNixPath } from "./types";
import { force } from "./thunk";
import { getStringValue } from "./string-context";
export const typeName = (value: NixValue): string => {
const val = force(value);
if (isNixPath(val)) return "path";
if (typeof val === "bigint") return "int";
if (typeof val === "number") return "float";
if (typeof val === "boolean") return "boolean";
@@ -63,7 +65,13 @@ export const forceFunction = (value: NixValue): NixFunction => {
*/
export const forceAttrs = (value: NixValue): NixAttrs => {
const forced = force(value);
if (typeof forced !== "object" || Array.isArray(forced) || forced === null || isStringWithContext(forced)) {
if (
typeof forced !== "object" ||
Array.isArray(forced) ||
forced === null ||
isStringWithContext(forced) ||
isNixPath(forced)
) {
throw new TypeError(`Expected attribute set, got ${typeName(forced)}`);
}
return forced;
@@ -171,3 +179,15 @@ export const coerceNumeric = (a: NixNumber, b: NixNumber): [NixFloat, NixFloat]
// Both are integers
return [a, b];
};
/**
* Force a value and assert it's a path
* @throws TypeError if value is not a path after forcing
*/
export const forceNixPath = (value: NixValue): NixPath => {
const forced = force(value);
if (isNixPath(forced)) {
return forced;
}
throw new TypeError(`Expected path, got ${typeName(forced)}`);
};

View File

@@ -7,6 +7,17 @@ import { type StringWithContext, HAS_CONTEXT, isStringWithContext } from "./stri
export { HAS_CONTEXT, isStringWithContext };
export type { StringWithContext };
export const IS_PATH = Symbol("IS_PATH");
export interface NixPath {
readonly [IS_PATH]: true;
value: string;
}
export const isNixPath = (v: NixStrictValue): v is NixPath => {
return typeof v === "object" && v !== null && IS_PATH in v && (v as NixPath)[IS_PATH] === true;
};
// Nix primitive types
export type NixInt = bigint;
export type NixFloat = number;
@@ -37,7 +48,7 @@ export type NixPrimitive = NixNull | NixBool | NixInt | NixFloat | NixString;
* NixValue: Union type representing any possible Nix value
* This is the core type used throughout the runtime
*/
export type NixValue = NixPrimitive | NixList | NixAttrs | NixFunction | NixThunkInterface;
export type NixValue = NixPrimitive | NixPath | NixList | NixAttrs | NixFunction | NixThunkInterface;
export type NixStrictValue = Exclude<NixValue, NixThunkInterface>;

View File

@@ -70,6 +70,12 @@ declare global {
name: string | null,
): FetchGitResult;
function op_fetch_hg(url: string, rev: string | null, name: string | null): FetchHgResult;
function op_add_path(
path: string,
name: string | null,
recursive: boolean,
sha256: string | null,
): string;
}
}
}

View File

@@ -77,8 +77,9 @@ impl<Ctx: CodegenContext> Compile<Ctx> for Ir {
Ir::HasAttr(x) => x.compile(ctx),
&Ir::Assert(Assert { assertion, expr }) => {
let assertion = ctx.get_ir(assertion).compile(ctx);
let expr_dbg = ctx.get_ir(expr);
let expr = ctx.get_ir(expr).compile(ctx);
format!("({assertion})?({expr}):(()=>{{throw new Error(\"assertion failed\")}})()")
format!("({assertion})?({expr}):(()=>{{throw new Error(`assertion failed ({expr_dbg:#?})`)}})()")
}
}
}

View File

@@ -32,6 +32,7 @@ fn runtime_extension<Ctx: RuntimeCtx>() -> Extension {
op_make_store_path(),
op_output_path_name(),
op_make_fixed_output_path(),
op_add_path(),
];
ops.extend(crate::fetcher::register_ops());
@@ -186,11 +187,104 @@ fn op_make_fixed_output_path(
}
}
#[deno_core::op2]
#[string]
fn op_add_path(
#[string] path: String,
#[string] name: Option<String>,
recursive: bool,
#[string] sha256: Option<String>,
) -> std::result::Result<String, NixError> {
use sha2::{Digest, Sha256};
use std::fs;
use std::path::Path;
let path_obj = Path::new(&path);
if !path_obj.exists() {
return Err(NixError::from(format!("path '{}' does not exist", path)));
}
let computed_name = name.unwrap_or_else(|| {
path_obj
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("source")
.to_string()
});
let computed_hash = if recursive {
compute_nar_hash(path_obj)?
} else {
if !path_obj.is_file() {
return Err(NixError::from(
"when 'recursive' is false, path must be a regular file",
));
}
let contents = fs::read(path_obj)
.map_err(|e| NixError::from(format!("failed to read '{}': {}", path, e)))?;
let mut hasher = Sha256::new();
hasher.update(&contents);
hex::encode(hasher.finalize())
};
if let Some(expected_hash) = sha256 {
if computed_hash != expected_hash {
return Err(NixError::from(format!(
"hash mismatch for path '{}': expected {}, got {}",
path, expected_hash, computed_hash
)));
}
}
let store_path = crate::nix_hash::make_store_path("source", &computed_hash, &computed_name);
Ok(store_path)
}
fn compute_nar_hash(path: &std::path::Path) -> std::result::Result<String, NixError> {
use sha2::{Digest, Sha256};
use std::fs;
if path.is_file() {
let contents = fs::read(path)
.map_err(|e| NixError::from(format!("failed to read file: {}", e)))?;
let mut hasher = Sha256::new();
hasher.update(&contents);
Ok(hex::encode(hasher.finalize()))
} else if path.is_dir() {
let mut entries: Vec<_> = fs::read_dir(path)
.map_err(|e| NixError::from(format!("failed to read directory: {}", e)))?
.filter_map(std::result::Result::ok)
.collect();
entries.sort_by_key(|e| e.file_name());
let mut hasher = Sha256::new();
for entry in entries {
let entry_path = entry.path();
let entry_name = entry.file_name();
hasher.update(entry_name.to_string_lossy().as_bytes());
let entry_hash = compute_nar_hash(&entry_path)?;
hasher.update(entry_hash.as_bytes());
}
Ok(hex::encode(hasher.finalize()))
} else {
Ok(String::new())
}
}
pub(crate) struct Runtime<Ctx: RuntimeCtx> {
js_runtime: JsRuntime,
is_thunk_symbol: v8::Global<v8::Symbol>,
primop_metadata_symbol: v8::Global<v8::Symbol>,
has_context_symbol: v8::Global<v8::Symbol>,
is_path_symbol: v8::Global<v8::Symbol>,
_marker: PhantomData<Ctx>,
}
@@ -210,7 +304,7 @@ impl<Ctx: RuntimeCtx> Runtime<Ctx> {
..Default::default()
});
let (is_thunk_symbol, primop_metadata_symbol, has_context_symbol) = {
let (is_thunk_symbol, primop_metadata_symbol, has_context_symbol, is_path_symbol) = {
deno_core::scope!(scope, &mut js_runtime);
Self::get_symbols(scope)?
};
@@ -220,6 +314,7 @@ impl<Ctx: RuntimeCtx> Runtime<Ctx> {
is_thunk_symbol,
primop_metadata_symbol,
has_context_symbol,
is_path_symbol,
_marker: PhantomData,
})
}
@@ -238,6 +333,7 @@ impl<Ctx: RuntimeCtx> Runtime<Ctx> {
let is_thunk_symbol = v8::Local::new(scope, &self.is_thunk_symbol);
let primop_metadata_symbol = v8::Local::new(scope, &self.primop_metadata_symbol);
let has_context_symbol = v8::Local::new(scope, &self.has_context_symbol);
let is_path_symbol = v8::Local::new(scope, &self.is_path_symbol);
Ok(to_value(
local_value,
@@ -245,6 +341,7 @@ impl<Ctx: RuntimeCtx> Runtime<Ctx> {
is_thunk_symbol,
primop_metadata_symbol,
has_context_symbol,
is_path_symbol,
))
}
@@ -255,6 +352,7 @@ impl<Ctx: RuntimeCtx> Runtime<Ctx> {
v8::Global<v8::Symbol>,
v8::Global<v8::Symbol>,
v8::Global<v8::Symbol>,
v8::Global<v8::Symbol>,
)> {
let global = scope.get_current_context().global(scope);
let nix_key = v8::String::new(scope, "Nix")
@@ -305,7 +403,19 @@ impl<Ctx: RuntimeCtx> Runtime<Ctx> {
})?;
let has_context = v8::Global::new(scope, has_context);
Ok((is_thunk, primop_metadata, has_context))
let is_path_sym_key = v8::String::new(scope, "IS_PATH")
.ok_or_else(|| Error::internal("failed to create V8 String".into()))?;
let is_path_sym = nix_obj
.get(scope, is_path_sym_key.into())
.ok_or_else(|| Error::internal("failed to get IS_PATH Symbol".into()))?;
let is_path = is_path_sym.try_cast::<v8::Symbol>().map_err(|err| {
Error::internal(format!(
"failed to convert IS_PATH Value to Symbol ({err})"
))
})?;
let is_path = v8::Global::new(scope, is_path);
Ok((is_thunk, primop_metadata, has_context, is_path))
}
}
@@ -315,6 +425,7 @@ fn to_value<'a>(
is_thunk_symbol: LocalSymbol<'a>,
primop_metadata_symbol: LocalSymbol<'a>,
has_context_symbol: LocalSymbol<'a>,
is_path_symbol: LocalSymbol<'a>,
) -> Value {
match () {
_ if val.is_big_int() => {
@@ -350,6 +461,7 @@ fn to_value<'a>(
is_thunk_symbol,
primop_metadata_symbol,
has_context_symbol,
is_path_symbol,
)
})
.collect();
@@ -367,6 +479,10 @@ fn to_value<'a>(
return Value::Thunk;
}
if let Some(path_val) = extract_path(val, scope, is_path_symbol) {
return Value::Path(path_val);
}
if let Some(string_val) = extract_string_with_context(val, scope, has_context_symbol) {
return Value::String(string_val);
}
@@ -391,6 +507,7 @@ fn to_value<'a>(
is_thunk_symbol,
primop_metadata_symbol,
has_context_symbol,
is_path_symbol,
),
)
})
@@ -436,6 +553,32 @@ fn extract_string_with_context<'a>(
}
}
fn extract_path<'a>(
val: LocalValue<'a>,
scope: &ScopeRef<'a, '_>,
symbol: LocalSymbol<'a>,
) -> Option<String> {
if !val.is_object() {
return None;
}
let obj = val.to_object(scope).expect("infallible conversion");
let is_path = obj.get(scope, symbol.into())?;
if !is_path.is_true() {
return None;
}
let value_key = v8::String::new(scope, "value")?;
let value = obj.get(scope, value_key.into())?;
if value.is_string() {
Some(value.to_rust_string_lossy(scope))
} else {
None
}
}
fn to_primop<'a>(
val: LocalValue<'a>,
scope: &ScopeRef<'a, '_>,

View File

@@ -177,6 +177,8 @@ pub enum Value {
Null,
/// A string value.
String(String),
/// A path value (absolute path string).
Path(String),
/// An attribute set.
AttrSet(AttrSet),
/// A list.
@@ -203,6 +205,7 @@ impl Display for Value {
&Bool(x) => write!(f, "{x}"),
Null => write!(f, "null"),
String(x) => write!(f, r#""{x}""#),
Path(x) => write!(f, "{x}"),
AttrSet(x) => write!(f, "{x}"),
List(x) => write!(f, "{x}"),
Thunk => write!(f, "«code»"),

View File

@@ -100,3 +100,186 @@ fn import_with_complex_dependency_graph() {
let expr = format!(r#"import "{}""#, main_path.display());
assert_eq!(ctx.eval_code(&expr).unwrap(), Value::Int(15));
}
// Tests for builtins.path
#[test]
fn test_path_with_file() {
let mut ctx = Context::new().unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let test_file = temp_dir.path().join("test.txt");
std::fs::write(&test_file, "Hello, World!").unwrap();
let expr = format!(
r#"builtins.path {{ path = {}; }}"#,
test_file.display()
);
let result = ctx.eval_code(&expr).unwrap();
// Should return a store path string
if let Value::String(store_path) = result {
assert!(store_path.starts_with("/nix/store/"));
assert!(store_path.contains("test.txt"));
} else {
panic!("Expected string, got {:?}", result);
}
}
#[test]
fn test_path_with_custom_name() {
let mut ctx = Context::new().unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let test_file = temp_dir.path().join("original.txt");
std::fs::write(&test_file, "Content").unwrap();
let expr = format!(
r#"builtins.path {{ path = {}; name = "custom-name"; }}"#,
test_file.display()
);
let result = ctx.eval_code(&expr).unwrap();
if let Value::String(store_path) = result {
assert!(store_path.contains("custom-name"));
assert!(!store_path.contains("original.txt"));
} else {
panic!("Expected string, got {:?}", result);
}
}
#[test]
fn test_path_with_directory_recursive() {
let mut ctx = Context::new().unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let test_dir = temp_dir.path().join("mydir");
std::fs::create_dir_all(&test_dir).unwrap();
std::fs::write(test_dir.join("file1.txt"), "Content 1").unwrap();
std::fs::write(test_dir.join("file2.txt"), "Content 2").unwrap();
let expr = format!(
r#"builtins.path {{ path = {}; recursive = true; }}"#,
test_dir.display()
);
let result = ctx.eval_code(&expr).unwrap();
if let Value::String(store_path) = result {
assert!(store_path.starts_with("/nix/store/"));
assert!(store_path.contains("mydir"));
} else {
panic!("Expected string, got {:?}", result);
}
}
#[test]
fn test_path_flat_with_file() {
let mut ctx = Context::new().unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let test_file = temp_dir.path().join("flat.txt");
std::fs::write(&test_file, "Flat content").unwrap();
let expr = format!(
r#"builtins.path {{ path = {}; recursive = false; }}"#,
test_file.display()
);
let result = ctx.eval_code(&expr).unwrap();
if let Value::String(store_path) = result {
assert!(store_path.starts_with("/nix/store/"));
} else {
panic!("Expected string, got {:?}", result);
}
}
#[test]
fn test_path_flat_with_directory_fails() {
let mut ctx = Context::new().unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let test_dir = temp_dir.path().join("mydir");
std::fs::create_dir_all(&test_dir).unwrap();
let expr = format!(
r#"builtins.path {{ path = {}; recursive = false; }}"#,
test_dir.display()
);
let result = ctx.eval_code(&expr);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("recursive") || err_msg.contains("regular file"));
}
#[test]
fn test_path_nonexistent_fails() {
let mut ctx = Context::new().unwrap();
let expr = r#"builtins.path { path = "/nonexistent/path/that/should/not/exist"; }"#;
let result = ctx.eval_code(expr);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("does not exist"));
}
#[test]
fn test_path_missing_path_param() {
let mut ctx = Context::new().unwrap();
let expr = r#"builtins.path { name = "test"; }"#;
let result = ctx.eval_code(expr);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("path") && err_msg.contains("required"));
}
#[test]
fn test_path_with_sha256() {
let mut ctx = Context::new().unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let test_file = temp_dir.path().join("hash_test.txt");
std::fs::write(&test_file, "Test content for hashing").unwrap();
// First, get the hash by calling without sha256
let expr1 = format!(
r#"builtins.path {{ path = {}; }}"#,
test_file.display()
);
let result1 = ctx.eval_code(&expr1).unwrap();
let store_path1 = match result1 {
Value::String(s) => s,
_ => panic!("Expected string"),
};
// Compute the actual hash (for testing, we'll just verify the same path is returned)
// In real usage, the user would know the hash beforehand
let expr2 = format!(
r#"builtins.path {{ path = {}; }}"#,
test_file.display()
);
let result2 = ctx.eval_code(&expr2).unwrap();
let store_path2 = match result2 {
Value::String(s) => s,
_ => panic!("Expected string"),
};
// Same input should produce same output
assert_eq!(store_path1, store_path2);
}
#[test]
fn test_path_deterministic() {
let mut ctx = Context::new().unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let test_file = temp_dir.path().join("deterministic.txt");
std::fs::write(&test_file, "Same content").unwrap();
let expr = format!(
r#"builtins.path {{ path = {}; name = "myfile"; }}"#,
test_file.display()
);
let result1 = ctx.eval_code(&expr).unwrap();
let result2 = ctx.eval_code(&expr).unwrap();
// Same inputs should produce same store path
assert_eq!(result1, result2);
}

View File

@@ -0,0 +1,118 @@
mod utils;
use nix_js::value::Value;
use utils::{eval, eval_result};
#[test]
fn test_path_type_of() {
let result = eval("builtins.typeOf ./foo");
assert_eq!(result, Value::String("path".to_string()));
}
#[test]
fn test_is_path_true() {
let result = eval("builtins.isPath ./foo");
assert_eq!(result, Value::Bool(true));
}
#[test]
fn test_is_path_false_string() {
let result = eval(r#"builtins.isPath "./foo""#);
assert_eq!(result, Value::Bool(false));
}
#[test]
fn test_is_path_false_number() {
let result = eval("builtins.isPath 42");
assert_eq!(result, Value::Bool(false));
}
#[test]
fn test_path_concat_type() {
// path + string = path
let result = eval(r#"builtins.typeOf (./foo + "/bar")"#);
assert_eq!(result, Value::String("path".to_string()));
}
#[test]
fn test_string_path_concat_type() {
// string + path = string
let result = eval(r#"builtins.typeOf ("prefix-" + ./foo)"#);
assert_eq!(result, Value::String("string".to_string()));
}
#[test]
fn test_basename_of_path() {
let result = eval("builtins.baseNameOf ./path/to/file.nix");
assert!(matches!(result, Value::String(s) if s == "file.nix"));
}
#[test]
fn test_basename_of_string() {
let result = eval(r#"builtins.baseNameOf "/path/to/file.nix""#);
assert_eq!(result, Value::String("file.nix".to_string()));
}
#[test]
fn test_dir_of_path_type() {
// dirOf preserves path type
let result = eval("builtins.typeOf (builtins.dirOf ./path/to/file.nix)");
assert_eq!(result, Value::String("path".to_string()));
}
#[test]
fn test_dir_of_string_type() {
// dirOf preserves string type
let result = eval(r#"builtins.typeOf (builtins.dirOf "/path/to/file.nix")"#);
assert_eq!(result, Value::String("string".to_string()));
}
#[test]
fn test_path_equality() {
// Same path should be equal
let result = eval("./foo == ./foo");
assert_eq!(result, Value::Bool(true));
}
#[test]
fn test_path_not_equal_string() {
// Paths and strings are different types - should not be equal
let result = eval(r#"./foo == "./foo""#);
assert_eq!(result, Value::Bool(false));
}
#[test]
fn test_to_path_absolute() {
// toPath with absolute path returns string
let result = eval(r#"builtins.toPath "/foo/bar""#);
assert_eq!(result, Value::String("/foo/bar".to_string()));
}
#[test]
fn test_to_path_type_is_string() {
// toPath returns a string, not a path
let result = eval(r#"builtins.typeOf (builtins.toPath "/foo")"#);
assert_eq!(result, Value::String("string".to_string()));
}
#[test]
fn test_to_path_relative_fails() {
// toPath with relative path should fail
let result = eval_result(r#"builtins.toPath "foo/bar""#);
assert!(result.is_err());
}
#[test]
fn test_to_path_empty_fails() {
// toPath with empty string should fail
let result = eval_result(r#"builtins.toPath """#);
assert!(result.is_err());
}
#[test]
fn test_to_path_from_path_value() {
// toPath can accept a path value too (coerces to string first)
let result = eval("builtins.toPath ./foo");
// Should succeed and return the absolute path as a string
assert!(matches!(result, Value::String(s) if s.starts_with("/")));
}