feat: implement fromTOML; fix fromJSON implementation

This commit is contained in:
2026-02-13 22:17:28 +08:00
parent d95a6e509c
commit 60cd61d771
12 changed files with 246 additions and 55 deletions

55
Cargo.lock generated
View File

@@ -1966,6 +1966,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.17",
"tokio",
"toml",
"tracing",
"tracing-subscriber",
"xz2",
@@ -2280,7 +2281,7 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
dependencies = [
"toml_edit",
"toml_edit 0.23.10+spec-1.0.0",
]
[[package]]
@@ -2824,6 +2825,15 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -3358,6 +3368,27 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime 0.6.11",
"toml_edit 0.22.27",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
@@ -3367,6 +3398,20 @@ dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime 0.6.11",
"toml_write",
"winnow",
]
[[package]]
name = "toml_edit"
version = "0.23.10+spec-1.0.0"
@@ -3374,7 +3419,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
dependencies = [
"indexmap",
"toml_datetime",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"winnow",
]
@@ -3388,6 +3433,12 @@ dependencies = [
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tower"
version = "0.5.2"

View File

@@ -9,6 +9,7 @@ let
flake = (
import flake-compat {
src = ./.;
copySourceTreeToStore = false;
}
);
in

View File

@@ -49,6 +49,7 @@ bzip2 = "0.5"
zip = "2.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
dirs = "5.0"
tempfile = "3.24"
rusqlite = { version = "0.33", features = ["bundled"] }

View File

@@ -6,55 +6,22 @@ import type { NixString, NixValue } from "../types";
import { isStringWithContext, isNixPath } from "../types";
import { force } from "../thunk";
import { type NixStringContext, mkStringWithContext, addBuiltContext } from "../string-context";
import { forceFunction } from "../type-assert";
import { forceFunction, forceStringNoCtx } from "../type-assert";
import { nixValueToJson } from "../conversion";
import { isAttrs, isPath, typeOf } from "./type-check";
const convertJsonToNix = (json: unknown): NixValue => {
if (json === null) {
return null;
}
if (typeof json === "boolean") {
return json;
}
if (typeof json === "number") {
if (Number.isInteger(json)) {
return BigInt(json);
}
return json;
}
if (typeof json === "string") {
return json;
}
if (Array.isArray(json)) {
return json.map(convertJsonToNix);
}
if (typeof json === "object") {
const result: Record<string, NixValue> = {};
for (const [key, value] of Object.entries(json)) {
result[key] = convertJsonToNix(value);
}
return result;
}
throw new TypeError(`unsupported JSON value type: ${typeof json}`);
};
export const fromJSON = (e: NixValue): NixValue => {
const str = force(e);
if (typeof str !== "string" && !isStringWithContext(str)) {
throw new TypeError(`builtins.fromJSON: expected a string, got ${typeOf(str)}`);
}
const jsonStr = isStringWithContext(str) ? str.value : str;
try {
const parsed = JSON.parse(jsonStr);
return convertJsonToNix(parsed);
} catch (err) {
throw new SyntaxError(`builtins.fromJSON: ${err instanceof Error ? err.message : String(err)}`);
}
return Deno.core.ops.op_from_json(jsonStr);
};
export const fromTOML = (e: NixValue): never => {
throw new Error("Not implemented: fromTOML");
export const fromTOML = (e: NixValue): NixValue => {
const toml = forceStringNoCtx(e);
return Deno.core.ops.op_from_toml(toml);
};
export const toJSON = (e: NixValue): NixString => {

View File

@@ -41,11 +41,12 @@ export const nixValueToJson = (
}
}
if (seen.has(v)) {
throw new Error("cycle detected in toJSON");
} else {
seen.add(v)
}
// FIXME: is this check necessary?
// if (seen.has(v)) {
// throw new Error("cycle detected in toJSON");
// } else {
// seen.add(v)
// }
if (Array.isArray(v)) {
return v.map((item) => nixValueToJson(item, strict, outContext, copyToStore, seen));

View File

@@ -74,6 +74,8 @@ declare global {
): string;
function op_match(regex: string, text: string): (string | null)[] | null;
function op_split(regex: string, text: string): (string | (string | null)[])[];
function op_from_json(json: string): any;
function op_from_toml(toml: string): any;
}
}
}

View File

@@ -183,9 +183,7 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), std::io::Error> {
Ok(())
}
pub fn extract_tarball_to_temp(
data: &[u8],
) -> Result<(PathBuf, tempfile::TempDir), ArchiveError> {
pub fn extract_tarball_to_temp(data: &[u8]) -> Result<(PathBuf, tempfile::TempDir), ArchiveError> {
let temp_dir = tempfile::tempdir()?;
let extracted_path = extract_archive(data, temp_dir.path())?;
Ok((extracted_path, temp_dir))

View File

@@ -69,6 +69,8 @@ fn runtime_extension<Ctx: RuntimeContext>() -> Extension {
op_add_filtered_path::<Ctx>(),
op_match(),
op_split(),
op_from_json(),
op_from_toml(),
];
ops.extend(crate::fetcher::register_ops::<Ctx>());

View File

@@ -4,6 +4,7 @@ use std::str::FromStr;
use hashbrown::hash_map::{Entry, HashMap};
use deno_core::OpState;
use deno_core::v8;
use regex::Regex;
use rust_embed::Embed;
@@ -1039,3 +1040,118 @@ pub(super) enum SplitResult {
Text(String),
Captures(Vec<Option<String>>),
}
pub(super) enum NixJsonValue {
Null,
Bool(bool),
Int(i64),
Float(f64),
Str(String),
Arr(Vec<NixJsonValue>),
Obj(Vec<(String, NixJsonValue)>),
}
impl<'a> deno_core::convert::ToV8<'a> for NixJsonValue {
type Error = deno_error::JsErrorBox;
fn to_v8<'i>(
self,
scope: &mut v8::PinScope<'a, 'i>,
) -> std::result::Result<v8::Local<'a, v8::Value>, Self::Error> {
match self {
Self::Null => Ok(v8::null(scope).into()),
Self::Bool(b) => Ok(v8::Boolean::new(scope, b).into()),
Self::Int(i) => Ok(v8::BigInt::new_from_i64(scope, i).into()),
Self::Float(f) => Ok(v8::Number::new(scope, f).into()),
Self::Str(s) => v8::String::new(scope, &s)
.map(|s| s.into())
.ok_or_else(|| deno_error::JsErrorBox::type_error("failed to create v8 string")),
Self::Arr(arr) => {
let elements = arr
.into_iter()
.map(|v| v.to_v8(scope))
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(v8::Array::new_with_elements(scope, &elements).into())
}
Self::Obj(entries) => {
let obj = v8::Object::new(scope);
for (k, v) in entries {
let key: v8::Local<v8::Value> = v8::String::new(scope, &k)
.ok_or_else(|| {
deno_error::JsErrorBox::type_error("failed to create v8 string")
})?
.into();
let val = v.to_v8(scope)?;
obj.set(scope, key, val);
}
Ok(obj.into())
}
}
}
}
fn json_to_nix(value: serde_json::Value) -> NixJsonValue {
match value {
serde_json::Value::Null => NixJsonValue::Null,
serde_json::Value::Bool(b) => NixJsonValue::Bool(b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
NixJsonValue::Int(i)
} else if let Some(f) = n.as_f64() {
NixJsonValue::Float(f)
} else {
NixJsonValue::Float(n.as_u64().unwrap_or(0) as f64)
}
}
serde_json::Value::String(s) => NixJsonValue::Str(s),
serde_json::Value::Array(arr) => {
NixJsonValue::Arr(arr.into_iter().map(json_to_nix).collect())
}
serde_json::Value::Object(map) => {
NixJsonValue::Obj(map.into_iter().map(|(k, v)| (k, json_to_nix(v))).collect())
}
}
}
fn toml_to_nix(value: toml::Value) -> std::result::Result<NixJsonValue, NixRuntimeError> {
match value {
toml::Value::String(s) => Ok(NixJsonValue::Str(s)),
toml::Value::Integer(i) => Ok(NixJsonValue::Int(i)),
toml::Value::Float(f) => Ok(NixJsonValue::Float(f)),
toml::Value::Boolean(b) => Ok(NixJsonValue::Bool(b)),
toml::Value::Datetime(_) => Err(NixRuntimeError::from(
"while parsing TOML: Dates and times are not supported",
)),
toml::Value::Array(arr) => {
let items: std::result::Result<Vec<_>, _> = arr.into_iter().map(toml_to_nix).collect();
Ok(NixJsonValue::Arr(items?))
}
toml::Value::Table(table) => {
let entries: std::result::Result<Vec<_>, _> = table
.into_iter()
.map(|(k, v)| toml_to_nix(v).map(|v| (k, v)))
.collect();
Ok(NixJsonValue::Obj(entries?))
}
}
}
#[deno_core::op2]
#[to_v8]
pub(super) fn op_from_json(
#[string] json_str: String,
) -> std::result::Result<NixJsonValue, NixRuntimeError> {
let parsed: serde_json::Value = serde_json::from_str(&json_str)
.map_err(|e| NixRuntimeError::from(format!("builtins.fromJSON: {e}")))?;
Ok(json_to_nix(parsed))
}
#[deno_core::op2]
#[to_v8]
pub(super) fn op_from_toml(
#[string] toml_str: String,
) -> std::result::Result<NixJsonValue, NixRuntimeError> {
let parsed: toml::Value = toml::from_str(&toml_str)
.map_err(|e| NixRuntimeError::from(format!("while parsing TOML: {e}")))?;
toml_to_nix(parsed)
}

View File

@@ -268,12 +268,66 @@ fn escape_quote_string(s: &str) -> String {
ret
}
/// Format a float matching C's `printf("%g", x)` with default precision 6.
fn fmt_nix_float(f: &mut Formatter<'_>, x: f64) -> FmtResult {
if !x.is_finite() {
return write!(f, "{x}");
}
if x == 0.0 {
return if x.is_sign_negative() {
write!(f, "-0")
} else {
write!(f, "0")
};
}
let precision: i32 = 6;
let exp = x.abs().log10().floor() as i32;
let formatted = if exp >= -4 && exp < precision {
let decimal_places = (precision - 1 - exp) as usize;
format!("{x:.decimal_places$}")
} else {
let sig_digits = (precision - 1) as usize;
let s = format!("{x:.sig_digits$e}");
let (mantissa, exp_part) = s
.split_once('e')
.expect("scientific notation must contain 'e'");
let (sign, digits) = if let Some(d) = exp_part.strip_prefix('-') {
("-", d)
} else if let Some(d) = exp_part.strip_prefix('+') {
("+", d)
} else {
("+", exp_part)
};
if digits.len() < 2 {
format!("{mantissa}e{sign}0{digits}")
} else {
format!("{mantissa}e{sign}{digits}")
}
};
if formatted.contains('.') {
if let Some(e_pos) = formatted.find('e') {
let trimmed = formatted[..e_pos]
.trim_end_matches('0')
.trim_end_matches('.');
write!(f, "{}{}", trimmed, &formatted[e_pos..])
} else {
let trimmed = formatted.trim_end_matches('0').trim_end_matches('.');
write!(f, "{trimmed}")
}
} else {
write!(f, "{formatted}")
}
}
impl Display for Value {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
use Value::*;
match self {
&Int(x) => write!(f, "{x}"),
&Float(x) => write!(f, "{x}"),
&Float(x) => fmt_nix_float(f, x),
&Bool(x) => write!(f, "{x}"),
Null => write!(f, "null"),
String(x) => write!(f, "{}", escape_quote_string(x)),
@@ -302,7 +356,7 @@ impl Display for ValueCompatDisplay<'_> {
use Value::*;
match self.0 {
&Int(x) => write!(f, "{x}"),
&Float(x) => write!(f, "{x}"),
&Float(x) => fmt_nix_float(f, x),
&Bool(x) => write!(f, "{x}"),
Null => write!(f, "null"),
String(x) => write!(f, "{}", escape_quote_string(x)),

View File

@@ -152,12 +152,9 @@ eval_okay_test!(foldlStrict_lazy_elements);
eval_okay_test!(foldlStrict_lazy_initial_accumulator);
eval_okay_test!(fromjson);
eval_okay_test!(fromjson_escapes);
eval_okay_test!(fromTOML);
eval_okay_test!(
#[ignore = "not implemented: fromTOML"]
fromTOML
);
eval_okay_test!(
#[ignore = "not implemented: fromTOML"]
#[ignore = "timestamps are not supported"]
fromTOML_timestamps
);
eval_okay_test!(functionargs);

View File

@@ -9,6 +9,7 @@ let
flake = (
import flake-compat {
src = ./.;
copySourceTreeToStore = false;
}
);
in