feat: implement coerceToString

This commit is contained in:
2026-01-10 17:24:10 +08:00
parent 1adb7a24a9
commit fbf35ba4cd
5 changed files with 184 additions and 7 deletions

View File

@@ -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": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",

View File

@@ -22,13 +22,16 @@
"rustfmt" "rustfmt"
"rust-analyzer" "rust-analyzer"
]) ])
cargo-outdated
lldb lldb
valgrind valgrind
claude-code hyperfine
nodejs nodejs
nodePackages.npm nodePackages.npm
biome biome
claude-code
]; ];
}; };
} }

View File

@@ -1,8 +1,9 @@
/** /**
* Conversion and serialization builtin functions (unimplemented) * Conversion and serialization builtin functions
*/ */
import type { NixValue } from "../types"; import type { NixValue } from "../types";
import { force } from "../thunk";
export const fromJSON = (e: NixValue): never => { export const fromJSON = (e: NixValue): never => {
throw new Error("Not implemented: fromJSON"); throw new Error("Not implemented: fromJSON");
@@ -20,6 +21,176 @@ export const toXML = (e: NixValue): never => {
throw new Error("Not implemented: toXML"); 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);
}; };

View File

@@ -8,6 +8,7 @@ import { createThunk, force, is_thunk, IS_THUNK } from "./thunk";
import { select, selectWithDefault, validateParams, resolvePath, hasAttr } from "./helpers"; import { select, selectWithDefault, validateParams, resolvePath, hasAttr } from "./helpers";
import { op } from "./operators"; import { op } from "./operators";
import { builtins, PRIMOP_METADATA } from "./builtins"; import { builtins, PRIMOP_METADATA } from "./builtins";
import { coerceToString, StringCoercionMode } from "./builtins/conversion";
export type NixRuntime = typeof Nix; export type NixRuntime = typeof Nix;
@@ -25,6 +26,8 @@ export const Nix = {
selectWithDefault, selectWithDefault,
validateParams, validateParams,
resolvePath, resolvePath,
coerceToString,
StringCoercionMode,
op, op,
builtins, builtins,

View File

@@ -292,8 +292,8 @@ impl<Ctx: CodegenContext> Compile<Ctx> for ConcatStrings {
.iter() .iter()
.map(|part| { .map(|part| {
let compiled = ctx.get_ir(*part).compile(ctx); let compiled = ctx.get_ir(*part).compile(ctx);
// TODO: coerce to string // TODO: copyToStore
format!("String(Nix.force({}))", compiled) format!("Nix.coerceToString({}, Nix.StringCoercionMode.Interpolation, false)", compiled)
}) })
.collect(); .collect();