unbash
v5.1.0
Published
Fast 0-deps bash parser written in TypeScript
Maintainers
Readme
unbash
Fast 0-deps bash parser written in TypeScript
Install
npm install unbashWhen to use unbash?
Use unbash when your input is Bash source: a pasted command, a complete script, or shell source embedded in another format, and you need to inspect its structure without executing it. It returns a typed, source-positioned AST.
Example use cases:
- Classify scripts for permission prompts, allowlists, or audit findings
- Collect executables and literal file, configuration, and dependency references
- Import or convert commands such as
curlto code - Build a shell portability checker using parse errors and AST analysis
- Attach diagnostics to generated or pasted Bash using source-positioned errors and partial trees
- Build explanations, visualizations, or structured previews of pipelines, conditions, expansions, and nested commands
Supported syntax
unbash supports commands, control flows, pipelines, redirects, assignments, compound statements, parameter and word expansions, process and nested substitutions, coproc, heredocs, herestrings, nested and generated syntax, etc.
unbash parses GNU Bash 5.3 with non-interactive defaults including extglob
patterns. POSIX sh scripts parse as Bash. unbash does not gate syntax by
version (i.e. it accepts syntax that older Bash releases reject).
Unsupported syntax
Other shell languages are not supported: zsh, ksh93, mksh, fish, PowerShell,
cmd.exe, csh, Bats test files. Such scripts may produce errors or a misleading
tree.
unbash is a tolerant parser: for malformed or incomplete input, it recovers where possible and returns a best-effort partial AST with source-positioned errors.
To parse process.argv (string[]), use Node.js parseArgs or a library
such as yargs or citty.
Usage
import { parse } from "unbash";
const ast = parse('if [ -f "$1" ]; then cat "$1"; fi');Result:
{
type: "Script",
commands: [{
type: "Statement",
command: {
type: "If",
clause: { type: "CompoundList", commands: [ /* [ -f "$1" ] */ ] },
then: { type: "CompoundList", commands: [ /* cat "$1" */ ] }
}
}]
}See the full AST at unbash.statichost.page#input=if [ -f...
Word parts
A Word holds its expansions in parts. This is a lazy getter, computed on
first access (not an own enumerable property):
const source = "echo a$(id)b";
const word = parse(source).commands[0].command.suffix[0];
word.parts; // [Literal, CommandExpansion, Literal]
Object.keys(word); // ["type", "text", "pos", "end"]
({ ...word }); // same
structuredClone(word); // sameRead parts directly, or serialize with JSON.stringify, which includes it
through toJSON. A generic walker driven by Object.keys finds no expansions
at all, and reports no error while doing so:
import { parse } from "unbash";
const script = parse('echo "$HOME" $(mktemp)');
for (const statement of script.commands) {
const command = statement.command;
if (command.type !== "Command") continue;
for (const word of [command.name, ...command.args]) {
if (word?.type !== "Word") continue;
for (const part of word.parts ?? []) {
if (part.type === "CommandExpansion") console.log(part.text);
}
}
}
// $(mktemp)BraceExpansion, ExtendedGlob, ArithmeticWord, and HereDocBody expose
nested syntax through parts. Parameter and assignment indexes are Words with
their own parts. Assignment values are Words or ArrayValues; array elements
are Words. Declaration assignments and redirects are separate command items,
each with its own children. Command.args and Command.redirects are lazy
views over the ordered prefix and suffix items. They are getters like
parts, but JSON.stringify omits them because the two lists already carry
every item.
Positions are zero-based UTF-16 code-unit offsets forming half-open [pos, end)
ranges in the source owned by the nearest ParsedScript. Root scripts and
verbatim nested substitutions share the caller's source, so their ranges slice
that source directly:
const nested = word.parts.find(
(part) => part.type === "CommandExpansion",
).script;
const command = nested.commands[0].command;
source.slice(command.pos, command.end); // exact nested command sourceA legacy backtick script whose body contains backslash escapes owns its decoded
string as an enumerable source property, retained by JSON serialization.
Ordinary scripts nested inside it index that decoded source. Live nodes still
have lazy prototype getters; use JSON.stringify when serializing their
complete public syntax.
Parse errors inside a lazily parsed script surface on that script, not on the
root: check errors on every nested script while traversing. A consumer that
only reads the root errors array cannot tell that a substitution body failed
to parse.
Basic opinionated printer, does not preserve whitespace or comments (except shebang):
import { parse } from "unbash";
import { print } from "unbash/printer";
const ast = parse('if [ -f "$1" ]; then cat "$1"; fi');
const script = print(ast);Result:
if [ -f "$1" ]; then
cat "$1"
fiunbash vs tree-sitter-bash
tree-sitter-bash is the right choice when you need:
- Incremental parsing
- CST output preserving all tokens and punctuation
- Error recovery with
ERRORnodes and missing tokens
unbash provides:
- A typed, executable-syntax AST instead of a grammar CST
- A synchronous, zero-dependency TypeScript package with no native addon, WASM runtime, parser initialization, or query layer
- Structured word parts, arithmetic and test-expression trees, recursively parsed substitutions, and direct source positions
- Best-effort error recovery that preserves a partial AST and collects errors
- Also see tree-sitter-bash gaps covered by unbash
unbash vs sh-syntax
sh-syntax is a WASM wrapper around the robust mvdan/sh Go parser. It is highly recommended if you need:
- Support for Bash, POSIX sh, mksh, Bats, and experimental Zsh parsing
- Mature, configurable formatting and pretty-printing
unbash provides:
- A zero-dependency, synchronous TypeScript API without WASM loading
- A smaller, JSON-friendly Bash AST with lazy structured word parts, recursively parsed substitutions, and direct source positions
- Best-effort partial ASTs for malformed or incomplete editor and user input
- Also see sh-syntax gaps covered by unbash
unbash vs bash-parser
bash-parser and its fork @ericcornelissen/bash-parser (community dependency maintenance fork ❤️ now archived) provide:
- A POSIX-only mode that rejects bash-specific syntax
unbash provides:
- A zero-dependency architecture
- A typed TypeScript API (ESM-only)
- Best-effort error recovery that preserves a partial AST and collects errors
- Structured AST nodes for parameter expansions, arithmetic expressions, and
[[ ]]test expressions;bash-parsertreats[[ ]]as ordinary commands and(( ))as nested subshells - Herestrings, C-style
for,select, process substitution,coproc, array assignments, extglob,;&/;;&case fallthrough, Bash 5.3 command substitutions, and{variable}file-descriptor redirects
Benchmarks
Parse throughput in MB/s; higher is better. Median per-run p75 iteration times from 11 runs per unbash version and 22 per other parser, on Apple M1 Pro/32GB with Node.js 24.19.0. Parentheses show speed relative to unbash v5.
| Parser | short (1.1KiB) | advanced (0.9KiB) | medium (150KiB) | large (970KiB) | | ---------------------------- | -------------: | ----------------: | --------------: | -------------: | | unbash v5 | 77.3 | 70.3 | 101.5 | 117.6 | | unbash v4 | 79.2 (0.98x) | 71.3 (0.99x) | 97.9 (1.04x) | 116.0 (1.01x) | | tree-sitter-bash (native) | 4.59 (17x) | 6.51 (11x) | 15.08 (7x) | 11.90 (10x) | | tree-sitter-bash (WASM) | 5.02 (15x) | 5.61 (13x) | 8.85 (11x) | 8.27 (14x) | | sh-syntax | 0.03 (2290x) | 0.04 (1659x) | 8.28 (12x) | 14.06 (8x) | | bash-parser | 0.27 (290x) | n/a | n/a | n/a | | @ericcornelissen/bash-parser | 0.26 (301x) | n/a | n/a | n/a |
Run the benchmarks using Node.js 24+:
pnpm install
pnpm benchSize
The parser bundle is 85K minified and 21K gzipped.
Playgrounds
License
ISC
