Visitar URL original
GitHub - un-ts/sh-syntax: A WASM shell parser and formatter with bash support, based on `mvdan/sh` Β· GitHub
Skip to content
un-tsPublic

About

A WASM shell parser and formatter with bash support, based on `mvdan/sh`

Topics

Resources

Security policy

Stars

72 stars

Watchers

0 watching

Forks

Latest commit

Β 

History

148 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

sh-syntax

GitHub Actions Workflow Status Codecov type-coverage CodeRabbit Pull Request Reviews npm GitHub Release

Conventional Commits Renovate enabled JavaScript Style Guide Code Style: Prettier changesets

A WASM shell parser and formatter with bash support, based on mvdan/sh

TOC

Usage

Install

# yarn
yarn add sh-syntax

# npm
npm i sh-syntax

API

node

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.
const formatted = await print(text)

// print(ast) prints the current tree, including edits made to its fields.
// print(ast) serializes the tree to Go and decodes it before printing. For
// large files, the originalText path below is much faster.
const printedAst = await print(ast)

// originalText still selects the source-text fast path.
const formattedSource = await print(ast, { originalText: text })

The synchronous Node API is available from sh-syntax/sync:

import { parse, print } from 'sh-syntax/sync'

const ast = parse("echo 'Hello World!'")
const formatted = print("echo 'Hello World!'")
const printedAst = print(ast)

syntax tree

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. Field values are raw shell source text, so the printer re-emits them verbatim; setting Lit.value to a b prints two words. Position fields are { offset, line, column } data:

import { LangVariant, parse, walk } from 'sh-syntax'

const ast = await parse("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.

source offsets

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, parse } from 'sh-syntax'

const text = 'echo "hΓ©llo πŸŽ‰" $var\n'
const sourceIndex = createSourceIndex(text)
const ast = await parse(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.

browser

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 sync entry is also available in browsers as sh-syntax/sync; unlike the async entry it uses top-level await to fetch and compile main.wasm before exposing synchronous parse/print. The bundler must support TLA and emit the WASM asset; the same Vite library-mode caveat applies. There is no sync variant on workerd.

workerd

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"] } }

Benchmark

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.

Sponsors and Backers

Sponsors and Backers

Sponsors

1stG RxTS UnTS
1stG Open Collective sponsors RxTS Open Collective sponsors UnTS Open Collective sponsors

Backers

1stG RxTS UnTS
1stG Open Collective backers RxTS Open Collective backers UnTS Open Collective backers

Changelog

Detailed changes for each release are documented in CHANGELOG.md.

License

MIT Β© JounQin@1stG.me

About

A WASM shell parser and formatter with bash support, based on `mvdan/sh`

Topics

Resources

Security policy

Stars

72 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages

Generated from un-ts/lib-boilerplate