Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion crates/tsv_ts/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Standard `ast/` (internal + convert), `lexer/`, `parser/`, `printer/` layout —
- `parse(source, arena: &'arena Bump) -> Result<Program<'arena>>` — 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<Program>` — `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<u8>` / `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 `<script>`-comment fixtures that exercise the fused offset translation. `tsv_svelte` composes the embedded writers (`write_program_embedded`, `write_expression_embedded`, `write_pattern_embedded`, `write_variable_declaration_embedded`, `write_identifier_expression_with_character`), each taking a `CommentMode`: `Off` for an ordinary emission, `Emit` to consult a precomputed `WriterComments` map (an island's per-node comment assignments) at each node's close, `Record` to capture the wire node tree into a `SkeletonRecorder` during a byte-space skeleton emit (the comment-attach walk's input — no re-parse of the emitted bytes).
- `convert_ast_json_bytes(...) -> Vec<u8>` / `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 delegate to `serde_json` so escaping matches exactly, and numeric literal values reproduce JS `Number::toString` — integers expand below `1e21`, and the one non-integral decade `[1e-6, 1e-5)` where ryu diverges is written as JS's fixed form; everything else is ryu, which already matches — see `write_number_value`). `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 `<script>`-comment fixtures that exercise the fused offset translation. `tsv_svelte` composes the embedded writers (`write_program_embedded`, `write_expression_embedded`, `write_pattern_embedded`, `write_variable_declaration_embedded`, `write_identifier_expression_with_character`), each taking a `CommentMode`: `Off` for an ordinary emission, `Emit` to consult a precomputed `WriterComments` map (an island's per-node comment assignments) at each node's close, `Record` to capture the wire node tree into a `SkeletonRecorder` during a byte-space skeleton emit (the comment-attach walk's input — no re-parse of the emitted bytes).
- `convert_ast_json_bytes_no_locations(...)` / `convert_ast_json_string_no_locations(...)` — the opt-in **span-only** variant: same wire minus the per-node `loc` object (line/column). `loc` is a pure function of a node's `start`/`end` (UTF-16 offsets) plus the source, so a consumer with the source loses nothing — it derives line/column lazily. Removing it drops ~46% of the wire and ~61% of downstream `JSON.parse` cost, and lets emission skip the per-node line/column lookup. Gated at emission by a runtime `Ctx.emit_loc` flag (constant per document → one predicted branch on the default path); the strip-loc invariant is `tests/no_locations.rs`. Mirrors acorn's `locations: false` — a distinct, narrower product, not a second encoding of the drop-in contract. The parse-capable wasm packages ship that lazy derivation as a pure-JS helper (`reconstruct_locations` / `create_locator`), so a JS consumer recovers exact `loc` (TS) without a re-parse. See the CLI `--no-locations` and [../tsv_wasm/CLAUDE.md](../tsv_wasm/CLAUDE.md).

**Embedding** (used by `tsv_svelte` — shares indent and comment buffers with the host document; spans are host-file coordinates):
Expand Down
42 changes: 34 additions & 8 deletions crates/tsv_ts/src/ast/convert/write/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -704,14 +704,10 @@ 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 —
/// which matches JS except the one non-integral decade handled below.
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).
Expand All @@ -738,6 +734,36 @@ 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;
}
} else {
// Non-integral. JS `Number::toString` uses fixed notation down to the
// spec's `n = -5` (|x| in [1e-6, 1e-5)), whereas ryu switches to
// scientific one decade earlier — the sole non-integral divergence.
// In that single decade the point sits at position -5, so the fixed
// form is `0.` + five zeros + the shortest significant digits.
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.
let sci = format!("{a:e}");
let mantissa = sci.split('e').next().unwrap_or(&sci);
let mut out = String::with_capacity(8 + mantissa.len());
if n.is_sign_negative() {
out.push('-');
}
out.push_str("0.00000");
out.extend(mantissa.chars().filter(|&c| c != '.'));
w.raw(&out);
return;
}
}
w.f64(n);
}
Expand Down
Loading