From d1317c617674aabc93b3c6cc313834a7c1c65d03 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 11 May 2026 16:08:56 +0000 Subject: [PATCH] fix: control-flow keyword detection precedes paren-balance (B4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `case x in a) ;; esac` previously surfaced as `IsUnparseable=true` with reason `unbalanced parens at position N` because the closing `)` in `a)` tripped SplitIntoSegments before ParseClauseSegment's per-clause control-flow check could fire. The reason hid the real cause from consumers building safe-fail prompts. The anomaly pass (TryDetectAnomaly) now scans the filtered token stream for control-flow keywords at verb position (index 0 OR immediately after a clause separator `&&`, `||`, `;`, `|`, or `(`) and short-circuits before downstream checks. Scope matches the existing per-clause check: `echo case` is still fine (case is a positional arg, not a verb). SPEC §11 now pins the full diagnostic precedence order: 1. UnparseableSentinel tokens (lexer-level) 2. Control-flow keyword at verb position <-- NEW SLOT 3. Function definition (Word()() 4. Process substitution (<( / >() 5. Segment-split errors (unbalanced parens, unexpected operator) 6. bash -c recursion depth Folding into 0.1.2-alpha (no version bump) — the same release that ships B2 + B3. Changes: - src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs: new keyword-at-verb-position scan at the top of TryDetectAnomaly. - SPEC.md §11: new "Diagnostic precedence" enumeration. - tests/.../Corpus/bash/122_unparseable_case_with_body.json: new entry pinning `case x in a) ;; esac` → 'case' reason. - RELEASE_NOTES.md: 0.1.2-alpha section extended with B4 bullet. Public API impact: zero. dotnet build -c Release: 0 warnings, 0 errors. dotnet test -c Release: 360/360 passing (was 359; +1 entry). dotnet pack: ShellSyntaxTree.0.1.2-alpha.nupkg + .snupkg clean. --- RELEASE_NOTES.md | 22 +++++++++++--- SPEC.md | 17 +++++++++++ .../Bash/Parsing/BashCommandParser.cs | 30 +++++++++++++++++++ .../bash/122_unparseable_case_with_body.json | 9 ++++++ 4 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/122_unparseable_case_with_body.json diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index fe2852b..406d123 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,6 +1,6 @@ #### 0.1.2-alpha May 11th 2026 #### -Two parser correctness fixes. Public API unchanged. +Three parser correctness fixes. Public API unchanged. **Fixed** @@ -19,17 +19,31 @@ Two parser correctness fixes. Public API unchanged. non-trailing position. Forward-slash behavior is unchanged — `dir/` still classifies as a path (trailing `/` is a meaningful bash directory hint). +- **Control-flow keyword detection precedes paren-balance (B4).** + Previously `case x in a) ;; esac` produced `IsUnparseable=true` with + reason `unbalanced parens at position N` because the `)` in `a)` + tripped `SplitIntoSegments` before the per-clause keyword check + could fire. The anomaly pass now scans the token stream for + control-flow keywords at verb position (start of input or after + `&&` / `||` / `;` / `|` / `(`) and short-circuits with the helpful + `control-flow keyword 'case' is not supported in v0.1` reason + before downstream checks run. SPEC §11 now pins the full diagnostic + precedence order. **Behavior notes** - Public API surface is unchanged (no `PublicApiSnapshotTests` delta). - SPEC.md §8: new "Step 0: Single-quoted bypass" preamble; LooksLikePath heuristic updated to call out the trailing-backslash carve-out. +- SPEC.md §11: new "Diagnostic precedence" section enumerating the + order in which unparseable conditions are checked. - Corpus entries 104 (`echo 'literal $HOME'`) and 109 (`echo "trailing - backslash\\"`) updated to the corrected outputs. Three new entries - (119–121) pin the regression guards: single-quoted absolute paths + backslash\\"`) updated to the corrected outputs. Four new entries + (119–122) pin the regression guards: single-quoted absolute paths still resolve, `cd dir/` still classifies as a path, single-quoted - `$VAR` stays literal under `rm`. + `$VAR` stays literal under `rm`, and `case x in a) ;; esac` now + reports the control-flow keyword reason instead of a paren-balance + error. #### 0.1.1-alpha May 11th 2026 #### diff --git a/SPEC.md b/SPEC.md index 9b0852b..91bac16 100644 --- a/SPEC.md +++ b/SPEC.md @@ -862,6 +862,23 @@ Conditions that produce `IsUnparseable = true`: mechanism). - Recursion depth exceeded on `bash -c` chains (>5 levels). +**Diagnostic precedence.** When multiple conditions could fire on a +single input (e.g. `case x in a) ;; esac` is both a control-flow +keyword AND has unbalanced parens), the parser checks them in this +order so the most informative reason wins: + +1. Lexer-emitted `UnparseableSentinel` tokens (unbalanced quote / + unterminated heredoc / arithmetic / complex parameter expansion). +2. Control-flow keyword at verb position (start of input or + immediately after a clause separator `&&`, `||`, `;`, `|`, or + `(`). Catches `case x in a) ;; esac` before the `)` triggers a + paren-balance error. +3. Function definition pattern (`Word` immediately followed by `(`, + `)`). +4. Process substitution (`<(` or `>(` adjacent). +5. Segment-split errors (unbalanced parens, unexpected operator). +6. `bash -c` recursion depth cap. + Consumers (e.g. Netclaw's gate evaluator) route unparseable commands to a safe-fail path (prompt the user; offer only Once and Deny — no persistent grants on shapes the parser can't model). diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs index a47ad06..c488e0f 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs @@ -511,6 +511,36 @@ private static List FilterSignificant(IReadOnlyList tokens private static bool TryDetectAnomaly(IReadOnlyList tokens, out string? reason) { + // Control-flow keyword at verb position runs FIRST (SPEC §11 + // precedence). A keyword like `case x in a) ;; esac` would + // otherwise trip the paren-balance check in SplitIntoSegments + // before we get to ParseClauseSegment's per-clause keyword check + // — and the resulting "unbalanced parens" reason hides the real + // cause. Verb position = index 0 OR immediately after a clause + // separator (`&&`, `||`, `;`, `|`, or the `(` that opens a + // subshell). This intentionally skips `case` / `for` / etc. + // appearing as POSITIONAL ARGS (e.g. `echo case`), matching the + // ParseClauseSegment scoping. + var nextIsVerbSlot = true; + foreach (var t in tokens) + { + if (t.Kind == BashTokenKind.Operator) + { + nextIsVerbSlot = t.OperatorText is "&&" or "||" or ";" or "|" or "("; + continue; + } + + if (nextIsVerbSlot + && t.Kind == BashTokenKind.Word + && BashVerbs.ControlFlowKeywords.Contains(t.Value)) + { + reason = $"control-flow keyword '{t.Value}' is not supported in v0.1"; + return true; + } + + nextIsVerbSlot = false; + } + // Function definition: `name() { ... }`. Trigger = a Word followed // by an immediately-adjacent `(` and `)`. for (var i = 0; i + 2 < tokens.Count; i++) diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/122_unparseable_case_with_body.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/122_unparseable_case_with_body.json new file mode 100644 index 0000000..c491f8d --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/122_unparseable_case_with_body.json @@ -0,0 +1,9 @@ +{ + "name": "Unparseable: case keyword precedence (case x in a) ;; esac)", + "input": "case x in a) ;; esac", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "'case'" + }, + "notes": "v0.1.2 / B4: SPEC §11 precedence — control-flow keyword detection runs BEFORE paren-balance. Previously this input tripped 'unbalanced parens at position N' because the `)` in `a)` reached SplitIntoSegments before ParseClauseSegment's first-verb check could fire. The anomaly pass now scans the token stream for control-flow keywords at verb position (start of input or after a clause separator) and short-circuits with the helpful reason." +}