mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-22 07:05:41 +00:00
* feat(tree-sitter-bash): scaffold pure-TypeScript bash parser package
Add packages/tree-sitter-bash with the SyntaxNode tree model (UTF-16
code-unit offsets, tree-sitter-bash named-node type names), a parse
budget (deadline + node cap) with an aborted ParseResult variant, and
a placeholder parse() to be replaced by the real lexer/parser.
* feat(tree-sitter-bash): add lexer and core recursive-descent parser
Cover the permission-analysis grammar subset: lists, pipelines,
commands, words, quotes, expansions, command/process substitution,
subshells, the full redirect operator set, heredocs and comments,
with tree-sitter-bash named-node type names. Long scan loops check
the parse deadline via budget.progress() so large literals cannot
exhaust the node cap; parse depth is bounded on all recursion
chains.
* feat(tree-sitter-bash): support the full bash grammar
Add compound commands (if/while/until/for/c-style-for/select/case/
function/compound/do_group), test commands with reference-exact
extglob/regex right-hand-side rules, a Pratt expression engine for
arithmetic expansion shared across arith/c-for/test modes, arrays
and subscripts, declaration/unset commands, ansi-c/translated
strings and brace expressions. Reserved words are recognized only
in statement position. Case-aware balanced scanning keeps command
substitutions intact around case items, expression leftovers are
kept as ERROR nodes instead of dropped, and recursion depth is
bounded per chain (substitution 150, parse 500, lexer scan 1024).
* test(tree-sitter-bash): add differential fixtures, corpus, fuzz and perf suites
Turn the ad-hoc wasm comparison work into permanent infrastructure:
a differential helper pinning reference-equivalent and
known-difference samples against the real tree-sitter-bash wasm
(478 match / 79 known-diff fixtures plus the official v0.25.0
corpus), a three-way consistency check between fixtures, the
known-difference registry and the README, deterministic seeded
fuzz with tree-integrity assertions, and performance smoke tests.
Converges 15 further divergence groups found by systematic probing
and documents the rest; the full suite is 853 tests in ~4s.
* feat(agent-core-v2): add App-scope bashParser service
Wrap the pure @moonshot-ai/tree-sitter-bash package as
IBashParserService (L1, no dependencies): parse(source, options)
returns a wire-safe BashParseResult whose nodes drop the cyclic
parent link so trees can cross the RPC boundary. Budget exhaustion
surfaces as { ok: false, reason: 'aborted' }, malformed input as
hasError — never a throw.
* chore: add changeset for the bash parser service
* chore: update pnpmDeps hash after adding tree-sitter-bash package
* fix(agent-core-v2): register bashParser service with ScopeActivation
The registration was written against the removed InstantiationType API
(#/_base/di/extensions); switch to ScopeActivation.OnDemand from
#/_base/di/scope so the package typechecks and the service registers.
* feat(kimi-inspect): add Bash Parser view
Add a fourth icon-rail tab that exercises the App-scope bash parser
service over the debug RPC surface: a source textarea with a parse
budget (timeoutMs / maxNodes), a dropdown of curated examples adapted
from the tree-sitter-bash differential fixtures, and an expandable
syntax tree with per-node type, UTF-16 range and leaf text, plus
hasError / aborted / node-count badges.
* fix(agent-core-v2): snapshot bash syntax trees iteratively
A long left-associative chain (e.g. an arithmetic expression with a
few thousand operands) parses into a tree thousands of levels deep
while still within budget; the recursive DTO conversion then overflowed
the JS call stack and made parse throw RangeError, breaking the
never-throws contract. Convert the tree with an explicit stack, the
same approach as the parser's own materialize.
* feat(kimi-inspect): add a deep-arithmetic example to the Bash Parser view
A thousand-operand left-associative chain fills the textarea with a
thousand-level binary_expression tree — the shape that once overflowed
the DTO conversion. Deeper chains still parse in-process but cannot
cross the JSON RPC transport (V8 call-stack limit in serialization),
so the example stays within the wire limit.
* chore: update pnpmDeps hash for the rebased lockfile
The rebase onto main merged pnpm-lock.yaml, invalidating the recorded
fetchPnpmDeps hash; use the hash CI computed for the merged lockfile.
519 lines
20 KiB
TypeScript
519 lines
20 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import { Aborted, ParseBudget } from '#/budget';
|
|
import type { SyntaxNode } from '#/node';
|
|
import { descendantsOfType } from '#/node';
|
|
import { parse } from '#/parse';
|
|
|
|
/** Compact S-expression of the tree: named leaves are (type "text"),
|
|
* anonymous leaves are just their quoted type, inner nodes nest. */
|
|
function sexp(node: SyntaxNode): string {
|
|
if (node.children.length === 0) {
|
|
return node.isNamed ? `(${node.type} ${JSON.stringify(node.text)})` : JSON.stringify(node.type);
|
|
}
|
|
const label = node.isNamed ? node.type : JSON.stringify(node.type);
|
|
return `(${label} ${node.children.map(sexp).join(' ')})`;
|
|
}
|
|
|
|
function parseOk(source: string): { rootNode: SyntaxNode; hasError: boolean } {
|
|
const result = parse(source);
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) throw new Error('unreachable');
|
|
return result;
|
|
}
|
|
|
|
/** Parse and assert the exact tree shape plus the hasError flag. */
|
|
function expectTree(source: string, expected: string, hasError = false): SyntaxNode {
|
|
const { rootNode, hasError: actual } = parseOk(source);
|
|
expect(actual).toBe(hasError);
|
|
expect(sexp(rootNode)).toBe(expected);
|
|
return rootNode;
|
|
}
|
|
|
|
describe('ParseBudget', () => {
|
|
it('counts nodes and stays under the cap', () => {
|
|
const budget = new ParseBudget({ timeoutMs: 60_000, maxNodes: 3 });
|
|
budget.tick();
|
|
budget.tick();
|
|
expect(budget.nodesUsed).toBe(2);
|
|
});
|
|
|
|
it('throws Aborted when the node cap is exceeded', () => {
|
|
const budget = new ParseBudget({ timeoutMs: 60_000, maxNodes: 1 });
|
|
budget.tick();
|
|
expect(() => {
|
|
budget.tick();
|
|
}).toThrow(Aborted);
|
|
});
|
|
|
|
it('throws Aborted once the deadline is reached', () => {
|
|
const budget = new ParseBudget({ timeoutMs: 0, maxNodes: 1_000 });
|
|
expect(() => {
|
|
budget.tick();
|
|
}).toThrow(Aborted);
|
|
});
|
|
|
|
it('applies documented defaults', () => {
|
|
const budget = new ParseBudget();
|
|
// Default node cap is 50_000: the 50_000th node is still allowed.
|
|
for (let i = 0; i < 50_000; i++) budget.tick();
|
|
expect(() => {
|
|
budget.tick();
|
|
}).toThrow(Aborted);
|
|
});
|
|
});
|
|
|
|
describe('parse entry contract', () => {
|
|
it('parses an empty source into an empty program', () => {
|
|
const { rootNode, hasError } = parseOk('');
|
|
expect(rootNode.type).toBe('program');
|
|
expect(rootNode.text).toBe('');
|
|
expect(rootNode.children).toHaveLength(0);
|
|
expect(hasError).toBe(false);
|
|
});
|
|
|
|
it('returns { ok: false, reason: "aborted" } when the node budget is exceeded', () => {
|
|
expect(parse('echo hello', { timeoutMs: 60_000, maxNodes: 1 })).toEqual({ ok: false, reason: 'aborted' });
|
|
});
|
|
|
|
it('returns { ok: false, reason: "aborted" } when the deadline has passed', () => {
|
|
expect(parse('echo hello', { timeoutMs: 0 })).toEqual({ ok: false, reason: 'aborted' });
|
|
});
|
|
|
|
it('aborts instead of hanging on tens of thousands of statements', () => {
|
|
expect(parse('ls; '.repeat(20_000))).toEqual({ ok: false, reason: 'aborted' });
|
|
});
|
|
});
|
|
|
|
describe('simple commands', () => {
|
|
it('parses a command with arguments', () => {
|
|
expectTree('ls -la', `(program (command (command_name (word "ls")) (word "-la")))`);
|
|
});
|
|
|
|
it('parses double-quoted strings with expansions and raw strings', () => {
|
|
expectTree(
|
|
`echo "hello $NAME" 'raw'`,
|
|
`(program (command (command_name (word "echo")) (string "\\"" (string_content "hello ") (simple_expansion "$" (variable_name "NAME")) "\\"") (raw_string "'raw'")))`,
|
|
);
|
|
});
|
|
|
|
it('parses a pipeline inside && / || lists, left associative', () => {
|
|
expectTree(
|
|
'FOO=bar env | grep FOO && echo ok || echo fail',
|
|
`(program (list (list (pipeline (command (variable_assignment (variable_name "FOO") "=" (word "bar")) (command_name (word "env"))) "|" (command (command_name (word "grep")) (word "FOO"))) "&&" (command (command_name (word "echo")) (word "ok"))) "||" (command (command_name (word "echo")) (word "fail"))))`,
|
|
);
|
|
});
|
|
|
|
it('parses ! as negated_command', () => {
|
|
expectTree(
|
|
'! grep -q x file',
|
|
`(program (negated_command "!" (command (command_name (word "grep")) (word "-q") (word "x") (word "file"))))`,
|
|
);
|
|
});
|
|
|
|
it('attaches comments as named children', () => {
|
|
expectTree(
|
|
'# a comment\necho hi # trailing',
|
|
`(program (comment "# a comment") (command (command_name (word "echo")) (word "hi")) (comment "# trailing"))`,
|
|
);
|
|
});
|
|
|
|
it('distinguishes number from word', () => {
|
|
expectTree(
|
|
'echo 123 45.6',
|
|
`(program (command (command_name (word "echo")) (number "123") (word "45.6")))`,
|
|
);
|
|
});
|
|
|
|
it('parses concatenations of adjacent pieces', () => {
|
|
expectTree(
|
|
`echo a"b"c'd'$e\${f}g`,
|
|
`(program (command (command_name (word "echo")) (concatenation (word "a") (string "\\"" (string_content "b") "\\"") (word "c") (raw_string "'d'") (simple_expansion "$" (variable_name "e")) (expansion "\${" (variable_name "f") "}") (word "g"))))`,
|
|
);
|
|
});
|
|
|
|
it('splits { } into single-character word pieces', () => {
|
|
expectTree(
|
|
'echo {a,b}',
|
|
`(program (command (command_name (word "echo")) (concatenation (word "{") (word "a,b") (word "}"))))`,
|
|
);
|
|
});
|
|
|
|
it('treats a lone $ as an anonymous argument', () => {
|
|
expectTree('echo $', `(program (command (command_name (word "echo")) "$"))`);
|
|
});
|
|
});
|
|
|
|
describe('expansions and substitutions', () => {
|
|
it('parses backtick and $() command substitutions', () => {
|
|
expectTree(
|
|
'echo `hostname` $(date)',
|
|
`(program (command (command_name (word "echo")) (command_substitution "\`" (command (command_name (word "hostname"))) "\`") (command_substitution "$(" (command (command_name (word "date"))) ")")))`,
|
|
);
|
|
});
|
|
|
|
it('parses nested command substitutions recursively', () => {
|
|
expectTree(
|
|
'echo $(a $(b c))',
|
|
`(program (command (command_name (word "echo")) (command_substitution "$(" (command (command_name (word "a")) (command_substitution "$(" (command (command_name (word "b")) (word "c")) ")")) ")")))`,
|
|
);
|
|
});
|
|
|
|
it('parses ${...} expansions with operators and defaults', () => {
|
|
expectTree(
|
|
'echo ${v:-d} ${x} $1 $@',
|
|
`(program (command (command_name (word "echo")) (expansion "\${" (variable_name "v") ":-" (word "d") "}") (expansion "\${" (variable_name "x") "}") (simple_expansion "$" (variable_name "1")) (simple_expansion "$" (special_variable_name "@"))))`,
|
|
);
|
|
});
|
|
|
|
it('parses ${#v}, ${v:=word} and ${a[0]}', () => {
|
|
expectTree('echo ${#v}', `(program (command (command_name (word "echo")) (expansion "\${" "#" (variable_name "v") "}")))`);
|
|
expectTree(
|
|
'echo ${v:=word}',
|
|
`(program (command (command_name (word "echo")) (expansion "\${" (variable_name "v") ":=" (word "word") "}")))`,
|
|
);
|
|
expectTree(
|
|
'echo ${a[0]}',
|
|
`(program (command (command_name (word "echo")) (expansion "\${" (subscript (variable_name "a") "[" (number "0") "]") "}")))`,
|
|
);
|
|
});
|
|
|
|
it('parses expansions nested inside expansions', () => {
|
|
expectTree(
|
|
'echo ${v:-$(cmd)}',
|
|
`(program (command (command_name (word "echo")) (expansion "\${" (variable_name "v") ":-" (command_substitution "$(" (command (command_name (word "cmd"))) ")") "}")))`,
|
|
);
|
|
});
|
|
|
|
it('parses process substitutions', () => {
|
|
expectTree(
|
|
'diff <(cmd) >(other)',
|
|
`(program (command (command_name (word "diff")) (process_substitution "<(" (command (command_name (word "cmd"))) ")") (process_substitution ">(" (command (command_name (word "other"))) ")")))`,
|
|
);
|
|
});
|
|
|
|
it('parses $((...)) as a real arithmetic expression', () => {
|
|
expectTree(
|
|
'echo $((1<<2))',
|
|
`(program (command (command_name (word "echo")) (arithmetic_expansion "$((" (binary_expression (number "1") "<<" (number "2")) "))")))`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('redirects', () => {
|
|
it('parses output redirect and fd duplication', () => {
|
|
expectTree(
|
|
'cmd > out 2>&1',
|
|
`(program (redirected_statement (command (command_name (word "cmd"))) (file_redirect ">" (word "out")) (file_redirect (file_descriptor "2") ">&" (number "1"))))`,
|
|
);
|
|
});
|
|
|
|
it('parses &>> appending both streams', () => {
|
|
expectTree(
|
|
'cmd &>> log',
|
|
`(program (redirected_statement (command (command_name (word "cmd"))) (file_redirect "&>>" (word "log"))))`,
|
|
);
|
|
});
|
|
|
|
it('parses <> read-write redirect with a file descriptor', () => {
|
|
expectTree(
|
|
'exec 3<>file',
|
|
`(program (redirected_statement (command (command_name (word "exec"))) (file_redirect (file_descriptor "3") "<>" (word "file"))))`,
|
|
);
|
|
});
|
|
|
|
it('parses >|, <&-, < and fd redirects', () => {
|
|
expectTree(
|
|
'cmd >| file',
|
|
`(program (redirected_statement (command (command_name (word "cmd"))) (file_redirect ">|" (word "file"))))`,
|
|
);
|
|
expectTree('cmd >&-', `(program (redirected_statement (command (command_name (word "cmd"))) (file_redirect ">&-")))`);
|
|
expectTree(
|
|
'cmd < in',
|
|
`(program (redirected_statement (command (command_name (word "cmd"))) (file_redirect "<" (word "in"))))`,
|
|
);
|
|
expectTree(
|
|
'cmd 2>/dev/null',
|
|
`(program (redirected_statement (command (command_name (word "cmd"))) (file_redirect (file_descriptor "2") ">" (word "/dev/null"))))`,
|
|
);
|
|
});
|
|
|
|
it('keeps a herestring inside the command node', () => {
|
|
expectTree(
|
|
'cmd <<< "$str"',
|
|
`(program (command (command_name (word "cmd")) (herestring_redirect "<<<" (string "\\"" (simple_expansion "$" (variable_name "str")) "\\""))))`,
|
|
);
|
|
});
|
|
|
|
it('wraps a command carrying both a herestring and a file redirect', () => {
|
|
expectTree(
|
|
'cmd <<< x > out',
|
|
`(program (redirected_statement (command (command_name (word "cmd")) (herestring_redirect "<<<" (word "x"))) (file_redirect ">" (word "out"))))`,
|
|
);
|
|
});
|
|
|
|
it('parses a redirect-only statement', () => {
|
|
expectTree('> out', `(program (redirected_statement (file_redirect ">" (word "out"))))`);
|
|
});
|
|
|
|
it('keeps a prefix redirect inside the command', () => {
|
|
expectTree(
|
|
'> a cmd x',
|
|
`(program (command (file_redirect ">" (word "a")) (command_name (word "cmd")) (word "x")))`,
|
|
);
|
|
});
|
|
|
|
it('wraps subshells and negated commands with their redirects', () => {
|
|
expectTree(
|
|
'(ls) > out',
|
|
`(program (redirected_statement (subshell "(" (command (command_name (word "ls"))) ")") (file_redirect ">" (word "out"))))`,
|
|
);
|
|
expectTree(
|
|
'! ls > out',
|
|
`(program (redirected_statement (negated_command "!" (command (command_name (word "ls")))) (file_redirect ">" (word "out"))))`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('heredocs', () => {
|
|
it('parses a heredoc with expansions in the body', () => {
|
|
expectTree(
|
|
'cat <<EOF\nhello $USER\nEOF',
|
|
`(program (redirected_statement (command (command_name (word "cat"))) (heredoc_redirect "<<" (heredoc_start "EOF") (heredoc_body (heredoc_content "hello ") (simple_expansion "$" (variable_name "USER")) (heredoc_content "\\n")) (heredoc_end "EOF"))))`,
|
|
);
|
|
});
|
|
|
|
it('strips first-line tabs for <<- and keeps the marker indented', () => {
|
|
expectTree(
|
|
'cat <<-EOF\n\tindented\n\tEOF',
|
|
`(program (redirected_statement (command (command_name (word "cat"))) (heredoc_redirect "<<-" (heredoc_start "EOF") (heredoc_body (heredoc_content "indented\\n\\t")) (heredoc_end "EOF"))))`,
|
|
);
|
|
});
|
|
|
|
it('does not expand the body of a quoted delimiter', () => {
|
|
expectTree(
|
|
`cat <<'EOF'\nraw $notexpanded\nEOF`,
|
|
`(program (redirected_statement (command (command_name (word "cat"))) (heredoc_redirect "<<" (heredoc_start "'EOF'") (heredoc_body "raw $notexpanded\\n") (heredoc_end "EOF"))))`,
|
|
);
|
|
});
|
|
|
|
it('absorbs trailing words and redirects into the heredoc_redirect', () => {
|
|
expectTree(
|
|
'cat <<EOF arg > out\nbody\nEOF',
|
|
`(program (redirected_statement (command (command_name (word "cat"))) (heredoc_redirect "<<" (heredoc_start "EOF") (word "arg") (file_redirect ">" (word "out")) (heredoc_body (heredoc_content "body\\n")) (heredoc_end "EOF"))))`,
|
|
);
|
|
});
|
|
|
|
it('absorbs a pipeline tail into the heredoc_redirect', () => {
|
|
expectTree(
|
|
'cat <<EOF | grep x\nbody\nEOF',
|
|
`(program (redirected_statement (command (command_name (word "cat"))) (heredoc_redirect "<<" (heredoc_start "EOF") (pipeline "|" (command (command_name (word "grep")) (word "x"))) (heredoc_body (heredoc_content "body\\n")) (heredoc_end "EOF"))))`,
|
|
);
|
|
});
|
|
|
|
it('absorbs `;` follow-up statements into the heredoc_redirect', () => {
|
|
expectTree(
|
|
'cat <<EOF; echo x\nbody\nEOF',
|
|
`(program (redirected_statement (command (command_name (word "cat"))) (heredoc_redirect "<<" (heredoc_start "EOF") ";" (command (command_name (word "echo")) (word "x")) (heredoc_body (heredoc_content "body\\n")) (heredoc_end "EOF"))))`,
|
|
);
|
|
});
|
|
|
|
it('degrades a second heredoc on the same line to ERROR (matches tree-sitter-bash)', () => {
|
|
expectTree(
|
|
'cat <<A <<B\nba\nA\nbb\nB',
|
|
`(program (redirected_statement (command (command_name (word "cat"))) (heredoc_redirect "<<" (heredoc_start "A") (ERROR "<<" (word "B")) (heredoc_body (heredoc_content "ba\\n")) (heredoc_end "A"))) (command (command_name (word "bb"))) (command (command_name (word "B"))))`,
|
|
true,
|
|
);
|
|
});
|
|
|
|
it('recovers from an unterminated heredoc', () => {
|
|
expectTree(
|
|
'cat <<EOF\nno end marker',
|
|
`(program (redirected_statement (command (command_name (word "cat"))) (heredoc_redirect "<<" (heredoc_start "EOF") (heredoc_body (heredoc_content "no end marker")))))`,
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('statement lists', () => {
|
|
it('mixes semicolons and newlines as terminators', () => {
|
|
expectTree(
|
|
'ls; echo a\necho b',
|
|
`(program (command (command_name (word "ls"))) ";" (command (command_name (word "echo")) (word "a")) (command (command_name (word "echo")) (word "b")))`,
|
|
);
|
|
});
|
|
|
|
it('parses & background terminators', () => {
|
|
expectTree('a & b &', `(program (command (command_name (word "a"))) "&" (command (command_name (word "b"))) "&")`);
|
|
});
|
|
|
|
it('parses |& pipelines', () => {
|
|
expectTree(
|
|
'cmd1 |& cmd2',
|
|
`(program (pipeline (command (command_name (word "cmd1"))) "|&" (command (command_name (word "cmd2")))))`,
|
|
);
|
|
});
|
|
|
|
it('parses a subshell', () => {
|
|
expectTree('(ls)', `(program (subshell "(" (command (command_name (word "ls"))) ")"))`);
|
|
});
|
|
|
|
it('parses standalone assignments', () => {
|
|
expectTree('FOO=bar ls', `(program (command (variable_assignment (variable_name "FOO") "=" (word "bar")) (command_name (word "ls"))))`);
|
|
expectTree('VAR=value', `(program (variable_assignment (variable_name "VAR") "=" (word "value")))`);
|
|
expectTree(
|
|
'A=1 B=2',
|
|
`(program (variable_assignments (variable_assignment (variable_name "A") "=" (number "1")) (variable_assignment (variable_name "B") "=" (number "2"))))`,
|
|
);
|
|
});
|
|
|
|
it('recovers a trailing && with a partial list and hasError', () => {
|
|
expectTree('ls &&', `(program (list (command (command_name (word "ls"))) "&&"))`, true);
|
|
});
|
|
|
|
it('recovers a trailing | with a partial pipeline and hasError', () => {
|
|
expectTree('ls |', `(program (pipeline (command (command_name (word "ls"))) "|"))`, true);
|
|
});
|
|
|
|
it('continues a list across a newline after &&', () => {
|
|
expectTree(
|
|
'a &&\nb',
|
|
`(program (list (command (command_name (word "a"))) "&&" (command (command_name (word "b")))))`,
|
|
);
|
|
});
|
|
|
|
it('splits compound commands into separate command nodes', () => {
|
|
const root = expectTree(
|
|
'git status && rm -rf /',
|
|
`(program (list (command (command_name (word "git")) (word "status")) "&&" (command (command_name (word "rm")) (word "-rf") (word "/"))))`,
|
|
);
|
|
const commands = descendantsOfType(root, 'command');
|
|
expect(commands).toHaveLength(2);
|
|
const argv = commands.map((cmd) =>
|
|
cmd.namedChildren.map((part) => (part.type === 'command_name' ? part.namedChildren[0]!.text : part.text)),
|
|
);
|
|
expect(argv).toEqual([
|
|
['git', 'status'],
|
|
['rm', '-rf', '/'],
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('offsets', () => {
|
|
it('uses UTF-16 code unit offsets', () => {
|
|
// 🎉 is two UTF-16 code units.
|
|
const { rootNode } = parseOk('x🎉y; ls');
|
|
expect(rootNode.endIndex).toBe(8);
|
|
const commands = descendantsOfType(rootNode, 'command');
|
|
expect(commands).toHaveLength(2);
|
|
expect(commands[0]!.startIndex).toBe(0);
|
|
expect(commands[0]!.endIndex).toBe(4);
|
|
expect(commands[1]!.startIndex).toBe(6);
|
|
expect(commands[1]!.endIndex).toBe(8);
|
|
expect(commands[1]!.text).toBe('ls');
|
|
});
|
|
|
|
it('node.text always equals the source slice', () => {
|
|
const source = 'FOO=bar env | grep FOO && echo "ok $X"';
|
|
const { rootNode } = parseOk(source);
|
|
const walk = (node: SyntaxNode): void => {
|
|
expect(node.text).toBe(source.slice(node.startIndex, node.endIndex));
|
|
node.children.forEach(walk);
|
|
};
|
|
walk(rootNode);
|
|
});
|
|
});
|
|
|
|
describe('adversarial input', () => {
|
|
it('recovers unterminated quotes, substitutions and expansions', () => {
|
|
for (const source of ['echo "abc', "echo 'abc", 'echo $(foo', 'echo ${foo', 'echo `foo']) {
|
|
const result = parse(source);
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.hasError).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('recovers stray operators without throwing', () => {
|
|
for (const source of ['echo )', '&& echo', '| echo', ')']) {
|
|
const result = parse(source);
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.hasError).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('parses a 500KB double-quoted string under the default budget', () => {
|
|
// Regression: per-character budget.tick() used to burn the 50k node cap
|
|
// on long strings even though only a handful of nodes are created.
|
|
const body = 'a'.repeat(500 * 1024);
|
|
const result = parse(`echo "${body}"`);
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.hasError).toBe(false);
|
|
const content = descendantsOfType(result.rootNode, 'string_content');
|
|
expect(content).toHaveLength(1);
|
|
expect(content[0]!.text).toBe(body);
|
|
});
|
|
|
|
it('parses a 500KB heredoc body under the default budget', () => {
|
|
const body = 'b'.repeat(500 * 1024);
|
|
const result = parse(`cat <<EOF\n${body}\nEOF`);
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.hasError).toBe(false);
|
|
const content = descendantsOfType(result.rootNode, 'heredoc_content');
|
|
expect(content).toHaveLength(1);
|
|
expect(content[0]!.text).toBe(`${body}\n`);
|
|
});
|
|
|
|
it('handles 1000-level nested ${} expansions within the depth cap', () => {
|
|
const source = 'echo ' + '${a:-'.repeat(1000) + 'x' + '}'.repeat(1000);
|
|
const result = parse(source, { timeoutMs: 10_000 });
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.hasError).toBe(true);
|
|
});
|
|
|
|
it('handles 1000-level nested command substitutions within the depth cap', () => {
|
|
const source = '$('.repeat(1000) + 'x' + ')'.repeat(1000);
|
|
const result = parse(source, { timeoutMs: 10_000 });
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.hasError).toBe(true);
|
|
});
|
|
|
|
it('handles 1000-level nested subshells within the depth cap', () => {
|
|
const source = '('.repeat(1000) + 'x' + ')'.repeat(1000);
|
|
const result = parse(source, { timeoutMs: 10_000 });
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.hasError).toBe(true);
|
|
});
|
|
|
|
it('parses a multi-megabyte single word without throwing', () => {
|
|
const source = 'a'.repeat(2 * 1024 * 1024);
|
|
const result = parse(source, { timeoutMs: 10_000 });
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.hasError).toBe(false);
|
|
const word = result.rootNode.namedChildren[0]!.namedChildren[0]!.namedChildren[0]!;
|
|
expect(word.type).toBe('word');
|
|
expect(word.text).toBe(source);
|
|
});
|
|
|
|
it('does not throw on random binary garbage', () => {
|
|
let source = '';
|
|
for (let i = 0; i < 20_000; i++) {
|
|
source += String.fromCodePoint(Math.floor(Math.random() * 0xd800));
|
|
}
|
|
// The call itself must never throw; either it parses (possibly with
|
|
// errors) or the budget cuts it off.
|
|
const result = parse(source);
|
|
if (result.ok) {
|
|
expect(result.rootNode.type).toBe('program');
|
|
} else {
|
|
expect(result.reason).toBe('aborted');
|
|
}
|
|
});
|
|
|
|
it('does not throw on NUL bytes', () => {
|
|
const result = parse('echo \0\0 test \0');
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
});
|