diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
index 9020b7b..710e596 100644
--- a/IMPLEMENTATION_PLAN.md
+++ b/IMPLEMENTATION_PLAN.md
@@ -233,8 +233,15 @@ priorities.
precedence, ancestry coordinates, reference identity, span and enum
validity, value/redirect invariants, safe defaults, copied collections,
and the 16-container bound.
-- [ ] Adapt the existing Bash and PowerShell grammars to emit the structural
- and command-occurrence projections before enabling any control-flow
+- [x] Adapt the existing Bash grammar to emit structural and command-occurrence
+ projections before enabling control flow. The recursive coordinator
+ preserves pipeline/list precedence, isolated nested groups, decoded
+ wrapper ownership and nullable spans, compatibility operators, and exact
+ `Clause` identity; unsupported wrapper tails and depth overflow fail
+ closed. Redirect-bearing leaves remain incomplete until the explicit
+ redirect-analysis slice lands.
+- [ ] Adapt the existing PowerShell grammar to emit the structural and
+ command-occurrence projections before enabling any control-flow
construct.
- [ ] Deliver paired Bash and PowerShell `$()` substitution slices for all
locked executable value positions, including ordering, ancestry,
diff --git a/SPEC.md b/SPEC.md
index e1d03f7..2d1d3dc 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -929,7 +929,10 @@ command := clause (compound_op clause)*
compound_op := "&&" | "||" | ";" | "|" | NEWLINE
clause := subshell | bash_c_wrapper | simple_clause
subshell := "(" command ")"
-bash_c_wrapper := ("bash" | "sh") "-c" QUOTED_STRING
+bash_c_wrapper := ("bash" | "sh") static_flag* "-c" STATIC_QUOTED_STRING
+static_flag := exact-one literal Word beginning with "-"
+STATIC_QUOTED_STRING := QuotedString whose outer-shell provenance is entirely
+ literal and exactly one value
simple_clause := verb_chain arg* redirect*
verb_chain := verb_like_word (FW_pair? verb_like_word)*
// greedy walk per §6.1; FW_pair is a
@@ -1669,6 +1672,16 @@ The outer `bash -c` itself does not appear as a clause — it's "consumed"
by the recursion. Consumers that care that this came from a wrapper can
inspect `IsCommandStringWrapped` on the surfaced clauses.
+A `bash` or `sh` clause whose authored arguments are dynamic, contain a decoded
+`-c`, or contain a combined short option that may select command-string mode,
+but that does not match the complete static wrapper production, remains visible
+through its v0.2 compatibility leaf, including its direct outer source spans.
+Its v0.3 command occurrence has `IsComplete=false`. Wrapper-control tokens and
+the quoted body must each have literal, exactly-one outer-shell provenance;
+token kind or decoded spelling alone is insufficient. A proved `--` ends this
+conservative option scan. The parser does not claim to have discovered a
+dynamic or otherwise unsupported command-string body.
+
**Recursion limit:** parse `bash -c "bash -c ..."` chains up to depth 5.
Deeper nesting → set the outer `ParsedCommand.IsUnparseable = true` with
reason `"bash -c recursion depth exceeded (>5)"` per locked interpretation
diff --git a/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md b/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md
index 8d680e2..5a13cb9 100644
--- a/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md
+++ b/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md
@@ -68,6 +68,11 @@ partial command and compatibility result.
- **THEN** projection fails closed
- **THEN** the occurrence is not published with `IsComplete=true`
+#### Scenario: Dynamic Bash command string remains incomplete
+- **WHEN** Bash parses a `bash` or `sh` clause with dynamic wrapper-control input, decoded or combined command-string options, or an expanding quoted body that does not match the complete literal exactly-one wrapper production
+- **THEN** the existing outer compatibility leaf remains visible with direct source provenance
+- **THEN** its command occurrence has `IsComplete=false` because no hidden command-string body was discovered
+
#### Scenario: While condition and body roles
- **WHEN** Bash parses `while curl URL; do sleep 1; done`
- **THEN** `curl` is identified as a condition occurrence
diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md
index 853c955..c243947 100644
--- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md
+++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md
@@ -27,7 +27,7 @@
- [x] 3.3 Add `ParsedCommand.Syntax` and `ParsedCommand.Commands` while retaining all v0.2 members.
- [x] 3.4 Build a library-owned traversal that emits each simple command occurrence exactly once in deterministic source order.
- [x] 3.5 Build the conservative `Clauses` compatibility flattener without inventing cross-structure compound operators.
-- [ ] 3.6 Adapt the existing Bash grammar to emit the structural model with no newly supported syntax.
+- [x] 3.6 Adapt the existing Bash grammar to emit the structural model with no newly supported syntax.
- [ ] 3.7 Adapt the existing PowerShell grammar to emit the structural model with no newly supported syntax.
- [ ] 3.8 Add tests proving existing parser inputs retain their v0.2 leaf and compatibility results.
- [ ] 3.9 Add corpus expectations for syntax shape, occurrences, roles, and completeness for existing constructs.
diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs
index 62d84c0..267c510 100644
--- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs
+++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs
@@ -19,9 +19,11 @@ namespace ShellSyntaxTree.Internal.Bash.Parsing;
/// (SPEC §7), the resolver (SPEC §8), and the flag-with-value-aware verb-
/// chain probe. PR 5 lands cd-in-compound attribution propagation
/// (SPEC §9), subshell isolation (SPEC §10), and bash -c recursion
-/// with the depth-5 cap per locked interpretation #4.
+/// with the depth-5 cap per locked interpretation #4. The v0.3 structural
+/// coordinator owns lists, pipelines, groups, and command projections while
+/// this type retains the proven simple-command leaf analysis.
///
-internal static class BashCommandParser
+internal static partial class BashCommandParser
{
///
/// Maximum allowed bash -c / sh -c nesting depth before
@@ -68,250 +70,36 @@ private static ParsedCommand ParseInternal(
{
if (source.Length == 0)
{
- return new ParsedCommand
- {
- Source = source,
- Clauses = Array.Empty(),
- IsUnparseable = false,
- };
+ return ParseStructured(
+ source,
+ Array.Empty(),
+ options,
+ bashCDepth,
+ markBashCWrapped);
}
var tokens = BashLexer.Tokenize(source);
-
- // Step 1: lift any UnparseableSentinel to the outer ParsedCommand.
- // SPEC §11 step 3 says we may also return whatever clauses were
- // parsed up to that point — we keep it strictly safe-fail (empty
- // Clauses) so consumers can't build on a partial AST whose shape
- // a sibling clause might invalidate. The reason text comes
- // straight from the lexer.
- for (var i = 0; i < tokens.Count; i++)
+ for (var index = 0; index < tokens.Count; index++)
{
- var t = tokens[i];
- if (t.Kind == BashTokenKind.UnparseableSentinel)
+ var token = tokens[index];
+ if (token.Kind == BashTokenKind.UnparseableSentinel)
{
- return new ParsedCommand
- {
- Source = source,
- Clauses = Array.Empty(),
- IsUnparseable = true,
- UnparseableReason = t.UnparseableReason,
- };
+ return StructuralFailure(source, token.UnparseableReason);
}
}
- // Step 2: split the (filtered, non-whitespace) token stream into
- // clause segments at top-level &&, ||, ;, and |.
var significant = FilterSignificant(tokens);
-
- // Step 3: detect anomalies that map straight to outer IsUnparseable.
if (TryDetectAnomaly(significant, out var anomalyReason))
{
- return new ParsedCommand
- {
- Source = source,
- Clauses = Array.Empty(),
- IsUnparseable = true,
- UnparseableReason = anomalyReason,
- };
- }
-
- var segments = SplitIntoSegments(significant, source, out var splitError);
- if (splitError is not null)
- {
- return new ParsedCommand
- {
- Source = source,
- Clauses = Array.Empty(),
- IsUnparseable = true,
- UnparseableReason = splitError,
- };
+ return StructuralFailure(source, anomalyReason);
}
- // Step 4: walk segments with the cd-attribution context and the
- // bash -c recursion machinery.
- var clauses = new List(segments.Count);
- var attribution = new CdAttributionContext();
- IReadOnlyList prevStack = new[] { 0 };
-
- foreach (var segment in segments)
- {
- // ---- Subshell push/pop driven by SubshellStack divergence ----
- //
- // Each segment carries the *full* stack of subshell IDs it
- // sits inside (outer-most → inner-most), with ID 0 reserved
- // for the top-level command. We pop pushed frames back to the
- // common prefix between prevStack and segment.SubshellStack,
- // then push fresh frames for each new ID we're entering. This
- // correctly handles `(a) && (b)`: between the two segments
- // we exit subshell A (pop) and enter subshell B (push), even
- // though SubshellDepth=1 on both.
- var commonPrefix = 0;
- while (commonPrefix < prevStack.Count
- && commonPrefix < segment.SubshellStack.Count
- && prevStack[commonPrefix] == segment.SubshellStack[commonPrefix])
- {
- commonPrefix++;
- }
-
- // Pop everything past the common prefix in prevStack.
- for (var k = prevStack.Count - 1; k >= commonPrefix; k--)
- {
- attribution.PopForSubshell();
- }
-
- // Push fresh frames for the new IDs in segment.SubshellStack.
- for (var k = commonPrefix; k < segment.SubshellStack.Count; k++)
- {
- attribution.PushForSubshell();
- }
-
- prevStack = segment.SubshellStack;
-
- // ---- bash -c detection (before clause-build) ----
- //
- // Locked interpretation #4: nested `bash -c "..."` wrappers
- // expand inline; the outer wrapper clause is consumed. The cap
- // at depth 5 fires here — one more level past 5 → outer
- // ParsedCommand.IsUnparseable = true with reason naming the
- // overflow. Sub-clauses parsed *up to* the cap may still appear,
- // but per SPEC §11 + locked interpretation #4 we keep clauses
- // empty for hostile-input safety.
- if (TryDetectBashCWrapper(segment, source, out var innerCommand))
- {
- if (bashCDepth + 1 > MaxBashCRecursionDepth)
- {
- return new ParsedCommand
- {
- Source = source,
- Clauses = Array.Empty(),
- IsUnparseable = true,
- UnparseableReason = "bash -c recursion depth exceeded (>5)",
- };
- }
-
- // Recurse with the original options — bash -c spawns a
- // fresh shell, so outer cd-attribution does *not* propagate
- // into the inner command. This is a v0.1 decision; v0.1.x
- // can revisit if real-world commands surface a counter-case.
- var inner = ParseInternal(
- innerCommand!,
- options,
- bashCDepth: bashCDepth + 1,
- markBashCWrapped: true);
-
- if (inner.IsUnparseable)
- {
- return new ParsedCommand
- {
- Source = source,
- Clauses = Array.Empty(),
- IsUnparseable = true,
- UnparseableReason = inner.UnparseableReason,
- };
- }
-
- // First inner clause inherits the outer segment's operator
- // (since the bash -c clause itself is consumed). Remaining
- // inner clauses keep their parsed operators.
- var innerClauses = inner.Clauses;
- for (var k = 0; k < innerClauses.Count; k++)
- {
- var ic = innerClauses[k];
- var op = k == 0 ? segment.PrecedingOperator : ic.Operator;
- var isSubshell = segment.SubshellDepth > 0 || ic.IsSubshell;
- clauses.Add(ic with
- {
- Operator = op,
- IsSubshell = isSubshell,
- IsCommandStringWrapped = true,
- Elements = ClauseElementProvenance.WithoutOuterSourceSpans(ic.Elements),
- });
- }
-
- continue;
- }
-
- // ---- Normal clause path with attribution propagation ----
- //
- // Effective resolution options depend on attribution state:
- // - no attribution → caller options pass through unchanged.
- // - literal cd attribution → swap WorkingDirectory to the cd
- // target so relative path args in subsequent clauses
- // resolve under it (SPEC §9 example: `cd /a && cat foo`
- // → cat's `foo` resolves to `/a/foo`).
- // - dynamic cd attribution (locked interpretation #6) →
- // keep caller options but set the resolver's
- // `workingDirectoryUnknown` flag so relative paths surface
- // as DynamicSkip (the daemon cwd is *not* the right
- // fallback; we statically don't know the actual cwd).
- var effectiveOptions = options;
- var workingDirectoryUnknown = false;
- if (attribution.HasAttribution && !attribution.IsDynamic)
- {
- effectiveOptions = new BashParserOptions
- {
- HomeDirectory = options.HomeDirectory,
- WorkingDirectory = attribution.ResolvedCwd,
- };
- }
- else if (attribution.IsDynamic)
- {
- workingDirectoryUnknown = true;
- }
-
- var clauseOrError = ParseClauseSegment(segment, source, effectiveOptions, workingDirectoryUnknown);
- if (clauseOrError.Error is not null)
- {
- return new ParsedCommand
- {
- Source = source,
- Clauses = Array.Empty(),
- IsUnparseable = true,
- UnparseableReason = clauseOrError.Error,
- };
- }
-
- foreach (var clause in clauseOrError.Clauses)
- {
- // Apply the IsSubshell / IsCommandStringWrapped flags first; both
- // are properties of the *segment*, not the clause body.
- var withFlags = clause with
- {
- IsSubshell = segment.SubshellDepth > 0,
- IsCommandStringWrapped = markBashCWrapped,
- };
-
- // Inspect the verb to decide whether this clause updates
- // the attribution context after emission.
- var verb = clause.Verb;
- var firstVerbToken = verb.Tokens.Count > 0 ? verb.Tokens[0] : null;
- var isCdLike = firstVerbToken is not null
- && (string.Equals(firstVerbToken, "cd", StringComparison.OrdinalIgnoreCase)
- || string.Equals(firstVerbToken, "chdir", StringComparison.OrdinalIgnoreCase));
-
- // Every clause receives the *current* attribution as a
- // synthetic arg — including cd/chdir clauses themselves
- // (per SPEC §9 rule 2 "subsequent clauses inherit", which
- // applies to cd /b in `cd /a && cd /b`). The attribution
- // state is updated *after* the arg is attached, so the
- // newly-set cd /b doesn't attribute to itself.
- var emitted = AttachAttributionArg(withFlags, attribution);
-
- if (isCdLike)
- {
- UpdateAttributionFromCd(clause, attribution);
- }
-
- clauses.Add(emitted);
- }
- }
-
- return new ParsedCommand
- {
- Source = source,
- Clauses = clauses,
- IsUnparseable = false,
- };
+ return ParseStructured(
+ source,
+ significant,
+ options,
+ bashCDepth,
+ markBashCWrapped);
}
// ---------------------------------------------------------------- cd attribution
@@ -432,9 +220,11 @@ private static Clause AttachAttributionArg(Clause clause, CdAttributionContext a
/// We scan the segment's tokens directly (rather than running through
/// the full clause parser first) so the wrapper is consumed cleanly —
/// the outer clause never appears in ParsedCommand.Clauses. The
- /// scan looks for: a Word verb of bash or sh, followed by
- /// any combination of flag tokens, then a -c Word flag, then an
- /// adjacent QuotedString token whose value becomes the inner command.
+ /// scan looks for an exact literal Word verb of bash or sh,
+ /// followed by exact literal flag tokens, an exact -c Word flag,
+ /// and a quoted body whose outer-shell provenance is entirely literal
+ /// and exactly one value. Decoded spelling alone is insufficient because
+ /// outer expansions could change the command before the inner shell sees it.
///
private static bool TryDetectBashCWrapper(Segment segment, string source, out string? innerCommand)
{
@@ -447,7 +237,7 @@ private static bool TryDetectBashCWrapper(Segment segment, string source, out st
// First non-flag Word token must be `bash` or `sh`.
var t0 = segment.Tokens[0];
- if (t0.Kind != BashTokenKind.Word)
+ if (t0.Kind != BashTokenKind.Word || !HasExactLiteralValue(t0))
{
return false;
}
@@ -462,7 +252,7 @@ private static bool TryDetectBashCWrapper(Segment segment, string source, out st
for (var i = 1; i < segment.Tokens.Count - 1; i++)
{
var t = segment.Tokens[i];
- if (t.Kind != BashTokenKind.Word)
+ if (t.Kind != BashTokenKind.Word || !HasExactLiteralValue(t))
{
return false;
}
@@ -470,7 +260,8 @@ private static bool TryDetectBashCWrapper(Segment segment, string source, out st
if (string.Equals(t.Value, "-c", StringComparison.Ordinal))
{
var next = segment.Tokens[i + 1];
- if (next.Kind == BashTokenKind.QuotedString)
+ if (next.Kind == BashTokenKind.QuotedString &&
+ HasExactLiteralValue(next))
{
innerCommand = next.Value;
return true;
@@ -500,8 +291,8 @@ private static List FilterSignificant(IReadOnlyList tokens
foreach (var t in tokens)
{
// A newline-bearing Whitespace token is a statement separator
- // (SPEC §4) — it survives filtering so SplitIntoSegments can
- // split clauses on it, exactly like an explicit ';'. Plain
+ // (SPEC §4) — it survives filtering so the structural coordinator
+ // can treat it exactly like an explicit ';'. Plain
// space/tab Whitespace, Continuation, and Comment carry no
// structural signal and are dropped.
if ((t.Kind == BashTokenKind.Whitespace && !t.IsStatementSeparator)
@@ -547,7 +338,7 @@ private static bool TryDetectAnomaly(IReadOnlyList tokens, out string
{
// 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
+ // otherwise trip structural parenthesis validation
// 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
@@ -622,166 +413,15 @@ private static bool TryDetectAnomaly(IReadOnlyList tokens, out string
return false;
}
- // ---------------------------------------------------------------- segment split
+ // ---------------------------------------------------------------- simple-command leaf adapter
private sealed class Segment
{
public CompoundOperator PrecedingOperator { get; init; }
public List Tokens { get; init; } = new();
-
- public bool FromSubshell { get; init; }
-
- ///
- /// Paren-nesting depth of this segment. 0 = top-level; 1 = direct
- /// child of one subshell; etc. PR 5 uses transitions in this value
- /// across consecutive segments to push/pop the cd-attribution stack.
- ///
- public int SubshellDepth { get; init; }
-
- ///
- /// Stack of subshell IDs from outermost to innermost. ID 0 is the
- /// top-level command; each subsequent ( open assigns a fresh
- /// monotonically-increasing ID. PR 5 uses divergence in this stack
- /// across consecutive segments to detect exit-then-re-enter
- /// boundaries (e.g. (a) && (b) where both segments
- /// have SubshellDepth=1 but live in different subshells).
- ///
- public IReadOnlyList SubshellStack { get; init; } = Array.Empty();
- }
-
- private static List SplitIntoSegments(
- IReadOnlyList tokens,
- string source,
- out string? error)
- {
- var segments = new List();
- var subshellStack = new List { 0 }; // ID 0 is the top-level command.
- var nextSubshellId = 1;
- var depth = 0;
-
- // Shared factory for the segment opened at every clause boundary:
- // FromSubshell / SubshellDepth / SubshellStack are uniform (driven
- // by the current `depth`), so only the preceding operator varies.
- Segment OpenSegment(CompoundOperator precedingOperator) => new()
- {
- PrecedingOperator = precedingOperator,
- FromSubshell = depth > 0,
- SubshellDepth = depth,
- SubshellStack = subshellStack.ToArray(),
- };
-
- var current = OpenSegment(CompoundOperator.None);
-
- for (var i = 0; i < tokens.Count; i++)
- {
- var t = tokens[i];
-
- // A retained Whitespace token is a newline statement separator
- // (SPEC §4), equivalent to ';'. Unlike a stray ';', a newline
- // on an empty pending segment is NOT an error — it simply
- // collapses, so blank lines, leading newlines, and a newline
- // right after a compound operator never produce an empty clause.
- if (t.Kind == BashTokenKind.Whitespace)
- {
- if (current.Tokens.Count == 0)
- {
- continue;
- }
-
- segments.Add(current);
- current = OpenSegment(CompoundOperator.Sequence);
- continue;
- }
-
- if (t.Kind == BashTokenKind.Operator)
- {
- var op = t.OperatorText;
-
- if (op == "(")
- {
- if (current.Tokens.Count > 0)
- {
- segments.Add(current);
- }
-
- depth++;
- subshellStack.Add(nextSubshellId++);
- current = OpenSegment(current.Tokens.Count > 0
- ? CompoundOperator.Sequence
- : current.PrecedingOperator);
- continue;
- }
-
- if (op == ")")
- {
- if (depth == 0)
- {
- error = $"unbalanced parens at position {t.SourceStart}";
- return segments;
- }
-
- depth--;
- subshellStack.RemoveAt(subshellStack.Count - 1);
-
- if (current.Tokens.Count > 0)
- {
- segments.Add(current);
- }
-
- current = OpenSegment(CompoundOperator.None);
- continue;
- }
-
- if (op == "&&" || op == "||" || op == ";" || op == "|")
- {
- if (current.Tokens.Count == 0 && current.PrecedingOperator != CompoundOperator.None)
- {
- error = $"unexpected operator '{op}' at position {t.SourceStart}";
- return segments;
- }
-
- if (current.Tokens.Count > 0)
- {
- segments.Add(current);
- }
-
- current = OpenSegment(MapOperator(op));
- continue;
- }
-
- // Redirect operators stay inside the current segment.
- current.Tokens.Add(t);
- continue;
- }
-
- current.Tokens.Add(t);
- }
-
- if (depth != 0)
- {
- error = $"unbalanced parens at position {source.Length}";
- return segments;
- }
-
- if (current.Tokens.Count > 0)
- {
- segments.Add(current);
- }
-
- error = null;
- return segments;
}
- private static CompoundOperator MapOperator(string? op) => op switch
- {
- "&&" => CompoundOperator.AndIf,
- "||" => CompoundOperator.OrIf,
- ";" => CompoundOperator.Sequence,
- "|" => CompoundOperator.Pipe,
- _ => CompoundOperator.None,
- };
-
// ---------------------------------------------------------------- clause parse
private readonly struct ClauseResult
@@ -796,25 +436,14 @@ public ClauseResult(IReadOnlyList clauses, string? error)
Error = error;
}
- public static ClauseResult Empty() => new(Array.Empty(), null);
-
public static ClauseResult Ok(Clause c) => new(new[] { c }, null);
public static ClauseResult Fail(string reason) => new(Array.Empty(), reason);
}
- private static ClauseResult ParseClauseSegment(
- Segment segment, string source, BashParserOptions options)
- => ParseClauseSegment(segment, source, options, workingDirectoryUnknown: false);
-
private static ClauseResult ParseClauseSegment(
Segment segment, string source, BashParserOptions options, bool workingDirectoryUnknown)
{
- if (segment.Tokens.Count == 0)
- {
- return ClauseResult.Empty();
- }
-
// Verb-chain extraction per SPEC §6.1. The FileVerb carveout is
// load-bearing: downstream per-verb positional-arg classification
// depends on the verb chain staying 1 token for FILE verbs so
diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs
new file mode 100644
index 0000000..3a58081
--- /dev/null
+++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs
@@ -0,0 +1,995 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Aaron Stannard
+//
+// -----------------------------------------------------------------------
+using System;
+using System.Collections.Generic;
+using ShellSyntaxTree.Internal.Bash.Lexing;
+using ShellSyntaxTree.Internal.Parsing;
+using ShellSyntaxTree.Internal.Resolving;
+
+namespace ShellSyntaxTree.Internal.Bash.Parsing;
+
+internal static partial class BashCommandParser
+{
+ private static ParsedCommand ParseStructured(
+ string source,
+ IReadOnlyList tokens,
+ BashParserOptions options,
+ int bashCDepth,
+ bool markBashCWrapped)
+ {
+ var coordinator = new StructuralCoordinator(
+ source,
+ tokens,
+ options,
+ bashCDepth,
+ markBashCWrapped);
+ if (!coordinator.TryParse(out var syntax, out var error))
+ {
+ return StructuralFailure(source, error, syntax);
+ }
+
+ if (!ShellSyntaxProjection.TryProject(
+ syntax,
+ simple => new CommandOccurrenceFacts
+ {
+ IsComplete = simple.Clause.Redirects.Count == 0 &&
+ !HasUnexpandedCommandString(simple.Clause) &&
+ !HasUndiscoveredExecutableRegion(simple.Clause),
+ },
+ out var projection))
+ {
+ return StructuralFailure(
+ source,
+ "Bash structural syntax exceeded limits or contained invalid parser-owned facts",
+ syntax);
+ }
+
+ return new ParsedCommand
+ {
+ Source = source,
+ Syntax = syntax,
+ Commands = projection.Commands,
+ Clauses = projection.Clauses,
+ };
+ }
+
+ private static ParsedCommand StructuralFailure(
+ string source,
+ string? reason,
+ ShellBlockSyntax? syntax = null) => new()
+ {
+ Source = source,
+ Syntax = syntax ?? new ShellBlockSyntax(),
+ Commands = Array.Empty(),
+ Clauses = Array.Empty(),
+ IsUnparseable = true,
+ UnparseableReason = reason,
+ };
+
+ private sealed class StructuralCoordinator
+ {
+ private readonly string _source;
+ private readonly IReadOnlyList _tokens;
+ private readonly BashParserOptions _options;
+ private readonly int _bashCDepth;
+ private readonly bool _markBashCWrapped;
+ private readonly CdAttributionContext _attribution = new();
+ private int _position;
+ private int _subshellDepth;
+
+ internal StructuralCoordinator(
+ string source,
+ IReadOnlyList tokens,
+ BashParserOptions options,
+ int bashCDepth,
+ bool markBashCWrapped)
+ {
+ _source = source;
+ _tokens = tokens;
+ _options = options;
+ _bashCDepth = bashCDepth;
+ _markBashCWrapped = markBashCWrapped;
+ }
+
+ internal bool TryParse(out ShellBlockSyntax syntax, out string? error)
+ {
+ SkipNewlines();
+ if (_position == _tokens.Count)
+ {
+ syntax = new ShellBlockSyntax
+ {
+ SourceStart = 0,
+ SourceLength = _source.Length,
+ };
+ error = null;
+ return true;
+ }
+
+ if (!TryParseList(
+ stopAtRightParen: false,
+ CompoundOperator.None,
+ out var command,
+ out error) ||
+ command is null)
+ {
+ syntax = new ShellBlockSyntax();
+ return false;
+ }
+
+ SkipNewlines();
+ if (_position != _tokens.Count)
+ {
+ syntax = new ShellBlockSyntax();
+ error = IsOperator(")")
+ ? $"unbalanced parens at position {_tokens[_position].SourceStart}"
+ : $"unexpected token at position {_tokens[_position].SourceStart}";
+ return false;
+ }
+
+ syntax = new ShellBlockSyntax
+ {
+ Statements = new[] { command },
+ SourceStart = 0,
+ SourceLength = _source.Length,
+ };
+ return true;
+ }
+
+ private bool TryParseList(
+ bool stopAtRightParen,
+ CompoundOperator firstCompatibilityOperator,
+ out ShellSyntaxNode? command,
+ out string? error)
+ {
+ command = null;
+ error = null;
+ SkipNewlines();
+ if (_position == _tokens.Count || stopAtRightParen && IsOperator(")"))
+ {
+ return true;
+ }
+
+ if (!TryParsePipeline(firstCompatibilityOperator, out var first, out error))
+ {
+ return false;
+ }
+
+ var items = new List
+ {
+ new() { Operator = CompoundOperator.None, Command = first! },
+ };
+
+ while (_position < _tokens.Count)
+ {
+ if (stopAtRightParen && IsOperator(")"))
+ {
+ break;
+ }
+
+ if (IsOperator(")"))
+ {
+ error = $"unbalanced parens at position {_tokens[_position].SourceStart}";
+ return false;
+ }
+
+ if (!TryReadListOperator(out var listOperator))
+ {
+ error = $"unexpected token at position {_tokens[_position].SourceStart}";
+ return false;
+ }
+
+ SkipNewlines();
+ if (_position == _tokens.Count || stopAtRightParen && IsOperator(")"))
+ {
+ if (listOperator == CompoundOperator.Sequence)
+ {
+ break;
+ }
+
+ error = "compound operator is missing a following command";
+ return false;
+ }
+
+ if (!TryParsePipeline(listOperator, out var next, out error))
+ {
+ return false;
+ }
+
+ items.Add(new CommandListItemSyntax
+ {
+ Operator = listOperator,
+ Command = next!,
+ });
+ }
+
+ if (items.Count == 1)
+ {
+ command = first;
+ return true;
+ }
+
+ var children = new List(items.Count);
+ foreach (var item in items)
+ {
+ children.Add(item.Command);
+ }
+
+ command = new CommandListSyntax
+ {
+ Items = items,
+ SourceStart = CombinedStart(children),
+ SourceLength = CombinedLength(children),
+ };
+ return true;
+ }
+
+ private bool TryParsePipeline(
+ CompoundOperator firstCompatibilityOperator,
+ out ShellSyntaxNode? command,
+ out string? error)
+ {
+ command = null;
+ if (!TryParseCommand(firstCompatibilityOperator, out var first, out error))
+ {
+ return false;
+ }
+
+ var stages = new List { first! };
+ while (IsOperator("|"))
+ {
+ _position++;
+ SkipNewlines();
+ if (_position == _tokens.Count || IsOperator(")"))
+ {
+ error = "pipeline operator is missing a following command";
+ return false;
+ }
+
+ if (!TryParseCommand(CompoundOperator.Pipe, out var stage, out error))
+ {
+ return false;
+ }
+
+ stages.Add(stage!);
+ }
+
+ if (stages.Count == 1)
+ {
+ command = first;
+ return true;
+ }
+
+ command = new PipelineSyntax
+ {
+ Stages = stages,
+ SourceStart = CombinedStart(stages),
+ SourceLength = CombinedLength(stages),
+ };
+ error = null;
+ return true;
+ }
+
+ private bool TryParseCommand(
+ CompoundOperator compatibilityOperator,
+ out ShellSyntaxNode? command,
+ out string? error)
+ {
+ command = null;
+ error = null;
+ if (_position == _tokens.Count)
+ {
+ error = "expected a command";
+ return false;
+ }
+
+ if (IsOperator("("))
+ {
+ return TryParseSubshell(compatibilityOperator, out command, out error);
+ }
+
+ if (IsOperator(")") || IsListOperator(_tokens[_position]) || IsOperator("|"))
+ {
+ error = $"unexpected operator at position {_tokens[_position].SourceStart}";
+ return false;
+ }
+
+ var start = _position;
+ while (_position < _tokens.Count && !IsStructuralBoundary(_tokens[_position]))
+ {
+ _position++;
+ }
+
+ var segmentTokens = new List(_position - start);
+ for (var index = start; index < _position; index++)
+ {
+ segmentTokens.Add(_tokens[index]);
+ }
+
+ var segment = new Segment
+ {
+ PrecedingOperator = compatibilityOperator,
+ Tokens = segmentTokens,
+ };
+
+ if (TryDetectBashCWrapper(segment, _source, out var innerCommand))
+ {
+ if (!IsExactStaticWrapper(segmentTokens))
+ {
+ error = "bash -c wrapper has unsupported trailing arguments or redirects";
+ return false;
+ }
+
+ if (_bashCDepth + 1 > MaxBashCRecursionDepth)
+ {
+ error = "bash -c recursion depth exceeded (>5)";
+ return false;
+ }
+
+ var inner = ParseInternal(
+ innerCommand!,
+ _options,
+ _bashCDepth + 1,
+ markBashCWrapped: true);
+ if (inner.IsUnparseable)
+ {
+ error = inner.UnparseableReason;
+ return false;
+ }
+
+ var firstLeaf = true;
+ if (!TryCloneDecodedBlock(
+ inner.Syntax,
+ compatibilityOperator,
+ _subshellDepth > 0,
+ ref firstLeaf,
+ out var body))
+ {
+ error = "decoded bash -c syntax could not be lifted safely";
+ return false;
+ }
+
+ var firstToken = segmentTokens[0];
+ var lastToken = segmentTokens[segmentTokens.Count - 1];
+ command = new GroupSyntax
+ {
+ GroupKind = ShellGroupKind.IsolatedScope,
+ Body = body,
+ SourceStart = firstToken.SourceStart,
+ SourceLength = lastToken.SourceStart + lastToken.SourceLength -
+ firstToken.SourceStart,
+ };
+ return true;
+ }
+
+ var effectiveOptions = _options;
+ var workingDirectoryUnknown = false;
+ if (_attribution.HasAttribution && !_attribution.IsDynamic)
+ {
+ effectiveOptions = new BashParserOptions
+ {
+ HomeDirectory = _options.HomeDirectory,
+ WorkingDirectory = _attribution.ResolvedCwd,
+ };
+ }
+ else if (_attribution.IsDynamic)
+ {
+ workingDirectoryUnknown = true;
+ }
+
+ var parsed = ParseClauseSegment(
+ segment,
+ _source,
+ effectiveOptions,
+ workingDirectoryUnknown);
+ if (parsed.Error is not null)
+ {
+ error = parsed.Error;
+ return false;
+ }
+
+ if (parsed.Clauses.Count != 1)
+ {
+ error = "simple-command parser did not produce exactly one compatibility leaf";
+ return false;
+ }
+
+ var clause = parsed.Clauses[0] with
+ {
+ IsSubshell = _subshellDepth > 0,
+ IsCommandStringWrapped = _markBashCWrapped,
+ };
+ var emitted = AttachAttributionArg(clause, _attribution);
+ var firstVerbToken = clause.Verb.Tokens.Count > 0 ? clause.Verb.Tokens[0] : null;
+ if (firstVerbToken is not null &&
+ (string.Equals(firstVerbToken, "cd", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(firstVerbToken, "chdir", StringComparison.OrdinalIgnoreCase)))
+ {
+ UpdateAttributionFromCd(clause, _attribution);
+ }
+
+ var firstSource = segmentTokens[0];
+ var lastSource = segmentTokens[segmentTokens.Count - 1];
+ command = new SimpleCommandSyntax
+ {
+ Clause = emitted,
+ SourceStart = firstSource.SourceStart,
+ SourceLength = lastSource.SourceStart + lastSource.SourceLength -
+ firstSource.SourceStart,
+ };
+ return true;
+ }
+
+ private bool TryParseSubshell(
+ CompoundOperator compatibilityOperator,
+ out ShellSyntaxNode? command,
+ out string? error)
+ {
+ if (_subshellDepth >= ShellAnalysisLimits.MaxStructuralNesting)
+ {
+ command = null;
+ error = "Bash structural nesting depth exceeded (>16)";
+ return false;
+ }
+
+ var open = _tokens[_position++];
+ _attribution.PushForSubshell();
+ _subshellDepth++;
+ var parsed = TryParseList(
+ stopAtRightParen: true,
+ compatibilityOperator,
+ out var bodyCommand,
+ out error);
+ _subshellDepth--;
+ _attribution.PopForSubshell();
+
+ if (!parsed)
+ {
+ command = null;
+ return false;
+ }
+
+ if (_position == _tokens.Count || !IsOperator(")"))
+ {
+ command = null;
+ error = $"unbalanced parens at position {_source.Length}";
+ return false;
+ }
+
+ var close = _tokens[_position++];
+ var body = new ShellBlockSyntax
+ {
+ Statements = bodyCommand is null
+ ? Array.Empty()
+ : new[] { bodyCommand },
+ SourceStart = open.SourceStart + open.SourceLength,
+ SourceLength = close.SourceStart - open.SourceStart - open.SourceLength,
+ };
+ command = new GroupSyntax
+ {
+ GroupKind = ShellGroupKind.IsolatedScope,
+ Body = body,
+ SourceStart = open.SourceStart,
+ SourceLength = close.SourceStart + close.SourceLength - open.SourceStart,
+ };
+ return true;
+ }
+
+ private bool TryReadListOperator(out CompoundOperator @operator)
+ {
+ @operator = CompoundOperator.None;
+ if (_position == _tokens.Count)
+ {
+ return false;
+ }
+
+ var token = _tokens[_position];
+ if (token.Kind == BashTokenKind.Whitespace)
+ {
+ @operator = CompoundOperator.Sequence;
+ _position++;
+ return true;
+ }
+
+ if (token.Kind != BashTokenKind.Operator)
+ {
+ return false;
+ }
+
+ @operator = token.OperatorText switch
+ {
+ "&&" => CompoundOperator.AndIf,
+ "||" => CompoundOperator.OrIf,
+ ";" => CompoundOperator.Sequence,
+ _ => CompoundOperator.None,
+ };
+ if (@operator == CompoundOperator.None)
+ {
+ return false;
+ }
+
+ _position++;
+ return true;
+ }
+
+ private void SkipNewlines()
+ {
+ while (_position < _tokens.Count &&
+ _tokens[_position].Kind == BashTokenKind.Whitespace)
+ {
+ _position++;
+ }
+ }
+
+ private bool IsOperator(string value) =>
+ _position < _tokens.Count &&
+ _tokens[_position].Kind == BashTokenKind.Operator &&
+ string.Equals(_tokens[_position].OperatorText, value, StringComparison.Ordinal);
+ }
+
+ private static bool IsStructuralBoundary(BashToken token) =>
+ token.Kind == BashTokenKind.Whitespace ||
+ token.Kind == BashTokenKind.Operator && token.OperatorText is
+ "(" or ")" or "&&" or "||" or ";" or "|";
+
+ private static bool IsListOperator(BashToken token) =>
+ token.Kind == BashTokenKind.Whitespace ||
+ token.Kind == BashTokenKind.Operator && token.OperatorText is "&&" or "||" or ";";
+
+ private static bool IsExactStaticWrapper(IReadOnlyList tokens)
+ {
+ for (var index = 1; index + 1 < tokens.Count; index++)
+ {
+ if (tokens[index].Kind == BashTokenKind.Word &&
+ string.Equals(tokens[index].Value, "-c", StringComparison.Ordinal))
+ {
+ if (index + 2 != tokens.Count ||
+ tokens[index + 1].Kind != BashTokenKind.QuotedString)
+ {
+ return false;
+ }
+
+ for (var controlIndex = 0; controlIndex <= index + 1; controlIndex++)
+ {
+ if (!HasExactLiteralValue(tokens[controlIndex]))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool HasExactLiteralValue(BashToken token)
+ {
+ if (token.ResolverValue is null ||
+ !string.Equals(token.ResolverValue.Decoded, token.Value, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ var fragments = token.ResolverValue.Fragments;
+ for (var index = 0; index < fragments.Count; index++)
+ {
+ var fragment = fragments[index];
+ if (fragment.Kind != ShellValueFragmentKind.Literal ||
+ fragment.AllowedTransforms != ShellLexicalTransform.None ||
+ fragment.Expansion is not null ||
+ fragment.Cardinality != ShellValueCardinality.ExactlyOne ||
+ fragment.OpaqueCause != ShellOpaqueCause.None)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool HasUnexpandedCommandString(Clause clause)
+ {
+ if (clause.Verb.Tokens.Count == 0 ||
+ !string.Equals(clause.Verb.Tokens[0], "bash", StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(clause.Verb.Tokens[0], "sh", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ for (var index = 0; index < clause.Elements.Count; index++)
+ {
+ var element = clause.Elements[index];
+ if (element.Role != ClauseElementRole.Argument)
+ {
+ continue;
+ }
+
+ if (element.Kind != ArgKind.Literal)
+ {
+ return true;
+ }
+
+ if (string.Equals(element.Value, "--", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ if (IsCommandStringOption(element.Value))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool IsCommandStringOption(string value)
+ {
+ if (string.Equals(value, "-c", StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ if (value.Length < 2 || value[0] != '-' || value[1] == '-')
+ {
+ return false;
+ }
+
+ for (var index = 1; index < value.Length; index++)
+ {
+ if (value[index] == 'c')
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool HasUndiscoveredExecutableRegion(Clause clause)
+ {
+ for (var index = 0; index < clause.Elements.Count; index++)
+ {
+ var element = clause.Elements[index];
+ if (element.Kind != ArgKind.DynamicSkip)
+ {
+ continue;
+ }
+
+ if (element.Raw.IndexOf("$(", StringComparison.Ordinal) >= 0 ||
+ element.Raw.IndexOf('`') >= 0)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static int? CombinedStart(IReadOnlyList nodes)
+ {
+ if (nodes.Count == 0 || nodes[0].SourceStart is null)
+ {
+ return null;
+ }
+
+ for (var index = 1; index < nodes.Count; index++)
+ {
+ if (nodes[index].SourceStart is null)
+ {
+ return null;
+ }
+ }
+
+ return nodes[0].SourceStart;
+ }
+
+ private static int? CombinedLength(IReadOnlyList nodes)
+ {
+ var start = CombinedStart(nodes);
+ var last = nodes.Count == 0 ? null : nodes[nodes.Count - 1];
+ if (start is null || last?.SourceStart is null || last.SourceLength is null)
+ {
+ return null;
+ }
+
+ return last.SourceStart.Value + last.SourceLength.Value - start.Value;
+ }
+
+ private static bool TryCloneDecodedBlock(
+ ShellBlockSyntax source,
+ CompoundOperator firstOperator,
+ bool outerSubshell,
+ ref bool firstLeaf,
+ out ShellBlockSyntax clone)
+ {
+ if (source is null || source.Statements is null)
+ {
+ clone = new ShellBlockSyntax();
+ return false;
+ }
+
+ var statements = new List(source.Statements.Count);
+ foreach (var statement in source.Statements)
+ {
+ if (!TryCloneDecodedNode(
+ statement,
+ firstOperator,
+ outerSubshell,
+ ref firstLeaf,
+ out var cloned))
+ {
+ clone = new ShellBlockSyntax();
+ return false;
+ }
+
+ statements.Add(cloned!);
+ }
+
+ clone = new ShellBlockSyntax { Statements = statements };
+ return true;
+ }
+
+ private static bool TryCloneDecodedNode(
+ ShellSyntaxNode source,
+ CompoundOperator firstOperator,
+ bool outerSubshell,
+ ref bool firstLeaf,
+ out ShellSyntaxNode? clone)
+ {
+ clone = null;
+ switch (source)
+ {
+ case ShellBlockSyntax block:
+ if (!TryCloneDecodedBlock(
+ block,
+ firstOperator,
+ outerSubshell,
+ ref firstLeaf,
+ out var clonedBlock))
+ {
+ return false;
+ }
+
+ clone = clonedBlock;
+ return true;
+ case SimpleCommandSyntax simple:
+ var substitutions = new List(simple.Substitutions.Count);
+ foreach (var substitution in simple.Substitutions)
+ {
+ if (!TryCloneDecodedNode(
+ substitution,
+ firstOperator,
+ outerSubshell,
+ ref firstLeaf,
+ out var clonedSubstitution) ||
+ clonedSubstitution is not CommandSubstitutionSyntax typedSubstitution)
+ {
+ return false;
+ }
+
+ substitutions.Add(typedSubstitution);
+ }
+
+ var clauseOperator = firstLeaf ? firstOperator : simple.Clause.Operator;
+ firstLeaf = false;
+ clone = new SimpleCommandSyntax
+ {
+ Clause = simple.Clause with
+ {
+ Operator = clauseOperator,
+ IsSubshell = outerSubshell || simple.Clause.IsSubshell,
+ IsCommandStringWrapped = true,
+ Elements = ClauseElementProvenance.WithoutOuterSourceSpans(
+ simple.Clause.Elements),
+ },
+ Substitutions = substitutions,
+ };
+ return true;
+ case PipelineSyntax pipeline:
+ return TryCloneDecodedCollection(
+ pipeline.Stages,
+ firstOperator,
+ outerSubshell,
+ ref firstLeaf,
+ stages => new PipelineSyntax { Stages = stages },
+ out clone);
+ case CommandListSyntax list:
+ var items = new List(list.Items.Count);
+ foreach (var item in list.Items)
+ {
+ if (item is null || !TryCloneDecodedNode(
+ item.Command,
+ firstOperator,
+ outerSubshell,
+ ref firstLeaf,
+ out var itemCommand))
+ {
+ return false;
+ }
+
+ items.Add(new CommandListItemSyntax
+ {
+ Operator = item.Operator,
+ Command = itemCommand!,
+ });
+ }
+
+ clone = new CommandListSyntax { Items = items };
+ return true;
+ case GroupSyntax group:
+ if (!TryCloneDecodedBlock(
+ group.Body,
+ firstOperator,
+ outerSubshell,
+ ref firstLeaf,
+ out var groupBody))
+ {
+ return false;
+ }
+
+ clone = new GroupSyntax { GroupKind = group.GroupKind, Body = groupBody };
+ return true;
+ case ForEachSyntax forEach:
+ if (!TryCloneDecodedBlock(
+ forEach.IteratorCommands,
+ firstOperator,
+ outerSubshell,
+ ref firstLeaf,
+ out var iterator) ||
+ !TryCloneDecodedBlock(
+ forEach.Body,
+ firstOperator,
+ outerSubshell,
+ ref firstLeaf,
+ out var forBody))
+ {
+ return false;
+ }
+
+ clone = new ForEachSyntax
+ {
+ Binding = new LoopBindingSyntax
+ {
+ Name = forEach.Binding.Name,
+ Source = ClearSpan(forEach.Binding.Source),
+ },
+ Iterable = ClearSpan(forEach.Iterable),
+ IteratorCommands = iterator,
+ Body = forBody,
+ };
+ return true;
+ case ConditionLoopSyntax loop:
+ if (!TryCloneDecodedBlock(
+ loop.Condition,
+ firstOperator,
+ outerSubshell,
+ ref firstLeaf,
+ out var condition) ||
+ !TryCloneDecodedBlock(
+ loop.Body,
+ firstOperator,
+ outerSubshell,
+ ref firstLeaf,
+ out var loopBody))
+ {
+ return false;
+ }
+
+ clone = new ConditionLoopSyntax
+ {
+ LoopKind = loop.LoopKind,
+ Condition = condition,
+ Body = loopBody,
+ };
+ return true;
+ case ConditionalSyntax conditional:
+ var branches = new List(conditional.Branches.Count);
+ foreach (var branch in conditional.Branches)
+ {
+ if (!TryCloneDecodedNode(
+ branch,
+ firstOperator,
+ outerSubshell,
+ ref firstLeaf,
+ out var clonedBranch) ||
+ clonedBranch is not ConditionalBranchSyntax typedBranch)
+ {
+ return false;
+ }
+
+ branches.Add(typedBranch);
+ }
+
+ ShellBlockSyntax? @else = null;
+ if (conditional.Else is not null && !TryCloneDecodedBlock(
+ conditional.Else,
+ firstOperator,
+ outerSubshell,
+ ref firstLeaf,
+ out @else))
+ {
+ return false;
+ }
+
+ clone = new ConditionalSyntax { Branches = branches, Else = @else };
+ return true;
+ case ConditionalBranchSyntax branch:
+ if (!TryCloneDecodedBlock(
+ branch.Condition,
+ firstOperator,
+ outerSubshell,
+ ref firstLeaf,
+ out var branchCondition) ||
+ !TryCloneDecodedBlock(
+ branch.Body,
+ firstOperator,
+ outerSubshell,
+ ref firstLeaf,
+ out var branchBody))
+ {
+ return false;
+ }
+
+ clone = new ConditionalBranchSyntax
+ {
+ Condition = branchCondition,
+ Body = branchBody,
+ };
+ return true;
+ case CommandSubstitutionSyntax substitution:
+ if (!TryCloneDecodedBlock(
+ substitution.Body,
+ firstOperator,
+ outerSubshell,
+ ref firstLeaf,
+ out var substitutionBody))
+ {
+ return false;
+ }
+
+ clone = new CommandSubstitutionSyntax { Body = substitutionBody };
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private static bool TryCloneDecodedCollection(
+ IReadOnlyList source,
+ CompoundOperator firstOperator,
+ bool outerSubshell,
+ ref bool firstLeaf,
+ Func, ShellSyntaxNode> factory,
+ out ShellSyntaxNode? clone)
+ {
+ var children = new List(source.Count);
+ foreach (var child in source)
+ {
+ if (!TryCloneDecodedNode(
+ child,
+ firstOperator,
+ outerSubshell,
+ ref firstLeaf,
+ out var clonedChild))
+ {
+ clone = null;
+ return false;
+ }
+
+ children.Add(clonedChild!);
+ }
+
+ clone = factory(children);
+ return true;
+ }
+
+ private static ShellSourceFragment ClearSpan(ShellSourceFragment source) => new()
+ {
+ Raw = source.Raw,
+ };
+}
diff --git a/tests/ShellSyntaxTree.Tests/Parsing/BashStructuralProjectionTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/BashStructuralProjectionTests.cs
new file mode 100644
index 0000000..b66bdd5
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Parsing/BashStructuralProjectionTests.cs
@@ -0,0 +1,347 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Aaron Stannard
+//
+// -----------------------------------------------------------------------
+using System.Linq;
+using Xunit;
+
+namespace ShellSyntaxTree.Tests.Parsing;
+
+/// Pins the v0.3 Bash syntax and authorization projections.
+public class BashStructuralProjectionTests
+{
+ [Fact]
+ public void Simple_command_populates_all_projections_with_shared_leaf_identity()
+ {
+ const string source = "echo hello";
+
+ var result = Parse(source);
+
+ Assert.False(result.IsUnparseable);
+ Assert.Equal(0, result.Syntax.SourceStart);
+ Assert.Equal(source.Length, result.Syntax.SourceLength);
+ var simple = Assert.IsType(Assert.Single(result.Syntax.Statements));
+ Assert.Equal(0, simple.SourceStart);
+ Assert.Equal(source.Length, simple.SourceLength);
+
+ var occurrence = Assert.Single(result.Commands);
+ var clause = Assert.Single(result.Clauses);
+ Assert.Same(clause, simple.Clause);
+ Assert.Same(clause, occurrence.Clause);
+ Assert.True(occurrence.IsComplete);
+ Assert.Equal(CommandOccurrenceRole.Ordinary, occurrence.ImmediateRole);
+
+ var root = Assert.Single(occurrence.Ancestry);
+ Assert.Equal(ShellSyntaxKind.Block, root.AncestorKind);
+ Assert.Equal(CommandAncestryRegion.Root, root.Region);
+ Assert.Equal(0, root.ChildIndex);
+ }
+
+ [Fact]
+ public void Mixed_pipeline_and_list_preserve_authored_structure_and_roles()
+ {
+ var result = Parse("printf x | grep x && echo ok");
+
+ var list = Assert.IsType(Assert.Single(result.Syntax.Statements));
+ Assert.Equal(2, list.Items.Count);
+ Assert.Equal(CompoundOperator.None, list.Items[0].Operator);
+ Assert.Equal(CompoundOperator.AndIf, list.Items[1].Operator);
+
+ var pipeline = Assert.IsType(list.Items[0].Command);
+ Assert.Equal(2, pipeline.Stages.Count);
+ Assert.All(pipeline.Stages, stage => Assert.IsType(stage));
+ Assert.IsType(list.Items[1].Command);
+
+ Assert.Equal(3, result.Commands.Count);
+ Assert.Equal(
+ new[]
+ {
+ CommandOccurrenceRole.PipelineStage,
+ CommandOccurrenceRole.PipelineStage,
+ CommandOccurrenceRole.Ordinary,
+ },
+ result.Commands.Select(command => command.ImmediateRole));
+ Assert.Equal(result.Clauses, result.Commands.Select(command => command.Clause));
+ }
+
+ [Fact]
+ public void Nested_and_sibling_subshells_remain_distinct_isolated_groups()
+ {
+ const string source = "(echo a && (echo b | grep b)) || (echo c)";
+
+ var result = Parse(source);
+
+ var list = Assert.IsType(Assert.Single(result.Syntax.Statements));
+ Assert.Equal(2, list.Items.Count);
+ var firstGroup = Assert.IsType(list.Items[0].Command);
+ var secondGroup = Assert.IsType(list.Items[1].Command);
+ Assert.NotSame(firstGroup, secondGroup);
+ Assert.Equal(ShellGroupKind.IsolatedScope, firstGroup.GroupKind);
+ Assert.Equal(ShellGroupKind.IsolatedScope, secondGroup.GroupKind);
+ Assert.Equal(0, firstGroup.SourceStart);
+ Assert.Equal(29, firstGroup.SourceLength);
+ Assert.Equal(33, secondGroup.SourceStart);
+ Assert.Equal(8, secondGroup.SourceLength);
+
+ var firstBodyList = Assert.IsType(Assert.Single(firstGroup.Body.Statements));
+ var nestedGroup = Assert.IsType(firstBodyList.Items[1].Command);
+ var nestedPipeline = Assert.IsType(Assert.Single(nestedGroup.Body.Statements));
+ Assert.Equal(2, nestedPipeline.Stages.Count);
+ Assert.Equal(4, result.Commands.Count);
+ Assert.All(result.Commands, command => Assert.True(command.IsComplete));
+ }
+
+ [Fact]
+ public void Bash_command_string_keeps_nested_shape_but_clears_outer_source_ranges()
+ {
+ var result = Parse("bash -c \"echo a | grep a && echo b\"");
+
+ Assert.False(result.IsUnparseable);
+ var wrapper = Assert.IsType(Assert.Single(result.Syntax.Statements));
+ Assert.Equal(ShellGroupKind.IsolatedScope, wrapper.GroupKind);
+ Assert.Equal(0, wrapper.SourceStart);
+ Assert.Equal(result.Source.Length, wrapper.SourceLength);
+ Assert.Null(wrapper.Body.SourceStart);
+ Assert.Null(wrapper.Body.SourceLength);
+ var wrapped = Assert.IsType(Assert.Single(wrapper.Body.Statements));
+ Assert.Null(wrapped.SourceStart);
+ Assert.Null(wrapped.SourceLength);
+ var pipeline = Assert.IsType(wrapped.Items[0].Command);
+ Assert.All(pipeline.Stages, stage =>
+ {
+ Assert.Null(stage.SourceStart);
+ Assert.Null(stage.SourceLength);
+ });
+
+ Assert.Equal(3, result.Commands.Count);
+ Assert.Equal(3, result.Clauses.Count);
+ Assert.All(result.Clauses, clause => Assert.True(clause.IsCommandStringWrapped));
+ Assert.Equal(result.Clauses, result.Commands.Select(command => command.Clause));
+ }
+
+ [Fact]
+ public void Structural_depth_overflow_fails_closed_without_partial_projections()
+ {
+ var source = new string('(', ShellAnalysisLimits.MaxStructuralNesting + 1)
+ + "echo ok"
+ + new string(')', ShellAnalysisLimits.MaxStructuralNesting + 1);
+
+ var result = Parse(source);
+
+ Assert.True(result.IsUnparseable);
+ Assert.Empty(result.Commands);
+ Assert.Empty(result.Clauses);
+ }
+
+ [Fact]
+ public void Hostile_structural_depth_is_rejected_before_recursive_descent()
+ {
+ const int hostileDepth = 4096;
+ var source = new string('(', hostileDepth)
+ + "echo ok"
+ + new string(')', hostileDepth);
+
+ var result = Parse(source);
+
+ Assert.True(result.IsUnparseable);
+ Assert.Empty(result.Commands);
+ Assert.Empty(result.Clauses);
+ Assert.Contains("nesting depth", result.UnparseableReason!);
+ }
+
+ [Fact]
+ public void Exact_structural_depth_limit_remains_parseable()
+ {
+ var source = new string('(', ShellAnalysisLimits.MaxStructuralNesting)
+ + "echo ok"
+ + new string(')', ShellAnalysisLimits.MaxStructuralNesting);
+
+ var result = Parse(source);
+
+ Assert.False(result.IsUnparseable);
+ Assert.Single(result.Commands);
+ Assert.Single(result.Clauses);
+ }
+
+ [Fact]
+ public void Operator_entering_subshell_is_carried_by_its_first_compatibility_leaf()
+ {
+ var result = Parse("echo a && (echo b | echo c)");
+
+ var list = Assert.IsType(Assert.Single(result.Syntax.Statements));
+ var group = Assert.IsType(list.Items[1].Command);
+ Assert.Equal(CompoundOperator.AndIf, list.Items[1].Operator);
+ Assert.IsType(Assert.Single(group.Body.Statements));
+ Assert.Equal(
+ new[]
+ {
+ CompoundOperator.None,
+ CompoundOperator.AndIf,
+ CompoundOperator.Pipe,
+ },
+ result.Clauses.Select(clause => clause.Operator));
+ Assert.Equal(result.Clauses, result.Commands.Select(command => command.Clause));
+ }
+
+ [Fact]
+ public void Operator_entering_wrapper_is_carried_by_its_first_decoded_leaf()
+ {
+ var result = Parse("echo a || bash -c \"echo b; echo c\"");
+
+ var list = Assert.IsType(Assert.Single(result.Syntax.Statements));
+ var wrapper = Assert.IsType(list.Items[1].Command);
+ Assert.Equal(CompoundOperator.OrIf, list.Items[1].Operator);
+ Assert.IsType(Assert.Single(wrapper.Body.Statements));
+ Assert.Equal(
+ new[]
+ {
+ CompoundOperator.None,
+ CompoundOperator.OrIf,
+ CompoundOperator.Sequence,
+ },
+ result.Clauses.Select(clause => clause.Operator));
+ }
+
+ [Fact]
+ public void Decoded_wrapper_preserves_inner_subshell_shape_with_unavailable_spans()
+ {
+ var result = Parse("bash -c \"(echo a && echo b)\"");
+
+ var wrapper = Assert.IsType(Assert.Single(result.Syntax.Statements));
+ var innerGroup = Assert.IsType(Assert.Single(wrapper.Body.Statements));
+ Assert.Null(innerGroup.SourceStart);
+ Assert.Null(innerGroup.SourceLength);
+ Assert.Null(innerGroup.Body.SourceStart);
+ Assert.Null(innerGroup.Body.SourceLength);
+ Assert.All(result.Clauses, clause =>
+ {
+ Assert.True(clause.IsSubshell);
+ Assert.True(clause.IsCommandStringWrapped);
+ });
+ }
+
+ [Theory]
+ [InlineData("bash -c \"echo ok\" argv0")]
+ [InlineData("bash -c \"echo ok\" > out.txt")]
+ public void Unsupported_wrapper_tail_fails_closed(string source)
+ {
+ var result = Parse(source);
+
+ Assert.True(result.IsUnparseable);
+ Assert.Empty(result.Commands);
+ Assert.Empty(result.Clauses);
+ Assert.Contains("wrapper", result.UnparseableReason!);
+ }
+
+ [Fact]
+ public void Redirect_leaf_is_structurally_visible_but_incomplete_until_redirect_analysis_lands()
+ {
+ var result = Parse("echo ok > out.txt");
+
+ Assert.False(result.IsUnparseable);
+ var command = Assert.Single(result.Commands);
+ Assert.False(command.IsComplete);
+ Assert.Single(result.Clauses);
+ }
+
+ [Fact]
+ public void Dynamic_command_string_preserves_compatibility_but_is_not_complete()
+ {
+ var result = Parse("bash -c $code");
+
+ Assert.False(result.IsUnparseable);
+ var command = Assert.Single(result.Commands);
+ Assert.False(command.IsComplete);
+ Assert.Same(Assert.Single(result.Clauses), command.Clause);
+ Assert.False(command.Clause.IsCommandStringWrapped);
+ }
+
+ [Theory]
+ [InlineData("bash -c \"$code\"")]
+ [InlineData("bash -c \"echo $HOME\"")]
+ public void Expanding_quoted_command_string_is_not_treated_as_static(string source)
+ {
+ var result = Parse(source);
+
+ Assert.False(result.IsUnparseable);
+ var command = Assert.Single(result.Commands);
+ Assert.False(command.IsComplete);
+ Assert.Equal(new[] { "bash" }, command.Clause.Verb.Tokens);
+ Assert.False(command.Clause.IsCommandStringWrapped);
+ Assert.IsType(Assert.Single(result.Syntax.Statements));
+ Assert.All(command.Clause.Elements, element =>
+ {
+ Assert.NotNull(element.SourceStart);
+ Assert.NotNull(element.SourceLength);
+ });
+ }
+
+ [Theory]
+ [InlineData("bash -c 'echo $HOME'")]
+ [InlineData("bash -c \"echo \\$HOME\"")]
+ [InlineData("bash -x -c 'echo $HOME'")]
+ public void Outer_literal_command_string_is_recursively_analyzed(string source)
+ {
+ var result = Parse(source);
+
+ Assert.False(result.IsUnparseable);
+ var command = Assert.Single(result.Commands);
+ Assert.True(command.IsComplete);
+ Assert.True(command.Clause.IsCommandStringWrapped);
+ Assert.Equal(new[] { "echo" }, command.Clause.Verb.Tokens);
+ Assert.IsType(Assert.Single(result.Syntax.Statements));
+ }
+
+ [Theory]
+ [InlineData("bash -$opts -c 'echo safe'")]
+ [InlineData("bash $opts 'echo hidden'")]
+ [InlineData("bash \"-c\" 'echo hidden'")]
+ [InlineData("bash -ce 'echo hidden'")]
+ [InlineData("bash -ec 'echo hidden'")]
+ [InlineData("bash -xc 'echo hidden'")]
+ [InlineData("bash -O extglob -c 'echo hidden'")]
+ public void Noncanonical_command_string_options_remain_outer_and_incomplete(string source)
+ {
+ var result = Parse(source);
+
+ Assert.False(result.IsUnparseable);
+ var command = Assert.Single(result.Commands);
+ Assert.False(command.IsComplete);
+ Assert.False(command.Clause.IsCommandStringWrapped);
+ Assert.IsType(Assert.Single(result.Syntax.Statements));
+ }
+
+ [Theory]
+ [InlineData("rm $(find /tmp)")]
+ [InlineData("printf '%s' \"$(whoami)\"")]
+ [InlineData("echo `whoami`")]
+ public void Undiscovered_command_substitution_keeps_outer_leaf_incomplete(string source)
+ {
+ var result = Parse(source);
+
+ Assert.False(result.IsUnparseable);
+ var command = Assert.Single(result.Commands);
+ Assert.False(command.IsComplete);
+ Assert.Same(Assert.Single(result.Clauses), command.Clause);
+ }
+
+ [Fact]
+ public void Ordinary_variable_value_does_not_make_structure_incomplete()
+ {
+ var result = Parse("printf '%s' $value");
+
+ Assert.False(result.IsUnparseable);
+ Assert.True(Assert.Single(result.Commands).IsComplete);
+ }
+
+ private static ParsedCommand Parse(string input)
+ {
+ var parser = new BashParser(new BashParserOptions
+ {
+ HomeDirectory = "/home/test",
+ WorkingDirectory = "/work",
+ });
+ return parser.Parse(input);
+ }
+}