Upstreaming TSRX to OXC
This page is for OXC maintainers and contributors evaluating whether .tsrx
support belongs upstream, and for anyone who wants to attempt it. It maps what
would transfer, what would have to be rewritten, and what upstream would have to
open up first, against pinned revisions.
Nothing here has been submitted to OXC and nothing is scheduled. OXC for TSRX is
an independent community project, not affiliated with or endorsed by VoidZero or
the OXC team, and no maintainer interest is claimed. What ships today is one npm
package owning the oxlint and oxfmt command names, which does not depend on
any of this.
Four facts set up the rest:
- TSRX works by making a copy: a scan builds a temporary in-memory TSX version of a file, OXC parses that copy exactly once, and results map back onto the authored bytes. It is the shape of a Vite plugin transform, except the copy never reaches disk.
- The reusable core is one crate,
crates/tsrx_syntax. Its one direct dependency isunicode-id-start = "1", a standalone Unicode table, not an OXC API. - There is no fork. All twelve OXC dependencies sit behind
crates/oxc_adapterat one exact commit pin. - At the audited revision, OXC has no merged whole-file language hook that could
load this front end. Audited 2026-07-16 against the pin,
8e0ed2e(opens in new tab), and then-current OXCmain,6fe866a(opens in new tab). Boundaries move, so a real proposal would repeat that audit.
Select a node to read what it is, or step the buttons through the order:
The reusable core#
crates/tsrx_syntax keeps its modules private and its public API small. Two
ideas run through it: the copy, a temporary legal-TSX version OXC reads
while your file is never touched, and mapping back, the return trip that
puts OXC's output into TSRX and checks itself before anything is written.
The module tree, and what each part owns
| Module | Owns |
|---|---|
scanner/mod.rs |
the borrowed source, scanner state, and the main loop |
scanner/lexical.rs |
strings, comments, regular expressions, templates, numbers, and identifiers, as plain methods with no dynamic dispatch |
scanner/control.rs, header.rs, jsx.rs, overlay.rs, stack.rs |
the TSRX grammar. scanner/overlay.rs records scanner state and can undo it |
projection/mapping.rs, builder.rs, marker.rs |
which byte of the copy came from which byte of your file, building the copy, and naming its placeholders |
projection/lint.rs, types.rs, format.rs, lift/* |
one path per job. projection/lift/scaffold.rs is the formatter's return trip: it maps output back into TSRX and verifies it |
diagnostics.rs, model.rs |
the error shapes, and the compact records |
What could move upstream#
The classification is the point, not the directory names. "Direct reuse" means the code and its tests could move with little conceptual change, not that a patch would compile as-is.
| Local responsibility | Classification | Where it could land |
|---|---|---|
| Scanner records, checkpoints, spans, shape fingerprint | Direct reuse | An oxc_tsrx-style crate |
| The mapping between your file and the copy | Direct reuse | An Oxlint route that accepts a copy and tracks which ranges are yours |
| The formatting copy and its checked return trip | Direct reuse | A TSRX crate beside the language-agnostic formatter |
| TSRX control and header grammar | Adapt or replace | oxc_parser grammar modules. Test cases transfer, the rest follows upstream |
| Lexical shielding and Unicode boundaries | Adapt or replace | The parser lexer, or oxc_lexer (opens in new tab) once OXC adopts it |
JSX, dynamic tags, raw <style> |
Adapt or replace | JSX parser and lexer, once maintainers decide who owns the AST |
| Error construction | Adapt or replace | oxc_diagnostics, if errors keep pointing at your file |
| The test suites | Direct reuse as evidence | OXC's fixture and snapshot systems |
oxc_adapter, config discovery, type-aware process protocol, CLI |
Standalone product glue | Usually nowhere |
| Vite+, npm platform packages, the LSP multiplexer | Standalone product glue | Separate ecosystem packages |
| Native TSRX AST nodes, visitors, semantics, formatter | Upstream-only redesign | oxc_ast and friends. The alternative to the copy, and not something this repository can build in advance |
.tsrx dispatch, linter loading, Oxfmt routing, editor selection |
Upstream-only redesign | OXC application and editor layers |
What OXC does not expose today#
Every route a third-party language would need is closed in the audited source:
| Upstream boundary | What it actually is |
|---|---|
SourceType (opens in new tab) |
JavaScript and TypeScript syntax choices plus known extensions, not a registry a third-party grammar can join |
Oxlint's PartialLoader (opens in new tab) |
Hard-coded framework containers returning contiguous borrowed script regions at fixed offsets |
Oxfmt's FileKind classification (opens in new tab) and Oxfmt LSP routing (opens in new tab) |
Closed application routes. Neither loads a native TSRX formatter from project configuration |
ParserConfig (opens in new tab) |
Tuning for the existing parser, not a way to replace its grammar |
The language server's ToolBuilder (opens in new tab) |
A compile-time Rust embedding seam, not a runtime language loader |
The PartialLoader row matters most. It hands the linter one contiguous slice
of a file, which suits a <script> block inside HTML. TSRX is not like that:
the whole file becomes a copy in which some ranges are yours and some are
placeholders the tool wrote, and the two have to stay told apart. Treating it as
a partial-loader case would quietly lose accuracy.
Unmerged research threads upstream
Three exist. They are research, not runtime dependencies: Language Plugins RFC #21936 (opens in new tab), custom-template issue #19918 (opens in new tab), and draft custom-parser PR #24262 (opens in new tab), which as of 2026-07-24 reaches past a bare AST hook into editor routing but is still Oxlint-only.
Provider discovery patches, built locally#
A smaller question stands on its own: how would an OXC tool find out that a
project installed a third-party language provider at all? The proposed answer is
a static oxc.provider block, and three adoption
patches exist as source. "Built, verified locally" means the patch was applied
to a clone at a pinned revision, compiled, and run against tests.
| Target | Pinned revision | Size | Diff | Status |
|---|---|---|---|---|
oxc-project/oxc (opens in new tab) Oxlint npm wrapper |
a065946 (opens in new tab) |
+1463 / -10, of which 43 lines touch existing code | docs/architecture/patches/oxlint-provider-dispatch.patch |
built, verified locally |
oxc-project/oxc-vscode (opens in new tab) document selector |
beaffb9 (opens in new tab) |
+106 / -1 | docs/architecture/patches/oxc-vscode-provider-selector.patch |
built, verified locally |
voidzero-dev/vite-plus (opens in new tab) |
a24eede (opens in new tab) |
zero lines | none needed | verified, with a version pin caveat below |
The diffs live under docs/architecture/patches/, with the pinned revisions and
the exact git apply commands. No source is vendored here.
The Oxlint one is the substantive patch, and it stays in JavaScript: it sits
directly above the single lint(args, ...callbacks) call at the end of
apps/oxlint/src-js/cli.ts, so nothing crosses into Rust. With no provider
installed the wrapper is unchanged. With one, it sends the paths that provider
claims to its binary in pass-through mode and takes the worst exit code, and in
--lsp mode composes both language servers behind the editor's one stdio
connection. See the calling
convention. With
node_modules/.bin deleted and every tool name shadowed on PATH, the released
official OXC extension still found that patched wrapper by ordinary Node
resolution and started the provider's language server, which proves the protocol
implementable and nothing more.
Vite+ needs no patch of its own, because it resolves the oxlint package and
runs bin/oxlint from it without reading file extensions, so patching the
resolved wrapper reaches Vite+ users. That holds only while Vite+ pins one exact
oxlint version, and the pinned number moves between Vite+ releases.
tests/packaging/vite-plus-provider.test.mjs records which version, the rest of
the measurements, and the limit of a JavaScript-only patch: a directory argument
goes to the native walker and never enumerates provider extensions.
Landing order#
The order this proposal would suggest, if a maintainer first accepted reading a TSX copy as a starting design. None of it is scheduled or agreed to.
- Prototype a private TSRX front end. Scanner, copy builder, mapping, and errors in an experimental crate, with ordinary JS and TS skipping all of it, measured for speed and memory before anything else.
- Give Oxlint a real way to accept a copy. It needs your file path, the TSX
copy, the full mapping, and the rules for when a fix is safe.
PartialLoadercannot carry that. - Integrate one tool at a time. Oxlint diagnostics and fixes, then
formatting. Only then should
SourceType,FileKind, and the language server advertise.tsrx. - Decide about native AST nodes separately. First-class TSRX redesigns node types, visitors, semantic analysis, and the formatter. Hiding that inside a "parser support" patch would be dishonest.
Budgets any experiment has to keep#
Ordinary JS, JSX, TS, and TSX files skip TSRX work entirely. Beyond that, four budgets:
- a normal TSRX lint does one scan, one TSX copy, and one OXC parse;
- TSRX format does two structural scans, the authored one plus a cheap check that the result came back the same shape, and still one OXC parse;
- the scanner borrows your text and stores numbers rather than copying strings per node, and fixes and formatting check themselves before writing;
- moving a module upstream adds no new copies, parses, allocations, or dynamic dispatch without measurements and an agreed new budget.
Tests freeze the size of sixteen hot data records, and release benchmarks gate every stage. See the core performance contract and the acceptance matrix.
Reproducible evidence#
Every command, to rerun it from a clean checkout
cargo test --locked -p tsrx_syntax --test architecture
cargo test --locked -p tsrx_syntax --all-targets
pnpm run benchmark:native-lint
pnpm run benchmark:native-formatThe architecture test checks the private module tree, the public API, and the dependency boundary. The two benchmarks are the ones the budgets above are measured against.