From ebfd56ace1bdc9e70a134cb266d855960c982ee4 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 7 Aug 2026 06:04:53 +0000 Subject: [PATCH] Add structured PowerShell command substitutions --- IMPLEMENTATION_PLAN.md | 12 +- SPEC.POWERSHELL.md | 25 +- docs/CONSUMER_GUIDE.md | 32 +- .../v0-3-structured-shell-analysis/tasks.md | 4 + .../Internal/Pwsh/Lexing/PwshLexer.cs | 261 ++++++- .../Pwsh/Parsing/PwshCommandParser.cs | 85 ++- .../Pwsh/Parsing/PwshStructuralCoordinator.cs | 712 +++++++++++++++++- .../Corpus/CorpusRunnerTests.cs | 81 +- .../165_dynamic_subexpression_arg.json | 71 +- .../301_dynamic_command_identity.json | 16 + .../311_v03_substitution_multiple.json | 207 +++++ .../312_v03_substitution_nested.json | 222 ++++++ .../313_v03_substitution_standalone.json | 100 +++ .../314_v03_substitution_call_operator.json | 140 ++++ ...15_v03_substitution_current_scope_cwd.json | 313 ++++++++ .../316_v03_substitution_redirect.json | 144 ++++ ...03_substitution_expanding_here_string.json | 133 ++++ ...v03_substitution_dynamic_host_payload.json | 138 ++++ ..._v03_substitution_static_host_payload.json | 185 +++++ ..._substitution_direct_comment_boundary.json | 211 ++++++ ...03_substitution_array_execution_gated.json | 10 + ...v03_substitution_hash_execution_gated.json | 10 + ...substitution_numeric_expression_gated.json | 10 + ..._v03_substitution_member_suffix_gated.json | 10 + ..._substitution_inner_here_string_gated.json | 10 + ...bstitution_invalid_expandable_comment.json | 9 + ...v03_substitution_numeric_suffix_gated.json | 10 + ...28_v03_substitution_hex_literal_gated.json | 10 + ...29_v03_substitution_unary_comma_gated.json | 10 + ...330_v03_substitution_unary_join_gated.json | 10 + ...03_substitution_statement_array_gated.json | 10 + ...3_substitution_later_expression_gated.json | 10 + ...substitution_grouped_expression_gated.json | 10 + ..._substitution_scriptblock_value_gated.json | 10 + ...3_substitution_digit_command_identity.json | 133 ++++ ...03_substitution_dynamic_cwd_poisoning.json | 313 ++++++++ ...bstitution_integer_width_suffix_gated.json | 10 + ...338_v03_substitution_unary_bnot_gated.json | 10 + ...substitution_decimal_command_identity.json | 133 ++++ ...v03_substitution_unary_variable_gated.json | 10 + ...03_substitution_dash_command_identity.json | 133 ++++ .../DesignCorpus/v0.3/powershell.json | 8 + .../Parsing/PwshCommandParserTests.cs | 17 +- .../Parsing/PwshStructuralProjectionTests.cs | 382 +++++++++- .../Parsing/ResolverProvenanceTests.cs | 5 +- tools/PwshCorpusTool/CorpusManifest.cs | 95 +++ 46 files changed, 4418 insertions(+), 62 deletions(-) create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/311_v03_substitution_multiple.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/312_v03_substitution_nested.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/313_v03_substitution_standalone.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/314_v03_substitution_call_operator.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/315_v03_substitution_current_scope_cwd.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/316_v03_substitution_redirect.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/317_v03_substitution_expanding_here_string.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/318_v03_substitution_dynamic_host_payload.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/319_v03_substitution_static_host_payload.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/320_v03_substitution_direct_comment_boundary.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/321_v03_substitution_array_execution_gated.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/322_v03_substitution_hash_execution_gated.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/323_v03_substitution_numeric_expression_gated.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/324_v03_substitution_member_suffix_gated.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/325_v03_substitution_inner_here_string_gated.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/326_v03_substitution_invalid_expandable_comment.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/327_v03_substitution_numeric_suffix_gated.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/328_v03_substitution_hex_literal_gated.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/329_v03_substitution_unary_comma_gated.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/330_v03_substitution_unary_join_gated.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/331_v03_substitution_statement_array_gated.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/332_v03_substitution_later_expression_gated.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/333_v03_substitution_grouped_expression_gated.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/334_v03_substitution_scriptblock_value_gated.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/335_v03_substitution_digit_command_identity.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/336_v03_substitution_dynamic_cwd_poisoning.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/337_v03_substitution_integer_width_suffix_gated.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/338_v03_substitution_unary_bnot_gated.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/339_v03_substitution_decimal_command_identity.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/340_v03_substitution_unary_variable_gated.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/341_v03_substitution_dash_command_identity.json diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index d4a332b..455c0ea 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -256,7 +256,7 @@ priorities. roles, ancestry, completeness, nullable decoded spans, compatibility operators, and exact shared `Clause` identity. The strict DTO rejects unknown fields and always requires unparseable projections to be empty. - The PowerShell manifest now owns all 310 entries and round-trips exactly; + The PowerShell manifest now owns all 341 entries and round-trips exactly; explicit false/null assertions remain opt-in and generator-preserved. - [x] Deliver the first Bash `$()` substitution slice for supported simple-command arguments and redirect targets. Direct tests and corpus @@ -267,10 +267,12 @@ priorities. and malformed interiors. - [ ] Extend Bash substitution discovery to iterables and expanding heredoc bodies, then add the corresponding Netclaw approval-matrix cases. -- [ ] Deliver the PowerShell `$()` substitution slice for every locked value - and expression position, including current-scope state propagation, - literal boundaries, incomplete dynamic identities, executable corpus, - and Netclaw cases. +- [ ] Complete PowerShell `$()` discovery in `foreach` expressions and add the + Netclaw approval-matrix cases. The simple-command slice is delivered for + ordinary, adjacent, quoted, here-string, redirect, standalone, + call-operator, dynamic-identity, and host-wrapper positions, with + current-scope state propagation and bounded expression rejection pinned + by the 341-entry executable corpus. - [ ] Deliver Bash `for ... in` and PowerShell `foreach` as the first two language-specific vertical slices, then extract only the shared analysis proven by both implementations. diff --git a/SPEC.POWERSHELL.md b/SPEC.POWERSHELL.md index c2fb86d..d955db2 100644 --- a/SPEC.POWERSHELL.md +++ b/SPEC.POWERSHELL.md @@ -1047,9 +1047,16 @@ pipeline elements within a statement, not separate statements. ### Opaque regions -Script blocks `{ ... }`, subexpressions `$( ... )`, array subexpressions -`@( ... )`, and hash literals `@{ ... }` are bounded by the shared -`OpaqueRegionScanner` and emitted as single tokens. The compatibility parser +Script blocks `{ ... }`, array subexpressions `@( ... )`, and hash literals +`@{ ... }` are bounded by the shared `OpaqueRegionScanner`. PowerShell command +subexpressions `$( ... )` use a specialized scanner that understands nested +subexpressions, quoted regions, backtick escapes, line and block comments, and +the shared structural-depth cap. Direct `$()` bodies recognize line comments +only at PowerShell word boundaries. A `$()` discovered while decoding an expandable +string or here-string rejects comment-bearing interiors conservatively because +the parent quoting context changes whether PowerShell can close the region. +Here-strings nested inside `$()` remain an unsupported grammar boundary. Each +bounded region is emitted as one token. The compatibility parser retains each as one `Arg { Kind=DynamicSkip, IsPath=false, Resolved=null }`, `Raw` being the verbatim region slice. Stable v0.3 additionally parses every supported executable `$()` interior into `SimpleCommandSyntax.Substitutions`. @@ -1067,9 +1074,10 @@ evaluation, and following outer commands. Unknown location mutations propagate as unknown. This differs from Bash command substitution, whose state is isolated from the containing shell. -`OpaqueRegionScanner` is grammar-agnostic but escapes on backslash; for -PowerShell it is given a backtick-escape mode so `` { `} } `` scans -correctly. +`OpaqueRegionScanner` is grammar-agnostic but escapes on backslash; the +PowerShell script-block, array, and hash paths give it a backtick-escape mode +so `` { `} } `` scans correctly. The specialized `$()` scanner applies the +same backtick behavior directly. ### `pwsh -Command` recursion @@ -1496,8 +1504,9 @@ testable step; most are a single PR. binding tables, §6.5), `PwshPerVerbRules` (§7). 3. **`PwshLexer`** (`Internal/Pwsh/Lexing/`) — quoting, backtick escape, `$var` / `$env:` / `${name}`, parameters, stream redirects, statement - separators, comments; opaque regions via the shared `OpaqueRegionScanner` - (with the backtick-escape mode). Heavy unit tests. + separators, comments; script-block/array/hash regions via the shared + `OpaqueRegionScanner` (with the backtick-escape mode), and `$()` via its + specialized scanner. Heavy unit tests. 4. **`PwshCommandParser` core** — pipeline / statement splitting, verb-chain extraction, the §6.5 parameter-binding decision, args & parameters, redirects, `VerbChain.IsDynamic` for dynamic command names. diff --git a/docs/CONSUMER_GUIDE.md b/docs/CONSUMER_GUIDE.md index 6c64e44..47c7254 100644 --- a/docs/CONSUMER_GUIDE.md +++ b/docs/CONSUMER_GUIDE.md @@ -372,9 +372,35 @@ Redirects authored on the outer PowerShell wrapper remain attached to the last surfaced clause, so redirect policy still sees paths such as `pwsh -Command "git status" > audit.log`. -PowerShell script blocks, subexpressions, splats, and `--%` regions are opaque -and surface as `DynamicSkip`. A dynamically invoked command such as `& $exe` -sets `VerbChain.IsDynamic = true`; no verb-pattern grant should match it. +Supported PowerShell `$()` subexpressions are structural rather than hidden +opaque values. The containing `SimpleCommandSyntax.Substitutions` records each +authored child, and `ParsedCommand.Commands` projects its executable commands +before the containing command, with `ImmediateRole = Substitution`. Consumers +should authorize that occurrence list directly; walking `Syntax` again would +double-count the same shared `Clause` instances. A standalone +`$(Write-Output Get-Date)` exposes `Write-Output` without inventing an outer +invocation. By contrast, `& $(Write-Output Get-Date)` also retains an +incomplete dynamic outer occurrence because PowerShell invokes the produced +name. + +Quoting also determines the scope of host-wrapper substitutions. In +`pwsh -Command "Write-Output $(Get-Date)"`, the parent evaluates `Get-Date`, so +the result contains that parent-scope occurrence plus an incomplete outer +`pwsh` occurrence; the parser does not pretend the expanded payload is a +literal child script. A literal payload such as +`pwsh -Command 'Write-Output $(Get-Date)'` can be decoded into child-host +syntax. Decoded child nodes have null source spans because their offsets do not +map exactly onto the outer source. + +An ordinary script-block argument, splat, or `--%` remainder stays opaque and +surfaces as `DynamicSkip`. Proved-literal `@()` / `@{}` data stays opaque and +incomplete; execution-bearing forms and unsupported arbitrary expressions make +the whole result unparseable with empty `Commands` and `Clauses`. A containing +command may be structurally complete after every supported `$()` command is +visible while its produced argument value remains policy-sensitive. Treat +completeness and value safety as separate decisions. A dynamically invoked +command such as `& $exe` sets `VerbChain.IsDynamic = true`; no verb-pattern +grant should match it. ## Safe-fail rules diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md index 61539a2..4dd3102 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -34,10 +34,13 @@ - [ ] 3.10 Implement Bash `$()` discovery in supported argument words, redirect values, iterables, and expanding heredoc bodies; retain literal/escaped spellings and fail closed on command-name substitutions, legacy backticks, or incomplete interiors. - [x] 3.10a Implement the simple-command argument and redirect-target slice, including comment-safe boundaries and fail-closed unsupported interiors. - [ ] 3.11 Implement PowerShell `$()` discovery in supported words, redirect values, foreach expressions, call-operator dynamic identities, standalone expression statements, double-quoted strings, and expandable here-strings; never invent invocation from standalone output, retain literal/escaped spellings, and fail closed on trailing command-style arguments, call-operator script blocks, or unsupported execution-bearing `@()` / `@{}` forms. + - [x] 3.11a Implement words, redirect values, call-operator dynamic identities, standalone statements, expandable strings/here-strings, and parent-versus-child host payload provenance; fail closed on arbitrary expression values and unsupported execution-bearing `@()` / `@{}` forms. - [ ] 3.12 Pin substitution parentage, authored sibling indices, innermost-first ordering, Bash-isolated versus PowerShell-current-scope state, unknown-state propagation, nesting/depth limits, and incomplete dynamic identities in direct tests. - [x] 3.12a Pin the Bash argument/redirect slice, isolated cwd behavior, wrapper provenance, and the shared structural-depth budget. + - [x] 3.12b Pin the PowerShell simple-command slice, current-scope exact and unknown cwd propagation, parent/child wrapper provenance, expression boundaries, and the shared structural-depth budget. - [ ] 3.13 Promote ordinary, multiple, nested, iterator, redirect, quoted, escaped, stateful, malformed, and hidden-execution substitution cases into both executable corpora and the Netclaw approval matrix. - [x] 3.13a Promote the Bash ordinary, multiple, nested, redirect, quoted, escaped, stateful, malformed, and hidden-execution cases into its executable corpus. + - [x] 3.13b Promote the PowerShell ordinary, multiple, nested, redirect, quoted, escaped, stateful, malformed, expression-boundary, and hidden-execution cases into its executable corpus. ## 4. Explicit Redirect Semantics @@ -52,6 +55,7 @@ ## 5. Consumer Migration Baseline - [ ] 5.1 Rewrite the production-shaped consumer loop in `docs/CONSUMER_GUIDE.md` to enumerate every command occurrence. + - [x] 5.1a Document the PowerShell `$()` occurrence ordering, standalone/call-operator distinction, parent-versus-child host payload provenance, and completeness-versus-value-safety contract. - [ ] 5.2 Document syntax-tree display traversal separately from authorization traversal. - [ ] 5.3 Document exact, finite, pattern, unknown, joined-state, redirect, and incomplete-result handling. - [ ] 5.4 Document record equality, hashing, `ToString()`, serialization, and `Clauses` compatibility effects. diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs index f7da416..7d78450 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs @@ -21,9 +21,10 @@ namespace ShellSyntaxTree.Internal.Pwsh.Lexing; /// The lexer never expands variables. $var, ${name}, /// and $env:NAME stay literal inside a Word token; the /// resolver classifies them. -/// Opaque regions — $( … ), @( … ), @{ … }, -/// { … } — are bounded by -/// (in PowerShell backtick-escape mode) and emitted whole. +/// $( … ) uses a PowerShell-specific boundary scan that +/// honors comments and nested interpolation. Other opaque regions — +/// @( … ), @{ … }, and { … } — use +/// in backtick-escape mode. /// Malformed regions surface as /// tokens the /// parser lifts into ParsedCommand.IsUnparseable. @@ -701,15 +702,13 @@ private static bool TryAppendPwshExpansion( var next = value[start + 1]; if (next == '(') { - var scan = OpaqueRegionScanner.Scan( + var scan = ScanCommandSubexpression( value, start + 1, - '(', - ')', - OpaqueRegionScanner.PwshEscape); + allowComments: false); if (!scan.Closed) { - error = "unbalanced '$(' subexpression"; + error = scan.Error ?? "unbalanced '$(' subexpression"; index = value.Length; return true; } @@ -931,14 +930,15 @@ private static int ConsumeBalancedRegion( char openChar, char closeChar, PwshTokenKind kind, string unbalancedReason, List tokens) { - var scan = OpaqueRegionScanner.Scan( - src, openAt, openChar, closeChar, OpaqueRegionScanner.PwshEscape); + var scan = start < src.Length && src[start] == '$' && openChar == '(' + ? ScanCommandSubexpression(src, openAt, allowComments: true) + : ScanOpaqueRegion(src, openAt, openChar, closeChar); if (!scan.Closed) { tokens.Add(new PwshToken( PwshTokenKind.UnparseableSentinel, src.Slice(start).ToString(), null, start, src.Length - start, - unbalancedReason)); + scan.Error ?? unbalancedReason)); return src.Length; } @@ -957,6 +957,245 @@ private static int ConsumeBalancedRegion( return start + length; } + private static CommandSubexpressionScan ScanOpaqueRegion( + ReadOnlySpan src, + int openAt, + char openChar, + char closeChar) + { + var scan = OpaqueRegionScanner.Scan( + src, openAt, openChar, closeChar, OpaqueRegionScanner.PwshEscape); + return new CommandSubexpressionScan(scan.EndIndex, scan.Closed, null); + } + + private static CommandSubexpressionScan ScanCommandSubexpression( + ReadOnlySpan src, + int openParen, + bool allowComments) + { + if (openParen < 0 || openParen >= src.Length || src[openParen] != '(') + { + return new CommandSubexpressionScan(src.Length, false, null); + } + + var resumeDoubleQuote = new Stack(); + resumeDoubleQuote.Push(false); + var inDoubleQuote = false; + var atWordBoundary = true; + var i = openParen + 1; + while (i < src.Length) + { + var c = src[i]; + if (inDoubleQuote) + { + if (c == '`' && i + 1 < src.Length) + { + i += src[i + 1] == '\r' && i + 2 < src.Length && src[i + 2] == '\n' + ? 3 + : 2; + continue; + } + + if (c == '"') + { + inDoubleQuote = false; + atWordBoundary = true; + i++; + continue; + } + + if (c == '$' && i + 1 < src.Length && src[i + 1] == '(') + { + if (resumeDoubleQuote.Count >= ShellAnalysisLimits.MaxStructuralNesting) + { + return PowerShellNestingOverflow(src.Length); + } + + resumeDoubleQuote.Push(true); + inDoubleQuote = false; + i += 2; + continue; + } + + i++; + continue; + } + + if (c == '`' && i + 1 < src.Length) + { + if (src[i + 1] is not '\n' and not '\r') + { + atWordBoundary = false; + } + + i += src[i + 1] == '\r' && i + 2 < src.Length && src[i + 2] == '\n' + ? 3 + : 2; + continue; + } + + if (c == '\'') + { + i++; + while (i < src.Length) + { + if (src[i] != '\'') + { + i++; + continue; + } + + if (i + 1 < src.Length && src[i + 1] == '\'') + { + i += 2; + continue; + } + + i++; + break; + } + + if (i >= src.Length && (src.Length == 0 || src[src.Length - 1] != '\'')) + { + return new CommandSubexpressionScan(src.Length, false, null); + } + + atWordBoundary = true; + continue; + } + + if (c == '"') + { + inDoubleQuote = true; + atWordBoundary = false; + i++; + continue; + } + + if (c == '#' && atWordBoundary) + { + if (!allowComments || resumeDoubleQuote.Peek()) + { + return new CommandSubexpressionScan( + src.Length, + false, + "comments inside expandable PowerShell subexpressions are not supported"); + } + + while (i < src.Length && src[i] is not '\n' and not '\r') + { + i++; + } + + atWordBoundary = true; + continue; + } + + if (c == '<' && i + 1 < src.Length && src[i + 1] == '#') + { + if (!allowComments || resumeDoubleQuote.Peek()) + { + return new CommandSubexpressionScan( + src.Length, + false, + "comments inside expandable PowerShell subexpressions are not supported"); + } + + i += 2; + while (i + 1 < src.Length && (src[i] != '#' || src[i + 1] != '>')) + { + i++; + } + + if (i + 1 >= src.Length) + { + return new CommandSubexpressionScan(src.Length, false, null); + } + + i += 2; + atWordBoundary = true; + continue; + } + + if (c == '@' && i + 1 < src.Length && src[i + 1] is '\'' or '"' && + StartsHereString(src, i)) + { + return new CommandSubexpressionScan( + src.Length, + false, + "here-strings inside PowerShell subexpressions are not supported"); + } + + if (c == '$' && i + 1 < src.Length && src[i + 1] == '(') + { + if (resumeDoubleQuote.Count >= ShellAnalysisLimits.MaxStructuralNesting) + { + return PowerShellNestingOverflow(src.Length); + } + + resumeDoubleQuote.Push(false); + atWordBoundary = true; + i += 2; + continue; + } + + if (c == '(') + { + if (resumeDoubleQuote.Count >= ShellAnalysisLimits.MaxStructuralNesting) + { + return PowerShellNestingOverflow(src.Length); + } + + resumeDoubleQuote.Push(false); + atWordBoundary = true; + i++; + continue; + } + + if (c == ')') + { + var restoreDoubleQuote = resumeDoubleQuote.Pop(); + if (resumeDoubleQuote.Count == 0) + { + return new CommandSubexpressionScan(i, true, null); + } + + inDoubleQuote = restoreDoubleQuote; + atWordBoundary = true; + i++; + continue; + } + + atWordBoundary = char.IsWhiteSpace(c) || + c is ';' or '|' or '&' or '<' or '>' or '{' or '}'; + i++; + } + + return new CommandSubexpressionScan(src.Length, false, null); + } + + private static bool StartsHereString(ReadOnlySpan src, int start) + { + var index = start + 2; + while (index < src.Length && IsInlineWhitespace(src[index])) + { + index++; + } + + return index < src.Length && src[index] is '\n' or '\r'; + } + + private static CommandSubexpressionScan PowerShellNestingOverflow(int endIndex) => + new( + endIndex, + false, + $"PowerShell structural nesting depth exceeded (>{ShellAnalysisLimits.MaxStructuralNesting})"); + + private readonly record struct CommandSubexpressionScan( + int EndIndex, + bool Closed, + string? Error); + // ---------------------------------------------------------------- comments private static int ConsumeLineComment( diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs index 1e6468e..6bea308 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs @@ -43,12 +43,16 @@ internal static ParsedCommand Parse(string source, PwshParserOptions options) } return ParseInternal( - source, options, recursionDepth: 0, markWrapped: false, + source, options, recursionDepth: 0, structuralDepth: 0, markWrapped: false, sharedLocation: null); } private static ParsedCommand ParseInternal( - string source, PwshParserOptions options, int recursionDepth, bool markWrapped, + string source, + PwshParserOptions options, + int recursionDepth, + int structuralDepth, + bool markWrapped, PwshSetLocationContext? sharedLocation) { // §11 item 10: the size cap is checked before lexing, on the @@ -91,6 +95,7 @@ private static ParsedCommand ParseInternal( significant, options, recursionDepth, + structuralDepth, markWrapped, sharedLocation); } @@ -480,7 +485,10 @@ public static BuildResult Fail(string? reason) => private static BuildResult BuildSegment( Segment segment, string source, PwshParserOptions baseOptions, PwshParserOptions effectiveOptions, bool workingDirectoryUnknown, - int recursionDepth, bool markWrapped, PwshSetLocationContext attribution) + int recursionDepth, + int structuralDepth, + bool markWrapped, + PwshSetLocationContext attribution) { var body = segment.Tokens; var start = 0; @@ -516,7 +524,7 @@ private static BuildResult BuildSegment( var classified = ClassifyVerb(body, start); if (TryHandleInvokeExpression( body, start, classified, source, baseOptions, recursionDepth, - segment, markWrapped, attribution, out var expressionResult)) + structuralDepth, segment, markWrapped, attribution, out var expressionResult)) { return expressionResult; } @@ -526,7 +534,7 @@ private static BuildResult BuildSegment( var recursion = TryRecurseIntoPwsh( body, start, classified, source, baseOptions, effectiveOptions, workingDirectoryUnknown, recursionDepth, - segment, markWrapped, out var recursionResult); + structuralDepth, segment, markWrapped, out var recursionResult); if (recursion) { return recursionResult; @@ -1670,7 +1678,11 @@ private static RedirectDirection MapRedirect(string op, out bool isMerge, out st private static bool TryHandleInvokeExpression( List body, int start, ClassifiedVerb verb, string source, - PwshParserOptions options, int recursionDepth, Segment segment, bool markWrapped, + PwshParserOptions options, + int recursionDepth, + int structuralDepth, + Segment segment, + bool markWrapped, PwshSetLocationContext attribution, out BuildResult result) { result = default; @@ -1832,7 +1844,7 @@ private static bool TryHandleInvokeExpression( } var innerParsed = ParseInternal( - payloadValue!, options, recursionDepth + 1, markWrapped: true, + payloadValue!, options, recursionDepth + 1, structuralDepth + 1, markWrapped: true, sharedLocation: attribution); if (innerParsed.IsUnparseable) { @@ -1946,7 +1958,11 @@ private static bool IsInvokeExpressionName(string identity) private static bool TryRecurseIntoPwsh( List body, int start, ClassifiedVerb verb, string source, PwshParserOptions options, PwshParserOptions redirectOptions, - bool workingDirectoryUnknown, int recursionDepth, Segment segment, bool markWrapped, + bool workingDirectoryUnknown, + int recursionDepth, + int structuralDepth, + Segment segment, + bool markWrapped, out BuildResult result) { result = default; @@ -2011,6 +2027,15 @@ private static bool TryRecurseIntoPwsh( return false; } + if (!isEncoded && HasDynamicCommandPayload( + body, i, colonValue, payloadEndExclusive)) + { + // The parent shell evaluates expandable command payloads before + // starting the child host. Keep the outer host visible and let + // structural discovery expose only those parent-scope commands. + return false; + } + if (failure is not null) { result = BuildResult.Fail(failure); @@ -2055,7 +2080,7 @@ private static bool TryRecurseIntoPwsh( } var innerParsed = ParseInternal( - inner, options, recursionDepth + 1, markWrapped: true, + inner, options, recursionDepth + 1, structuralDepth + 1, markWrapped: true, sharedLocation: null); if (innerParsed.IsUnparseable) { @@ -2083,6 +2108,48 @@ private static bool TryRecurseIntoPwsh( return false; } + private static bool HasDynamicCommandPayload( + IReadOnlyList body, + int parameterIndex, + string? colonValue, + int payloadEndExclusive) + { + if (colonValue is not null) + { + return body[parameterIndex].HasInterpolation; + } + + if (parameterIndex + 1 >= payloadEndExclusive) + { + return false; + } + + for (var index = parameterIndex + 1; index < payloadEndExclusive; index++) + { + var token = body[index]; + if (token.HasInterpolation) + { + return true; + } + + if (token.ResolverValue is null) + { + continue; + } + + foreach (var fragment in token.ResolverValue.Fragments) + { + if (fragment.Kind == ShellValueFragmentKind.Opaque && + fragment.OpaqueCause == ShellOpaqueCause.PowerShellSubexpression) + { + return true; + } + } + } + + return false; + } + private static bool HasExactWrapperPrefix( IReadOnlyList body, int start, diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs index 1b46a75..77891cc 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using ShellSyntaxTree.Internal.Parsing; using ShellSyntaxTree.Internal.Pwsh.Lexing; +using ShellSyntaxTree.Internal.Resolving; namespace ShellSyntaxTree.Internal.Pwsh.Parsing; @@ -17,6 +18,7 @@ private static ParsedCommand ParseStructured( IReadOnlyList tokens, PwshParserOptions options, int recursionDepth, + int structuralDepth, bool markWrapped, PwshSetLocationContext? sharedLocation) { @@ -25,8 +27,13 @@ private static ParsedCommand ParseStructured( tokens, options, recursionDepth, + structuralDepth, markWrapped, - sharedLocation); + sharedLocation, + sourceStart: 0, + source.Length, + CompoundOperator.None, + insideCommandSubstitution: false); if (!coordinator.TryParse(out var syntax, out var error)) { return StructuralFailure(source, error, syntax); @@ -36,7 +43,7 @@ private static ParsedCommand ParseStructured( syntax, simple => new CommandOccurrenceFacts { - IsComplete = IsStructurallyComplete(simple.Clause), + IsComplete = IsStructurallyComplete(simple), }, out var projection)) { @@ -74,8 +81,13 @@ private sealed class StructuralCoordinator private readonly IReadOnlyList _tokens; private readonly PwshParserOptions _options; private readonly int _recursionDepth; + private readonly int _structuralDepth; private readonly bool _markWrapped; private readonly PwshSetLocationContext _attribution; + private readonly int _sourceStart; + private readonly int _sourceLength; + private readonly CompoundOperator _firstCompatibilityOperator; + private readonly bool _insideCommandSubstitution; private int _position; private int _groupDepth; @@ -84,15 +96,25 @@ internal StructuralCoordinator( IReadOnlyList tokens, PwshParserOptions options, int recursionDepth, + int structuralDepth, bool markWrapped, - PwshSetLocationContext? sharedLocation) + PwshSetLocationContext? sharedLocation, + int sourceStart, + int sourceLength, + CompoundOperator firstCompatibilityOperator, + bool insideCommandSubstitution) { _source = source; _tokens = tokens; _options = options; _recursionDepth = recursionDepth; + _structuralDepth = structuralDepth; _markWrapped = markWrapped; _attribution = sharedLocation ?? new PwshSetLocationContext(); + _sourceStart = sourceStart; + _sourceLength = sourceLength; + _firstCompatibilityOperator = firstCompatibilityOperator; + _insideCommandSubstitution = insideCommandSubstitution; } internal bool TryParse(out ShellBlockSyntax syntax, out string? error) @@ -102,14 +124,14 @@ internal bool TryParse(out ShellBlockSyntax syntax, out string? error) { syntax = new ShellBlockSyntax { - SourceStart = 0, - SourceLength = _source.Length, + SourceStart = _sourceStart, + SourceLength = _sourceLength, }; error = null; return true; } - if (!TryParseList(CompoundOperator.None, out var command, out error) || + if (!TryParseList(_firstCompatibilityOperator, out var command, out error) || command is null) { syntax = new ShellBlockSyntax(); @@ -129,8 +151,8 @@ internal bool TryParse(out ShellBlockSyntax syntax, out string? error) syntax = new ShellBlockSyntax { Statements = new[] { command }, - SourceStart = 0, - SourceLength = _source.Length, + SourceStart = _sourceStart, + SourceLength = _sourceLength, }; return true; } @@ -345,6 +367,69 @@ private bool TryParseCommand( return false; } + if (_insideCommandSubstitution) + { + var firstSegmentToken = segmentTokens[0]; + var lastSegmentToken = segmentTokens[segmentTokens.Count - 1]; + var segmentSource = _source.Substring( + firstSegmentToken.SourceStart, + lastSegmentToken.SourceStart + lastSegmentToken.SourceLength - + firstSegmentToken.SourceStart); + if (IsUnsupportedSubstitutionBody(segmentSource, segmentTokens)) + { + error = "unsupported PowerShell expression statement in subexpression"; + return false; + } + + if (segmentTokens[0].Kind == PwshTokenKind.Parameter) + { + // PowerShell permits dash-leading native command names. + // At statement position this token is the executable, not + // a parameter waiting for a missing command. + segmentTokens[0] = segmentTokens[0] with { Kind = PwshTokenKind.Word }; + } + } + + if (!TryValidateOpaqueExpressions(segmentTokens, out error)) + { + return false; + } + + if (!TryCollectCommandSubstitutions( + segmentTokens, + out var substitutionFragments, + out error)) + { + return false; + } + + var hasCallOperator = segmentTokens[0].Kind == PwshTokenKind.Operator && + segmentTokens[0].OperatorText == "&"; + var firstIsDirectSubstitution = substitutionFragments.Count > 0 && + IsDirectCommandSubstitution(segmentTokens[0], substitutionFragments[0]); + if (!hasCallOperator && firstIsDirectSubstitution) + { + if (segmentTokens.Count != 1 || substitutionFragments.Count != 1) + { + error = "a standalone PowerShell subexpression cannot have command-style arguments"; + return false; + } + + return TryParseStandaloneSubstitution( + substitutionFragments[0], + compatibilityOperator, + out command, + out error); + } + + if (!TryParseCommandSubstitutions( + substitutionFragments, + out var substitutions, + out error)) + { + return false; + } + var effectiveOptions = _options; var workingDirectoryUnknown = false; if (_attribution.HasAttribution && !_attribution.IsDynamic) @@ -373,6 +458,7 @@ private bool TryParseCommand( effectiveOptions, workingDirectoryUnknown, _recursionDepth, + _structuralDepth + _groupDepth, _markWrapped, _attribution); if (built.Error is not null) @@ -383,6 +469,12 @@ private bool TryParseCommand( if (built.Syntax is not null) { + if (substitutions.Count > 0) + { + error = "PowerShell wrapper recursion cannot retain parent-scope substitutions safely"; + return false; + } + command = built.Syntax; return true; } @@ -405,6 +497,7 @@ private bool TryParseCommand( command = new SimpleCommandSyntax { Clause = clause, + Substitutions = substitutions, SourceStart = first.SourceStart, SourceLength = last.SourceStart + last.SourceLength - first.SourceStart, }; @@ -416,7 +509,8 @@ private bool TryParseGroup( out ShellSyntaxNode? command, out string? error) { - if (_groupDepth >= ShellAnalysisLimits.MaxStructuralNesting) + if (_structuralDepth + _groupDepth >= + ShellAnalysisLimits.MaxStructuralNesting) { command = null; error = "PowerShell structural nesting depth exceeded (>16)"; @@ -441,7 +535,7 @@ private bool TryParseGroup( if (_position == _tokens.Count || !IsOperator(")")) { command = null; - error = $"unbalanced '(' grouping at position {_source.Length}"; + error = $"unbalanced '(' grouping at position {_sourceStart + _sourceLength}"; return false; } @@ -524,6 +618,598 @@ private bool IsOperator(string value) => _position < _tokens.Count && _tokens[_position].Kind == PwshTokenKind.Operator && string.Equals(_tokens[_position].OperatorText, value, StringComparison.Ordinal); + + private bool TryCollectCommandSubstitutions( + IReadOnlyList tokens, + out IReadOnlyList substitutions, + out string? error) + { + var discovered = new List(); + foreach (var token in tokens) + { + if (token.ResolverValue is null) + { + continue; + } + + foreach (var fragment in token.ResolverValue.Fragments) + { + if (fragment.Kind != ShellValueFragmentKind.Opaque || + fragment.OpaqueCause != ShellOpaqueCause.PowerShellSubexpression) + { + continue; + } + + if (fragment.SourceStart is null || fragment.SourceLength is null || + fragment.SourceLength < 3 || + fragment.SourceStart < _sourceStart || + fragment.SourceStart + fragment.SourceLength > + _sourceStart + _sourceLength) + { + substitutions = Array.Empty(); + error = "PowerShell subexpression has invalid source provenance"; + return false; + } + + var raw = _source.Substring( + fragment.SourceStart.Value, + fragment.SourceLength.Value); + if (!raw.StartsWith("$(", StringComparison.Ordinal)) + { + continue; + } + + if (raw[raw.Length - 1] != ')') + { + substitutions = Array.Empty(); + error = "unsupported PowerShell subexpression provenance"; + return false; + } + + discovered.Add(fragment); + } + } + + substitutions = discovered; + error = null; + return true; + } + + private static bool IsUnsupportedSubstitutionBody( + string source, + IReadOnlyList tokens) + { + var hasCallOperator = tokens[0].Kind == PwshTokenKind.Operator && + tokens[0].OperatorText == "&"; + if (hasCallOperator) + { + return false; + } + + var value = tokens[0].Value; + if (value.Length == 0) + { + return false; + } + + var trimmed = source.TrimStart(); + if (trimmed.StartsWith(",", StringComparison.Ordinal) || + trimmed.StartsWith("!", StringComparison.Ordinal) || + trimmed.StartsWith("++", StringComparison.Ordinal) || + trimmed.StartsWith("--", StringComparison.Ordinal) || + StartsWithUnarySignExpression(trimmed) || + StartsWithUnaryExpressionOperator(trimmed) || + trimmed.StartsWith("@", StringComparison.Ordinal) || + trimmed.StartsWith("{", StringComparison.Ordinal)) + { + return true; + } + + if (tokens[0].Kind == PwshTokenKind.Word && + value[0] == '$' && !value.StartsWith("$(", StringComparison.Ordinal)) + { + return true; + } + + if (tokens[0].Kind is not PwshTokenKind.Word and not PwshTokenKind.Parameter) + { + return false; + } + + return IsNumericExpressionWord(value); + } + + private static bool StartsWithUnaryExpressionOperator(string value) + { + foreach (var unaryOperator in new[] { "-not", "-bnot", "-join", "-split" }) + { + if (value.StartsWith(unaryOperator, StringComparison.OrdinalIgnoreCase) && + (value.Length == unaryOperator.Length || + char.IsWhiteSpace(value[unaryOperator.Length]))) + { + return true; + } + } + + return false; + } + + private static bool StartsWithUnarySignExpression(string value) => + value.Length > 1 && value[0] is '+' or '-' && + value[1] is '$' or '[' or '(' or '\'' or '"' or '@' or '{'; + + private static bool IsNumericExpressionWord(string value) + { + var index = value[0] is '+' or '-' ? 1 : 0; + if (index == value.Length || !char.IsDigit(value[index])) + { + return false; + } + + if (index + 1 < value.Length && value[index] == '0' && + value[index + 1] is 'x' or 'X' or 'b' or 'B') + { + var isHex = value[index + 1] is 'x' or 'X'; + index += 2; + var digitStart = index; + while (index < value.Length && (value[index] == '_' || + isHex && Uri.IsHexDigit(value[index]) || + !isHex && value[index] is '0' or '1')) + { + index++; + } + + return index > digitStart && IsNumericSuffix(value.Substring(index)); + } + + var hasExponent = false; + while (index < value.Length) + { + var character = value[index]; + if (char.IsDigit(character) || character is '_' or '.') + { + index++; + continue; + } + + if (!hasExponent && character is 'e' or 'E') + { + hasExponent = true; + index++; + if (index < value.Length && value[index] is '+' or '-') + { + index++; + } + + continue; + } + + break; + } + + var suffix = value.Substring(index); + if (suffix.Length > 0 && suffix[0] is '+' or '-' or '*' or '/' or '%') + { + return true; + } + + return IsNumericSuffix(suffix); + } + + private static bool IsNumericSuffix(string suffix) => + suffix.Length == 0 || suffix.Equals("d", StringComparison.OrdinalIgnoreCase) || + suffix.Equals("l", StringComparison.OrdinalIgnoreCase) || + suffix.Equals("u", StringComparison.OrdinalIgnoreCase) || + suffix.Equals("ul", StringComparison.OrdinalIgnoreCase) || + suffix.Equals("lu", StringComparison.OrdinalIgnoreCase) || + suffix.Equals("n", StringComparison.OrdinalIgnoreCase) || + suffix.Equals("s", StringComparison.OrdinalIgnoreCase) || + suffix.Equals("us", StringComparison.OrdinalIgnoreCase) || + suffix.Equals("y", StringComparison.OrdinalIgnoreCase) || + suffix.Equals("uy", StringComparison.OrdinalIgnoreCase) || + suffix.Equals("kb", StringComparison.OrdinalIgnoreCase) || + suffix.Equals("mb", StringComparison.OrdinalIgnoreCase) || + suffix.Equals("gb", StringComparison.OrdinalIgnoreCase) || + suffix.Equals("tb", StringComparison.OrdinalIgnoreCase) || + suffix.Equals("pb", StringComparison.OrdinalIgnoreCase); + + private bool TryValidateOpaqueExpressions( + IReadOnlyList tokens, + out string? error) + { + foreach (var token in tokens) + { + if (token.ResolverValue is null) + { + continue; + } + + foreach (var fragment in token.ResolverValue.Fragments) + { + if (fragment.Kind != ShellValueFragmentKind.Opaque || + fragment.OpaqueCause != ShellOpaqueCause.PowerShellSubexpression || + fragment.SourceStart is null || fragment.SourceLength is null) + { + continue; + } + + var fragmentEnd = fragment.SourceStart.Value + + fragment.SourceLength.Value; + if (fragmentEnd < _sourceStart + _sourceLength && + _source[fragmentEnd] is '.' or '[') + { + error = "PowerShell expression suffix after subexpression is not supported"; + return false; + } + + var raw = _source.Substring( + fragment.SourceStart.Value, + fragment.SourceLength.Value); + if (raw.StartsWith("@(", StringComparison.Ordinal) && + !IsLiteralArrayExpression(raw) || + raw.StartsWith("@{", StringComparison.Ordinal) && + !IsLiteralHashExpression(raw)) + { + error = "execution-bearing PowerShell @() or @{} expressions are not supported"; + return false; + } + } + } + + error = null; + return true; + } + + private static bool IsLiteralArrayExpression(string raw) + { + var index = 2; + var end = raw.Length - 1; + SkipExpressionWhitespace(raw, ref index, end); + if (index == end) + { + return true; + } + + while (index < end) + { + if (!TryReadLiteralExpressionValue(raw, ref index, end)) + { + return false; + } + + SkipExpressionWhitespace(raw, ref index, end); + if (index == end) + { + return true; + } + + if (raw[index] != ',') + { + return false; + } + + index++; + SkipExpressionWhitespace(raw, ref index, end); + } + + return false; + } + + private static bool IsLiteralHashExpression(string raw) + { + var index = 2; + var end = raw.Length - 1; + SkipExpressionWhitespace(raw, ref index, end); + if (index == end) + { + return true; + } + + while (index < end) + { + if (!TryReadLiteralHashKey(raw, ref index, end)) + { + return false; + } + + SkipExpressionWhitespace(raw, ref index, end); + if (index >= end || raw[index] != '=') + { + return false; + } + + index++; + SkipExpressionWhitespace(raw, ref index, end); + if (!TryReadLiteralExpressionValue(raw, ref index, end)) + { + return false; + } + + SkipExpressionWhitespace(raw, ref index, end); + if (index == end) + { + return true; + } + + if (raw[index] != ';') + { + return false; + } + + index++; + SkipExpressionWhitespace(raw, ref index, end); + } + + return false; + } + + private static bool TryReadLiteralHashKey(string raw, ref int index, int end) + { + if (index < end && raw[index] is '\'' or '"') + { + return TryReadQuotedLiteral(raw, ref index, end); + } + + var start = index; + while (index < end && + (raw[index] == '_' || raw[index] == '-' || + char.IsLetterOrDigit(raw[index]))) + { + index++; + } + + return index > start; + } + + private static bool TryReadLiteralExpressionValue( + string raw, + ref int index, + int end) + { + if (index >= end) + { + return false; + } + + if (raw[index] is '\'' or '"') + { + return TryReadQuotedLiteral(raw, ref index, end); + } + + foreach (var literal in new[] { "$true", "$false", "$null" }) + { + if (index + literal.Length <= end && + string.Compare( + raw, + index, + literal, + 0, + literal.Length, + StringComparison.OrdinalIgnoreCase) == 0) + { + index += literal.Length; + return true; + } + } + + var start = index; + if (raw[index] is '+' or '-') + { + index++; + } + + var hasDigit = false; + while (index < end && (char.IsDigit(raw[index]) || raw[index] == '.')) + { + hasDigit |= char.IsDigit(raw[index]); + index++; + } + + return hasDigit && index > start; + } + + private static bool TryReadQuotedLiteral(string raw, ref int index, int end) + { + var quote = raw[index++]; + while (index < end) + { + if (quote == '"' && raw[index] == '`' && index + 1 < end) + { + index += 2; + continue; + } + + if (raw[index] != quote) + { + if (quote == '"' && raw[index] == '$' && index + 1 < end && + raw[index + 1] == '(') + { + return false; + } + + index++; + continue; + } + + if (quote == '\'' && index + 1 < end && raw[index + 1] == '\'') + { + index += 2; + continue; + } + + index++; + return true; + } + + return false; + } + + private static void SkipExpressionWhitespace(string raw, ref int index, int end) + { + while (index < end && char.IsWhiteSpace(raw[index])) + { + index++; + } + } + + private static bool IsDirectCommandSubstitution( + PwshToken token, + ShellValueFragment substitution) => + token.Kind == PwshTokenKind.Subexpression && + substitution.SourceStart == token.SourceStart && + substitution.SourceLength == token.SourceLength; + + private bool TryParseStandaloneSubstitution( + ShellValueFragment fragment, + CompoundOperator compatibilityOperator, + out ShellSyntaxNode? command, + out string? error) + { + if (!TryParseSubstitutionBody( + fragment, + compatibilityOperator, + out var body, + out error)) + { + command = null; + return false; + } + + command = new CommandSubstitutionSyntax + { + Body = body, + SourceStart = fragment.SourceStart, + SourceLength = fragment.SourceLength, + }; + return true; + } + + private bool TryParseCommandSubstitutions( + IReadOnlyList fragments, + out IReadOnlyList substitutions, + out string? error) + { + if (fragments.Count == 0) + { + substitutions = Array.Empty(); + error = null; + return true; + } + + if (_structuralDepth + _groupDepth + 1 > + ShellAnalysisLimits.MaxStructuralNesting) + { + substitutions = Array.Empty(); + error = "PowerShell structural nesting depth exceeded (>16)"; + return false; + } + + var parsed = new List(fragments.Count); + foreach (var fragment in fragments) + { + if (!TryParseSubstitutionBody( + fragment, + CompoundOperator.None, + out var body, + out error)) + { + substitutions = Array.Empty(); + return false; + } + + parsed.Add(new CommandSubstitutionSyntax + { + Body = body, + SourceStart = fragment.SourceStart, + SourceLength = fragment.SourceLength, + }); + } + + substitutions = parsed; + error = null; + return true; + } + + private bool TryParseSubstitutionBody( + ShellValueFragment fragment, + CompoundOperator firstCompatibilityOperator, + out ShellBlockSyntax body, + out string? error) + { + var sourceStart = fragment.SourceStart!.Value + 2; + var sourceLength = fragment.SourceLength!.Value - 3; + var source = _source.Substring(sourceStart, sourceLength); + var relativeTokens = PwshLexer.Tokenize(source); + foreach (var token in relativeTokens) + { + if (token.Kind == PwshTokenKind.UnparseableSentinel) + { + body = new ShellBlockSyntax(); + error = token.UnparseableReason; + return false; + } + } + + var significant = FilterSignificant(relativeTokens); + if (TryDetectAnomaly(significant, out error)) + { + body = new ShellBlockSyntax(); + return false; + } + + var shifted = ShiftTokens(significant, sourceStart); + var coordinator = new StructuralCoordinator( + _source, + shifted, + _options, + _recursionDepth, + _structuralDepth + _groupDepth + 1, + _markWrapped, + _attribution, + sourceStart, + sourceLength, + firstCompatibilityOperator, + insideCommandSubstitution: true); + return coordinator.TryParse(out body, out error); + } + } + + private static IReadOnlyList ShiftTokens( + IReadOnlyList tokens, + int sourceOffset) + { + var shifted = new PwshToken[tokens.Count]; + for (var index = 0; index < tokens.Count; index++) + { + var token = tokens[index]; + shifted[index] = token with + { + SourceStart = token.SourceStart + sourceOffset, + ResolverValue = ShiftValue(token.ResolverValue, sourceOffset), + }; + } + + return shifted; + } + + private static ShellValue? ShiftValue(ShellValue? value, int sourceOffset) + { + if (value is null) + { + return null; + } + + var fragments = new ShellValueFragment[value.Fragments.Count]; + for (var index = 0; index < value.Fragments.Count; index++) + { + var fragment = value.Fragments[index]; + fragments[index] = fragment with + { + SourceStart = fragment.SourceStart + sourceOffset, + }; + } + + return new ShellValue(value.Decoded, fragments); } private static bool IsStructuralBoundary(PwshToken token) => @@ -857,8 +1543,9 @@ private static bool TryCloneDecodedCollection( Raw = source.Raw, }; - private static bool IsStructurallyComplete(Clause clause) + private static bool IsStructurallyComplete(SimpleCommandSyntax simple) { + var clause = simple.Clause; if (clause.Redirects.Count > 0 || clause.Verb.IsDynamic || clause.Verb.Tokens.Count == 0) @@ -874,8 +1561,7 @@ private static bool IsStructurallyComplete(Clause clause) foreach (var element in clause.Elements) { if (element.Kind == ArgKind.DynamicSkip && - (element.Raw.IndexOf("$(", StringComparison.Ordinal) >= 0 || - element.Raw.IndexOf("@(", StringComparison.Ordinal) >= 0 || + (element.Raw.IndexOf("@(", StringComparison.Ordinal) >= 0 || element.Raw.IndexOf("@{", StringComparison.Ordinal) >= 0)) { return false; diff --git a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs index a6b4de3..c645aff 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs +++ b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs @@ -89,6 +89,7 @@ private static void AssertAuthoredTokenCoverage( var pwshTokens = PwshLexer.Tokenize(parsed.Source); var pwshDirectSegments = DirectPwshSegments(parsed, pwshTokens); + var standaloneSubstitutions = StandaloneSubstitutionRegions(parsed.Syntax); var pwshSegment = 0; var pwshRedirectTargetPending = false; foreach (var token in pwshTokens) @@ -103,10 +104,14 @@ private static void AssertAuthoredTokenCoverage( elements, context, token.Value, - !parsed.Clauses.Any(clause => clause.IsCommandStringWrapped) - || pwshDirectSegments.Contains(pwshSegment) - || isRedirectOperator - || isRedirectTarget); + (!parsed.Clauses.Any(clause => clause.IsCommandStringWrapped) + || pwshDirectSegments.Contains(pwshSegment) + || isRedirectOperator + || isRedirectTarget) && + !standaloneSubstitutions.Any(region => + region.Start <= token.SourceStart && + region.Start + region.Length >= + token.SourceStart + token.SourceLength)); pwshRedirectTargetPending = isRedirectOperator && token.OperatorText is not null && token.OperatorText.IndexOf(">&", StringComparison.Ordinal) < 0; @@ -119,6 +124,74 @@ private static void AssertAuthoredTokenCoverage( } } + private static IReadOnlyList StandaloneSubstitutionRegions( + ShellSyntaxNode syntax) + { + var regions = new List(); + CollectStandaloneSubstitutionRegions(syntax, attachedToSimple: false, regions); + return regions; + } + + private static void CollectStandaloneSubstitutionRegions( + ShellSyntaxNode node, + bool attachedToSimple, + ICollection regions) + { + switch (node) + { + case CommandSubstitutionSyntax substitution: + if (!attachedToSimple && substitution.SourceStart.HasValue && + substitution.SourceLength.HasValue) + { + regions.Add(new SourceRegion( + substitution.SourceStart.Value, + substitution.SourceLength.Value)); + } + + CollectStandaloneSubstitutionRegions( + substitution.Body, attachedToSimple: false, regions); + break; + case SimpleCommandSyntax simple: + foreach (var substitution in simple.Substitutions) + { + CollectStandaloneSubstitutionRegions( + substitution, attachedToSimple: true, regions); + } + + break; + case ShellBlockSyntax block: + foreach (var statement in block.Statements) + { + CollectStandaloneSubstitutionRegions( + statement, attachedToSimple: false, regions); + } + + break; + case PipelineSyntax pipeline: + foreach (var stage in pipeline.Stages) + { + CollectStandaloneSubstitutionRegions( + stage, attachedToSimple: false, regions); + } + + break; + case CommandListSyntax list: + foreach (var item in list.Items) + { + CollectStandaloneSubstitutionRegions( + item.Command, attachedToSimple: false, regions); + } + + break; + case GroupSyntax group: + CollectStandaloneSubstitutionRegions( + group.Body, attachedToSimple: false, regions); + break; + } + } + + private readonly record struct SourceRegion(int Start, int Length); + private static HashSet DirectBashSegments( ParsedCommand parsed, IReadOnlyList tokens) { diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/165_dynamic_subexpression_arg.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/165_dynamic_subexpression_arg.json index 96e2fce..19a1687 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/165_dynamic_subexpression_arg.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/165_dynamic_subexpression_arg.json @@ -4,6 +4,14 @@ "expected": { "isUnparseable": false, "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Location" + ], + "args": [], + "redirects": [] + }, { "operator": "None", "verb": [ @@ -38,6 +46,39 @@ "childIndex": 0, "sourceStart": 0, "sourceLength": 27, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandSubstitution", + "parentIndex": 1, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 12, + "sourceLength": 15, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 2, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 14, + "sourceLength": 12, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 14, + "sourceLength": 12, "clauseIndex": 0, "groupKind": null, "listOperator": null @@ -46,8 +87,36 @@ "commands": [ { "clauseIndex": 0, + "immediateRole": "Substitution", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 27 + }, + { + "ancestorKind": "CommandSubstitution", + "region": "Substitution", + "childIndex": 0, + "sourceStart": 12, + "sourceLength": 15 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 14, + "sourceLength": 12 + } + ] + }, + { + "clauseIndex": 1, "immediateRole": "Ordinary", - "isComplete": false, + "isComplete": true, "ancestry": [ { "ancestorKind": "Block", diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/301_dynamic_command_identity.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/301_dynamic_command_identity.json index dc4a16e..8dda98d 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/301_dynamic_command_identity.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/301_dynamic_command_identity.json @@ -4,6 +4,22 @@ "expected": { "isUnparseable": false, "clauses": [ + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "Content", + "kind": "Literal", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [] + }, { "operator": "None", "verb": [ diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/311_v03_substitution_multiple.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/311_v03_substitution_multiple.json new file mode 100644 index 0000000..77c58a0 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/311_v03_substitution_multiple.json @@ -0,0 +1,207 @@ +{ + "name": "V03 substitution multiple", + "input": "Write-Output $(Get-Date) $(Get-Location)", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Date" + ], + "args": [], + "redirects": [] + }, + { + "operator": "None", + "verb": [ + "Get-Location" + ], + "args": [], + "redirects": [] + }, + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "$(Get-Date)", + "kind": "DynamicSkip", + "isPath": false + }, + { + "raw": "$(Get-Location)", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 40, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 40, + "clauseIndex": 2, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandSubstitution", + "parentIndex": 1, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 13, + "sourceLength": 11, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 2, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 8, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 8, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandSubstitution", + "parentIndex": 1, + "region": "Substitution", + "childIndex": 1, + "sourceStart": 25, + "sourceLength": 15, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 5, + "region": "Substitution", + "childIndex": 1, + "sourceStart": 27, + "sourceLength": 12, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 6, + "region": "Statement", + "childIndex": 0, + "sourceStart": 27, + "sourceLength": 12, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Substitution", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 40 + }, + { + "ancestorKind": "CommandSubstitution", + "region": "Substitution", + "childIndex": 0, + "sourceStart": 13, + "sourceLength": 11 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 8 + } + ] + }, + { + "clauseIndex": 1, + "immediateRole": "Substitution", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 40 + }, + { + "ancestorKind": "CommandSubstitution", + "region": "Substitution", + "childIndex": 1, + "sourceStart": 25, + "sourceLength": 15 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 27, + "sourceLength": 12 + } + ] + }, + { + "clauseIndex": 2, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 40 + } + ] + } + ] + }, + "notes": "Sibling subexpressions are projected in authored order before their consumer." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/312_v03_substitution_nested.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/312_v03_substitution_nested.json new file mode 100644 index 0000000..f889e61 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/312_v03_substitution_nested.json @@ -0,0 +1,222 @@ +{ + "name": "V03 substitution nested", + "input": "Write-Output $(Get-Item $(Get-Location))", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Location" + ], + "args": [], + "redirects": [] + }, + { + "operator": "None", + "verb": [ + "Get-Item" + ], + "args": [ + { + "raw": "$(Get-Location)", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [] + }, + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "$(Get-Item $(Get-Location))", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 40, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 40, + "clauseIndex": 2, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandSubstitution", + "parentIndex": 1, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 13, + "sourceLength": 27, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 2, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 24, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 24, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandSubstitution", + "parentIndex": 4, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 24, + "sourceLength": 15, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 5, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 26, + "sourceLength": 12, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 6, + "region": "Statement", + "childIndex": 0, + "sourceStart": 26, + "sourceLength": 12, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Substitution", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 40 + }, + { + "ancestorKind": "CommandSubstitution", + "region": "Substitution", + "childIndex": 0, + "sourceStart": 13, + "sourceLength": 27 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 24 + }, + { + "ancestorKind": "CommandSubstitution", + "region": "Substitution", + "childIndex": 0, + "sourceStart": 24, + "sourceLength": 15 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 26, + "sourceLength": 12 + } + ] + }, + { + "clauseIndex": 1, + "immediateRole": "Substitution", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 40 + }, + { + "ancestorKind": "CommandSubstitution", + "region": "Substitution", + "childIndex": 0, + "sourceStart": 13, + "sourceLength": 27 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 24 + } + ] + }, + { + "clauseIndex": 2, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 40 + } + ] + } + ] + }, + "notes": "Nested subexpressions project innermost commands first without losing parentage." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/313_v03_substitution_standalone.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/313_v03_substitution_standalone.json new file mode 100644 index 0000000..a32d693 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/313_v03_substitution_standalone.json @@ -0,0 +1,100 @@ +{ + "name": "V03 substitution standalone", + "input": "$(Write-Output Get-Date)", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "Get-Date", + "kind": "Literal", + "isPath": false + } + ], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 24, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandSubstitution", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 24, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 2, + "sourceLength": 21, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 2, + "region": "Statement", + "childIndex": 0, + "sourceStart": 2, + "sourceLength": 21, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Substitution", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 24 + }, + { + "ancestorKind": "CommandSubstitution", + "region": "Substitution", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 24 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 2, + "sourceLength": 21 + } + ] + } + ] + }, + "notes": "A standalone subexpression exposes its body without inventing an invocation." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/314_v03_substitution_call_operator.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/314_v03_substitution_call_operator.json new file mode 100644 index 0000000..e25caee --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/314_v03_substitution_call_operator.json @@ -0,0 +1,140 @@ +{ + "name": "V03 substitution call operator", + "input": "\u0026 $(Write-Output Get-Date) argument", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "Get-Date", + "kind": "Literal", + "isPath": false + } + ], + "redirects": [] + }, + { + "operator": "None", + "verb": [ + "$(Write-Output Get-Date)" + ], + "isDynamic": true, + "args": [ + { + "raw": "argument", + "kind": "Literal", + "isPath": false + } + ], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 35, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 35, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandSubstitution", + "parentIndex": 1, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 2, + "sourceLength": 24, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 2, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 4, + "sourceLength": 21, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 4, + "sourceLength": 21, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Substitution", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 35 + }, + { + "ancestorKind": "CommandSubstitution", + "region": "Substitution", + "childIndex": 0, + "sourceStart": 2, + "sourceLength": 24 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 4, + "sourceLength": 21 + } + ] + }, + { + "clauseIndex": 1, + "immediateRole": "Ordinary", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 35 + } + ] + } + ] + }, + "notes": "The call operator exposes the producer and retains an incomplete dynamic invocation." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/315_v03_substitution_current_scope_cwd.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/315_v03_substitution_current_scope_cwd.json new file mode 100644 index 0000000..4195ecc --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/315_v03_substitution_current_scope_cwd.json @@ -0,0 +1,313 @@ +{ + "name": "V03 substitution current scope cwd", + "input": "Write-Output $(Set-Location C:\\sensitive; Get-Location); Get-Item child.txt", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Set-Location" + ], + "args": [ + { + "raw": "C:\\sensitive", + "kind": "Literal", + "isPath": true, + "resolved": "C:/sensitive" + } + ], + "redirects": [] + }, + { + "operator": "Sequence", + "verb": [ + "Get-Location" + ], + "args": [ + { + "raw": "C:/sensitive", + "kind": "Literal", + "isPath": true, + "resolved": "C:/sensitive", + "isCwdAttribution": true + } + ], + "redirects": [] + }, + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "$(Set-Location C:\\sensitive; Get-Location)", + "kind": "DynamicSkip", + "isPath": false + }, + { + "raw": "C:/sensitive", + "kind": "Literal", + "isPath": true, + "resolved": "C:/sensitive", + "isCwdAttribution": true + } + ], + "redirects": [] + }, + { + "operator": "Sequence", + "verb": [ + "Get-Item" + ], + "args": [ + { + "raw": "child.txt", + "kind": "Literal", + "isPath": true, + "resolved": "C:/sensitive/child.txt" + }, + { + "raw": "C:/sensitive", + "kind": "Literal", + "isPath": true, + "resolved": "C:/sensitive", + "isCwdAttribution": true + } + ], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 75, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandList", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 75, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 1, + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 55, + "clauseIndex": 2, + "groupKind": null, + "listOperator": "None" + }, + { + "kind": "CommandSubstitution", + "parentIndex": 2, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 13, + "sourceLength": 42, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 3, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 39, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandList", + "parentIndex": 4, + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 39, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 5, + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 25, + "clauseIndex": 0, + "groupKind": null, + "listOperator": "None" + }, + { + "kind": "SimpleCommand", + "parentIndex": 5, + "region": "Statement", + "childIndex": 1, + "sourceStart": 42, + "sourceLength": 12, + "clauseIndex": 1, + "groupKind": null, + "listOperator": "Sequence" + }, + { + "kind": "SimpleCommand", + "parentIndex": 1, + "region": "Statement", + "childIndex": 1, + "sourceStart": 57, + "sourceLength": 18, + "clauseIndex": 3, + "groupKind": null, + "listOperator": "Sequence" + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Substitution", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 75 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 75 + }, + { + "ancestorKind": "CommandSubstitution", + "region": "Substitution", + "childIndex": 0, + "sourceStart": 13, + "sourceLength": 42 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 39 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 39 + } + ] + }, + { + "clauseIndex": 1, + "immediateRole": "Substitution", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 75 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 75 + }, + { + "ancestorKind": "CommandSubstitution", + "region": "Substitution", + "childIndex": 0, + "sourceStart": 13, + "sourceLength": 42 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 39 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 1, + "sourceStart": 15, + "sourceLength": 39 + } + ] + }, + { + "clauseIndex": 2, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 75 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 75 + } + ] + }, + { + "clauseIndex": 3, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 75 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 1, + "sourceStart": 0, + "sourceLength": 75 + } + ] + } + ] + }, + "notes": "Subexpression location changes affect its consumer and following current-scope commands." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/316_v03_substitution_redirect.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/316_v03_substitution_redirect.json new file mode 100644 index 0000000..7d27911 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/316_v03_substitution_redirect.json @@ -0,0 +1,144 @@ +{ + "name": "V03 substitution redirect", + "input": "Get-Content \u003E $(Join-Path C:\\temp out.txt)", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Join-Path" + ], + "args": [ + { + "raw": "C:\\temp", + "kind": "DynamicSkip", + "isPath": false + }, + { + "raw": "out.txt", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [] + }, + { + "operator": "None", + "verb": [ + "Get-Content" + ], + "args": [], + "redirects": [ + { + "direction": "Out", + "target": "$(Join-Path C:\\temp out.txt)", + "isDynamicSkip": true + } + ] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 42, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 42, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandSubstitution", + "parentIndex": 1, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 14, + "sourceLength": 28, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 2, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 16, + "sourceLength": 25, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 16, + "sourceLength": 25, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Substitution", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 42 + }, + { + "ancestorKind": "CommandSubstitution", + "region": "Substitution", + "childIndex": 0, + "sourceStart": 14, + "sourceLength": 28 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 16, + "sourceLength": 25 + } + ] + }, + { + "clauseIndex": 1, + "immediateRole": "Ordinary", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 42 + } + ] + } + ] + }, + "notes": "A redirect subexpression is visible while the outer redirect remains incomplete." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/317_v03_substitution_expanding_here_string.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/317_v03_substitution_expanding_here_string.json new file mode 100644 index 0000000..c9feba0 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/317_v03_substitution_expanding_here_string.json @@ -0,0 +1,133 @@ +{ + "name": "V03 substitution expanding here string", + "input": "Write-Output @\u0022\nvalue $(Get-Date)\n\u0022@", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Date" + ], + "args": [], + "redirects": [] + }, + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "@\u0022\nvalue $(Get-Date)\n\u0022@", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 36, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 36, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandSubstitution", + "parentIndex": 1, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 22, + "sourceLength": 11, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 2, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 24, + "sourceLength": 8, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 24, + "sourceLength": 8, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Substitution", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 36 + }, + { + "ancestorKind": "CommandSubstitution", + "region": "Substitution", + "childIndex": 0, + "sourceStart": 22, + "sourceLength": 11 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 24, + "sourceLength": 8 + } + ] + }, + { + "clauseIndex": 1, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 36 + } + ] + } + ] + }, + "notes": "An expanding here-string exposes its command subexpression." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/318_v03_substitution_dynamic_host_payload.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/318_v03_substitution_dynamic_host_payload.json new file mode 100644 index 0000000..9fc3f7c --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/318_v03_substitution_dynamic_host_payload.json @@ -0,0 +1,138 @@ +{ + "name": "V03 substitution dynamic host payload", + "input": "pwsh -Command \u0022Write-Output $(Get-Date)\u0022", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Date" + ], + "args": [], + "redirects": [] + }, + { + "operator": "None", + "verb": [ + "pwsh" + ], + "args": [ + { + "raw": "-Command", + "kind": "Literal", + "isPath": false + }, + { + "raw": "\u0022Write-Output $(Get-Date)\u0022", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 40, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 40, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandSubstitution", + "parentIndex": 1, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 28, + "sourceLength": 11, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 2, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 30, + "sourceLength": 8, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 30, + "sourceLength": 8, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Substitution", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 40 + }, + { + "ancestorKind": "CommandSubstitution", + "region": "Substitution", + "childIndex": 0, + "sourceStart": 28, + "sourceLength": 11 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 30, + "sourceLength": 8 + } + ] + }, + { + "clauseIndex": 1, + "immediateRole": "Ordinary", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 40 + } + ] + } + ] + }, + "notes": "Parent-scope expansion remains visible beside an incomplete outer pwsh host." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/319_v03_substitution_static_host_payload.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/319_v03_substitution_static_host_payload.json new file mode 100644 index 0000000..55acd13 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/319_v03_substitution_static_host_payload.json @@ -0,0 +1,185 @@ +{ + "name": "V03 substitution static host payload", + "input": "pwsh -Command \u0027Write-Output $(Get-Date)\u0027", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Date" + ], + "args": [], + "redirects": [], + "isCommandStringWrapped": true + }, + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "$(Get-Date)", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [], + "isCommandStringWrapped": true + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 40, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Group", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 40, + "clauseIndex": null, + "groupKind": "IsolatedScope", + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "GroupBody", + "childIndex": null, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 2, + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandSubstitution", + "parentIndex": 3, + "region": "Substitution", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 4, + "region": "Substitution", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 5, + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Substitution", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 40 + }, + { + "ancestorKind": "Group", + "region": "GroupBody", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 40 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null + }, + { + "ancestorKind": "CommandSubstitution", + "region": "Substitution", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null + } + ] + }, + { + "clauseIndex": 1, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 40 + }, + { + "ancestorKind": "Group", + "region": "GroupBody", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 40 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null + } + ] + } + ] + }, + "notes": "A literal child payload is decoded and its child-scope substitution is exposed." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/320_v03_substitution_direct_comment_boundary.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/320_v03_substitution_direct_comment_boundary.json new file mode 100644 index 0000000..4756db4 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/320_v03_substitution_direct_comment_boundary.json @@ -0,0 +1,211 @@ +{ + "name": "V03 substitution direct comment boundary", + "input": "Write-Output $(Write-Output \u0027x\u0027# )\nGet-Date)", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "\u0027x\u0027", + "kind": "Literal", + "isPath": false + } + ], + "redirects": [] + }, + { + "operator": "Sequence", + "verb": [ + "Get-Date" + ], + "args": [], + "redirects": [] + }, + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "$(Write-Output \u0027x\u0027# )\nGet-Date)", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 44, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 44, + "clauseIndex": 2, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandSubstitution", + "parentIndex": 1, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 13, + "sourceLength": 31, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 2, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 28, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandList", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 28, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 4, + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 16, + "clauseIndex": 0, + "groupKind": null, + "listOperator": "None" + }, + { + "kind": "SimpleCommand", + "parentIndex": 4, + "region": "Statement", + "childIndex": 1, + "sourceStart": 35, + "sourceLength": 8, + "clauseIndex": 1, + "groupKind": null, + "listOperator": "Sequence" + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Substitution", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 44 + }, + { + "ancestorKind": "CommandSubstitution", + "region": "Substitution", + "childIndex": 0, + "sourceStart": 13, + "sourceLength": 31 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 28 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 28 + } + ] + }, + { + "clauseIndex": 1, + "immediateRole": "Substitution", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 44 + }, + { + "ancestorKind": "CommandSubstitution", + "region": "Substitution", + "childIndex": 0, + "sourceStart": 13, + "sourceLength": 31 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 28 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 1, + "sourceStart": 15, + "sourceLength": 28 + } + ] + }, + { + "clauseIndex": 2, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 44 + } + ] + } + ] + }, + "notes": "A comment after a closed quoted argument cannot terminate the containing subexpression." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/321_v03_substitution_array_execution_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/321_v03_substitution_array_execution_gated.json new file mode 100644 index 0000000..4d6559b --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/321_v03_substitution_array_execution_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 substitution array execution gated", + "input": "Write-Output @(Get-Date)", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "execution-bearing PowerShell @() or @{} expressions are not supported" + }, + "notes": "Execution-bearing array subexpressions fail closed until their expression grammar is modeled.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/322_v03_substitution_hash_execution_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/322_v03_substitution_hash_execution_gated.json new file mode 100644 index 0000000..dac7c51 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/322_v03_substitution_hash_execution_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 substitution hash execution gated", + "input": "Write-Output @{ value = $(Get-Date) }", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "execution-bearing PowerShell @() or @{} expressions are not supported" + }, + "notes": "Execution-bearing hash literals fail closed instead of hiding their command.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/323_v03_substitution_numeric_expression_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/323_v03_substitution_numeric_expression_gated.json new file mode 100644 index 0000000..c21e7b3 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/323_v03_substitution_numeric_expression_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 substitution numeric expression gated", + "input": "Write-Output $(1-1)", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "unsupported PowerShell expression statement in subexpression" + }, + "notes": "Numeric expression bodies are not fabricated into command occurrences.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/324_v03_substitution_member_suffix_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/324_v03_substitution_member_suffix_gated.json new file mode 100644 index 0000000..4009ed0 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/324_v03_substitution_member_suffix_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 substitution member suffix gated", + "input": "Write-Output $(Get-Date).ToString()", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "PowerShell expression suffix after subexpression is not supported" + }, + "notes": "Member access after a subexpression fails the whole result until expression suffixes are modeled.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/325_v03_substitution_inner_here_string_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/325_v03_substitution_inner_here_string_gated.json new file mode 100644 index 0000000..a7226cd --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/325_v03_substitution_inner_here_string_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 substitution inner here string gated", + "input": "Write-Output $(Write-Output @\u0022\nvalue\n\u0022@)", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "here-strings inside PowerShell subexpressions are not supported" + }, + "notes": "Here-strings nested inside a subexpression remain a conservative grammar boundary.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/326_v03_substitution_invalid_expandable_comment.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/326_v03_substitution_invalid_expandable_comment.json new file mode 100644 index 0000000..8e89e56 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/326_v03_substitution_invalid_expandable_comment.json @@ -0,0 +1,9 @@ +{ + "name": "V03 substitution invalid expandable comment", + "input": "Write-Output \u0022$($(Write-Output x)# )\nGet-Date)\u0022", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "comments inside expandable PowerShell subexpressions are not supported" + }, + "notes": "Real PowerShell rejects comment termination inside this expandable-string subexpression." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/327_v03_substitution_numeric_suffix_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/327_v03_substitution_numeric_suffix_gated.json new file mode 100644 index 0000000..931b1d3 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/327_v03_substitution_numeric_suffix_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 substitution numeric suffix gated", + "input": "Write-Output $(1kb)", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "unsupported PowerShell expression statement in subexpression" + }, + "notes": "PowerShell numeric suffixes are expression values, not command identities.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/328_v03_substitution_hex_literal_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/328_v03_substitution_hex_literal_gated.json new file mode 100644 index 0000000..2a1c253 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/328_v03_substitution_hex_literal_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 substitution hex literal gated", + "input": "Write-Output $(0x10)", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "unsupported PowerShell expression statement in subexpression" + }, + "notes": "PowerShell hexadecimal literals are not fabricated into commands.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/329_v03_substitution_unary_comma_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/329_v03_substitution_unary_comma_gated.json new file mode 100644 index 0000000..3ab6d2f --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/329_v03_substitution_unary_comma_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 substitution unary comma gated", + "input": "Write-Output $(,$x)", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "unsupported PowerShell expression statement in subexpression" + }, + "notes": "Unary comma creates an expression result and cannot be interpreted as a command.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/330_v03_substitution_unary_join_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/330_v03_substitution_unary_join_gated.json new file mode 100644 index 0000000..29e017e --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/330_v03_substitution_unary_join_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 substitution unary join gated", + "input": "Write-Output $(-join @(\u0027a\u0027,\u0027b\u0027))", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "unsupported PowerShell expression statement in subexpression" + }, + "notes": "Unary expression operators remain outside the command grammar.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/331_v03_substitution_statement_array_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/331_v03_substitution_statement_array_gated.json new file mode 100644 index 0000000..2746028 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/331_v03_substitution_statement_array_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 substitution statement array gated", + "input": "Write-Output $(@(1,2,3))", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "unsupported PowerShell expression statement in subexpression" + }, + "notes": "A literal array at statement position is data rather than an executable identity.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/332_v03_substitution_later_expression_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/332_v03_substitution_later_expression_gated.json new file mode 100644 index 0000000..ba22a4f --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/332_v03_substitution_later_expression_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 substitution later expression gated", + "input": "Write-Output $(Get-Date; 1kb)", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "unsupported PowerShell expression statement in subexpression" + }, + "notes": "Every statement in a subexpression is checked for the command-versus-expression boundary.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/333_v03_substitution_grouped_expression_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/333_v03_substitution_grouped_expression_gated.json new file mode 100644 index 0000000..b96ffac --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/333_v03_substitution_grouped_expression_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 substitution grouped expression gated", + "input": "Write-Output $((1kb))", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "unsupported PowerShell expression statement in subexpression" + }, + "notes": "Grouping cannot bypass expression rejection inside a subexpression.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/334_v03_substitution_scriptblock_value_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/334_v03_substitution_scriptblock_value_gated.json new file mode 100644 index 0000000..241c78e --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/334_v03_substitution_scriptblock_value_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 substitution scriptblock value gated", + "input": "Write-Output $({ Get-Date })", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "unsupported PowerShell expression statement in subexpression" + }, + "notes": "A script-block value is not executed merely by occupying subexpression statement position.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/335_v03_substitution_digit_command_identity.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/335_v03_substitution_digit_command_identity.json new file mode 100644 index 0000000..2eee245 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/335_v03_substitution_digit_command_identity.json @@ -0,0 +1,133 @@ +{ + "name": "V03 substitution digit command identity", + "input": "Write-Output $(7z)", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "7z" + ], + "args": [], + "redirects": [] + }, + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "$(7z)", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 18, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 18, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandSubstitution", + "parentIndex": 1, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 13, + "sourceLength": 5, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 2, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 2, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 2, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Substitution", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 18 + }, + { + "ancestorKind": "CommandSubstitution", + "region": "Substitution", + "childIndex": 0, + "sourceStart": 13, + "sourceLength": 5 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 2 + } + ] + }, + { + "clauseIndex": 1, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 18 + } + ] + } + ] + }, + "notes": "A real digit-leading executable name remains distinct from bounded numeric literals." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/336_v03_substitution_dynamic_cwd_poisoning.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/336_v03_substitution_dynamic_cwd_poisoning.json new file mode 100644 index 0000000..b6577e7 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/336_v03_substitution_dynamic_cwd_poisoning.json @@ -0,0 +1,313 @@ +{ + "name": "V03 substitution dynamic cwd poisoning", + "input": "Write-Output $(Set-Location $target; Get-Item child.txt); Get-Item sibling.txt", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Set-Location" + ], + "args": [ + { + "raw": "$target", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [] + }, + { + "operator": "Sequence", + "verb": [ + "Get-Item" + ], + "args": [ + { + "raw": "child.txt", + "kind": "DynamicSkip", + "isPath": false + }, + { + "raw": "\u003Cdynamic-cwd\u003E", + "kind": "DynamicSkip", + "isPath": false, + "isCwdAttribution": true + } + ], + "redirects": [] + }, + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "$(Set-Location $target; Get-Item child.txt)", + "kind": "DynamicSkip", + "isPath": false + }, + { + "raw": "\u003Cdynamic-cwd\u003E", + "kind": "DynamicSkip", + "isPath": false, + "isCwdAttribution": true + } + ], + "redirects": [] + }, + { + "operator": "Sequence", + "verb": [ + "Get-Item" + ], + "args": [ + { + "raw": "sibling.txt", + "kind": "DynamicSkip", + "isPath": false + }, + { + "raw": "\u003Cdynamic-cwd\u003E", + "kind": "DynamicSkip", + "isPath": false, + "isCwdAttribution": true + } + ], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 78, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandList", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 78, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 1, + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 56, + "clauseIndex": 2, + "groupKind": null, + "listOperator": "None" + }, + { + "kind": "CommandSubstitution", + "parentIndex": 2, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 13, + "sourceLength": 43, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 3, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 40, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandList", + "parentIndex": 4, + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 40, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 5, + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 20, + "clauseIndex": 0, + "groupKind": null, + "listOperator": "None" + }, + { + "kind": "SimpleCommand", + "parentIndex": 5, + "region": "Statement", + "childIndex": 1, + "sourceStart": 37, + "sourceLength": 18, + "clauseIndex": 1, + "groupKind": null, + "listOperator": "Sequence" + }, + { + "kind": "SimpleCommand", + "parentIndex": 1, + "region": "Statement", + "childIndex": 1, + "sourceStart": 58, + "sourceLength": 20, + "clauseIndex": 3, + "groupKind": null, + "listOperator": "Sequence" + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Substitution", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 78 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 78 + }, + { + "ancestorKind": "CommandSubstitution", + "region": "Substitution", + "childIndex": 0, + "sourceStart": 13, + "sourceLength": 43 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 40 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 40 + } + ] + }, + { + "clauseIndex": 1, + "immediateRole": "Substitution", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 78 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 78 + }, + { + "ancestorKind": "CommandSubstitution", + "region": "Substitution", + "childIndex": 0, + "sourceStart": 13, + "sourceLength": 43 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 40 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 1, + "sourceStart": 15, + "sourceLength": 40 + } + ] + }, + { + "clauseIndex": 2, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 78 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 78 + } + ] + }, + { + "clauseIndex": 3, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 78 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 1, + "sourceStart": 0, + "sourceLength": 78 + } + ] + } + ] + }, + "notes": "Unknown current-scope location changes poison inner, consumer, and continuation attribution." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/337_v03_substitution_integer_width_suffix_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/337_v03_substitution_integer_width_suffix_gated.json new file mode 100644 index 0000000..ab4892d --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/337_v03_substitution_integer_width_suffix_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 substitution integer width suffix gated", + "input": "Write-Output $(0x10s)", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "unsupported PowerShell expression statement in subexpression" + }, + "notes": "PowerShell integer-width suffixes produce numeric values rather than executable identities.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/338_v03_substitution_unary_bnot_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/338_v03_substitution_unary_bnot_gated.json new file mode 100644 index 0000000..d0c34cd --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/338_v03_substitution_unary_bnot_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 substitution unary bnot gated", + "input": "Write-Output $(-bnot 1)", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "unsupported PowerShell expression statement in subexpression" + }, + "notes": "Unary bitwise negation is an expression and cannot produce a command occurrence.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/339_v03_substitution_decimal_command_identity.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/339_v03_substitution_decimal_command_identity.json new file mode 100644 index 0000000..92eef90 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/339_v03_substitution_decimal_command_identity.json @@ -0,0 +1,133 @@ +{ + "name": "V03 substitution decimal command identity", + "input": "Write-Output $(1.0f)", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "1.0f" + ], + "args": [], + "redirects": [] + }, + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "$(1.0f)", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 20, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 20, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandSubstitution", + "parentIndex": 1, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 13, + "sourceLength": 7, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 2, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 4, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 4, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Substitution", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 20 + }, + { + "ancestorKind": "CommandSubstitution", + "region": "Substitution", + "childIndex": 0, + "sourceStart": 13, + "sourceLength": 7 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 4 + } + ] + }, + { + "clauseIndex": 1, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 20 + } + ] + } + ] + }, + "notes": "A decimal-looking name without a PowerShell numeric type is still an executable identity." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/340_v03_substitution_unary_variable_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/340_v03_substitution_unary_variable_gated.json new file mode 100644 index 0000000..19d3f16 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/340_v03_substitution_unary_variable_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 substitution unary variable gated", + "input": "Write-Output $(-$x)", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "unsupported PowerShell expression statement in subexpression" + }, + "notes": "A sign applied to a variable is an expression rather than a sign-leading command name.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/341_v03_substitution_dash_command_identity.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/341_v03_substitution_dash_command_identity.json new file mode 100644 index 0000000..ac2cc8b --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/341_v03_substitution_dash_command_identity.json @@ -0,0 +1,133 @@ +{ + "name": "V03 substitution dash command identity", + "input": "Write-Output $(-foo)", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "-foo" + ], + "args": [], + "redirects": [] + }, + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "$(-foo)", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 20, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 20, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandSubstitution", + "parentIndex": 1, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 13, + "sourceLength": 7, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 2, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 4, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 4, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Substitution", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 20 + }, + { + "ancestorKind": "CommandSubstitution", + "region": "Substitution", + "childIndex": 0, + "sourceStart": 13, + "sourceLength": 7 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 4 + } + ] + }, + { + "clauseIndex": 1, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 20 + } + ] + } + ] + }, + "notes": "A dash-leading word remains an executable identity when it is not a unary expression operand." +} diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json index cef95a5..d8906d9 100644 --- a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json @@ -4,6 +4,7 @@ { "id": "pwsh-simple-command-substitution", "concern": "Subexpression command is attached to its containing simple command", + "compatibilityProjectionLanded": true, "input": "Remove-Item $(Get-Item target.txt)", "current": { "isUnparseable": false, @@ -37,6 +38,7 @@ { "id": "pwsh-multiple-command-substitutions", "concern": "Sibling subexpressions preserve authored order before their consumer", + "compatibilityProjectionLanded": true, "input": "Write-Output $(Get-Date) $(Get-Location)", "current": { "isUnparseable": false }, "desired": { @@ -62,6 +64,7 @@ { "id": "pwsh-nested-command-substitutions", "concern": "Nested subexpressions retain parentage and project innermost first", + "compatibilityProjectionLanded": true, "input": "Write-Output $(Get-Item $(Get-Location))", "current": { "isUnparseable": false }, "desired": { @@ -87,6 +90,7 @@ { "id": "pwsh-literal-substitution-spellings", "concern": "Single-quoted and escaped subexpression-looking text does not execute", + "compatibilityProjectionLanded": true, "input": "Write-Output '$(Get-Date)' \"literal `$(Get-Location)\"", "current": { "isUnparseable": false }, "desired": { @@ -104,6 +108,7 @@ { "id": "pwsh-array-subexpression-execution-gated", "concern": "Execution-bearing array subexpression fails closed until discovery is modeled", + "compatibilityProjectionLanded": true, "input": "Write-Output @(Get-Date)", "current": { "isUnparseable": false, @@ -140,6 +145,7 @@ { "id": "pwsh-standalone-subexpression-is-not-invocation", "concern": "Standalone subexpression output does not invent an outer command", + "compatibilityProjectionLanded": true, "input": "$(Write-Output Get-Date)", "current": { "isUnparseable": false }, "desired": { @@ -160,6 +166,7 @@ { "id": "pwsh-call-operator-subexpression-invocation", "concern": "Call operator invokes unknown subexpression output after exposing its commands", + "compatibilityProjectionLanded": true, "input": "& $(Write-Output Get-Date)", "current": { "isUnparseable": false }, "desired": { @@ -182,6 +189,7 @@ { "id": "pwsh-command-substitution-current-scope-cwd", "concern": "PowerShell subexpression location changes propagate to the consumer and continuation", + "compatibilityProjectionLanded": true, "input": "Write-Output $(Set-Location /tmp; Get-Location); Get-Item relative.txt", "current": { "isUnparseable": false }, "desired": { diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs index 5353e06..f349378 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs @@ -558,10 +558,11 @@ public void Pwsh_file_is_not_recursion() } [Fact] - public void Inner_unparseable_command_propagates_to_the_outer() + public void Expanding_host_payload_remains_an_incomplete_outer_command() { var result = Parse("pwsh -Command \"foreach ($x in $y) { $x }\""); - Assert.True(result.IsUnparseable); + Assert.False(result.IsUnparseable); + Assert.False(Assert.Single(result.Commands).IsComplete); } [Theory] @@ -624,7 +625,6 @@ public void Invoke_expression_escaped_dollar_is_a_static_outer_string() [InlineData("Invoke-Expression $code", "$code")] [InlineData("iex \"Remove-$noun C:\\x\"", "\"Remove-$noun C:\\x\"")] [InlineData("iex \"Remove-$é C:\\x\"", "\"Remove-$é C:\\x\"")] - [InlineData("iex $(Get-Content script.ps1)", "$(Get-Content script.ps1)")] [InlineData("iex ('Get-' + 'Date')", "('Get-' + 'Date')")] [InlineData("Invoke-Expression Write-Output,OTHER", "Write-Output,OTHER")] public void Invoke_expression_computed_payload_is_one_dynamic_arg( @@ -641,6 +641,17 @@ public void Invoke_expression_computed_payload_is_one_dynamic_arg( Assert.Null(arg.Resolved); } + [Fact] + public void Invoke_expression_subexpression_payload_exposes_parent_command() + { + var result = Parse("iex $(Get-Content script.ps1)"); + + Assert.Equal(new[] { "Get-Content", "iex" }, + result.Commands.Select(command => command.Clause.Verb.Joined)); + Assert.True(result.Commands[0].IsComplete); + Assert.False(result.Commands[1].IsComplete); + } + [Theory] [InlineData("Invoke-Expression")] [InlineData("Get-Content script.ps1 | Invoke-Expression")] diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshStructuralProjectionTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshStructuralProjectionTests.cs index c852226..660ab12 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshStructuralProjectionTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshStructuralProjectionTests.cs @@ -163,6 +163,47 @@ public void Structural_depth_overflow_fails_before_recursive_descent() Assert.Contains("nesting depth", result.UnparseableReason!); } + [Fact] + public void Exact_substitution_depth_limit_remains_parseable() + { + var result = Parse(NestedSubstitution(ShellAnalysisLimits.MaxStructuralNesting)); + + Assert.False(result.IsUnparseable); + Assert.Equal(2, result.Commands.Count); + } + + [Fact] + public void Substitution_depth_overflow_fails_before_recursive_descent() + { + var result = Parse(NestedSubstitution(ShellAnalysisLimits.MaxStructuralNesting + 1)); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + Assert.Contains("nesting depth", result.UnparseableReason!); + } + + [Fact] + public void Group_and_substitution_share_the_exact_structural_depth_budget() + { + var result = Parse("(" + NestedSubstitution( + ShellAnalysisLimits.MaxStructuralNesting - 1) + ")"); + + Assert.False(result.IsUnparseable); + Assert.Equal(2, result.Commands.Count); + } + + [Fact] + public void Mixed_group_and_substitution_depth_overflow_fails_closed() + { + var result = Parse("(" + NestedSubstitution( + ShellAnalysisLimits.MaxStructuralNesting) + ")"); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + [Fact] public void PowerShell_host_wrapper_preserves_inner_structure_in_an_isolated_group() { @@ -292,15 +333,327 @@ public void Exact_static_host_prefix_still_allows_structural_recursion() } [Fact] - public void Undiscovered_subexpression_is_incomplete_but_ordinary_variable_data_is_not() + public void Discovered_subexpression_completes_the_outer_command_but_variable_data_is_ordinary() { - var hidden = Parse("Write-Output $(Get-Date)"); + var discovered = Parse("Write-Output $(Get-Date)"); var data = Parse("Write-Output $value"); - Assert.False(Assert.Single(hidden.Commands).IsComplete); + Assert.Equal(new[] { "Get-Date", "Write-Output" }, + discovered.Commands.Select(CommandVerb)); + Assert.All(discovered.Commands, command => Assert.True(command.IsComplete)); Assert.True(Assert.Single(data.Commands).IsComplete); } + [Fact] + public void Simple_subexpression_preserves_exact_structure_identity_and_spans() + { + const string source = "Remove-Item $(Get-Item target.txt)"; + var result = Parse(source); + + Assert.False(result.IsUnparseable); + Assert.Equal(new[] { "Get-Item", "Remove-Item" }, result.Commands.Select(CommandVerb)); + var outer = Assert.IsType(Assert.Single(result.Syntax.Statements)); + var substitution = Assert.Single(outer.Substitutions); + Assert.Equal(source.IndexOf("$(", System.StringComparison.Ordinal), substitution.SourceStart); + Assert.Equal("$(Get-Item target.txt)".Length, substitution.SourceLength); + var inner = Assert.IsType(Assert.Single(substitution.Body.Statements)); + Assert.Same(inner.Clause, result.Commands[0].Clause); + Assert.Same(outer.Clause, result.Commands[1].Clause); + Assert.Contains(outer.Clause.Args, argument => argument.Kind == ArgKind.DynamicSkip); + Assert.All(result.Commands, command => Assert.True(command.IsComplete)); + } + + [Fact] + public void Multiple_and_nested_subexpressions_emit_innermost_first() + { + var siblings = Parse("Write-Output $(Get-Date) $(Get-Location)"); + Assert.Equal(new[] { "Get-Date", "Get-Location", "Write-Output" }, + siblings.Commands.Select(CommandVerb)); + Assert.Equal(2, Assert.IsType( + Assert.Single(siblings.Syntax.Statements)).Substitutions.Count); + + var nested = Parse("Write-Output $(Get-Item $(Get-Location))"); + Assert.Equal(new[] { "Get-Location", "Get-Item", "Write-Output" }, + nested.Commands.Select(CommandVerb)); + Assert.Equal(5, nested.Commands[0].Ancestry.Count); + Assert.Equal(3, nested.Commands[1].Ancestry.Count); + } + + [Theory] + [InlineData("Write-Output pre$(Get-Date)post")] + [InlineData("Write-Output \"pre$(Get-Date)post\"")] + [InlineData("native --output=$(Get-Date)")] + public void Supported_word_forms_discover_subexpressions(string source) + { + var result = Parse(source); + + Assert.False(result.IsUnparseable); + Assert.Equal("Get-Date", CommandVerb(result.Commands[0])); + Assert.True(result.Commands[0].IsComplete); + Assert.Contains(result.Clauses.Last().Args, + argument => argument.Kind == ArgKind.DynamicSkip); + } + + [Fact] + public void Expandable_here_string_discovers_subexpression() + { + var result = Parse("Write-Output @\"\nvalue $(Get-Date)\n\"@"); + + Assert.False(result.IsUnparseable); + Assert.Equal(new[] { "Get-Date", "Write-Output" }, result.Commands.Select(CommandVerb)); + } + + [Theory] + [InlineData("Write-Output '$(Get-Date)'")] + [InlineData("Write-Output \"literal `$(Get-Date)\"")] + [InlineData("Write-Output @'\n$(Get-Date)\n'@")] + public void Literal_subexpression_spellings_do_not_create_occurrences(string source) + { + var result = Parse(source); + + Assert.False(result.IsUnparseable); + Assert.Single(result.Commands); + Assert.Empty(Assert.IsType( + Assert.Single(result.Syntax.Statements)).Substitutions); + } + + [Fact] + public void Standalone_subexpression_exposes_only_its_body_commands() + { + var result = Parse("$(Write-Output Get-Date)"); + + Assert.False(result.IsUnparseable); + Assert.Equal("Write-Output", CommandVerb(Assert.Single(result.Commands))); + Assert.IsType(Assert.Single(result.Syntax.Statements)); + } + + [Theory] + [InlineData("$(Get-Date) argument")] + [InlineData("$(Get-Date) | (Get-Process)")] + public void Unsupported_standalone_subexpression_shapes_fail_closed(string source) + { + var result = Parse(source); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + + [Fact] + public void Call_operator_subexpression_retains_one_incomplete_dynamic_outer_occurrence() + { + var result = Parse("& $(Write-Output Get-Date) argument"); + + Assert.False(result.IsUnparseable); + Assert.Equal(new[] { "Write-Output", "$(Write-Output Get-Date)" }, + result.Commands.Select(CommandVerb)); + Assert.True(result.Commands[0].IsComplete); + Assert.False(result.Commands[1].IsComplete); + Assert.True(result.Commands[1].Clause.Verb.IsDynamic); + } + + [Fact] + public void Interpolated_command_word_remains_dynamic_while_exposing_its_subexpression() + { + var result = Parse("Get-$(Write-Output Content) /etc/passwd"); + + Assert.False(result.IsUnparseable); + Assert.Equal(2, result.Commands.Count); + Assert.Equal("Write-Output", CommandVerb(result.Commands[0])); + Assert.True(result.Commands[1].Clause.Verb.IsDynamic); + Assert.False(result.Commands[1].IsComplete); + } + + [Fact] + public void Subexpression_location_changes_propagate_before_outer_resolution() + { + var result = Parse( + "Write-Output $(Set-Location C:\\sensitive; Get-Location); Get-Item child.txt"); + + Assert.False(result.IsUnparseable); + Assert.Equal(new[] { "Set-Location", "Get-Location", "Write-Output", "Get-Item" }, + result.Commands.Select(CommandVerb)); + Assert.Contains(result.Clauses[1].Args, + argument => argument.IsCwdAttribution && argument.Resolved == "C:/sensitive"); + Assert.Contains(result.Clauses[2].Args, + argument => argument.IsCwdAttribution && argument.Resolved == "C:/sensitive"); + Assert.Contains(result.Clauses[3].Args, + argument => argument.Raw == "child.txt" && + argument.Resolved == "C:/sensitive/child.txt"); + } + + [Fact] + public void Dynamic_subexpression_location_poisons_inner_consumer_and_continuation() + { + var result = Parse( + "Write-Output $(Set-Location $target; Get-Item child.txt); Get-Item sibling.txt"); + + Assert.False(result.IsUnparseable); + Assert.All(result.Clauses.Skip(1), clause => Assert.Contains( + clause.Args, + argument => argument.IsCwdAttribution && argument.Kind == ArgKind.DynamicSkip)); + Assert.DoesNotContain(result.Clauses.SelectMany(clause => clause.Args), + argument => argument.Raw is "child.txt" or "sibling.txt" && + argument.Resolved is not null); + } + + [Fact] + public void Redirect_subexpression_is_visible_while_outer_redirect_stays_incomplete() + { + var result = Parse("Get-Content > $(Join-Path C:\\temp out.txt)"); + + Assert.False(result.IsUnparseable); + Assert.Equal(new[] { "Join-Path", "Get-Content" }, result.Commands.Select(CommandVerb)); + Assert.True(result.Commands[0].IsComplete); + Assert.False(result.Commands[1].IsComplete); + Assert.True(Assert.Single(result.Clauses[1].Redirects).IsDynamicSkip); + } + + [Theory] + [InlineData("Write-Output @(Get-Date)")] + [InlineData("Write-Output @(1; Get-Date)")] + [InlineData("Write-Output @{x=$(Get-Date)}")] + [InlineData("Write-Output @{x=(Get-Date)}")] + [InlineData("Write-Output $(1+1)")] + [InlineData("Write-Output $(1)")] + [InlineData("Write-Output $(1-1)")] + [InlineData("Write-Output $(-1)")] + [InlineData("Write-Output $(1kb)")] + [InlineData("Write-Output $(0x10)")] + [InlineData("Write-Output $(1e3)")] + [InlineData("Write-Output $(1L)")] + [InlineData("Write-Output $(1s)")] + [InlineData("Write-Output $(1us)")] + [InlineData("Write-Output $(1y)")] + [InlineData("Write-Output $(1uy)")] + [InlineData("Write-Output $(0x10s)")] + [InlineData("Write-Output $(,$x)")] + [InlineData("Write-Output $(!$true)")] + [InlineData("Write-Output $(++$x)")] + [InlineData("Write-Output $(-not $true)")] + [InlineData("Write-Output $(-bnot 1)")] + [InlineData("Write-Output $(-$x)")] + [InlineData("Write-Output $(+$x)")] + [InlineData("Write-Output $(-[int]'1')")] + [InlineData("Write-Output $(+[int]'1')")] + [InlineData("Write-Output $(-join @('a','b'))")] + [InlineData("Write-Output $(@(1,2,3))")] + [InlineData("Write-Output $(@{x=1})")] + [InlineData("Write-Output $(@args)")] + [InlineData("Write-Output $({ Get-Date })")] + [InlineData("Write-Output $(Get-Date; 1kb)")] + [InlineData("Write-Output $(Get-Date; ,$x)")] + [InlineData("Write-Output $(Get-Date; @('a'))")] + [InlineData("Write-Output $((1kb))")] + [InlineData("Write-Output $($x)")] + [InlineData("Write-Output $(Get-Date).Property")] + [InlineData("Write-Output $(Get-Date).ToString()")] + public void Unsupported_execution_bearing_expressions_fail_whole(string source) + { + var result = Parse(source); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + + [Theory] + [InlineData("Write-Output $(7z)", "7z")] + [InlineData("Write-Output $(7zip)", "7zip")] + [InlineData("Write-Output $(1.0f)", "1.0f")] + [InlineData("Write-Output $(1.0m)", "1.0m")] + [InlineData("Write-Output $(-foo)", "-foo")] + public void Expression_like_command_names_remain_executable_identities( + string source, + string expectedVerb) + { + var result = Parse(source); + + Assert.False(result.IsUnparseable); + Assert.Equal(expectedVerb, CommandVerb(result.Commands[0])); + } + + [Theory] + [InlineData("Write-Output @(1, 2, 3)")] + [InlineData("New-Item -Path C:\\x @{ Force = $true }")] + public void Proved_literal_at_expressions_remain_opaque_data(string source) + { + var result = Parse(source); + + Assert.False(result.IsUnparseable); + Assert.Single(result.Commands); + Assert.False(Assert.Single(result.Commands).IsComplete); + } + + [Theory] + [InlineData("Write-Output $(Write-Output x # )\nGet-Date)")] + [InlineData("Write-Output $(Write-Output x <# ) #>; Get-Date)")] + [InlineData("Write-Output $(Write-Output abc#def; Get-Date)")] + public void Comment_parentheses_do_not_hide_later_subexpression_commands(string source) + { + var result = Parse(source); + + Assert.False(result.IsUnparseable); + Assert.Equal(new[] { "Write-Output", "Get-Date", "Write-Output" }, + result.Commands.Select(CommandVerb)); + } + + [Theory] + [InlineData("Write-Output $(Write-Output 'x'# )\nGet-Date)")] + [InlineData("Write-Output $($(Write-Output x)# )\nGet-Date)")] + public void Direct_subexpression_comments_after_closed_regions_are_recognized(string source) + { + var result = Parse(source); + + Assert.False(result.IsUnparseable); + Assert.Equal("Get-Date", CommandVerb(result.Commands[^2])); + } + + [Theory] + [InlineData("Write-Output \"$($(Write-Output x)# )\nGet-Date)\"")] + [InlineData("Write-Output @\"\n$($(Write-Output x)# )\nGet-Date)\n\"@")] + public void Comment_bearing_subexpressions_in_expandable_data_fail_closed(string source) + { + var result = Parse(source); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + + [Fact] + public void Expanding_host_payload_keeps_parent_substitution_and_incomplete_outer_host() + { + var result = Parse("pwsh -Command \"Write-Output $(Get-Date)\""); + + Assert.False(result.IsUnparseable); + Assert.Equal(new[] { "Get-Date", "pwsh" }, result.Commands.Select(CommandVerb)); + Assert.True(result.Commands[0].IsComplete); + Assert.False(result.Commands[1].IsComplete); + Assert.IsType(Assert.Single(result.Syntax.Statements)); + } + + [Fact] + public void Literal_host_payload_recurses_and_clears_decoded_substitution_spans() + { + var result = Parse("pwsh -Command 'Write-Output $(Get-Date)'"); + + Assert.False(result.IsUnparseable); + Assert.Equal(new[] { "Get-Date", "Write-Output" }, result.Commands.Select(CommandVerb)); + var wrapper = Assert.IsType(Assert.Single(result.Syntax.Statements)); + var outer = Assert.Single( + Descendants(wrapper.Body).OfType(), + command => command.Clause.Verb.Joined == "Write-Output"); + var substitution = Assert.Single(outer.Substitutions); + Assert.Null(substitution.SourceStart); + Assert.Null(substitution.SourceLength); + Assert.All(Descendants(substitution.Body), node => + { + Assert.Null(node.SourceStart); + Assert.Null(node.SourceLength); + }); + } + [Fact] public void Empty_wrapper_body_with_redirect_preserves_the_compatibility_leaf() { @@ -320,6 +673,12 @@ public void Empty_wrapper_body_with_redirect_preserves_the_compatibility_leaf() WorkingDirectory = "C:/work", }).Parse(source); + private static string CommandVerb(CommandOccurrence command) => command.Clause.Verb.Joined; + + private static string NestedSubstitution(int depth) => + "Write-Output " + string.Concat(Enumerable.Repeat("$(", depth)) + "Get-Date" + + new string(')', depth); + private static IEnumerable Descendants(ShellSyntaxNode node) { yield return node; @@ -361,6 +720,23 @@ private static IEnumerable Descendants(ShellSyntaxNode node) yield return descendant; } + break; + case SimpleCommandSyntax simple: + foreach (var substitution in simple.Substitutions) + { + foreach (var descendant in Descendants(substitution)) + { + yield return descendant; + } + } + + break; + case CommandSubstitutionSyntax substitution: + foreach (var descendant in Descendants(substitution.Body)) + { + yield return descendant; + } + break; } } diff --git a/tests/ShellSyntaxTree.Tests/Parsing/ResolverProvenanceTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/ResolverProvenanceTests.cs index 6b1e9f5..f29c415 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/ResolverProvenanceTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/ResolverProvenanceTests.cs @@ -271,8 +271,9 @@ public void Opaque_command_identity_does_not_become_literal() Assert.True(bash.IsUnparseable); Assert.Empty(bash.Clauses); - var pwshClause = Assert.Single(Pwsh.Parse( - "Get-$(Write-Output Content) /etc/passwd").Clauses); + var pwshResult = Pwsh.Parse("Get-$(Write-Output Content) /etc/passwd"); + Assert.Equal("Write-Output", pwshResult.Clauses[0].Verb.Joined); + var pwshClause = pwshResult.Clauses[1]; Assert.True(pwshClause.Verb.IsDynamic); } diff --git a/tools/PwshCorpusTool/CorpusManifest.cs b/tools/PwshCorpusTool/CorpusManifest.cs index 5056122..927eab4 100644 --- a/tools/PwshCorpusTool/CorpusManifest.cs +++ b/tools/PwshCorpusTool/CorpusManifest.cs @@ -720,5 +720,100 @@ private static string NestIex(string inner, int depth) S("v03_mixed_list_pipeline", "gci | Select-Object Name && Get-Date; Get-Process", "A pipeline followed by && and ; pins statement-versus-pipeline structure."), + + // ---- v0.3 PowerShell command-substitution structure ---- + S("v03_substitution_multiple", + "Write-Output $(Get-Date) $(Get-Location)", + "Sibling subexpressions are projected in authored order before their consumer."), + S("v03_substitution_nested", + "Write-Output $(Get-Item $(Get-Location))", + "Nested subexpressions project innermost commands first without losing parentage."), + S("v03_substitution_standalone", + "$(Write-Output Get-Date)", + "A standalone subexpression exposes its body without inventing an invocation."), + S("v03_substitution_call_operator", + "& $(Write-Output Get-Date) argument", + "The call operator exposes the producer and retains an incomplete dynamic invocation."), + S("v03_substitution_current_scope_cwd", + "Write-Output $(Set-Location C:\\sensitive; Get-Location); Get-Item child.txt", + "Subexpression location changes affect its consumer and following current-scope commands."), + S("v03_substitution_redirect", + "Get-Content > $(Join-Path C:\\temp out.txt)", + "A redirect subexpression is visible while the outer redirect remains incomplete."), + S("v03_substitution_expanding_here_string", + "Write-Output @\"\nvalue $(Get-Date)\n\"@", + "An expanding here-string exposes its command subexpression."), + S("v03_substitution_dynamic_host_payload", + "pwsh -Command \"Write-Output $(Get-Date)\"", + "Parent-scope expansion remains visible beside an incomplete outer pwsh host."), + S("v03_substitution_static_host_payload", + "pwsh -Command 'Write-Output $(Get-Date)'", + "A literal child payload is decoded and its child-scope substitution is exposed."), + S("v03_substitution_direct_comment_boundary", + "Write-Output $(Write-Output 'x'# )\nGet-Date)", + "A comment after a closed quoted argument cannot terminate the containing subexpression."), + Oos("v03_substitution_array_execution_gated", + "Write-Output @(Get-Date)", + "Execution-bearing array subexpressions fail closed until their expression grammar is modeled."), + Oos("v03_substitution_hash_execution_gated", + "Write-Output @{ value = $(Get-Date) }", + "Execution-bearing hash literals fail closed instead of hiding their command."), + Oos("v03_substitution_numeric_expression_gated", + "Write-Output $(1-1)", + "Numeric expression bodies are not fabricated into command occurrences."), + Oos("v03_substitution_member_suffix_gated", + "Write-Output $(Get-Date).ToString()", + "Member access after a subexpression fails the whole result until expression suffixes are modeled."), + Oos("v03_substitution_inner_here_string_gated", + "Write-Output $(Write-Output @\"\nvalue\n\"@)", + "Here-strings nested inside a subexpression remain a conservative grammar boundary."), + E("v03_substitution_invalid_expandable_comment", + "Write-Output \"$($(Write-Output x)# )\nGet-Date)\"", + "Real PowerShell rejects comment termination inside this expandable-string subexpression."), + Oos("v03_substitution_numeric_suffix_gated", + "Write-Output $(1kb)", + "PowerShell numeric suffixes are expression values, not command identities."), + Oos("v03_substitution_hex_literal_gated", + "Write-Output $(0x10)", + "PowerShell hexadecimal literals are not fabricated into commands."), + Oos("v03_substitution_unary_comma_gated", + "Write-Output $(,$x)", + "Unary comma creates an expression result and cannot be interpreted as a command."), + Oos("v03_substitution_unary_join_gated", + "Write-Output $(-join @('a','b'))", + "Unary expression operators remain outside the command grammar."), + Oos("v03_substitution_statement_array_gated", + "Write-Output $(@(1,2,3))", + "A literal array at statement position is data rather than an executable identity."), + Oos("v03_substitution_later_expression_gated", + "Write-Output $(Get-Date; 1kb)", + "Every statement in a subexpression is checked for the command-versus-expression boundary."), + Oos("v03_substitution_grouped_expression_gated", + "Write-Output $((1kb))", + "Grouping cannot bypass expression rejection inside a subexpression."), + Oos("v03_substitution_scriptblock_value_gated", + "Write-Output $({ Get-Date })", + "A script-block value is not executed merely by occupying subexpression statement position."), + S("v03_substitution_digit_command_identity", + "Write-Output $(7z)", + "A real digit-leading executable name remains distinct from bounded numeric literals."), + S("v03_substitution_dynamic_cwd_poisoning", + "Write-Output $(Set-Location $target; Get-Item child.txt); Get-Item sibling.txt", + "Unknown current-scope location changes poison inner, consumer, and continuation attribution."), + Oos("v03_substitution_integer_width_suffix_gated", + "Write-Output $(0x10s)", + "PowerShell integer-width suffixes produce numeric values rather than executable identities."), + Oos("v03_substitution_unary_bnot_gated", + "Write-Output $(-bnot 1)", + "Unary bitwise negation is an expression and cannot produce a command occurrence."), + S("v03_substitution_decimal_command_identity", + "Write-Output $(1.0f)", + "A decimal-looking name without a PowerShell numeric type is still an executable identity."), + Oos("v03_substitution_unary_variable_gated", + "Write-Output $(-$x)", + "A sign applied to a variable is an expression rather than a sign-leading command name."), + S("v03_substitution_dash_command_identity", + "Write-Output $(-foo)", + "A dash-leading word remains an executable identity when it is not a unary expression operand."), }; }