From 4dba992321dc2e57af3f74c09f45ff5cac35679a Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 7 Aug 2026 03:03:52 +0000 Subject: [PATCH] Build structural command projections --- IMPLEMENTATION_PLAN.md | 15 +- SPEC.md | 24 + .../executable-command-projection/spec.md | 43 + .../v0-3-structured-shell-analysis/tasks.md | 4 +- src/ShellSyntaxTree/ShellSyntaxProjection.cs | 744 ++++++++++++ .../ShellSyntaxProjectionTests.cs | 1023 +++++++++++++++++ 6 files changed, 1849 insertions(+), 4 deletions(-) create mode 100644 src/ShellSyntaxTree/ShellSyntaxProjection.cs create mode 100644 tests/ShellSyntaxTree.Tests/ShellSyntaxProjectionTests.cs diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index a70ab80..9020b7b 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -223,8 +223,19 @@ priorities. and assembly-only closure mechanism. Until the projection passes land, `Syntax` is an empty block and `Commands` is empty, so early use remains fail-closed while v0.2 `Clauses` behavior is unchanged. -- [ ] Add the structural and command-occurrence projections for the existing - grammar before enabling any control-flow construct. +- [x] Add the parser-owned structural projector and conservative compatibility + flattener. It walks every syntax shape in deterministic authored order, + assigns immediate roles and compositional ancestry, preserves the exact + `Clause` instance and its authored operator, joins parser-owned analysis + facts without mutating compatibility leaves, and discards every partial + projection on malformed, aliased, cyclic, or over-depth structure or + invalid joined facts. Direct tests pin ordering, branch and pipeline + precedence, ancestry coordinates, reference identity, span and enum + validity, value/redirect invariants, safe defaults, copied collections, + and the 16-container bound. +- [ ] Adapt the existing Bash and PowerShell grammars to emit the structural + and command-occurrence projections before enabling any control-flow + construct. - [ ] Deliver paired Bash and PowerShell `$()` substitution slices for all locked executable value positions, including ordering, ancestry, shell-specific cwd propagation, literal/escaped boundaries, dynamic diff --git a/SPEC.md b/SPEC.md index a08b599..e1d03f7 100644 --- a/SPEC.md +++ b/SPEC.md @@ -490,6 +490,30 @@ zero-based `ChildIndex` in the structural collection that owns the `CommandSubstitutionSyntax`. For a simple command this is its `Substitutions` collection; for an iterator it is the containing iterator-command collection. +Each ancestry frame describes the relationship from its `AncestorKind` to the +next node on the path. The root block uses `Root`; non-root blocks and command +lists use `Statement`; pipelines use `PipelineStage`; groups use `GroupBody`; +foreach nodes use `Iterator` or `LoopBody`; condition loops use `Condition` or +`LoopBody`; conditionals use `Branch`; conditional-branch nodes use +`Condition` or `Branch`; and command substitutions use `Substitution`. +Repeated children use their zero-based authored index. The `else` child uses +the branch count, placing it after every condition/body pair. Frame source +ranges belong to the ancestor. Blocks, command lists, and groups retain the +incoming immediate role; pipeline stages, iterator/body regions, +condition/body regions, branches, and substitutions replace it with their +nearer execution role. + +Projection accepts only a parser-owned tree: a syntax-node or `Clause` +reference cannot appear at two authored positions, node and fragment spans are +either both unavailable or a non-negative start/length pair, and structural +enum values consumed by the projector must be known. Empty blocks remain +valid, but empty pipelines, command lists, and conditionals are malformed. +Joined value domains, effective-argument coordinates, cwd facts, redirect +coordinates, redirect shapes, and heredoc facts must satisfy their contracts. +Any violation discards the partial `Commands` and `Clauses` collections and +makes the outer parse unparseable; it is never published as a complete +occurrence. + `EffectiveArgument.ClauseElementIndex` is a stable authored coordinate. The analysis never mutates a compatibility `Arg` to hold loop-specific values. An occurrence can be structurally complete while one effective value remains diff --git a/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md b/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md index aee727e..8d680e2 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md +++ b/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md @@ -25,6 +25,49 @@ their enum zero values. Ancestry SHALL be ordered outermost to innermost, exclude the simple-command leaf, and retain child indices and exact-or-null source ranges for correlation. +Each frame SHALL describe the relationship from its ancestor to the next node +on the path. The root block SHALL use `Root`; non-root blocks and command lists +SHALL use `Statement`; pipelines SHALL use `PipelineStage`; groups SHALL use +`GroupBody`; foreach nodes SHALL use `Iterator` or `LoopBody`; condition loops +SHALL use `Condition` or `LoopBody`; conditionals SHALL use `Branch`; +conditional-branch nodes SHALL use `Condition` or `Branch`; and substitutions +SHALL use `Substitution`. Repeated children SHALL use their zero-based authored +index, with an `else` child indexed after all conditional branches. Frame +source ranges SHALL identify the ancestor. Blocks, command lists, and groups +SHALL retain the incoming immediate role; a nearer pipeline, iterator, body, +condition, branch, or substitution relation SHALL replace it. + +#### Scenario: Root and nested block coordinates are deterministic +- **WHEN** a root statement contains a loop-body pipeline +- **THEN** a stage occurrence has outer-to-inner `Root`, `LoopBody`, + `Statement`, and `PipelineStage` ancestry +- **THEN** each repeated relation carries its authored child index + +### Requirement: Projection rejects malformed parser-owned structure and facts +The projector SHALL accept only a tree with one syntax-node and one `Clause` +reference per authored simple-command position. Node and source-fragment spans +SHALL be both unavailable or a non-negative start/length pair. Structural enum +values consumed by projection SHALL be known. Empty blocks MAY be valid, but +empty pipelines, command lists, and conditionals SHALL be rejected. + +Value domains, cwd facts, effective-argument coordinates, redirect +coordinates, redirect shapes, and heredoc facts SHALL satisfy their locked +record invariants before projection succeeds. Any repeated identity, malformed +shape, invalid coordinate, cycle, or depth overflow SHALL discard every +partial command and compatibility result. + +#### Scenario: Shared leaf identity is not counted twice +- **WHEN** an internal parser bug places one syntax leaf or `Clause` reference + at two authored positions +- **THEN** projection fails instead of emitting two occurrences +- **THEN** no partial command or compatibility projection is returned + +#### Scenario: Complete occurrence cannot contain invalid facts +- **WHEN** parser-owned analysis supplies an invalid value domain, argument + coordinate, redirect shape, or unknown scope-affecting structural kind +- **THEN** projection fails closed +- **THEN** the occurrence is not published with `IsComplete=true` + #### Scenario: While condition and body roles - **WHEN** Bash parses `while curl URL; do sleep 1; done` - **THEN** `curl` is identified as a condition occurrence diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md index 4cc8c91..853c955 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -25,8 +25,8 @@ - [x] 3.1 Add the locked public syntax-node hierarchy and defaults to the public API snapshot. - [x] 3.2 Add the locked command-occurrence, role, ancestry, completeness, and analysis records to the public API snapshot. - [x] 3.3 Add `ParsedCommand.Syntax` and `ParsedCommand.Commands` while retaining all v0.2 members. -- [ ] 3.4 Build a library-owned traversal that emits each simple command occurrence exactly once in deterministic source order. -- [ ] 3.5 Build the conservative `Clauses` compatibility flattener without inventing cross-structure compound operators. +- [x] 3.4 Build a library-owned traversal that emits each simple command occurrence exactly once in deterministic source order. +- [x] 3.5 Build the conservative `Clauses` compatibility flattener without inventing cross-structure compound operators. - [ ] 3.6 Adapt the existing Bash grammar to emit the structural model with no newly supported syntax. - [ ] 3.7 Adapt the existing PowerShell grammar to emit the structural model with no newly supported syntax. - [ ] 3.8 Add tests proving existing parser inputs retain their v0.2 leaf and compatibility results. diff --git a/src/ShellSyntaxTree/ShellSyntaxProjection.cs b/src/ShellSyntaxTree/ShellSyntaxProjection.cs new file mode 100644 index 0000000..348e0bf --- /dev/null +++ b/src/ShellSyntaxTree/ShellSyntaxProjection.cs @@ -0,0 +1,744 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace ShellSyntaxTree; + +/// +/// Parser-owned analysis that is joined to a simple-command leaf while its +/// structural projections are built. +/// +internal sealed class CommandOccurrenceFacts +{ + internal IReadOnlyList EffectiveArguments { get; init; } = + Array.Empty(); + + internal ShellValueDomain WorkingDirectory { get; init; } = ShellValueDomain.Unknown; + + internal IReadOnlyList Redirects { get; init; } = + Array.Empty(); + + internal bool IsComplete { get; init; } +} + +/// One successful projection of a parser-owned syntax tree. +internal sealed class ShellProjectionResult +{ + internal static ShellProjectionResult Empty { get; } = new(); + + internal IReadOnlyList Commands { get; init; } = + Array.Empty(); + + internal IReadOnlyList Clauses { get; init; } = Array.Empty(); +} + +/// +/// Owns executable-command discovery so security consumers never need to +/// recursively match syntax-node types. +/// +internal static class ShellSyntaxProjection +{ + internal static bool TryProject( + ShellBlockSyntax syntax, + out ShellProjectionResult result) => + TryProject(syntax, _ => new CommandOccurrenceFacts(), out result); + + internal static bool TryProject( + ShellBlockSyntax syntax, + Func factsFactory, + out ShellProjectionResult result) + { + if (syntax is null || factsFactory is null) + { + result = ShellProjectionResult.Empty; + return false; + } + + var walker = new ProjectionWalker(factsFactory); + return walker.TryProject(syntax, out result); + } + + private sealed class ProjectionWalker + { + private readonly Func _factsFactory; + private readonly List _ancestry = new(); + private readonly List _commands = new(); + private readonly List _clauses = new(); + private readonly HashSet _visitedNodes = + new(NodeReferenceComparer.Instance); + private readonly HashSet _visitedClauses = + new(ClauseReferenceComparer.Instance); + + internal ProjectionWalker( + Func factsFactory) + { + _factsFactory = factsFactory; + } + + internal bool TryProject( + ShellBlockSyntax syntax, + out ShellProjectionResult result) + { + if (!TryVisit( + syntax, + CommandOccurrenceRole.Ordinary, + structuralDepth: 0, + isRoot: true)) + { + result = ShellProjectionResult.Empty; + return false; + } + + result = new ShellProjectionResult + { + Commands = _commands.ToArray(), + Clauses = _clauses.ToArray(), + }; + return true; + } + + private bool TryVisit( + ShellSyntaxNode node, + CommandOccurrenceRole role, + int structuralDepth, + bool isRoot = false, + int? substitutionChildIndex = null) + { + if (node is null || + !IsValidSpan(node.SourceStart, node.SourceLength) || + !_visitedNodes.Add(node)) + { + return false; + } + + var nextDepth = structuralDepth + (CountsTowardStructuralDepth(node) ? 1 : 0); + if (nextDepth > ShellAnalysisLimits.MaxStructuralNesting) + { + return false; + } + + var succeeded = node switch + { + ShellBlockSyntax block => TryVisitBlock(block, role, nextDepth, isRoot), + SimpleCommandSyntax simple => TryVisitSimple(simple, role, nextDepth), + PipelineSyntax pipeline => TryVisitPipeline(pipeline, nextDepth), + CommandListSyntax list => TryVisitCommandList(list, role, nextDepth), + GroupSyntax group => + group.GroupKind is ShellGroupKind.CurrentScope or ShellGroupKind.IsolatedScope && + group.Body is not null && + TryVisitChild( + group, + group.Body, + CommandAncestryRegion.GroupBody, + childIndex: null, + role, + nextDepth), + ForEachSyntax forEach => TryVisitForEach(forEach, nextDepth), + ConditionLoopSyntax loop => + loop.LoopKind is ConditionLoopKind.While or ConditionLoopKind.Until && + TryVisitConditionLoop(loop, nextDepth), + ConditionalSyntax conditional => TryVisitConditional(conditional, role, nextDepth), + ConditionalBranchSyntax branch => TryVisitConditionalBranch(branch, nextDepth), + CommandSubstitutionSyntax substitution => + TryVisitCommandSubstitution( + substitution, + substitutionChildIndex, + nextDepth), + _ => false, + }; + + return succeeded; + } + + private bool TryVisitBlock( + ShellBlockSyntax block, + CommandOccurrenceRole role, + int structuralDepth, + bool isRoot) + { + if (block.Statements is null) + { + return false; + } + + var region = isRoot + ? CommandAncestryRegion.Root + : CommandAncestryRegion.Statement; + for (var index = 0; index < block.Statements.Count; index++) + { + var statement = block.Statements[index]; + if (statement is null || + !TryVisitChild( + block, + statement, + region, + index, + role, + structuralDepth)) + { + return false; + } + } + + return true; + } + + private bool TryVisitSimple( + SimpleCommandSyntax simple, + CommandOccurrenceRole role, + int structuralDepth) + { + if (simple.Substitutions is null) + { + return false; + } + + for (var index = 0; index < simple.Substitutions.Count; index++) + { + var substitution = simple.Substitutions[index]; + if (substitution is null || + !TryVisit( + substitution, + CommandOccurrenceRole.Substitution, + structuralDepth, + substitutionChildIndex: index)) + { + return false; + } + } + + if (simple.Clause is null || + !IsValidClauseShape(simple.Clause) || + !_visitedClauses.Add(simple.Clause)) + { + return false; + } + + var facts = _factsFactory(simple); + if (!TryCopyFacts( + simple.Clause, + facts, + out var effectiveArguments, + out var workingDirectory, + out var redirects)) + { + return false; + } + + _commands.Add(new CommandOccurrence + { + Clause = simple.Clause, + ImmediateRole = role, + Ancestry = _ancestry.ToArray(), + EffectiveArguments = effectiveArguments, + WorkingDirectory = workingDirectory, + Redirects = redirects, + IsComplete = facts.IsComplete, + }); + _clauses.Add(simple.Clause); + return true; + } + + private bool TryVisitCommandSubstitution( + CommandSubstitutionSyntax substitution, + int? childIndex, + int structuralDepth) => + substitution.Body is not null && + TryVisitChild( + substitution, + substitution.Body, + CommandAncestryRegion.Substitution, + childIndex, + CommandOccurrenceRole.Substitution, + structuralDepth); + + private bool TryVisitPipeline( + PipelineSyntax pipeline, + int structuralDepth) + { + if (pipeline.Stages is null || pipeline.Stages.Count == 0) + { + return false; + } + + for (var index = 0; index < pipeline.Stages.Count; index++) + { + var stage = pipeline.Stages[index]; + if (stage is null || + !TryVisitChild( + pipeline, + stage, + CommandAncestryRegion.PipelineStage, + index, + CommandOccurrenceRole.PipelineStage, + structuralDepth)) + { + return false; + } + } + + return true; + } + + private bool TryVisitCommandList( + CommandListSyntax list, + CommandOccurrenceRole role, + int structuralDepth) + { + if (list.Items is null || list.Items.Count == 0) + { + return false; + } + + for (var index = 0; index < list.Items.Count; index++) + { + var item = list.Items[index]; + if (item is null || + item.Command is null || + !Enum.IsDefined(typeof(CompoundOperator), item.Operator) || + !TryVisitChild( + list, + item.Command, + CommandAncestryRegion.Statement, + index, + role, + structuralDepth)) + { + return false; + } + } + + return true; + } + + private bool TryVisitForEach( + ForEachSyntax forEach, + int structuralDepth) + { + if (forEach.Binding is null || + string.IsNullOrEmpty(forEach.Binding.Name) || + forEach.Binding.Source is null || + !IsValidSourceFragment(forEach.Binding.Source) || + forEach.Iterable is null || + !IsValidSourceFragment(forEach.Iterable) || + forEach.IteratorCommands is null || + forEach.Body is null) + { + return false; + } + + return TryVisitChild( + forEach, + forEach.IteratorCommands, + CommandAncestryRegion.Iterator, + childIndex: null, + CommandOccurrenceRole.Iterator, + structuralDepth) && + TryVisitChild( + forEach, + forEach.Body, + CommandAncestryRegion.LoopBody, + childIndex: null, + CommandOccurrenceRole.LoopBody, + structuralDepth); + } + + private bool TryVisitConditionLoop( + ConditionLoopSyntax loop, + int structuralDepth) + { + if (loop.Condition is null || loop.Body is null) + { + return false; + } + + return TryVisitChild( + loop, + loop.Condition, + CommandAncestryRegion.Condition, + childIndex: null, + CommandOccurrenceRole.Condition, + structuralDepth) && + TryVisitChild( + loop, + loop.Body, + CommandAncestryRegion.LoopBody, + childIndex: null, + CommandOccurrenceRole.LoopBody, + structuralDepth); + } + + private bool TryVisitConditional( + ConditionalSyntax conditional, + CommandOccurrenceRole role, + int structuralDepth) + { + if (conditional.Branches is null || conditional.Branches.Count == 0) + { + return false; + } + + for (var index = 0; index < conditional.Branches.Count; index++) + { + var branch = conditional.Branches[index]; + if (branch is null || + !TryVisitChild( + conditional, + branch, + CommandAncestryRegion.Branch, + index, + role, + structuralDepth)) + { + return false; + } + } + + return conditional.Else is null || + TryVisitChild( + conditional, + conditional.Else, + CommandAncestryRegion.Branch, + conditional.Branches.Count, + CommandOccurrenceRole.Branch, + structuralDepth); + } + + private bool TryVisitConditionalBranch( + ConditionalBranchSyntax branch, + int structuralDepth) + { + if (branch.Condition is null || branch.Body is null) + { + return false; + } + + return TryVisitChild( + branch, + branch.Condition, + CommandAncestryRegion.Condition, + childIndex: null, + CommandOccurrenceRole.Condition, + structuralDepth) && + TryVisitChild( + branch, + branch.Body, + CommandAncestryRegion.Branch, + childIndex: null, + CommandOccurrenceRole.Branch, + structuralDepth); + } + + private bool TryVisitChild( + ShellSyntaxNode ancestor, + ShellSyntaxNode child, + CommandAncestryRegion region, + int? childIndex, + CommandOccurrenceRole role, + int structuralDepth) + { + _ancestry.Add(new CommandAncestryFrame + { + AncestorKind = ancestor.Kind, + Region = region, + ChildIndex = childIndex, + SourceStart = ancestor.SourceStart, + SourceLength = ancestor.SourceLength, + }); + var succeeded = TryVisit( + child, + role, + structuralDepth, + substitutionChildIndex: child is CommandSubstitutionSyntax + ? childIndex + : null); + _ancestry.RemoveAt(_ancestry.Count - 1); + return succeeded; + } + + private static bool CountsTowardStructuralDepth(ShellSyntaxNode node) => + node is ForEachSyntax or + ConditionLoopSyntax or + ConditionalSyntax or + GroupSyntax or + CommandSubstitutionSyntax; + + private static bool TryCopyFacts( + Clause clause, + CommandOccurrenceFacts facts, + out IReadOnlyList effectiveArguments, + out ShellValueDomain workingDirectory, + out IReadOnlyList redirects) + { + effectiveArguments = Array.Empty(); + workingDirectory = ShellValueDomain.Unknown; + redirects = Array.Empty(); + if (facts is null || + facts.EffectiveArguments is null || + facts.WorkingDirectory is null || + facts.Redirects is null || + ContainsNull(facts.EffectiveArguments) || + ContainsNull(facts.Redirects) || + !IsValidValueDomain(facts.WorkingDirectory) || + facts.WorkingDirectory.Kind is not ( + ShellValueDomainKind.Unknown or ShellValueDomainKind.Exact) || + !AreValidEffectiveArguments(clause, facts.EffectiveArguments) || + !AreValidRedirects(clause, facts.Redirects, facts.IsComplete) || + facts.IsComplete && + (clause.Verb.IsDynamic || clause.Verb.Tokens.Count == 0)) + { + return false; + } + + effectiveArguments = Copy(facts.EffectiveArguments); + workingDirectory = facts.WorkingDirectory; + redirects = Copy(facts.Redirects); + return true; + } + + private static bool IsValidClauseShape(Clause clause) => + Enum.IsDefined(typeof(CompoundOperator), clause.Operator) && + clause.Verb is not null && + clause.Verb.Tokens is not null && + clause.Args is not null && + clause.Redirects is not null && + clause.Elements is not null && + !ContainsNull(clause.Verb.Tokens) && + !ContainsNull(clause.Args) && + !ContainsNull(clause.Redirects) && + !ContainsNull(clause.Elements); + + private static bool AreValidEffectiveArguments( + Clause clause, + IReadOnlyList arguments) + { + var coordinates = new HashSet(); + for (var index = 0; index < arguments.Count; index++) + { + var argument = arguments[index]; + if (argument.ClauseElementIndex < 0 || + argument.ClauseElementIndex >= clause.Elements.Count || + clause.Elements[argument.ClauseElementIndex].Role != ClauseElementRole.Argument || + !coordinates.Add(argument.ClauseElementIndex) || + argument.Value is null || + !IsValidValueDomain(argument.Value)) + { + return false; + } + } + + return true; + } + + private static bool AreValidRedirects( + Clause clause, + IReadOnlyList redirects, + bool occurrenceIsComplete) + { + if (occurrenceIsComplete && redirects.Count != clause.Redirects.Count) + { + return false; + } + + var coordinates = new HashSet(); + for (var index = 0; index < redirects.Count; index++) + { + var redirect = redirects[index]; + if (redirect.RedirectIndex < 0 || + redirect.RedirectIndex >= clause.Redirects.Count || + !coordinates.Add(redirect.RedirectIndex) || + occurrenceIsComplete && !redirect.IsComplete || + !IsValidRedirect(redirect)) + { + return false; + } + } + + return true; + } + + private static bool IsValidRedirect(RedirectAnalysis redirect) + { + if (redirect.Source is null || + redirect.Target is null || + !IsValidRedirectSource(redirect.Source) || + !IsValidValueDomain(redirect.Target)) + { + return false; + } + + if (redirect.IsComplete && redirect.Source.Kind == RedirectSourceKind.Unknown) + { + return false; + } + + return redirect.Operation switch + { + RedirectOperation.Unknown => + !redirect.IsComplete && + redirect.TargetDescriptor is null && + redirect.HereDocument is null && + !redirect.IsPathRelevant && + redirect.Target.Kind == ShellValueDomainKind.Unknown, + RedirectOperation.FileInput or + RedirectOperation.FileOutput or + RedirectOperation.FileAppend or + RedirectOperation.CombinedOutput or + RedirectOperation.CombinedOutputAppend => + redirect.TargetDescriptor is null && + redirect.HereDocument is null && + redirect.IsPathRelevant, + RedirectOperation.DescriptorDuplicate or RedirectOperation.DescriptorMove => + (!redirect.IsComplete || redirect.TargetDescriptor >= 0) && + (redirect.TargetDescriptor is null || redirect.TargetDescriptor >= 0) && + redirect.HereDocument is null && + !redirect.IsPathRelevant && + redirect.Target.Kind == ShellValueDomainKind.Unknown, + RedirectOperation.DescriptorClose => + redirect.TargetDescriptor is null && + redirect.HereDocument is null && + !redirect.IsPathRelevant && + redirect.Target.Kind == ShellValueDomainKind.Unknown, + RedirectOperation.HereDocument => + redirect.TargetDescriptor is null && + redirect.HereDocument is not null && + !redirect.IsPathRelevant && + redirect.Target.Kind == ShellValueDomainKind.Unknown && + IsValidHereDocument(redirect.HereDocument, redirect.IsComplete), + RedirectOperation.HereString => + redirect.TargetDescriptor is null && + redirect.HereDocument is null && + !redirect.IsPathRelevant, + _ => false, + }; + } + + private static bool IsValidRedirectSource(RedirectSource source) => + source.Kind switch + { + RedirectSourceKind.Unknown => source.Descriptor is null, + RedirectSourceKind.Default => source.Descriptor is null, + RedirectSourceKind.Descriptor => source.Descriptor >= 0, + RedirectSourceKind.PowerShellAllStreams => source.Descriptor is null, + _ => false, + }; + + private static bool IsValidHereDocument( + HereDocumentAnalysis hereDocument, + bool redirectIsComplete) => + hereDocument.Delimiter is not null && + hereDocument.Body is not null && + IsValidSourceFragment(hereDocument.Delimiter) && + IsValidSourceFragment(hereDocument.Body) && + hereDocument.ExpansionMode is + HereDocumentExpansionMode.Unknown or + HereDocumentExpansionMode.Literal or + HereDocumentExpansionMode.Expand && + (!hereDocument.IsComplete || + hereDocument.ExpansionMode is + HereDocumentExpansionMode.Literal or HereDocumentExpansionMode.Expand) && + (!redirectIsComplete || hereDocument.IsComplete); + + private static bool IsValidSourceFragment(ShellSourceFragment fragment) => + fragment.Raw is not null && + IsValidSpan(fragment.SourceStart, fragment.SourceLength); + + private static bool IsValidSpan(int? sourceStart, int? sourceLength) => + sourceStart.HasValue == sourceLength.HasValue && + (!sourceStart.HasValue || sourceStart >= 0 && sourceLength >= 0); + + private static bool IsValidValueDomain(ShellValueDomain domain) + { + if (domain.Values is null || ContainsNull(domain.Values)) + { + return false; + } + + return domain.Kind switch + { + ShellValueDomainKind.Unknown => + domain.Values.Count == 0 && + domain.Pattern is null && + domain.CoveringDirectory is null, + ShellValueDomainKind.Exact => + domain.Values.Count == 1 && + domain.Pattern is null && + domain.CoveringDirectory is null, + ShellValueDomainKind.FiniteSet => + domain.Values.Count >= 2 && + domain.Values.Count <= ShellAnalysisLimits.MaxValueCandidates && + AreDistinct(domain.Values) && + domain.Pattern is null && + domain.CoveringDirectory is null, + ShellValueDomainKind.Pattern => + domain.Values.Count == 0 && + !string.IsNullOrEmpty(domain.Pattern) && + !string.IsNullOrEmpty(domain.CoveringDirectory), + _ => false, + }; + } + + private static bool AreDistinct(IReadOnlyList values) + { + var distinct = new HashSet(StringComparer.Ordinal); + for (var index = 0; index < values.Count; index++) + { + if (!distinct.Add(values[index])) + { + return false; + } + } + + return true; + } + + private static T[] Copy(IReadOnlyList items) + { + var copy = new T[items.Count]; + for (var index = 0; index < items.Count; index++) + { + copy[index] = items[index]; + } + + return copy; + } + + private static bool ContainsNull(IReadOnlyList items) + where T : class + { + for (var index = 0; index < items.Count; index++) + { + if (items[index] is null) + { + return true; + } + } + + return false; + } + } + + private sealed class NodeReferenceComparer : IEqualityComparer + { + internal static NodeReferenceComparer Instance { get; } = new(); + + public bool Equals(ShellSyntaxNode? x, ShellSyntaxNode? y) => + ReferenceEquals(x, y); + + public int GetHashCode(ShellSyntaxNode obj) => RuntimeHelpers.GetHashCode(obj); + } + + private sealed class ClauseReferenceComparer : IEqualityComparer + { + internal static ClauseReferenceComparer Instance { get; } = new(); + + public bool Equals(Clause? x, Clause? y) => ReferenceEquals(x, y); + + public int GetHashCode(Clause obj) => RuntimeHelpers.GetHashCode(obj); + } +} diff --git a/tests/ShellSyntaxTree.Tests/ShellSyntaxProjectionTests.cs b/tests/ShellSyntaxTree.Tests/ShellSyntaxProjectionTests.cs new file mode 100644 index 0000000..9a4a071 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/ShellSyntaxProjectionTests.cs @@ -0,0 +1,1023 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace ShellSyntaxTree.Tests; + +/// Tests parser-owned command and compatibility projections. +public class ShellSyntaxProjectionTests +{ + [Fact] + public void Root_command_preserves_identity_and_joins_analysis_facts() + { + var clause = ClauseFor("echo") with + { + Elements = new[] + { + new ClauseElement { Role = ClauseElementRole.Verb }, + new ClauseElement { Role = ClauseElementRole.Argument }, + }, + Redirects = new[] { new Redirect() }, + }; + var leaf = new SimpleCommandSyntax + { + Clause = clause, + SourceStart = 0, + SourceLength = 10, + }; + var root = new ShellBlockSyntax + { + SourceStart = 0, + SourceLength = 10, + Statements = new ShellSyntaxNode[] { leaf }, + }; + var effectiveArguments = new[] + { + new EffectiveArgument + { + ClauseElementIndex = 1, + Value = new ShellValueDomain + { + Kind = ShellValueDomainKind.Exact, + Values = new[] { "ready" }, + }, + }, + }; + var redirects = new[] + { + new RedirectAnalysis + { + RedirectIndex = 0, + Source = new RedirectSource { Kind = RedirectSourceKind.Default }, + Operation = RedirectOperation.FileOutput, + Target = new ShellValueDomain + { + Kind = ShellValueDomainKind.Exact, + Values = new[] { "/work/out.txt" }, + }, + IsPathRelevant = true, + IsComplete = true, + }, + }; + var workingDirectory = new ShellValueDomain + { + Kind = ShellValueDomainKind.Exact, + Values = new[] { "/work" }, + }; + + var succeeded = ShellSyntaxProjection.TryProject( + root, + _ => new CommandOccurrenceFacts + { + EffectiveArguments = effectiveArguments, + WorkingDirectory = workingDirectory, + Redirects = redirects, + IsComplete = true, + }, + out var result); + + Assert.True(succeeded); + var occurrence = Assert.Single(result.Commands); + Assert.Same(clause, occurrence.Clause); + Assert.Same(clause, Assert.Single(result.Clauses)); + Assert.Equal(CommandOccurrenceRole.Ordinary, occurrence.ImmediateRole); + Assert.True(occurrence.IsComplete); + Assert.Same(workingDirectory, occurrence.WorkingDirectory); + Assert.NotSame(effectiveArguments, occurrence.EffectiveArguments); + Assert.Same(effectiveArguments[0], Assert.Single(occurrence.EffectiveArguments)); + Assert.NotSame(redirects, occurrence.Redirects); + Assert.Same(redirects[0], Assert.Single(occurrence.Redirects)); + + var rootFrame = Assert.Single(occurrence.Ancestry); + Assert.Equal(ShellSyntaxKind.Block, rootFrame.AncestorKind); + Assert.Equal(CommandAncestryRegion.Root, rootFrame.Region); + Assert.Equal(0, rootFrame.ChildIndex); + Assert.Equal(0, rootFrame.SourceStart); + Assert.Equal(10, rootFrame.SourceLength); + } + + [Fact] + public void Embedded_substitution_precedes_its_containing_command() + { + var find = Leaf("find", 5); + var remove = Leaf("rm", 0) with + { + Substitutions = new[] + { + Substitution(3, find), + }, + }; + var root = Block(0, remove); + + var succeeded = ShellSyntaxProjection.TryProject(root, out var result); + + Assert.True(succeeded); + Assert.Equal(new[] { "find", "rm" }, result.Commands.Select(Verb)); + Assert.Equal(result.Commands.Select(command => command.Clause), result.Clauses); + Assert.Same(find.Clause, result.Clauses[0]); + Assert.Same(remove.Clause, result.Clauses[1]); + AssertFrames( + result.Commands[0], + (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 0), + (ShellSyntaxKind.CommandSubstitution, CommandAncestryRegion.Substitution, 0), + (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0)); + AssertFrames( + result.Commands[1], + (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 0)); + } + + [Fact] + public void Sibling_substitutions_use_authored_order_and_child_indices_without_spans() + { + var first = Leaf("first", 1); + var second = Leaf("second", 2); + var outer = Leaf("printf", 0) with + { + Substitutions = new[] + { + new CommandSubstitutionSyntax + { + Body = new ShellBlockSyntax + { + Statements = new ShellSyntaxNode[] { first }, + }, + }, + new CommandSubstitutionSyntax + { + Body = new ShellBlockSyntax + { + Statements = new ShellSyntaxNode[] { second }, + }, + }, + }, + }; + + var succeeded = ShellSyntaxProjection.TryProject(Block(0, outer), out var result); + + Assert.True(succeeded); + Assert.Equal(new[] { "first", "second", "printf" }, result.Commands.Select(Verb)); + Assert.Equal(0, result.Commands[0].Ancestry[1].ChildIndex); + Assert.Equal(1, result.Commands[1].Ancestry[1].ChildIndex); + Assert.Null(result.Commands[0].Ancestry[1].SourceStart); + Assert.Null(result.Commands[1].Ancestry[1].SourceStart); + } + + [Fact] + public void Nested_substitutions_are_innermost_first_and_keep_parentage() + { + var whoami = Leaf("whoami", 9); + var echo = Leaf("echo", 5) with + { + Substitutions = new[] + { + Substitution(8, whoami), + }, + }; + var print = Leaf("printf", 0) with + { + Substitutions = new[] + { + Substitution(3, echo), + }, + }; + + var succeeded = ShellSyntaxProjection.TryProject(Block(0, print), out var result); + + Assert.True(succeeded); + Assert.Equal(new[] { "whoami", "echo", "printf" }, result.Commands.Select(Verb)); + AssertFrames( + result.Commands[0], + (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 0), + (ShellSyntaxKind.CommandSubstitution, CommandAncestryRegion.Substitution, 0), + (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0), + (ShellSyntaxKind.CommandSubstitution, CommandAncestryRegion.Substitution, 0), + (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0)); + } + + [Fact] + public void Iterator_substitutions_keep_nearest_role_and_authored_indices() + { + var first = Substitution(2, Leaf("first", 3)); + var second = Substitution(4, Leaf("second", 5)); + var body = Leaf("body", 8); + var loop = new ForEachSyntax + { + Binding = Binding("item"), + Iterable = new ShellSourceFragment { Raw = "$(first) $(second)" }, + IteratorCommands = Block(1, first, second), + Body = Block(7, body), + }; + + var succeeded = ShellSyntaxProjection.TryProject(Block(0, loop), out var result); + + Assert.True(succeeded); + Assert.Equal(new[] { "first", "second", "body" }, result.Commands.Select(Verb)); + Assert.Equal( + new[] + { + CommandOccurrenceRole.Substitution, + CommandOccurrenceRole.Substitution, + CommandOccurrenceRole.LoopBody, + }, + result.Commands.Select(command => command.ImmediateRole)); + AssertFrames( + result.Commands[0], + (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 0), + (ShellSyntaxKind.ForEach, CommandAncestryRegion.Iterator, null), + (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0), + (ShellSyntaxKind.CommandSubstitution, CommandAncestryRegion.Substitution, 0), + (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0)); + AssertFrames( + result.Commands[1], + (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 0), + (ShellSyntaxKind.ForEach, CommandAncestryRegion.Iterator, null), + (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 1), + (ShellSyntaxKind.CommandSubstitution, CommandAncestryRegion.Substitution, 1), + (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0)); + } + + [Fact] + public void Invalid_or_reused_substitution_children_discard_partial_projections() + { + var nullCollection = Leaf("null-collection", 0) with + { + Substitutions = null!, + }; + Assert.False(ShellSyntaxProjection.TryProject( + Block(0, nullCollection), + out var nullCollectionResult)); + Assert.Empty(nullCollectionResult.Commands); + Assert.Empty(nullCollectionResult.Clauses); + + var nullChild = Leaf("null-child", 0) with + { + Substitutions = new CommandSubstitutionSyntax[] { null! }, + }; + Assert.False(ShellSyntaxProjection.TryProject( + Block(0, nullChild), + out var nullChildResult)); + Assert.Empty(nullChildResult.Commands); + Assert.Empty(nullChildResult.Clauses); + + var shared = Substitution(1, Leaf("inner", 2)); + var reused = Leaf("reused", 0) with + { + Substitutions = new[] { shared, shared }, + }; + Assert.False(ShellSyntaxProjection.TryProject( + Block(0, reused), + out var reusedResult)); + Assert.Empty(reusedResult.Commands); + Assert.Empty(reusedResult.Clauses); + } + + [Fact] + public void Nested_projection_is_source_ordered_and_uses_nearest_execution_role() + { + var find = Leaf("find", 12); + var iteratorSort = Leaf("sort-iterator", 20); + var print = Leaf("printf", 40); + var bodySort = Leaf("sort-body", 50); + var test = Leaf("test", 70); + var remove = Leaf("rm", 80); + var probe = Leaf("probe", 90); + var echo = Leaf("echo", 100); + var fallback = Leaf("fallback", 110); + var inner = Leaf("inner", 130); + + var loop = new ForEachSyntax + { + SourceStart = 0, + SourceLength = 60, + Binding = Binding("f"), + Iterable = new ShellSourceFragment { Raw = "$(find .)" }, + IteratorCommands = Block( + 10, + new PipelineSyntax + { + SourceStart = 10, + SourceLength = 20, + Stages = new ShellSyntaxNode[] { find, iteratorSort }, + }), + Body = Block( + 35, + new PipelineSyntax + { + SourceStart = 35, + SourceLength = 25, + Stages = new ShellSyntaxNode[] { print, bodySort }, + }), + }; + var conditional = new ConditionalSyntax + { + SourceStart = 65, + SourceLength = 55, + Branches = new[] + { + new ConditionalBranchSyntax + { + SourceStart = 65, + SourceLength = 20, + Condition = Block(68, test), + Body = Block(78, remove), + }, + new ConditionalBranchSyntax + { + SourceStart = 86, + SourceLength = 20, + Condition = Block(88, probe), + Body = Block(98, echo), + }, + }, + Else = Block(108, fallback), + }; + var substitution = new CommandSubstitutionSyntax + { + SourceStart = 125, + SourceLength = 15, + Body = Block(128, inner), + }; + var root = new ShellBlockSyntax + { + SourceStart = 0, + SourceLength = 140, + Statements = new ShellSyntaxNode[] { loop, conditional, substitution }, + }; + + var succeeded = ShellSyntaxProjection.TryProject(root, out var result); + + Assert.True(succeeded); + Assert.Equal( + new[] + { + "find", "sort-iterator", "printf", "sort-body", "test", + "rm", "probe", "echo", "fallback", "inner", + }, + result.Commands.Select(Verb)); + Assert.Equal( + new[] + { + CommandOccurrenceRole.PipelineStage, + CommandOccurrenceRole.PipelineStage, + CommandOccurrenceRole.PipelineStage, + CommandOccurrenceRole.PipelineStage, + CommandOccurrenceRole.Condition, + CommandOccurrenceRole.Branch, + CommandOccurrenceRole.Condition, + CommandOccurrenceRole.Branch, + CommandOccurrenceRole.Branch, + CommandOccurrenceRole.Substitution, + }, + result.Commands.Select(command => command.ImmediateRole)); + Assert.Equal(result.Commands.Select(command => command.Clause), result.Clauses); + + AssertFrames( + result.Commands[2], + (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 0), + (ShellSyntaxKind.ForEach, CommandAncestryRegion.LoopBody, null), + (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0), + (ShellSyntaxKind.Pipeline, CommandAncestryRegion.PipelineStage, 0)); + AssertFrames( + result.Commands[4], + (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 1), + (ShellSyntaxKind.Conditional, CommandAncestryRegion.Branch, 0), + (ShellSyntaxKind.ConditionalBranch, CommandAncestryRegion.Condition, null), + (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0)); + AssertFrames( + result.Commands[8], + (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 1), + (ShellSyntaxKind.Conditional, CommandAncestryRegion.Branch, 2), + (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0)); + AssertFrames( + result.Commands[9], + (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 2), + (ShellSyntaxKind.CommandSubstitution, CommandAncestryRegion.Substitution, 2), + (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0)); + } + + [Fact] + public void Compatibility_projection_preserves_authored_operators_without_synthesizing_relations() + { + var condition = ClauseFor("test"); + var thenClause = ClauseFor("rm", CompoundOperator.AndIf); + var elseClause = ClauseFor("echo"); + var root = Block( + 0, + new ConditionalSyntax + { + Branches = new[] + { + new ConditionalBranchSyntax + { + Condition = Block(1, Leaf(condition, 1)), + Body = Block(2, Leaf(thenClause, 2)), + }, + }, + Else = Block(3, Leaf(elseClause, 3)), + }); + + var succeeded = ShellSyntaxProjection.TryProject(root, out var result); + + Assert.True(succeeded); + Assert.Equal(3, result.Clauses.Count); + Assert.Same(condition, result.Clauses[0]); + Assert.Same(thenClause, result.Clauses[1]); + Assert.Same(elseClause, result.Clauses[2]); + Assert.Equal( + new[] { CompoundOperator.None, CompoundOperator.AndIf, CompoundOperator.None }, + result.Clauses.Select(clause => clause.Operator)); + } + + [Fact] + public void Sixteen_structural_containers_are_supported() + { + var root = NestedGroups(ShellAnalysisLimits.MaxStructuralNesting); + + var succeeded = ShellSyntaxProjection.TryProject(root, out var result); + + Assert.True(succeeded); + Assert.Single(result.Commands); + Assert.Equal( + (ShellAnalysisLimits.MaxStructuralNesting * 2) + 1, + result.Commands[0].Ancestry.Count); + } + + [Fact] + public void Sixteen_nested_substitutions_are_supported() + { + var root = NestedSubstitutions(ShellAnalysisLimits.MaxStructuralNesting); + + var succeeded = ShellSyntaxProjection.TryProject(root, out var result); + + Assert.True(succeeded); + Assert.Equal(ShellAnalysisLimits.MaxStructuralNesting + 1, result.Commands.Count); + Assert.Equal( + (ShellAnalysisLimits.MaxStructuralNesting * 2) + 1, + result.Commands[0].Ancestry.Count); + } + + [Fact] + public void Seventeenth_nested_substitution_discards_partial_projections() + { + var root = NestedSubstitutions(ShellAnalysisLimits.MaxStructuralNesting + 1); + + var succeeded = ShellSyntaxProjection.TryProject(root, out var result); + + Assert.False(succeeded); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + + [Fact] + public void Structural_depth_overflow_discards_partial_projections() + { + var first = Leaf("first", 0); + var overflow = NestedGroups(ShellAnalysisLimits.MaxStructuralNesting + 1).Statements[0]; + var root = Block(0, first, overflow); + + var succeeded = ShellSyntaxProjection.TryProject(root, out var result); + + Assert.False(succeeded); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + + [Fact] + public void Cyclic_syntax_discards_partial_projections() + { + var statements = new List(); + var root = new ShellBlockSyntax { Statements = statements }; + statements.Add(Leaf("first", 0)); + statements.Add(root); + + var succeeded = ShellSyntaxProjection.TryProject(root, out var result); + + Assert.False(succeeded); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + + [Fact] + public void Reused_node_or_clause_identity_discards_the_whole_projection() + { + var sharedLeaf = Leaf("shared", 1); + var repeatedNode = Block(0, sharedLeaf, sharedLeaf); + + Assert.False(ShellSyntaxProjection.TryProject(repeatedNode, out var nodeResult)); + Assert.Empty(nodeResult.Commands); + Assert.Empty(nodeResult.Clauses); + + var sharedClause = ClauseFor("shared-clause"); + var repeatedClause = Block( + 0, + Leaf(sharedClause, 1), + Leaf(sharedClause, 2)); + + Assert.False(ShellSyntaxProjection.TryProject(repeatedClause, out var clauseResult)); + Assert.Empty(clauseResult.Commands); + Assert.Empty(clauseResult.Clauses); + } + + [Fact] + public void Unknown_or_empty_structural_shapes_fail_closed() + { + var invalidNodes = new ShellSyntaxNode[] + { + new GroupSyntax { Body = Block(1, Leaf("group", 1)) }, + new ConditionLoopSyntax + { + Condition = Block(1), + Body = Block(2, Leaf("loop", 2)), + }, + new PipelineSyntax(), + new CommandListSyntax(), + new CommandListSyntax + { + Items = new[] + { + new CommandListItemSyntax + { + Command = Leaf("invalid-operator", 1), + Operator = (CompoundOperator)999, + }, + }, + }, + new ConditionalSyntax(), + new ForEachSyntax + { + Binding = new LoopBindingSyntax(), + Iterable = new ShellSourceFragment(), + Body = Block(2, Leaf("foreach", 2)), + }, + new ForEachSyntax + { + Binding = Binding("item"), + Iterable = new ShellSourceFragment + { + Raw = "value", + SourceStart = -1, + SourceLength = 1, + }, + Body = Block(2, Leaf("foreach-span", 2)), + }, + }; + + foreach (var invalidNode in invalidNodes) + { + var succeeded = ShellSyntaxProjection.TryProject( + Block(0, invalidNode), + out var result); + Assert.False(succeeded); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + } + + [Fact] + public void Group_and_command_list_retain_condition_and_body_roles() + { + var condition = Leaf("condition", 4); + var body = Leaf("body", 12); + var root = Block( + 0, + new ConditionLoopSyntax + { + LoopKind = ConditionLoopKind.While, + Condition = Block( + 2, + new GroupSyntax + { + GroupKind = ShellGroupKind.CurrentScope, + Body = Block(3, condition), + }), + Body = Block( + 10, + new CommandListSyntax + { + Items = new[] + { + new CommandListItemSyntax + { + Command = body, + Operator = CompoundOperator.None, + }, + }, + }), + }); + + var succeeded = ShellSyntaxProjection.TryProject(root, out var result); + + Assert.True(succeeded); + Assert.Equal(CommandOccurrenceRole.Condition, result.Commands[0].ImmediateRole); + Assert.Equal(CommandOccurrenceRole.LoopBody, result.Commands[1].ImmediateRole); + AssertFrames( + result.Commands[0], + (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 0), + (ShellSyntaxKind.ConditionLoop, CommandAncestryRegion.Condition, null), + (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0), + (ShellSyntaxKind.Group, CommandAncestryRegion.GroupBody, null), + (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0)); + AssertFrames( + result.Commands[1], + (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 0), + (ShellSyntaxKind.ConditionLoop, CommandAncestryRegion.LoopBody, null), + (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0), + (ShellSyntaxKind.CommandList, CommandAncestryRegion.Statement, 0)); + } + + [Fact] + public void Null_spans_are_preserved_but_malformed_spans_fail_closed() + { + var root = new ShellBlockSyntax + { + Statements = new ShellSyntaxNode[] { Leaf("nullable-span", 1) }, + }; + + Assert.True(ShellSyntaxProjection.TryProject(root, out var validResult)); + var frame = Assert.Single(validResult.Commands[0].Ancestry); + Assert.Null(frame.SourceStart); + Assert.Null(frame.SourceLength); + + var malformed = new PipelineSyntax + { + SourceStart = 1, + Stages = new ShellSyntaxNode[] { Leaf("bad-span", 2) }, + }; + Assert.False(ShellSyntaxProjection.TryProject(Block(0, malformed), out var invalidResult)); + Assert.Empty(invalidResult.Commands); + Assert.Empty(invalidResult.Clauses); + } + + [Fact] + public void Invalid_value_domains_and_argument_coordinates_fail_closed() + { + var clause = ClauseFor("echo") with + { + Elements = new[] + { + new ClauseElement { Role = ClauseElementRole.Verb }, + new ClauseElement { Role = ClauseElementRole.Argument }, + }, + }; + var root = Block(0, Leaf(clause, 0)); + var invalidFacts = new Func[] + { + () => new CommandOccurrenceFacts + { + WorkingDirectory = new ShellValueDomain + { + Kind = ShellValueDomainKind.FiniteSet, + Values = new[] { "/a", "/b" }, + }, + }, + () => new CommandOccurrenceFacts + { + EffectiveArguments = new[] + { + new EffectiveArgument + { + ClauseElementIndex = 0, + Value = ShellValueDomain.Unknown, + }, + }, + }, + () => new CommandOccurrenceFacts + { + EffectiveArguments = new[] + { + new EffectiveArgument + { + ClauseElementIndex = 1, + Value = new ShellValueDomain { Kind = ShellValueDomainKind.Exact }, + }, + }, + }, + () => new CommandOccurrenceFacts + { + EffectiveArguments = new[] + { + new EffectiveArgument + { + ClauseElementIndex = 1, + Value = ShellValueDomain.Unknown, + }, + new EffectiveArgument + { + ClauseElementIndex = 1, + Value = ShellValueDomain.Unknown, + }, + }, + }, + }; + + foreach (var createFacts in invalidFacts) + { + var succeeded = ShellSyntaxProjection.TryProject( + root, + _ => createFacts(), + out var result); + Assert.False(succeeded); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + } + + [Fact] + public void Value_domain_validator_accepts_only_the_four_locked_shapes() + { + var clause = ClauseFor("echo") with + { + Elements = new[] + { + new ClauseElement { Role = ClauseElementRole.Verb }, + new ClauseElement { Role = ClauseElementRole.Argument }, + }, + }; + var root = Block(0, Leaf(clause, 0)); + var validDomains = new[] + { + ShellValueDomain.Unknown, + new ShellValueDomain + { + Kind = ShellValueDomainKind.Exact, + Values = new[] { "one" }, + }, + new ShellValueDomain + { + Kind = ShellValueDomainKind.FiniteSet, + Values = new[] { "one", "two" }, + }, + new ShellValueDomain + { + Kind = ShellValueDomainKind.Pattern, + Pattern = "/work/*.txt", + CoveringDirectory = "/work", + }, + }; + + foreach (var domain in validDomains) + { + var succeeded = ShellSyntaxProjection.TryProject( + root, + _ => FactsForArgument(domain), + out var result); + Assert.True(succeeded); + Assert.Single(result.Commands); + } + + var overLimit = Enumerable.Range( + 0, + ShellAnalysisLimits.MaxValueCandidates + 1) + .Select(index => index.ToString()) + .ToArray(); + var invalidDomains = new[] + { + new ShellValueDomain + { + Kind = ShellValueDomainKind.Unknown, + Values = new[] { "unexpected" }, + }, + new ShellValueDomain { Kind = ShellValueDomainKind.Exact }, + new ShellValueDomain + { + Kind = ShellValueDomainKind.Exact, + Values = new[] { "one", "two" }, + }, + new ShellValueDomain + { + Kind = ShellValueDomainKind.FiniteSet, + Values = new[] { "one" }, + }, + new ShellValueDomain + { + Kind = ShellValueDomainKind.FiniteSet, + Values = new[] { "duplicate", "duplicate" }, + }, + new ShellValueDomain + { + Kind = ShellValueDomainKind.FiniteSet, + Values = overLimit, + }, + new ShellValueDomain + { + Kind = ShellValueDomainKind.Pattern, + Pattern = "/work/*.txt", + }, + new ShellValueDomain { Kind = (ShellValueDomainKind)999 }, + }; + + foreach (var domain in invalidDomains) + { + var succeeded = ShellSyntaxProjection.TryProject( + root, + _ => FactsForArgument(domain), + out var result); + Assert.False(succeeded); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + } + + [Fact] + public void Invalid_redirect_facts_fail_closed() + { + var clause = ClauseFor("echo") with + { + Redirects = new[] { new Redirect() }, + }; + var root = Block(0, Leaf(clause, 0)); + var invalidRedirects = new[] + { + new RedirectAnalysis + { + RedirectIndex = 1, + Source = new RedirectSource { Kind = RedirectSourceKind.Default }, + Operation = RedirectOperation.FileOutput, + IsPathRelevant = true, + }, + new RedirectAnalysis + { + RedirectIndex = 0, + Operation = RedirectOperation.FileOutput, + IsPathRelevant = true, + IsComplete = true, + }, + new RedirectAnalysis + { + RedirectIndex = 0, + Source = new RedirectSource + { + Kind = RedirectSourceKind.Default, + Descriptor = 1, + }, + Operation = RedirectOperation.FileOutput, + IsPathRelevant = true, + }, + new RedirectAnalysis + { + RedirectIndex = 0, + Source = new RedirectSource { Kind = RedirectSourceKind.Default }, + Operation = RedirectOperation.HereDocument, + }, + new RedirectAnalysis + { + RedirectIndex = 0, + Source = new RedirectSource { Kind = RedirectSourceKind.Default }, + Operation = RedirectOperation.HereDocument, + HereDocument = new HereDocumentAnalysis + { + Delimiter = new ShellSourceFragment + { + Raw = "EOF", + SourceStart = 1, + }, + ExpansionMode = HereDocumentExpansionMode.Literal, + }, + }, + }; + + foreach (var invalidRedirect in invalidRedirects) + { + var succeeded = ShellSyntaxProjection.TryProject( + root, + _ => new CommandOccurrenceFacts + { + Redirects = new[] { invalidRedirect }, + }, + out var result); + Assert.False(succeeded); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + } + + [Fact] + public void Invalid_parser_owned_analysis_discards_partial_projections() + { + var root = Block(0, Leaf("echo", 0)); + + var succeeded = ShellSyntaxProjection.TryProject( + root, + _ => null!, + out var result); + + Assert.False(succeeded); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + + private static ShellBlockSyntax NestedGroups(int count) + { + ShellSyntaxNode current = Leaf("deepest", count); + for (var index = 0; index < count; index++) + { + current = new GroupSyntax + { + GroupKind = ShellGroupKind.CurrentScope, + SourceStart = index, + SourceLength = count - index, + Body = Block(index, current), + }; + } + + return Block(0, current); + } + + private static ShellBlockSyntax NestedSubstitutions(int count) + { + var current = Leaf("deepest", count); + for (var index = count - 1; index >= 0; index--) + { + current = Leaf($"level-{index}", index) with + { + Substitutions = new[] + { + Substitution(index, current), + }, + }; + } + + return Block(0, current); + } + + private static ShellBlockSyntax Block(int sourceStart, params ShellSyntaxNode[] statements) => + new() + { + SourceStart = sourceStart, + SourceLength = statements.Length, + Statements = statements, + }; + + private static SimpleCommandSyntax Leaf(string verb, int sourceStart) => + Leaf(ClauseFor(verb), sourceStart); + + private static SimpleCommandSyntax Leaf(Clause clause, int sourceStart) => + new() + { + Clause = clause, + SourceStart = sourceStart, + SourceLength = 1, + }; + + private static CommandSubstitutionSyntax Substitution( + int sourceStart, + params ShellSyntaxNode[] statements) => + new() + { + SourceStart = sourceStart, + SourceLength = statements.Length, + Body = Block(sourceStart, statements), + }; + + private static Clause ClauseFor( + string verb, + CompoundOperator @operator = CompoundOperator.None) => + new() + { + Operator = @operator, + Verb = new VerbChain { Tokens = new[] { verb } }, + Elements = new[] { new ClauseElement { Role = ClauseElementRole.Verb } }, + }; + + private static LoopBindingSyntax Binding(string name) => + new() + { + Name = name, + Source = new ShellSourceFragment { Raw = name }, + }; + + private static CommandOccurrenceFacts FactsForArgument(ShellValueDomain domain) => + new() + { + EffectiveArguments = new[] + { + new EffectiveArgument + { + ClauseElementIndex = 1, + Value = domain, + }, + }, + }; + + private static string Verb(CommandOccurrence occurrence) => + Assert.Single(occurrence.Clause.Verb.Tokens); + + private static void AssertFrames( + CommandOccurrence occurrence, + params (ShellSyntaxKind Kind, CommandAncestryRegion Region, int? ChildIndex)[] expected) + { + Assert.Equal(expected.Length, occurrence.Ancestry.Count); + for (var index = 0; index < expected.Length; index++) + { + Assert.Equal(expected[index].Kind, occurrence.Ancestry[index].AncestorKind); + Assert.Equal(expected[index].Region, occurrence.Ancestry[index].Region); + Assert.Equal(expected[index].ChildIndex, occurrence.Ancestry[index].ChildIndex); + } + } +}