From eaf215bd7cba5efa260e639247daf663f9e6f7d1 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Wed, 22 Jul 2026 17:37:33 -0400 Subject: [PATCH 1/3] fix: json number conversion --- crates/tsv_ts/src/ast/convert/write/mod.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/crates/tsv_ts/src/ast/convert/write/mod.rs b/crates/tsv_ts/src/ast/convert/write/mod.rs index be38acd1..e6eb376a 100644 --- a/crates/tsv_ts/src/ast/convert/write/mod.rs +++ b/crates/tsv_ts/src/ast/convert/write/mod.rs @@ -704,14 +704,9 @@ pub(super) fn write_name( /// Emit a numeric literal value the way acorn's JSON does: non-finite as /// `null` (JSON has no Infinity/NaN — an overflow literal like `1e999`), -/// integral doubles as expanded shortest-round-trip integers (JS -/// `JSON.stringify` semantics), everything else as ryu. -/// -/// Known divergence (pre-existing, carried over from the typed converter): -/// integral doubles in `(u64::MAX, 1e21)` — e.g. `1e20` — emit ryu's `1e+20` -/// where JS prints expanded digits. -/// TODO: emit JS-style expanded text for integral doubles beyond `u64` up to -/// 1e21 (fixtures-first). +/// integral doubles below `1e21` as their expanded shortest-round-trip integer +/// digits and integral doubles at/above `1e21` in exponential form (JS +/// `Number::toString` / `JSON.stringify` semantics), everything else as ryu. pub(super) fn write_number_value(w: &mut JsonWriter, n: f64) { if !n.is_finite() { // ±Inf → null, matching JSON.stringify (a parsed literal is never NaN). @@ -738,6 +733,15 @@ pub(super) fn write_number_value(w: &mut JsonWriter, n: f64) { w.u64(v); return; } + // Beyond u64 but below 1e21, JS `Number::toString` still prints the + // expanded integer (the spec's `k <= n <= 21` case); `shortest` — + // Rust's shortest-round-trip Display — already holds those exact digits. + // At/above 1e21 JS switches to exponential, where Rust's Display would + // wrongly keep expanding, so that range falls through to ryu (`w.f64`). + if n.abs() < 1e21 { + w.raw(&shortest); + return; + } } w.f64(n); } From 9bffe94197720843f1523df5c42fc722a0bbdaf5 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Wed, 22 Jul 2026 18:09:27 -0400 Subject: [PATCH 2/3] fixtures --- Cargo.toml | 13 +- crates/tsv_ts/CLAUDE.md | 2 +- crates/tsv_ts/src/ast/convert/write/mod.rs | 25 +- .../literals/beyond_u64_integer/expected.json | 598 ++++++++++++++++++ .../literals/beyond_u64_integer/input.svelte | 16 + .../literals/numeric/edge_cases/expected.json | 4 +- .../objects/numeric_keys/expected.json | 2 +- 7 files changed, 652 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/typescript/expressions/literals/beyond_u64_integer/expected.json create mode 100644 tests/fixtures/typescript/expressions/literals/beyond_u64_integer/input.svelte diff --git a/Cargo.toml b/Cargo.toml index b2347107..a373998a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,9 +68,16 @@ tokio = { version = "1" } futures-util = "0.3" phf = { version = "0.14", features = ["macros"] } serde = { version = "1", features = ["derive"] } -# float_roundtrip: exact f64 parsing so big literal values (e.g. integral -# doubles beyond u64) survive the expected.json round-trip byte-faithfully -serde_json = { version = "1", features = ["preserve_order", "float_roundtrip"] } +# preserve_order: keep object key order (wire field-order conformance). +# float_roundtrip: exact shortest f64 parsing. +# arbitrary_precision: `serde_json::Value` stores number tokens verbatim +# instead of collapsing them to f64, so the fixture pipeline (expected.json +# generation + validation, both of which round-trip through `Value` via +# `to_json_with_tabs`) compares the writer's exact number bytes against acorn's +# — a literal like `1e20`/`0.000001` no longer gets reformatted through ryu on +# both sides, which had masked `Number::toString` divergences. The production +# wire writer (`JsonWriter`) never touches `Value`, so its output is unchanged. +serde_json = { version = "1", features = ["preserve_order", "float_roundtrip", "arbitrary_precision"] } smallvec = "1.15.2" thiserror = "2" unicode-ident = "1.0" diff --git a/crates/tsv_ts/CLAUDE.md b/crates/tsv_ts/CLAUDE.md index 8d3fe5a0..579faae7 100644 --- a/crates/tsv_ts/CLAUDE.md +++ b/crates/tsv_ts/CLAUDE.md @@ -23,7 +23,7 @@ Standard `ast/` (internal + convert), `lexer/`, `parser/`, `printer/` layout — - `parse(source, arena: &'arena Bump) -> Result>` — the AST is bump-arena-allocated; the caller owns the `bumpalo::Bump` (caller-owns-arena, so the returned AST borrows it and the arena never escapes the call). See [docs/architecture.md §Nested AST](../../docs/architecture.md#nested-ast-bump-arena-not-flatindexed). `parse` is the `Goal::Module` form of `parse_with_goal(source, goal, arena)` — pass `Goal::Script` for a standalone strict script (`await` an ordinary identifier; `import`/`export`/`import.meta` errors). `Program.goal` drives the public `sourceType`. See **Strict mode only** below. - `parse_preserve_parens(source, arena) -> Result` — `parse` with grouping parens kept as `ParenthesizedExpression` nodes (acorn's `preserveParens: true`). The standalone analog of the embedding `parse_embedded_preserve_parens`; the binding audit (`tsv_debug binding_audit`) reparses formatted output this way so the paren structure a glued comment binds to is visible in the wire JSON. `is_jsdoc_type_cast_comment(content) -> bool` is exposed alongside it — the cast-content predicate the audit (and its own printer) classify a glued comment's anchor rule by. - `format(program, source) -> String` — the format entry point (output is identical for standalone `.ts` and Svelte-embedded TS). `format_in(program, source, &DocArena)` is the same but builds into a caller-provided doc arena, so multi-file drivers (CLI/FFI/NAPI) reuse one arena across files (`DocArena::reset()` between files); `format` is the fresh-arena wrapper -- `convert_ast_json_bytes(...) -> Vec` / `convert_ast_json_string(...) -> String` / `convert_ast_json(...) -> serde_json::Value` (all gated on `convert` feature). The bytes variant is the **sole emission path** — the compact-wire hot path (FFI/CLI non-pretty; the string variant is the same bytes plus one output UTF-8 validation, for `&str` boundaries — the WASM binding's `JSON.parse`, N-API strings). It is a **writer-mode conversion** (`ast/convert/write/`) that emits the wire JSON directly during a single walk of the *internal* AST — no typed public tree is ever materialized — and **fuses byte→UTF-16 offset translation into the walk**: the writer threads a `tsv_lang::LocationMapper` (tracker + `ByteToCharMap`) and emits final char-space `start`/`end`/`loc` directly, so no post-conversion translation walk runs (per-language pipeline shapes: [docs/architecture.md §Closed Scope, Open Convention](../../docs/architecture.md#closed-scope-open-convention)). The writer is a faithful emission of the acorn quirk catalog (field order, skip rules, scalar formatting; dynamic strings and non-integral floats delegate to `serde_json` so escaping matches exactly). `convert_ast_json` is a thin wrapper — `serde_json::from_slice(&convert_ast_json_bytes(...))` — for the `Value` consumers (the CLI's `--pretty`, the fixture gate), not an independent conversion. The oracle the writer is gated against is the canonical parser's `expected.json`, including the multibyte and ` diff --git a/tests/fixtures/typescript/expressions/literals/numeric/edge_cases/expected.json b/tests/fixtures/typescript/expressions/literals/numeric/edge_cases/expected.json index 25c05afb..702c4578 100644 --- a/tests/fixtures/typescript/expressions/literals/numeric/edge_cases/expected.json +++ b/tests/fixtures/typescript/expressions/literals/numeric/edge_cases/expected.json @@ -1268,7 +1268,7 @@ "column": 48 } }, - "value": 5.674724124163433e+20, + "value": 567472412416343300000, "raw": "0o75415030007726607105367" } } @@ -2026,7 +2026,7 @@ "column": 28 } }, - "value": 1e-6, + "value": 0.000001, "raw": "0.000001" } } diff --git a/tests/fixtures/typescript/expressions/objects/numeric_keys/expected.json b/tests/fixtures/typescript/expressions/objects/numeric_keys/expected.json index 6bb36b0f..de8166f1 100644 --- a/tests/fixtures/typescript/expressions/objects/numeric_keys/expected.json +++ b/tests/fixtures/typescript/expressions/objects/numeric_keys/expected.json @@ -331,7 +331,7 @@ "column": 22 } }, - "value": 3.3627389621760085e+20, + "value": 336273896217600850000, "raw": "0x123abcdef456abcdef" }, "value": { From 7710db175601f740d6aaaf68a0987e2dd61b9f58 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Wed, 22 Jul 2026 18:13:16 -0400 Subject: [PATCH 3/3] cleanup comment --- crates/tsv_ts/src/ast/convert/write/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/tsv_ts/src/ast/convert/write/mod.rs b/crates/tsv_ts/src/ast/convert/write/mod.rs index 916afcfe..2a4093c0 100644 --- a/crates/tsv_ts/src/ast/convert/write/mod.rs +++ b/crates/tsv_ts/src/ast/convert/write/mod.rs @@ -752,8 +752,7 @@ pub(super) fn write_number_value(w: &mut JsonWriter, n: f64) { let a = n.abs(); if (1e-6..1e-5).contains(&a) { // `{a:e}` is the shortest round-trip scientific form (`d[.ddd]e-6`); - // its mantissa digits are exactly `s` in the spec, so the fixed form - // is `0.` + five zeros + those digits. + // its mantissa digits are exactly `s` in the spec. let sci = format!("{a:e}"); let mantissa = sci.split('e').next().unwrap_or(&sci); let mut out = String::with_capacity(8 + mantissa.len());