From fbf35ba4cdf4f22cf16bb92ee6e08b88535a1445 Mon Sep 17 00:00:00 2001 From: imxyy_soope_ Date: Sat, 10 Jan 2026 17:24:10 +0800 Subject: [PATCH] feat: implement coerceToString --- biome.json | 2 +- flake.nix | 5 +- nix-js/runtime-ts/src/builtins/conversion.ts | 177 ++++++++++++++++++- nix-js/runtime-ts/src/index.ts | 3 + nix-js/src/codegen.rs | 4 +- 5 files changed, 184 insertions(+), 7 deletions(-) diff --git a/biome.json b/biome.json index 0cc7a82..8e38991 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.6/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.9/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/flake.nix b/flake.nix index dd2e7c3..5ffbdf5 100644 --- a/flake.nix +++ b/flake.nix @@ -22,13 +22,16 @@ "rustfmt" "rust-analyzer" ]) + cargo-outdated lldb valgrind - claude-code + hyperfine nodejs nodePackages.npm biome + + claude-code ]; }; } diff --git a/nix-js/runtime-ts/src/builtins/conversion.ts b/nix-js/runtime-ts/src/builtins/conversion.ts index b287faf..21f2e4b 100644 --- a/nix-js/runtime-ts/src/builtins/conversion.ts +++ b/nix-js/runtime-ts/src/builtins/conversion.ts @@ -1,8 +1,9 @@ /** - * Conversion and serialization builtin functions (unimplemented) + * Conversion and serialization builtin functions */ import type { NixValue } from "../types"; +import { force } from "../thunk"; export const fromJSON = (e: NixValue): never => { throw new Error("Not implemented: fromJSON"); @@ -20,6 +21,176 @@ export const toXML = (e: NixValue): never => { throw new Error("Not implemented: toXML"); }; -export const toString = (name: NixValue, s: NixValue): never => { - throw new Error("Not implemented: toString"); +/** + * String coercion modes control which types can be coerced to strings + * + * - Base: Only strings are allowed (no coercion) + * - Interpolation: Used in string interpolation "${expr}" - allows strings and integers + * - ToString: Used in builtins.toString - allows all types (bools, floats, null, lists, etc.) + */ +export enum StringCoercionMode { + Base = 0, + Interpolation = 1, + ToString = 2, +} + +/** + * Helper function to get human-readable type names for error messages + */ +const typeName = (value: NixValue): string => { + const val = force(value); + + if (typeof val === "bigint") return "int"; + if (typeof val === "number") return "float"; + if (typeof val === "boolean") return "boolean"; + if (typeof val === "string") return "string"; + if (val === null) return "null"; + if (Array.isArray(val)) return "list"; + if (typeof val === "function") return "lambda"; + if (typeof val === "object") return "attribute set"; + + return `unknown type`; +}; + +/** + * Coerce a Nix value to a string according to the specified mode. + * This implements the same behavior as Lix's EvalState::coerceToString. + * + * @param value - The value to coerce + * @param mode - The coercion mode (controls which types are allowed) + * @param copyToStore - If true, paths should be copied to the Nix store (not implemented yet) + * @returns The string representation of the value + * @throws TypeError if the value cannot be coerced in the given mode + * + * Coercion rules by type: + * - String: Always returns as-is + * - Path: Returns the path string (copyToStore not implemented yet) + * - Integer: Only in Interpolation or ToString mode + * - Float: Only in ToString mode + * - Boolean: Only in ToString mode (true → "1", false → "") + * - Null: Only in ToString mode (→ "") + * - List: Only in ToString mode (recursively coerce elements, join with spaces) + * - Attrs: Check for __toString method or outPath attribute + * - Function: Never coercible (throws error) + */ +export const coerceToString = ( + value: NixValue, + mode: StringCoercionMode = StringCoercionMode.ToString, + copyToStore: boolean = false, +): string => { + const v = force(value); + + // Strings are always returned as-is, regardless of mode + if (typeof v === "string") { + return v; + } + + // Attribute sets can define custom string conversion via __toString method + // or may have an outPath attribute (for derivations and paths) + 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 + if ("__toString" in v) { + // Force the method in case it's a thunk + const toStringMethod = force(v["__toString"]); + if (typeof toStringMethod === "function") { + // Call the method with self as argument + const result = force(toStringMethod(v)); + if (typeof result !== "string") { + throw new TypeError(`__toString returned ${typeName(result)} instead of string`); + } + return result; + } + } + + // If no __toString, try outPath (used for derivations and store paths) + // This allows derivation objects like { outPath = "/nix/store/..."; } to be coerced + if ("outPath" in v) { + // Recursively coerce the outPath value (it might itself be an attrs with __toString) + return coerceToString(v["outPath"], mode, copyToStore); + } + + // Attribute sets without __toString or outPath cannot be coerced + throw new TypeError(`cannot coerce ${typeName(v)} to a string`); + } + + // Integer coercion is allowed in Interpolation and ToString modes + // This enables string interpolation like "value: ${42}" + if (mode >= StringCoercionMode.Interpolation) { + if (typeof v === "bigint") { + return v.toString(); + } + } + + // The following types are only coercible in ToString mode (builtins.toString) + if (mode >= StringCoercionMode.ToString) { + // Booleans: true → "1", false → "" + // This is for shell scripting convenience (same as null) + if (typeof v === "boolean") { + return v ? "1" : ""; + } + + // Floats are converted using JavaScript's default toString + if (typeof v === "number") { + return v.toString(); + } + + // Null becomes empty string (for shell scripting convenience) + if (v === null) { + return ""; + } + + // Lists are recursively converted and joined with spaces + // We cannot use Array.join() directly because of special spacing rules: + // - Elements are recursively coerced to strings + // - Spaces are added between elements, BUT: + // * No space is added after an element if it's an empty list + // * The last element never gets a trailing space + // + // Examples: + // - [ 1 2 3 ] → "1 2 3" + // - [ 1 [ ] 2 ] → "1 2" (empty list doesn't add space) + // - [ 1 [ [ ] ] 2 ] → "1 2" (nested empty list is not itself empty, so adds space) + // - [ [ 1 2 ] [ 3 4 ] ] → "1 2 3 4" (nested lists flatten) + if (Array.isArray(v)) { + let result = ""; + for (let i = 0; i < v.length; i++) { + const item = v[i]; + // Recursively convert element to string + const str = coerceToString(item, mode, copyToStore); + result += str; + + // Add space after this element if: + // 1. It's not the last element, AND + // 2. The element is not an empty list + // + // Note: We check if the ELEMENT is an empty list, not if its + // string representation is empty. + // For example, [[]] is not an empty list (length 1), so it gets + // a trailing space even though its toString is "". + if (i < v.length - 1) { + const forcedItem = force(item); + if (!Array.isArray(forcedItem) || forcedItem.length !== 0) { + result += " "; + } + } + } + return result; + } + } + + throw new TypeError(`cannot coerce ${typeName(v)} to a string`); +}; + +/** + * builtins.toString - Convert a value to a string + * + * This is the public builtin function exposed to Nix code. + * It uses ToString mode, which allows coercing all types except functions. + * + * @param value - The value to convert to a string + * @returns The string representation + */ +export const toString = (value: NixValue): string => { + return coerceToString(value, StringCoercionMode.ToString, false); }; diff --git a/nix-js/runtime-ts/src/index.ts b/nix-js/runtime-ts/src/index.ts index c37cb88..d597048 100644 --- a/nix-js/runtime-ts/src/index.ts +++ b/nix-js/runtime-ts/src/index.ts @@ -8,6 +8,7 @@ import { createThunk, force, is_thunk, IS_THUNK } from "./thunk"; import { select, selectWithDefault, validateParams, resolvePath, hasAttr } from "./helpers"; import { op } from "./operators"; import { builtins, PRIMOP_METADATA } from "./builtins"; +import { coerceToString, StringCoercionMode } from "./builtins/conversion"; export type NixRuntime = typeof Nix; @@ -25,6 +26,8 @@ export const Nix = { selectWithDefault, validateParams, resolvePath, + coerceToString, + StringCoercionMode, op, builtins, diff --git a/nix-js/src/codegen.rs b/nix-js/src/codegen.rs index 84e8e13..fe7ed9b 100644 --- a/nix-js/src/codegen.rs +++ b/nix-js/src/codegen.rs @@ -292,8 +292,8 @@ impl Compile for ConcatStrings { .iter() .map(|part| { let compiled = ctx.get_ir(*part).compile(ctx); - // TODO: coerce to string - format!("String(Nix.force({}))", compiled) + // TODO: copyToStore + format!("Nix.coerceToString({}, Nix.StringCoercionMode.Interpolation, false)", compiled) }) .collect();