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."),
};
}