Fast 0-deps bash parser written in TypeScript
npm install unbashUse 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 commands, redirects, substitutions, and background execution for permission prompts, allowlists, or audit findings
- Statically inventory executables and literal file, configuration, and dependency references in package scripts, CI steps, task-runner configs, hooks, and source-code shell calls
- Import or convert a supported command such as
curlwithout folding neighboring pipelines, logical chains, redirects, or comments into its arguments - 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
- Find or migrate command invocations and flags with source-range edits that leave surrounding formatting and comments untouched
When Bash is embedded in JSON, YAML, or source code, use the host-language parser to extract the shell string first. unbash provides Bash structure and source ranges within that string; the consumer owns command-specific argument semantics and policy.
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.
Nested commands remain structured inside parameter operands and array indexes, arithmetic expressions, brace expansions, extglobs, redirect targets, and heredoc bodies. Nodes retain source positions and words retain both raw text and dequoted values.
Malformed and incomplete input returns a best-effort partial AST with detected,
source-positioned errors. Recovery is bounded for deeply nested parameter and
arithmetic expansions, substitutions, subshells, braces, conditionals, loops,
select, case, and [[ ]] groups.
unbash does not execute code, perform shell expansion, provide a sandbox, or
decide whether a command is safe. Security-sensitive consumers must inspect word
parts, nested scripts, and errors on each parsed script. 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. It does not
target PowerShell, cmd.exe, or other shell languages. Much POSIX sh syntax
is also valid Bash.
To parse process.argv (string[]), use Node.js parseArgs or a CLI
library such as yargs or citty.
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...
A Word holds its expansions in parts. This is a lazy getter, computed on
first access (not an own enumerable property):
const word = parse("echo a$(id)b").commands[0].command.suffix[0];
word.parts; // [Literal, CommandExpansion, Literal]
Object.keys(word); // ["text", "pos", "end"] — no `parts`
({ ...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.suffix]) {
for (const part of word?.parts ?? []) {
if (part.type === "CommandExpansion") console.log(part.text);
}
}
}
// $(mktemp)Word-like fields that can execute nested shell syntax expose the same structure.
BraceExpansion, ExtendedGlob, and ArithmeticWord use parts; parameter
and assignment array indexes use indexParts.
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 a non-enumerable source property. Ordinary scripts nested inside it
index that decoded source. Object spread and structuredClone omit source
because it is non-enumerable.
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"
fitree-sitter-bash is the right choice when you need:
- Incremental parsing
- CST output preserving all tokens and punctuation
- Granular error recovery that wraps errors in
ERRORnodes and continues parsing
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
sh-syntax is a WASM wrapper around the robust mvdan/sh Go parser. It is highly recommended if you need:
- Support for multiple shell dialects (Bash, POSIX sh, mksh, Bats, and Zsh)
- 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
bash-parser (last publish: 2017) 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
Parse throughput in MB/s, higher is better, with unbash's relative speed in parentheses. Median of eleven runs on Apple M1 Pro/32GB using Node.js 24.19.0.
| Parser | short (1.2KB) | advanced (0.9KB) | medium (150KB) | large (965KB) |
|---|---|---|---|---|
| unbash | 75.7 | 69.4 | 97.1 | 115.7 |
| tree-sitter-bash (native) | 4.59 (16x) | 6.52 (11x) | 14.90 (7x) | 11.78 (10x) |
| tree-sitter-bash (WASM) | 4.56 (17x) | 5.57 (12x) | 8.55 (11x) | 7.55 (15x) |
| sh-syntax | 0.03 (2870x) | 0.04 (1947x) | 7.59 (13x) | 13.58 (9x) |
| bash-parser | 0.28 (275x) | n/a | n/a | n/a |
| @ericcornelissen/bash-parser | 0.26 (291x) | n/a | n/a | n/a |
Run the benchmarks using Node.js v22+:
pnpm install
node bench/all.tsThe parser bundle is 80KB minified and 19KB gzipped.
ISC