@scalar/csharp-fmt
v0.2.0
Published
C# formatter (CSharpier on wasm) - runs on plain Node, no .NET
Readme
Scalar C# Formatter
C# formatter that runs on plain Node. No .NET SDK, no dotnet on PATH, no postinstall download.
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
npm i @scalar/csharp-fmtimport { format } from '@scalar/csharp-fmt'
await format('using B;using A;class A{int x = 1;void F(){G( "hi" );}}')
// using A;
// using B;
//
// class A
// {
// int x = 1;
//
// void F()
// {
// G("hi");
// }
// }Async because the first call decompresses the archive and boots the runtime — about 0.8s. That work is cached, so every later call is ~5ms.
Options: { printWidth } (100), { useTabs } (false), { indentSize } (4) and
{ endOfLine } ('auto', or 'lf'/'crlf'). Every default is CSharpier's own,
so format(source) means what csharpier format <file> means for a file with no
.csharpierrc beside it.
Source that does not parse throws, carrying the diagnostics CSharpier produced:
(1,9): error CS1513: } expected.
Node 22 or newer.
Formatting without awaiting
formatSync is for callers with 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.
import { formatSync, init } from '@scalar/csharp-fmt'
await init()
const formatted = formatSync(source)Booting is the one thing that cannot be made synchronous, so init covers it
once and formatSync throws until it has. Everything after that already was
synchronous — format was only ever awaiting the boot, and both produce the
same bytes.
Prefer format where you can await: it needs no setup call and cannot throw that
error.
It runs in the browser too
The import does not change — bundlers and browsers pick the browser export
condition on their own, and format has the same signature and returns the same
bytes. Only the assets' route in differs: fetched rather than read from disk.
import { format, init } from '@scalar/csharp-fmt'
// Optional. Both the archive and the runtime's four JavaScript files resolve
// next to the module by default, which Vite, Rollup, webpack and a plain CDN
// handle unaided. esbuild does not rewrite `new URL(..., import.meta.url)`, so
// there they need naming.
await init({ url: '/assets/csharp_fmt.br', runtimeBaseUrl: '/assets/dotnet' })
await format(source)Run it in a worker. Booting is ~1s of decompression, module loading and Roslyn warm-up, which is a visibly frozen tab if it happens on the main thread.
This is the one package whose assets are not all bytes. The assemblies, the
runtime wasm and the ICU data all come out of the archive, but dotnet.js,
dotnet.boot.js, dotnet.runtime.js and dotnet.native.js are ES modules the
.NET runtime imports by URL, so they have to exist at one. That is what
runtimeBaseUrl is for; the default needs no configuration, and a real Vite
production build emits all four under hashed names and still boots.
The browser reads the same brotli archive as Node (4.2 MB over the wire, 21 MB
expanded) and expands it with DecompressionStream('brotli') where the engine
has it, or a 208 KB wasm decoder where it does not — Chrome, today. Serving the
archive with Content-Encoding: br, or serving it uncompressed, skips the
decoder entirely:
await init({ url: '/assets/csharp_fmt', encoding: 'none' })This is the real CSharpier, and the output is exact
This is actual CSharpier 1.3.0 —
Roslyn's own C# parser included — compiled to WebAssembly by the .NET 10
browser-wasm toolchain. It is not a reimplementation, so it does not drift.
test/native-conformance.test.ts asserts byte-identical output against the same
version running on .NET. That test asserts rather than reports: any divergence
is a real bug. It skips cleanly when no native csharpier is around, so a
toolchain-free checkout still passes.
Beyond the samples in that test, the build was checked against 613 real C# files — CSharpier's own syntax-coverage test corpus plus its source — formatted by both the wasm build and the native CLI. All 613 came out identical.
format() is a string in and a string out
For a .cs file the CLI dispatches straight to the same
CSharpFormatter.Format that runs inside the module here, so unlike this repo's
Java package there is no extra CLI pipeline to replicate. What the CLI adds
around it is a caller's job, because none of it exists inside a module with no
filesystem:
- finding files, and honouring
.csharpierignore - resolving
.csharpierrcand.editorconfiginto options — pass them yourself - detecting a file's encoding
The one place that boundary is papered over is the byte-order mark. The library
drops a leading mark and the CLI re-attaches it from the encoding it detected,
so format() re-attaches it too — otherwise formatting a BOM-prefixed file
would silently strip it.
XML is not included
CSharpier also formats .csproj and other XML through a separate
XmlFormatter. This package exposes only the C# formatter. That is a scope
choice, not a limitation of the build.
One artifact, and how to get it
build/csharp_fmt/build.sh produces two things, both committed:
csharp_fmt.br— the assemblies, the runtime wasm and the ICU data, packed into one brotli archive. 21MB raw, 4.2MB compressed.runtime/— the four JavaScript files the .NET runtime imports as ES modules. They resolve by URL, so they have to stay real files.
Everything in the archive is fed to the runtime through
dotnet.withResourceLoader, which is why 21MB of assets can ship as one
compressed file instead of twenty loose ones.
The build needs the .NET 10 SDK and its wasm-tools workload, which the script
downloads itself. Neither the tests nor consumers ever need them.
build/csharp_fmt/NOTES.md records what was measured and why the build is
configured the way it is — including two settings that silently change output,
which is worth reading before touching a flag.
Community
We are API nerds. You too? Let's chat on Discord: https://discord.gg/scalar
Licensing
Everything embedded here is permissively licensed: CSharpier, Roslyn, the .NET
runtime and class library, and Emscripten are MIT, and the ICU data is
Unicode-3.0. Unlike this repo's Java package there is no restriction on who may
redistribute a copy or what they may charge. See licenses/NOTICE.md.
