| name | sh-ast |
| description | Parse and analyze shell commands using @aliou/sh. Use when working with shell
scripts programmatically: extracting commands, analyzing pipelines, finding
variables, checking for unsafe patterns, transforming shell code, or traversing
the AST.
|
@aliou/sh - Shell Parser
Parse shell commands into a typed AST for analysis and transformation.
Quick Start
import { parse } from "@aliou/sh";
const { ast } = parse('echo "hello $USER" | grep hello');
AST Structure
Program → contains Statement[] → each wraps a Command
Command Types
| Type | Shell Syntax |
|---|
SimpleCommand | cmd args... |
Pipeline | cmd1 | cmd2 |
Logical | cmd1 && cmd2, cmd1 || cmd2 |
IfClause | if cond; then ... fi |
WhileClause | while cond; do ... done |
ForClause | for x in a b c; do ... done |
SelectClause | select x in a b c; do ... done |
CStyleLoop | for ((i=0; i<10; i++)); do ... done |
CaseClause | case x in a) ... esac |
FunctionDecl | foo() { ... } |
Subshell | ( ... ) |
Block | { ... } |
TestClause | [[ ... ]] |
ArithCmd | (( ... )) |
CoprocClause | coproc name { ... } |
TimeClause | time cmd |
DeclClause | declare, local, export |
LetClause | let x=1 |
Word Parts
Words in commands contain typed parts:
| Part | Example |
|---|
Literal | echo |
SglQuoted | 'hello' |
DblQuoted | "hello $USER" (contains nested parts) |
ParamExp | $USER, ${var:-default} |
CmdSubst | $(date), `date` |
ArithExp | $((1 + 2)) |
ProcSubst | <(cmd), >(cmd) |
BraceExp | {a,b}, {1..5} (via splitBraces) |
ExtGlob | @(foo), *(bar) (Bash/mksh) |
Positions
Every AST node has pos and end of type Pos:
import type { Pos } from "@aliou/sh";
interface Pos {
offset: number;
line: number;
col: number;
}
Use NO_POS as a sentinel when building nodes outside the parser.
Parser Options
interface ParseOptions {
dialect?: "posix" | "bash" | "mksh" | "zsh";
keepComments?: boolean;
recoverErrors?: boolean;
}
Use recoverErrors: true to get a partial AST plus an errors array instead of throwing.
Other Exports
parseStmtsSeq(source, options?) -- yield each top-level statement lazily
parseWordsSeq(source, options?) -- yield each word lazily
splitBraces(word) -- expand {a,b} / {1..5} brace expansion in place
NO_POS -- sentinel position
Examples
Extract Command Names
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)) {
(.(val)) {
( item val) names.(...(item));
} (val && val === ) {
names.(...(val));
}
}
names;
}
{ ast } = ();
(ast);
Find All Variables Used
import { parse, type WordPart } from "@aliou/sh";
function findVariables(node: unknown): Set<string> {
const vars = new Set<string>();
function walk(n: unknown): void {
if (!n || typeof n !== "object") return;
const obj = n as Record<string, unknown>;
if (obj.type === "ParamExp") {
const param = (obj as { param: { value: string } }).param;
vars.add(param.value);
}
for (const val of Object.values(obj)) {
if (Array.isArray(val)) val.forEach(walk);
else if (val && typeof val === ) (val);
}
}
(node);
vars;
}
{ ast } = ();
(ast);
Check for Command Substitution (security analysis)
import { parse } from "@aliou/sh";
function hasCommandSubstitution(node: unknown): boolean {
if (!node || typeof node !== "object") return false;
const obj = node as Record<string, unknown>;
if (obj.type === "CmdSubst" || obj.type === "ProcSubst") {
return true;
}
for (const val of Object.values(obj)) {
if (Array.isArray(val)) {
if (val.some(hasCommandSubstitution)) return true;
} else if (val && typeof val === "object") {
if (hasCommandSubstitution(val)) return true;
}
}
return false;
}
const { ast } = parse('echo $(dangerous)');
hasCommandSubstitution(ast);
Analyze Pipeline Structure
import { parse, type Statement, type Pipeline, type SimpleCommand } from "@aliou/sh";
interface PipelineInfo {
commands: string[];
hasBackground: boolean;
}
function analyzePipeline(stmt: Statement): PipelineInfo | null {
const cmd = stmt.command;
if (cmd.type !== "Pipeline") return null;
const commands: string[] = [];
for (const pipelineStmt of cmd.commands) {
if (pipelineStmt.command.type === "SimpleCommand") {
const sc = pipelineStmt.command as SimpleCommand;
if (sc.words?.[0]?.parts?.[0]?.type === "Literal") {
commands.push(sc.words[].[].);
}
}
}
{
commands,
: stmt. ?? ,
};
}
{ ast } = ();
(ast.[]);
Extract Redirects
import { parse, type SimpleCommand, type Redirect } from "@aliou/sh";
interface RedirectInfo {
op: string;
fd?: string;
target: string;
}
function extractRedirects(cmd: SimpleCommand): RedirectInfo[] {
if (!cmd.redirects) return [];
return cmd.redirects.map((r: Redirect) => ({
op: r.op,
fd: r.fd,
target: r.target.parts
.map((p) => (p.type === "Literal" ? p.value : p.type === "ParamExp" ? `$${p.param.value}` : "..."))
.join(""),
}));
}
const { ast } = parse("echo hello > file.txt 2>&1");
cmd = ast.[]. ;
(cmd);
fd is either a numeric string ("2") or, in Bash/Zsh, a {varname} redirect such as "{fd}":
const { ast } = parse("foo {fd}<file");
const cmd = ast.body[0].command as SimpleCommand;
extractRedirects(cmd);
{varname} redirects are rejected in POSIX and mksh dialects.
Count AST Nodes (complexity metric)
import { parse } from "@aliou/sh";
function countNodes(node: unknown): number {
if (!node || typeof node !== "object") return 0;
const obj = node as Record<string, unknown>;
let count = 1;
for (const val of Object.values(obj)) {
if (Array.isArray(val)) {
count += val.reduce((sum, item) => sum + countNodes(item), 0);
} else if (val && typeof val === "object") {
count += countNodes(val);
}
}
return count;
}
const { ast } = parse("if true; then echo hi; fi");
countNodes(ast);
Expand Brace Expansion
import { parse, splitBraces, type Word } from "@aliou/sh";
const { ast } = parse("echo {a,b,c}.txt");
const word = ast.body[0].command as { words?: Word[] };
const target = word.words?.[1];
if (target) splitBraces(target);
Common Patterns
Walk all nodes
function walk(node: unknown, visitor: (n: unknown) => void): void {
if (!node || typeof node !== "object") return;
visitor(node);
const obj = node as Record<string, unknown>;
for (const val of Object.values(obj)) {
if (Array.isArray(val)) val.forEach((item) => walk(item, visitor));
else if (val && typeof val === "object") walk(val, visitor);
}
}
Type guards
function isSimpleCommand(node: unknown): node is { type: "SimpleCommand" } {
return (
typeof node === "object" &&
node !== null &&
(node as { type?: string }).type === "SimpleCommand"
);
}
Limitations
- Not a complete POSIX/Bash parser; some edge cases may differ from mvdan/sh.
ExtGlob keeps its inner pattern as a raw string.
parse does not perform brace expansion; call splitBraces explicitly.