Real code formatters compiled to WebAssembly, typed for TypeScript, with one hard constraint: formatting must work with nothing installed but Node.
Scalar is an open-source API platform for teams who want beautiful developer interfaces without vendor lock-in.
- API References — Interactive API documentation from OpenAPI and AsyncAPI specs.
- Developer Docs — Write in Markdown/MDX, generate API references, sync with two-way Git.
- SDK Generator — Type-safe SDKs and CLIs in TypeScript, Python, Go, PHP, Java, and Ruby.
- API Client — Open-source, offline-first Postman alternative built on OpenAPI.
20M+ monthly npm installs · 15,500+ GitHub stars · MIT licensed · scalar.com
No Ruby install, no gems, no JVM, no .NET SDK, no Swift toolchain, no PHP or Composer, no native binaries, no postinstall downloads.
| Package | Reference | Artifact | Status | Browser |
|---|---|---|---|---|
@scalar/ruby-fmt |
RuboCop (Layout) | 2.2 MB | ✅ exact by test | ✅ |
@scalar/java-fmt |
google-java-format | 0.77 MB | ✅ exact | ✅ |
@scalar/kotlin-fmt |
ktfmt | 0.82 MB | ✅ exact | ✅ |
@scalar/csharp-fmt |
CSharpier | 4.2 MB | ✅ exact | ✅ |
@scalar/swift-fmt |
swift-format | 12.5 MB | ✅ exact | ✅ |
@scalar/php-fmt |
PHP CS Fixer | 0.45 MB | ✅ exact | — |
@scalar/rust-fmt |
rustfmt | 1.3 MB | ✅ exact | ✅ |
Artifact is the brotli-compressed module as committed and published — the whole tool, its parser and its language runtime in one file. The JavaScript that loads it is small by comparison: nothing for Ruby, Swift, PHP and Rust, a 16 KB generated runtime for Java and Kotlin, and 0.46 MB of .NET loader scripts for C#.
Ruby's reference is rubocop --autocorrect --only Layout: the package corrects
Layout within the line structure it is handed and does not reprint, which suits
line-structured input such as generated code. See
packages/ruby.
Exactness is only meaningful against a named reference tool, so the reference is stated per package. "Exact" means the package is that tool compiled to wasm — not a reimplementation of it. A reimplementation drifts, because a formatter's line-breaking heuristics are emergent from its implementation rather than specified anywhere, and the drift stays invisible until a consumer's CI fails.
Ruby is the one package that is a reimplementation: it runs
rbcop, RuboCop 1.92.2 reimplemented in
Rust, so it is not exact by the definition above. It is exact by test: a
conformance test holds it byte-identical to the real rubocop 1.92.2, over its
samples and over any corpus it is given, at two line widths. Corpus runs have
found three rbcop bugs, each fixed and pinned by a sample — details
below.
Browser is the same claim held to the same standard: ✅ means the package has a
browser export condition and bun run test:browser loads that build in real
Chromium and asserts byte-identical output to the Node build. It is not a
prediction that it ought to work.
npm install @scalar/ruby-fmt
Every package is written in TypeScript and ships its own declarations, so the
options and the return type come from the source rather than from a hand-written
.d.ts that can fall out of step.
import { format, type FormatOptions } from '@scalar/ruby-fmt' const options: FormatOptions = { printWidth: 100 } const formatted: string = await format('x=[1,2,3].map{|n| n*2}', options) // x = [1, 2, 3].map { |n| n * 2 }
format is async because the first call boots a Ruby VM. The VM is cached, so
every later call is milliseconds.
Some callers have no await to give — a code generator that formats each file
inside the synchronous builder that emits it, a template renderer, a plugin hook
that has to return a string. Every package exports formatSync for them:
import { formatSync, init } from '@scalar/ruby-fmt' await init() const formatted: string = formatSync('x=[1,2,3].map{|n| n*2}')
Booting is the one thing that cannot be made synchronous — the wasm has to be
read or fetched, and compiled — so init covers it once and formatSync throws
until it has. Everything after that already was synchronous; format was only
ever awaiting the boot.
Prefer format where you can await: it needs no setup call and cannot throw that
error. Ruby carries one extra caveat — see its README —
because recycling its VM is asynchronous too.
Ruby, Java, Kotlin, C#, Swift and Rust run in a browser as well as under Node.
The import does not change; bundlers and browsers pick the browser export
condition on their own:
import { format } from '@scalar/rust-fmt' await format('pub fn add(a: i32,b:i32)->i32{a+b}')
Same function, same options, same bytes out. What differs is only how the wasm arrives: fetched rather than read from disk.
Run it in a worker. Every one of these compiles multi-megabyte wasm and holds tens to hundreds of megabytes of linear memory. On the main thread that is a frozen tab, and Swift — 12.5 MB over the wire, 49.4 MB of wasm — is one you want behind an explicit user action rather than on page load.
The default resolves the artifact next to the module. Vite, Rollup and webpack
recognise that form, emit the artifact as a hashed asset and rewrite the URL to
match; a CDN resolves it with no build step at all. esbuild is the exception —
it leaves the URL alone — so an esbuild build needs the artifact copied beside
its output, or named explicitly. Either way, init says so:
import { format, init } from '@scalar/rust-fmt' await init({ url: '/assets/rust_fmt.wasm.br' })
init is optional, it must come before the first format, and it doubles as a
way to pay the download up front rather than during the first call.
The browser reads the same brotli-compressed artifact the Node build does, rather than a second copy in a friendlier format — a gzip twin would cost 41–59% more over the wire and would double what every Node install carries for a file it never opens.
Why decompress at all, when browsers already decode brotli for free? Because
that is transfer encoding — it happens when a server sends Content-Encoding: br, and the browser undoes it before your code sees a byte. This artifact is
compressed at rest: the .br is what is committed and published, which is what
keeps an install megabytes rather than tens of megabytes. A bundler or CDN serves
it as opaque bytes with no encoding declared, so nothing has told the browser it
is brotli and nothing decodes it.
Expanding it uses DecompressionStream('brotli') where the engine has it
(Safari 18.4+, Firefox 147+) and falls back to a 208 KB wasm decoder otherwise.
Chrome has not shipped native brotli yet, so today the fallback is the common
path; it is loaded dynamically, so engines that do not need it never fetch it,
and it disappears from the equation as Chrome catches up.
If you serve the artifact yourself you can skip the decoder entirely, either by
setting Content-Encoding: br on the .br file or by serving an uncompressed
.wasm. Both are the same call:
await init({ url: '/assets/rust_fmt.wasm', encoding: 'none' })
C#'s runtime is the Blazor runtime, and it is the only package whose assets are
not all bytes: it imports four runtime/*.js files as ES modules by URL, so
they have to exist at one. They resolve next to the module by default and Vite,
Rollup and webpack emit them as hashed assets unaided — verified against a real
Vite production build, which rewrites all four and still boots. Where they land
somewhere those cannot derive, name the directory:
await init({ runtimeBaseUrl: '/assets/dotnet' })
The approach comes from @wasm-fmt, which ships
the real formatter compiled to WebAssembly instead of a lookalike — gofmt,
clang-format, dart_style, ruff format, all reachable from Node with no
toolchain installed. These packages do the same thing for the languages that set
does not cover.
See packages/ruby. It is rubocop --autocorrect --only Layout
and nothing else, through rbcop: RuboCop
1.92.2 reimplemented in Rust on Prism and compiled to a WASI reactor by
build/rbcop_fmt/build.sh. One 2.2 MB artifact,
~2.5 ms a file at the median, and a boot that is synchronous under Node, so formatSync needs
no init() there.
It corrects Layout - indentation, alignment, blank lines, spacing, line length -
within the line structure it is handed, and does not reprint. That suits input
that already has its final line structure, such as generated code; it will not
turn def x; a; b; end into one statement per line. printWidth is
Layout/LineLength's Max, defaulting to RuboCop's own 120, and that cop's
autocorrect only breaks at argument lists, collection literals and block braces,
so a long string literal or constant assignment stays long.
That makes it the one reimplementation in this repo, so it is held to the
exactness rule the only way a reimplementation can be: a conformance test runs
the real RuboCop::CLI, pinned to the four gem versions rbcop's own parity
gates ran against, over the same raw input, and asserts byte-identity at the
default width and at 80 - and, where RuboCop gives up on a file, that the
package rejects it. Corpus runs have found three rbcop bugs, each fixed and
pinned by a sample: the wrapped operand of /regex/ =~ x, a case/in guard
continued over lines, and a guard clause sharing its line inside
begin … end.
The package used to run a reprinter on CRuby compiled to wasm before this pass. It went in 0.9, with the 5.7 MB artifact, the VM's ~330 MB of linear memory and its recycling: see the package's upgrade notes.
See packages/java. It runs the real google-java-format 1.36.1
- javac's own parser included - compiled to WasmGC by TeaVM, so output is byte-identical to the same version on a JVM. A conformance test asserts that in both styles, and the build is checked against 658 real Java files (Guava's and google-java-format's own sources) in both styles - 1316 comparisons, all identical, and identical again to what a second, independent wasm build of the same version produces.
format() runs the CLI's pipeline rather than just its Formatter class -
format, remove unused imports, sort imports, reflow long strings - because the
reference this package claims to be is the tool. Stopping at formatSource
leaves imports untouched and text blocks unreflowed, which the conformance test
sees immediately as a divergence.
The package exports googleJavaFormatVersion alongside format, so a consumer
that has to install the matching jar - to re-verify its own committed bytes in
CI, say - reads the release off the package rather than pinning the number a
second time in its own repository.
One upstream quirk worth knowing: google-java-format is not idempotent in aosp
style on a reflowed string literal, and settles on the second pass. This build
reproduces that at every pass, which means formatting here and then verifying
with the jar compares pass one against pass two and looks like a divergence that
is not one. packages/java/README.md has the detail and the two ways out.
It ships as a 0.77 MB java_fmt.wasm.br plus TeaVM's generated runtime, which
supplies the module's imports. Both are committed, so a fresh clone needs
nothing extra; bun run java:build:teavm rebuilds them from a JDK 21, Maven,
git and Node.
Why TeaVM and not GraalVM. The first build of this package used GraalVM Web
Image, and it still lives in build/java_fmt/ - it is equally exact, and the two
agree on every file in the corpus. But its artifact embeds Oracle code under the
GFTC, which permits redistribution only where no fee is charged for the artifact
or for a product bundling it, so shipping it inside anything paid was out.
TeaVM is Apache-2.0 with its own class library rather than OpenJDK's, which
leaves javac's parser as the only non-permissive component - GPLv2 with the
Classpath Exception, which exists precisely to allow linking into a product
distributed under terms of your choice. Getting there took patches to TeaVM and
to google-java-format; packages/java/README.md names every one.
Two caveats. Node 24.15 or newer, and not because of WasmGC: below that V8
does not compile the module at all, because of how it used to type the wasm
exception handling TeaVM emits. bun is unaffected. And the module
has no filesystem and no stdin, so source goes in as a string and comes back as
one - enough for format(), but it rules out shipping the CLI itself.
See packages/csharp. It runs the real CSharpier 1.3.0 -
Roslyn's own parser included - compiled to WebAssembly by the .NET 10
browser-wasm toolchain, so output is byte-identical to the same version on
.NET. A conformance test asserts that, and the build was checked against 613
real C# files - CSharpier's own syntax-coverage corpus and its sources - all of
which came out identical.
Unlike the Java package there is no CLI pipeline to replicate: for a .cs file
csharpier dispatches straight to the same CSharpFormatter.Format that runs
inside the module. The one thing format() adds back is the byte-order mark,
which the library drops and the CLI preserves.
It ships as a 4.2 MB csharp_fmt.br - the assemblies, the runtime wasm and the
ICU data packed into one brotli archive and fed to the runtime through
dotnet.withResourceLoader - plus the four JavaScript files the runtime imports
as ES modules, which have to stay real files. Both are committed, so a fresh
clone needs nothing extra; bun run csharp:build rebuilds them using a .NET SDK
the script downloads itself.
Two things in the build change output rather than just size, and
build/csharp_fmt/NOTES.md records them:
InvariantGlobalization reorders using directives, because CSharpier sorts them
with a comparison that degrades to ordinal without ICU; and Diagnostic.ToString()
traps under AOT, so the error text is assembled from its parts instead.
See packages/swift. It runs the real swift-format 604.0.0 -
the release that pairs with Swift 6.4, swift-syntax's parser included - compiled
by the official Swift SDK for WebAssembly
from swift.org rather than the SwiftWasm fork. A conformance test asserts
byte-identical output against a native swift-format, and the build was checked
against 689 real Swift files (swift-format's own sources and tests, swift-syntax,
swift-argument-parser and swift-markdown), all of which came out identical.
format() wraps the SwiftFormat library rather than the CLI, because the CLI
cannot be compiled at all: its executable target imports Dispatch and the WASI
SDK ships no libdispatch. That is a small gap - swift-format format <file>
resolves a configuration and then calls exactly the one method the wrapper
calls. The behaviour that is genuinely missing is .swift-format discovery,
since there is no filesystem to search, so a project's configuration has to be
read and passed in.
It ships as a 12.5 MB swift_fmt.wasm.br, built by
build/swift_fmt/build.sh, which downloads its own
Swift toolchain and SDK. It is committed, so a fresh clone needs nothing extra;
bun run swift:build rebuilds it. Three times the size of the other packages,
almost all of it swift-syntax - -Osize and wasm-opt -Oz were both tried and
moved the compressed total by under 60KB, so it is built with -O instead, which
spends about 20% less time formatting.
Two caveats worth knowing. The module is a WASI reactor: instantiated once,
then run per format, which measured 2.5x faster than a fresh instance each
time and, unlike the Ruby VM, does not leak - memory plateaus at 75MB and stays
there. And the licence is unrestricted, unlike the Java package: everything
embedded is Apache-2.0 with the Runtime Library Exception, so a paid product may
ship a copy.
See packages/php. It runs the real PHP CS Fixer 3.95.30 - the
official phar, unmodified - on real PHP 8.4 compiled to WebAssembly, so output
is byte-identical to the same phar on a native PHP. A conformance test asserts
that, and the build was checked against 1117 real PHP files (PHP CS Fixer's own
sources and the Symfony, ReactPHP and PSR components vendored into its phar),
all of which came out identical - 1100 of them files the fixer actually
rewrote.
It is also the one package with a synchronous entry point. PHP on wasm has none
and cannot be given one - both builds suspend through Asyncify or JSPI, so every
export returns a promise - so formatSync() gets its synchrony from the thread
instead: PHP runs in a worker and the calling thread parks on Atomics.wait
until the result lands in shared memory. Same fixer, byte-identical output, and
nothing to install. It blocks the caller for the ~300ms a format takes, which is
the point and also the cost, so prefer format() wherever you can await.
It is the slowest package here by a wide margin, and the cost is PHP CS Fixer rather than the wasm: it autoloads several hundred classes per invocation and then runs every enabled fixer over every file. Both entry points therefore take an array as well as a string, and that is the form to use whenever the files are in hand - the autoload is paid once for the batch, and the batch is split across PHP instances in separate processes. On a four-core machine 200 generated files go from 11.5s to 4.6s. The instances are processes rather than worker threads because that is what actually parallelises: several PHP instances inside one process barely beat one, however many cores are idle, while the same instances in separate processes scale nearly linearly.
This is the one package that compiles nothing, and that is the point. The others
compile their reference tool because no wasm build of it exists; PHP CS Fixer is
pure PHP, so the released phar is the tool and a maintained wasm PHP already
exists to run it. Building from source here would only add a copy that could
drift. So build/php_fmt/build.sh downloads the
pinned phar, checks it really is one, and brotli-compresses it - 3.6MB down to
a committed 0.45MB.
It is also the one package whose runtime is not embedded in its artifact: PHP
arrives as an ordinary npm dependency, @php-wasm/node-8-4. That is
deliberately not the @php-wasm/node meta-package it sits under, which ships
prebuilt .node binaries and an install script - the two things this repo
exists to avoid - depends on every PHP from 7.4 to 8.5, and keeps Node's event
loop alive so a process that formats a file never exits.
Two caveats worth knowing. format() drives the fix command in-process
rather than executing the phar, because PHP's CLI SAPI is one-shot - a second
cli() call silently does nothing and hands back unformatted input. And
subprocess functions are disabled inside the runtime, which is load-bearing:
Config's CPU-core detection shells out through proc_open, leaked a pipe on
every format, and killed the runtime after ~100 of them.
See packages/rust. It runs the real rustfmt — the build that
ships with stable Rust 1.99.1, rustc's own parser included — compiled to
wasm32-wasip1, so output is byte-identical to the same version natively. A
conformance test asserts that across functions, traits, generics, lifetimes,
macros, async and unicode under four configurations, and the build was checked
against rustfmt's entire 345-file tests/source corpus, all of which came out
identical with matching exit status on the six rustfmt refuses.
That this is possible at all is the interesting part. rustfmt links eight
compiler crates behind #![feature(rustc_private)], and rustup publishes the
rustc-dev component that carries them for 34 host targets and no wasm target —
which is why this repo previously concluded it could not be done. But those
crates are ordinary Rust that wants bootstrap's environment, not bootstrap:
given CFG_RELEASE and five siblings, plain cargo cross-compiles them with zero
errors. rustfmt itself needs three lines of #[cfg].
build/rust_fmt/SPIKE.md is the full account.
It ships as a 1.3 MB rust_fmt.wasm.br, built by
build/rust_fmt/build.sh — which is not what eight
rustc crates and a parser sound like. It is committed, so a fresh clone needs
nothing extra; bun run rust:build rebuilds it.
Two caveats. The module has no filesystem, so a project's rustfmt.toml is
not discovered — read it and pass it in, and remember rustfmt's default
edition is 2015, so modern syntax needs { edition: '2021' } exactly as it does
on the CLI. And wasm cannot grow its stack the way stacker does natively, so
the build links with -z stack-size=33554432; at 32 MB it matches native at
every nesting depth native itself survives, but at the 1 MB default it would
trap on deeply nested expressions native handles fine.
bun install bun run build # compile each package's TypeScript to dist/ bun run test # bun test suite bun run test:node # load the built packages under plain Node (24+ for Java) bun run test:browser # load the built browser packages in real Chromium bun run check # biome lint + format check bun run types:check
build/ build pipelines for wasm artifacts
packages/ published npm packages
<pkg>/src/ TypeScript sources, one function per file
<pkg>/dist/ compiled output — what consumers import (gitignored)
<pkg>/test/ integration tests and the plain-Node smoke test
scripts/ repo tooling
tsconfig.json strict base config, shared by every package
tsconfig.build.json the same, in emit mode
tsconfig.scripts.json covers scripts/ and the build pipelines' scripts
tsconfig.node-smoke.json the smoke tests, checked against built dist
Bun is the development toolchain — package manager, test runner, script runner —
but nothing a published package does at runtime may depend on it. That is what
bun run test:node is for: it loads each package's built dist under plain
Node and formats through it.
bun run test:browser holds the browser builds to the same standard, in a real
Chromium: it serves the repo, resolves bare specifiers through an import map the
way a bundler would, and asserts each package's output matches the Node build
byte for byte. Chromium rather than a faster headless engine because it is the
one without native brotli, so it is the one that exercises the wasm decoder
fallback. It needs a browser (bunx playwright install chromium), or point
CHROMIUM_EXECUTABLE at one you already have.
CONTRIBUTING.md has the workflow, the exactness rule every change is held
to, and the changeset each pull request carries. Vulnerabilities go through
SECURITY.md's private route rather than the issue tracker.
We are API nerds. You too? Let's chat on Discord: https://discord.gg/scalar
The approach here is @wasm-fmt's: ship the real
formatter compiled to WebAssembly rather than a reimplementation that looks like
it. These packages extend that idea to languages the wasm-fmt set does not cover.
The source code in this repository is licensed under MIT.