Skip to content

Commit 436dff9

Browse files
committed
Initial commit
0 parents  commit 436dff9

76 files changed

Lines changed: 72011 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
build:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
- uses: pnpm/action-setup@v4
15+
- uses: actions/setup-node@v4
16+
with:
17+
node-version: 22
18+
cache: pnpm
19+
- run: pnpm install --frozen-lockfile
20+
- run: pnpm test
21+
- run: pnpm run build

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
dist
2+
node_modules
3+
playground/parser.js

.oxfmtrc.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"$schema": "./node_modules/oxfmt/configuration_schema.json",
3+
"printWidth": 120
4+
}

LICENSE

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
ISC License
2+
3+
Copyright (c) Lars Kappert
4+
5+
Permission to use, copy, modify, and/or distribute this software for any
6+
purpose with or without fee is hereby granted, provided that the above
7+
copyright notice and this permission notice appear in all copies.
8+
9+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15+
PERFORMANCE OF THIS SOFTWARE.

README.md

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# unbash
2+
3+
Fast 0-deps bash parser written in TypeScript
4+
5+
## Install
6+
7+
```sh
8+
npm install unbash
9+
```
10+
11+
## Usage
12+
13+
```ts
14+
import { parse } from "unbash";
15+
16+
const ast = parse('if [ -f "$1" ]; then cat "$1"; fi');
17+
```
18+
19+
```js
20+
// Result:
21+
{
22+
type: "Script",
23+
commands: [{
24+
type: "If",
25+
clause: { type: "Command", name: { text: "[" }, ... },
26+
then: { type: "Command", name: { text: "cat" }, ... }
27+
}]
28+
}
29+
```
30+
31+
## unbash vs tree-sitter-bash
32+
33+
[tree-sitter-bash][1] is an excellent choice if you need:
34+
35+
- Incremental parsing
36+
- CST output preserving all tokens and punctuation
37+
- Granular error recovery that wraps errors in `ERROR` nodes and continues parsing
38+
39+
unbash might be a good fit if you prefer:
40+
41+
- AST output
42+
- A zero-dependency dependendency that runs in any JS environment
43+
- A typed TypeScript API
44+
- Built-in parsing for command/process substitutions, coproc, Bash 5.3 `${ cmd; }`, `[[ ]]`, `(( ))`, and extglob
45+
- Error recovery that collects errors instead of throwing
46+
47+
## unbash vs sh-syntax
48+
49+
[sh-syntax][2] is a WASM wrapper around the robust [mvdan/sh][3] Go parser. It is highly recommended if you need:
50+
51+
- Support for multiple shell dialects (bash, POSIX sh, mksh, Bats)
52+
- Built-in formatting and pretty-printing (`print`)
53+
54+
unbash might be a good fit if you prefer:
55+
56+
- A zero-dependency, synchronous API
57+
- A detailed AST with structured word parts, parameter expansions, arithmetic expressions, and test expressions
58+
59+
## unbash vs bash-parser
60+
61+
[bash-parser][4] (last publish: 2017) and its fork [@ericcornelissen/bash-parser][5] (community dependency maintenance fork ❤️ now archived) might be interesting if you need:
62+
63+
- A POSIX-only mode that rejects bash-specific syntax
64+
65+
unbash might be a good fit if you prefer:
66+
67+
- A zero-dependency architecture
68+
- A typed TypeScript API (ESM-only)
69+
- Error recovery that collects errors instead of throwing
70+
- Structured AST nodes for parameter expansions, arithmetic expressions, and `[[ ]]` test expressions
71+
- Support for many additional syntax features (like herestrings, C-style for loops, `select`, process substitution, etc. etc.)
72+
73+
## Benchmarks
74+
75+
Relative performance comparison (on Apple M1 Pro/32GB), unbash is x times faster:
76+
77+
| Parser | short | advanced | medium | large |
78+
| ---------------------------- | ----: | -------: | -----: | ----: |
79+
| tree-sitter-bash (native) | 14x | 9x | 4x | 4x |
80+
| tree-sitter-bash (WASM) | 14x | 10x | 7x | 7x |
81+
| sh-syntax | 2008x | 1296x | 7x | 4x |
82+
| bash-parser | 230x | N/A | N/A | N/A |
83+
| @ericcornelissen/bash-parser | 247x | N/A | N/A | N/A |
84+
85+
Run the benchmarks using Node.js v22 or v24:
86+
87+
```sh
88+
pnpm install
89+
node bench/all.ts
90+
```
91+
92+
## Size
93+
94+
unbash is under 50K minified, 12KB gzipped.
95+
96+
## License
97+
98+
ISC
99+
100+
[1]: https://github.com/tree-sitter/tree-sitter-bash
101+
[2]: https://github.com/un-ts/sh-syntax
102+
[3]: https://github.com/mvdan/sh
103+
[4]: https://github.com/vorpaljs/bash-parser
104+
[5]: https://github.com/ericcornelissen/bash-parser

bench/all.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { parse } from "../src/parser.ts";
2+
import { join } from "node:path";
3+
import { bench, group, run, summary } from "mitata";
4+
import { short, advanced, installers, large } from "./fixtures.ts";
5+
6+
type ParseFn = (s: string) => unknown;
7+
type Parser = { name: string; parse: ParseFn; limited?: boolean };
8+
9+
const parsers: Parser[] = [];
10+
11+
try {
12+
// @ts-ignore — CJS default export
13+
const bashParser = (await import("bash-parser")).default;
14+
parsers.push({ name: "bash-parser", parse: bashParser, limited: true });
15+
} catch {
16+
console.warn("Error adding bash-parser");
17+
}
18+
19+
try {
20+
// @ts-ignore — CJS default export
21+
const ecBashParser = (await import("@ericcornelissen/bash-parser")).default;
22+
parsers.push({ name: "@ericcornelissen/bash-parser", parse: ecBashParser, limited: true });
23+
} catch {
24+
console.warn("Error adding @ericcornelissen/bash-parser");
25+
}
26+
27+
try {
28+
// @ts-ignore
29+
const NativeParser = (await import("tree-sitter")).default;
30+
const tsbNative = new NativeParser();
31+
tsbNative.setLanguage((await import("tree-sitter-bash")).default as never);
32+
parsers.push({ name: "tree-sitter (native)", parse: tsbNative.parse.bind(tsbNative) });
33+
} catch {
34+
console.warn("Error adding tree-sitter-bash");
35+
}
36+
37+
try {
38+
// @ts-ignore
39+
const { Language, Parser: WasmParser } = await import("web-tree-sitter");
40+
await WasmParser.init();
41+
const lang = await Language.load(join(import.meta.dirname, "../node_modules/tree-sitter-bash/tree-sitter-bash.wasm"));
42+
const tsbWasm = new WasmParser();
43+
tsbWasm.setLanguage(lang);
44+
parsers.push({ name: "tree-sitter (WASM)", parse: tsbWasm.parse.bind(tsbWasm) });
45+
} catch {
46+
console.warn("Error adding tree-sitter-bash.wasm");
47+
}
48+
49+
type AsyncParser = { name: string; parse: (s: string) => Promise<unknown> };
50+
const asyncParsers: AsyncParser[] = [];
51+
52+
try {
53+
const { parse: shSyntaxParse } = await import("sh-syntax");
54+
await shSyntaxParse("echo hello"); // warm up WASM
55+
asyncParsers.push({ name: "sh-syntax", parse: shSyntaxParse });
56+
} catch {
57+
console.warn("Error adding sh-syntax");
58+
}
59+
60+
for (const [label, scripts] of [
61+
["short", short],
62+
["advanced", advanced],
63+
["medium", installers],
64+
["large", large],
65+
] as const) {
66+
group(label, () => {
67+
summary(() => {
68+
bench("unbash", () => {
69+
for (const script of scripts) parse(script);
70+
}).baseline();
71+
72+
for (const parser of parsers) {
73+
if (/advanced|medium|large/.test(label) && parser.name.includes("bash-parser")) continue;
74+
75+
bench(parser.name, () => {
76+
for (const script of scripts) parser.parse(script);
77+
});
78+
}
79+
80+
for (const parser of asyncParsers) {
81+
bench(parser.name, async () => {
82+
for (const script of scripts) await parser.parse(script);
83+
});
84+
}
85+
});
86+
});
87+
}
88+
89+
await run();

bench/fixtures.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { readdirSync, readFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
4+
const fixturesDir = join(import.meta.dirname, "../fixtures");
5+
6+
const readFixture = (name: string) => readFileSync(join(fixturesDir, "bench", name), "utf8");
7+
8+
function readDir(subdir: string, ext: string): string[] {
9+
const dir = join(fixturesDir, subdir);
10+
const out: string[] = [];
11+
try {
12+
for (const f of readdirSync(dir)) {
13+
if (f.endsWith(ext)) out.push(readFileSync(join(dir, f), "utf8"));
14+
}
15+
} catch {}
16+
return out;
17+
}
18+
19+
export const short = [
20+
"echo hello",
21+
"program -short --long args",
22+
"npm run script",
23+
"pnpm install",
24+
"node script.js",
25+
"tsx ./main.ts",
26+
"bun test",
27+
"eslint .",
28+
"program -x && exec -y -- program2 -z",
29+
"cross-env NODE_ENV=production node -r pkg/config ./script.js",
30+
"dotenv -e .env3 -v VARIABLE=somevalue -- program",
31+
"npx --package @scope/pkg@0.6.0 --package pkg -- curl",
32+
"pnpm --silent run program script.js",
33+
"c8 --reporter=lcov --reporter text node --test --test-reporter=@org/rep",
34+
"NODE_ENV=production cross-env -- program --cache",
35+
"yarn --cwd components vitest -c vitest.components.config.ts",
36+
"changeset version && node ./scripts/deps/update-example-versions.js && pnpm install --no-frozen-lockfile && pnpm run format",
37+
'turbo run build --filter=astro --filter=create-astro --filter="@astrojs/*"',
38+
"biome ci --formatter-enabled=false --enforce-assist=false --reporter=github && eslint . --concurrency=auto && knip",
39+
'if test "$NODE_ENV" = "production" ; then make install ; fi',
40+
'f() { vite build "$@" || (echo content; exit 1;) }; f',
41+
"var=$(node ./script.js)",
42+
"var=`node ./script.js`;var=`node ./require.js`",
43+
'#!/bin/sh\n. "$(dirname "$0")/_/husky.sh"\nnpx lint-staged',
44+
'for S in "s"; do\n\tnpx rc@0.6.0\n\tnpx @scope/rc@0.6.0\ndone',
45+
"NODE_OPTIONS='--require pkg-a --require pkg-b' program",
46+
];
47+
48+
export const advanced = [
49+
"for (( c=1; c<=5; c++ )); do echo $c; done",
50+
"select i in 1 2 3; do echo $i; done",
51+
'[[ "$lsb_dist" != "Ubuntu" || "$ver" < "14.04" ]]',
52+
"case a in a) echo A ;& *) echo star ;; esac",
53+
'echo $"hello world"',
54+
'declare -A map\nmap[key]="value"\necho "${map[key]}"',
55+
'if [[ $(cat $file) =~ $regex ]]; then\n version="${BASH_REMATCH[1]}"\nfi',
56+
"node --maxWorkers=\"$(node -e 'process.stdout.write(os.cpus().length.toString())')\"",
57+
'[ "$CF_PAGES" = "1" ] && find dist -mindepth 2 -type f -name \'index.html\' -exec bash -c \'f="$1"; d=$(dirname "$f"); bn=$(basename "$d"); mv -v "$f" "$d/../$bn.html"\' _ {} \\; || true',
58+
'caddy run --config - <<< \'{"apps":{"http":{"servers":{"srv0":{"listen":[":8003"]}}}}}\'',
59+
'pnpm exec "cat package.json | jq -r \'\\\"\\\\(.name)@\\\\(.version)\\\"\'" | sort',
60+
"echo data | tee >(grep pattern > matches.txt) >(wc -l > count.txt) > /dev/null",
61+
"wc -c <(echo abc && echo def)",
62+
'case "$Z" in\n ab*|cd*) ef ;;\nesac',
63+
];
64+
65+
export const installers = readDir("installers", ".sh");
66+
67+
export const large = readDir("large", ".sh");
68+
69+
export const specialized: [string, string[]][] = [
70+
["word-parts", [readFixture("word-parts.sh")]],
71+
["param-expansion", [readFixture("param-expansion.sh")]],
72+
["arithmetic", [readFixture("arithmetic.sh")]],
73+
["test-expressions", [readFixture("test-expressions.sh")]],
74+
["heredoc-expansion", [readFixture("heredoc-expansion.sh")]],
75+
["assignments", [readFixture("assignments.sh")]],
76+
["dedicated-nodes", [readFixture("dedicated-nodes.sh")]],
77+
];

bench/self.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { parse } from "../src/parser.ts";
2+
import { bench, group, run } from "mitata";
3+
import { short, advanced, specialized, installers, large } from "./fixtures.ts";
4+
5+
for (const [label, scripts] of [
6+
["short", short],
7+
["advanced", advanced],
8+
...specialized,
9+
["medium", installers],
10+
["large", large],
11+
] as const) {
12+
group(label, () => {
13+
bench(label, () => {
14+
for (const s of scripts) parse(s);
15+
});
16+
});
17+
}
18+
19+
await run();

0 commit comments

Comments
 (0)