From 9ae9d84fe4546bf8de274c71b8e15c653991220e Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 7 Aug 2026 03:45:37 +0000 Subject: [PATCH] Adapt Bash parser to structural projections --- IMPLEMENTATION_PLAN.md | 11 +- SPEC.md | 15 +- .../executable-command-projection/spec.md | 5 + .../v0-3-structured-shell-analysis/tasks.md | 2 +- .../Bash/Parsing/BashCommandParser.cs | 439 +------- .../Bash/Parsing/BashStructuralCoordinator.cs | 995 ++++++++++++++++++ .../Parsing/BashStructuralProjectionTests.cs | 347 ++++++ 7 files changed, 1405 insertions(+), 409 deletions(-) create mode 100644 src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs create mode 100644 tests/ShellSyntaxTree.Tests/Parsing/BashStructuralProjectionTests.cs 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); + } +}