diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index d519315..dfc7be9 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 341 entries and round-trips exactly; + The PowerShell manifest now owns all 361 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 @@ -303,12 +303,25 @@ priorities. transfers, and occurrence-specific redirect values remain fail closed. Next add the Netclaw approval matrix before calling the Bash consumer integration complete. -- [ ] Complete PowerShell `$()` discovery in `foreach` expressions and add the - Netclaw approval-matrix cases. The simple-command slice is delivered for +- [ ] Complete PowerShell `foreach` value and state analysis and add the + Netclaw approval-matrix cases. The structural slice now preserves literal + scalar/array and executable iterator forms, recursively parses bodies, + projects iterator and loop-body ancestry, survives decoded wrappers, and + fails closed on dynamic iterables, iterator/body state or + command-resolution mutation, malformed boundaries, and depth overflow. + Loop-body and current-scope post-loop occurrences intentionally remain + incomplete; isolated child-host loops do not taint their outer continuation. + Before publishing exact or finite values, add an explicit PowerShell + initial-runspace contract and wrapper-state metadata: ambient typed, + read-only, scoped, alias, function, and module state can change binding + assignment and command resolution, while child hosts inherit no fresh + state guarantee unless their own invocation proves it. Expand the design + corpus for cardinality, mutation, separators, wrappers, redirects, and + transition caps before tasks 7.3-7.7. 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. + by the 361-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 d955db2..42e6973 100644 --- a/SPEC.POWERSHELL.md +++ b/SPEC.POWERSHELL.md @@ -360,15 +360,25 @@ quoted_string := single_quoted | double_quoted PowerShell retains a statement-versus-pipeline distinction. `foreach` is a language keyword only at statement position when followed by `(`; -`Get-ChildItem | foreach { ... }` remains command/alias syntax and its ordinary -script-block argument remains opaque. +`Get-ChildItem | foreach { ... }` and +`Write-Output x | foreach ($_)` remain command/alias syntax. An ordinary +script-block or bounded non-executing parenthesized argument remains opaque; it +is not reinterpreted as a loop body. `&&` and `||` join pipelines, not +control-flow statements, so +they cannot precede or follow `foreach`; `;` and newline remain legal statement +terminators. ```text -pwsh_script(stop) := pwsh_statement (statement_sep pwsh_statement)* +pwsh_script(stop) := pwsh_statement (statement_terminator pwsh_statement)* pwsh_statement := pwsh_foreach | pwsh_while | pwsh_if - | pwsh_pipeline + | pwsh_and_or + +statement_terminator := ";" | NEWLINE +pwsh_and_or := pwsh_pipeline + (("&&" | "||") pwsh_pipeline)* +pwsh_pipeline := pipeline_element ("|" pipeline_element)* pwsh_foreach := "foreach" "(" variable "in" foreach_expression ")" script_block_body diff --git a/openspec/changes/v0-3-structured-shell-analysis/design.md b/openspec/changes/v0-3-structured-shell-analysis/design.md index a52e18c..43ee66b 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/design.md +++ b/openspec/changes/v0-3-structured-shell-analysis/design.md @@ -1297,14 +1297,22 @@ evidence. PowerShell retains its statement-versus-pipeline distinction and contextual keyword rules. In particular, `foreach` is a language keyword only at a statement position when followed by `(`; `Get-ChildItem | foreach { ... }` -continues to treat `foreach` as command or alias syntax. +and `Write-Output x | foreach ($_)` continue to treat `foreach` as command or +alias syntax when the parenthesized argument is proved non-executing. `&&` and +`||` join pipelines rather than arbitrary control-flow +statements, so only `;` and newline terminate a statement around `foreach`. ```text -pwsh_script(stop) := pwsh_statement (statement_sep pwsh_statement)* +pwsh_script(stop) := pwsh_statement (statement_terminator pwsh_statement)* pwsh_statement := pwsh_foreach | pwsh_while | pwsh_if - | pwsh_pipeline + | pwsh_and_or + +statement_terminator := ";" | NEWLINE +pwsh_and_or := pwsh_pipeline + (("&&" | "||") pwsh_pipeline)* +pwsh_pipeline := pipeline_element ("|" pipeline_element)* pwsh_foreach := "foreach" "(" variable "in" foreach_expression ")" script_block_body diff --git a/openspec/changes/v0-3-structured-shell-analysis/specs/structured-shell-syntax/spec.md b/openspec/changes/v0-3-structured-shell-analysis/specs/structured-shell-syntax/spec.md index 0af7d0b..0fd1108 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/specs/structured-shell-syntax/spec.md +++ b/openspec/changes/v0-3-structured-shell-analysis/specs/structured-shell-syntax/spec.md @@ -127,6 +127,17 @@ without treating the script block as one opaque argument. - **THEN** the iterable preserves the two literal array elements - **THEN** the body contains one simple command for `Remove-Item` +#### Scenario: Foreach remains contextual in a pipeline command slot +- **WHEN** PowerShell parses `Write-Output x | foreach ($_)` +- **THEN** `foreach` remains an ordinary command or alias pipeline stage +- **THEN** no loop node is invented +- **THEN** the parenthesized argument remains opaque + +#### Scenario: Foreach requires a statement boundary +- **WHEN** PowerShell parses a `foreach` statement adjacent to another command +- **THEN** `;` or newline may terminate the statement +- **THEN** `|`, `&&`, and `||` do not admit the `foreach` statement as a pipeline element + ### Requirement: Shared loop structure does not erase shell grammar `ForEachSyntax` SHALL preserve the normalized binding name, the authored binding source, the raw iterable source fragment, commands discovered in the diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md index 2ee58f8..108addc 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -117,8 +117,17 @@ ## 7. PowerShell Foreach Vertical Slice -- [ ] 7.1 Parse PowerShell `foreach` with literal scalar and array iterables into the locked structural nodes. -- [ ] 7.2 Emit iterator and loop-body occurrences plus conservative compatibility clauses. +- [x] 7.1 Parse PowerShell `foreach` with literal scalar and array iterables into the locked structural nodes. + - Direct tests pin exact spans, nested structure, decoded-wrapper nullable + spans, contextual alias collisions, statement boundaries, malformed forms, + and the shared structural-depth cap. +- [x] 7.2 Emit iterator and loop-body occurrences plus conservative compatibility clauses. + - Iterator pipelines and direct `$()` are recursively visible with authored + roles and ancestry. Every loop-body occurrence remains incomplete until + tasks 7.3 and 7.4 prove binding values and runspace state; recognized + iterator/body state or command-resolution mutation and dynamic invocation + fail atomically. Current-scope continuations after a loop remain incomplete; + isolated child-host loops do not taint their outer continuation. - [ ] 7.3 Derive exact and finite string domains without treating pipeline objects as literal strings. - [ ] 7.4 Propagate PowerShell scope and location state according to the locked statement semantics. - [ ] 7.5 Cover aliases, cmdlets, native commands, nested loops, pipelines, script blocks, and wrapper boundaries. diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs index 6bea308..af6d13f 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs @@ -283,11 +283,10 @@ private static bool TryDetectKeywordAnomaly(IReadOnlyList tokens, out { // `foreach (` is the loop keyword; `foreach {` is the // ForEach-Object alias (ยง6.3 collision rule). + // The structural coordinator owns the supported statement + // form and rejects malformed headers atomically. if (NextSignificantIsOpenParen(tokens, i)) - { - reason = "control-flow keyword 'foreach' is not supported in v0.2"; - return true; - } + continue; } else { diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachStructuralParser.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachStructuralParser.cs new file mode 100644 index 0000000..1ea749d --- /dev/null +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachStructuralParser.cs @@ -0,0 +1,537 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System; +using System.Collections.Generic; +using ShellSyntaxTree.Internal.Pwsh.Lexing; +using ShellSyntaxTree.Internal.Resolving; + +namespace ShellSyntaxTree.Internal.Pwsh.Parsing; + +internal static partial class PwshCommandParser +{ + private sealed partial class StructuralCoordinator + { + private bool IsForEachStart() => + _position + 1 < _tokens.Count && + _tokens[_position].Kind == PwshTokenKind.Word && + string.Equals( + _tokens[_position].Value, + "foreach", + StringComparison.OrdinalIgnoreCase) && + _tokens[_position + 1].Kind == PwshTokenKind.Operator && + _tokens[_position + 1].OperatorText == "("; + + private bool TryParseForEach( + CompoundOperator compatibilityOperator, + out ShellSyntaxNode? command, + out string? error) + { + command = null; + if (compatibilityOperator is not CompoundOperator.None and + not CompoundOperator.Sequence) + { + error = "a PowerShell foreach statement requires a statement boundary"; + return false; + } + + if (_structuralDepth + _groupDepth >= ShellAnalysisLimits.MaxStructuralNesting) + { + error = "PowerShell structural nesting depth exceeded (>16)"; + return false; + } + + var start = _tokens[_position]; + _position += 2; + if (_position == _tokens.Count || + !TryReadSimpleLoopBinding(_tokens[_position], out var bindingName)) + { + error = "PowerShell foreach requires a simple variable binding"; + return false; + } + + var bindingToken = _tokens[_position++]; + if (_position == _tokens.Count || + _tokens[_position].Kind != PwshTokenKind.Word || + !string.Equals( + _tokens[_position].Value, + "in", + StringComparison.OrdinalIgnoreCase)) + { + error = "PowerShell foreach binding is missing 'in'"; + return false; + } + + _position++; + var iterableStart = _position; + if (!TryFindForEachHeaderClose(iterableStart, out var closePosition) || + closePosition == iterableStart) + { + error = "PowerShell foreach requires a bounded iterable expression"; + return false; + } + + var iterableTokens = CopyTokens(iterableStart, closePosition); + var firstIterable = iterableTokens[0]; + var lastIterable = iterableTokens[iterableTokens.Count - 1]; + var iterableSourceStart = firstIterable.SourceStart; + var iterableSourceLength = lastIterable.SourceStart + + lastIterable.SourceLength - iterableSourceStart; + if (!TryParseForEachIterator( + iterableTokens, + iterableSourceStart, + iterableSourceLength, + out var iteratorCommands, + out error)) + { + return false; + } + + _position = closePosition + 1; + if (_position == _tokens.Count || + _tokens[_position].Kind != PwshTokenKind.ScriptBlock) + { + error = "PowerShell foreach requires a script-block body"; + return false; + } + + var bodyToken = _tokens[_position++]; + if (!TryParseForEachBody(bodyToken, out var body, out error)) + { + return false; + } + + if (ContainsUnsupportedForEachStateTransfer(iteratorCommands) || + ContainsUnsupportedForEachStateTransfer(body)) + { + error = "PowerShell foreach state mutation or dynamic invocation is not supported in this structural slice"; + return false; + } + + command = new ForEachSyntax + { + Binding = new LoopBindingSyntax + { + Name = bindingName, + Source = new ShellSourceFragment + { + Raw = _source.Substring( + bindingToken.SourceStart, + bindingToken.SourceLength), + SourceStart = bindingToken.SourceStart, + SourceLength = bindingToken.SourceLength, + }, + }, + Iterable = new ShellSourceFragment + { + Raw = _source.Substring(iterableSourceStart, iterableSourceLength), + SourceStart = iterableSourceStart, + SourceLength = iterableSourceLength, + }, + IteratorCommands = iteratorCommands, + Body = body, + SourceStart = start.SourceStart, + SourceLength = bodyToken.SourceStart + bodyToken.SourceLength - + start.SourceStart, + }; + error = null; + return true; + } + + private bool TryFindForEachHeaderClose(int start, out int closePosition) + { + var depth = 1; + for (var index = start; index < _tokens.Count; index++) + { + var token = _tokens[index]; + if (token.Kind != PwshTokenKind.Operator) + { + continue; + } + + if (token.OperatorText == "(") + { + depth++; + } + else if (token.OperatorText == ")" && --depth == 0) + { + closePosition = index; + return true; + } + } + + closePosition = -1; + return false; + } + + private bool TryParseForEachIterator( + IReadOnlyList tokens, + int sourceStart, + int sourceLength, + out ShellBlockSyntax iterator, + out string? error) + { + if (IsLiteralForEachExpression(tokens)) + { + iterator = new ShellBlockSyntax + { + SourceStart = sourceStart, + SourceLength = sourceLength, + }; + error = null; + return true; + } + + if (tokens.Count == 1 && tokens[0].Kind == PwshTokenKind.Subexpression && + tokens[0].Value.StartsWith("$(", StringComparison.Ordinal)) + { + if (tokens[0].ResolverValue is null) + { + iterator = new ShellBlockSyntax(); + error = "PowerShell foreach subexpression lacks value provenance"; + return false; + } + + ShellValueFragment? fragment = null; + foreach (var candidate in tokens[0].ResolverValue!.Fragments) + { + if (candidate.SourceStart == tokens[0].SourceStart && + candidate.SourceLength == tokens[0].SourceLength) + { + fragment = candidate; + break; + } + } + + error = "PowerShell foreach subexpression lacks exact provenance"; + if (fragment is null || + !TryParseStandaloneSubstitution( + fragment.Value, + CompoundOperator.None, + out var substitution, + out error)) + { + iterator = new ShellBlockSyntax(); + return false; + } + + iterator = new ShellBlockSyntax + { + Statements = new[] { substitution! }, + SourceStart = sourceStart, + SourceLength = sourceLength, + }; + return true; + } + + if (tokens[0].Kind == PwshTokenKind.Splat || + tokens[0].Kind == PwshTokenKind.Word && + tokens[0].Value.StartsWith("$", StringComparison.Ordinal) || + tokens[0].Kind == PwshTokenKind.Operator && + tokens[0].OperatorText == "&") + { + iterator = new ShellBlockSyntax(); + error = "dynamic PowerShell foreach iterables are not supported"; + return false; + } + + var iteratorSource = _source.Substring(sourceStart, sourceLength); + var firstValue = tokens[0].Value.TrimEnd(','); + if (tokens.Count > 1 && + (tokens[0].Kind == PwshTokenKind.QuotedString || + firstValue.Length > 0 && IsNumericExpressionWord(firstValue))) + { + iterator = new ShellBlockSyntax(); + error = "unsupported PowerShell foreach literal-list expression"; + return false; + } + + if (IsUnsupportedSubstitutionBody(iteratorSource, tokens)) + { + iterator = new ShellBlockSyntax(); + error = "unsupported PowerShell foreach expression"; + return false; + } + + var coordinator = new StructuralCoordinator( + _source, + tokens, + _options, + _recursionDepth, + _structuralDepth + _groupDepth + 1, + _markWrapped, + _attribution, + sourceStart, + sourceLength, + CompoundOperator.None, + insideCommandSubstitution: false); + return coordinator.TryParse(out iterator, out error); + } + + private bool TryParseForEachBody( + PwshToken bodyToken, + out ShellBlockSyntax body, + out string? error) + { + if (bodyToken.Value.Length < 2 || + bodyToken.Value[0] != '{' || + bodyToken.Value[bodyToken.Value.Length - 1] != '}') + { + body = new ShellBlockSyntax(); + error = "PowerShell foreach script-block provenance is invalid"; + return false; + } + + var sourceStart = bodyToken.SourceStart + 1; + var sourceLength = bodyToken.SourceLength - 2; + 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, + CompoundOperator.None, + insideCommandSubstitution: false); + return coordinator.TryParse(out body, out error); + } + + private static bool TryReadSimpleLoopBinding(PwshToken token, out string name) + { + name = string.Empty; + if (token.Kind != PwshTokenKind.Word || token.Value.Length < 2 || + token.Value[0] != '$' || !IsVariableNameStart(token.Value[1])) + { + return false; + } + + for (var index = 2; index < token.Value.Length; index++) + { + if (!IsVariableNamePart(token.Value[index])) + { + return false; + } + } + + name = token.Value.Substring(1); + return true; + } + + private static bool IsVariableNameStart(char value) => + value == '_' || value is >= 'A' and <= 'Z' or >= 'a' and <= 'z'; + + private static bool IsVariableNamePart(char value) => + IsVariableNameStart(value) || value is >= '0' and <= '9'; + + private static bool IsLiteralForEachExpression(IReadOnlyList tokens) + { + if (tokens.Count != 1) + { + return false; + } + + var token = tokens[0]; + if (token.Kind == PwshTokenKind.QuotedString) + { + return !token.HasInterpolation; + } + + if (token.Kind == PwshTokenKind.Subexpression) + { + return token.Value.StartsWith("@(", StringComparison.Ordinal) && + IsLiteralArrayExpression(token.Value); + } + + if (token.Kind != PwshTokenKind.Word) + { + return false; + } + + return IsNumericExpressionWord(token.Value) || + string.Equals(token.Value, "$true", StringComparison.OrdinalIgnoreCase) || + string.Equals(token.Value, "$false", StringComparison.OrdinalIgnoreCase) || + string.Equals(token.Value, "$null", StringComparison.OrdinalIgnoreCase); + } + + private static bool ContainsUnsupportedForEachStateTransfer(ShellSyntaxNode node) + { + foreach (var clause in EnumerateClauses(node)) + { + if (clause.Verb.IsDynamic) + { + return true; + } + + var verb = clause.Verb.CanonicalVerb ?? + (clause.Verb.Tokens.Count == 0 ? null : clause.Verb.Tokens[0]); + if (verb is not null && + (IsUnsupportedForEachStateVerb(verb) || + IsProviderStateMutation(verb, clause))) + { + return true; + } + } + + return false; + } + + private static bool IsUnsupportedForEachStateVerb(string verb) => + verb.Equals("Set-Location", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Push-Location", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Pop-Location", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Set-Variable", StringComparison.OrdinalIgnoreCase) || + verb.Equals("New-Variable", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Remove-Variable", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Clear-Variable", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Set-Alias", StringComparison.OrdinalIgnoreCase) || + verb.Equals("New-Alias", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Remove-Alias", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Import-Module", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Remove-Module", StringComparison.OrdinalIgnoreCase) || + verb.Equals("New-PSDrive", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Remove-PSDrive", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Set-StrictMode", StringComparison.OrdinalIgnoreCase); + + private static bool IsProviderStateMutation(string verb, Clause clause) + { + if (!verb.Equals("Set-Item", StringComparison.OrdinalIgnoreCase) && + !verb.Equals("New-Item", StringComparison.OrdinalIgnoreCase) && + !verb.Equals("Remove-Item", StringComparison.OrdinalIgnoreCase) && + !verb.Equals("Rename-Item", StringComparison.OrdinalIgnoreCase) && + !verb.Equals("Move-Item", StringComparison.OrdinalIgnoreCase) && + !verb.Equals("Copy-Item", StringComparison.OrdinalIgnoreCase) && + !verb.Equals("Clear-Item", StringComparison.OrdinalIgnoreCase) && + !verb.Equals("Set-Content", StringComparison.OrdinalIgnoreCase) && + !verb.Equals("Add-Content", StringComparison.OrdinalIgnoreCase) && + !verb.Equals("Clear-Content", StringComparison.OrdinalIgnoreCase) && + !verb.Equals("Remove-Content", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + foreach (var element in clause.Elements) + { + if (element.Role == ClauseElementRole.Argument && + (IsMutableStateProviderPath(element.Value) || + IsMutableStateProviderPath(element.Raw) || + element.Resolved is not null && + IsMutableStateProviderPath(element.Resolved))) + { + return true; + } + } + + return false; + } + + private static bool IsMutableStateProviderPath(string value) + { + var providerStart = value.LastIndexOf('\\') + 1; + var providerPath = value.Substring(providerStart); + return providerPath.StartsWith("Alias:", StringComparison.OrdinalIgnoreCase) || + providerPath.StartsWith("Function:", StringComparison.OrdinalIgnoreCase) || + providerPath.StartsWith("Variable:", StringComparison.OrdinalIgnoreCase) || + providerPath.StartsWith("Environment:", StringComparison.OrdinalIgnoreCase) || + providerPath.StartsWith("Env:", StringComparison.OrdinalIgnoreCase); + } + + private static IEnumerable EnumerateClauses(ShellSyntaxNode node) + { + switch (node) + { + case SimpleCommandSyntax simple: + yield return simple.Clause; + foreach (var substitution in simple.Substitutions) + { + foreach (var clause in EnumerateClauses(substitution)) + { + yield return clause; + } + } + + yield break; + case ShellBlockSyntax block: + foreach (var statement in block.Statements) + { + foreach (var clause in EnumerateClauses(statement)) + { + yield return clause; + } + } + + yield break; + case CommandListSyntax list: + foreach (var item in list.Items) + { + foreach (var clause in EnumerateClauses(item.Command)) + { + yield return clause; + } + } + + yield break; + case PipelineSyntax pipeline: + foreach (var stage in pipeline.Stages) + { + foreach (var clause in EnumerateClauses(stage)) + { + yield return clause; + } + } + + yield break; + case GroupSyntax group: + foreach (var clause in EnumerateClauses(group.Body)) + { + yield return clause; + } + + yield break; + case ForEachSyntax forEach: + foreach (var clause in EnumerateClauses(forEach.IteratorCommands)) + { + yield return clause; + } + + foreach (var clause in EnumerateClauses(forEach.Body)) + { + yield return clause; + } + + yield break; + case CommandSubstitutionSyntax substitution: + foreach (var clause in EnumerateClauses(substitution.Body)) + { + yield return clause; + } + + yield break; + } + } + } +} diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs index 77891cc..0d71bc0 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs @@ -39,11 +39,13 @@ private static ParsedCommand ParseStructured( return StructuralFailure(source, error, syntax); } + var incompleteForEachClauses = CollectIncompleteForEachClauses(syntax); if (!ShellSyntaxProjection.TryProject( syntax, simple => new CommandOccurrenceFacts { - IsComplete = IsStructurallyComplete(simple), + IsComplete = IsStructurallyComplete(simple) && + !ContainsReference(incompleteForEachClauses, simple.Clause), }, out var projection)) { @@ -75,7 +77,7 @@ private static ParsedCommand StructuralFailure( UnparseableReason = reason, }; - private sealed class StructuralCoordinator + private sealed partial class StructuralCoordinator { private readonly string _source; private readonly IReadOnlyList _tokens; @@ -194,6 +196,13 @@ private bool TryParseList( return false; } + if (items[items.Count - 1].Command is ForEachSyntax && + listOperator is CompoundOperator.AndIf or CompoundOperator.OrIf) + { + error = "a PowerShell foreach statement cannot participate in an && or || chain"; + return false; + } + SkipNewlines(); if (_position == _tokens.Count) { @@ -250,6 +259,12 @@ private bool TryParsePipeline( return false; } + if (first is ForEachSyntax && IsOperator("|")) + { + error = "a PowerShell foreach statement cannot produce a pipeline stage"; + return false; + } + var stages = new List { first! }; while (IsOperator("|")) { @@ -309,6 +324,11 @@ private bool TryParseCommand( return TryParseGroup(compatibilityOperator, out command, out error); } + if (compatibilityOperator != CompoundOperator.Pipe && IsForEachStart()) + { + return TryParseForEach(compatibilityOperator, out command, out error); + } + if (IsOperator(")") || IsListOperator(_tokens[_position]) || IsOperator("|")) { error = $"unexpected operator at position {_tokens[_position].SourceStart}"; @@ -339,7 +359,10 @@ private bool TryParseCommand( } if (token.Kind == PwshTokenKind.Operator && token.OperatorText == "(" && - StartsWithInvokeExpression(CopyTokens(start, _position))) + (StartsWithInvokeExpression(CopyTokens(start, _position)) || + IsForEachCommandArgument( + CopyTokens(start, _position), + compatibilityOperator))) { expressionDepth = 1; _position++; @@ -367,6 +390,8 @@ private bool TryParseCommand( return false; } + CollapseSafeForEachCommandArgument(segmentTokens, compatibilityOperator); + if (_insideCommandSubstitution) { var firstSegmentToken = segmentTokens[0]; @@ -504,6 +529,84 @@ private bool TryParseCommand( return true; } + private static bool IsForEachCommandArgument( + IReadOnlyList prefix, + CompoundOperator compatibilityOperator) + { + if (prefix.Count == 1 && compatibilityOperator == CompoundOperator.Pipe) + { + return prefix[0].Kind == PwshTokenKind.Word && + string.Equals( + prefix[0].Value, + "foreach", + StringComparison.OrdinalIgnoreCase); + } + + return prefix.Count == 2 && + prefix[0].Kind == PwshTokenKind.Operator && + prefix[0].OperatorText == "&" && + prefix[1].Kind == PwshTokenKind.Word && + string.Equals( + prefix[1].Value, + "foreach", + StringComparison.OrdinalIgnoreCase); + } + + private void CollapseSafeForEachCommandArgument( + List tokens, + CompoundOperator compatibilityOperator) + { + var openIndex = compatibilityOperator == CompoundOperator.Pipe ? 1 : 2; + if (tokens.Count != openIndex + 3 || + !IsForEachCommandArgument(tokens.GetRange(0, openIndex), compatibilityOperator) || + tokens[openIndex].Kind != PwshTokenKind.Operator || + tokens[openIndex].OperatorText != "(" || + !IsSafeOpaqueForEachCommandArgument(tokens[openIndex + 1]) || + tokens[openIndex + 2].Kind != PwshTokenKind.Operator || + tokens[openIndex + 2].OperatorText != ")") + { + return; + } + + var open = tokens[openIndex]; + var close = tokens[openIndex + 2]; + var length = close.SourceStart + close.SourceLength - open.SourceStart; + var raw = _source.Substring(open.SourceStart, length); + tokens.RemoveRange(openIndex, 3); + tokens.Insert( + openIndex, + new PwshToken( + PwshTokenKind.Word, + raw, + null, + open.SourceStart, + length, + null) + { + ResolverValue = ShellValue.Opaque( + raw, + ShellOpaqueCause.Unsupported, + open.SourceStart, + length), + }); + } + + private static bool IsSafeOpaqueForEachCommandArgument(PwshToken token) + { + if (token.Kind == PwshTokenKind.QuotedString) + { + return !token.HasInterpolation; + } + + if (token.Kind != PwshTokenKind.Word) + { + return false; + } + + return token.Value.StartsWith("$", StringComparison.Ordinal) || + IsNumericExpressionWord(token.Value); + } + private bool TryParseGroup( CompoundOperator compatibilityOperator, out ShellSyntaxNode? command, @@ -1174,6 +1277,145 @@ private bool TryParseSubstitutionBody( } } + private static IReadOnlyList CollectIncompleteForEachClauses( + ShellSyntaxNode syntax) + { + var clauses = new List(); + CollectIncompleteForEachClauses(syntax, isStateIncomplete: false, clauses); + return clauses; + } + + private static bool CollectIncompleteForEachClauses( + ShellSyntaxNode syntax, + bool isStateIncomplete, + List clauses) + { + switch (syntax) + { + case SimpleCommandSyntax simple: + foreach (var substitution in simple.Substitutions) + { + isStateIncomplete = CollectIncompleteForEachClauses( + substitution, + isStateIncomplete, + clauses); + } + + if (isStateIncomplete) + { + clauses.Add(simple.Clause); + } + + return isStateIncomplete; + case ShellBlockSyntax block: + foreach (var statement in block.Statements) + { + isStateIncomplete = CollectIncompleteForEachClauses( + statement, + isStateIncomplete, + clauses); + } + + return isStateIncomplete; + case CommandListSyntax list: + foreach (var item in list.Items) + { + isStateIncomplete = CollectIncompleteForEachClauses( + item.Command, + isStateIncomplete, + clauses); + } + + return isStateIncomplete; + case PipelineSyntax pipeline: + foreach (var stage in pipeline.Stages) + { + isStateIncomplete = CollectIncompleteForEachClauses( + stage, + isStateIncomplete, + clauses); + } + + return isStateIncomplete; + case GroupSyntax group: + var groupState = CollectIncompleteForEachClauses( + group.Body, + isStateIncomplete, + clauses); + return group.GroupKind == ShellGroupKind.IsolatedScope + ? isStateIncomplete + : groupState; + case ForEachSyntax forEach: + CollectIncompleteForEachClauses( + forEach.IteratorCommands, + isStateIncomplete, + clauses); + CollectIncompleteForEachClauses( + forEach.Body, + isStateIncomplete: true, + clauses); + return true; + case ConditionLoopSyntax loop: + CollectIncompleteForEachClauses( + loop.Condition, + isStateIncomplete, + clauses); + CollectIncompleteForEachClauses( + loop.Body, + isStateIncomplete: true, + clauses); + return true; + case ConditionalSyntax conditional: + var branchState = isStateIncomplete; + foreach (var branch in conditional.Branches) + { + branchState |= CollectIncompleteForEachClauses( + branch, + isStateIncomplete, + clauses); + } + + if (conditional.Else is not null) + { + branchState |= CollectIncompleteForEachClauses( + conditional.Else, + isStateIncomplete, + clauses); + } + + return branchState; + case ConditionalBranchSyntax branch: + var conditionState = CollectIncompleteForEachClauses( + branch.Condition, + isStateIncomplete, + clauses); + return CollectIncompleteForEachClauses( + branch.Body, + conditionState, + clauses); + case CommandSubstitutionSyntax substitution: + return CollectIncompleteForEachClauses( + substitution.Body, + isStateIncomplete, + clauses); + default: + return isStateIncomplete; + } + } + + private static bool ContainsReference(IReadOnlyList clauses, Clause target) + { + foreach (var clause in clauses) + { + if (ReferenceEquals(clause, target)) + { + return true; + } + } + + return false; + } + private static IReadOnlyList ShiftTokens( IReadOnlyList tokens, int sourceOffset) diff --git a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs index febb18c..9ecea60 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs +++ b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs @@ -109,6 +109,7 @@ private static void AssertAuthoredTokenCoverage( || pwshDirectSegments.Contains(pwshSegment) || isRedirectOperator || isRedirectTarget) && + !IsPwshForEachStructuralToken(parsed.Syntax, token) && !standaloneSubstitutions.Any(region => region.Start <= token.SourceStart && region.Start + region.Length >= @@ -204,6 +205,81 @@ forEach.Iterable.SourceLength is null || token.SourceStart == bodyEnd && tokenEnd == loopEnd); } + private static bool IsPwshForEachStructuralToken( + ShellSyntaxNode node, + PwshToken token) + { + if (node is ForEachSyntax forEach && + IsPwshForEachStructuralToken(forEach, token)) + { + return true; + } + + return node switch + { + ShellBlockSyntax block => block.Statements.Any( + child => IsPwshForEachStructuralToken(child, token)), + SimpleCommandSyntax simple => simple.Substitutions.Any( + child => IsPwshForEachStructuralToken(child, token)), + PipelineSyntax pipeline => pipeline.Stages.Any( + child => IsPwshForEachStructuralToken(child, token)), + CommandListSyntax list => list.Items.Any( + item => IsPwshForEachStructuralToken(item.Command, token)), + GroupSyntax group => IsPwshForEachStructuralToken(group.Body, token), + ForEachSyntax nested => + IsPwshForEachStructuralToken(nested.IteratorCommands, token) || + IsPwshForEachStructuralToken(nested.Body, token), + ConditionLoopSyntax loop => + IsPwshForEachStructuralToken(loop.Condition, token) || + IsPwshForEachStructuralToken(loop.Body, token), + ConditionalSyntax conditional => conditional.Branches.Any( + branch => IsPwshForEachStructuralToken(branch, token)) || + conditional.Else is not null && + IsPwshForEachStructuralToken(conditional.Else, token), + ConditionalBranchSyntax branch => + IsPwshForEachStructuralToken(branch.Condition, token) || + IsPwshForEachStructuralToken(branch.Body, token), + CommandSubstitutionSyntax substitution => + IsPwshForEachStructuralToken(substitution.Body, token), + _ => false, + }; + } + + private static bool IsPwshForEachStructuralToken( + ForEachSyntax forEach, + PwshToken token) + { + if (forEach.SourceStart is null || forEach.SourceLength is null || + forEach.Binding.Source.SourceStart is null || + forEach.Binding.Source.SourceLength is null || + forEach.Iterable.SourceStart is null || + forEach.Iterable.SourceLength is null || + forEach.Body.SourceStart is null || forEach.Body.SourceLength is null) + { + return false; + } + + var tokenEnd = token.SourceStart + token.SourceLength; + var bindingStart = forEach.Binding.Source.SourceStart.Value; + var bindingEnd = bindingStart + forEach.Binding.Source.SourceLength.Value; + var iterableStart = forEach.Iterable.SourceStart.Value; + var iterableEnd = iterableStart + forEach.Iterable.SourceLength.Value; + var bodyStart = forEach.Body.SourceStart.Value; + var bodyEnd = bodyStart + forEach.Body.SourceLength.Value; + if (token.SourceStart >= iterableStart && tokenEnd <= iterableEnd || + token.SourceStart == bindingStart && tokenEnd == bindingEnd || + token.SourceStart == bodyStart - 1 && tokenEnd == bodyEnd + 1) + { + return true; + } + + return token.Kind == PwshTokenKind.Word && + (string.Equals(token.Value, "foreach", StringComparison.OrdinalIgnoreCase) && + token.SourceStart == forEach.SourceStart || + string.Equals(token.Value, "in", StringComparison.OrdinalIgnoreCase) && + token.SourceStart >= bindingEnd && tokenEnd <= iterableStart); + } + private static IReadOnlyList StandaloneSubstitutionRegions( ShellSyntaxNode syntax) { diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/185_unparseable_foreach.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/185_unparseable_foreach.json index 4ae4c01..f746994 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/185_unparseable_foreach.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/185_unparseable_foreach.json @@ -3,7 +3,7 @@ "input": "foreach ($f in 1, 2, 3) { Write-Output $f }", "expected": { "isUnparseable": true, - "unparseableReasonContains": "control-flow keyword \u0027foreach\u0027 is not supported in v0.2" + "unparseableReasonContains": "unsupported PowerShell foreach literal-list expression" }, "notes": "A foreach loop (keyword followed by \u0027(\u0027).", "oracleExpectation": "OutOfScope" diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/342_v03_foreach_literal_array.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/342_v03_foreach_literal_array.json new file mode 100644 index 0000000..ef6a7a8 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/342_v03_foreach_literal_array.json @@ -0,0 +1,130 @@ +{ + "name": "V03 foreach literal array", + "input": "foreach ($f in @(\u0027a.txt\u0027, \u0027b.txt\u0027)) { Remove-Item -LiteralPath $f }", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Remove-Item" + ], + "args": [ + { + "raw": "-LiteralPath", + "kind": "Literal", + "isPath": false + }, + { + "raw": "$f", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 67, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "ForEach", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 67, + "clauseIndex": null, + "groupKind": null, + "listOperator": null, + "bindingName": "f", + "bindingRaw": "$f", + "bindingSourceStart": 9, + "bindingSourceLength": 2, + "iterableRaw": "@(\u0027a.txt\u0027, \u0027b.txt\u0027)", + "iterableSourceStart": 15, + "iterableSourceLength": 19 + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "Iterator", + "childIndex": null, + "sourceStart": 15, + "sourceLength": 19, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "LoopBody", + "childIndex": null, + "sourceStart": 37, + "sourceLength": 29, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 38, + "sourceLength": 27, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "LoopBody", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 67 + }, + { + "ancestorKind": "ForEach", + "region": "LoopBody", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 67 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 37, + "sourceLength": 29 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ] + }, + "notes": "A static array exposes the loop body while binding-dependent facts remain incomplete." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/343_v03_foreach_pipeline_iterator.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/343_v03_foreach_pipeline_iterator.json new file mode 100644 index 0000000..ffac9a3 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/343_v03_foreach_pipeline_iterator.json @@ -0,0 +1,191 @@ +{ + "name": "V03 foreach pipeline iterator", + "input": "foreach ($f in Get-ChildItem C:\\input) { Remove-Item -LiteralPath $f }", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-ChildItem" + ], + "args": [ + { + "raw": "C:\\input", + "kind": "Literal", + "isPath": true, + "resolved": "C:/input" + } + ], + "redirects": [] + }, + { + "operator": "None", + "verb": [ + "Remove-Item" + ], + "args": [ + { + "raw": "-LiteralPath", + "kind": "Literal", + "isPath": false + }, + { + "raw": "$f", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 70, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "ForEach", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 70, + "clauseIndex": null, + "groupKind": null, + "listOperator": null, + "bindingName": "f", + "bindingRaw": "$f", + "bindingSourceStart": 9, + "bindingSourceLength": 2, + "iterableRaw": "Get-ChildItem C:\\input", + "iterableSourceStart": 15, + "iterableSourceLength": 22 + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "Iterator", + "childIndex": null, + "sourceStart": 15, + "sourceLength": 22, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 2, + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 22, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "LoopBody", + "childIndex": null, + "sourceStart": 40, + "sourceLength": 29, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 4, + "region": "Statement", + "childIndex": 0, + "sourceStart": 41, + "sourceLength": 27, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Iterator", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 70 + }, + { + "ancestorKind": "ForEach", + "region": "Iterator", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 70 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 22 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + }, + { + "clauseIndex": 1, + "immediateRole": "LoopBody", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 70 + }, + { + "ancestorKind": "ForEach", + "region": "LoopBody", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 70 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 40, + "sourceLength": 29 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ] + }, + "notes": "Iterator commands and loop-body commands remain distinct authored regions." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/344_v03_foreach_subexpression_iterator.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/344_v03_foreach_subexpression_iterator.json new file mode 100644 index 0000000..fa02b54 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/344_v03_foreach_subexpression_iterator.json @@ -0,0 +1,222 @@ +{ + "name": "V03 foreach subexpression iterator", + "input": "foreach ($f in $(Get-ChildItem C:\\input)) { Write-Output $f }", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-ChildItem" + ], + "args": [ + { + "raw": "C:\\input", + "kind": "Literal", + "isPath": true, + "resolved": "C:/input" + } + ], + "redirects": [] + }, + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "$f", + "kind": "EnvVar", + "isPath": false + } + ], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 61, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "ForEach", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 61, + "clauseIndex": null, + "groupKind": null, + "listOperator": null, + "bindingName": "f", + "bindingRaw": "$f", + "bindingSourceStart": 9, + "bindingSourceLength": 2, + "iterableRaw": "$(Get-ChildItem C:\\input)", + "iterableSourceStart": 15, + "iterableSourceLength": 25 + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "Iterator", + "childIndex": null, + "sourceStart": 15, + "sourceLength": 25, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandSubstitution", + "parentIndex": 2, + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 25, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 3, + "region": "Substitution", + "childIndex": 0, + "sourceStart": 17, + "sourceLength": 22, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 4, + "region": "Statement", + "childIndex": 0, + "sourceStart": 17, + "sourceLength": 22, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "LoopBody", + "childIndex": null, + "sourceStart": 43, + "sourceLength": 17, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 6, + "region": "Statement", + "childIndex": 0, + "sourceStart": 44, + "sourceLength": 15, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Substitution", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 61 + }, + { + "ancestorKind": "ForEach", + "region": "Iterator", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 61 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 25 + }, + { + "ancestorKind": "CommandSubstitution", + "region": "Substitution", + "childIndex": 0, + "sourceStart": 15, + "sourceLength": 25 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 17, + "sourceLength": 22 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + }, + { + "clauseIndex": 1, + "immediateRole": "LoopBody", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 61 + }, + { + "ancestorKind": "ForEach", + "region": "LoopBody", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 61 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 43, + "sourceLength": 17 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ] + }, + "notes": "A direct subexpression keeps substitution role inside iterator ancestry." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/345_v03_foreach_body_pipeline.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/345_v03_foreach_body_pipeline.json new file mode 100644 index 0000000..6890f08 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/345_v03_foreach_body_pipeline.json @@ -0,0 +1,204 @@ +{ + "name": "V03 foreach body pipeline", + "input": "foreach ($f in @(\u0027a\u0027, \u0027b\u0027)) { Write-Output $f | Sort-Object }", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "$f", + "kind": "EnvVar", + "isPath": false + } + ], + "redirects": [] + }, + { + "operator": "Pipe", + "verb": [ + "Sort-Object" + ], + "args": [], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 61, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "ForEach", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 61, + "clauseIndex": null, + "groupKind": null, + "listOperator": null, + "bindingName": "f", + "bindingRaw": "$f", + "bindingSourceStart": 9, + "bindingSourceLength": 2, + "iterableRaw": "@(\u0027a\u0027, \u0027b\u0027)", + "iterableSourceStart": 15, + "iterableSourceLength": 11 + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "Iterator", + "childIndex": null, + "sourceStart": 15, + "sourceLength": 11, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "LoopBody", + "childIndex": null, + "sourceStart": 29, + "sourceLength": 31, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Pipeline", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 30, + "sourceLength": 29, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 4, + "region": "PipelineStage", + "childIndex": 0, + "sourceStart": 30, + "sourceLength": 15, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 4, + "region": "PipelineStage", + "childIndex": 1, + "sourceStart": 48, + "sourceLength": 11, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "PipelineStage", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 61 + }, + { + "ancestorKind": "ForEach", + "region": "LoopBody", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 61 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 29, + "sourceLength": 31 + }, + { + "ancestorKind": "Pipeline", + "region": "PipelineStage", + "childIndex": 0, + "sourceStart": 30, + "sourceLength": 29 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + }, + { + "clauseIndex": 1, + "immediateRole": "PipelineStage", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 61 + }, + { + "ancestorKind": "ForEach", + "region": "LoopBody", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 61 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 29, + "sourceLength": 31 + }, + { + "ancestorKind": "Pipeline", + "region": "PipelineStage", + "childIndex": 1, + "sourceStart": 30, + "sourceLength": 29 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ] + }, + "notes": "Pipeline-stage roles remain nested beneath loop-body ancestry." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/346_v03_foreach_parenthesized_alias.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/346_v03_foreach_parenthesized_alias.json new file mode 100644 index 0000000..cb4978e --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/346_v03_foreach_parenthesized_alias.json @@ -0,0 +1,143 @@ +{ + "name": "V03 foreach parenthesized alias", + "input": "Write-Output x | foreach ($_)", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "x", + "kind": "Literal", + "isPath": false + } + ], + "redirects": [] + }, + { + "operator": "Pipe", + "verb": [ + "foreach" + ], + "canonicalVerb": "ForEach-Object", + "args": [ + { + "raw": "($_)", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 29, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Pipeline", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 29, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 1, + "region": "PipelineStage", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 14, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 1, + "region": "PipelineStage", + "childIndex": 1, + "sourceStart": 17, + "sourceLength": 12, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "PipelineStage", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 29 + }, + { + "ancestorKind": "Pipeline", + "region": "PipelineStage", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 29 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + }, + { + "clauseIndex": 1, + "immediateRole": "PipelineStage", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 29 + }, + { + "ancestorKind": "Pipeline", + "region": "PipelineStage", + "childIndex": 1, + "sourceStart": 0, + "sourceLength": 29 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ] + }, + "notes": "Foreach in a pipeline command slot remains an alias with an opaque argument." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/347_v03_foreach_call_operator_alias.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/347_v03_foreach_call_operator_alias.json new file mode 100644 index 0000000..17534e6 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/347_v03_foreach_call_operator_alias.json @@ -0,0 +1,72 @@ +{ + "name": "V03 foreach call operator alias", + "input": "\u0026 foreach ($x)", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "foreach" + ], + "canonicalVerb": "ForEach-Object", + "args": [ + { + "raw": "($x)", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 14, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 14, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 14 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ] + }, + "notes": "Foreach after a call operator remains an alias rather than a loop keyword." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/348_v03_foreach_numeric_pipeline_alias.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/348_v03_foreach_numeric_pipeline_alias.json new file mode 100644 index 0000000..f27863d --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/348_v03_foreach_numeric_pipeline_alias.json @@ -0,0 +1,143 @@ +{ + "name": "V03 foreach numeric pipeline alias", + "input": "Write-Output x | foreach (1)", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "x", + "kind": "Literal", + "isPath": false + } + ], + "redirects": [] + }, + { + "operator": "Pipe", + "verb": [ + "foreach" + ], + "canonicalVerb": "ForEach-Object", + "args": [ + { + "raw": "(1)", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 28, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Pipeline", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 28, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 1, + "region": "PipelineStage", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 14, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 1, + "region": "PipelineStage", + "childIndex": 1, + "sourceStart": 17, + "sourceLength": 11, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "PipelineStage", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 28 + }, + { + "ancestorKind": "Pipeline", + "region": "PipelineStage", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 28 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + }, + { + "clauseIndex": 1, + "immediateRole": "PipelineStage", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 28 + }, + { + "ancestorKind": "Pipeline", + "region": "PipelineStage", + "childIndex": 1, + "sourceStart": 0, + "sourceLength": 28 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ] + }, + "notes": "A non-executing numeric argument does not turn a pipeline alias into a loop." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/349_v03_foreach_numeric_call_operator_alias.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/349_v03_foreach_numeric_call_operator_alias.json new file mode 100644 index 0000000..1fe0264 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/349_v03_foreach_numeric_call_operator_alias.json @@ -0,0 +1,72 @@ +{ + "name": "V03 foreach numeric call operator alias", + "input": "\u0026 foreach (1)", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "foreach" + ], + "canonicalVerb": "ForEach-Object", + "args": [ + { + "raw": "(1)", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 13, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 13, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 13 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ] + }, + "notes": "A non-executing numeric argument after the call operator remains opaque." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/350_v03_foreach_semicolon_boundary.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/350_v03_foreach_semicolon_boundary.json new file mode 100644 index 0000000..0a9656b --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/350_v03_foreach_semicolon_boundary.json @@ -0,0 +1,237 @@ +{ + "name": "V03 foreach semicolon boundary", + "input": "Get-Date; foreach ($x in 1) { Write-Output $x }; Get-Process", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Date" + ], + "args": [], + "redirects": [] + }, + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "$x", + "kind": "EnvVar", + "isPath": false + } + ], + "redirects": [] + }, + { + "operator": "Sequence", + "verb": [ + "Get-Process" + ], + "args": [], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 60, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandList", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 60, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 1, + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 8, + "clauseIndex": 0, + "groupKind": null, + "listOperator": "None" + }, + { + "kind": "ForEach", + "parentIndex": 1, + "region": "Statement", + "childIndex": 1, + "sourceStart": 10, + "sourceLength": 37, + "clauseIndex": null, + "groupKind": null, + "listOperator": "Sequence", + "bindingName": "x", + "bindingRaw": "$x", + "bindingSourceStart": 19, + "bindingSourceLength": 2, + "iterableRaw": "1", + "iterableSourceStart": 25, + "iterableSourceLength": 1 + }, + { + "kind": "Block", + "parentIndex": 3, + "region": "Iterator", + "childIndex": null, + "sourceStart": 25, + "sourceLength": 1, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 3, + "region": "LoopBody", + "childIndex": null, + "sourceStart": 29, + "sourceLength": 17, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 5, + "region": "Statement", + "childIndex": 0, + "sourceStart": 30, + "sourceLength": 15, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 1, + "region": "Statement", + "childIndex": 2, + "sourceStart": 49, + "sourceLength": 11, + "clauseIndex": 2, + "groupKind": null, + "listOperator": "Sequence" + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 60 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 60 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + }, + { + "clauseIndex": 1, + "immediateRole": "LoopBody", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 60 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 1, + "sourceStart": 0, + "sourceLength": 60 + }, + { + "ancestorKind": "ForEach", + "region": "LoopBody", + "childIndex": null, + "sourceStart": 10, + "sourceLength": 37 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 29, + "sourceLength": 17 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + }, + { + "clauseIndex": 2, + "immediateRole": "Ordinary", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 60 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 2, + "sourceStart": 0, + "sourceLength": 60 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ] + }, + "notes": "A semicolon legally terminates statements around foreach." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/351_v03_foreach_child_host_isolation.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/351_v03_foreach_child_host_isolation.json new file mode 100644 index 0000000..aba42c0 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/351_v03_foreach_child_host_isolation.json @@ -0,0 +1,227 @@ +{ + "name": "V03 foreach child host isolation", + "input": "pwsh -Command \u0027foreach ($x in 1) { Write-Output $x }\u0027; Get-Date", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "$x", + "kind": "EnvVar", + "isPath": false + } + ], + "redirects": [], + "isCommandStringWrapped": true + }, + { + "operator": "Sequence", + "verb": [ + "Get-Date" + ], + "args": [], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 63, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandList", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 63, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Group", + "parentIndex": 1, + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 53, + "clauseIndex": null, + "groupKind": "IsolatedScope", + "listOperator": "None" + }, + { + "kind": "Block", + "parentIndex": 2, + "region": "GroupBody", + "childIndex": null, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "ForEach", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": null, + "groupKind": null, + "listOperator": null, + "bindingName": "x", + "bindingRaw": "$x", + "bindingSourceStart": null, + "bindingSourceLength": null, + "iterableRaw": "1", + "iterableSourceStart": null, + "iterableSourceLength": null + }, + { + "kind": "Block", + "parentIndex": 4, + "region": "Iterator", + "childIndex": null, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 4, + "region": "LoopBody", + "childIndex": null, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 6, + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 1, + "region": "Statement", + "childIndex": 1, + "sourceStart": 55, + "sourceLength": 8, + "clauseIndex": 1, + "groupKind": null, + "listOperator": "Sequence" + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "LoopBody", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 63 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 63 + }, + { + "ancestorKind": "Group", + "region": "GroupBody", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 53 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null + }, + { + "ancestorKind": "ForEach", + "region": "LoopBody", + "childIndex": null, + "sourceStart": null, + "sourceLength": null + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + }, + { + "clauseIndex": 1, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 63 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 1, + "sourceStart": 0, + "sourceLength": 63 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ] + }, + "notes": "A loop in an isolated child host does not taint the outer continuation." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/352_v03_foreach_following_and_or_pipeline.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/352_v03_foreach_following_and_or_pipeline.json new file mode 100644 index 0000000..12479fc --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/352_v03_foreach_following_and_or_pipeline.json @@ -0,0 +1,237 @@ +{ + "name": "V03 foreach following and or pipeline", + "input": "foreach ($x in 1) { Write-Output $x }; Get-Date \u0026\u0026 Get-Process", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "$x", + "kind": "EnvVar", + "isPath": false + } + ], + "redirects": [] + }, + { + "operator": "Sequence", + "verb": [ + "Get-Date" + ], + "args": [], + "redirects": [] + }, + { + "operator": "AndIf", + "verb": [ + "Get-Process" + ], + "args": [], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 62, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandList", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 62, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "ForEach", + "parentIndex": 1, + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 37, + "clauseIndex": null, + "groupKind": null, + "listOperator": "None", + "bindingName": "x", + "bindingRaw": "$x", + "bindingSourceStart": 9, + "bindingSourceLength": 2, + "iterableRaw": "1", + "iterableSourceStart": 15, + "iterableSourceLength": 1 + }, + { + "kind": "Block", + "parentIndex": 2, + "region": "Iterator", + "childIndex": null, + "sourceStart": 15, + "sourceLength": 1, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 2, + "region": "LoopBody", + "childIndex": null, + "sourceStart": 19, + "sourceLength": 17, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 4, + "region": "Statement", + "childIndex": 0, + "sourceStart": 20, + "sourceLength": 15, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 1, + "region": "Statement", + "childIndex": 1, + "sourceStart": 39, + "sourceLength": 8, + "clauseIndex": 1, + "groupKind": null, + "listOperator": "Sequence" + }, + { + "kind": "SimpleCommand", + "parentIndex": 1, + "region": "Statement", + "childIndex": 2, + "sourceStart": 51, + "sourceLength": 11, + "clauseIndex": 2, + "groupKind": null, + "listOperator": "AndIf" + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "LoopBody", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 62 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 62 + }, + { + "ancestorKind": "ForEach", + "region": "LoopBody", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 37 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 19, + "sourceLength": 17 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + }, + { + "clauseIndex": 1, + "immediateRole": "Ordinary", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 62 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 1, + "sourceStart": 0, + "sourceLength": 62 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + }, + { + "clauseIndex": 2, + "immediateRole": "Ordinary", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 62 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 2, + "sourceStart": 0, + "sourceLength": 62 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ] + }, + "notes": "A separate pipeline chain may follow a foreach statement terminator." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/353_v03_foreach_chain_boundary_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/353_v03_foreach_chain_boundary_gated.json new file mode 100644 index 0000000..c641452 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/353_v03_foreach_chain_boundary_gated.json @@ -0,0 +1,9 @@ +{ + "name": "V03 foreach chain boundary gated", + "input": "Get-Date \u0026\u0026 foreach ($x in 1) { Write-Output $x }", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "a PowerShell foreach statement requires a statement boundary" + }, + "notes": "PowerShell rejects foreach as an and/or pipeline operand." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/354_v03_foreach_dynamic_iterable_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/354_v03_foreach_dynamic_iterable_gated.json new file mode 100644 index 0000000..e26edbc --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/354_v03_foreach_dynamic_iterable_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 foreach dynamic iterable gated", + "input": "foreach ($x in $items) { Write-Output $x }", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "dynamic PowerShell foreach iterables are not supported" + }, + "notes": "Ambient iterable variables remain outside the bounded structural slice.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/355_v03_foreach_body_mutation_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/355_v03_foreach_body_mutation_gated.json new file mode 100644 index 0000000..8bba7f6 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/355_v03_foreach_body_mutation_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 foreach body mutation gated", + "input": "foreach ($x in 1) { Set-Variable x 2 }", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "PowerShell foreach state mutation or dynamic invocation is not supported in this structural slice" + }, + "notes": "Body mutation fails closed until PowerShell abstract state is modeled.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/356_v03_foreach_alias_mutation_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/356_v03_foreach_alias_mutation_gated.json new file mode 100644 index 0000000..d3aeb03 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/356_v03_foreach_alias_mutation_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 foreach alias mutation gated", + "input": "foreach ($x in 1) { Set-Alias wipe Remove-Item }; wipe file.txt", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "PowerShell foreach state mutation or dynamic invocation is not supported in this structural slice" + }, + "notes": "Command-resolution mutation cannot leave a post-loop command marked complete.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/357_v03_foreach_iterator_module_mutation_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/357_v03_foreach_iterator_module_mutation_gated.json new file mode 100644 index 0000000..c36e9f4 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/357_v03_foreach_iterator_module_mutation_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 foreach iterator module mutation gated", + "input": "foreach ($x in Import-Module ./commands.psm1) { Write-Output $x }; Invoke-Thing", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "PowerShell foreach state mutation or dynamic invocation is not supported in this structural slice" + }, + "notes": "Iterator module mutation fails closed before later command resolution can be trusted.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/358_v03_foreach_provider_mutation_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/358_v03_foreach_provider_mutation_gated.json new file mode 100644 index 0000000..4130b56 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/358_v03_foreach_provider_mutation_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 foreach provider mutation gated", + "input": "foreach ($x in 1) { Set-Item Alias:wipe Remove-Item }; wipe file.txt", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "PowerShell foreach state mutation or dynamic invocation is not supported in this structural slice" + }, + "notes": "Alias, function, variable, and environment provider writes are runspace mutation.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/359_v03_foreach_quoted_alias_mutation_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/359_v03_foreach_quoted_alias_mutation_gated.json new file mode 100644 index 0000000..b8408d8 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/359_v03_foreach_quoted_alias_mutation_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 foreach quoted alias mutation gated", + "input": "foreach ($x in 1) { Set-Item \u0027Alias:wipe\u0027 Remove-Item }; wipe file.txt", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "PowerShell foreach state mutation or dynamic invocation is not supported in this structural slice" + }, + "notes": "Quoted provider paths use decoded provenance and cannot bypass mutation gating.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/360_v03_foreach_quoted_environment_mutation_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/360_v03_foreach_quoted_environment_mutation_gated.json new file mode 100644 index 0000000..fdda1bf --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/360_v03_foreach_quoted_environment_mutation_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 foreach quoted environment mutation gated", + "input": "foreach ($x in 1) { Set-Item \u0022Env:PATH\u0022 C:\\tools }; tool", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "PowerShell foreach state mutation or dynamic invocation is not supported in this structural slice" + }, + "notes": "A quoted environment provider write can change later command resolution.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/361_v03_foreach_provider_qualified_iterator_mutation_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/361_v03_foreach_provider_qualified_iterator_mutation_gated.json new file mode 100644 index 0000000..d5f0fe7 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/361_v03_foreach_provider_qualified_iterator_mutation_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 foreach provider qualified iterator mutation gated", + "input": "foreach ($x in $(Set-Item \u0027Microsoft.PowerShell.Core\\Alias::wipe\u0027 Remove-Item; wipe victim)) { }", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "PowerShell foreach state mutation or dynamic invocation is not supported in this structural slice" + }, + "notes": "Provider-qualified iterator mutation cannot publish a later alias invocation as complete.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json index d8906d9..8602d40 100644 --- a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json @@ -215,6 +215,7 @@ }, { "id": "pwsh-foreach-literal-array", + "compatibilityProjectionLanded": true, "concern": "Finite literal foreach binding", "input": "foreach ($f in @('a.txt', 'b.txt')) { Remove-Item -LiteralPath $f }", "current": { "isUnparseable": true, "reasonContains": "'foreach'" }, @@ -242,6 +243,7 @@ }, { "id": "pwsh-foreach-pipeline-iterator", + "compatibilityProjectionLanded": true, "concern": "Iterator command produces unknown PowerShell objects", "input": "foreach ($f in Get-ChildItem C:\\input) { Remove-Item -LiteralPath $f }", "current": { "isUnparseable": true, "reasonContains": "'foreach'" }, @@ -273,6 +275,7 @@ }, { "id": "pwsh-foreach-subexpression-iterator", + "compatibilityProjectionLanded": true, "concern": "Subexpression iterator preserves nearest substitution role and outer iterator ancestry", "input": "foreach ($f in $(Get-ChildItem C:\\input)) { Remove-Item -LiteralPath $f }", "current": { "isUnparseable": true, "reasonContains": "'foreach'" }, @@ -306,6 +309,7 @@ }, { "id": "pwsh-foreach-cmdlet-parameter-like-value", + "compatibilityProjectionLanded": true, "concern": "Expanded cmdlet value is not a syntactic parameter token", "input": "foreach ($f in @('-Force', 'a.txt')) { Remove-Item $f }", "current": { "isUnparseable": true, "reasonContains": "'foreach'" }, @@ -334,6 +338,7 @@ }, { "id": "pwsh-foreach-native-option-like-value", + "compatibilityProjectionLanded": true, "concern": "Finite value can affect native option parsing", "input": "foreach ($f in @('-n', 'file.txt')) { tool $f }", "current": { "isUnparseable": true, "reasonContains": "'foreach'" }, @@ -361,6 +366,7 @@ }, { "id": "pwsh-nested-foreach", + "compatibilityProjectionLanded": true, "concern": "Nested finite domains and structural ancestry", "input": "foreach ($d in @('a', 'b')) { foreach ($f in @('x', 'y')) { Write-Output \"$d/$f\" } }", "current": { "isUnparseable": true, "reasonContains": "'foreach'" }, @@ -390,6 +396,7 @@ }, { "id": "pwsh-foreach-body-pipeline", + "compatibilityProjectionLanded": true, "concern": "Pipeline roles inside foreach ancestry", "input": "foreach ($f in @('a', 'b')) { Write-Output $f | Sort-Object }", "current": { "isUnparseable": true, "reasonContains": "'foreach'" }, @@ -451,6 +458,7 @@ }, { "id": "pwsh-malformed-foreach-missing-body", + "compatibilityProjectionLanded": true, "concern": "Missing statement body safe-fail", "input": "foreach ($f in 1, 2) Remove-Item $f", "current": { "isUnparseable": true, "reasonContains": "'foreach'" }, @@ -549,7 +557,7 @@ "id": "pwsh-foreach-dynamic-command-identity", "concern": "Dynamic invocation in a bounded loop body", "input": "foreach ($f in @('a', 'b')) { & $exe $f }", - "current": { "isUnparseable": true, "reasonContains": "'foreach'" }, + "current": { "isUnparseable": true, "reasonContains": "dynamic invocation" }, "desired": { "syntax": [ { "id": "root", "kind": "Block", "slot": "Root" }, @@ -574,6 +582,7 @@ }, { "id": "pwsh-foreach-candidate-cap-32", + "compatibilityProjectionLanded": true, "concern": "Finite candidate domain at the fixed cap", "input": "foreach ($f in @('v01','v02','v03','v04','v05','v06','v07','v08','v09','v10','v11','v12','v13','v14','v15','v16','v17','v18','v19','v20','v21','v22','v23','v24','v25','v26','v27','v28','v29','v30','v31','v32')) { Write-Output $f }", "current": { "isUnparseable": true, "reasonContains": "'foreach'" }, @@ -601,6 +610,7 @@ }, { "id": "pwsh-foreach-candidate-overflow-33", + "compatibilityProjectionLanded": true, "concern": "Candidate overflow collapses instead of truncating", "input": "foreach ($f in @('v01','v02','v03','v04','v05','v06','v07','v08','v09','v10','v11','v12','v13','v14','v15','v16','v17','v18','v19','v20','v21','v22','v23','v24','v25','v26','v27','v28','v29','v30','v31','v32','v33')) { Write-Output $f }", "current": { "isUnparseable": true, "reasonContains": "'foreach'" }, @@ -669,10 +679,10 @@ } }, { - "id": "pwsh-foreach-location-zero-iteration-join", - "concern": "Loop exit includes zero-iteration location state", + "id": "pwsh-foreach-location-failure-aware-join", + "concern": "Loop exit joins failure and divergent successful location state", "input": "foreach ($d in @('C:\\a', 'C:\\b')) { Set-Location $d }; Get-ChildItem file.txt", - "current": { "isUnparseable": true, "reasonContains": "'foreach'" }, + "current": { "isUnparseable": true, "reasonContains": "state mutation" }, "desired": { "syntax": [ { "id": "root", "kind": "Block", "slot": "Root" }, @@ -703,7 +713,8 @@ ], "compatibility": { "verbs": ["Set-Location", "Get-ChildItem"], "preservesAuthoredDynamicValues": true }, "securityInvariants": ["AllCommandsVisible", "EveryFiniteCandidateEvaluated", "StateJoinConservative", "UnknownPolicyValueFailsClosed"] - } + }, + "notes": "The static array is nonempty. Post-loop cwd is Unknown because either Set-Location can fail and the two successful targets disagree, not because a zero-iteration path exists." }, { "id": "pwsh-literal-here-string-is-value", diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs index f349378..0217106 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs @@ -1000,7 +1000,6 @@ private static string NestInvokeExpression(string inner, int depth) // ---------------------------------------------------------------- anomalies [Theory] - [InlineData("foreach ($f in $list) { $f }")] [InlineData("if ($true) { Get-Date }")] [InlineData("while ($true) { Get-Date }")] [InlineData("function Foo { }")] diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs new file mode 100644 index 0000000..6046c84 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs @@ -0,0 +1,318 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System; +using System.Linq; +using Xunit; + +namespace ShellSyntaxTree.Tests.Parsing; + +/// Pins the bounded PowerShell foreach structural grammar. +public class PwshForEachStructuralTests +{ + [Fact] + public void Literal_array_preserves_binding_iterable_body_and_exact_spans() + { + const string source = + "foreach ($f in @('a.txt', 'b.txt')) { Remove-Item -LiteralPath $f }"; + + var result = Parse(source); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var loop = Assert.IsType(Assert.Single(result.Syntax.Statements)); + Assert.Equal("f", loop.Binding.Name); + Assert.Equal("$f", loop.Binding.Source.Raw); + Assert.Equal(source.IndexOf("$f", StringComparison.Ordinal), loop.Binding.Source.SourceStart); + Assert.Equal(2, loop.Binding.Source.SourceLength); + Assert.Equal("@('a.txt', 'b.txt')", loop.Iterable.Raw); + Assert.Equal( + source.IndexOf("@(", StringComparison.Ordinal), + loop.Iterable.SourceStart); + Assert.Equal(loop.Iterable.Raw.Length, loop.Iterable.SourceLength); + Assert.Empty(loop.IteratorCommands.Statements); + Assert.Equal(0, loop.SourceStart); + Assert.Equal(source.Length, loop.SourceLength); + + var body = Assert.IsType(Assert.Single(loop.Body.Statements)); + var occurrence = Assert.Single(result.Commands); + Assert.Same(body.Clause, occurrence.Clause); + Assert.Same(body.Clause, Assert.Single(result.Clauses)); + Assert.Equal(CommandOccurrenceRole.LoopBody, occurrence.ImmediateRole); + Assert.False(occurrence.IsComplete); + } + + [Fact] + public void Iterator_pipeline_and_body_pipeline_preserve_roles_and_ancestry() + { + const string source = + "foreach ($f in Get-ChildItem C:\\input | Where-Object Name) " + + "{ Get-Item $f | Remove-Item }"; + + var result = Parse(source); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var loop = Assert.IsType(Assert.Single(result.Syntax.Statements)); + Assert.IsType(Assert.Single(loop.IteratorCommands.Statements)); + Assert.IsType(Assert.Single(loop.Body.Statements)); + Assert.Equal( + new[] { "Get-ChildItem", "Where-Object", "Get-Item", "Remove-Item" }, + result.Commands.Select(CommandVerb)); + Assert.Equal( + new[] + { + CommandOccurrenceRole.PipelineStage, + CommandOccurrenceRole.PipelineStage, + CommandOccurrenceRole.PipelineStage, + CommandOccurrenceRole.PipelineStage, + }, + result.Commands.Select(command => command.ImmediateRole)); + Assert.All(result.Commands.Take(2), command => + Assert.Contains(command.Ancestry, frame => + frame.Region == CommandAncestryRegion.Iterator)); + Assert.All(result.Commands.Skip(2), command => + Assert.Contains(command.Ancestry, frame => + frame.Region == CommandAncestryRegion.LoopBody)); + Assert.All(result.Commands.Take(2), command => Assert.True(command.IsComplete)); + Assert.All(result.Commands.Skip(2), command => Assert.False(command.IsComplete)); + Assert.Equal(result.Clauses, result.Commands.Select(command => command.Clause)); + } + + [Fact] + public void Direct_subexpression_iterator_discovers_inner_command() + { + var result = Parse( + "foreach ($item in $(Get-ChildItem C:\\input)) { Write-Output $item }"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var loop = Assert.IsType(Assert.Single(result.Syntax.Statements)); + Assert.IsType( + Assert.Single(loop.IteratorCommands.Statements)); + Assert.Equal(new[] { "Get-ChildItem", "Write-Output" }, + result.Commands.Select(CommandVerb)); + Assert.Equal(CommandOccurrenceRole.Substitution, result.Commands[0].ImmediateRole); + Assert.Contains(result.Commands[0].Ancestry, frame => + frame.Region == CommandAncestryRegion.Iterator); + Assert.Equal(CommandOccurrenceRole.LoopBody, result.Commands[1].ImmediateRole); + Assert.False(result.Commands[1].IsComplete); + } + + [Fact] + public void Nested_foreach_statements_preserve_distinct_loop_ancestry() + { + var result = Parse( + "foreach ($outer in 1) { foreach ($inner in 2) { Write-Output $inner } }"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var outer = Assert.IsType(Assert.Single(result.Syntax.Statements)); + Assert.IsType(Assert.Single(outer.Body.Statements)); + var occurrence = Assert.Single(result.Commands); + Assert.Equal(CommandOccurrenceRole.LoopBody, occurrence.ImmediateRole); + Assert.Equal( + 2, + occurrence.Ancestry.Count(frame => + frame.AncestorKind == ShellSyntaxKind.ForEach && + frame.Region == CommandAncestryRegion.LoopBody)); + Assert.False(occurrence.IsComplete); + } + + [Fact] + public void Decoded_host_wrapper_retains_foreach_structure_without_outer_spans() + { + var result = Parse( + "pwsh -Command 'foreach ($x in 1) { Write-Output $x }'"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var wrapper = Assert.IsType(Assert.Single(result.Syntax.Statements)); + var loop = Assert.IsType(Assert.Single(wrapper.Body.Statements)); + Assert.Null(loop.SourceStart); + Assert.Null(loop.SourceLength); + Assert.Null(loop.Binding.Source.SourceStart); + Assert.Null(loop.Binding.Source.SourceLength); + Assert.Null(loop.Iterable.SourceStart); + Assert.Null(loop.Iterable.SourceLength); + Assert.False(Assert.Single(result.Commands).IsComplete); + } + + [Theory] + [InlineData("foreach ($x in $items) { Write-Output $x }")] + [InlineData("foreach ($x in @items) { Write-Output $x }")] + [InlineData("foreach ($x in & $producer) { Write-Output $x }")] + [InlineData("foreach ($x in 1, 2, 3) { Write-Output $x }")] + [InlineData("foreach (${x} in 1) { Write-Output $x }")] + [InlineData("foreach ($global:x in 1) { Write-Output $x }")] + [InlineData("foreach ($x 1) { Write-Output $x }")] + [InlineData("foreach ($x in) { Write-Output $x }")] + [InlineData("foreach ($x in 1) Write-Output $x")] + [InlineData("foreach ($x in 1) { Write-Output $x")] + [InlineData("foreach ($x in 1) { & $command $x }")] + public void Dynamic_or_malformed_foreach_fails_atomically(string source) + { + var result = Parse(source); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + + [Theory] + [InlineData("foreach ($x in 1) { Set-Location C:\\other }")] + [InlineData("foreach ($x in 1) { Push-Location C:\\other }")] + [InlineData("foreach ($x in 1) { Pop-Location }")] + [InlineData("foreach ($x in 1) { Set-Variable x 2 }")] + [InlineData("foreach ($x in 1) { Set-Alias wipe Remove-Item }; wipe file.txt")] + [InlineData("foreach ($x in Set-Alias wipe Remove-Item) { Write-Output $x }; wipe file.txt")] + [InlineData("foreach ($x in New-Alias wipe Remove-Item) { Write-Output $x }; wipe file.txt")] + [InlineData("foreach ($x in Import-Module ./commands.psm1) { Write-Output $x }; Invoke-Thing")] + [InlineData("foreach ($x in 1) { Set-Item Alias:wipe Remove-Item }; wipe file.txt")] + [InlineData("foreach ($x in 1) { Set-Item 'Alias:wipe' Remove-Item }; wipe file.txt")] + [InlineData("foreach ($x in 1) { New-Item Function:wipe -Value { Remove-Item $args } }; wipe file.txt")] + [InlineData("foreach ($x in 1) { Set-Item Env:PATH C:\\tools }; tool")] + [InlineData("foreach ($x in 1) { Set-Item \"Env:PATH\" C:\\tools }; tool")] + [InlineData("foreach ($x in $(Set-Item 'Microsoft.PowerShell.Core\\Alias::wipe' Remove-Item; wipe victim)) { }")] + public void Unmodeled_foreach_state_transfer_fails_atomically(string source) + { + var result = Parse(source); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + Assert.Contains("state mutation", result.UnparseableReason!); + } + + [Theory] + [InlineData("foreach ($x in 1) { Write-Output $x } | Select-Object")] + [InlineData("Get-Date && foreach ($x in 1) { Write-Output $x }")] + [InlineData("foreach ($x in 1) { Write-Output $x } || Get-Date")] + public void Foreach_rejects_pipeline_and_chain_boundaries(string source) + { + var result = Parse(source); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + + [Fact] + public void Foreach_with_parenthesized_argument_in_pipeline_slot_remains_an_alias() + { + var result = Parse("Write-Output x | foreach ($_) "); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.IsType(Assert.Single(result.Syntax.Statements)); + Assert.Equal("ForEach-Object", result.Commands[1].Clause.Verb.CanonicalVerb); + Assert.Equal(2, result.Commands.Count); + } + + [Theory] + [InlineData("Write-Output x | foreach (1)")] + [InlineData("& foreach (1)")] + public void Foreach_with_literal_parenthesized_argument_remains_an_alias(string source) + { + var result = Parse(source); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = result.Commands[^1]; + Assert.Equal("ForEach-Object", command.Clause.Verb.CanonicalVerb); + Assert.Equal("(1)", Assert.Single(command.Clause.Args).Raw); + } + + [Fact] + public void Foreach_after_call_operator_remains_an_alias() + { + var result = Parse("& foreach ($x)"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.IsType(Assert.Single(result.Syntax.Statements)); + var command = Assert.Single(result.Commands); + Assert.Equal("ForEach-Object", command.Clause.Verb.CanonicalVerb); + Assert.True(command.IsComplete); + } + + [Fact] + public void Semicolon_remains_a_valid_foreach_statement_boundary() + { + var result = Parse( + "Get-Date; foreach ($x in 1) { Write-Output $x }; Get-Process"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var list = Assert.IsType(Assert.Single(result.Syntax.Statements)); + Assert.Equal(3, list.Items.Count); + Assert.IsType(list.Items[1].Command); + Assert.True(result.Commands[0].IsComplete); + Assert.False(result.Commands[1].IsComplete); + Assert.False(result.Commands[2].IsComplete); + } + + [Fact] + public void Separate_and_or_pipeline_after_foreach_statement_remains_valid() + { + var result = Parse( + "foreach ($x in 1) { Write-Output $x }; Get-Date && Get-Process"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var list = Assert.IsType(Assert.Single(result.Syntax.Statements)); + Assert.Equal(3, list.Items.Count); + Assert.Equal(CompoundOperator.Sequence, list.Items[1].Operator); + Assert.Equal(CompoundOperator.AndIf, list.Items[2].Operator); + Assert.All(result.Commands, command => Assert.False(command.IsComplete)); + } + + [Fact] + public void Isolated_child_host_loop_does_not_taint_outer_continuation() + { + var result = Parse( + "pwsh -Command 'foreach ($x in 1) { Write-Output $x }'; Get-Date"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.Equal(2, result.Commands.Count); + Assert.False(result.Commands[0].IsComplete); + Assert.True(result.Commands[1].IsComplete); + } + + [Fact] + public void Foreach_object_alias_remains_an_opaque_script_block_argument() + { + var result = Parse("Get-ChildItem | foreach { Write-Output $_ }"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.Equal("Get-ChildItem", CommandVerb(result.Commands[0])); + Assert.Equal("ForEach-Object", result.Commands[1].Clause.Verb.CanonicalVerb); + Assert.IsType(Assert.Single(result.Syntax.Statements)); + Assert.Equal(2, result.Commands.Count); + } + + [Fact] + public void Foreach_shares_the_structural_depth_budget() + { + var exact = Parse(NestedLoops(ShellAnalysisLimits.MaxStructuralNesting)); + var overflow = Parse(NestedLoops(ShellAnalysisLimits.MaxStructuralNesting + 1)); + + Assert.False(exact.IsUnparseable, exact.UnparseableReason); + Assert.True(overflow.IsUnparseable); + Assert.Empty(overflow.Commands); + Assert.Empty(overflow.Clauses); + Assert.Contains("nesting depth", overflow.UnparseableReason!); + } + + private static ParsedCommand Parse(string source) => new PwshParser( + new PwshParserOptions + { + HomeDirectory = "C:/Users/test", + WorkingDirectory = "C:/work", + }).Parse(source); + + private static string CommandVerb(CommandOccurrence command) => command.Clause.Verb.Joined; + + private static string NestedLoops(int depth) + { + var source = "Write-Output $x"; + for (var index = 0; index < depth; index++) + { + source = $"foreach ($x{index} in 1) {{ {source} }}"; + } + + return source; + } +} diff --git a/tools/PwshCorpusTool/CorpusManifest.cs b/tools/PwshCorpusTool/CorpusManifest.cs index 927eab4..ea77fd4 100644 --- a/tools/PwshCorpusTool/CorpusManifest.cs +++ b/tools/PwshCorpusTool/CorpusManifest.cs @@ -26,6 +26,7 @@ internal sealed record ManifestEntry( bool IncludeElements = false, bool IncludeStructure = false, bool IncludeOptionalAssertions = false, + bool IncludeV03Assertions = false, string? DisplayName = null) { /// Explicit display name when supplied; otherwise derived from the slug. @@ -63,6 +64,16 @@ private static ManifestEntry P(string slug, string input, string notes) => private static ManifestEntry S(string slug, string input, string notes) => new(slug, input, notes, false, ManifestTransform.None, IncludeStructure: true); + private static ManifestEntry V(string slug, string input, string notes) => + new( + slug, + input, + notes, + false, + ManifestTransform.None, + IncludeStructure: true, + IncludeV03Assertions: true); + private static ManifestEntry A(string slug, string name, string input, string notes) => new( slug, @@ -815,5 +826,67 @@ private static string NestIex(string inner, int depth) 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."), + + // ---- v0.3 foreach structure ---- + V("v03_foreach_literal_array", + "foreach ($f in @('a.txt', 'b.txt')) { Remove-Item -LiteralPath $f }", + "A static array exposes the loop body while binding-dependent facts remain incomplete."), + V("v03_foreach_pipeline_iterator", + "foreach ($f in Get-ChildItem C:\\input) { Remove-Item -LiteralPath $f }", + "Iterator commands and loop-body commands remain distinct authored regions."), + V("v03_foreach_subexpression_iterator", + "foreach ($f in $(Get-ChildItem C:\\input)) { Write-Output $f }", + "A direct subexpression keeps substitution role inside iterator ancestry."), + V("v03_foreach_body_pipeline", + "foreach ($f in @('a', 'b')) { Write-Output $f | Sort-Object }", + "Pipeline-stage roles remain nested beneath loop-body ancestry."), + V("v03_foreach_parenthesized_alias", + "Write-Output x | foreach ($_)", + "Foreach in a pipeline command slot remains an alias with an opaque argument."), + V("v03_foreach_call_operator_alias", + "& foreach ($x)", + "Foreach after a call operator remains an alias rather than a loop keyword."), + V("v03_foreach_numeric_pipeline_alias", + "Write-Output x | foreach (1)", + "A non-executing numeric argument does not turn a pipeline alias into a loop."), + V("v03_foreach_numeric_call_operator_alias", + "& foreach (1)", + "A non-executing numeric argument after the call operator remains opaque."), + V("v03_foreach_semicolon_boundary", + "Get-Date; foreach ($x in 1) { Write-Output $x }; Get-Process", + "A semicolon legally terminates statements around foreach."), + V("v03_foreach_child_host_isolation", + "pwsh -Command 'foreach ($x in 1) { Write-Output $x }'; Get-Date", + "A loop in an isolated child host does not taint the outer continuation."), + V("v03_foreach_following_and_or_pipeline", + "foreach ($x in 1) { Write-Output $x }; Get-Date && Get-Process", + "A separate pipeline chain may follow a foreach statement terminator."), + E("v03_foreach_chain_boundary_gated", + "Get-Date && foreach ($x in 1) { Write-Output $x }", + "PowerShell rejects foreach as an and/or pipeline operand."), + Oos("v03_foreach_dynamic_iterable_gated", + "foreach ($x in $items) { Write-Output $x }", + "Ambient iterable variables remain outside the bounded structural slice."), + Oos("v03_foreach_body_mutation_gated", + "foreach ($x in 1) { Set-Variable x 2 }", + "Body mutation fails closed until PowerShell abstract state is modeled."), + Oos("v03_foreach_alias_mutation_gated", + "foreach ($x in 1) { Set-Alias wipe Remove-Item }; wipe file.txt", + "Command-resolution mutation cannot leave a post-loop command marked complete."), + Oos("v03_foreach_iterator_module_mutation_gated", + "foreach ($x in Import-Module ./commands.psm1) { Write-Output $x }; Invoke-Thing", + "Iterator module mutation fails closed before later command resolution can be trusted."), + Oos("v03_foreach_provider_mutation_gated", + "foreach ($x in 1) { Set-Item Alias:wipe Remove-Item }; wipe file.txt", + "Alias, function, variable, and environment provider writes are runspace mutation."), + Oos("v03_foreach_quoted_alias_mutation_gated", + "foreach ($x in 1) { Set-Item 'Alias:wipe' Remove-Item }; wipe file.txt", + "Quoted provider paths use decoded provenance and cannot bypass mutation gating."), + Oos("v03_foreach_quoted_environment_mutation_gated", + "foreach ($x in 1) { Set-Item \"Env:PATH\" C:\\tools }; tool", + "A quoted environment provider write can change later command resolution."), + Oos("v03_foreach_provider_qualified_iterator_mutation_gated", + "foreach ($x in $(Set-Item 'Microsoft.PowerShell.Core\\Alias::wipe' Remove-Item; wipe victim)) { }", + "Provider-qualified iterator mutation cannot publish a later alias invocation as complete."), }; } diff --git a/tools/PwshCorpusTool/Program.cs b/tools/PwshCorpusTool/Program.cs index 2f6f72a..09b807d 100644 --- a/tools/PwshCorpusTool/Program.cs +++ b/tools/PwshCorpusTool/Program.cs @@ -78,7 +78,7 @@ int Generate(string outputDir) entry.IncludeElements, entry.IncludeStructure, entry.IncludeOptionalAssertions, - includeV03Assertions: false); + entry.IncludeV03Assertions); var fileName = $"{index:D3}_{entry.Slug}.json"; File.WriteAllText(Path.Combine(outputDir, fileName), json); index++;