From c987a2ec07c503cddc3a634302e6561c62e13680 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 7 Aug 2026 19:50:27 +0000 Subject: [PATCH] Project and clone execution regions --- IMPLEMENTATION_PLAN.md | 9 +- .../v0-3-structured-shell-analysis/tasks.md | 2 +- .../Bash/Parsing/BashStructuralCoordinator.cs | 42 ++ .../Pwsh/Parsing/PwshStructuralCoordinator.cs | 33 ++ src/ShellSyntaxTree/ShellSyntaxProjection.cs | 133 ++++- .../ShellSyntaxTree.Tests/Corpus/AstAssert.cs | 133 ++++- .../Corpus/CorpusRunnerTests.cs | 237 ++++++++- .../ShellSyntaxProjectionTests.cs | 466 ++++++++++++++++++ 8 files changed, 1032 insertions(+), 23 deletions(-) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index d87ec4b..6c950f5 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -356,9 +356,12 @@ priorities. syntax node with independent origin, phase, timing, and cardinality rather than a false shared/isolated scope flag. The inert additive public API skeleton, enum/default snapshots, recorded local PowerShell probe evidence, and - design-corpus categories are delivered; no parser emits a region yet and - automated execution-region oracle coverage remains in task 7.7. Continue in - small slices: projection; pinned receiver/parameter binding including + design-corpus categories are delivered. The shared projector, + compatibility flattener, depth guard, decoded-wrapper cloning, and + executable-corpus DTOs now preserve direct and command-owned regions in + the locked substitution-host-region order. No parser emits a region yet, + and automated execution-region oracle coverage remains in task 7.7. + Continue in small slices: pinned receiver/parameter binding including ForEach-Object multi-block phases; direct `&` / `.` and synchronous current-runspace callbacks; child process/runspace jobs and parallel blocks; deferred breakpoint/event/completion actions; then unknown diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md index f59f8f2..03d38f7 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -55,7 +55,7 @@ - [x] 3.14 Add `ExecutionRegionSyntax`, its four discriminant enums, `SimpleCommandSyntax.ExecutionRegions`, and appended occurrence/ancestry enum members to the public API and snapshot without changing existing enum values. -- [ ] 3.15 Extend the structural projector, compatibility flattener, depth +- [x] 3.15 Extend the structural projector, compatibility flattener, depth validation, cloning, and corpus DTOs so direct and command-owned execution regions emit every body command exactly once in the locked order. diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs index 4cd3d7b..299e25d 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs @@ -1832,6 +1832,25 @@ private static bool TryCloneDecodedNode( var clauseOperator = firstLeaf ? firstOperator : simple.Clause.Operator; firstLeaf = false; + var executionRegions = new List( + simple.ExecutionRegions.Count); + foreach (var executionRegion in simple.ExecutionRegions) + { + if (!TryCloneDecodedNode( + executionRegion, + firstOperator, + outerSubshell, + ref firstLeaf, + referenceMap, + out var clonedExecutionRegion) || + clonedExecutionRegion is not ExecutionRegionSyntax typedExecutionRegion) + { + return false; + } + + executionRegions.Add(typedExecutionRegion); + } + var clonedClause = simple.Clause with { Operator = clauseOperator, @@ -1844,6 +1863,7 @@ private static bool TryCloneDecodedNode( { Clause = clonedClause, Substitutions = substitutions, + ExecutionRegions = executionRegions, }; referenceMap.Add(simple.Clause, clonedClause); return true; @@ -2025,6 +2045,28 @@ private static bool TryCloneDecodedNode( clone = new CommandSubstitutionSyntax { Body = substitutionBody }; return true; + case ExecutionRegionSyntax executionRegion: + if (!TryCloneDecodedBlock( + executionRegion.Body, + firstOperator, + outerSubshell, + ref firstLeaf, + referenceMap, + out var executionBody)) + { + return false; + } + + clone = new ExecutionRegionSyntax + { + Origin = executionRegion.Origin, + HostClauseElementIndex = executionRegion.HostClauseElementIndex, + Phase = executionRegion.Phase, + Timing = executionRegion.Timing, + Cardinality = executionRegion.Cardinality, + Body = executionBody, + }; + return true; default: return false; } diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs index dd6cf64..d6df419 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs @@ -1755,6 +1755,22 @@ private static bool TryCloneDecodedNode( var isFirst = state.LeafIndex == 0; var isLast = state.LeafIndex == state.LeafCount - 1; state.LeafIndex++; + var executionRegions = new List( + simple.ExecutionRegions.Count); + foreach (var executionRegion in simple.ExecutionRegions) + { + if (!TryCloneDecodedNode( + executionRegion, + state, + out var clonedExecutionRegion) || + clonedExecutionRegion is not ExecutionRegionSyntax typedExecutionRegion) + { + return false; + } + + executionRegions.Add(typedExecutionRegion); + } + var redirects = new List(simple.Clause.Redirects.Count + (isLast ? state.WrapperRedirects.Count : 0)); redirects.AddRange(simple.Clause.Redirects); @@ -1789,6 +1805,7 @@ private static bool TryCloneDecodedNode( IsCommandStringWrapped = true, }, Substitutions = substitutions, + ExecutionRegions = executionRegions, }; return true; case PipelineSyntax pipeline: @@ -1900,6 +1917,22 @@ private static bool TryCloneDecodedNode( clone = new CommandSubstitutionSyntax { Body = substitutionBody }; return true; + case ExecutionRegionSyntax executionRegion: + if (!TryCloneDecodedBlock(executionRegion.Body, state, out var executionBody)) + { + return false; + } + + clone = new ExecutionRegionSyntax + { + Origin = executionRegion.Origin, + HostClauseElementIndex = executionRegion.HostClauseElementIndex, + Phase = executionRegion.Phase, + Timing = executionRegion.Timing, + Cardinality = executionRegion.Cardinality, + Body = executionBody, + }; + return true; default: return false; } diff --git a/src/ShellSyntaxTree/ShellSyntaxProjection.cs b/src/ShellSyntaxTree/ShellSyntaxProjection.cs index bc7fcce..2e0c4d7 100644 --- a/src/ShellSyntaxTree/ShellSyntaxProjection.cs +++ b/src/ShellSyntaxTree/ShellSyntaxProjection.cs @@ -100,6 +100,7 @@ private sealed class ProjectionWalker private readonly List _ancestry = new(); private readonly List _commands = new(); private readonly List _clauses = new(); + private bool _structuralContextIsComplete = true; private readonly HashSet _visitedNodes = new(NodeReferenceComparer.Instance); private readonly HashSet _visitedClauses = @@ -138,7 +139,8 @@ private bool TryVisit( CommandOccurrenceRole role, int structuralDepth, bool isRoot = false, - int? substitutionChildIndex = null) + int? nestedCollectionChildIndex = null, + bool isAttachedExecutionRegion = false) { if (node is null || !IsValidSpan(node.SourceStart, node.SourceLength) || @@ -178,8 +180,14 @@ loop.LoopKind is ConditionLoopKind.While or ConditionLoopKind.Until && CommandSubstitutionSyntax substitution => TryVisitCommandSubstitution( substitution, - substitutionChildIndex, + nestedCollectionChildIndex, nextDepth), + ExecutionRegionSyntax executionRegion => + TryVisitExecutionRegion( + executionRegion, + nestedCollectionChildIndex, + nextDepth, + isAttachedExecutionRegion), _ => false, }; @@ -224,7 +232,11 @@ private bool TryVisitSimple( CommandOccurrenceRole role, int structuralDepth) { - if (simple.Substitutions is null) + if (simple.Substitutions is null || + simple.ExecutionRegions is null || + simple.Clause is null || + !IsValidClauseShape(simple.Clause) || + !AreValidAttachedExecutionRegions(simple.Clause, simple.ExecutionRegions)) { return false; } @@ -237,15 +249,13 @@ private bool TryVisitSimple( substitution, CommandOccurrenceRole.Substitution, structuralDepth, - substitutionChildIndex: index)) + nestedCollectionChildIndex: index)) { return false; } } - if (simple.Clause is null || - !IsValidClauseShape(simple.Clause) || - !_visitedClauses.Add(simple.Clause)) + if (!_visitedClauses.Add(simple.Clause)) { return false; } @@ -269,9 +279,24 @@ private bool TryVisitSimple( EffectiveArguments = effectiveArguments, WorkingDirectory = workingDirectory, Redirects = redirects, - IsComplete = facts.IsComplete, + IsComplete = facts.IsComplete && _structuralContextIsComplete && + AreExecutionRegionFactsComplete(simple.ExecutionRegions), }); _clauses.Add(simple.Clause); + + for (var index = 0; index < simple.ExecutionRegions.Count; index++) + { + if (!TryVisit( + simple.ExecutionRegions[index], + CommandOccurrenceRole.ExecutionRegion, + structuralDepth, + nestedCollectionChildIndex: index, + isAttachedExecutionRegion: true)) + { + return false; + } + } + return true; } @@ -288,6 +313,30 @@ substitution.Body is not null && CommandOccurrenceRole.Substitution, structuralDepth); + private bool TryVisitExecutionRegion( + ExecutionRegionSyntax executionRegion, + int? childIndex, + int structuralDepth, + bool isAttachedToSimple) + { + if (!IsValidExecutionRegion(executionRegion, isAttachedToSimple)) + { + return false; + } + + var priorCompleteness = _structuralContextIsComplete; + _structuralContextIsComplete &= IsExecutionRegionFactComplete(executionRegion); + var succeeded = TryVisitChild( + executionRegion, + executionRegion.Body, + CommandAncestryRegion.ExecutionRegion, + childIndex, + CommandOccurrenceRole.ExecutionRegion, + structuralDepth); + _structuralContextIsComplete = priorCompleteness; + return succeeded; + } + private bool TryVisitPipeline( PipelineSyntax pipeline, int structuralDepth) @@ -485,7 +534,7 @@ private bool TryVisitChild( child, role, structuralDepth, - substitutionChildIndex: child is CommandSubstitutionSyntax + nestedCollectionChildIndex: child is CommandSubstitutionSyntax or ExecutionRegionSyntax ? childIndex : null); _ancestry.RemoveAt(_ancestry.Count - 1); @@ -497,7 +546,71 @@ node is ForEachSyntax or ConditionLoopSyntax or ConditionalSyntax or GroupSyntax or - CommandSubstitutionSyntax; + CommandSubstitutionSyntax or + ExecutionRegionSyntax; + + private static bool AreValidAttachedExecutionRegions( + Clause clause, + IReadOnlyList executionRegions) + { + var hostCoordinates = new HashSet(); + for (var index = 0; index < executionRegions.Count; index++) + { + var region = executionRegions[index]; + if (region is null || + !IsValidExecutionRegion(region, isAttachedToSimple: true) || + region.HostClauseElementIndex >= clause.Elements.Count || + !hostCoordinates.Add(region.HostClauseElementIndex!.Value) || + clause.Elements[region.HostClauseElementIndex.Value].Role != + ClauseElementRole.Argument || + clause.Elements[region.HostClauseElementIndex.Value].Kind != + ArgKind.DynamicSkip) + { + return false; + } + } + + return true; + } + + private static bool IsValidExecutionRegion( + ExecutionRegionSyntax executionRegion, + bool isAttachedToSimple) => + executionRegion.Body is not null && + Enum.IsDefined(typeof(ExecutionRegionOrigin), executionRegion.Origin) && + Enum.IsDefined(typeof(ExecutionRegionPhase), executionRegion.Phase) && + Enum.IsDefined(typeof(ExecutionRegionTiming), executionRegion.Timing) && + Enum.IsDefined( + typeof(ExecutionRegionCardinality), + executionRegion.Cardinality) && + executionRegion.Origin switch + { + ExecutionRegionOrigin.DirectCall or ExecutionRegionOrigin.DotSource => + !isAttachedToSimple && executionRegion.HostClauseElementIndex is null, + ExecutionRegionOrigin.CommandArgument => + isAttachedToSimple && executionRegion.HostClauseElementIndex >= 0, + _ => false, + }; + + private static bool AreExecutionRegionFactsComplete( + IReadOnlyList executionRegions) + { + for (var index = 0; index < executionRegions.Count; index++) + { + if (!IsExecutionRegionFactComplete(executionRegions[index])) + { + return false; + } + } + + return true; + } + + private static bool IsExecutionRegionFactComplete( + ExecutionRegionSyntax executionRegion) => + executionRegion.Phase != ExecutionRegionPhase.Unknown && + executionRegion.Timing != ExecutionRegionTiming.Unknown && + executionRegion.Cardinality != ExecutionRegionCardinality.Unknown; private static bool TryCopyFacts( Clause clause, diff --git a/tests/ShellSyntaxTree.Tests/Corpus/AstAssert.cs b/tests/ShellSyntaxTree.Tests/Corpus/AstAssert.cs index 06fc228..dc5a4ed 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/AstAssert.cs +++ b/tests/ShellSyntaxTree.Tests/Corpus/AstAssert.cs @@ -122,7 +122,7 @@ internal static void Equal( if (expected.Syntax is not null) { - AssertSyntaxEqual(expected.Syntax, actual, prefix); + AssertSyntaxEqual(expected.Syntax, expectedClauses, actual, prefix); } if (expected.Commands is not null) @@ -133,10 +133,11 @@ internal static void Equal( private static void AssertSyntaxEqual( IReadOnlyList expected, + IReadOnlyList expectedClauses, ParsedCommand actual, string prefix) { - ValidateExpectedSyntax(expected, prefix); + ValidateExpectedSyntax(expected, expectedClauses, prefix); var flattened = new List(); AppendSyntax( actual.Syntax, @@ -172,7 +173,12 @@ private static void AssertSyntaxEqual( wanted.BindingSourceLength != observed.BindingSourceLength || wanted.IterableRaw != observed.IterableRaw || wanted.IterableSourceStart != observed.IterableSourceStart || - wanted.IterableSourceLength != observed.IterableSourceLength) + wanted.IterableSourceLength != observed.IterableSourceLength || + wanted.ExecutionOrigin != observed.ExecutionOrigin || + wanted.HostClauseElementIndex != observed.HostClauseElementIndex || + wanted.ExecutionPhase != observed.ExecutionPhase || + wanted.ExecutionTiming != observed.ExecutionTiming || + wanted.ExecutionCardinality != observed.ExecutionCardinality) { throw new XunitException( prefix + $"syntax[{index}]: expected={Summarize(wanted)}, " @@ -195,6 +201,7 @@ private static void AssertSyntaxEqual( private static void ValidateExpectedSyntax( IReadOnlyList expected, + IReadOnlyList expectedClauses, string prefix) { if (expected.Count == 0 || @@ -207,12 +214,20 @@ expected[0].ChildIndex is not null || throw new XunitException(prefix + "syntax[0] must be the root block"); } + var executionRegionCoordinates = new HashSet<(int ParentIndex, int ElementIndex)>(); for (var index = 0; index < expected.Count; index++) { var node = expected[index]; + var isExecutionRegion = node.Kind == ShellSyntaxKind.ExecutionRegion; if (node.Kind == ShellSyntaxKind.Unknown || (node.Kind == ShellSyntaxKind.SimpleCommand) != node.ClauseIndex.HasValue || - (node.Kind == ShellSyntaxKind.Group) != node.GroupKind.HasValue) + (node.Kind == ShellSyntaxKind.Group) != node.GroupKind.HasValue || + isExecutionRegion != node.ExecutionOrigin.HasValue || + isExecutionRegion != node.ExecutionPhase.HasValue || + isExecutionRegion != node.ExecutionTiming.HasValue || + isExecutionRegion != node.ExecutionCardinality.HasValue || + isExecutionRegion && !IsValidExpectedExecutionRegion(node) || + !isExecutionRegion && node.HostClauseElementIndex.HasValue) { throw new XunitException( prefix + $"syntax[{index}] has an invalid kind-specific field"); @@ -249,7 +264,10 @@ expected[0].ChildIndex is not null || : CommandAncestryRegion.Statement) && node.ChildIndex.HasValue, ShellSyntaxKind.SimpleCommand => - node.Region == CommandAncestryRegion.Substitution && + ((node.Kind == ShellSyntaxKind.CommandSubstitution && + node.Region == CommandAncestryRegion.Substitution) || + (node.Kind == ShellSyntaxKind.ExecutionRegion && + node.Region == CommandAncestryRegion.ExecutionRegion)) && node.ChildIndex.HasValue, ShellSyntaxKind.Pipeline => node.Region == CommandAncestryRegion.PipelineStage && @@ -278,9 +296,27 @@ expected[0].ChildIndex is not null || node.ChildIndex is null, ShellSyntaxKind.CommandSubstitution => node.Region == CommandAncestryRegion.Substitution, + ShellSyntaxKind.ExecutionRegion => + node.Kind == ShellSyntaxKind.Block && + node.Region == CommandAncestryRegion.ExecutionRegion, _ => false, }; + var executionRegionPlacementIsValid = + node.Kind != ShellSyntaxKind.ExecutionRegion || + (node.ExecutionOrigin == ExecutionRegionOrigin.CommandArgument) == + (parent.Kind == ShellSyntaxKind.SimpleCommand); + var executionRegionCoordinateIsValid = + node.Kind != ShellSyntaxKind.ExecutionRegion || + node.ExecutionOrigin != ExecutionRegionOrigin.CommandArgument || + IsValidExpectedHostCoordinate( + node, + parent, + node.ParentIndex.Value, + expectedClauses, + executionRegionCoordinates); if (!relationshipIsValid || + !executionRegionPlacementIsValid || + !executionRegionCoordinateIsValid || parent.Kind != ShellSyntaxKind.CommandList && node.ListOperator.HasValue) { throw new XunitException( @@ -289,6 +325,50 @@ expected[0].ChildIndex is not null || } } + private static bool IsValidExpectedHostCoordinate( + ExpectedSyntaxNode executionRegion, + ExpectedSyntaxNode parent, + int parentIndex, + IReadOnlyList expectedClauses, + ISet<(int ParentIndex, int ElementIndex)> coordinates) + { + if (parent.ClauseIndex is not int clauseIndex || + clauseIndex < 0 || + clauseIndex >= expectedClauses.Count || + executionRegion.HostClauseElementIndex is not int elementIndex || + expectedClauses[clauseIndex].Elements is not { } elements || + elementIndex < 0 || + elementIndex >= elements.Count) + { + return false; + } + + var element = elements[elementIndex]; + return coordinates.Add((parentIndex, elementIndex)) && + element.Role == ClauseElementRole.Argument && + element.Kind == ArgKind.DynamicSkip; + } + + private static bool IsValidExpectedExecutionRegion(ExpectedSyntaxNode node) => + node.ExecutionOrigin.HasValue && + System.Enum.IsDefined(typeof(ExecutionRegionOrigin), node.ExecutionOrigin.Value) && + node.ExecutionOrigin.Value != ExecutionRegionOrigin.Unknown && + node.ExecutionPhase.HasValue && + System.Enum.IsDefined(typeof(ExecutionRegionPhase), node.ExecutionPhase.Value) && + node.ExecutionTiming.HasValue && + System.Enum.IsDefined(typeof(ExecutionRegionTiming), node.ExecutionTiming.Value) && + node.ExecutionCardinality.HasValue && + System.Enum.IsDefined( + typeof(ExecutionRegionCardinality), + node.ExecutionCardinality.Value) && + node.ExecutionOrigin.Value switch + { + ExecutionRegionOrigin.DirectCall or ExecutionRegionOrigin.DotSource => + node.HostClauseElementIndex is null, + ExecutionRegionOrigin.CommandArgument => node.HostClauseElementIndex >= 0, + _ => false, + }; + private static void AssertCommandsEqual( IReadOnlyList expected, ParsedCommand actual, @@ -438,6 +518,7 @@ private static void AppendSyntax( var clause = (node as SimpleCommandSyntax)?.Clause; var forEachNode = node as ForEachSyntax; + var executionRegion = node as ExecutionRegionSyntax; int? clauseIndex = clause is null ? null : FindClauseIndex(clauses, clause); var currentIndex = nodes.Count; nodes.Add(new ActualSyntaxNode( @@ -457,6 +538,11 @@ private static void AppendSyntax( forEachNode?.Iterable.Raw, forEachNode?.Iterable.SourceStart, forEachNode?.Iterable.SourceLength, + executionRegion?.Origin, + executionRegion?.HostClauseElementIndex, + executionRegion?.Phase, + executionRegion?.Timing, + executionRegion?.Cardinality, clause)); switch (node) @@ -491,6 +577,18 @@ private static void AppendSyntax( nodes); } + for (var index = 0; index < simple.ExecutionRegions.Count; index++) + { + AppendSyntax( + simple.ExecutionRegions[index], + currentIndex, + CommandAncestryRegion.ExecutionRegion, + index, + listOperator: null, + clauses, + nodes); + } + break; case PipelineSyntax pipeline: for (var index = 0; index < pipeline.Stages.Count; index++) @@ -620,6 +718,16 @@ private static void AppendSyntax( clauses, nodes); break; + case ExecutionRegionSyntax regionNode: + AppendSyntax( + regionNode.Body, + currentIndex, + CommandAncestryRegion.ExecutionRegion, + childIndex, + listOperator: null, + clauses, + nodes); + break; default: throw new XunitException( $"Cannot flatten unsupported syntax type {node.GetType().FullName} into corpus expectations"); @@ -643,13 +751,19 @@ private static string Summarize(ExpectedSyntaxNode node) => $"{{kind={node.Kind}, parent={node.ParentIndex}, region={node.Region}, " + $"child={node.ChildIndex}, span={node.SourceStart}:{node.SourceLength}, " + $"clause={node.ClauseIndex}, group={node.GroupKind}, listOp={node.ListOperator}, " - + $"binding={node.BindingName}, iterable={node.IterableRaw}}}"; + + $"binding={node.BindingName}, iterable={node.IterableRaw}, " + + $"execution={node.ExecutionOrigin}/{node.ExecutionPhase}/" + + $"{node.ExecutionTiming}/{node.ExecutionCardinality}, " + + $"hostElement={node.HostClauseElementIndex}}}"; private static string Summarize(ActualSyntaxNode node) => $"{{kind={node.Kind}, parent={node.ParentIndex}, region={node.Region}, " + $"child={node.ChildIndex}, span={node.SourceStart}:{node.SourceLength}, " + $"clause={node.ClauseIndex}, group={node.GroupKind}, listOp={node.ListOperator}, " - + $"binding={node.BindingName}, iterable={node.IterableRaw}}}"; + + $"binding={node.BindingName}, iterable={node.IterableRaw}, " + + $"execution={node.ExecutionOrigin}/{node.ExecutionPhase}/" + + $"{node.ExecutionTiming}/{node.ExecutionCardinality}, " + + $"hostElement={node.HostClauseElementIndex}}}"; private sealed record ActualSyntaxNode( ShellSyntaxKind Kind, @@ -668,6 +782,11 @@ private sealed record ActualSyntaxNode( string? IterableRaw, int? IterableSourceStart, int? IterableSourceLength, + ExecutionRegionOrigin? ExecutionOrigin, + int? HostClauseElementIndex, + ExecutionRegionPhase? ExecutionPhase, + ExecutionRegionTiming? ExecutionTiming, + ExecutionRegionCardinality? ExecutionCardinality, Clause? Clause); private static void AssertClauseEqual(ExpectedClause expected, Clause actual, string path) diff --git a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs index c1ca364..19b8202 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs +++ b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs @@ -12,6 +12,7 @@ using ShellSyntaxTree.Internal.Bash.Lexing; using ShellSyntaxTree.Internal.Pwsh.Lexing; using Xunit; +using Xunit.Sdk; namespace ShellSyntaxTree.Tests.Corpus; @@ -44,6 +45,210 @@ public void Corpus_entry_parses_to_expected_ast(string shell, string fileName, C AssertAuthoredTokenCoverage(shell, actual, $"{shell}/{fileName}"); } + [Fact] + public void Executable_corpus_shape_preserves_execution_region_facts() + { + var hostClause = new Clause + { + Verb = new VerbChain { Tokens = new[] { "ForEach-Object" } }, + Elements = new[] + { + new ClauseElement { Role = ClauseElementRole.Verb }, + new ClauseElement + { + Role = ClauseElementRole.Argument, + Kind = ArgKind.DynamicSkip, + }, + }, + }; + var bodyClause = new Clause + { + Verb = new VerbChain { Tokens = new[] { "Remove-Item" } }, + Elements = new[] { new ClauseElement { Role = ClauseElementRole.Verb } }, + }; + var actual = new ParsedCommand + { + Clauses = new[] { hostClause, bodyClause }, + Syntax = new ShellBlockSyntax + { + Statements = new ShellSyntaxNode[] + { + new SimpleCommandSyntax + { + Clause = hostClause, + ExecutionRegions = new[] + { + new ExecutionRegionSyntax + { + Origin = ExecutionRegionOrigin.CommandArgument, + HostClauseElementIndex = 1, + Phase = ExecutionRegionPhase.Process, + Timing = ExecutionRegionTiming.Synchronous, + Cardinality = ExecutionRegionCardinality.OncePerInputObject, + Body = new ShellBlockSyntax + { + Statements = new ShellSyntaxNode[] + { + new SimpleCommandSyntax { Clause = bodyClause }, + }, + }, + }, + }, + }, + }, + }, + }; + var expected = new ExpectedParsedCommand + { + Clauses = new List + { + new() + { + Verb = new List { "ForEach-Object" }, + Elements = new List + { + new() { Role = ClauseElementRole.Verb }, + new() + { + Role = ClauseElementRole.Argument, + Kind = ArgKind.DynamicSkip, + }, + }, + }, + new() { Verb = new List { "Remove-Item" } }, + }, + Syntax = new List + { + new() { Kind = ShellSyntaxKind.Block }, + new() + { + Kind = ShellSyntaxKind.SimpleCommand, + ParentIndex = 0, + Region = CommandAncestryRegion.Root, + ChildIndex = 0, + ClauseIndex = 0, + }, + new() + { + Kind = ShellSyntaxKind.ExecutionRegion, + ParentIndex = 1, + Region = CommandAncestryRegion.ExecutionRegion, + ChildIndex = 0, + ExecutionOrigin = ExecutionRegionOrigin.CommandArgument, + HostClauseElementIndex = 1, + ExecutionPhase = ExecutionRegionPhase.Process, + ExecutionTiming = ExecutionRegionTiming.Synchronous, + ExecutionCardinality = ExecutionRegionCardinality.OncePerInputObject, + }, + new() + { + Kind = ShellSyntaxKind.Block, + ParentIndex = 2, + Region = CommandAncestryRegion.ExecutionRegion, + ChildIndex = 0, + }, + new() + { + Kind = ShellSyntaxKind.SimpleCommand, + ParentIndex = 3, + Region = CommandAncestryRegion.Statement, + ChildIndex = 0, + ClauseIndex = 1, + }, + }, + }; + + AstAssert.Equal(expected, actual); + } + + [Fact] + public void Executable_corpus_rejects_invalid_execution_region_host_coordinates() + { + Assert.Throws(() => AstAssert.Equal( + CreateExpectation(ArgKind.Literal, 1), + CreateActual(ArgKind.Literal))); + Assert.Throws(() => AstAssert.Equal( + CreateExpectation(ArgKind.DynamicSkip, 2), + CreateActual(ArgKind.DynamicSkip))); + Assert.Throws(() => AstAssert.Equal( + CreateExpectation(ArgKind.DynamicSkip, 1, 1), + CreateActual(ArgKind.DynamicSkip))); + + static ParsedCommand CreateActual(ArgKind hostKind) => new() + { + Clauses = new[] + { + new Clause + { + Verb = new VerbChain { Tokens = new[] { "host" } }, + Elements = new[] + { + new ClauseElement { Role = ClauseElementRole.Verb }, + new ClauseElement + { + Role = ClauseElementRole.Argument, + Kind = hostKind, + }, + }, + }, + }, + }; + + static ExpectedParsedCommand CreateExpectation( + ArgKind hostKind, + params int[] coordinates) + { + var syntax = new List + { + new() { Kind = ShellSyntaxKind.Block }, + new() + { + Kind = ShellSyntaxKind.SimpleCommand, + ParentIndex = 0, + Region = CommandAncestryRegion.Root, + ChildIndex = 0, + ClauseIndex = 0, + }, + }; + for (var index = 0; index < coordinates.Length; index++) + { + syntax.Add(new ExpectedSyntaxNode + { + Kind = ShellSyntaxKind.ExecutionRegion, + ParentIndex = 1, + Region = CommandAncestryRegion.ExecutionRegion, + ChildIndex = index, + ExecutionOrigin = ExecutionRegionOrigin.CommandArgument, + HostClauseElementIndex = coordinates[index], + ExecutionPhase = ExecutionRegionPhase.Main, + ExecutionTiming = ExecutionRegionTiming.Synchronous, + ExecutionCardinality = ExecutionRegionCardinality.Once, + }); + } + + return new ExpectedParsedCommand + { + Clauses = new List + { + new() + { + Verb = new List { "host" }, + Elements = new List + { + new() { Role = ClauseElementRole.Verb }, + new() + { + Role = ClauseElementRole.Argument, + Kind = hostKind, + }, + }, + }, + }, + Syntax = syntax, + }; + } + } + private static void AssertAuthoredTokenCoverage( string shell, ParsedCommand parsed, string context) { @@ -145,7 +350,9 @@ private static bool IsBashForEachStructuralToken( ShellBlockSyntax block => block.Statements.Any( child => IsBashForEachStructuralToken(child, token)), SimpleCommandSyntax simple => simple.Substitutions.Any( - child => IsBashForEachStructuralToken(child, token)), + child => IsBashForEachStructuralToken(child, token)) || + simple.ExecutionRegions.Any( + child => IsBashForEachStructuralToken(child, token)), PipelineSyntax pipeline => pipeline.Stages.Any( child => IsBashForEachStructuralToken(child, token)), CommandListSyntax list => list.Items.Any( @@ -166,6 +373,8 @@ conditional.Else is not null && IsBashForEachStructuralToken(branch.Body, token), CommandSubstitutionSyntax substitution => IsBashForEachStructuralToken(substitution.Body, token), + ExecutionRegionSyntax executionRegion => + IsBashForEachStructuralToken(executionRegion.Body, token), _ => false, }; } @@ -224,7 +433,9 @@ private static bool IsPwshForEachStructuralToken( ShellBlockSyntax block => block.Statements.Any( child => IsPwshForEachStructuralToken(child, token)), SimpleCommandSyntax simple => simple.Substitutions.Any( - child => IsPwshForEachStructuralToken(child, token)), + child => IsPwshForEachStructuralToken(child, token)) || + simple.ExecutionRegions.Any( + child => IsPwshForEachStructuralToken(child, token)), PipelineSyntax pipeline => pipeline.Stages.Any( child => IsPwshForEachStructuralToken(child, token)), CommandListSyntax list => list.Items.Any( @@ -245,6 +456,8 @@ conditional.Else is not null && IsPwshForEachStructuralToken(branch.Body, token), CommandSubstitutionSyntax substitution => IsPwshForEachStructuralToken(substitution.Body, token), + ExecutionRegionSyntax executionRegion => + IsPwshForEachStructuralToken(executionRegion.Body, token), _ => false, }; } @@ -318,6 +531,16 @@ private static void CollectStandaloneSubstitutionRegions( substitution, attachedToSimple: true, regions); } + foreach (var executionRegion in simple.ExecutionRegions) + { + CollectStandaloneSubstitutionRegions( + executionRegion, attachedToSimple: false, regions); + } + + break; + case ExecutionRegionSyntax executionRegion: + CollectStandaloneSubstitutionRegions( + executionRegion.Body, attachedToSimple: false, regions); break; case ShellBlockSyntax block: foreach (var statement in block.Statements) @@ -683,6 +906,16 @@ public sealed record ExpectedSyntaxNode public int? IterableSourceStart { get; init; } public int? IterableSourceLength { get; init; } + + public ExecutionRegionOrigin? ExecutionOrigin { get; init; } + + public int? HostClauseElementIndex { get; init; } + + public ExecutionRegionPhase? ExecutionPhase { get; init; } + + public ExecutionRegionTiming? ExecutionTiming { get; init; } + + public ExecutionRegionCardinality? ExecutionCardinality { get; init; } } public sealed record ExpectedCommandOccurrence diff --git a/tests/ShellSyntaxTree.Tests/ShellSyntaxProjectionTests.cs b/tests/ShellSyntaxTree.Tests/ShellSyntaxProjectionTests.cs index 9a4a071..7cfdb25 100644 --- a/tests/ShellSyntaxTree.Tests/ShellSyntaxProjectionTests.cs +++ b/tests/ShellSyntaxTree.Tests/ShellSyntaxProjectionTests.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; using Xunit; namespace ShellSyntaxTree.Tests; @@ -200,6 +201,209 @@ public void Nested_substitutions_are_innermost_first_and_keep_parentage() (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0)); } + [Fact] + public void Direct_execution_region_projects_only_its_body() + { + var remove = Leaf("Remove-Item", 3); + var region = ExecutionRegion( + ExecutionRegionOrigin.DirectCall, + hostClauseElementIndex: null, + 1, + remove); + + var succeeded = ShellSyntaxProjection.TryProject( + Block(0, region), + _ => new CommandOccurrenceFacts { IsComplete = true }, + out var result); + + Assert.True(succeeded); + var occurrence = Assert.Single(result.Commands); + Assert.Equal("Remove-Item", Verb(occurrence)); + Assert.Equal(CommandOccurrenceRole.ExecutionRegion, occurrence.ImmediateRole); + Assert.True(occurrence.IsComplete); + Assert.Same(remove.Clause, Assert.Single(result.Clauses)); + AssertFrames( + occurrence, + (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 0), + (ShellSyntaxKind.ExecutionRegion, CommandAncestryRegion.ExecutionRegion, 0), + (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0)); + } + + [Fact] + public void Command_owned_regions_follow_the_host_in_authored_order() + { + var hostClause = ClauseFor("ForEach-Object") with + { + Elements = new[] + { + new ClauseElement { Role = ClauseElementRole.Verb }, + new ClauseElement + { + Role = ClauseElementRole.Argument, + Kind = ArgKind.DynamicSkip, + }, + new ClauseElement + { + Role = ClauseElementRole.Argument, + Kind = ArgKind.DynamicSkip, + }, + }, + }; + var host = Leaf(hostClause, 1) with + { + Substitutions = new[] + { + Substitution(1, Leaf("prepare", 1)), + }, + ExecutionRegions = new[] + { + ExecutionRegion( + ExecutionRegionOrigin.CommandArgument, + hostClauseElementIndex: 1, + 2, + Leaf("end", 3), + ExecutionRegionPhase.End), + ExecutionRegion( + ExecutionRegionOrigin.CommandArgument, + hostClauseElementIndex: 2, + 4, + Leaf("begin", 5), + ExecutionRegionPhase.Begin), + }, + }; + var pipeline = new PipelineSyntax + { + Stages = new ShellSyntaxNode[] { host }, + }; + + var succeeded = ShellSyntaxProjection.TryProject( + Block(0, pipeline), + _ => new CommandOccurrenceFacts { IsComplete = true }, + out var result); + + Assert.True(succeeded); + Assert.Equal( + new[] { "prepare", "ForEach-Object", "end", "begin" }, + result.Commands.Select(Verb)); + Assert.Equal(result.Commands.Select(command => command.Clause), result.Clauses); + Assert.Equal(CommandOccurrenceRole.Substitution, result.Commands[0].ImmediateRole); + Assert.Equal(CommandOccurrenceRole.PipelineStage, result.Commands[1].ImmediateRole); + Assert.All( + result.Commands.Skip(2), + occurrence => Assert.Equal( + CommandOccurrenceRole.ExecutionRegion, + occurrence.ImmediateRole)); + Assert.All(result.Commands, occurrence => Assert.True(occurrence.IsComplete)); + AssertFrames( + result.Commands[2], + (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 0), + (ShellSyntaxKind.Pipeline, CommandAncestryRegion.PipelineStage, 0), + (ShellSyntaxKind.ExecutionRegion, CommandAncestryRegion.ExecutionRegion, 0), + (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0)); + AssertFrames( + result.Commands[3], + (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 0), + (ShellSyntaxKind.Pipeline, CommandAncestryRegion.PipelineStage, 0), + (ShellSyntaxKind.ExecutionRegion, CommandAncestryRegion.ExecutionRegion, 1), + (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0)); + } + + [Fact] + public void Unknown_execution_region_facts_make_host_and_body_incomplete() + { + var clause = ClauseFor("Invoke-Custom") with + { + Elements = new[] + { + new ClauseElement { Role = ClauseElementRole.Verb }, + new ClauseElement + { + Role = ClauseElementRole.Argument, + Kind = ArgKind.DynamicSkip, + }, + }, + }; + var host = Leaf(clause, 1) with + { + ExecutionRegions = new[] + { + new ExecutionRegionSyntax + { + Origin = ExecutionRegionOrigin.CommandArgument, + HostClauseElementIndex = 1, + Body = Block(2, Leaf("Remove-Item", 3)), + }, + }, + }; + + var succeeded = ShellSyntaxProjection.TryProject( + Block(0, host), + _ => new CommandOccurrenceFacts { IsComplete = true }, + out var result); + + Assert.True(succeeded); + Assert.Equal(new[] { "Invoke-Custom", "Remove-Item" }, result.Commands.Select(Verb)); + Assert.All(result.Commands, occurrence => Assert.False(occurrence.IsComplete)); + } + + [Fact] + public void PowerShell_decoded_wrapper_clone_preserves_regions_and_targets_last_body_leaf() + { + var source = DecodedExecutionRegionTree(); + var wrapperRedirect = new Redirect + { + Direction = RedirectDirection.Out, + Target = "wrapper.txt", + }; + var wrapperElement = new ClauseElement + { + Role = ClauseElementRole.Redirect, + Raw = "> wrapper.txt", + Value = "wrapper.txt", + }; + + var clone = InvokePowerShellDecodedClone( + source, + CompoundOperator.AndIf, + new[] { wrapperRedirect }, + new[] { wrapperElement }); + + var host = Assert.IsType(Assert.Single(clone.Statements)); + var region = Assert.Single(host.ExecutionRegions); + var body = Assert.IsType(Assert.Single(region.Body.Statements)); + Assert.Equal(CompoundOperator.AndIf, host.Clause.Operator); + Assert.Empty(host.Clause.Redirects); + Assert.Equal(CompoundOperator.None, body.Clause.Operator); + Assert.Same(wrapperRedirect, Assert.Single(body.Clause.Redirects)); + Assert.Equal( + wrapperElement with { PrecedingVerbElementCount = 1 }, + Assert.Single(body.Clause.Elements.Skip(1))); + Assert.True(host.Clause.IsSubshell); + Assert.True(body.Clause.IsSubshell); + Assert.True(host.Clause.IsCommandStringWrapped); + Assert.True(body.Clause.IsCommandStringWrapped); + AssertExecutionRegionClone(region); + } + + [Fact] + public void Bash_decoded_wrapper_clone_preserves_regions_after_the_host() + { + var clone = InvokeBashDecodedClone( + DecodedExecutionRegionTree(), + CompoundOperator.OrIf); + + var host = Assert.IsType(Assert.Single(clone.Statements)); + var region = Assert.Single(host.ExecutionRegions); + var body = Assert.IsType(Assert.Single(region.Body.Statements)); + Assert.Equal(CompoundOperator.OrIf, host.Clause.Operator); + Assert.Equal(CompoundOperator.None, body.Clause.Operator); + Assert.True(host.Clause.IsSubshell); + Assert.True(body.Clause.IsSubshell); + Assert.True(host.Clause.IsCommandStringWrapped); + Assert.True(body.Clause.IsCommandStringWrapped); + AssertExecutionRegionClone(region); + } + [Fact] public void Iterator_substitutions_keep_nearest_role_and_authored_indices() { @@ -474,6 +678,23 @@ public void Seventeenth_nested_substitution_discards_partial_projections() Assert.Empty(result.Clauses); } + [Fact] + public void Execution_regions_share_the_structural_depth_budget() + { + var exact = NestedExecutionRegions(ShellAnalysisLimits.MaxStructuralNesting); + var overflow = NestedExecutionRegions(ShellAnalysisLimits.MaxStructuralNesting + 1); + + Assert.True(ShellSyntaxProjection.TryProject(exact, out var exactResult)); + Assert.Single(exactResult.Commands); + Assert.Equal( + (ShellAnalysisLimits.MaxStructuralNesting * 2) + 1, + exactResult.Commands[0].Ancestry.Count); + + Assert.False(ShellSyntaxProjection.TryProject(overflow, out var overflowResult)); + Assert.Empty(overflowResult.Commands); + Assert.Empty(overflowResult.Clauses); + } + [Fact] public void Structural_depth_overflow_discards_partial_projections() { @@ -566,6 +787,28 @@ public void Unknown_or_empty_structural_shapes_fail_closed() }, Body = Block(2, Leaf("foreach-span", 2)), }, + new ExecutionRegionSyntax + { + Body = Block(2, Leaf("unknown-origin", 2)), + }, + new ExecutionRegionSyntax + { + Origin = ExecutionRegionOrigin.DirectCall, + HostClauseElementIndex = 1, + Phase = ExecutionRegionPhase.Main, + Timing = ExecutionRegionTiming.Synchronous, + Cardinality = ExecutionRegionCardinality.Once, + Body = Block(2, Leaf("direct-host-index", 2)), + }, + new ExecutionRegionSyntax + { + Origin = ExecutionRegionOrigin.CommandArgument, + HostClauseElementIndex = 1, + Phase = ExecutionRegionPhase.Main, + Timing = ExecutionRegionTiming.Synchronous, + Cardinality = ExecutionRegionCardinality.Once, + Body = Block(2, Leaf("detached-argument", 2)), + }, }; foreach (var invalidNode in invalidNodes) @@ -579,6 +822,81 @@ public void Unknown_or_empty_structural_shapes_fail_closed() } } + [Fact] + public void Invalid_command_owned_execution_regions_fail_closed() + { + var clause = ClauseFor("host") with + { + Elements = new[] + { + new ClauseElement { Role = ClauseElementRole.Verb }, + new ClauseElement + { + Role = ClauseElementRole.Argument, + Kind = ArgKind.DynamicSkip, + }, + }, + }; + var valid = ExecutionRegion( + ExecutionRegionOrigin.CommandArgument, + hostClauseElementIndex: 1, + 2, + Leaf("body", 3)); + var invalidCollections = new IReadOnlyList[] + { + null!, + new ExecutionRegionSyntax[] { null! }, + new[] { valid with { Origin = ExecutionRegionOrigin.DirectCall } }, + new[] { valid with { HostClauseElementIndex = null } }, + new[] { valid with { HostClauseElementIndex = 2 } }, + new[] { valid with { HostClauseElementIndex = 0 } }, + new[] { valid with { Phase = (ExecutionRegionPhase)999 } }, + new[] { valid with { Body = null! } }, + new[] { valid, valid }, + new[] + { + valid, + valid with + { + Body = Block(4, Leaf("distinct-duplicate-coordinate", 5)), + }, + }, + }; + + foreach (var executionRegions in invalidCollections) + { + var host = Leaf(clause, 1) with { ExecutionRegions = executionRegions }; + var succeeded = ShellSyntaxProjection.TryProject(Block(0, host), out var result); + + Assert.False(succeeded); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + + var literalHost = Leaf( + clause with + { + Elements = new[] + { + new ClauseElement { Role = ClauseElementRole.Verb }, + new ClauseElement + { + Role = ClauseElementRole.Argument, + Kind = ArgKind.Literal, + }, + }, + }, + 1) with + { + ExecutionRegions = new[] { valid }, + }; + Assert.False(ShellSyntaxProjection.TryProject( + Block(0, literalHost), + out var literalResult)); + Assert.Empty(literalResult.Commands); + Assert.Empty(literalResult.Clauses); + } + [Fact] public void Group_and_command_list_retain_condition_and_body_roles() { @@ -946,6 +1264,136 @@ private static ShellBlockSyntax NestedSubstitutions(int count) return Block(0, current); } + private static ShellBlockSyntax NestedExecutionRegions(int count) + { + ShellSyntaxNode current = Leaf("deepest", count); + for (var index = count - 1; index >= 0; index--) + { + current = ExecutionRegion( + ExecutionRegionOrigin.DirectCall, + hostClauseElementIndex: null, + index, + current); + } + + return Block(0, current); + } + + private static ShellBlockSyntax DecodedExecutionRegionTree() + { + var hostClause = ClauseFor("host") with + { + Elements = new[] + { + new ClauseElement { Role = ClauseElementRole.Verb }, + new ClauseElement + { + Role = ClauseElementRole.Argument, + Kind = ArgKind.DynamicSkip, + }, + }, + }; + return Block( + 0, + Leaf(hostClause, 1) with + { + ExecutionRegions = new[] + { + ExecutionRegion( + ExecutionRegionOrigin.CommandArgument, + hostClauseElementIndex: 1, + 2, + Leaf("body", 3), + ExecutionRegionPhase.End) with + { + Timing = ExecutionRegionTiming.Concurrent, + Cardinality = ExecutionRegionCardinality.ZeroOrMore, + }, + }, + }); + } + + private static ShellBlockSyntax InvokePowerShellDecodedClone( + ShellBlockSyntax source, + CompoundOperator firstOperator, + IReadOnlyList wrapperRedirects, + IReadOnlyList wrapperElements) + { + var parserType = typeof(PwshParser).Assembly.GetType( + "ShellSyntaxTree.Internal.Pwsh.Parsing.PwshCommandParser", + throwOnError: true)!; + var stateType = parserType.GetNestedType( + "DecodedCloneState", + BindingFlags.NonPublic)!; + var state = Activator.CreateInstance(stateType, nonPublic: true)!; + SetProperty(stateType, state, "FirstOperator", firstOperator); + SetProperty(stateType, state, "OuterSubshell", true); + SetProperty(stateType, state, "LeafCount", 2); + SetProperty(stateType, state, "WrapperRedirects", wrapperRedirects); + SetProperty(stateType, state, "WrapperRedirectElements", wrapperElements); + var method = parserType.GetMethod( + "TryCloneDecodedBlock", + BindingFlags.Static | BindingFlags.NonPublic)!; + var arguments = new object?[] { source, state, null }; + + Assert.True((bool)method.Invoke(null, arguments)!); + Assert.Equal(2, GetProperty(stateType, state, "LeafIndex")); + return Assert.IsType(arguments[2]); + } + + private static ShellBlockSyntax InvokeBashDecodedClone( + ShellBlockSyntax source, + CompoundOperator firstOperator) + { + var parserType = typeof(BashParser).Assembly.GetType( + "ShellSyntaxTree.Internal.Bash.Parsing.BashCommandParser", + throwOnError: true)!; + var method = parserType + .GetMethods(BindingFlags.Static | BindingFlags.NonPublic) + .Single(candidate => + candidate.Name == "TryCloneDecodedBlock" && + candidate.GetParameters()[4].ParameterType == + typeof(ShellBlockSyntax).MakeByRefType()); + var arguments = new object?[] + { + source, + firstOperator, + true, + true, + null, + null, + }; + + Assert.True((bool)method.Invoke(null, arguments)!); + Assert.False(Assert.IsType(arguments[3])); + Assert.NotNull(arguments[5]); + return Assert.IsType(arguments[4]); + } + + private static void AssertExecutionRegionClone(ExecutionRegionSyntax region) + { + Assert.Equal(ExecutionRegionOrigin.CommandArgument, region.Origin); + Assert.Equal(1, region.HostClauseElementIndex); + Assert.Equal(ExecutionRegionPhase.End, region.Phase); + Assert.Equal(ExecutionRegionTiming.Concurrent, region.Timing); + Assert.Equal(ExecutionRegionCardinality.ZeroOrMore, region.Cardinality); + Assert.Null(region.SourceStart); + Assert.Null(region.SourceLength); + } + + private static void SetProperty( + Type declaringType, + object target, + string name, + object value) => + declaringType.GetProperty(name, BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(target, value); + + private static T GetProperty(Type declaringType, object target, string name) => + Assert.IsType(declaringType + .GetProperty(name, BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(target)); + private static ShellBlockSyntax Block(int sourceStart, params ShellSyntaxNode[] statements) => new() { @@ -975,6 +1423,24 @@ private static CommandSubstitutionSyntax Substitution( Body = Block(sourceStart, statements), }; + private static ExecutionRegionSyntax ExecutionRegion( + ExecutionRegionOrigin origin, + int? hostClauseElementIndex, + int sourceStart, + ShellSyntaxNode statement, + ExecutionRegionPhase phase = ExecutionRegionPhase.Main) => + new() + { + Origin = origin, + HostClauseElementIndex = hostClauseElementIndex, + Phase = phase, + Timing = ExecutionRegionTiming.Synchronous, + Cardinality = ExecutionRegionCardinality.Once, + SourceStart = sourceStart, + SourceLength = 1, + Body = Block(sourceStart, statement), + }; + private static Clause ClauseFor( string verb, CompoundOperator @operator = CompoundOperator.None) =>