A WASM shell parser and formatter with bash support, based on mvdan/sh
# yarn yarn add sh-syntax # npm npm i sh-syntax
import { init, parse, print } from 'sh-syntax' // Optional: load and initialize the WASM runtime up front. await init() const text = "echo 'Hello World!'" const ast = await parse(text) // print() formats source text; printing an edited AST is a planned follow-up. const formatted = await print(text) // Compatibility: print(ast, { originalText }) is accepted, but the AST is // ignored and originalText is formatted. const legacyFormatted = await print(ast, { originalText: text })
The synchronous API is available as parseSync and printSync:
import { parseSync, printSync } from 'sh-syntax' const ast = parseSync("echo 'Hello World!'") const formatted = printSync("echo 'Hello World!'")
parse returns the complete shell syntax tree as plain, JSON-friendly data.
Every node has a type plus start/end byte offsets, and fields use
lowerCamelCase. Position fields are { offset, line, column } data:
import { LangVariant, parseSync, walk } from 'sh-syntax' const ast = parseSync("echo 'Hello World!'", { keepComments: true, variant: LangVariant.LangBash, filepath: 'script.sh', }) // A function visitor is the enter phase. walk(ast, (node, parent, prop, index) => { if (node.type === 'Lit') { console.log(node.value) } }) // An object visitor can pair enter and exit; exit is required. walk(ast, { enter(node, parent, prop, index) { // ... }, exit(node, parent, prop, index) { // ... }, }) const command = ast.stmts[0].cmd if (command?.type === 'CallExpr') { console.log(command.args[0].start) // 0 }
Node start/end and position fields use UTF-8 byte offsets; position fields
expose offset, line (1-based), and column (0-based). walk passes the
nearest enclosing node, the key in the nearest enclosing object (which may be a
non-node wrapper), and the array index; parent, prop, and index are null
when absent. Returning false from enter skips a node's children, while
false from exit stops the walk.
AST offsets are UTF-8 byte offsets, while JavaScript strings use UTF-16 code units. Build a converter once for the source text:
import { createSourceIndex, parseSync } from 'sh-syntax' const text = 'echo "héllo 🎉" $var\n' const sourceIndex = createSourceIndex(text) const ast = parseSync(text) const word = ast.stmts[0].cmd.args.at(-1) const start = sourceIndex(word.start) const end = sourceIndex(word.end) console.log(text.slice(start, end)) // $var
Offsets inside a multi-byte character map to the start of that character, and offsets outside the source are clamped to its bounds.
The browser entry uses the same init() and async API as Node. The entry has no
top-level await, so importing it never blocks; call init() once when
convenient, or let the first parse/print call initialize the runtime:
import { init, parse, print } from 'sh-syntax' await init() const text = "echo 'Hello World!'" const ast = await parse(text) const formatted = await print(text)
The bundler must emit main.wasm as an asset (for example, Vite handles
new URL('../main.wasm', import.meta.url)); Vite library mode instead inlines
new URL() assets as base64, so libraries should supply their own loader:
import { getProcessor } from 'sh-syntax' const processor = getProcessor(() => fetch('https://cdn.jsdelivr.net/npm/sh-syntax@latest/main.wasm'), )
The workerd export imports the bundled main.wasm as a compiled module, so
Worker code does not call getProcessor or fetch the asset:
import { init, parse } from 'sh-syntax' export default { async fetch() { await init() return new Response(JSON.stringify(await parse("echo 'Hello World!'"))) }, }
Call init() inside the request handler: Go startup uses
crypto.getRandomValues, which Workers disallow outside a request context.
Wrangler and @cloudflare/vite-plugin already resolve .wasm imports as
WebAssembly.Module. Other bundlers must do the same for the workerd entry;
a URL or instantiated module is not sufficient. Nitro's unwasm turns a
plain .wasm import into instantiated exports, so it needs a loader that
returns a WebAssembly.Module.
For TypeScript Worker builds, select the runtime declaration:
{ "compilerOptions": { "customConditions": ["workerd"] } }clk: ~3.03 GHz cpu: Apple M1 Max runtime: node 26.12.0 (arm64-darwin) benchmark avg (min … max) p75 / p99 (min … top 1%) ------------------------------------------- ------------------------------- sh-syntax 18.35 ms/iter 20.02 ms █ (14.59 ms … 22.44 ms) 22.20 ms ▇▇ ▂ ▇▂▂▇ ▂█▂ ▂ ▂ ( 94.64 kb … 608.02 kb) 577.27 kb ██▆▆▁█▁▁████▆███▁█▁▁█ 4.26 ipc ( 0.57% stalls) NaN% L1 data cache 58.65M cycles 249.90M instructions 0.00% retired LD/ST ( 0.00) sh-syntax (sync) 18.70 ms/iter 20.53 ms █ ██ (14.52 ms … 21.81 ms) 21.48 ms █ █ ███ ██ █ ████ (598.83 kb … 692.00 kb) 603.65 kb █▁█▁█▁███████████████ 4.23 ipc ( 0.55% stalls) NaN% L1 data cache 58.66M cycles 248.24M instructions 0.00% retired LD/ST ( 0.00) sh-syntax (synckit) 18.48 ms/iter 19.62 ms ▂ ▂ █ ▂ (15.49 ms … 20.98 ms) 20.57 ms ▅ ▅█ █▅ ▅█▅ ▅▅█ (291.08 kb … 1.48 mb) 325.51 kb ▇▇█▇▁▁▇██▁██▇███▇▇███ 2.80 ipc ( 9.87% stalls) NaN% L1 data cache 497.94k cycles 1.39M instructions 0.00% retired LD/ST ( 0.00) mvdan-sh 67.72 ms/iter 65.80 ms █ (61.50 ms … 88.60 ms) 82.67 ms █ █ █ ( 4.46 mb … 22.87 mb) 16.25 mb █████▁█▁▁▁▁▁▁▁▁▁▁▁▁▁█ 5.38 ipc ( 1.20% stalls) NaN% L1 data cache 210.36M cycles 1.13G instructions 0.00% retired LD/ST ( 0.00) summary sh-syntax 1.01x faster than sh-syntax (synckit) 1.02x faster than sh-syntax (sync) 3.69x faster than mvdan-sh
See benchmark for more details.
You can try it with running node benchmark by yourself. Here is the benchmark source code.
| 1stG | RxTS | UnTS |
|---|---|---|
| 1stG | RxTS | UnTS |
|---|---|---|
Detailed changes for each release are documented in CHANGELOG.md.