Skip to content

shell: end a statement at the then/elif/else/fi that follows a compound command - #45904

Open
robobun wants to merge 3 commits into
mainfrom
robobun/c15f8a10/shell-if-keyword-after-compound
Open

robobun wants to merge 3 commits into
mainfrom
robobun/c15f8a10/shell-if-keyword-after-compound

Conversation

@robobun

@robobun robobun commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • if false; then (echo a) else echo b; fi prints nothing and exits 0 (bash prints b). After fi, ) or ]], the next then, elif, else or fi runs as a command or fails the parse: Expected "else", "elif", or "fi" but got: Eof.
  • Cause: Parser::parse_stmt (src/shell_parser/parse.rs:844) starts another command there.
  • No user reported this. A read of the parser found it.

Fix

  • parse_if_body passes the words its clause waits for to parse_stmt. One of them ends the statement after fi, ) or ]].
  • Correct because the statement ends where the loop of parse_if_body stops. bash 5.2 accepts each test script.
  • Verified: test/js/bun/shell/parse.test.ts (170 new cases fail on main), bunshell.test.ts, test/cli/run/run-shell.test.ts, all of test/js/bun/shell/.
  • Self-reviewed: 23 concerns raised, 21 addressed. Rejected: a restack on shell: keep words after a redirection as arguments of the same command #34901.

Background

Downsides

  • A valid script that was a parse error can now reach another open bug, sometimes with no message. One that main parses because two bugs cancel is now a parse error.
  • Of the scripts that bash rejects, some now parse and two kinds that exited 0 now fail (Notes).
  • Cost per statement: +10 instructions (+36 in an if body, of about 1,900), no allocation. .text: +256 bytes.
Notes

Before and after. Release builds of main (017b243) and of this PR. bash 5.2.37 gives the result of the last column for each valid script. dash does too, except for the [[ ]] row: dash has no [[.

script main this PR
if false; then (echo a) else echo b; fi prints nothing, exit 0 prints b
if true; then (echo a) else⏎ echo b; fi prints a, bun: command not found: else, b prints a
if true; then if true; then echo hi; fi else echo no; fi prints hi, bun: command not found: else, exit 1 prints hi
if true; then if true; then echo hi; fi fi Expected "else", "elif", or "fi" but got: Eof prints hi
if true; then (echo hi) fi the same error prints hi
if (true) then echo yes; fi Expected "then" but got: Eof prints yes
if [[ a == a ]] then echo yes; fi Expected "then" but got: Eof prints yes
if true; then⏎ if true; then⏎ echo x⏎ fi # inner⏎fi Expected "else", "elif", or "fi" but got: Eof prints x
if false; then⏎ if true; then⏎ echo a⏎ fi # inner⏎else⏎ echo b⏎fi prints nothing, exit 0 prints b
if true; then echo hi fi parse error parse error (fi is an argument of echo)
(echo hi) fi prints hi, then bun: command not found: fi the same

What changes, by class. The probe corpus has 45 scripts that bash accepts with a word of the clause directly after a compound command, 21 scripts that work on main, and 51 scripts that bash rejects. Each script went through shellInternals.parse on both builds.

  • Wrong result on main: 7 of the 45. 4 skip the else or elif branch with exit 0 and no message. 3 run else as a command. All 7 now parse to the tree of their spelling with ;.

  • Parse error on main for a valid script: 38 of the 45. All 38 now parse to the tree of their spelling with ;.

  • No change for a script that works: 21 of 21 parse to the same tree. The 633 script templates without interpolation in test/js/bun/shell/ and test/cli/run/run-shell.test.ts on main give the same parse result. So do 11,886 shell sources on the build host (1,135 shell files and 10,751 package.json scripts).

  • A script that bash rejects and that exited 0 on main now fails. Two kinds:

    • if false; then (echo a) elif true; fi. On main elif true was a command in a branch that did not run. It is now a parse error: Expected "then" but got: Eof.
    • if false; then (echo b) fi; fi. On main the first fi was a command in a branch that did not run. Now the first fi closes the clause and the second one runs: bun: command not found: fi, exit 1.
  • A script that bash rejects and that now parses. Main rejects 26 of the 51 and this PR rejects 19. The 7:

    • if true; then (echo a) (echo b) fi, if true; then echo a [[ b == b ]] fi and if true; then echo a > /dev/null (echo b) fi. These are two commands with no separator. At top level main runs both commands. bash rejects them by the statement rule (below).
    • if true; then (echo hi) fi fi, if true; then ( (echo a) fi ) fi and if true; then (echo a) else (echo b) elif true; then echo c; fi. A stray word of a clause runs as a command, as it does at the start of a statement on main. shell: reject !, brace groups, here-documents and stray then/elif/else/fi at parse time #43922 makes it a parse error.
    • if true; then (echo hi) \fi. The lexer drops the backslash, so \fi is fi in each position on main. shell: fail the parse when for/while/until/select/case/esac/function/do/done is used as a command #43921 records the escaped characters.
    • Not in the corpus: an empty body. if (true) then fi and if (true) then (echo a) else fi were parse errors and now run. Main accepts an empty body in the spelling with ; (if (true); then fi).
  • A valid script that was a parse error on main and that now runs into another open bug. The parse error hid that bug. Main does the same for the spelling with ;. Two examples:

  • A valid script that parses on main and is a parse error here. It needs two things in one clause: a word of the clause directly after a compound command, and a comment after a simple command on the line before then or fi (the bug of shell: end a comment before its newline, accept a line break after &&, ||, | and inside [[ ]] #41468). On main the two bugs cancel:

    if false; then
    (echo a) elif
    false # c
    then
    true; fi; echo z

    main and bash print z. On main elif is a command in a branch that does not run, and then is an argument of false. Here elif is the word of the clause, then is still an argument of false, and the parse fails with Expected "then" but got: Eof.

Reach. No GitHub issue exists for this bug, and the error text appears in no issue. No local shell source changes (above). The reason to change it without a report is the first row of the table: a valid script takes the wrong branch with no message.

The statement rule. In bash a statement holds one command: a second word after fi, ) or ]] is a syntax error. The parse_stmt of Bun Shell loops and runs that word as a second command. #34901 had a rewrite of parse_stmt with the bash rule. It removed the rule on 2026-10-20 and keeps the word loop of parse_simple_cmd, which makes the words after a redirection arguments.

The rule stops valid scripts today. In (echo a) # note⏎echo b the comment swallows its newline, so echo b follows ) with no separator. Main and this PR run both commands, as bash does. The rule needs the lexer change of #41468 or #29670 first, so it is not in this PR.

When the rule comes, this change is one condition on it: if !self.match_stmt_end() && !self.peek_any_ifclausetok(until), with the until parameter from parse_if_body. Expr::ends_in_compound_cmd can go when parse_simple_cmd takes each word after a redirection (the word loop of #34901).

Order with #43922. #43922 turns a stray then, elif, else or fi at the start of a command into Unexpected token. By a read of that branch, without this PR it rejects all 45 valid scripts, and the 7 wrong results of main become parse errors. With this PR the statement ends before parse_compound_cmd sees the word, so the two compose with no code change.

Conformance cases in the tree. bunshell.test.ts has six .todo cases from posix-shell-tests if.tst ("nest between ..."). They test this rule with { } and !, which Bun Shell does not have, so they stay .todo. Their ( ) forms are in the new tests.

Tests.

  • parse.test.ts: 150 generated pairs (10 positions of a clause, 3 compound commands, 5 list shapes) and 19 pairs written by hand. Each pair compares the tree of a script with the tree of its spelling with ;. 13 scripts must fail with an exact message. 2 tests pin trees.
  • bunshell.test.ts: 20 scripts run through $. One more pins an interpolated fi.
  • run-shell.test.ts: one script through bun script.sh.
  • Each clause of the change was switched off in a debug build, one at a time, 13 variants. Each variant fails at least one test. The variants: no compound-command test, no word test, first() or any() for last(), each arm of ends_in_compound_cmd widened or narrowed, and each wrong set of words from parse_impl and parse_if_body.
  • 15 of the 206 new tests pass on main. They pin what must stay: a word after a redirection is not a word of the clause, a quoted or interpolated fi is a plain word, echo hi fi is one command, and a word that no clause waits for does not end the statement.

Measurements. Release builds of main (017b243) and of this PR. perf, valgrind, strace and bloaty are not available in the build container. The instruction, branch and allocator counts come from a ptrace single-step of the main thread, from the entry of the first Parser::parse_impl call to its return. Each script ends in an unclosed if true; then, so the parse fails and no command runs. A per-statement number is the count for 200 statements minus the count for 100 statements, divided by 100.

measurement main this PR
instructions per top-level statement echo a b c 1,912.18 1,922.18 (+10)
conditional branches per top-level statement 257.02 258.02 (+1)
instructions per statement in an if body 1,914.52 1,950.52 (+36)
conditional branches per statement in an if body 261.55 265.55 (+4)
mi_heap_malloc calls for echo a; echo b; echo c, if true; then echo a; fi, if true; then (echo a); fi and a 20-line script 26, 21, 24, 124 26, 21, 24, 124
mi_heap_malloc calls for if true; then (echo a) fi (parse error) 24, as for the spelling with ;
calls of if_clause_tok_at for the same four scripts (debug counter) 3, 9, 8, 37 3, 9, 8, 37
calls of if_clause_tok_at for if true; then (echo a) fi (parse error) 9
.text of the stripped binary (llvm-size -A) 81,368,037 81,368,293 (+256)
file size of the stripped binary 106,911,456 106,911,456
parse_stmt, parse_if_body, peek_any_ifclausetok in bytes (llvm-nm -S) 1,088, 1,025, inlined 1,289, 804, 355

26 of the 36 instructions in an if body are a call. peek_any_ifclausetok now has two callers, so the compiler no longer inlines it into the loop of parse_if_body. The same test written with IfClauseTok::from_tok keeps it inlined and measures +13 instructions and +1 branch for each kind of statement. This PR keeps the one shared predicate.

The new test ran once in the 515 parses of parse.test.ts, bunshell.test.ts and lex.test.ts as they are on main (debug counter). The script is FOO="" [[ -n $FOO ]] && echo yes!, a statement of two commands.

Considered and not taken.

Not changed here.

Suites. Debug (ASAN) build of the branch with main (9634520) merged: parse.test.ts, bunshell.test.ts, lex.test.ts, exec.test.ts and test/cli/run/run-shell.test.ts pass. That merge brings the stack check of #46021 into parse_stmt. The measurements above are from before the merge and were not repeated. Release build of this PR before the merge: the 44 files of test/js/bun/shell/ and run-shell.test.ts pass. On the build host, some tests that start a debug binary or delete a large tree time out in one run. They pass when run again.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/shell/bunshell.test.ts, test/cli/run/run-shell.test.ts

…nd command

After a compound command (an if clause, a subshell, a [[ ]] test) the
parser started another command of the same statement, so the word that
closes or continues the enclosing if clause became a command name.

parse_stmt now gets the words that the enclosing clause waits for from
parse_if_body. One of them ends the statement when it follows a
compound command. parse_if_clause consumes the word as before.
@robobun

robobun commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:19 PM PT - Oct 21st, 2026

✅ @robobun, your commit 58975ee3654d9b23b2680905cb14098fc7f8e65d passed in Build #130134! 🎉


🧪   To try this PR locally:

bunx bun-pr 45904

That installs a local version of the PR into your bun-45904 executable, so you can run:

bun-45904 --bun

@robobun

robobun commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: the fix is in this PR (#45904).

How to reproduce on main, with bash for comparison:

printf 'if false; then (echo a) else echo b; fi\n' > a.sh
bun a.sh    # prints nothing, exit 0. bash prints "b"

printf 'if true; then if true; then echo hi; fi fi\n' > b.sh
bun b.sh    # error: Failed to run b.sh due to error Expected "else", "elif", or "fi" but got: Eof. bash prints "hi"

With this PR both scripts print what bash prints.

@coderabbitai

coderabbitai Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: f48c6490-c0a4-4d0a-ae3f-35e9def1f6d5
📥 Commits

Reviewing files that changed from the base of the PR and between e52fe5f and 2765235.

📒 Files selected for processing: 1
  • test/js/bun/shell/bunshell.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 2 remain after this review.

Walkthrough

The parser now stops before an enclosing if-clause terminator when the preceding statement ends in a compound command. Parser and shell tests cover clause keywords after compound commands.

Changes

If-clause terminator handling

Layer / File(s) Summary
Parser recognition and validation
src/shell_parser/parse.rs, test/js/bun/shell/parse.test.ts, test/js/bun/shell/bunshell.test.ts, test/cli/run/run-shell.test.ts
parse_stmt receives enclosing terminators and stops before a matching token when the accumulated expression ends in a subshell, if, or conditional expression. Tests cover AST results, parse errors, nested clauses, and shell execution.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 27652

No actionable merge-blocking risk is identified.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main parser change: ending statements at if-clause words that follow compound commands.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description check Passed The pull request description is complete and relevant. It explains the problem, cause, fix, scope, limitations, performance impact, and verification results. It does not use the template headings exac…
Full details: Description check

Explanation

The pull request description is complete and relevant. It explains the problem, cause, fix, scope, limitations, performance impact, and verification results. It does not use the template headings exactly, but it provides the required information in equivalent sections.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked two paths: a stray clause word after a compound command inside a nested ( ) or $( ) still runs as a command because parse_impl passes &[], but an if nested inside that subshell still forwards its own until through parse_if_body, so only the shapes bash itself rejects are affected; and interpolated ${...} values lex as non-Text tokens, so peek_any_ifclausetok cannot treat them as fi/else, which the new bunshell.test.ts case pins. parse_stmt has only the two shell-parser callers, both updated.

Extended reasoning...

The diff adds Expr::ends_in_compound_cmd and a single early break in parse_stmt of src/shell_parser/parse.rs, threaded via a new until parameter from parse_if_body, plus tests in three files; no security-sensitive surface beyond the shell parser consuming user script text, and the new break only ever shortens a statement at a keyword the enclosing clause already waits for. One inline finding concerns pre-existing lexer handling of trailing comments that the new comment tests lean on rather than the fix itself, so a human should weigh whether those cases belong in this PR.

Comment thread test/js/bun/shell/parse.test.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread test/js/bun/shell/bunshell.test.ts
…l-if-keyword-after-compound

# Conflicts:
#	src/shell_parser/parse.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-reviewed the current revision and found no bugs. Because this changes which Bun Shell scripts parse (and how a few bash-rejected scripts now behave, per the Notes), a maintainer's look at the semantics is still worthwhile before merging.

What was reviewed:

  • The new parse_stmt break: it fires only when the previous Expr ends in a subshell/if/[[ ]] and the next token passes the same peek_any_ifclausetok predicate parse_if_body already uses (plain Token::Text, delimited, not interpolated), so "fi", fi$X and ${"fi"} stay words; top-level parse passes &[], so nothing changes outside an if clause.
  • Token flow after an inner fi/): the lexer emits Delimit before ;/newline and match_if_clausetok consumes only the Delimit, so the existing separator-based exit still runs; the new break only matters when no separator token remains (none written, or a trailing # comment ate the newline).
  • ends_in_compound_cmd is exhaustive over Expr and PipelineItem, and every parse_stmt caller received the until parameter.
  • Tests: the pair matrix compares against the ;-spelled AST (which fails if either side throws), the negative cases go through a helper that returns undefined on success, and the run-shell subprocess test drains stdout/stderr/exit concurrently.
Extended reasoning...

The diff adds 24 lines to src/shell_parser/parse.rs (an until: &[IfClauseTok] parameter on parse_stmt, one early break in its expression loop, and an exhaustive Expr::ends_in_compound_cmd helper) plus about 220 lines of tests across parse.test.ts, bunshell.test.ts and run-shell.test.ts. It touches no security-sensitive surface; the parser error paths remain recoverable ParseErrors with no new unwrap/panic. Both prior inline threads were author-resolved after follow-up commits; I re-checked the .error() case and it can fail either way (stdout mismatch if the clause closes, thrown ShellError if fi runs as a command), and the comment-newline issue is pre-existing and out of scope. The decision to defer rather than approve rests on this being a grammar-semantics change with author-documented behavioral tradeoffs for bash-invalid scripts, which a maintainer should weigh; no CODEOWNER covers these paths and the hunt ran dry with no findings.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant