tsv

introduction #

tsv is a Rust toolchain for TypeScript/JS, CSS, and Svelte (and planned HTML/JSON). Today it ships a formatter that closely follows Prettier + prettier-plugin-svelte, and a drop-in for Svelte's parser + acorn + acorn-typescript.

tsv aims to simplify its covered domains and stay lean, and so it makes opinionated choices. The formatter has a single non-configurable style, using Svelte's Prettier config. Among other benefits, this means tsv doesn't depend on a JS runtime, which it would need in order to resolve configs the way Prettier does.

Compared to Oxc, Biome, and swc, tsv is a set of focused tools, not an extensible language platform, so it targets Web standards + TS + Svelte and there's no support for JSX/SCSS/etc. tsv's extensibility story is currently limited to using its Rust crates as libraries (or forking); bridging to JS or wasm plugins is an open question (leaning against).

Compared to rsvelte, tsv has its own TS/JS/CSS parsers instead of using Oxc. rsvelte also ships a Svelte compiler and linter/typechecker integration; tsv has experimental work in that direction that may never ship.

tsv prioritizes, in order:

  1. correctness (spec conformance for JS/CSS, planned for HTML/JSON; fidelity to Svelte and TypeScript)
  2. speed
  3. binary size and memory usage
  4. extensibility, modularity, reusability

Staying simple is an overarching goal, and is sometimes at odds with flexibility. Feedback is welcome to help navigate these tradeoffs.

See the benchmarks for measurements. Compared to Oxc/Oxfmt and Biome, tsv is generally faster and smaller, but lacks their features, extensibility, and broad language support. One reason for tsv to exist is to help find the performance left on the table in the Web's implementations.

tsv is near production-ready, with a long tail of rare bugs, and APIs may still change. Reports and opinions are appreciated. See the issues and discussions.

AI disclosure: this codebase is LLM-generated. It's a high-effort project that prioritizes quality.

These docs are a work in progress. There are more design details in the readme.

Install
#

For format-on-save in VS Code and vsix-compatible editors, install the fuzdev.tsv-format extension. It runs tsv's wasm build, so it works in both desktop VS Code and the browser host:

tsv is published to npm as @fuzdev/tsv with native binaries:

npm i -D @fuzdev/tsv npx tsv format src npx tsv format --check src npx tsv parse src/foo.svelte

tsv format writes changed files in place; --check writes nothing and exits 1 if any file would change. Inside a git repo, discovery honors .gitignore, .prettierignore, and .formatignore.

The native package covers Linux (x64 gnu and musl, arm64 gnu), macOS (arm64 and x64), and Windows x64 - for other platforms use the wasm build below.

The same CLI binaries are also attached to each GitHub Release with a SHA256SUMS and a build provenance attestation, for use without npm.

tsv also ships as wasm, which runs everywhere including browsers and Deno, and provides the same tsv command:

npm i -D @fuzdev/tsv-wasm npx tsv format src

Both packages claim the tsv bin name. (TBD if the wasm package needs a different name)

For smaller builds, the formatter and parser also ship solo:

npm i -D @fuzdev/tsv-format-wasm npm i -D @fuzdev/tsv-parse-wasm

Optimally-sized builds for parsing and formatting individual languages may be added upon request. (open an issue)

See the benchmarks for size and performance details.

Usage
#

All four packages share one API — the same function names, options, and errors — so @fuzdev/tsv and @fuzdev/tsv-wasm are drop-in swaps for each other. Both export the formatter and parser together:

import { format_svelte, parse_svelte, type Root } from '@fuzdev/tsv'; const formatted = format_svelte('<script>\nconst x=1\n</script>'); const ast: Root = parse_svelte('<script>const x = 1;</script>');

The formatter alone:

import { format_svelte } from '@fuzdev/tsv-format-wasm'; const formatted = format_svelte('<script>\nconst x=1\n</script>');

The parser alone:

import { parse_svelte, type Root } from '@fuzdev/tsv-parse-wasm'; const ast: Root = parse_svelte('<script>const x = 1;</script>');

format_typescript, format_css, parse_typescript, and parse_css work the same way, and the parsers return plain-object ASTs in acorn's and Svelte's shapes, with bundled TS types. Their output is checked against acorn-typescript's and Svelte's at corpus scale, but hasn't yet been fed through Svelte's compiler end to end.

Each parser has a _json twin (parse_svelte_json and so on) that returns the AST as a JSON string, skipping the JSON.parse, for callers that pass it along rather than walk it; it takes every option but locations.

The object parsers also take an acorn-style options object: {locations: true} for per-node line and column (below), and for TypeScript {sourceType: 'script' | 'module'} (default 'module'). format_typescript takes sourceType too; without it, formatting retries as a script when the module parse fails, so a legacy sloppy script needs no options.

A source that doesn't parse throws a SyntaxError from the parsers and formatters alike, carrying start (the UTF-16 offset) and loc: {line, column}. A bad argument — a source that isn't a string, an unknown option — throws a TypeError.

The native package needs no initialization; the wasm packages work zero-config in Node.js, Bun, and Deno (sync auto-init); in browsers and bundlers, call await init() once first.

Line and column
#

The parsers return a span-only AST: every node carries its start/end offsets and no per-node loc, as with acorn's defaults and oxc-parser's. A span-only tree is smaller and faster to hand to JS, and line and column can be derived from the offsets and the source at any time, without re-parsing.

import { parse_typescript, create_locator } from '@fuzdev/tsv-parse-wasm'; const source = 'const x = 1;'; // the default AST is span-only: start/end offsets, no per-node loc const ast = parse_typescript(source); // loc on every node, the tree acorn's `locations: true` returns const located = parse_typescript(source, { locations: true }); // or look up only the positions you need, over one line table const locator = create_locator(source, { language: 'typescript' }); locator.position_at(ast.body[0].start); // {line: 1, column: 0} locator.loc_of(ast.body[0]); // {start: {line, column}, end: {line, column}}

Details:

  • {locations: true} adds loc to every object that has start/end, and name_loc to the Svelte elements, attributes, and directives that carry one. It's computed in JS from the offsets after the parse, so it costs nothing when off.
  • loc has one definition: the line and UTF-16 column of the node's own start and end, breaking lines on ECMAScript's line terminators in TypeScript and on \n alone in Svelte and CSS. For TypeScript that is exactly acorn's locations: true; for Svelte and CSS it's a superset of the canonical parsers' loc that follows the offsets where Svelte's own loc departs from them.
  • reconstruct_locations(ast, source) runs the same walk over a tree you already hold, mutating it in place.
  • For sparse lookups, create_locator(source, {language}) builds the line table once, so position_at(offset) and loc_of(node) cost only the positions you ask for.
  • The helpers also ship alone, loading no engine, as the ./locations subpath of every package that parses.
  • On the command line, tsv parse --locations prints the same tree.

Source code
#

playground #

Try tsv's formatter and parser live in the playground.

benchmarks #

The benchmarks compare tsv's speed and size with Prettier, Oxc, Biome, and others.

conformance #

The conformance page compares parser coverage on edge-case test suites.