feat: initial path implementation
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,3 +1,7 @@
|
||||
target/
|
||||
|
||||
/.direnv/
|
||||
|
||||
# Profiling
|
||||
flamegraph*.svg
|
||||
perf.data*
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
127
nix-js/runtime-ts/src/builtins/path.ts
Normal file
127
nix-js/runtime-ts/src/builtins/path.ts
Normal 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);
|
||||
};
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -2,27 +2,84 @@
|
||||
* 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);
|
||||
strParts.push(str);
|
||||
// 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 => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
9
nix-js/runtime-ts/src/path.ts
Normal file
9
nix-js/runtime-ts/src/path.ts
Normal 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;
|
||||
};
|
||||
@@ -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`;
|
||||
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")}`;
|
||||
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}`;
|
||||
msg += "\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;
|
||||
|
||||
forceStack.push(thunk);
|
||||
if (DEBUG_THUNKS.enabled) {
|
||||
forceStack.push(thunk);
|
||||
}
|
||||
try {
|
||||
const result = force(func());
|
||||
thunk.result = result;
|
||||
|
||||
@@ -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)}`);
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
6
nix-js/runtime-ts/src/types/global.d.ts
vendored
6
nix-js/runtime-ts/src/types/global.d.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:#?})`)}})()")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, '_>,
|
||||
|
||||
@@ -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»"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
118
nix-js/tests/path_operations.rs
Normal file
118
nix-js/tests/path_operations.rs
Normal 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("/")));
|
||||
}
|
||||
Reference in New Issue
Block a user