Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -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**

Expand All @@ -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 ####

Expand Down
17 changes: 17 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
30 changes: 30 additions & 0 deletions src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,36 @@ private static List<BashToken> FilterSignificant(IReadOnlyList<BashToken> tokens

private static bool TryDetectAnomaly(IReadOnlyList<BashToken> 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++)
Expand Down
Original file line number Diff line number Diff line change
@@ -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."
}
Loading