File size: 16,106 Bytes
d522ffa | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 | # @moonshot-ai/tree-sitter-bash
A pure-TypeScript bash parser that produces a syntax tree whose named node
types match [tree-sitter-bash](https://github.com/tree-sitter/tree-sitter-bash)
0.25.0 one-to-one, built for agent-side command permission analysis.
- No native addons; offsets are UTF-16 code units (`node.text` is always a
direct `source.slice(startIndex, endIndex)`).
- Parsing runs under a hard budget and never throws: budget exhaustion
returns `{ ok: false, reason: 'aborted' }`, malformed input returns a
degraded tree with `hasError: true`.
- Correctness is enforced by a differential test suite that parses hundreds
of samples β plus the complete official tree-sitter-bash 0.25.0 corpus β
with both this parser and the real tree-sitter-bash (wasm) and compares
the trees byte-for-byte (see `test/fixtures/`). The only remaining
deviations are the documented ones listed under
[Known differences](#known-differences-from-tree-sitter-bash) below.
## API
```ts
import { parse } from '@moonshot-ai/tree-sitter-bash';
const result = parse('git status && rm -rf /');
if (result.ok) {
// result.rootNode: program β list β command β¦
}
```
- `parse(source: string, options?: ParseOptions): ParseResult`
- `ParseResult` is one of:
- `{ ok: true; rootNode: SyntaxNode; hasError: boolean }`
- `{ ok: false; reason: 'aborted' }`
- `ParseOptions`: `{ timeoutMs?: number; maxNodes?: number }` (defaults
50 ms / 50 000 nodes; `timeoutMs: Infinity` disables the time check).
- `SyntaxNode`: `{ type, text, startIndex, endIndex, isNamed, parent,
children, namedChildren }`. Named node types come from tree-sitter-bash's
`node-types.json`; `children` includes the anonymous (punctuation /
keyword token) nodes as well, `namedChildren` only the named ones, and
`descendantsOfType(root, ...types)` walks named descendants.
- Depth caps (pathological nesting degrades locally β ERROR nodes,
`hasError: true` β instead of overflowing the stack):
- `MAX_SUBSTITUTION_DEPTH = 150` for the $( β¦ ) / `β¦` / <( β¦ )
sub-parser chain (the deepest per level, ~13 frames β measured stack
overflow at ~380β500 levels on a default Node stack, so the cap keeps
a β₯2.5Γ margin);
- `MAX_PARSE_DEPTH = 500` for subshell/compound/`${β¦}`/expression
nesting (verified safe at those caps);
- `MAX_SCAN_DEPTH = 1024` for the lexer's own scan recursion.
## Budget semantics
The budget caps total work, not input size: a multi-hundred-KB string or
heredoc body parses fine (it produces only a handful of nodes), but a
source whose tree would exceed 50 000 nodes β a megabyte-long `case`
statement, tens of thousands of arithmetic expressions β is aborted under
the defaults. Every created node ticks the budget; long character-level
scan loops re-check the deadline periodically. When either limit is hit,
`parse` returns `{ ok: false, reason: 'aborted' }` and the abort is prompt
(see Performance). Pass `timeoutMs` / `maxNodes` to raise the limits.
## Error recovery
Malformed input never throws either. Unterminated constructs (quotes,
expansions, substitutions, heredocs, compound commands) are kept as
partial nodes and flagged with `hasError: true`; tokens that cannot start
or continue a statement (stray `)`, a leading `&&`, β¦) are wrapped in
`ERROR` nodes and parsing continues. Newlines never appear in the tree:
statement-terminating `\n`s produce no nodes at all (matching
tree-sitter-bash, whose scanner only emits `\n` tokens in heredoc
position), while `;` / `&` terminators are anonymous children. As a
last-resort guard against parser bugs, any unexpected internal exception
degrades to a `program` root with a single `ERROR` child spanning the
source and `hasError: true` β callers still get a usable tree.
## Known differences from tree-sitter-bash
Named node types always come from tree-sitter-bash's `node-types.json`, but
for the following constructs the tree shape deliberately deviates from what
tree-sitter-bash 0.25.0 produces (each verified against the real parser,
and pinned by a stored expectation in `test/fixtures/` β see
`test/helpers/known-differences.ts`):
- `<>` (read-write redirect) is parsed as a normal `file_redirect`
operator; tree-sitter-bash 0.25.0 fails to parse it.
- Several heredocs on one line (`cat <<A <<B`) cannot be represented as an
overlap-free tree (their bodies interleave with the redirect nodes).
tree-sitter-bash errors; this parser degrades the second `<<` to an
`ERROR` node, skips its body registration, and parses the "body" lines as
ordinary commands, mirroring the reference's recovery shape. The same
applies to a `<<` inside a heredoc's line tail.
- Statements after a heredoc on the same line (`cat <<EOF; echo x`, also
`&`) are absorbed into the `heredoc_redirect` node so the tree stays
overlap-free once the body (which textually follows the whole line) is
attached. tree-sitter-bash errors on the whole line.
- Inside an unquoted `heredoc_body`, every plain text chunk β including the
leading one β becomes a `heredoc_content` node, and backtick command
substitutions are parsed as `command_substitution` children.
tree-sitter-bash's scanner hides the leading chunk and swallows backticks
into the content.
- A `$'` sequence inside an unquoted heredoc body is plain text here
(bash does not expand ANSI-C strings in heredocs; the reference does the
same mid-line). Quirk: when a body line STARTS with `$'β¦'`, the
reference's scanner errors and drops the whole `heredoc_redirect`.
- A heredoc redirect at statement start (`<<EOF cat`) parses normally;
tree-sitter-bash errors.
- Unterminated or invalid constructs keep their partial nodes with
`hasError: true` (see above); tree-sitter-bash degrades them to `ERROR`
nodes, and the exact recovery shape differs. This covers invalid compound
commands β a fallthrough terminator on the last case item
(`a) x ;;& esac`), a missing separator before a compound keyword
(`if a; then b fi`, `{ ls }` β both flagged `hasError: true` here), a
heredoc inside an if/while condition, `foo () ls`, a regex with an
unquoted space after `=~` β and small recovery details:
- An unterminated test command always keeps its partial `test_command`
with a zero-width closer here. The reference only inserts that
zero-width closer for an unterminated `[` or for a `[[` ending in a
complete unary test (`[[ -f file`); other unterminated `[[` forms
(`[[ $a`, `[[ a == b`) become an `ERROR` node with no closer there.
(An empty `[[ ]]` sets the error flag in BOTH parsers β the reference
recovers by inserting the missing closer as a zero-width token β and
this parser matches its tree exactly: a concatenation of two `]` word
pieces plus a zero-width `]]`.)
- An unterminated compound command gets no synthetic keyword here
(`if a; then b;` ends after the last statement with `hasError: true`);
the reference inserts zero-width keyword nodes (a zero-width `fi`).
- `${x@}` (transformation operator with no letter) keeps an `expansion`
with `hasError: true` here; the reference splits it into an `ERROR`
node and a `}` command.
- A trailing connector (`ls &&`, `ls |`) yields a single-child
`list` / `pipeline` with `hasError: true`; tree-sitter-bash inserts a
zero-width `command` recovery node.
- An empty backtick substitution (`` `` ``), or a pair containing only
whitespace, is a `command_substitution` with no statements;
tree-sitter-bash treats the two backticks as a single `` `` `` token
that is only valid inside a concatenation and errors in argument
position.
- An empty command substitution (`$( )`) is a clean `command_substitution`
with no statements here; the reference inserts a zero-width `command`
recovery node (and flags `hasError`).
- In arithmetic, a hex literal is a `number` (`$((0x1F))` β number
"0x1F"); the reference's arithmetic number token does not cover hex and
produces a `variable_name` "0x1F" instead (a reference quirk β bash does
evaluate hex in arithmetic).
- `[[ ((a) == x) && y ]]` (parenthesized test group followed by `&&`)
parses cleanly here as nested `parenthesized_expression`s; the reference
mis-reads it as an `arithmetic_expansion` with an embedded `ERROR` node
(a reference quirk β the valid `[[ ((a)) == x ]]` form matches exactly).
- An extglob group the reference rejects β one that directly follows a
pure literal or a dot (`x@(y|z)w`, `*.@(a|b)`, `_@(y)`, `*@(y)`,
`.@(y)`) β is an error there (an `ERROR` node inside the comparison);
this parser ends the pattern at the group and recovers with a different
error shape. Groups the reference ACCEPTS β at the start of the right
side (`+(a|b)`) or after glob characters (`*/@(default).vim`,
`*_@(LIB|SYMLINK)`, `@(LLD|GNU\ ld)*`, `*([0-9])([0-9])`, `\"+(?)\"`) β
match exactly, as do right sides split around one substitution or quote
(`*${var}*`, `*/${a}*`, `*"7f 45"*`).
- An extglob group the reference rejects in a case pattern (`x@(y))`, and
the same mechanism for `.(` (`x.(y))`, `*.(a|b))` β `.` is not a sigil,
but the reference rejects it identically): the reference reparses the
group as the start of a new case item; this parser degrades the whole
pattern to an `ERROR` node. (Accepted groups β `*(a|b))`,
`*([0-9])([0-9]))` β match exactly.)
- A single-dash short-option case pattern (`-o)`) is a `word` β the form
this parser always produces β but the reference's classification flips
with leftover scanner state (a first case item right after the `in`
line's newline parses it as an `extglob_pattern`).
- A case pattern directly after a line continuation (`| \<newline>`) is a
`word` in the reference even when its content would make it a glob
(`cmake_modules` β a scanner-state quirk); this parser classifies
patterns by content, so such a pattern is an `extglob_pattern` here. A
continuation INSIDE a pattern (`cont\<newline>ued)`) is an error in the
reference (word "cont" + `ERROR` "ued", `hasError: true`), and a
continuation fused with a digit (`a\<newline>1)`) is an
`extglob_pattern` there; this parser keeps the continuation and parses
the whole pattern cleanly as one `word` β which is also how bash itself
reads it.
- A comparison right side with TWO substitutions or quotes
(`*${x}*${y}`, `*"s"*"t"`) does not fit the reference's one-construct
pattern rule either; it recovers with a nested `binary_expression`
there, while this parser keeps a single `concatenation` right side.
- A few `[[ β¦ ]]` comparison right-hand sides deviate:
- A negative decimal operand (`[[ $n == -0.5 ]]`) is a
`unary_expression` (`-` over word "0.5") in the reference; this parser
produces a single `extglob_pattern` "-0.5" (the unary-minus reading
only covers integers here).
- A test operator fused with an expansion (`[[ -x$f && β¦ ]]`) is a
`unary_expression` (`-` over a `concatenation` of `x` and `$f`) in the
reference; this parser produces a flat `concatenation` of `-x` and
`$f` with no unary wrapper.
- An escaped `|` inside a pattern (`[[ $a == foo\|bar ]]`) splits the
expression in the reference β `binary_expression(==, extglob "foo\")`
then `|` then word "bar" (a reference quirk); this parser keeps one
`extglob_pattern` "foo\|bar".
- `${!# }` and `${!## }` (and `${!##/}`) are pathological expansion forms:
the reference recovers with zero-width operator tokens, this parser with
a flagged partial `expansion`; the shapes differ. (The sane forms
`${#}`, `${!#}`, `${!##}`, `${#!}` all match exactly.)
- A base prefix fused with an expansion (`10#${x}`) is one `number` node
spanning the expansion in the reference (a quirk); this parser produces
a `concatenation` of word "10#" and the expansion.
- `${v:-(default)}` (a parenthesized default value) is a plain `word`
here; the reference parses it as an `array` node (a quirk).
- `${=1}` (zsh-style word splitting flag) produces a `variable_name` "1"
in the reference; this parser produces a `word`.
- An escaped space or tab between arguments is dropped by the reference
as invisible extras (its tree has gaps: `echo 1 \ 2` produces number
"1" and number "2", and `echo a\<TAB>b` splits into word "a" and word
"b"); this parser keeps the escape inside the word. (An escaped space
INSIDE a word, `a\ b`, is kept by both parsers and matches.)
- A `$` directly fused with a backtick substitution (`` $`echo x` ``) is
one `command_substitution` with a `$`+backtick token in the reference;
this parser produces a `($)` token followed by the substitution.
- A non-ASCII βidentifierβ in assignment position (`ει=εΌ`, `Γ©=1`) is a
`variable_assignment` flagged `hasError` in the reference; this parser
keeps it a plain command word β which is also how bash itself treats it
(variable names are ASCII-only, so the text runs as a command).
- A negative literal after a compound assignment in a c-style for header
(`for (( β¦ j *= -1, β¦ ))`) is folded into a `number` "-1" by the
reference; this parser produces a `unary_expression` (`-` over number
"1") β matching what the reference itself produces everywhere else.
- A double backslash in a replacement value (`${x// /\\|}`) loses its
first backslash in the reference's tree (a quirk β the `word` starts
one character later); this parser keeps both.
- `string_content` is not split at newlines (tree-sitter-bash's scanner
splits it).
For completeness, a few constructs that LOOK like deviations but are not β
the reference behaves the same way (verified):
- `coproc` has no grammar support in tree-sitter-bash 0.25.0 and parses as
an ordinary command; so does this parser.
- `select` is parsed as a `for_statement` with a `select` keyword token β
that is exactly the reference's tree (there is no `select_statement`
node type).
- `$"β¦"` (translated string): in command-argument position it is an
anonymous `$` argument followed by the `string` argument; everywhere
else (assignment values, redirect destinations, for-loop values, β¦) it
is a `translated_string` node. Both match the reference exactly.
`$'β¦'` is a single-child-free `ansi_c_string` node, also matching.
- Brace expansion: only `{N..M}` (unsigned digits) is a `brace_expression`;
`{a..z}`, `{1..10..2}` and `{-5..5}` are plain word concatenations in the
reference and here.
- Statement-position `((word++β¦))` (inner text starting with a bare
identifier plus a postfix `++`/`--`) is a `test_command` in the
reference β its statement-position arithmetic grammar does not accept
that form β while `((x = 1))`, `((b + a++))`, `((a[i]++))` and
argument-position `((a++))` are `arithmetic_expansion`. This parser
matches all of these, including after `!`.
- Command-prefix redirects take exactly one destination (`> a cmd x` makes
`cmd` the `command_name`), while redirects after the command name consume
every following word (`cmd > out arg` puts both words in the redirect) β
this matches tree-sitter-bash's actual disambiguation.
## Performance
Measured on an Apple-silicon MacBook (Node 24, default 50 ms / 50 000-node
budget):
- A typical one-line command (`git status && rm -rf /`): ~4 Β΅s per parse.
- A 100 KB realistic deployment script (functions, loops, redirects,
expansions, heredocs): tens of milliseconds per parse (~20β60 ms across
runs and machines) β the same order of magnitude as the default 50 ms
budget, so such inputs may return `ok: true` or a prompt abort depending
on the machine; raise `timeoutMs` when parsing whole scripts.
- A 500 KB heredoc body: parses within the default budget (the tree has
only a handful of nodes).
- The abort path is prompt: a 400 KB node-budget bomb aborts in ~20 ms
(hard guarantee asserted by the test suite: < 100 ms).
These numbers are smoke-tested as orders of magnitude in
`test/performance.test.ts` to guard against accidental quadratic
complexity; absolute timings vary by machine.
|