The CLI uses argh for declarative arg parsing:
- Each command is a
FromArgsstruct in its own module undersrc/cli/commands/ cli::TopLevelholds a top-level--versionswitch plus theSubcommandenum;main.rsparses argv and dispatches, answering a bare--versionitself ahead of argh (cli::is_bare_version) since the subcommand argh requires is not one, so a baretsvgets argh's own required-subcommand error- argh has no struct-flattening attribute, so the shared input fields (
--content,--stdin,--parser, file path) are declared per command and assembled intocli::input::InputArgsfor resolution
Adding Commands: Create src/cli/commands/newcmd.rs with a FromArgs struct and a run() method, declare it in cli/commands/mod.rs, and add a variant to Subcommand plus its TopLevel::run arm in cli/mod.rs.
tsv_cli exports CLI infrastructure as a library, reused by tsv_debug and the integration tests for consistent UX:
- Input handling (file,
--content,--stdin) —cli/input.rs - File/directory discovery with extension filter, gitignore-aware ignore evaluation (hierarchical
.gitignore/.formatignore/.prettierignore), and the build-output heuristic fallback where no.gitignoreis in scope (the per-entry verdicts fromtsv_discover) —cli/discover.rs - The in-process parse+format entry point (
format_source) —cli/format_source.rs - The thread stack reservation and the
--jobsceiling (STACK_SIZE,sized_thread,clamp_worker_count) —cli/stack.rs - The
--prettyre-indenter (tab-indented form of the compact wire, no deserializer) —json_utils.rs
-
tsv(production): Pure Rust, no external tool dependencies- Crates:
tsv_cli - Commands:
parse,format,strip(plus the top-level--versionswitch)
- Crates:
-
tsvnpm bin, native (@fuzdev/tsv): the productiontsvbinary itself, shipped inside each@fuzdev/tsv-<triple>platform package and exec'd by the loader'scrates/tsv_napi/npm/bin.jsdispatcher (argv, stdio, exit codes, and signals forwarded verbatim) — sonpx tsvon the native package has this CLI's exact contract, real--jobsparallelism included. When no binary is reachable the dispatcher falls back to the JS mirror below. -
tsvGitHub Release asset: the same production binary, one per platform package (tsv-<triple>,tsv-win32-x64.exe), attached to each GitHub Release beside aSHA512SUMS(sha512sum -c), for use without npm (curl+chmod +x). Pulled back from the npm registry at release time, so it is byte-identical to the binary the platform package ships; every asset carries a build provenance attestation (gh attestation verify <file> -R fuzdev/tsv). The checksum file and the attestation are this channel's whole integrity story; it has no signed release log of its own. -
tsvnpm bin, WASM (@fuzdev/tsv-wasm):crates/tsv_wasm/npm/cli.js— a hand-written Node mirror of this CLI's contract (subcommands, flags, exit codes, output streams, traversal rules).--jobsis real: path mode fans ontonode:worker_threads, spawningcli.jsas its own worker. Its two defaults are both smaller than the native CLI's:- When to pool at all. A pool costs tens of milliseconds to bring up here
against the native pool's near-free thread spawn, so with no
--jobsgiven the run stays single-threaded until the in-scope file count clears a threshold — measured per engine (lower on N-API than on WASM), with the shipped constants set above each so a pool is only taken where it clearly pays. - How wide. Not the native
min(logical, ceil(1.5 × physical)). That rule assumes an idle machine, and on the WASM engine V8's own wasm tier-up is already using part of one before the first worker exists — so the pool peaks at half the physical cores and regresses past it. The N-API mirror has no compiler thread to compete with and peaks at the physical core count.
An explicit
--jobs Nis held to the native CLI's4 × logicalceiling, warned about on stderr when it bites (see §Multi-File Formatting's parallelism note;cli.jsrestates the nativeclamp_worker_countby hand — same constant, same message, so both surfaces refuse the same numbers). The logical count under that ceiling, and under both default widths, is Rust'savailable_parallelismon either side: the affinity mask capped by the cgroup CPU quota. Node's and Bun'savailableParallelism()leave the quota out (Deno's applies it), socli.jsreads it itself (cgroup_cpu_quota, a transcription of std's) — under a--cpusorCPUQuota=limit every runtime names the native CLI's ceiling rather than one sized past the quota.Both also ACCEPT the same values, which takes its own restatement: the flag's value is parsed as a Rust
usizeon one side and by a hand transcription ofusize::from_stron the other (usize_from_str) — ASCII digits, an optional leading+, nothing pastusize::MAX, refused inParseIntError's own words — andscripts/test_napi_npm.ts, the only place both bins exist at once, runs the accept/reject table through both. Repetition needs no restatement:cli.jsparses argv by a transcription of argh's grammar (parse_argv), so a second value for any value-taking flag (--content,--parser,--source-type,--jobs) isduplicate values providedon both, while repeated SWITCHES stay fine on both (argh counts them).The bound does a different job here than natively: a JS worker is a whole V8 isolate (megabytes resident on either engine, where the native thread's reservation is lazily committed and costs almost nothing), so an unbounded width on a large tree hits the machine's memory — ending in an uncatchable OOM SIGKILL — long before the OS refuses a thread, and the file-count clamp bounds nothing on exactly the trees large enough to matter. An explicit width remains the only way to compare the two paths at a given size, which calibrating those defaults needed; every size that calibration uses is far under the ceiling on both.
--content,--stdin,--listand--jobs 1are single-threaded on both CLIs — except--jobs 1incli.jsover the N-API engine, a pool of one worker, since a native overflow on the main thread is aSIGSEGVno catch survives and only a pool worker carries the reserved stack (§Recursion Depth). The parallel and single-threaded paths report identically (same sorted stdout, summary and exit code), so the split is a cost decision, not a contract one.One source:
cli.jsimports its engine from./index.js, so the copy staged into the native@fuzdev/tsv(as the dispatcher's fallback) binds to the N-API engine with no adapter — its workers, having no compiled module to inherit, load that engine themselves, while WASM workers take the main thread's module through the package's./workerentry and recompile nothing. Behavioral changes toformat/parse/stripin either CLI must be mirrored in the other and in the CLI tests ofscripts/test_npm.ts(wasm) andscripts/test_napi_npm.ts(native) —strip's rows inscripts/strip_suite.ts, which both run. - When to pool at all. A pool costs tens of milliseconds to bring up here
against the native pool's near-free thread spawn, so with no
-
tsv_debug(development): Uses embedded Deno sidecar for external tools- Reuses
tsv_cliinfrastructure - Commands, by family (the full catalog is the root CLAUDE.md §Debug Tooling and audits.md, not duplicated here): fixtures (
fixture_init,fixtures_validate,fixtures_update*,fixtures_audit), oracles (check,compare,ast_diff,canonical_parse,format_prettier,test262,tsc_conformance— see typechecker.md), the standing audit family (comment_audit,gap_audit,census_audit, …), the compiler harnesses (compile_*,canonical_compile,render_compare), the type strip (strip, its--stdinthe strip-corpus side), and profiling/metrics (profile,json_profile,arena_stats,buffer_sizes,metrics,line_width,lex_diff)
- Reuses
tsv_debug calls these external tools via an embedded Deno sidecar (spawned lazily on first use; bulk commands spawn a small pool of sidecar processes — see crates/tsv_debug/CLAUDE.md):
| tool | used by | purpose |
|---|---|---|
| prettier + prettier-plugin-svelte | compare, format_prettier, fixture management |
format code, compare outputs, validate formatter behavior |
| svelte | canonical_parse, ast_diff, fixture management |
parse Svelte code with the official compiler |
| acorn + @sveltejs/acorn-typescript | canonical_parse, ast_diff, fixture management |
parse TypeScript code (matches Svelte's TS parser) |
Versions are pinned exactly in crates/tsv_debug/src/deno/sidecar.ts, the source of truth (not repeated here); benches/js/package.json pins the same versions for the bench harness — keep the two in sync (deno task pins:audit gates it).
All content-processing commands support three input methods (strip, TypeScript's alone,
takes them without --parser — §Stripping types):
- File path:
command <file>- parser from the extension, which must be one tsv handles (the dispatch has no unknown arm, so a.mdwould otherwise parse as TypeScript); an unsupported one is an argument error (format <file>'s message) unless--parsernames the grammar. A directory is refused by name ahead of either check (<dir>: is a directory (one file is expected)): every command resolving a file this way —tsv_debug's too — reads one file, and the read would otherwise fail under a message calling it one - Content:
command --content <string> --parser <type>- Requires explicit--parser svelte|typescript|css - Stdin:
command --stdin --parser <type>- Requires explicit--parser svelte|typescript|css
parse, format and strip also take --source-type script|module — ESTree's own spelling, and
the value the wire's Program.sourceType carries — for TypeScript only: naming one for a
Svelte or CSS input is an error, as on every JS binding, never a silent drop (the C FFI
alone accepts its module code 0 on every language, the neutral value of a required
u32, while rejecting the script code there — tsv_ffi's lib.rs states the
carve-out). It selects the parse goal: at script, await is an ordinary identifier
and top-level import/export, for await and import.meta are errors (a TypeScript
namespace body keeps its import/export). Unset, parse uses module, while
format and strip use module retried as script if that parse fails (see
§Multi-File Formatting). For format and strip the flag applies to
--content/--stdin only — a path argument with --source-type is a usage error
(exit 2), since a path's goal is its extension's (and in format's path mode Svelte and
CSS have no goal at all); parse honors --source-type on file paths too. The goal does not decide strictness:
Module code is strict, Script code is strict only once a "use strict" directive
prologue says so (see
CLAUDE.md §Strictness; the
goal axis itself is
conformance_test262.md §Module Strict, Script by Directive).
Three constructs follow strictness rather than the goal: a with statement, a
leading-zero numeric literal (010, 08) and a legacy string escape ('\7',
'\8') all parse in a sloppy script and are syntax errors in strict code.
tsv is non-configurable by design — "no config files, CLI flags, or runtime options"
(CLAUDE.md §Configuration) — and --source-type is no
exception, because the contract governs style. What this flag
selects is which grammar symbol the parse starts from: ecma262 gives ParseScript and
ParseModule as two separate entry points over the same text, and a source that is a
script is not a module with a setting flipped. The flag shapes only the parse the
formatter runs; no --source-type value changes how anything is printed. The same axis appears on the bindings as the
sourceType option (tsv_wasm, @fuzdev/tsv) and as the C-ABI source-type code
(tsv_ffi); there is no style knob on any of them either.
parse emits the span-only wire by default — start/end offsets but no per-node loc
(line/column) object, and for Svelte no name_loc either — the same wire every binding
emits; loc is derivable from the offsets plus source, so nothing is lost for a consumer
that has the source. --locations adds it, through the Rust loc emitter (the one
product surface that writer has; it mirrors acorn's locations: true): the line (1-based)
and column (0-based, UTF-16 code units) of each object's own start/end, on every object
that carries them — CSS's included, which parseCss itself leaves bare — under the
document's line rule (ECMAScript's terminators for TypeScript, LF alone for Svelte and
everything in it, and for CSS). The JS mirror's parse --locations (npm/cli.js) gets the
same loc from the packages' {locations: true} — the JS reconstruction over the span-only
tree — so its tree deep-equals the native one while the key order differs (loc after
end natively, appended last in JS). Orthogonal to --source-type (the source type drives
the parser, --locations the writer), so the two compose.
Implemented in tsv_cli/src/cli/input.rs
A source that does not parse is reported as the message, a located line, and a caret — after
Parse error: from parse and from format --content/--stdin, and after error: <path>:
from a path-mode format:
Parse error: Expected identifier or destructuring pattern, found '='
2:7 const = ;
^ here
The line:col header is the error's point in the wire's coordinates — the loc a node
at the error would carry, under the same definition as --locations (above): the line
(1-based) under the document's line rule, and the column counted in UTF-16 code units,
printed 1-based. A
leading byte-order mark is counted on line 1 for TypeScript and elided for Svelte and CSS, as
their wires do. format reports the point in the file's own coordinates: its parse reads the
CR-folded text (see CLAUDE.md §Line Terminators),
and the error is mapped back onto the file's own bytes before it prints, so for the same error
it prints exactly what parse does. The same error is not guaranteed: with no
--source-type named, a TypeScript format retries a failed module parse as a script
(§Multi-File Formatting) and can report the script attempt's error
where a module-only parse reports the module's — at one goal the two agree. The excerpt is a
display line, bounded by every ECMAScript terminator whatever the language, since a raw CR,
U+2028 or U+2029 printed mid-line garbles the text the caret points into; it never echoes a
leading byte-order mark, in any language (the header alone carries the wire's reading of one).
The caret is padded by the display width of what the excerpt prints ahead of it (a tab echoed
as a tab), so it sits under the error's character even where the header's UTF-16 column and
the screen column differ. The text comes from tsv_lang's ParseError and is the same
on both bins and in every package — where the {kind: 'parse'} error the facade returns
carries it split in two, the summary as message and the header, excerpt and caret lines as
frame (so `${message}\n${frame}` is the text), beside a start / end / loc span
over the same point (crates/tsv_wasm/npm/api.js).
Two errors are not parse errors, and a single input prints them after Error: rather than
Parse error: (a path-mode format names the file either way): a source over the 4 GiB
size cap the parsers' u32 offsets index, and a format refusal. Neither is a syntax error
(ParseError::is_syntax_error), which is how the native bin tells them from a parse error
(cli::out::exit_with_parse_error); cli.js tells them by the facade: the size cap throws,
and the refusal is a returned error of kind 'refused' where a parse failure's is 'parse'.
The refusal: a Svelte document holding a lone CR (not part of a CRLF) with comment text after
it inside a // comment between a tag's attributes. Svelte ends that comment at LF alone, so
the CR is comment text, and the format path's CR fold would end the comment there and print
the rest of its line as markup. (A CR with only whitespace after it, as in a doubled
CR CR LF ending, ends the comment where the fold does, and formats.) The message names the
CR's line:col; parse accepts the same bytes. It is a per-file error (exit 2) like any
other, and in the packages a returned {kind: 'refused', message, frame, start, end, loc}
error spanning the CR rather than a kind: 'parse' one (tsv_svelte::parse_folded). A
document whose own bytes do not parse reports that parse error first, exactly as parse does.
tsv strip runs the type strip (strip.md) on one input — a file path,
--content, or --stdin — and prints the JS to stdout byte for byte: no newline appended,
every line terminator, a leading BOM and a hashbang kept. The file is never written. It
takes no --parser (the strip is TypeScript's), and a file path's extension must be one of
.ts .mts .cts (a .svelte.ts rune module and a .d.ts are .ts) — anything else is
an argument error naming that list (tsv_discover::strip_unsupported_extension_error), as is
a directory. A JavaScript path (.js .mjs .cjs) is refused with a reason of its own
(a JavaScript file has no types to strip) rather than read as TypeScript, where
f(a < b, c > (d)) is a generic call whose type arguments the strip would blank and
JavaScript reads two comparisons. The goal is format's
(strip.md §The parse goal): unset, a module retried as a
script; --source-type script|module names it exactly for --content / --stdin (a value
naming neither is a usage error), and beside a path it is refused in format's words, since
a path's goal is its extension's — a .mts a module with no retry, a .ts / .cts the
fallback.
--content and --stdin follow parse's precedence between themselves, but either beside
a path is refused (--content/--stdin cannot be combined with a file path), as format
refuses it. Every argument is graded before anything is read, so a refused --stdin never
waits on its writer.
Exit codes — the format scheme, with "would change" read as "refused":
- 0 — stripped (printed, or with
--checknothing printed); - 1 — refused: the source holds TypeScript the strip cannot erase. stderr is
Error: cannot strip <the construct>over theline:colheader, line and caret of the refused span's start, in the same coordinates a parse error prints; - 2 — a parse error (
Parse error: …, the texttsv format --contentprints for the same source and goal — with none named, the error of the reading it belongs to, per §Multi-File Formatting), a read error, invalid UTF-8, a source over the size cap, or a usage error.
--check prints nothing on stdout and keeps the same codes (a refusal still exits 1 and
says why on stderr), so CI can ask whether a file strips. argh's own flag-parsing refusals
exit 1, as for every command; stderr tells them apart. One input per run: several paths
are argh's Unrecognized argument. The JS mirror (npm/cli.js) answers every row alike,
byte for byte (scripts/strip_suite.ts, held to the native binary by
scripts/test_napi_npm.ts).
Implemented in tsv_cli/src/cli/commands/strip.rs
The parser, the wire-JSON writers, the printers and the type strip all recurse over the
tree, and a stack overflow is not a catchable panic: no catch_unwind and no panic
contract can turn it into a per-file error. Natively it aborts the process (exit 134 on
Unix for the CLI; a bare SIGSEGV with no message for a Node or Deno process holding the
N-API addon or the FFI library, since Rust's guard-page handler is installed by its
runtime startup, which a library loaded into a host never runs), and a directory format
that dies that way has already rewritten files it never got to report. In WASM it traps,
and the trap strands the instance (below). So the parsers bound the depth of the tree
they build, and refuse a deeper input with a syntax error before any walk sees it.
Two counts, both in tsv_lang (NESTING_LIMIT, SPINE_LIMIT), because no single
counter bounds both shapes a walk recurses through:
- Nesting, at most 256 wire levels. The parsers' own recursion, counted where the
grammar recurses: each step charges at least the wire-JSON nodes it puts on the path
from the root — one for a paren, an array, a call's arguments, a computed member, a
template's
${(a tagged template's quasi one more), an operator's operand, an assignment's right side, a conditional's branch, a unary operand, anew's callee, a spread, a statement, a class expression, a parameter (a type-member signature's is paid for by its list's level, the signature's own being the member's), a parameter property's binding (its decorators sit on that binding, a level below a plain parameter's), a rest parameter's pattern, a shorthand property's default ({ a = d }, theAssignmentPatterna pattern reads it as), a declarator, aforhead's declaration, acatchclause, an enum member, a decorator, a declaration under itsexport, a tuple element (a named one's type one more), a conditional type's extends type and branches, an arrow's concise body; two for an object literal (the object and itsProperty), a type argument (theTSTypeParameterInstantiationand the argument), a function type's parameter (the function type and the parameter), an index signature's parameter type (the parameter and itsTSTypeAnnotation), a type predicate's type, a mapped type's constraint, a template literal type's span, a{@const}'s binding and init (theVariableDeclarationand itsVariableDeclarator), a Svelte element or block (the node and itsFragment), a CSS rule or at-rule (the node and itsBlock); three for a function expression's or an arrow function's block body, whatever the arrow's head. An arrow's bare parameter (a => …) is charged as the format writes it, in a pair: the parameter and the pair, a step that returns before the body. A step can charge more than its nodes — a list's own level where its first element's node already counts one, a parameter list's level beside a declaration's — but never fewer:tests/depth_limit.rschecks, for every shape it generates, that no path through the accepted input's wire nests past the limit, and for the calibrated shapes that it sits at it. An assignment over a target other than a bare name (a[k] = x,({ [k]: a } = x)) is one more level, found only once its=is read, when the target is already parsed: it is charged then, as a printer's pair is (below) — framed, added up within its frame, and checked against the deepest nesting the frame has reached: the target's, and the right side's too, which the level does not sit above, so a right-nested chain of them (a.b = c.d = …,f(a[k] = f(…))) is accepted well short of its wire's depth. An optional chain'sChainExpressionis the one wire node no walk recurses through (the wire writer emits it inline, and no other walk's tree holds it), so it is charged nothing. A CSS declaration value is a string on the wire but a tree to the printer, so its functions and parenthesized groups are counted too, one level each, as the value parser enters them — which counts what every walk of the value recurses through, an unquotedurl('s interior parens included, though the lexer reads that url-token whole. - Spine, at most 6,000 units. The chains the parsers build iteratively, without
recursing — a binary or logical operator chain (
a + b + cnests to the left), a member / call / non-null / tagged-template / instantiation /as/satisfieschain, a type's[]suffixes, a qualified name — and the other nodes that wrap an operand parsed before the parser knew it was one: a conditional's test, a sequence's first expression, a conditional type's check type, a tuple element's?. Each repetition of one of those is a level no nesting count can see coming (((a ? b : c) ? d : e)nests a conditional per paren), so it is counted as a link. Their parse costs no stack, and neither do the walks that iterate a spine rather than recurse down it — every walk over an operator chain (the wire writers, the printers' chain collectors, the type strip), the wire writers over a member, call and non-null chain, the printer's questions about one and its member-chain flattening of one past its first few links, and the type strip over any expression chain (member, call, non-null, tagged-template, instantiation,as/satisfies) past its first few links — but every other walk recurses down the spine, so a flat chain needs a bound of its own; a nesting count never sees one. A link counts the units its walks spend stack for: one for an operator, a qualified name's., a member access, a call or an instantiation; two for a non-null assertion; eight for an assertion; six for a type's[]or[K], a conditional type's check type or a tuple element's?; fourteen for a tagged template; and ten for a conditional's test, a sequence's first expression, and a link whose walk re-enters the expression printer for its operand rather than continuing the chain it is printing. Which links re-enter is the printer's own answer, asked of the same predicates the printer decides by, so the price follows the walk: an operator whose left operand the binary-chain printer does not flatten into its chain (a == b == c,a * b / c,a % b % c,a << b << c,a && b || c, everyinin aforheader's init, an operator over anas/satisfies); a call on a call, through any run of!and instantiations (f()(),f()!!(),f()<T>()); a link over a tagged template, which the member-chain printer takes for its chain's base; and a member or!over a call the printer prints whole — a curried call on a member chain, or a call whose sole argument is a multi-line template — and that call itself. The fixed units are set by the build that spends the most on the link, over every build the contract covers, and graded by the gate below, which generates every pair of link kinds and a seeded sample of triples, rather than trusting a list. The re-entry weight is the WASM engine's: its shadow stack, once V8 has tiered the walks up, needs the most, and the weight keeps about a quarter over the smallest one at which every leg holds its margin. A run of!, each with a block comment in its gap (a!/*c*/!/*c*/), is printed iteratively, so its links stay plain non-null assertions.
The spine count bounds every root-to-leaf path, not one chain. A path can pass
several chains (a chain whose leaf holds another, (a + … + a) + a + …), and the leaf is
parsed before the chain over it is known, so the count runs over frames: within a frame
every link adds up, and a closed frame hands its count up as the greater of it and its
siblings'. Frames open where siblings can run long — the statements of a list, the
operands of an operator chain, and the elements of every other list (array and object
elements, call arguments, sequences, declarators, class and type members, union and
intersection constituents, switch cases, enum members, parameters, type parameters and
type arguments, tuple elements, function-type parameters, heritage lists, and a template
literal's interpolations) — once the count has run past a few dozen units or a list past
eight elements, so short code pays nothing for them. The count is exact for
a lone chain and loose by at most those few dozen units elsewhere: a long list of short
chains (a data table, a minified file) is charged one element's chains, never their total. Nesting is charged against the spine too, at
15 units a level: each limit alone fits the stack, but a chain at its limit whose leaf is
nested to its limit costs both walks' stack at once, which neither limit sees. A link is
checked with the deepest nesting its frame has reached charged on top (at least 32 levels,
below which the count is not tracked), so a flat chain at shallow nesting gets 5,520 units.
The deepest nesting is framed as the count is — per statement, and per element of a long
list — so a chain is charged the nesting of its own paths, never a sibling's, and whether
a document parses does not depend on the order of its statements.
The count runs document-wide. A Svelte template expression, a <script> or a
<style> starts its own count at the depth of the markup around it (the embedded entry
points take that depth, host_nesting), so an expression deep in the markup has what the
markup left of the 256 levels, not all of them. A <script> or <style> nested inside the markup is raw text to the parser and
is parsed by the printer, from the depth of the deepest one; a body past the budget keeps
its author's bytes.
The pairs the printer adds are charged as nesting. A format writes grouping parens,
and argument lists, the source does not hold — (a == b) == c, -(-a), f((a = b)),
new A(), keyof (keyof T) — and in the output each one is a level of nesting, which the
output's own parse counts. So the parse charges every such pair as it reads the input, at
the node the printer writes it around, asked of the printer's own predicates where it has
one:
- an operand's position: a binary's operand (the clarity pairs, the non-flattening
chains, a function dividend), an
as/satisfiesoperand (asyncincluded), a unary, prefix update,awaitor angle-bracket cast's operand, a spread's argument, a numeric literal a member access reads ((1).a), a function expression a chain calls or tags, an instantiation instantiated again ((f<T>)<U>), a class heritage, anewcallee, a conditional's test and branches ((a ?? b) ? (c as T) : d, a consequent that is itself a conditional), an arrow's body (a conditional, a decorated class), and a decorator's expression where the bare decorator grammar cannot spell it (@a!(…)prints@(a!(…)); asked of the printer's own predicate, and counted only where the author's pair does not wrap the whole expression — a pair around a callee alone,@(a!)(…), holds less than the printer's); - a node that takes a pair wherever it stands: an assignment used as a value
(everywhere but a statement's expression, a
forheader's own clause and another assignment's right side — a right side that becomes a destructuring default once the=after its pattern is read takes one again,[a = (b = c)] = x), a sequence (everywhere but aforheader's init and update), and everyinbinary in aforheader's init; - a shell or list the printer writes: an angle-bracket cast's break shell
(
<T>(⏎ x⏎)), a unary's comment holder (!(/* c */ x)), areturn/throwargument that is a binary chain (return (⏎ a &&⏎ b⏎)), an argument-lessnew's argument list, a bare arrow parameter's pair (a => …prints(a) => …), aletheading afor-in head; - types: a type operator over a type operator or an
infer, a union or intersection member that binds looser than its neighbours ((A & B) | C), an array element or indexed access's object ((typeof a)[],(-1)[]), an optional tuple element ([(A | B)?]), a union element of a tuple, a conditional true branch, a conditional type-parameter constraint, and an arrow's function return type.
The count at a node is the pairs the printer may write there, less the pairs the author
wrote: what the printer writes is a function of the tree and its comments, which a format
keeps, so on the output — whose pairs are the ones the printer wrote — the count is none,
and the reparse counts no more than the parse did. A pair is counted in the frame of its
node, against the nesting limit with the frame's deepest nesting under it, and against the
spine limit with the frame's links beside it. The frames open where they would for the
output, since no frame decision reads the pairs, so the output of an accepted input
parses again. tests/depth_limit.rs grades that — with the output being its own format —
for every shape it generates, a prefix-operator alphabet among them that nests every
prefix operator over every other, bare and commented, so a site the parse does not charge
fails the gate. The cost is a lower limit wherever the printer parenthesizes link by link:
an ==, % or << chain ends below the nesting limit's 256 operators — the 32 levels
every chain is charged come off it first — rather than near the spine limit, and a run of
- - - or of <T> casts at a half or a third of the 256 levels.
The refusal is an ordinary syntax error at the token where the count tripped (Nesting exceeds tsv's depth limit of 256 levels, Chain of operators, member accesses, calls, assertions or type suffixes exceeds tsv's length limit — no number, since the spine limit
counts weighted units with the nesting charged on top, which no count of links names; a
CSS value's at the ( that passes the limit): parse and format report it like any other,
and ParseError::is_too_deep tells it apart. A speculative parse that meets it hands it
back rather than trying the other reading, since that reading would be chosen for the
stack's sake, not the grammar's — and for the same reason a format's module goal refused by
it is not retried as a script, which could only succeed by reading await as a name. It is a deliberate over-rejection — every canonical
parser accepts deeper input, up to its own stack
(conformance_tsc.md §The reject-vs-defer line,
conformance_svelte.md §Depth Limits) — set above
every input the conformance gates parse, the decision both limits rest on: the deepest
nesting is tsc's parsingDeepParenthensizedExpression.ts at 137 counted levels, the
longest spine tsc's two binderBinaryExpressionStress files, whose 4,953 operators come
to 5,440 units with the nesting charge.
Every walk of an accepted tree fits a 2 MiB thread in an optimized build, and a 16 MiB
thread in a debug one. tests/depth_limit.rs holds the contract: for every shape the
limits count — each TypeScript expression, statement and type shape, the flat chains, CSS
rules, at-rules, selectors and value parens, Svelte elements, blocks and islands, the two
counts at once, a catalog of grammar positions (positions that hold an
expression, a statement, a type, a pattern, a member or a selector which can hold the
position again, each nested in itself — a position the catalog lacks is one the gate cannot see), and a generated sweep of every ordered pair, and a seeded sample of
triples, over the whole link alphabet (every chain link kind and every binary operator)
behind each of a set of heads, with the same links again with comments in their gaps; the prefix
alphabet: every prefix operator over every other, bare and with a comment in its gap, over
operands ending in each link and as each binary operator's operand, and over an authored
pair in each commented form; and the decorators: a class nested in a decorator's argument
on a class expression, a member and a parameter, behind every callee shape and inside
every payload (a spread, an assertion, a !, a member) — it generates
the deepest accepted input, checks that no path through its wire passes either limit (the
count charges at least what the walks recurse through, so a construct the parse leaves
uncounted fails here, whatever its stack), runs parse, both wire writers (span-only and
loc), format and the type strip on a thread nine tenths of the contract's size, checks
that the format's output parses again and is its own format, and that one level more is
refused. An overflow
aborts the test binary. The tenth kept in hand is the minimum margin the gate enforces: a frame that grows
fails the gate while every accepted input still fits the contract. Frame sizes move with
every printer change, and differ between optimizations by more than that margin, so the
gate runs in each world it speaks for: deno task test:depth:napi in the shipped
optimization (the napi profile, gating every release in publish.ts and every push in
CI's artifacts job) over the whole sweep, deno task test:depth in deno task check's
corpus world over a smaller one — the pairs behind two of the heads, a smaller triple
sample, every commented link, the bare prefix pairs, a sample of the rest of the prefix
alphabet and of the decorators — and a plain cargo test against the debug contract, over the
named shapes and a fixed stride of that smaller sweep. deno task test:depth:wasm runs the
whole sweep's shapes at the same depths through the WASM package on a Node main thread
(below), in CI's artifacts job and publish's artifact validation.
A consumer's debug tests sit below the debug contract. Rust's test harness runs each
test on a 2 MiB thread, so cargo test over code that hands tsv its deepest accepted
input can overflow in a debug build. Raise the test threads' stack to the debug contract
with RUST_MIN_STACK=16777216, or build tsv optimized in the dev profile
([profile.dev.package.tsv_ts] opt-level = 2, and the same for tsv_lang, tsv_css
and tsv_svelte), which keeps a dev build's checks and brings its frames within the
optimized contract's 2 MiB — the harness's own default.
main runs the whole subcommand on a thread with STACK_SIZE reserved (32 MiB,
cli/stack.rs), and the format workers reserve the same, so the stack is a property of
tsv rather than of the route, the host and the platform: inherited, the main thread's
stack is the process RLIMIT_STACK on Unix — commonly 8 MiB, but whatever the machine
says — and 1 MiB on Windows, where the linker writes it into the executable header,
below the contract; a spawned thread takes Rust's 2 MiB. The reservation also keeps a
debug build of the CLI above its contract, and it names the threads (tsv,
tsv-format), because the runtime's thread '<name>' has overflowed its stack line is
the one diagnostic an overflow leaves. A reservation is address space, not memory: pages
commit lazily.
The wire has no reader ceiling of its own. tsv parse --pretty re-indents the compact
wire bytes in one linear pass (json_utils::indent_json_with_tabs) rather than reading
them back into a tree, so it reads whatever the parser accepts; fixture
typescript/expressions/objects/nested_deep
pins the pipeline past serde_json's default recursion limit of 128 JSON levels. The
one reader of the wire left is tsv_debug's json module, which reads with that limit
disabled on the same reservation.
The bindings run on their host's thread, so the host's stack decides whether the contract holds there:
| surface | stack | at the limits |
|---|---|---|
tsv (this CLI), every route |
STACK_SIZE, explicit |
holds |
| N-API addon on the host's main thread | the host process's RLIMIT_STACK |
holds at 2 MiB and up |
N-API addon on a worker_threads worker |
Node's 4 MiB stackSizeMb default |
holds |
| FFI library | the calling thread (Deno's main thread: the process stack) | the release contract: holds at 2 MiB and up |
| WASM on Node, Deno or Bun, main thread | the 1 MiB shadow stack, and the engine's own | holds, cold and tiered up |
| WASM on a Node, Deno or Bun worker | as above, on the worker's stack | holds |
Every row was measured with every shape tests/depth_limit.rs generates, at its deepest
accepted depth, through the packages' API (parse with and without locations, the _json
string, format, strip); the WASM main-thread row on Node is the standing gate,
deno task test:depth:wasm, which runs V8's stack at nine tenths of its default, the
native gate's margin. A WASM build keeps its spilled frames on a 1 MiB shadow stack in
linear memory (wasm-ld's default) and its call frames on the engine's native stack, about
1 MiB on a V8 main thread — and an engine's tier decides those frames: V8's optimizing
compiler lays a recursive walk's frames out larger than its baseline compiler does, so a
call that fits a fresh process can overflow the same process once its code has tiered up.
The gate grades both, each pass in fresh processes: on the baseline compiler alone, and
after a warm-up pass has tiered up every function the run makes hot — under V8's default
dynamic tiering, which optimizes what runs hot; an engine flagged to compile everything
optimized up front (--no-liftoff) lays out larger frames still, and is outside the
contract. That is also why the
walks over a binary operator chain, the wire writers over a member, call and non-null
chain, and the type strip over any expression chain past its first few links, iterate the
spine rather than recurse down it: a frame per link of a chain thousands of links long
spends the optimized engine's main-thread stack whatever the per-link frame, where an
iteration spends none.
The packages' {locations: true} reconstruction (locations.js) walks the tree with an
explicit stack rather than recursion, so it adds no depth of its own.
Below those stacks, a WASM overflow is a trap the process survives but the instance does
not — the shadow stack pointer stays where the deep call left it, poisoning every later
call. The npm packages ship a reinstantiate() recovery hook, and the JS CLI (cli.js)
calls it on any trap in format_one — and on the RangeError an engine raises when a deep
call exhausts its native stack first, which strands the instance too — so a file that meets
either is one per-file error (… (WASM engine trapped and was reinstantiated)) and the
rest of the run formats normally, on the sequential path and in every pool worker alike. See
tsv_wasm/CLAUDE.md §Panic Reporting.
cli.js gives every pool worker the native CLI's STACK_SIZE (resourceLimits.stackSizeMb,
restated as WORKER_STACK_SIZE_MB) where the engine honors it, and re-runs a file whose
main-thread format hit a RangeError in a one-worker pool (retry_overflowed_files). On
the N-API engine an overflow is a process-fatal SIGSEGV rather than a RangeError, so
cli.js never formats a path on the N-API main thread: a --jobs 1 run there is a pool of
one worker (resolve_route); its single-input mode (--content/--stdin, parse) runs
on the main thread, under the table's main-thread row.
A walk's stack per level is its frames', and a frame is sized once for its widest arm:
every level pays all of it whichever arm it takes, so a dispatcher that holds one by-value
AST node per arm multiplies that node's size by its arm count, at every level. That is why
no parse_* on the expression cycle hands its caller a bare Expression by value, and no
parse_* on the type ladder a bare TSType: a node builder either boxes into the arena at
its own tail (alloc_expr for expressions, Parser::alloc for types, leaving the caller
an 8-byte reference) or returns its own concrete node struct, whose wrapping temporary the
compiler merges with its sibling arms'. The printer answers the same pressure the same
way — the chain entry points fill a caller-owned ChainNodeVec rather than returning one.
The node enums answer it by density: a variant wide enough to set its enum's size on its
own, and rare enough that an arena allocation apiece is free, holds its payload by
reference (Expression's five widest variants, TSType's three, Statement's rare
declaration heads), and a by-value field that only copies a node out of the arena
borrows it instead (Property, VariableDeclarator, every Expression-holding statement
head). The size_of asserts beside the AST pin those widths, so a variant that widens an
enum fails the build instead of quietly eating the limits' margin; the margin itself is
graded by tests/depth_limit.rs.
tsv format accepts any mix of files and directories:
-
Discovery: directories recurse over the JS/TS family (
.ts/.mts/.cts/.js/.mjs/.cjs, all parsed as TypeScript —.jsx/.tsxare out of scope),.svelte, and.css(compound forms like.svelte.tsincluded), each extension read ASCII-case-insensitively (A.TS,styles.CSS), as prettier infers a parser from the lowercased name — a walk, a named path, the parser dispatch and the.mjs/.mtsgoal all read it the same way, on both bins.The safety nets
.git,node_modules,.sl,.hg,.svn,.jjare always pruned. A path an argument names is bounded by the ignore files alone. The safety nets and the build-output heuristic prune what a walk discovers — they are tsv's guesses about a tree, and a guess never overrides a path someone typed — so they grade neither a named path nor its ancestors:tsv format node_modules/pkg,dist/subor.cache/xwalks what naming its parent walks there, and below the named root they classify every child as usual. An ignore rule, which the user or their repo wrote, bounds a named path — file or directory — exactly as it bounds the walk that would have reached it, through an ancestor or at the path itself: namingpkg/distdoesn't override adist/rule, nor namingvendor/x.tsavendor/one, the line prettier, ESLint, oxfmt and deno fmt draw too. Such a path is skipped.A named file a
.formatignoreor.prettierignorerule excludes is skipped quietly, as prettier skips it, whether or not a.gitignoreexcludes it too — at the file or an ancestor, and even where that.gitignorerule sits above the tsv rule and bounds the path on its own, the case the directory warning below names both blockers for: those files exist to say what not to format, the rule is the author's verdict on the file however the path is bounded, and a pre-commit hook handing over its staged files names such a file on every commit that touches one (ESLint, which warns there, ships--no-warn-ignoredfor exactly that noise; tsv has no flags to offer).Every other exclusion prints a stderr warning naming the file the excluding rule sits in and how to undo it — a
.gitignorerule is about version control, so one excluding a named file is a surprise, and a named directory is a scope someone typed. A path only a.gitignoreexcludes gets the lines that re-include it and nothing beside it, for the repo root's own tsv file (read after every.gitignore, so its!wins):!/src/a.gen.tsfor a path the rule matched itself, and for one under an excluded directory that directory re-included, its contents excluded again, and so on one level at a time down to the path (!/build/,/build/*,!/build/a.ts) — every line anchored with a leading/, without which a one-segment pattern matches at every depth, and spelling its path literally (each*,?,[,],\and trailing space escaped), so a[slug]directory names itself rather than a character class. A path holding a control character other than a tab — a line feed, which splits any line; a carriage return at a file name's end, which a line's end drops; or any other, which an ignore file could hold only raw and the warning will not print — gets no lines, and the warning says to narrow the rule instead. The file named is the one the root reads: its.prettierignorewhere it has no.formatignore, since a.formatignorecreated beside it would shadow every rule in it.For a named directory, the rule named is the shallowest exclusion's — a
.formatignore/.prettierignorerule over a.gitignorerule at the same prefix, the.gitignorerule where it excludes an ancestor above the tsv rule's — and the remedy turns on what else excludes the path. Only tsv rules: the rule is the user's own to narrow or negate. A.gitignorerule anywhere at or below the named one: narrowing a tsv rule would leave it standing, and every!under an excluded directory is inert, so the warning gives the lines that re-include the path level by level from the shallowest exclusion down, for the repo-root tsv file — "after that rule" when the named rule is a root tsv rule (a later line wins there), "which also override" a root tsv rule below a named.gitignoreone — while a tsv rule in a deeper file, read after the root's lines, stays the user's to narrow and is named as the second blocker: one warning stating both, not one per rerun — wherever that deeper file was read. No ignore file inside a directory a rule excludes is read for a named path, as the walk that prunes the directory reads none — so nothing in one is warned about, and a rule in one is named, and can exclude the path, only once the directory is re-included and the path named again.A run whose every argument was an excluded file exits 0 rather than failing with
No files to format— what a pre-commit hook hands over when only ignored files are staged — while a run left empty by an excluded directory is still that error, so a mis-scoped command fails loudly.File arguments share one ignore scope, moved from each argument's directory to the next's (popping back to their common ancestor and pushing down), so the ignore files above many named files are read and parsed once rather than once per directory holding one.
A file arg is held to the extension check first — the parser dispatch behind a path has no unknown arm (everything that isn't
.svelteor.cssgoes to the TypeScript parser), so a named.json/.md/extensionless file would be parsed as TypeScript: usually a baffling syntax error, and occasionally a successful rewrite of a file tsv doesn't support (a top-level-array.jsonreprints as a TS expression statement, semicolon and all, which is no longer valid JSON). Naming one is an argument error instead — reported alongside the unresolvable-path errors, failing the run upfront with nothing written, the same line prettier draws with "No parser could be inferred". A directory arg is a scope rather than a target, so the check doesn't apply to it: unsupported files inside are filtered out by the walk. A shell glob (tsv format *) names such directories as well: a safety-net or heuristic one (node_modules,dist) it walks, and one an ignore file excludes it skips, with the directory warning.Symlinks inside directories are not followed; pass them explicitly — and a passed one is graded where it was typed: a path argument that is itself a symbolic link is bounded by the ignore files at its own path (its parent canonicalized, its name kept, the repo root found from there), as
git check-ignoreand prettier read it, not where it points — a.gitignorenaminglink.tsexcludes it, a link into a gitignoredbuild/is not underbuild/, and a linked directory root is bounded by the rules of the repo it sits in even when it points outside that repo; only the overlap dedup below reads the link's target. And it is graded as the link it is, whatever it points at, as git grades it: a directory-only rule (foo/) does not match a symlinked directory argument (git check-ignore foosays not ignored), the link is walked as a directory once it is in scope, a warning's re-include names it as a file (!/foo, since a!/foo/reaches no link), and one a rule does exclude counts as an excluded file argument — a run naming only such links exits 0.Hard links are not detected either: two names for one inode are two files in scope, each read and formatted by its own name — a
--checklists both, and a format rewrites the inode through whichever name it reaches first and finds the other already formatted, so it reports one name or both (the same bytes either way; under a parallel run the second name can also read the first's write in flight, the exposure any process writing a file while tsv reads it has). -
Ignore files (two regimes, keyed on
.git): for each directory root, the format root — the scope boundary, derived from the argument, never the cwd — is the repo root inside a git tree (a hard stop where the upward walk ends, so nothing above the repo is read and--checkis reproducible) or the filesystem root outside one. The regime is decided once at the target root, and any ignored directory is pruned (its whole subtree is skipped).-
Inside a repo, discovery honors, relative to the repo root:
.gitignore— hierarchical and repo-rooted exactly like git (gitignore syntax, matched againstgit check-ignoreon case-sensitive filesystems). This goes beyond Prettier, which reads only one.gitignoreand one.prettierignore, both relative to its own directory (the cwd by default), and ignores nested ones entirely..formatignore— hierarchical (one per directory from the repo root down, deeper wins), applied after.gitignoreso its!can re-include a gitignore'd path (subject to git's parent-directory rule)..prettierignore— drop-in compat, honored hierarchically the same way, read as the tsv-layer fallback in any directory with no.formatignoreof its own; a sibling.formatignoreshadows it per-directory (used alone when present, even if that.formatignoreis present-but-unreadable — a read error can't silently demote tsv's native file to prettier's). Like the hierarchical.gitignoreabove, this goes beyond Prettier's single cwd-relative.prettierignore— so a monorepo that runsprettierper-package (each package with its own.prettierignore) is honored from one repo-root tsv invocation. Because the shadow silently drops the sibling.prettierignore's rules for that directory (Prettier applies both files), tsv emits a non-fatal stderr warning wherever a.formatignoreshadows a.prettierignore, pointing at merging the patterns into.formatignore. Compat caveat: as a tsv layer a.prettierignore!can re-include a path.gitignoreexcluded (subject to git's parent-directory rule), whereas Prettier treats.gitignoreand.prettierignoreas independent sources OR'd together, where a.prettierignore!can't rescue a gitignore'd file — tsv's model is the more powerful superset, and the divergence only surfaces for a.prettierignore!targeting a gitignore'd path (rare).
-
Outside a repo,
.gitignoreand.prettierignoreare not read (as git itself does); only.formatignoregoverns, hierarchically from the filesystem root down — so a~/.formatignoreis global config for loose files. A.prettierignorein the target root (the directory tsv was pointed at, where prettier would have read it) raises a non-fatal stderr warning — rename it to.formatignore, orgit init— without changing what gets formatted. The warning is bounded to the target root: outside a repo tsv's regime is.formatignore-only at every depth, so this is one courtesy heads-up at the entry point (not a per-directory scan), and an ancestor of a subdirectory target has no repo boundary to anchor on. -
Heuristic fallback: a
.gitignorein scope is authoritative and turns the heuristic off; with no.gitignore, the heuristic — hidden directories plusdist/build/target— is the fallback "not source" guess, except that an explicit tsv-layer!re-include overrides it. -
Re-include idiom: to selectively re-include under a pruned (or otherwise ignored) directory, re-include the directory itself first —
!/dist/admits the whole directory, then/dist/*+!/dist/keep.tsnarrows it back to just the files you want. The leading/anchors each line to the directory of the ignore file holding it; without it a one-segment!dist/re-includes adistat every depth. A bare!dist/keep.ts(without the directory re-include) is a no-op — the heuristic prunesdistbefore descending, mirroring git's parent-directory rule, and a gitignored (or tsv-excluded)dist/blocks a later!dist/keep.tsthe same way.tsv emits a stderr warning in either case (non-fatal — no effect on the exit code, stdout, or
--list/--checkoutput), once per pruned directory, saying what pruned it (the heuristic, or the rule and its file), naming the file the re-include was written in, and spelling the lines that reach what the rule named, for that file — anchored and relative to its directory, since a line spelled from the repo root does nothing in a nested file, and outside a repo, where the format root is the filesystem root, in any file:!/dist/,/dist/*,!/dist/keep.tsfor!dist/keep.ts, with!/dist/sub/,/dist/sub/*between for a nested!dist/sub/keep.ts(every directory down to the target has to be opened, and a/dist/*alone would closedist/subagain), the last lines being every re-include written under the directory, each the author's own pattern anchored (a/dist/*line would silence any it did not re-spell after it) — so a glob (!dist/*.ts) keeps its glob, and a rule reaching below the directory (!dist/**/keep.ts) opens it with/dist/**+!/dist/**/in place of/dist/*, git's idiom for every directory and no file. A tsv layer is read after every.gitignoreand a later line wins within a file, so the lines override the excluding rule wherever it sits, except a tsv rule in a file deeper than the re-include's, which is read after it and which the warning says to narrow or negate instead — beside the lines that pass a.gitignorerule standing behind it, which narrowing alone would leave in force. -
Subdirectory invocation: because the boundary is found by walking up, the repo-root rules apply even from a subdirectory, and a subdirectory named directly is bounded by the same ignore rules as when it is reached via an ancestor — only the safety nets and the heuristic, which grade no named path, can tell the two apart. But a tree that contains repos (a non-repo directory with
.gitsubdirectories below it) does not honor the inner repos'.gitignores — run tsv per repo. -
Unreadable ignore files: a
.gitignore/.formatignore/.prettierignorethat is present but can't be read (invalid UTF-8 — read strictly, as in the invalid-UTF-8 bullet below — or a permission error) is not silently treated as absent: tsv emits a non-fatal stderr warning and drops that file's rules (so an unreadable.gitignorealso leaves the build-output heuristic on for its subtree). A file that genuinely isn't there, or is deleted between the directory listing and the read, stays silent. This is also a--checkreproducibility hazard — surfacing it is the point. Present means a regular file, reached through a symlink when the name is one; a directory of that name is not an ignore file and stays silent — the same rule in a walked directory and a preloaded ancestor. The one exception is.gitignore, which git never reads through a symbolic link in a working tree (gitignore(5)): a symlinked.gitignoreis not applied — so the build-output heuristic stays on for its subtree, as for an unreadable one — and warns, by the same rule in both walks..formatignoreand.prettierignorekeep reading through links, as prettier does. -
--checkreproducibility assumes the ignore files are committed: a local/uncommitted.formatignoreor.prettierignore(or git's unread.git/info/exclude/core.excludesFile) makes a clean CI checkout disagree. -
Shared by construction: the matcher is the
tsv_ignorecrate'sIgnoreStack; the per-directory prune/descend policy (heuristic, safety nets, the shadow warning) is thetsv_discovercrate's verdict. The WASM CLI, the native npm package, and editors call into the same two crates, so every surface agrees rather than hand-mirroring the logic. Seecli/discover.rs.
-
-
Piped output — a closed consumer is not a failure.
tsv format . | headfills the 64 KiB pipe buffer on any tree whose changed-path report exceeds it, soheadhas exited by the time the rest is written. Both bins stop writing and finish the run on their own terms: the exit code still reports the work (0 clean, 1--checkwould-change, 2 errors) and the stderr summary still prints when stderr is not the closed fd, sotsv format . | headstays informative. The rule covers both fds —2>&1 | headcloses the same pipe for both, and a stdout-only rule would just move the failure one line down, onto the summary.Why not the two alternatives, since this is a shipped exit-code contract: 141 (what a tool killed by
SIGPIPEreports) and restoringSIGPIPEtoSIG_DFLboth replace the 0/1/2 verdict with "the reader left", and for--checkthe exit code is the API. Exiting 0 is also the honest answer forformat, whose stdout is a report of files already rewritten rather than the product: a reader that left does not un-format them. And only this answer is one both bins can give identically — Node ignoresSIGPIPEtoo, socli.jscould never die by the signal, only fake a code where the native side died by one. Any other write error still aborts loudly on both: a report truncated by a full disk, with nothing said about it, is worse than a crash.The mechanism differs because the two runtimes fail differently. Native: Rust sets
SIGPIPEtoSIG_IGNat startup, so the write returnsEPIPEandprintln!/eprintln!panic on it — every byte therefore goes throughcli/out.rs(write_stdout/write_stderr, and theout_line!/err_line!macros over them), which absorbsBrokenPipe, waits outWouldBlock(a backoff capped at a millisecond, partial writes honored), and panics on anything else.WouldBlockis reachable because the fd's blocking-ness belongs to the open file description a child shares with its parent: a Node parent that opens its own pipedprocess.stdoutafter spawningtsvasynchronously (a task runner logging beside it) flips that description to non-blocking under the running child — libuv resets fds 0–2 to blocking at the spawn itself, so the@fuzdev/tsvloader, waiting inspawnSync, never does.parserides the same writer, so a closed reader there does not report1, its parse-error code.cli.jswrites both fds synchronously (an asyncprocess.stdout.writebeforeprocess.exittruncates), which is only safe while the fd stays blocking — and it takes that away from itself: spawning the worker pool pipes the workers' stdio through the parent, which flips fd 1 to non-blocking. Itswrite_fdtherefore loops overwriteSync, honors partial writes, sleeps 1 ms and retries onEAGAIN, goes quiet onEPIPE, and on any other error printsfailed printing to stdout: …and exits with the shipped native binary's abort status (134) — never a verdict code, since an uncaught throw's 1 reads as--check's would-change. The two bins reachEAGAINby different roads —cli.jsflips its own fd, the native CLI inherits a flipped one — and answer it alike.The read side takes the same rule: a parent that opens its own piped
process.stdinflips fd 0 the same way, and--stdinon either bin (Input::from_stdin,cli.js'sread_fd_to_end) waits outEAGAINthere too rather than reporting a slow writer as a read error the moment the pipe is momentarily empty.A consumer that is merely slow gets every line on both bins:
EPIPEends the output,EAGAINis waited out, and any other write error is a failure. Pinned bytests/cli_tests/(*_closed_pipe_*,*_slow_pipe_*,*_non_blocking_stdout_*) andscripts/test_npm.ts's twin rows. -
A non-UTF-8 file NAME: the native walk joins each entry's raw bytes, so a file or directory whose name is not UTF-8 is discovered, formatted, and listed by its own bytes on unix (the changed-path report and
--listprint paths as bytes there,cli::out::path_bytes; elsewhere the U+FFFD spelling is all there is). A stderr diagnostic spells such a name with U+FFFD on every platform (cli::out::path_text), deliberately: a diagnostic is prose for a reader and a script reads stdout, and a byte-faithful stderr would put raw non-UTF-8 into the one channel every consumer reads as text.Two limits: such a name reaches the matcher in its lossy spelling, so a
?or class counts one U+FFFD where git counts each byte (the multibyte edgetsv_ignore's CLAUDE.md scopes the git parity to ASCII by), and a non-UTF-8 path given as an argument is refused at the argv boundary (Invalid utf8: …, exit 1 — argh reads&str), so it is reachable only through a directory.cli.jssees such a name only as Node spells it, lossily, on every route, so it reports the file as unreadable rather than formatting it — a deliberate, permanent split: Node hands the bytes back only asBuffers (readdir'sencoding: 'buffer'), which would have to travel as a second path type through the walk, the ignore-file reads, the sort, the canonical-path dedup (realpathSyncresolves aBufferpath through its lossy spelling and fails), the worker handoff and the report, for a name no published repo holds. -
A path holding a control character or a double quote is printed C-quoted, as git prints one. One rule for every path either bin prints — the
--listand changed-path lines on stdout, the per-fileerror:lines, the traversal errors, the ignore-file warnings and the warning naming an excluded argument, a bad path argument,parse's read failure — stated once intsv_discover::quote_path(the warnings are built there already; the native CLI routes its own paths throughcli::out::path_bytes/path_text, andcli.jsrestates the rule by hand, as it doesclamp_worker_count): a path prints verbatim unless it holds a control character (U+0000–U+001F, U+007F) or a double quote, in which case the whole path is wrapped in double quotes and C-escaped the waygit ls-filesprints such a name undercore.quotePath=false—\a\b\t\n\v\f\r\"\\by name, any other control character as three octal digits ("r\033s.ts"), every other byte as itself (a character outside ASCII prints raw even inside the quotes, as git prints it, and so does a non-UTF-8 byte on a unix stdout listing; a stderr diagnostic spells that byte as U+FFFD, above). A backslash escapes inside a quoted path but does not trigger the quoting on its own — every Windows path holds one — so a quoted path unquotes exactly as git's does while a plainsrc\a.tsprints as it is. Two things follow. A diagnostic that names such a path stays one line (raw, a line feed in the name would split thewarning:line in two and a carriage return garble the terminal). And the stdout listings stay parseable by whatever already readsgit ls-filesorgit status: a line beginning with"is a quoted path, any other line names the file exactly, so a script overtsv format --listor the changed-path report can unquote with the routine it uses for git (or treat the leading"as the tell that a name needs it).The one text that stays literal is a re-include pattern a warning offers (
`!/build/n<TAB>o.ts`): it is meant to be pasted into an ignore file, which reads no escapes, so it is spelled as the file must hold it — and a pattern that would hold any control character but a tab is not offered at all, as above (a tab is spelled raw: it renders as the whitespace it is, and the pasted line reads it back as the file holds it). The ignore-file names in prose (the repo-root .gitignore) hold nothing to quote.Pinned by
tests/cli_tests/(test_format_quotes_a_path_holding_a_control_character_wherever_it_prints_it,test_argument_errors_quote_a_path_holding_a_control_character) andscripts/test_npm.ts's twin row; the spellings themselves bytsv_discover'squote_path_*tests, taken from git 2.47's own output. -
Invalid UTF-8 in a source file: reading is strict UTF-8 on both CLIs — the native one because Rust's
read_to_stringrefuses invalid bytes, andcli.jsbecause it decodes throughTextDecoder(..., {fatal: true})rather thanreadFileSync(path, 'utf-8'), which would substitute U+FFFD. The distinction is not cosmetic on the format path: a stray byte inside a string literal still parses after substitution, so a lossy reader would write the repaired text back over the author's file and call it formatted. Both bins instead reportread failed: stream did not contain valid UTF-8, count the file as an error, and leave every byte in place. The same strict-UTF-8 rule holds for--stdinand forparse, each with its own message (Error reading from stdin: …,Error reading file <path>: …). -
Source type: module, retried as a script. Path mode names no source type — a directory can hold Svelte and CSS beside JS/TS, and there is no one grammar to declare for the run — so each JS/TS file is parsed as a module, and only if that parse fails is it retried as a script — on the grammar: a module the depth limit refuses takes no retry, since the limit is no verdict on the grammar and the script grammar could accept the text only by reading
awaitas a name (await <T>aasawait < T > a), so the refusal stands (§Recursion Depth). That is what lets a legacy sloppy script (awithstatement, a leading-zero literal or escape,awaitas an ordinary name) format from a bare path. The retry runs on the error path only, so nothing the module grammar already accepts is ever reinterpreted, and the printer never reads the goal — no formatted output changes for any module-valid file.When both grammars reject the file, the reported error is the attempt's whose grammar the file was written against, decided in two steps. A script retry that died on a goal gate — a top-level
import/export, animport.meta, a top-levelfor await, or the operand a module reads after a top-levelawait, the constructs only a module holds — has proved the file a module wherever that construct sits, so the module error is reported: a broken module's script attempt dies there even when its real error comes first (definitions first,exportat the bottom — position alone would blame the validexportline). Otherwise the error that reached further into the source is reported, the module's on a tie: a broken sloppy script's module attempt dies early at its firstwith/legacy literal/awaitname — the construct the retry exists to admit — so the script error (the typo) is reported rather than a pointer at a line tsv accepts. prettier's babel parser reaches the same answer by tolerating those strict-mode productions at the module goal (allowedReasonCodes). Pinned bytests/format_fallback_error_attribution.rs.An explicit
--source-typeis exact —--source-type modulerefuses a script-only source rather than retrying — which is why it is a usage error in path mode rather than a per-run override.parsehas no fallback at either surface: its wire'sProgram.sourceTypeis a claim about which grammar produced the AST, and one settled goal has to produce it. The same rule reaches every format surface that takes no source type from its caller: the JS CLI's path mode, an editor'sformat_typescript(source), and theformat_*exports of all three bindings called with nosourceType, and the type strip —tsv stripandstrip_typescriptread an unset goal the same way (strip.md §The parse goal). -
Two extensions settle the goal themselves, and skip the retry.
.mjsand.mtsare ES modules whatever any config says — Node loads a.mjsas ESM unconditionally, and TypeScript maps both toModuleKind.ESNextwith the extension overridingmodule— so a path with one of those names is parsed as a module with no script retry (tsv_ts::Goal::from_extension). The fallback above exists to reach a legacy sloppy script, and a file that is a module by its own name cannot be one; without the narrowing,tsv format a.mjswould format awithstatement that no runtime would load. Nothing else settles a goal:.js/.tsare ambiguous by design, and.cjs/.ctsare the CommonJS half of that same switch — script code, but nothing in tsv's output turns on it, so they keep the fallback with the rest. The narrowing can only ever reject a file the fallback would have formatted: the retry runs on a module-parse failure alone, so no module-valid source formats differently. Bothtsvbins apply it — the native CLI per file informat_file, andcrates/tsv_wasm/npm/cli.jsfrom a hand-restated copy (as withclamp_worker_count), pinned on both sides.parse <file>reads no extension rule: with no flag every file parses as a module already, and an explicit--source-type scripton a.mtsis honored as the caller's exact claim rather than refused — the precedence prettier draws too (its__babelSourceTypeoption beatsgetSourceType(filepath)), and the one parse takes on every surface, since the wire'ssourceTypeis what was asked for.An editor keeps the fallback, because it has no path to read: the VS Code extension dispatches on the document's
languageId(.mjsarrives asjavascript,.mtsastypescript) and calls the binding's bareformat_typescript(source). So a.mjsholding a sloppy script is unformattable from the CLI and formats on save — the same split the goal axis draws everywhere between a surface that names a file and one that is handed a buffer. -
Fail-fast args, isolated traversal: path args that don't resolve to a file or directory fail the whole run before anything is written (every bad arg reported); traversal errors below a valid root (e.g. an unreadable subdirectory) report to stderr and discovery continues. A relative directory root that cannot be made absolute — its working directory was deleted out from under the run — is such an error for that root (
cannot resolve a relative path: the working directory is unavailable) rather than a walk anchored on no format root, which would read none of its ancestors' ignore files. -
No per-file options: formatting style is fixed (see CLAUDE.md §Configuration). In particular
<svelte:options preserveWhitespace />is not detected — whitespace handling is uniform, with only<pre>/<textarea>content whitespace-sensitive; see conformance_svelte.md §Template Whitespace. -
Deduplication: with multiple path args, overlapping spellings of the same file (
srcvs./src, absolute vs relative, symlink aliases) dedupe by canonical path, keeping the first spelling in sorted order. Only arguments that can overlap pay for it — a file argument among them, or one directory root an ancestor-or-self of another; a single root or disjoint roots can't produce duplicates, so the per-file canonicalization is skipped.Discovery's warnings and traversal errors collapse the same way: an ignore-file warning (a shadowed
.prettierignore, an unreadable ignore file, a.prettierignoreoutside a repo) and a traversal error (an unreadable directory) name their directory by its absolute path, whichever root or argument spelling reached it, sotsv format . sub— the repo root walked as.and preloaded assub's ancestor — andtsv format . ./each warn once, andtsv format t ./treports an unreadablet/lockedonce.Every canonical path the native walk takes — the dedup key, the working directory, a named path's parent — is read through one
canonicalizethat strips the verbatim prefix Windows'fs::canonicalizereturns (\\?\C:\…→C:\…,\\?\UNC\s\v→\\s\v; a no-op on unix), so a diagnostic naming the format root outside a repo printsC:\, and one argument whose parent would not canonicalize (spelled by the lexical fallback) does not read as a different format root from its neighbor that did. Verified against std's own Windows path code rather than on a Windows runner: every fs call re-adds the prefix itself where a path is long enough to need it (maybe_verbatim, behindread_dir,metadata, and theFile::openunderread_to_stringandwrite), and passes a shorter absolute path to Win32 as spelled — the spelling every walked path, joined from the argument, already takes — so the strip opens no access path the walk did not already use. The one residual is a name only a verbatim path reaches, a trailing dot or space Win32 normalizes away or a reserved device name, which git refuses to check out on Windows and which the walk never reached through the argument's spelling either. Node'srealpathSyncnever returns the prefix, socli.jsneeds no strip. -
In-place writes: files are rewritten only when output differs (no mtime churn), in place — a plain truncate-and-write, as prettier does, which keeps the inode, its mode and any hard link to it, and means a process killed or a disk filled mid-write can leave the file truncated (an atomic temp-file-and-rename would swap the inode and break hard links; not planned).
--content/--stdinkeep printing to stdout. -
--check: lists files that would change without writing; exits 1 if any would. For CI. Also works with--content/--stdin(nothing printed to stdout; the exit code is the API) for editor integrations. -
--list: prints the discovered in-scope files (one per line, C-quoted by the path-quoting rule above where a name needs it) without formatting — a read-only view of the setformatwould touch, after the ignore files are applied. Path mode only (errors with--content/--stdin), mutually exclusive with--check, and takes no--jobs(it spawns no pool, so a width there is refused as it is with--content). Unlike the format action, an empty scope is a valid answer (exit 0, no output) rather than the "no supported files" error; traversal errors still exit 2. Useful for debugging ignore-file scoping and for scripting over the set. -
Parallelism: files format concurrently on
std::thread::scopeworkers claiming one file at a time from a shared queue — dynamic load balancing with no thread-pool dependency.--jobs Noverrides the worker count, floored at 1 (--jobs 0is a width, not an opt-out — it means--jobs 1) and clamped to the file count where that is known up front — explicit file arguments and multiple roots; a single directory root streams (below) and cannot know the count before it walks, so it spawns the full width and the surplus workers park; path mode only, an error with--content/--stdin. Each worker reserves the same stack every other tsv thread runs on (STACK_SIZE,cli/stack.rs), so the pool is not a route with a depth ceiling of its own — see §Recursion Depth.An explicit
--jobsis held to4 × logical CPUs, warned about on stderr when it bites. Four per core is far past what the workload can use — the default lands below the logical count for measured reasons — so the ceiling is about blast radius, not throughput: each worker reservesSTACK_SIZEof address space, and an unbounded count takes task slots until the OS refuses, which on a systemd machine is the login session's wholeTasksMaxand wedges every other process on it.And a
--jobsthe OS still won't give narrows the pool rather than failing the run. The count is a user-supplied number, so a refused thread is an ordinary outcome of an ordinary argument, and the work is claimed rather than partitioned — however many workers exist drain the whole list between them. tsv warns (warning: only N of M format workers started) and formats the tree; if not one thread could be started, it says so and formats on the calling thread. Both messages are the JS CLI's, word for word.The default is
min(logical CPUs, ceil(1.5 × physical cores)), not one worker per logical CPU. This workload does not scale onto SMT siblings — the per-file work is memory-bound, and on a large tree the discovery walk is the bottleneck, so extra workers compete with it for cores. One worker per logical CPU costs up to 28% on walk-bound trees while buying nothing on flat repos. The SMT width is read once from/sys/devices/system/cpu/cpu0/topology/thread_siblings_list; where that is unavailable (no SMT, or a non-Linux platform) the cap is inert and the default is the logical count, so it can only ever lower the worker count. -
Streaming discovery: a single directory root — the common invocation — feeds the workers as the walk finds files, so the directory walk runs beside the first files' parse+format rather than in front of an idle pool. It is worth having: the walk is 5–10% of the wall on an application repo, and 40–67% on a repo with a large tree, where it can outrun what the pool consumes. Other argument shapes (explicit files, multiple roots) discover the whole set first, because the canonical-path dedup above is set-wide. The set of files formatted is identical either way, as is the reporting order below — only the order work is handed out differs.
-
Error isolation: a per-file read/parse/write error (or panic, caught via
catch_unwind— effective only in builds withpanic = "unwind"; release usespanic = "abort") reports to stderr and processing continues. -
Deterministic reporting: changed paths print to stdout in sorted-path order regardless of completion order — component-wise, each component by code point, the order a path's UTF-8 bytes sort in, and the same order on both bins (
cli.jscompares code points rather than UTF-16 units, which would put an astral-plane name ahead of U+E000..U+FFFF) — each spelled by the path-quoting rule above; errors (traversal and per-file) and the summary line go to stderr. -
Exit codes: 0 clean, 1 would-change (
--checkonly), 2 errors.