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 < { 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 < 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 < { expectTree( 'cat < { expectTree( 'cat < { expectTree( 'cat < { 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 < { 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); }); });