* fix(tree-sitter-bash): recognize heredocs in character-level balanced scanning
scanBalancedStatements treated heredoc bodies as ordinary characters, so a stray quote, paren, or backtick inside a heredoc body (for example an apostrophe in a PR body passed through a command substitution) broke the scan and produced ERROR nodes; the resulting hasError made dangerous-command-ask judge the whole command unanalyzable and prompt for approval even in yolo mode.
Parse << and <<- delimiters (excluding <<< herestrings) with the same unquoting rules as the token-level heredoc reader, queue pending bodies, and skip them line-wise at newlines. Skip arithmetic $(( ... )) regions via scanBalanced so a left-shift << is never mistaken for a heredoc operator.
* fix(agent-core-v2): raise the bash parse wall-clock budget to 500ms
A 20ms wall-clock budget could abort an otherwise fine parse under CPU contention, GC pauses, or cold-start JIT, flipping the permission verdict to unanalyzable (spurious approval prompts) or silently dropping AGENTS.md re-reminders. Normal commands parse in well under 1ms; maxNodes stays the deterministic cap, and 500ms remains a backstop against pathological parser loops.
* fix(tree-sitter-bash): skip comments and legacy arithmetic during heredoc-aware scanning
The heredoc-aware scan queued a heredoc for any << it encountered, including inside comments (echo $(printf x # <<EOF\n)) and legacy arithmetic expansions (echo $(echo $[x << 2]\n)); both are valid bash that parsed cleanly before, and the regression flipped them to hasError and an unanalyzable permission verdict.
Skip # comments to end of line when the preceding character starts a new word, and skip ${ ... } / $[ ... ] expansions as balanced units (mirroring skipDollar), so << is only recognized where a redirection operator can actually appear.
* fix(tree-sitter-bash): skip word-glued subscripts during heredoc-aware scanning
Indexed assignments such as echo $(a[x<<2]=3\n) put arithmetic inside a word-glued [ ... ] subscript; the heredoc-aware scan read the << shift operator there as a heredoc start, regressing valid bash that parsed cleanly before to hasError and an unanalyzable permission verdict.
Skip a [ ... ] region as a balanced unit when the bracket immediately follows a word character (subscripts and glued glob classes); a bracket at word start keeps the existing character scan, so real heredocs after words (cat foo[ab]<<EOF) and bare [ command arguments are unaffected. The subscript regression is covered by a unit case only: the reference parser splits subscript arithmetic into binary_expression while this parser keeps it an opaque word, a structural difference that predates this change and has no differential fixture yet.
* fix(tree-sitter-bash): skip conditional regions and look through continuations in heredoc-aware scanning
Two more character-scan contexts queued bogus heredocs after the heredoc-aware scan: a [[ ... ]] conditional region (echo $( [[ x == @(<<EOF) ]]\n) and regex right-hand sides such as [[ x =~ <<a ]]), and a # preceded by a backslash-newline continuation (echo $(printf foo\<newline>#bar)), where removing the continuation keeps the hash inside the preceding word instead of starting a comment. Both are valid bash that parsed cleanly before.
Skip word-start [[ ... ]] as a balanced region (a << inside a conditional is never a heredoc operator; a bracket in argument position keeps the character scan), and walk back over \+newline pairs before classifying a # as a comment. The extglob conditional case joins the differential fixtures; the continuation case is unit-only because the reference parser errors on it.
* fix(tree-sitter-bash): scan substitutions as part of heredoc delimiters
A heredoc delimiter containing a substitution (echo $(cat <<$(foo)\nbody\n$(foo)\n)) was truncated at the first paren, so the queued delimiter never matched the body closing line and the scan swallowed the rest of the range, regressing valid bash to hasError and an unanalyzable permission verdict.
scanHeredocDelimiter now scans $( ), ${ }, $[ ], and backtick regions wholesale as part of the delimiter word (recursing with the heredoc-aware statement scanner for $( )), mirroring how bash treats the whole word as the delimiter. The case stays unit-only because unquoted delimiters hit the already-registered heredoc-content-chunks structural difference with the reference parser.
|
||
|---|---|---|
| .. | ||
| src | ||
| test | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| tsdown.config.ts | ||
| vitest.config.ts | ||
@moonshot-ai/tree-sitter-bash
A pure-TypeScript bash parser that produces a syntax tree whose named node types match 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.textis always a directsource.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 withhasError: 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 below.
API
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): ParseResultParseResultis one of:{ ok: true; rootNode: SyntaxNode; hasError: boolean }{ ok: false; reason: 'aborted' }
ParseOptions:{ timeoutMs?: number; maxNodes?: number }(defaults 50 ms / 50 000 nodes;timeoutMs: Infinitydisables the time check).SyntaxNode:{ type, text, startIndex, endIndex, isNamed, parent, children, namedChildren }. Named node types come from tree-sitter-bash'snode-types.json;childrenincludes the anonymous (punctuation / keyword token) nodes as well,namedChildrenonly the named ones, anddescendantsOfType(root, ...types)walks named descendants.- Depth caps (pathological nesting degrades locally — ERROR nodes,
hasError: true— instead of overflowing the stack):MAX_SUBSTITUTION_DEPTH = 150for 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 = 500for subshell/compound/${…}/expression nesting (verified safe at those caps);MAX_SCAN_DEPTH = 1024for 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 \ns 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 normalfile_redirectoperator; 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 anERRORnode, 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 theheredoc_redirectnode 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 aheredoc_contentnode, and backtick command substitutions are parsed ascommand_substitutionchildren. 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 wholeheredoc_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 toERRORnodes, 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 flaggedhasError: truehere), 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_commandwith 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 anERRORnode 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 withhasError: true); the reference inserts zero-width keyword nodes (a zero-widthfi). ${x@}(transformation operator with no letter) keeps anexpansionwithhasError: truehere; the reference splits it into anERRORnode and a}command.
- An unterminated test command always keeps its partial
- A trailing connector (
ls &&,ls |) yields a single-childlist/pipelinewithhasError: true; tree-sitter-bash inserts a zero-widthcommandrecovery 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 singletoken that is only valid inside a concatenation and errors in argument position. - An empty command substitution (
$( )) is a cleancommand_substitutionwith no statements here; the reference inserts a zero-widthcommandrecovery node (and flagshasError). - In arithmetic, a hex literal is a
number($((0x1F))→ number "0x1F"); the reference's arithmetic number token does not cover hex and produces avariable_name"0x1F" instead (a reference quirk — bash does evaluate hex in arithmetic). [[ ((a) == x) && y ]](parenthesized test group followed by&&) parses cleanly here as nestedparenthesized_expressions; the reference mis-reads it as anarithmetic_expansionwith an embeddedERRORnode (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 (anERRORnode 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 anERRORnode. (Accepted groups —*(a|b)),*([0-9])([0-9]))— match exactly.) - A single-dash short-option case pattern (
-o)) is aword— the form this parser always produces — but the reference's classification flips with leftover scanner state (a first case item right after theinline's newline parses it as anextglob_pattern). - A case pattern directly after a line continuation (
| \<newline>) is awordin 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 anextglob_patternhere. 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 anextglob_patternthere; this parser keeps the continuation and parses the whole pattern cleanly as oneword— 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 nestedbinary_expressionthere, while this parser keeps a singleconcatenationright side. - A few
[[ … ]]comparison right-hand sides deviate:- A negative decimal operand (
[[ $n == -0.5 ]]) is aunary_expression(-over word "0.5") in the reference; this parser produces a singleextglob_pattern"-0.5" (the unary-minus reading only covers integers here). - A test operator fused with an expansion (
[[ -x$f && … ]]) is aunary_expression(-over aconcatenationofxand$f) in the reference; this parser produces a flatconcatenationof-xand$fwith 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 oneextglob_pattern"foo|bar".
- A negative decimal operand (
${!# }and${!## }(and${!##/}) are pathological expansion forms: the reference recovers with zero-width operator tokens, this parser with a flagged partialexpansion; the shapes differ. (The sane forms${#},${!#},${!##},${#!}all match exactly.)- A base prefix fused with an expansion (
10#${x}) is onenumbernode spanning the expansion in the reference (a quirk); this parser produces aconcatenationof word "10#" and the expansion. ${v:-(default)}(a parenthesized default value) is a plainwordhere; the reference parses it as anarraynode (a quirk).${=1}(zsh-style word splitting flag) produces avariable_name"1" in the reference; this parser produces aword.- An escaped space or tab between arguments is dropped by the reference
as invisible extras (its tree has gaps:
echo 1 \ 2produces number "1" and number "2", andecho a\<TAB>bsplits 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 onecommand_substitutionwith a$+backtick token in the reference; this parser produces a($)token followed by the substitution. - A non-ASCII “identifier” in assignment position (
变量=值,é=1) is avariable_assignmentflaggedhasErrorin 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 anumber"-1" by the reference; this parser produces aunary_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 — thewordstarts one character later); this parser keeps both. string_contentis 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):
coprochas no grammar support in tree-sitter-bash 0.25.0 and parses as an ordinary command; so does this parser.selectis parsed as afor_statementwith aselectkeyword token — that is exactly the reference's tree (there is noselect_statementnode type).$"…"(translated string): in command-argument position it is an anonymous$argument followed by thestringargument; everywhere else (assignment values, redirect destinations, for-loop values, …) it is atranslated_stringnode. Both match the reference exactly.$'…'is a single-child-freeansi_c_stringnode, also matching.- Brace expansion: only
{N..M}(unsigned digits) is abrace_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 atest_commandin the reference — its statement-position arithmetic grammar does not accept that form — while((x = 1)),((b + a++)),((a[i]++))and argument-position((a++))arearithmetic_expansion. This parser matches all of these, including after!. - Command-prefix redirects take exactly one destination (
> a cmd xmakescmdthecommand_name), while redirects after the command name consume every following word (cmd > out argputs 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: trueor a prompt abort depending on the machine; raisetimeoutMswhen 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.