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." +}