TypeScript shell parser inspired by mvdan/sh. Parses POSIX/Bash/mksh/zsh shell commands into a typed AST.
Zero runtime dependencies. ~66 KB bundled.
import { parse } from "@aliou/sh";
const { ast } = parse('echo "hello $USER" | grep hello');
// ast.type === "Program"
// ast.body[0].command.type === "Pipeline"The parser returns a Program node containing Statement nodes. Each statement wraps a Command, which is one of:
SimpleCommand-- words, assignments, redirectsPipeline,Logical(&&,||)IfClause,WhileClause,ForClause,SelectClause,CaseClauseFunctionDecl,Subshell,BlockTestClause([[ ]]),ArithCmd((( ))),CoprocClause,TimeClauseDeclClause(declare,local,export,readonly,typeset,nameref)LetClause(let),CStyleLoop(for (( ; ; )))
Words contain typed parts: Literal, SglQuoted, DblQuoted, ParamExp, CmdSubst, ArithExp, ProcSubst, BraceExp, ExtGlob.
All AST nodes carry source positions via pos and end (Pos: offset, line, col).
import { parse, type SimpleCommand } from "@aliou/sh";
function extractCommandNames(node: unknown): string[] {
if (!node || typeof node !== "object") return [];
const n = node as Record<string, unknown>;
const names: string[] = [];
if (n.type === "SimpleCommand") {
const cmd = n as unknown as SimpleCommand;
if (cmd.words?.length) {
const first = cmd.words[0];
if (first.parts.length === 1 && first.parts[0].type === "Literal") {
names.push(first.parts[0].value);
}
}
}
for (const val of Object.values(n)) {
if (Array.isArray(val)) {
for (const item of val) names.push(...extractCommandNames(item));
} else if (val && typeof val === "object") {
names.push(...extractCommandNames(val));
}
}
return names;
}
const { ast } = parse("grep -rn npm package.json | head -5");
extractCommandNames(ast); // ["grep", "head"]- Simple commands, pipelines, logical operators (
&&,||) - Single and double quotes, parameter expansion (
$var,${var:-default}) - Command substitution (
$(cmd),`cmd`), arithmetic expansion ($((expr))) - Process substitution (
<(cmd),>(cmd)) - Heredocs (
<<,<<-), herestrings (<<<) - All redirect operators (
>,>>,<,>&,<&,<>,>|,&>,&>>), including{varname}file-descriptor redirects (foo {fd}<file, Bash/Zsh only) - Assignments (
FOO=bar cmd), append assignments (FOO+=bar) - Array expressions (
arr=(a b c),arr=([0]=x [1]=y)) - Declaration builtins as special forms (
declare,local,export,readonly,typeset,nameref) letexpressions (let i++ j=2)- Control flow:
if/elif/else/fi,while/until,for/in,for ((...)),select/in,case/esac - Functions (
foo() {},function foo {}) - Subshells
(), blocks{} [[ ]]test expressions,(( ))arithmetic commandscoproc,time, negation (!)- Extended globs (
@(foo),*(bar)) in Bash/mksh mode - Comments (optionally preserved via
keepCommentsoption), backslash line continuations, background (&), semicolons
Brace expansion ({a,b}, {1..5}) is available via the splitBraces helper; the parser does not emit it by default.
interface ParseOptions {
dialect?: "posix" | "bash" | "mksh" | "zsh"; // default: "bash"
keepComments?: boolean; // default: false
recoverErrors?: boolean; // default: false
}Use recoverErrors: true to get a partial AST and a list of non-fatal parse errors instead of throwing.
parseStmtsSeq(source, options?)-- lazy generator yielding top-level statementsparseWordsSeq(source, options?)-- lazy generator yielding wordssplitBraces(word)-- expand{a,b}/{1..5}brace expansion in a wordNO_POS-- sentinel position for nodes built outside the parser
npm install @aliou/shRequires Nix (provides Node 24 and pnpm):
nix develop
pnpm install # install deps
pnpm test # run tests (vitest)
pnpm typecheck # tsc --noEmit
pnpm lint # biome check
pnpm format # biome check --write
pnpm build # rolldown + tsc declarationsGit hooks (via lefthook):
- pre-commit: staged file formatting/linting + typecheck
- pre-push: tests
Work in progress. Covers the Bash subset needed for AST-based command analysis (command classification, variable mutation tracking, guardrail enforcement). Not a complete POSIX/Bash parser.
UNLICENSED