From 73a1e0b2d635f0bc76bd4a31a14f869c83c7d1a6 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 7 Aug 2026 21:22:07 +0000 Subject: [PATCH] Add PowerShell execution-region binding catalog --- IMPLEMENTATION_PLAN.md | 14 +- .../v0-3-structured-shell-analysis/tasks.md | 2 +- .../PwshExecutionRegionBindingCatalog.cs | 1813 +++++++++++++++++ .../PwshExecutionRegionBindingCatalogTests.cs | 622 ++++++ 4 files changed, 2448 insertions(+), 3 deletions(-) create mode 100644 src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs create mode 100644 tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 6c950f5..02243be 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -361,8 +361,18 @@ priorities. 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 + The PowerShell 7.6.4 receiver and parameter-binding catalog is now + implemented with command-resolution proof as an explicit input. It pins + aliases, supported module qualification, exact and abbreviated/inline + parameters, positional slots, parameter sets, `ScriptBlock[]`, authored + ForEach-Object multi-block coordinates, and semantic Begin/Process/End + phases. The optional Microsoft.PowerShell.ThreadJob 2.2.0 entry remains + incomplete unless its separate module-baseline proof is supplied. Local + `Invoke-Command -AsJob`, ambiguous prefixes, malformed value binding, + unproved identities, and unknown receivers retain unknown/incomplete + facts. Module-qualified identities are catalogued, but the parser keeps + rejecting those forms atomically until the region-emission slice can + expose every body command. Continue in small slices with direct `&` / `.` and synchronous current-runspace callbacks; child process/runspace jobs and parallel blocks; deferred breakpoint/event/completion actions; then unknown receiver and nested/adversarial matrices. Preserve script blocks proved diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md index 03d38f7..b67cb19 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -181,7 +181,7 @@ pipeline, alias/cmdlet/native, redirect, and adversarial matrices remain in tasks 7.5-7.7. - [ ] 7.5 Cover aliases, cmdlets, native commands, nested loops, pipelines, script blocks, and wrapper boundaries. - - [ ] 7.5a Implement the version-pinned PowerShell 7 script-block receiver and + - [x] 7.5a Implement the version-pinned PowerShell 7 script-block receiver and parameter-binding catalog, including aliases, supported module-qualified identities, parameter abbreviations/inline values, positional binding, parameter sets, `ScriptBlock[]`, and ForEach-Object Begin/Process/End diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs new file mode 100644 index 0000000..345a026 --- /dev/null +++ b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs @@ -0,0 +1,1813 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace ShellSyntaxTree.Internal.Pwsh.Verbs; + +internal enum PwshExecutionRegionBindingStatus +{ + NotApplicable, + ProvedData, + ProvedExecution, + Ambiguous, +} + +internal enum PwshExecutionRegionReceiver +{ + Unknown, + ForEachObject, + WhereObject, + InvokeCommand, + MeasureCommand, + TraceCommand, + StartJob, + NewModule, + SetPSBreakpoint, + RegisterObjectEvent, + RegisterEngineEvent, + RegisterArgumentCompleter, + StartThreadJob, + WriteOutput, +} + +internal enum PwshExecutionRegionParameterSet +{ + Unknown, + ForEachScriptBlock, + ForEachParallel, + WhereScriptBlock, + InvokeInProcess, + InvokeRemote, + MeasureExpression, + TraceExpression, + StartJobScriptBlock, + StartJobFilePath, + NewModuleScriptBlock, + Breakpoint, + ObjectEvent, + EngineEvent, + ArgumentCompleter, + ThreadJobScriptBlock, + ThreadJobFilePath, +} + +internal enum PwshParameterValueKind +{ + String, + Object, + ScriptBlock, + Switch, + Int32, + Enum, + Uri, + Guid, + Version, + Hashtable, + RuntimeObject, +} + +internal readonly record struct PwshExecutionRegionBinding( + int HostClauseElementIndex, + string? CanonicalParameterName, + ExecutionRegionPhase Phase, + ExecutionRegionTiming Timing, + ExecutionRegionCardinality Cardinality, + bool IsComplete); + +internal sealed record PwshExecutionRegionBindingResult +{ + internal PwshExecutionRegionBindingStatus Status { get; init; } + + internal PwshExecutionRegionReceiver Receiver { get; init; } + + internal PwshExecutionRegionParameterSet ParameterSet { get; init; } + + internal string? CanonicalCommandName { get; init; } + + internal IReadOnlyList Bindings { get; init; } = + Array.Empty(); +} + +/// +/// Version-pinned PowerShell 7.6.4 command and parameter metadata used to +/// identify script-block arguments. This is intentionally separate from the +/// parser's broad v0.2 path-binding table: only this closed catalog is allowed +/// to prove that a script block executes or is data. +/// +internal static class PwshExecutionRegionBindingCatalog +{ + internal const string PinnedPowerShellVersion = "7.6.4"; + + internal const string PinnedThreadJobModuleVersion = "2.2.0"; + + private static readonly HashSet CommonParameters = Names( + "Debug,ErrorAction,ErrorVariable,InformationAction,InformationVariable," + + "OutBuffer,OutVariable,PipelineVariable,ProgressAction,Verbose," + + "WarningAction,WarningVariable"); + + private static readonly HashSet CommonSwitchParameters = Names("Debug,Verbose"); + + private static readonly HashSet ScriptBlockParameters = Names( + "Action,Begin,End,Expression,FilterScript,InitializationScript,Parallel," + + "Process,RemainingScripts,ScriptBlock"); + + private static readonly HashSet SwitchParameters = Names( + "AllowRedirection,AsCustomObject,AsJob,CContains,CEQ,CGE,CGT,CIn,CLE,CLT," + + "CLike,CMatch,CNE,CNotContains,CNotIn,CNotLike,CNotMatch,Confirm,Contains," + + "Debug,Debugger,EnableNetworkAccess,EQ,Force,Forward,GE,GT,HideComputerName," + + "In,InDisconnectedSession,Is,IsNot,LE,Like,LT,Match,Native,NativeFallback," + + "NE,NoEnumerate,NoNewScope,Not,NotContains,NotIn,NotLike,NotMatch,PSHost," + + "RemoteDebug,ReturnResult,RunAs32,RunAsAdministrator,SSHTransport,SupportEvent," + + "UseNewRunspace,UseSSL,Verbose,WhatIf"); + + private static readonly HashSet Int32Parameters = Names( + "Column,ConnectingTimeout,Line,MaxTriggerCount,OutBuffer,Port,ThrottleLimit," + + "TimeoutSeconds"); + + private static readonly HashSet EnumParameters = Names( + "Authentication,ErrorAction,InformationAction,ListenerOption,Mode,Option," + + "ProgressAction,WarningAction"); + + private static readonly HashSet ObjectParameters = Names( + "ArgumentList,InputObject,MessageData,Value"); + + private static readonly HashSet RuntimeObjectParameters = Names( + "Credential,Runspace,Session,SessionOption,SSHConnection,StreamingHost"); + + private static readonly IReadOnlyDictionary CommonParameterAliases = Aliases( + "db=Debug,ea=ErrorAction,ev=ErrorVariable,infa=InformationAction," + + "iv=InformationVariable,ob=OutBuffer,ov=OutVariable,pv=PipelineVariable," + + "proga=ProgressAction,vb=Verbose,wa=WarningAction,wv=WarningVariable"); + + private static readonly IReadOnlyDictionary Commands = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ForEach-Object"] = Entry( + PwshExecutionRegionReceiver.ForEachObject, + "Microsoft.PowerShell.Core", + "ArgumentList,AsJob,Begin,Confirm,End,InputObject,MemberName,Parallel," + + "Process,RemainingScripts,ThrottleLimit,TimeoutSeconds,UseNewRunspace,WhatIf", + "AsJob,Confirm,UseNewRunspace,WhatIf", + aliases: "Args=ArgumentList,cf=Confirm,wi=WhatIf"), + ["Where-Object"] = Entry( + PwshExecutionRegionReceiver.WhereObject, + "Microsoft.PowerShell.Core", + "CContains,CEQ,CGE,CGT,CIn,CLE,CLT,CLike,CMatch,CNE,CNotContains," + + "CNotIn,CNotLike,CNotMatch,Contains,EQ,FilterScript,GE,GT,In,InputObject," + + "Is,IsNot,LE,LT,Like,Match,NE,Not,NotContains,NotIn,NotLike,NotMatch," + + "Property,Value", + "CContains,CEQ,CGE,CGT,CIn,CLE,CLT,CLike,CMatch,CNE,CNotContains," + + "CNotIn,CNotLike,CNotMatch,Contains,EQ,GE,GT,In,Is,IsNot,LE,LT,Like," + + "Match,NE,Not,NotContains,NotIn,NotLike,NotMatch", + aliases: "IContains=Contains,IEQ=EQ,IGE=GE,IGT=GT,IIn=In,ILE=LE," + + "ILike=Like,ILT=LT,IMatch=Match,INE=NE,INotContains=NotContains," + + "INotIn=NotIn,INotLike=NotLike,INotMatch=NotMatch"), + ["Invoke-Command"] = Entry( + PwshExecutionRegionReceiver.InvokeCommand, + "Microsoft.PowerShell.Core", + "AllowRedirection,ApplicationName,ArgumentList,AsJob,Authentication," + + "CertificateThumbprint,ComputerName,ConfigurationName,ConnectingTimeout," + + "ConnectionUri,ContainerId,Credential,EnableNetworkAccess,FilePath," + + "HideComputerName,HostName,InDisconnectedSession,InputObject,JobName," + + "KeyFilePath,NoNewScope,Options,Port,RemoteDebug,RunAsAdministrator," + + "ScriptBlock,Session,SessionName,SessionOption,SSHConnection,SSHTransport," + + "Subsystem,ThrottleLimit,UseSSL,UserName,VMId,VMName", + "AllowRedirection,AsJob,EnableNetworkAccess,HideComputerName," + + "InDisconnectedSession,NoNewScope,RemoteDebug,RunAsAdministrator," + + "SSHTransport,UseSSL", + aliases: "Args=ArgumentList,Cn=ComputerName,URI=ConnectionUri," + + "CU=ConnectionUri,PSPath=FilePath,HCN=HideComputerName," + + "Disconnected=InDisconnectedSession,IdentityFilePath=KeyFilePath," + + "Command=ScriptBlock,VMGuid=VMId"), + ["Measure-Command"] = Entry( + PwshExecutionRegionReceiver.MeasureCommand, + "Microsoft.PowerShell.Utility", + "Expression,InputObject", + ""), + ["Trace-Command"] = Entry( + PwshExecutionRegionReceiver.TraceCommand, + "Microsoft.PowerShell.Utility", + "ArgumentList,Command,Debugger,Expression,FilePath,Force,InputObject," + + "ListenerOption,Name,Option,PSHost", + "Debugger,Force,PSHost", + aliases: "Args=ArgumentList,PSPath=FilePath,Path=FilePath"), + ["Start-Job"] = Entry( + PwshExecutionRegionReceiver.StartJob, + "Microsoft.PowerShell.Core", + "ArgumentList,Authentication,ConnectingTimeout,Credential,DefinitionName," + + "DefinitionPath,FilePath,InitializationScript,InputObject,LiteralPath,Name," + + "Options,PSVersion,RunAs32,ScriptBlock,Type,WorkingDirectory", + "RunAs32", + aliases: "Args=ArgumentList,PSPath=LiteralPath,LP=LiteralPath," + + "Command=ScriptBlock"), + ["New-Module"] = Entry( + PwshExecutionRegionReceiver.NewModule, + "Microsoft.PowerShell.Core", + "ArgumentList,AsCustomObject,Cmdlet,Function,Name,ReturnResult,ScriptBlock", + "AsCustomObject,ReturnResult", + aliases: "Args=ArgumentList"), + ["Set-PSBreakpoint"] = Entry( + PwshExecutionRegionReceiver.SetPSBreakpoint, + "Microsoft.PowerShell.Utility", + "Action,Column,Command,Line,Mode,Runspace,Script,Variable", + "", + aliases: "C=Command,V=Variable"), + ["Register-ObjectEvent"] = Entry( + PwshExecutionRegionReceiver.RegisterObjectEvent, + "Microsoft.PowerShell.Utility", + "Action,EventName,Forward,InputObject,MaxTriggerCount,MessageData," + + "SourceIdentifier,SupportEvent", + "Forward,SupportEvent"), + ["Register-EngineEvent"] = Entry( + PwshExecutionRegionReceiver.RegisterEngineEvent, + "Microsoft.PowerShell.Utility", + "Action,Forward,MaxTriggerCount,MessageData,SourceIdentifier,SupportEvent", + "Forward,SupportEvent"), + ["Register-ArgumentCompleter"] = Entry( + PwshExecutionRegionReceiver.RegisterArgumentCompleter, + "Microsoft.PowerShell.Core", + "CommandName,Native,NativeFallback,ParameterName,ScriptBlock", + "Native,NativeFallback"), + ["Start-ThreadJob"] = Entry( + PwshExecutionRegionReceiver.StartThreadJob, + "Microsoft.PowerShell.ThreadJob", + "ArgumentList,FilePath,InitializationScript,InputObject,Name,ScriptBlock," + + "StreamingHost,ThrottleLimit", + ""), + ["Write-Output"] = Entry( + PwshExecutionRegionReceiver.WriteOutput, + "Microsoft.PowerShell.Utility", + "InputObject,NoEnumerate", + "NoEnumerate"), + }; + + internal static bool IsSupportedModuleQualifiedCommand(string command) + { + var separator = command.LastIndexOf('\\'); + if (separator <= 0 || separator + 1 >= command.Length) + { + return false; + } + + var module = command.Substring(0, separator); + var name = command.Substring(separator + 1); + return Commands.TryGetValue(name, out var entry) + && string.Equals(module, entry.ModuleName, StringComparison.OrdinalIgnoreCase); + } + + internal static bool TryResolveStaticCommandName( + string command, + out string? canonicalName) + { + canonicalName = command; + var separator = command.LastIndexOf('\\'); + if (separator > 0) + { + if (!IsSupportedModuleQualifiedCommand(command)) + { + canonicalName = null; + return false; + } + + canonicalName = command.Substring(separator + 1); + return true; + } + + var alias = PwshAliases.Resolve(command); + if (alias is not null) + { + canonicalName = alias; + } + + return Commands.ContainsKey(canonicalName); + } + + internal static PwshExecutionRegionBindingResult Bind( + Clause clause, + bool commandIdentityProven, + bool threadJobModuleProven = false) + { + var scriptBlocks = FindScriptBlocks(clause.Elements); + if (scriptBlocks.Count == 0) + { + return new PwshExecutionRegionBindingResult + { + Status = PwshExecutionRegionBindingStatus.NotApplicable, + }; + } + + if (!TryResolveCommand(clause.Verb, out var canonicalName, out var entry)) + { + return Unknown(canonicalName, scriptBlocks); + } + + if (!commandIdentityProven) + { + return Ambiguous(canonicalName, entry.Receiver, scriptBlocks); + } + + if (entry.Receiver == PwshExecutionRegionReceiver.StartThreadJob + && !threadJobModuleProven) + { + return Ambiguous(canonicalName, entry.Receiver, scriptBlocks); + } + + if (entry.Receiver == PwshExecutionRegionReceiver.WriteOutput) + { + return new PwshExecutionRegionBindingResult + { + Status = PwshExecutionRegionBindingStatus.ProvedData, + Receiver = entry.Receiver, + CanonicalCommandName = canonicalName, + }; + } + + var arguments = BindArguments(clause.Elements, entry); + return BindReceiver(canonicalName!, entry, arguments, scriptBlocks); + } + + private static PwshExecutionRegionBindingResult BindReceiver( + string canonicalName, + CommandEntry command, + BoundArguments arguments, + IReadOnlyList scriptBlocks) + { + var receiver = command.Receiver; + var compatibleSets = command.GetCompatibleParameterSets(arguments); + if (arguments.HasAmbiguousScriptBlockBinding + || arguments.HasDuplicateParameter + || arguments.HasInvalidScalarScriptBlockArray + || HasReceiverValidationConflict(receiver, arguments) + || compatibleSets.Count == 0) + { + return Ambiguous(canonicalName, receiver, scriptBlocks); + } + + var bindings = new List(); + var parameterSet = PwshExecutionRegionParameterSet.Unknown; + switch (receiver) + { + case PwshExecutionRegionReceiver.ForEachObject: + parameterSet = BindForEach(arguments, bindings); + break; + case PwshExecutionRegionReceiver.WhereObject: + parameterSet = BindSingle( + arguments, + bindings, + "FilterScript", + PwshExecutionRegionParameterSet.WhereScriptBlock, + ExecutionRegionPhase.Filter, + ExecutionRegionTiming.Synchronous, + ExecutionRegionCardinality.OncePerInputObject, + positionalSlot: 0); + break; + case PwshExecutionRegionReceiver.InvokeCommand: + parameterSet = BindInvokeCommand(arguments, bindings, compatibleSets); + break; + case PwshExecutionRegionReceiver.MeasureCommand: + parameterSet = BindSingle( + arguments, + bindings, + "Expression", + PwshExecutionRegionParameterSet.MeasureExpression, + ExecutionRegionPhase.Main, + ExecutionRegionTiming.Synchronous, + ExecutionRegionCardinality.Once, + positionalSlot: 0); + break; + case PwshExecutionRegionReceiver.TraceCommand: + parameterSet = BindTraceCommand(arguments, bindings); + break; + case PwshExecutionRegionReceiver.StartJob: + parameterSet = BindStartJob(arguments, bindings); + break; + case PwshExecutionRegionReceiver.NewModule: + parameterSet = BindNewModule(arguments, bindings); + break; + case PwshExecutionRegionReceiver.SetPSBreakpoint: + parameterSet = BindNamed( + arguments, + bindings, + "Action", + PwshExecutionRegionParameterSet.Breakpoint, + ExecutionRegionPhase.Action, + ExecutionRegionTiming.Deferred, + ExecutionRegionCardinality.ZeroOrMore); + break; + case PwshExecutionRegionReceiver.RegisterObjectEvent: + parameterSet = BindEvent( + arguments, + bindings, + PwshExecutionRegionParameterSet.ObjectEvent, + "InputObject", "EventName", "SourceIdentifier"); + break; + case PwshExecutionRegionReceiver.RegisterEngineEvent: + parameterSet = BindEvent( + arguments, + bindings, + PwshExecutionRegionParameterSet.EngineEvent, + "SourceIdentifier"); + break; + case PwshExecutionRegionReceiver.RegisterArgumentCompleter: + parameterSet = BindNamed( + arguments, + bindings, + "ScriptBlock", + PwshExecutionRegionParameterSet.ArgumentCompleter, + ExecutionRegionPhase.Completion, + ExecutionRegionTiming.Deferred, + ExecutionRegionCardinality.ZeroOrMore); + break; + case PwshExecutionRegionReceiver.StartThreadJob: + parameterSet = BindThreadJob(arguments, bindings); + break; + } + + if (bindings.Count != scriptBlocks.Count) + { + return Ambiguous(canonicalName, receiver, scriptBlocks); + } + + return new PwshExecutionRegionBindingResult + { + Status = PwshExecutionRegionBindingStatus.ProvedExecution, + Receiver = receiver, + ParameterSet = parameterSet, + CanonicalCommandName = canonicalName, + Bindings = bindings.OrderBy(binding => binding.HostClauseElementIndex).ToArray(), + }; + } + + private static PwshExecutionRegionParameterSet BindForEach( + BoundArguments arguments, + List bindings) + { + if (arguments.HasNamed("Parallel")) + { + AddNamed( + arguments, + bindings, + "Parallel", + ExecutionRegionPhase.Process, + ExecutionRegionTiming.Concurrent, + ExecutionRegionCardinality.OncePerInputObject); + return PwshExecutionRegionParameterSet.ForEachParallel; + } + + var hasBegin = AddNamed(arguments, bindings, "Begin", ExecutionRegionPhase.Begin, + ExecutionRegionTiming.Synchronous, ExecutionRegionCardinality.Once); + var hasEnd = AddNamed(arguments, bindings, "End", ExecutionRegionPhase.End, + ExecutionRegionTiming.Synchronous, ExecutionRegionCardinality.Once); + + var positionalBlocks = arguments.PositionalScriptBlocks().ToArray(); + if (positionalBlocks.Length != arguments.PositionalArguments.Count + || !positionalBlocks.Select((block, index) => block.Position == index) + .All(value => value)) + { + return PwshExecutionRegionParameterSet.Unknown; + } + + var processBlocks = arguments.NamedScriptBlocks("Process") + .Concat(arguments.NamedScriptBlocks("RemainingScripts")) + .Concat(positionalBlocks) + .GroupBy(block => block.ElementIndex) + .Select(group => group.First()) + .OrderBy(block => block.ElementIndex) + .ToArray(); + if (processBlocks.Length == 0) + { + return PwshExecutionRegionParameterSet.Unknown; + } + + var firstProcessIndex = 0; + var lastProcessIndex = processBlocks.Length - 1; + if (hasBegin == 0 && processBlocks.Length > 1) + { + bindings.Add(Binding(processBlocks[0].ElementIndex, "Begin", + ExecutionRegionPhase.Begin, ExecutionRegionTiming.Synchronous, + ExecutionRegionCardinality.Once)); + firstProcessIndex++; + } + + if (hasEnd == 0 && lastProcessIndex - firstProcessIndex + 1 > 1) + { + bindings.Add(Binding(processBlocks[lastProcessIndex].ElementIndex, "End", + ExecutionRegionPhase.End, ExecutionRegionTiming.Synchronous, + ExecutionRegionCardinality.Once)); + lastProcessIndex--; + } + + for (var index = firstProcessIndex; index <= lastProcessIndex; index++) + { + bindings.Add(Binding(processBlocks[index].ElementIndex, "Process", + ExecutionRegionPhase.Process, ExecutionRegionTiming.Synchronous, + ExecutionRegionCardinality.OncePerInputObject)); + } + + return PwshExecutionRegionParameterSet.ForEachScriptBlock; + } + + private static PwshExecutionRegionParameterSet BindInvokeCommand( + BoundArguments arguments, + List bindings, + IReadOnlyList compatibleSets) + { + var remote = compatibleSets.Any(set => set.IsRemote); + var inProcess = compatibleSets.Any(set => !set.IsRemote); + if (remote == inProcess) + { + return PwshExecutionRegionParameterSet.Unknown; + } + + var set = remote + ? PwshExecutionRegionParameterSet.InvokeRemote + : PwshExecutionRegionParameterSet.InvokeInProcess; + var named = AddNamed( + arguments, + bindings, + "ScriptBlock", + ExecutionRegionPhase.Main, + remote ? ExecutionRegionTiming.Unknown : ExecutionRegionTiming.Synchronous, + remote ? ExecutionRegionCardinality.Unknown : ExecutionRegionCardinality.Once, + isComplete: !remote); + if (named > 0) + { + return set; + } + + var block = arguments.FirstPositionalScriptBlockBoundTo( + "ScriptBlock", + compatibleSets[0]); + if (block is BoundArgument boundBlock) + { + bindings.Add(Binding( + boundBlock.ElementIndex, + "ScriptBlock", + ExecutionRegionPhase.Main, + remote ? ExecutionRegionTiming.Unknown : ExecutionRegionTiming.Synchronous, + remote ? ExecutionRegionCardinality.Unknown : ExecutionRegionCardinality.Once, + isComplete: !remote)); + } + + return set; + } + + private static PwshExecutionRegionParameterSet BindTraceCommand( + BoundArguments arguments, + List bindings) + { + if (arguments.HasNamed("Command")) + { + return PwshExecutionRegionParameterSet.Unknown; + } + + var named = AddNamed(arguments, bindings, "Expression", ExecutionRegionPhase.Main, + ExecutionRegionTiming.Synchronous, ExecutionRegionCardinality.Once); + if (named > 0) + { + return PwshExecutionRegionParameterSet.TraceExpression; + } + + var expressionPosition = arguments.HasNamed("Name") ? 0 : 1; + var block = arguments.FirstPositionalScriptBlockAt(expressionPosition); + if (block is BoundArgument boundBlock) + { + bindings.Add(Binding(boundBlock.ElementIndex, "Expression", ExecutionRegionPhase.Main, + ExecutionRegionTiming.Synchronous, ExecutionRegionCardinality.Once)); + } + + return PwshExecutionRegionParameterSet.TraceExpression; + } + + private static PwshExecutionRegionParameterSet BindStartJob( + BoundArguments arguments, + List bindings) + { + var filePath = arguments.HasAnyNamed("FilePath", "LiteralPath"); + var definition = arguments.HasNamed("DefinitionName"); + var nonScriptSet = filePath || definition; + if (!nonScriptSet) + { + AddNamed(arguments, bindings, "ScriptBlock", ExecutionRegionPhase.Main, + ExecutionRegionTiming.Concurrent, ExecutionRegionCardinality.Once); + } + + AddNamed(arguments, bindings, "InitializationScript", ExecutionRegionPhase.Initialization, + ExecutionRegionTiming.Concurrent, ExecutionRegionCardinality.Once); + + var initializationPosition = arguments.HasNamed("ScriptBlock") || filePath ? 0 : 1; + foreach (var block in arguments.PositionalScriptBlocks()) + { + if (!nonScriptSet && block.Position == 0 && !arguments.HasNamed("ScriptBlock")) + { + bindings.Add(Binding(block.ElementIndex, "ScriptBlock", ExecutionRegionPhase.Main, + ExecutionRegionTiming.Concurrent, ExecutionRegionCardinality.Once)); + } + else if (!definition + && block.Position == initializationPosition + && !arguments.HasNamed("InitializationScript")) + { + bindings.Add(Binding(block.ElementIndex, "InitializationScript", + ExecutionRegionPhase.Initialization, ExecutionRegionTiming.Concurrent, + ExecutionRegionCardinality.Once)); + } + } + + return filePath + ? PwshExecutionRegionParameterSet.StartJobFilePath + : PwshExecutionRegionParameterSet.StartJobScriptBlock; + } + + private static PwshExecutionRegionParameterSet BindNewModule( + BoundArguments arguments, + List bindings) + { + var named = AddNamed(arguments, bindings, "ScriptBlock", + ExecutionRegionPhase.Initialization, ExecutionRegionTiming.Synchronous, + ExecutionRegionCardinality.Once); + if (named == 0) + { + var expectedPosition = arguments.HasNamed("Name") ? 0 : + arguments.PositionalArguments.Any(value => value.Position == 0 && !value.IsScriptBlock) + ? 1 + : 0; + var block = arguments.FirstPositionalScriptBlockAt(expectedPosition); + if (block is BoundArgument boundBlock) + { + bindings.Add(Binding(boundBlock.ElementIndex, "ScriptBlock", + ExecutionRegionPhase.Initialization, ExecutionRegionTiming.Synchronous, + ExecutionRegionCardinality.Once)); + } + } + + return PwshExecutionRegionParameterSet.NewModuleScriptBlock; + } + + private static PwshExecutionRegionParameterSet BindThreadJob( + BoundArguments arguments, + List bindings) + { + var filePath = arguments.HasNamed("FilePath"); + if (!filePath) + { + var named = AddNamed(arguments, bindings, "ScriptBlock", ExecutionRegionPhase.Main, + ExecutionRegionTiming.Concurrent, ExecutionRegionCardinality.Once); + if (named == 0) + { + var block = arguments.FirstPositionalScriptBlockAt(0); + if (block is BoundArgument boundBlock) + { + bindings.Add(Binding(boundBlock.ElementIndex, "ScriptBlock", + ExecutionRegionPhase.Main, ExecutionRegionTiming.Concurrent, + ExecutionRegionCardinality.Once)); + } + } + } + + AddNamed(arguments, bindings, "InitializationScript", + ExecutionRegionPhase.Initialization, ExecutionRegionTiming.Concurrent, + ExecutionRegionCardinality.Once); + return filePath + ? PwshExecutionRegionParameterSet.ThreadJobFilePath + : PwshExecutionRegionParameterSet.ThreadJobScriptBlock; + } + + private static PwshExecutionRegionParameterSet BindEvent( + BoundArguments arguments, + List bindings, + PwshExecutionRegionParameterSet parameterSet, + params string[] parametersBeforeAction) + { + var named = AddNamed(arguments, bindings, "Action", ExecutionRegionPhase.Action, + ExecutionRegionTiming.Deferred, ExecutionRegionCardinality.ZeroOrMore); + if (named == 0) + { + var expected = parametersBeforeAction.Count(parameter => + !arguments.HasNamed(parameter)); + var block = arguments.FirstPositionalScriptBlockAt(expected); + if (block is BoundArgument boundBlock) + { + bindings.Add(Binding(boundBlock.ElementIndex, "Action", ExecutionRegionPhase.Action, + ExecutionRegionTiming.Deferred, ExecutionRegionCardinality.ZeroOrMore)); + } + } + + return parameterSet; + } + + private static bool HasReceiverValidationConflict( + PwshExecutionRegionReceiver receiver, + BoundArguments arguments) + { + if (receiver == PwshExecutionRegionReceiver.StartJob + && arguments.HasNamed("RunAs32")) + { + // RunAs32 availability depends on the installed PowerShell host. + return true; + } + + if (receiver == PwshExecutionRegionReceiver.ForEachObject + && arguments.HasNamed("AsJob") + && arguments.HasNamed("TimeoutSeconds")) + { + return true; + } + + return (receiver == PwshExecutionRegionReceiver.RegisterObjectEvent + || receiver == PwshExecutionRegionReceiver.RegisterEngineEvent) + && arguments.HasNamed("Forward"); + } + + private static PwshExecutionRegionParameterSet BindSingle( + BoundArguments arguments, + List bindings, + string parameterName, + PwshExecutionRegionParameterSet parameterSet, + ExecutionRegionPhase phase, + ExecutionRegionTiming timing, + ExecutionRegionCardinality cardinality, + int positionalSlot) + { + var named = AddNamed(arguments, bindings, parameterName, phase, timing, cardinality); + if (named == 0) + { + var block = arguments.FirstPositionalScriptBlockAt(positionalSlot); + if (block is BoundArgument boundBlock) + { + bindings.Add(Binding( + boundBlock.ElementIndex, parameterName, phase, timing, cardinality)); + } + } + + return parameterSet; + } + + private static PwshExecutionRegionParameterSet BindNamed( + BoundArguments arguments, + List bindings, + string parameterName, + PwshExecutionRegionParameterSet parameterSet, + ExecutionRegionPhase phase, + ExecutionRegionTiming timing, + ExecutionRegionCardinality cardinality) + { + AddNamed(arguments, bindings, parameterName, phase, timing, cardinality); + return parameterSet; + } + + private static int AddNamed( + BoundArguments arguments, + List bindings, + string parameterName, + ExecutionRegionPhase phase, + ExecutionRegionTiming timing, + ExecutionRegionCardinality cardinality, + bool isComplete = true) + { + var count = 0; + foreach (var argument in arguments.NamedScriptBlocks(parameterName)) + { + bindings.Add(Binding( + argument.ElementIndex, + parameterName, + phase, + timing, + cardinality, + isComplete)); + count++; + } + + return count; + } + + private static PwshExecutionRegionBinding Binding( + int elementIndex, + string parameterName, + ExecutionRegionPhase phase, + ExecutionRegionTiming timing, + ExecutionRegionCardinality cardinality, + bool isComplete = true) => + new(elementIndex, parameterName, phase, timing, cardinality, isComplete); + + private static BoundArguments BindArguments( + IReadOnlyList elements, + CommandEntry command) + { + var result = new BoundArguments(); + var consumed = new HashSet(); + var positionalIndex = 0; + for (var index = 0; index < elements.Count; index++) + { + var element = elements[index]; + if (element.Role != ClauseElementRole.Argument || consumed.Contains(index)) + { + continue; + } + + if (element.IsFlag) + { + var parameter = ParseParameter(element.Value); + if (!parameter.IsSupportedSpelling) + { + result.HasAmbiguousScriptBlockBinding = true; + continue; + } + + var resolution = command.Resolve(parameter.Name); + if (!resolution.IsKnown) + { + // An unknown or ambiguous parameter can select a different + // parameter set or consume a later value. Once this clause + // contains a script block, no local adjacency heuristic is + // strong enough to retain proved receiver semantics. + result.HasAmbiguousScriptBlockBinding = true; + continue; + } + + if (resolution.IsSwitch + && parameter.HasInlineSeparator + && !parameter.HasInlineValue) + { + result.HasAmbiguousScriptBlockBinding = true; + continue; + } + + result.AddNamedParameter(resolution.CanonicalName!); + if (parameter.InlineScriptBlock) + { + result.NamedArguments.Add(new BoundArgument( + index, -1, true, resolution.CanonicalName, + HasTrailingComma(element), parameter.InlineValue!)); + if (HasTrailingComma(element) + && !AcceptsScriptBlockArray(resolution.CanonicalName!)) + { + result.HasInvalidScalarScriptBlockArray = true; + } + + continue; + } + + if (parameter.HasInlineValue) + { + result.NamedArguments.Add(new BoundArgument( + index, -1, false, resolution.CanonicalName, false, + parameter.InlineValue!)); + continue; + } + + if (resolution.IsSwitch || + !TryFindNextArgument(elements, index + 1, out var valueIndex)) + { + if (!resolution.IsSwitch) + { + result.HasAmbiguousScriptBlockBinding = true; + } + + continue; + } + + if (elements[valueIndex].IsFlag) + { + result.HasAmbiguousScriptBlockBinding = true; + continue; + } + + consumed.Add(valueIndex); + result.NamedArguments.Add(new BoundArgument( + valueIndex, -1, IsScriptBlock(elements[valueIndex]), + resolution.CanonicalName, HasTrailingComma(elements[valueIndex]), + elements[valueIndex].Value)); + if (HasTrailingComma(elements[valueIndex]) + && !AcceptsScriptBlockArray(resolution.CanonicalName!)) + { + result.HasInvalidScalarScriptBlockArray = true; + } + + continue; + } + + result.PositionalArguments.Add(new BoundArgument( + index, positionalIndex, IsScriptBlock(element), null, + HasTrailingComma(element), element.Value)); + positionalIndex++; + } + + return result; + } + + private static bool TryFindNextArgument( + IReadOnlyList elements, + int start, + out int index) + { + for (index = start; index < elements.Count; index++) + { + if (elements[index].Role == ClauseElementRole.Argument) + { + return true; + } + } + + index = -1; + return false; + } + + private static ParsedParameter ParseParameter(string value) + { + if (value.Length < 2 || value[0] != '-' || value[1] == '-') + { + return new ParsedParameter(string.Empty, false, false, false, false, null); + } + + var colon = value.IndexOf(':'); + if (colon < 0) + { + return new ParsedParameter(value.Substring(1), false, false, true, false, null); + } + + var inlineValue = value.Substring(colon + 1).Trim(); + return new ParsedParameter( + value.Substring(1, colon - 1), + inlineValue.Length > 0, + LooksLikeScriptBlock(inlineValue), + true, + true, + inlineValue); + } + + private static bool AcceptsScriptBlockArray(string parameterName) => + string.Equals(parameterName, "Process", StringComparison.OrdinalIgnoreCase) + || string.Equals(parameterName, "RemainingScripts", StringComparison.OrdinalIgnoreCase); + + private static bool HasTrailingComma(ClauseElement element) => + element.Raw.TrimEnd().EndsWith(",", StringComparison.Ordinal); + + private static PwshParameterValueKind ValueKindFor(string parameterName) + { + if (ScriptBlockParameters.Contains(parameterName)) + { + return PwshParameterValueKind.ScriptBlock; + } + + if (SwitchParameters.Contains(parameterName)) + { + return PwshParameterValueKind.Switch; + } + + if (Int32Parameters.Contains(parameterName)) + { + return PwshParameterValueKind.Int32; + } + + if (EnumParameters.Contains(parameterName)) + { + return PwshParameterValueKind.Enum; + } + + if (ObjectParameters.Contains(parameterName)) + { + return PwshParameterValueKind.Object; + } + + if (RuntimeObjectParameters.Contains(parameterName)) + { + return PwshParameterValueKind.RuntimeObject; + } + + switch (parameterName) + { + case "ConnectionUri": + return PwshParameterValueKind.Uri; + case "VMId": + return PwshParameterValueKind.Guid; + case "PSVersion": + return PwshParameterValueKind.Version; + case "Options": + return PwshParameterValueKind.Hashtable; + default: + return PwshParameterValueKind.String; + } + } + + private static bool CanConvert( + BoundArgument argument, + string parameterName) + { + var valueKind = ValueKindFor(parameterName); + switch (valueKind) + { + case PwshParameterValueKind.String: + return !argument.IsScriptBlock + && IsStringLiteral(parameterName, argument.Value); + case PwshParameterValueKind.Object: + return !string.Equals( + argument.Value, + "$null", + StringComparison.OrdinalIgnoreCase); + case PwshParameterValueKind.ScriptBlock: + return argument.IsScriptBlock; + case PwshParameterValueKind.Switch: + return IsBooleanLiteral(parameterName, argument.Value); + case PwshParameterValueKind.Int32: + return IsInt32Literal(parameterName, argument.Value); + case PwshParameterValueKind.Enum: + return IsEnumLiteral(parameterName, argument.Value); + case PwshParameterValueKind.Uri: + return Uri.TryCreate(argument.Value, UriKind.Absolute, out _); + case PwshParameterValueKind.Guid: + return System.Guid.TryParse(argument.Value, out _); + case PwshParameterValueKind.Version: + return string.Equals(argument.Value, "5.1", StringComparison.Ordinal); + case PwshParameterValueKind.Hashtable: + return argument.Value.StartsWith("@{", StringComparison.Ordinal) + && argument.Value.EndsWith("}", StringComparison.Ordinal) + && argument.Value.Substring(2, argument.Value.Length - 3) + .Trim().Length > 0; + case PwshParameterValueKind.RuntimeObject: + default: + return false; + } + } + + private static bool IsStringLiteral(string parameterName, string value) + { + if (value.Length == 0 + || string.Equals(value, "$null", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (string.Equals(parameterName, "WorkingDirectory", + StringComparison.OrdinalIgnoreCase)) + { + return !string.IsNullOrWhiteSpace(value); + } + + if (parameterName.EndsWith("Variable", StringComparison.OrdinalIgnoreCase)) + { + return IsSimpleVariableName(value); + } + + return true; + } + + private static bool IsSimpleVariableName(string value) + { + if (value.Length == 0 + || !(value[0] == '_' || char.IsLetter(value[0]))) + { + return false; + } + + return value.Skip(1).All(character => + character == '_' || char.IsLetterOrDigit(character)); + } + + private static bool IsBooleanLiteral(string parameterName, string value) + { + var trueValue = string.Equals(value, "$true", StringComparison.OrdinalIgnoreCase) + || value == "1"; + if (string.Equals(parameterName, "SSHTransport", StringComparison.OrdinalIgnoreCase)) + { + return trueValue; + } + + return trueValue + || string.Equals(value, "$false", StringComparison.OrdinalIgnoreCase) + || value == "0"; + } + + private static bool IsInt32Literal(string parameterName, string value) + { + if (!int.TryParse(value, NumberStyles.Integer, + CultureInfo.InvariantCulture, out var parsed)) + { + return false; + } + + switch (parameterName) + { + case "ThrottleLimit": + return parsed >= 1 && parsed <= 1_000_000; + case "TimeoutSeconds": + return parsed >= 0 && parsed <= 2_147_483; + case "OutBuffer": + return parsed >= 0; + case "Port": + return parsed >= 1 && parsed <= 65_535; + case "Column": + case "Line": + return parsed >= 1; + default: + return true; + } + } + + private static bool IsEnumLiteral(string parameterName, string value) + { + string names; + switch (parameterName) + { + case "Authentication": + names = "Default"; + break; + case "ListenerOption": + names = "None,LogicalOperationStack,DateTime,Timestamp,ProcessId,ThreadId," + + "Callstack"; + break; + case "Mode": + names = "Read,Write,ReadWrite"; + break; + case "Option": + names = "None,Constructor,Dispose,Finalizer,Method,Property,Delegates,Events," + + "Exception,Lock,Error,Errors,Warning,Verbose,WriteLine,Data,Scope," + + "ExecutionFlow,Assert,All"; + break; + default: + names = "SilentlyContinue,Stop,Continue,Inquire,Ignore,Break"; + break; + } + + var allowed = Names(names); + var parts = value.Split(',').Select(part => part.Trim()).ToArray(); + return parts.Length > 0 + && parts.All(part => part.Length > 0 && allowed.Contains(part)); + } + + private static IReadOnlyList FindScriptBlocks(IReadOnlyList elements) + { + var result = new List(); + for (var index = 0; index < elements.Count; index++) + { + if (elements[index].Role == ClauseElementRole.Argument && IsScriptBlock(elements[index])) + { + result.Add(index); + } + } + + return result; + } + + private static bool IsScriptBlock(ClauseElement element) + { + if (element.Kind != ArgKind.DynamicSkip) + { + return false; + } + + if (LooksLikeScriptBlock(element.Raw.Trim())) + { + return true; + } + + var colon = element.Raw.IndexOf(':'); + return colon >= 0 && LooksLikeScriptBlock(element.Raw.Substring(colon + 1).Trim()); + } + + private static bool LooksLikeScriptBlock(string value) => + IsDelimitedScriptBlock(value) + || value.Length >= 3 && value[value.Length - 1] == ',' + && IsDelimitedScriptBlock(value.Substring(0, value.Length - 1).TrimEnd()); + + private static bool IsDelimitedScriptBlock(string value) => + value.Length >= 2 && value[0] == '{' && value[value.Length - 1] == '}'; + + private static bool TryResolveCommand( + VerbChain verb, + out string? canonicalName, + out CommandEntry entry) + { + canonicalName = verb.CanonicalVerb ?? verb.Tokens.FirstOrDefault(); + entry = default!; + if (verb.IsDynamic || canonicalName is null) + { + return false; + } + + return TryResolveStaticCommandName(canonicalName, out canonicalName) + && Commands.TryGetValue(canonicalName!, out entry!); + } + + private static PwshExecutionRegionBindingResult Unknown( + string? canonicalName, + IReadOnlyList scriptBlocks) => + Ambiguous(canonicalName, PwshExecutionRegionReceiver.Unknown, scriptBlocks); + + private static PwshExecutionRegionBindingResult Ambiguous( + string? canonicalName, + PwshExecutionRegionReceiver receiver, + IReadOnlyList scriptBlocks) => + new() + { + Status = PwshExecutionRegionBindingStatus.Ambiguous, + Receiver = receiver, + ParameterSet = PwshExecutionRegionParameterSet.Unknown, + CanonicalCommandName = canonicalName, + Bindings = scriptBlocks.Select(index => new PwshExecutionRegionBinding( + index, + null, + ExecutionRegionPhase.Unknown, + ExecutionRegionTiming.Unknown, + ExecutionRegionCardinality.Unknown, + false)).ToArray(), + }; + + private static CommandEntry Entry( + PwshExecutionRegionReceiver receiver, + string moduleName, + string parameters, + string switches, + string aliases = "") => + new(receiver, moduleName, Names(parameters), Names(switches), Aliases(aliases)); + + private static HashSet Names(string names) + { + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var name in names.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) + { + result.Add(name.Trim()); + } + + return result; + } + + private static IReadOnlyDictionary Aliases(string aliases) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in aliases.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) + { + var separator = pair.IndexOf('='); + if (separator > 0 && separator + 1 < pair.Length) + { + result[pair.Substring(0, separator).Trim()] = + pair.Substring(separator + 1).Trim(); + } + } + + return result; + } + + private static IReadOnlyList ParameterSetsFor( + PwshExecutionRegionReceiver receiver) + { + switch (receiver) + { + case PwshExecutionRegionReceiver.ForEachObject: + return new[] + { + Set("ScriptBlockSet", + "InputObject,Begin,Process,End,RemainingScripts,WhatIf,Confirm", + "Process", new[] { Pos("Process", acceptsScriptBlock: true, multiple: true) }, + remaining: "RemainingScripts", remainingAcceptsScriptBlock: true), + Set("PropertyAndMethodSet", + "InputObject,MemberName,ArgumentList,WhatIf,Confirm", + "MemberName", new[] { Pos("MemberName") }, remaining: "ArgumentList"), + Set("ParallelParameterSet", + "InputObject,Parallel,ThrottleLimit,TimeoutSeconds,AsJob," + + "UseNewRunspace,WhatIf,Confirm", + "Parallel"), + }; + case PwshExecutionRegionReceiver.WhereObject: + return WhereParameterSets(); + case PwshExecutionRegionReceiver.InvokeCommand: + return InvokeParameterSets(); + case PwshExecutionRegionReceiver.MeasureCommand: + return new[] + { + Set("__AllParameterSets", "InputObject,Expression", "Expression", + new[] { Pos("Expression", acceptsScriptBlock: true) }), + }; + case PwshExecutionRegionReceiver.TraceCommand: + return new[] + { + Set("expressionSet", + "InputObject,Name,Option,Expression,ListenerOption,FilePath,Force," + + "Debugger,PSHost", + "Name,Expression", + new[] { Pos("Name"), Pos("Expression", true), Pos("Option") }), + Set("commandSet", + "InputObject,Name,Option,Command,ArgumentList,ListenerOption,FilePath," + + "Force,Debugger,PSHost", + "Name,Command", + new[] { Pos("Name"), Pos("Command"), Pos("Option") }, + remaining: "ArgumentList"), + }; + case PwshExecutionRegionReceiver.StartJob: + return new[] + { + Set("ComputerName", + "Name,ScriptBlock,Credential,Authentication,InitializationScript," + + "WorkingDirectory,RunAs32,PSVersion,InputObject,ArgumentList", + "ScriptBlock", + new[] { Pos("ScriptBlock", true), Pos("InitializationScript", true) }), + Set("DefinitionName", + "DefinitionName,DefinitionPath,Type,WorkingDirectory", + "DefinitionName", + new[] { Pos("DefinitionName"), Pos("DefinitionPath"), Pos("Type") }), + Set("FilePathComputerName", + "Name,Credential,FilePath,Authentication,InitializationScript," + + "WorkingDirectory,RunAs32,PSVersion,InputObject,ArgumentList", + "FilePath", + new[] { Pos("FilePath"), Pos("InitializationScript", true) }), + Set("LiteralFilePathComputerName", + "Name,Credential,LiteralPath,Authentication,InitializationScript," + + "WorkingDirectory,RunAs32,PSVersion,InputObject,ArgumentList", + "LiteralPath", + new[] { Pos("InitializationScript", true) }), + Set("SSHHost", "WorkingDirectory,ConnectingTimeout,Options", string.Empty), + }; + case PwshExecutionRegionReceiver.NewModule: + return new[] + { + Set("ScriptBlock", + "ScriptBlock,Function,Cmdlet,ReturnResult,AsCustomObject,ArgumentList", + "ScriptBlock", new[] { Pos("ScriptBlock", true) }, + remaining: "ArgumentList"), + Set("Name", + "Name,ScriptBlock,Function,Cmdlet,ReturnResult,AsCustomObject,ArgumentList", + "Name,ScriptBlock", new[] { Pos("Name"), Pos("ScriptBlock", true) }, + remaining: "ArgumentList"), + }; + case PwshExecutionRegionReceiver.SetPSBreakpoint: + return new[] + { + Set("Line", "Action,Column,Line,Script,Runspace", "Line,Script", + new[] { Pos("Script"), Pos("Line"), Pos("Column") }), + Set("Command", "Action,Command,Script,Runspace", "Command", + new[] { Pos("Script") }), + Set("Variable", "Action,Script,Variable,Mode,Runspace", "Variable", + new[] { Pos("Script") }), + }; + case PwshExecutionRegionReceiver.RegisterObjectEvent: + return new[] + { + Set("__AllParameterSets", + "InputObject,EventName,SourceIdentifier,Action,MessageData,SupportEvent," + + "Forward,MaxTriggerCount", + "InputObject,EventName", + new[] + { + Pos("InputObject"), Pos("EventName"), Pos("SourceIdentifier"), + Pos("Action", true), + }), + }; + case PwshExecutionRegionReceiver.RegisterEngineEvent: + return new[] + { + Set("__AllParameterSets", + "SourceIdentifier,Action,MessageData,SupportEvent,Forward,MaxTriggerCount", + "SourceIdentifier", + new[] { Pos("SourceIdentifier"), Pos("Action", true) }), + }; + case PwshExecutionRegionReceiver.RegisterArgumentCompleter: + return new[] + { + Set("NativeCommandSet", "CommandName,ScriptBlock,Native", + "CommandName,ScriptBlock,Native"), + Set("PowerShellSet", "CommandName,ParameterName,ScriptBlock", + "ParameterName,ScriptBlock"), + Set("NativeFallbackSet", "ScriptBlock,NativeFallback", + "ScriptBlock,NativeFallback"), + }; + case PwshExecutionRegionReceiver.StartThreadJob: + return new[] + { + Set("ScriptBlock", + "ScriptBlock,Name,InitializationScript,InputObject,ArgumentList," + + "ThrottleLimit,StreamingHost", + "ScriptBlock", new[] { Pos("ScriptBlock", true) }), + Set("FilePath", + "FilePath,Name,InitializationScript,InputObject,ArgumentList," + + "ThrottleLimit,StreamingHost", + "FilePath", new[] { Pos("FilePath") }), + }; + case PwshExecutionRegionReceiver.WriteOutput: + return new[] + { + Set("__AllParameterSets", "InputObject,NoEnumerate", "InputObject", + new[] { Pos("InputObject") }, remaining: "InputObject"), + }; + default: + return Array.Empty(); + } + } + + private static IReadOnlyList WhereParameterSets() + { + var result = new List + { + Set("ScriptBlockSet", "InputObject,FilterScript", "FilterScript", + new[] { Pos("FilterScript", true) }), + Set("EqualSet", "InputObject,Property,Value,EQ", "Property", + new[] { Pos("Property"), Pos("Value") }), + }; + var operators = new[] + { + "CEQ", "NE", "CNE", "GT", "CGT", "LT", "CLT", "GE", "CGE", "LE", + "CLE", "Like", "CLike", "NotLike", "CNotLike", "Match", "CMatch", + "NotMatch", "CNotMatch", "Contains", "CContains", "NotContains", + "CNotContains", "In", "CIn", "NotIn", "CNotIn", "Is", "IsNot", + }; + foreach (var operation in operators) + { + result.Add(Set(operation + "Set", "InputObject,Property,Value," + operation, + "Property," + operation, new[] { Pos("Property"), Pos("Value") })); + } + + result.Add(Set("Not", "InputObject,Property,Not", "Property,Not", + new[] { Pos("Property") })); + return result; + } + + private static IReadOnlyList InvokeParameterSets() => new[] + { + Set("InProcess", "ScriptBlock,NoNewScope,InputObject,ArgumentList", "ScriptBlock", + new[] { Pos("ScriptBlock", true) }), + Set("Session", + "Session,ThrottleLimit,AsJob,HideComputerName,JobName,ScriptBlock,RemoteDebug," + + "InputObject,ArgumentList", + "ScriptBlock", new[] { Pos("Session"), Pos("ScriptBlock", true) }, + selectionRequiredAny: "Session", remote: true), + Set("FilePathRunspace", + "Session,ThrottleLimit,AsJob,HideComputerName,JobName,FilePath,RemoteDebug," + + "InputObject,ArgumentList", + "FilePath", new[] { Pos("Session"), Pos("FilePath") }, + selectionRequiredAny: "Session", remote: true), + Set("ComputerName", + "ComputerName,Credential,Port,UseSSL,ConfigurationName,ApplicationName," + + "ThrottleLimit,AsJob,InDisconnectedSession,SessionName,HideComputerName," + + "JobName,ScriptBlock,SessionOption,Authentication,EnableNetworkAccess," + + "RemoteDebug,InputObject,ArgumentList,CertificateThumbprint", + "ScriptBlock", new[] { Pos("ComputerName"), Pos("ScriptBlock", true) }, + selectionRequiredAny: "ComputerName", remote: true), + Set("FilePathComputerName", + "ComputerName,Credential,Port,UseSSL,ConfigurationName,ApplicationName," + + "ThrottleLimit,AsJob,InDisconnectedSession,SessionName,HideComputerName," + + "JobName,FilePath,SessionOption,Authentication,EnableNetworkAccess,RemoteDebug," + + "InputObject,ArgumentList", + "FilePath", new[] { Pos("ComputerName"), Pos("FilePath") }, + selectionRequiredAny: "ComputerName", remote: true), + Set("Uri", + "Credential,ConfigurationName,ThrottleLimit,ConnectionUri,AsJob," + + "InDisconnectedSession,HideComputerName,JobName,ScriptBlock,AllowRedirection," + + "SessionOption,Authentication,EnableNetworkAccess,RemoteDebug,InputObject," + + "ArgumentList,CertificateThumbprint", + "ScriptBlock", new[] { Pos("ConnectionUri"), Pos("ScriptBlock", true) }, + selectionRequiredAny: "ConnectionUri", remote: true), + Set("FilePathUri", + "Credential,ConfigurationName,ThrottleLimit,ConnectionUri,AsJob," + + "InDisconnectedSession,HideComputerName,JobName,FilePath,AllowRedirection," + + "SessionOption,Authentication,EnableNetworkAccess,RemoteDebug,InputObject," + + "ArgumentList", + "FilePath", new[] { Pos("ConnectionUri"), Pos("FilePath") }, + selectionRequiredAny: "ConnectionUri", remote: true), + Set("VMId", + "Credential,ConfigurationName,ThrottleLimit,AsJob,HideComputerName,ScriptBlock," + + "RemoteDebug,InputObject,ArgumentList,VMId", + "Credential,ScriptBlock,VMId", new[] { Pos("VMId"), Pos("ScriptBlock", true) }, + remote: true), + Set("VMName", + "Credential,ConfigurationName,ThrottleLimit,AsJob,HideComputerName,ScriptBlock," + + "RemoteDebug,InputObject,ArgumentList,VMName", + "Credential,ScriptBlock,VMName", new[] { Pos("ScriptBlock", true) }, remote: true), + Set("SSHHost", + "Port,AsJob,HideComputerName,JobName,ScriptBlock,HostName,UserName,KeyFilePath," + + "Subsystem,ConnectingTimeout,SSHTransport,Options,RemoteDebug,InputObject," + + "ArgumentList", + "ScriptBlock,HostName", new[] { Pos("ScriptBlock", true) }, remote: true), + Set("ContainerId", + "ConfigurationName,ThrottleLimit,AsJob,HideComputerName,JobName,ScriptBlock," + + "RunAsAdministrator,RemoteDebug,InputObject,ArgumentList,ContainerId", + "ScriptBlock,ContainerId", new[] { Pos("ScriptBlock", true) }, remote: true), + Set("SSHHostHashParam", + "AsJob,HideComputerName,JobName,ScriptBlock,SSHConnection,RemoteDebug," + + "InputObject,ArgumentList", + "ScriptBlock,SSHConnection", new[] { Pos("ScriptBlock", true) }, remote: true), + Set("FilePathVMId", + "Credential,ConfigurationName,ThrottleLimit,AsJob,HideComputerName,FilePath," + + "RemoteDebug,InputObject,ArgumentList,VMId", + "Credential,FilePath,VMId", new[] { Pos("VMId"), Pos("FilePath") }, remote: true), + Set("FilePathVMName", + "Credential,ConfigurationName,ThrottleLimit,AsJob,HideComputerName,FilePath," + + "RemoteDebug,InputObject,ArgumentList,VMName", + "Credential,FilePath,VMName", new[] { Pos("FilePath") }, remote: true), + Set("FilePathContainerId", + "ConfigurationName,ThrottleLimit,AsJob,HideComputerName,JobName,FilePath," + + "RunAsAdministrator,RemoteDebug,InputObject,ArgumentList,ContainerId", + "FilePath,ContainerId", remote: true), + Set("FilePathSSHHost", + "AsJob,HideComputerName,FilePath,HostName,UserName,KeyFilePath,Subsystem," + + "ConnectingTimeout,SSHTransport,Options,RemoteDebug,InputObject,ArgumentList", + "FilePath,HostName", remote: true), + Set("FilePathSSHHostHash", + "AsJob,HideComputerName,FilePath,SSHConnection,RemoteDebug,InputObject,ArgumentList", + "FilePath,SSHConnection", remote: true), + }; + + private static ParameterSetDefinition Set( + string name, + string allowed, + string mandatory, + IReadOnlyList? positionals = null, + string? remaining = null, + bool remainingAcceptsScriptBlock = false, + string selectionRequiredAny = "", + bool remote = false) => + new(name, Names(allowed), Names(mandatory), + positionals ?? Array.Empty(), remaining, + remainingAcceptsScriptBlock, Names(selectionRequiredAny), remote); + + private static PositionalParameter Pos( + string name, + bool acceptsScriptBlock = false, + bool multiple = false) => new(name, acceptsScriptBlock, multiple); + + private readonly record struct ParsedParameter( + string Name, + bool HasInlineValue, + bool InlineScriptBlock, + bool IsSupportedSpelling, + bool HasInlineSeparator, + string? InlineValue); + + private readonly record struct BoundArgument( + int ElementIndex, + int Position, + bool IsScriptBlock, + string? ParameterName, + bool HasTrailingComma, + string Value); + + private sealed class BoundArguments + { + internal List NamedArguments { get; } = new(); + + internal List PositionalArguments { get; } = new(); + + internal HashSet NamedParameters { get; } = + new(StringComparer.OrdinalIgnoreCase); + + internal bool HasAmbiguousScriptBlockBinding { get; set; } + + internal bool HasDuplicateParameter { get; private set; } + + internal bool HasInvalidScalarScriptBlockArray { get; set; } + + internal void AddNamedParameter(string name) + { + if (!NamedParameters.Add(name)) + { + HasDuplicateParameter = true; + } + } + + internal bool HasNamed(string name) => NamedParameters.Contains(name); + + internal bool HasAnyNamed(params string[] names) => names.Any(HasNamed); + + internal int CountNamed(params string[] names) => names.Count(HasNamed); + + internal IEnumerable NamedScriptBlocks(string parameterName) => + NamedArguments.Where(argument => + argument.IsScriptBlock && string.Equals( + argument.ParameterName, + parameterName, + StringComparison.OrdinalIgnoreCase)); + + internal IEnumerable PositionalScriptBlocks() => + PositionalArguments.Where(argument => argument.IsScriptBlock); + + internal BoundArgument? FirstPositionalScriptBlockAt(int position) + { + foreach (var block in PositionalScriptBlocks()) + { + if (block.Position == position) + { + return block; + } + } + + return null; + } + + internal BoundArgument? FirstPositionalScriptBlockBoundTo( + string parameterName, + ParameterSetDefinition parameterSet) + { + return parameterSet.BindPositionals(this) + .Where(binding => string.Equals( + binding.ParameterName, + parameterName, + StringComparison.OrdinalIgnoreCase)) + .Select(binding => (BoundArgument?)binding.Argument) + .FirstOrDefault(); + } + } + + private sealed class CommandEntry + { + private readonly HashSet _parameters; + private readonly HashSet _switches; + private readonly Dictionary _aliases; + private readonly IReadOnlyList _parameterSets; + + internal CommandEntry( + PwshExecutionRegionReceiver receiver, + string moduleName, + HashSet parameters, + HashSet switches, + IReadOnlyDictionary aliases) + { + Receiver = receiver; + ModuleName = moduleName; + _parameterSets = ParameterSetsFor(receiver); + _parameters = new HashSet(CommonParameters, StringComparer.OrdinalIgnoreCase); + _parameters.UnionWith(parameters); + _switches = new HashSet(CommonSwitchParameters, StringComparer.OrdinalIgnoreCase); + _switches.UnionWith(switches); + _aliases = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var alias in CommonParameterAliases) + { + _aliases[alias.Key] = alias.Value; + } + + foreach (var alias in aliases) + { + _aliases[alias.Key] = alias.Value; + } + } + + internal PwshExecutionRegionReceiver Receiver { get; } + + internal string ModuleName { get; } + + internal IReadOnlyList GetCompatibleParameterSets( + BoundArguments arguments) => + _parameterSets.Where(set => set.IsCompatible(arguments)).ToArray(); + + internal ParameterResolution Resolve(string prefix) + { + var exact = _parameters.FirstOrDefault(name => + string.Equals(name, prefix, StringComparison.OrdinalIgnoreCase)); + if (exact is not null) + { + return new ParameterResolution(exact, _switches.Contains(exact), true); + } + + if (_aliases.TryGetValue(prefix, out var exactAlias)) + { + return new ParameterResolution( + exactAlias, + _switches.Contains(exactAlias), + true); + } + + var matches = _parameters.Where(name => + name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .Concat(_aliases.Where(alias => + alias.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .Select(alias => alias.Value)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + return matches.Length == 1 + ? new ParameterResolution(matches[0], _switches.Contains(matches[0]), true) + : new ParameterResolution(null, false, false); + } + } + + private sealed class ParameterSetDefinition + { + private readonly HashSet _allowed; + private readonly HashSet _mandatory; + private readonly IReadOnlyList _positionals; + private readonly string? _remaining; + private readonly bool _remainingAcceptsScriptBlock; + private readonly HashSet _selectionRequiredAny; + + internal ParameterSetDefinition( + string name, + HashSet allowed, + HashSet mandatory, + IReadOnlyList positionals, + string? remaining, + bool remainingAcceptsScriptBlock, + HashSet selectionRequiredAny, + bool isRemote) + { + Name = name; + _allowed = allowed; + _mandatory = mandatory; + _positionals = positionals; + _remaining = remaining; + _remainingAcceptsScriptBlock = remainingAcceptsScriptBlock; + _selectionRequiredAny = selectionRequiredAny; + IsRemote = isRemote; + } + + internal string Name { get; } + + internal bool IsRemote { get; } + + internal bool IsCompatible(BoundArguments arguments) + { + if (arguments.NamedParameters.Any(parameter => + !CommonParameters.Contains(parameter) && !_allowed.Contains(parameter))) + { + return false; + } + + if (arguments.NamedArguments.Any(argument => + !CanConvert(argument, argument.ParameterName!))) + { + return false; + } + + if (!TryBindPositionals(arguments, out var positionalBindings)) + { + return false; + } + + var bound = new HashSet(arguments.NamedParameters, + StringComparer.OrdinalIgnoreCase); + bound.UnionWith(positionalBindings.Select(binding => binding.ParameterName)); + return _mandatory.All(bound.Contains) + && (_selectionRequiredAny.Count == 0 + || _selectionRequiredAny.Any(bound.Contains)); + } + + internal IReadOnlyList BindPositionals(BoundArguments arguments) + { + return TryBindPositionals(arguments, out var bindings) + ? bindings + : Array.Empty(); + } + + private bool TryBindPositionals( + BoundArguments arguments, + out IReadOnlyList bindings) + { + var result = new List(); + var slot = 0; + foreach (var argument in arguments.PositionalArguments) + { + while (slot < _positionals.Count + && arguments.HasNamed(_positionals[slot].Name)) + { + slot++; + } + + if (slot < _positionals.Count) + { + var positional = _positionals[slot]; + if (argument.IsScriptBlock != positional.AcceptsScriptBlock + || !CanConvert(argument, positional.Name) + || (argument.HasTrailingComma && !positional.AcceptsMultiple)) + { + bindings = Array.Empty(); + return false; + } + + result.Add(new PositionalBinding(argument, positional.Name)); + if (!positional.AcceptsMultiple) + { + slot++; + } + + continue; + } + + if (_remaining is null + || argument.IsScriptBlock && !_remainingAcceptsScriptBlock) + { + bindings = Array.Empty(); + return false; + } + + result.Add(new PositionalBinding(argument, _remaining)); + } + + bindings = result; + return true; + } + } + + private readonly record struct PositionalParameter( + string Name, + bool AcceptsScriptBlock, + bool AcceptsMultiple); + + private readonly record struct PositionalBinding( + BoundArgument Argument, + string ParameterName); + + private readonly record struct ParameterResolution( + string? CanonicalName, + bool IsSwitch, + bool IsKnown); +} diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs new file mode 100644 index 0000000..3480239 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs @@ -0,0 +1,622 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System; +using System.Linq; +using ShellSyntaxTree.Internal.Pwsh.Verbs; +using Xunit; + +namespace ShellSyntaxTree.Tests.Parsing; + +public class PwshExecutionRegionBindingCatalogTests +{ + private static readonly PwshParser Parser = new(new PwshParserOptions + { + HomeDirectory = "C:/Users/user", + WorkingDirectory = "C:/work", + }); + + [Theory] + [InlineData("% { Get-Date }", "ForEachObject", + "ForEachScriptBlock", ExecutionRegionPhase.Process, + ExecutionRegionTiming.Synchronous, ExecutionRegionCardinality.OncePerInputObject)] + [InlineData("? { Get-Date }", "WhereObject", + "WhereScriptBlock", ExecutionRegionPhase.Filter, + ExecutionRegionTiming.Synchronous, ExecutionRegionCardinality.OncePerInputObject)] + [InlineData("icm { Get-Date }", "InvokeCommand", + "InvokeInProcess", ExecutionRegionPhase.Main, + ExecutionRegionTiming.Synchronous, ExecutionRegionCardinality.Once)] + [InlineData("Measure-Command { Get-Date }", "MeasureCommand", + "MeasureExpression", ExecutionRegionPhase.Main, + ExecutionRegionTiming.Synchronous, ExecutionRegionCardinality.Once)] + [InlineData("Trace-Command * { Get-Date }", "TraceCommand", + "TraceExpression", ExecutionRegionPhase.Main, + ExecutionRegionTiming.Synchronous, ExecutionRegionCardinality.Once)] + [InlineData("sajb { Get-Date }", "StartJob", + "StartJobScriptBlock", ExecutionRegionPhase.Main, + ExecutionRegionTiming.Concurrent, ExecutionRegionCardinality.Once)] + [InlineData("nmo { Get-Date }", "NewModule", + "NewModuleScriptBlock", ExecutionRegionPhase.Initialization, + ExecutionRegionTiming.Synchronous, ExecutionRegionCardinality.Once)] + [InlineData("sbp -Variable x -Action { Get-Date }", + "SetPSBreakpoint", + "Breakpoint", ExecutionRegionPhase.Action, + ExecutionRegionTiming.Deferred, ExecutionRegionCardinality.ZeroOrMore)] + [InlineData("Register-ObjectEvent $source Changed subscription { Get-Date }", + "RegisterObjectEvent", + "ObjectEvent", ExecutionRegionPhase.Action, + ExecutionRegionTiming.Deferred, ExecutionRegionCardinality.ZeroOrMore)] + [InlineData("Register-EngineEvent source { Get-Date }", + "RegisterEngineEvent", + "EngineEvent", ExecutionRegionPhase.Action, + ExecutionRegionTiming.Deferred, ExecutionRegionCardinality.ZeroOrMore)] + [InlineData("Register-ArgumentCompleter -CommandName git -ParameterName x -ScriptBlock { Get-Date }", + "RegisterArgumentCompleter", + "ArgumentCompleter", ExecutionRegionPhase.Completion, + ExecutionRegionTiming.Deferred, ExecutionRegionCardinality.ZeroOrMore)] + public void Pinned_receivers_bind_their_script_block( + string source, + string receiver, + string parameterSet, + ExecutionRegionPhase phase, + ExecutionRegionTiming timing, + ExecutionRegionCardinality cardinality) + { + var result = Bind(source); + + Assert.Equal(PwshExecutionRegionBindingStatus.ProvedExecution, result.Status); + Assert.Equal(receiver, result.Receiver.ToString()); + Assert.Equal(parameterSet, result.ParameterSet.ToString()); + var binding = Assert.Single(result.Bindings); + Assert.Equal(phase, binding.Phase); + Assert.Equal(timing, binding.Timing); + Assert.Equal(cardinality, binding.Cardinality); + Assert.True(binding.IsComplete); + } + + [Theory] + [InlineData("& 'ForEach-Object' { Get-Date }")] + [InlineData("& '%' { Get-Date }")] + public void Static_command_spellings_share_the_canonical_receiver(string source) + { + var result = Bind(source); + + Assert.Equal(PwshExecutionRegionBindingStatus.ProvedExecution, result.Status); + Assert.Equal(PwshExecutionRegionReceiver.ForEachObject, result.Receiver); + Assert.Equal("ForEach-Object", result.CanonicalCommandName); + Assert.Equal(ExecutionRegionPhase.Process, Assert.Single(result.Bindings).Phase); + } + + [Fact] + public void Module_qualified_receiver_is_catalogued_but_not_admitted_before_body_emission() + { + var resolved = PwshExecutionRegionBindingCatalog.TryResolveStaticCommandName( + "Microsoft.PowerShell.Core\\ForEach-Object", + out var canonical); + var parsed = Parser.Parse( + "Microsoft.PowerShell.Core\\ForEach-Object { Remove-Item victim.txt }"); + + Assert.True(resolved); + Assert.Equal("ForEach-Object", canonical); + Assert.True(parsed.IsUnparseable); + Assert.Empty(parsed.Commands); + Assert.Empty(parsed.Clauses); + } + + [Theory] + [InlineData("ForEach-Object -Proc { Get-Date }")] + [InlineData("ForEach-Object -Process:{ Get-Date }")] + [InlineData("ForEach-Object -PROCESS { Get-Date }")] + public void Exact_prefix_inline_and_case_forms_share_process_binding(string source) + { + var binding = Assert.Single(Bind(source).Bindings); + + Assert.Equal("Process", binding.CanonicalParameterName); + Assert.Equal(ExecutionRegionPhase.Process, binding.Phase); + } + + [Theory] + [InlineData("ForEach-Object -ov captured { Get-Date }")] + [InlineData("ForEach-Object -db { Get-Date }")] + public void Common_parameter_aliases_preserve_the_positional_process_block(string source) + { + var binding = Assert.Single(Bind(source).Bindings); + + Assert.Equal(ExecutionRegionPhase.Process, binding.Phase); + } + + [Fact] + public void Parameter_alias_prefix_can_bind_the_script_block_parameter() + { + var result = Bind("Invoke-Command -Comm { Get-Date }"); + + Assert.Equal(PwshExecutionRegionParameterSet.InvokeInProcess, result.ParameterSet); + Assert.Equal("ScriptBlock", Assert.Single(result.Bindings).CanonicalParameterName); + } + + [Fact] + public void Ambiguous_parameter_prefix_keeps_every_block_unknown() + { + var result = Bind("ForEach-Object -Pro { Get-Date }"); + + Assert.Equal(PwshExecutionRegionBindingStatus.Ambiguous, result.Status); + var binding = Assert.Single(result.Bindings); + Assert.Equal(ExecutionRegionPhase.Unknown, binding.Phase); + Assert.False(binding.IsComplete); + } + + [Fact] + public void Positional_for_each_script_block_array_gets_begin_process_end_semantics() + { + var result = Bind( + "ForEach-Object { Write-Output begin } { Write-Output process } " + + "{ Write-Output remaining } { Write-Output end }"); + + Assert.Equal(PwshExecutionRegionBindingStatus.ProvedExecution, result.Status); + Assert.Equal( + new[] + { + ExecutionRegionPhase.Begin, + ExecutionRegionPhase.Process, + ExecutionRegionPhase.Process, + ExecutionRegionPhase.End, + }, + result.Bindings.Select(binding => binding.Phase)); + Assert.Equal( + new[] + { + ExecutionRegionCardinality.Once, + ExecutionRegionCardinality.OncePerInputObject, + ExecutionRegionCardinality.OncePerInputObject, + ExecutionRegionCardinality.Once, + }, + result.Bindings.Select(binding => binding.Cardinality)); + } + + [Fact] + public void Explicit_process_script_block_array_promotes_unbound_edges() + { + var result = Bind( + "ForEach-Object -Process { Write-Output one }, { Write-Output two }, " + + "{ Write-Output three }"); + + Assert.Equal(PwshExecutionRegionBindingStatus.ProvedExecution, result.Status); + Assert.Equal( + new[] + { + ExecutionRegionPhase.Begin, + ExecutionRegionPhase.Process, + ExecutionRegionPhase.End, + }, + result.Bindings.Select(binding => binding.Phase)); + } + + [Theory] + [InlineData( + "ForEach-Object { Write-Output first } { Write-Output second }", + "Begin,Process")] + [InlineData( + "ForEach-Object -Begin { Write-Output begin } " + + "-Process { Write-Output first }, { Write-Output second }", + "Begin,Process,End")] + [InlineData( + "ForEach-Object -End { Write-Output end } " + + "-Process { Write-Output first }, { Write-Output second }", + "End,Begin,Process")] + [InlineData( + "ForEach-Object -Begin { Write-Output begin } -End { Write-Output end } " + + "-Process { Write-Output first }, { Write-Output second }", + "Begin,End,Process,Process")] + public void For_each_edge_promotion_accounts_for_explicit_begin_and_end( + string source, + string expectedPhases) + { + var result = Bind(source); + + Assert.Equal( + expectedPhases.Split(','), + result.Bindings.Select(binding => binding.Phase.ToString())); + } + + [Fact] + public void Process_and_remaining_scripts_share_one_authored_edge_promotion_sequence() + { + var result = Bind( + "ForEach-Object -RemainingScripts { Write-Output first }, " + + "{ Write-Output second } -Process { Write-Output third }"); + + Assert.Equal( + new[] + { + ExecutionRegionPhase.Begin, + ExecutionRegionPhase.Process, + ExecutionRegionPhase.End, + }, + result.Bindings.Select(binding => binding.Phase)); + } + + [Fact] + public void Named_for_each_blocks_keep_authored_order_and_semantic_phases() + { + var result = Bind( + "ForEach-Object -End { Write-Output end } " + + "-Begin { Write-Output begin } -Process { Write-Output process }"); + + Assert.Equal( + new[] + { + ExecutionRegionPhase.End, + ExecutionRegionPhase.Begin, + ExecutionRegionPhase.Process, + }, + result.Bindings.Select(binding => binding.Phase)); + Assert.True(result.Bindings.Select(binding => binding.HostClauseElementIndex) + .SequenceEqual(result.Bindings.Select(binding => binding.HostClauseElementIndex) + .OrderBy(index => index))); + } + + [Fact] + public void Parallel_parameter_selects_concurrent_parameter_set() + { + var result = Bind("ForEach-Object -Parallel { Get-Date } -AsJob"); + + Assert.Equal(PwshExecutionRegionParameterSet.ForEachParallel, result.ParameterSet); + var binding = Assert.Single(result.Bindings); + Assert.Equal(ExecutionRegionTiming.Concurrent, binding.Timing); + Assert.Equal(ExecutionRegionCardinality.OncePerInputObject, binding.Cardinality); + } + + [Fact] + public void Invoke_command_distinguishes_in_process_and_remote_parameter_sets() + { + var local = Bind("Invoke-Command -ScriptBlock { Get-Date } -NoNewScope"); + var remote = Bind( + "Invoke-Command -ComputerName server -ScriptBlock { Get-Date } -AsJob"); + var inlineRemote = Bind( + "Invoke-Command -ComputerName:server -ScriptBlock:{ Get-Date }"); + var aliasRemote = Bind("Invoke-Command -Cn server -Command { Get-Date }"); + var positionalRemote = Bind("Invoke-Command server { Get-Date }"); + + Assert.Equal(PwshExecutionRegionParameterSet.InvokeInProcess, local.ParameterSet); + Assert.True(Assert.Single(local.Bindings).IsComplete); + Assert.Equal(PwshExecutionRegionParameterSet.InvokeRemote, remote.ParameterSet); + var remoteBinding = Assert.Single(remote.Bindings); + Assert.Equal(ExecutionRegionTiming.Unknown, remoteBinding.Timing); + Assert.Equal(ExecutionRegionCardinality.Unknown, remoteBinding.Cardinality); + Assert.False(remoteBinding.IsComplete); + Assert.Equal(PwshExecutionRegionParameterSet.InvokeRemote, inlineRemote.ParameterSet); + Assert.False(Assert.Single(inlineRemote.Bindings).IsComplete); + Assert.Equal(PwshExecutionRegionParameterSet.InvokeRemote, aliasRemote.ParameterSet); + Assert.False(Assert.Single(aliasRemote.Bindings).IsComplete); + Assert.Equal(PwshExecutionRegionParameterSet.InvokeRemote, positionalRemote.ParameterSet); + Assert.False(Assert.Single(positionalRemote.Bindings).IsComplete); + } + + [Theory] + [InlineData("Invoke-Command server -ScriptBlock { Get-Date }")] + [InlineData("Invoke-Command -Command { Get-Date } server")] + public void Mixed_named_and_positional_remote_targets_cannot_be_proved_local(string source) + { + var result = Bind(source); + + Assert.Equal(PwshExecutionRegionParameterSet.InvokeRemote, result.ParameterSet); + var binding = Assert.Single(result.Bindings); + Assert.Equal(ExecutionRegionTiming.Unknown, binding.Timing); + Assert.False(binding.IsComplete); + } + + [Fact] + public void Missing_value_before_another_parameter_keeps_script_block_binding_unknown() + { + var result = Bind("ForEach-Object -Begin -Process { Get-Date }"); + + Assert.Equal(PwshExecutionRegionBindingStatus.Ambiguous, result.Status); + Assert.False(Assert.Single(result.Bindings).IsComplete); + } + + [Theory] + [InlineData("Invoke-Command -Com server -ScriptBlock { Get-Date }")] + [InlineData("Invoke-Command -Port 22 -ScriptBlock { Get-Date }")] + [InlineData("Where-Object -FilterScript { Get-Date } -Property Name")] + [InlineData("ForEach-Object -Process { Get-Date } -Process { Get-Item }")] + [InlineData("ForEach-Object -Parallel { Get-Date } -Begin { Get-Item }")] + [InlineData("Trace-Command -Expression { Get-Date } -Command Get-Date")] + public void Ambiguous_aliases_and_parameter_set_conflicts_remain_unknown(string source) + { + var result = Bind(source); + + Assert.Equal(PwshExecutionRegionBindingStatus.Ambiguous, result.Status); + Assert.All(result.Bindings, binding => Assert.False(binding.IsComplete)); + } + + [Theory] + [InlineData("Where-Object -FilterScript { Get-Date } -Value x")] + [InlineData("Trace-Command -Name * -Expression { Get-Date } -ArgumentList x")] + [InlineData("Start-Job -ScriptBlock { Get-Date } -ConnectingTimeout 1")] + [InlineData("Set-PSBreakpoint -Action { Get-Date }")] + [InlineData("Register-ObjectEvent -Action { Get-Date }")] + [InlineData("Register-EngineEvent -Action { Get-Date }")] + [InlineData( + "Register-ArgumentCompleter -NativeFallback -CommandName git " + + "-ScriptBlock { Get-Date }")] + public void Incompatible_or_incomplete_parameter_sets_remain_unknown(string source) + { + var result = Bind(source); + + Assert.Equal(PwshExecutionRegionBindingStatus.Ambiguous, result.Status); + Assert.All(result.Bindings, binding => Assert.False(binding.IsComplete)); + } + + [Theory] + [InlineData("ForEach-Object --Process { Get-Date }")] + [InlineData("ForEach-Object -Begin { Get-Date }, { Get-Item }")] + [InlineData("ForEach-Object -End { Get-Date }, { Get-Item }")] + [InlineData("Start-Job { Write-Output main }, { Write-Output init }")] + [InlineData("Start-Job -ScriptBlock { Write-Output main } not-a-scriptblock")] + [InlineData( + "Start-Job -ScriptBlock { Write-Output main } " + + "-InitializationScript not-a-scriptblock")] + [InlineData("ForEach-Object -Process { Get-Date } -Debug:notbool")] + [InlineData("ForEach-Object -Parallel { Get-Date } -TimeoutSeconds:notint")] + [InlineData( + "Trace-Command -Name ParameterBinding -Expression { Get-Date } " + + "-Option:notoption -PSHost")] + [InlineData("ForEach-Object -Parallel { Get-Date } -TimeoutSeconds notint")] + [InlineData("ForEach-Object -Process { Get-Date } -Debug:true")] + [InlineData("ForEach-Object -Process { Get-Date } -Debug:false")] + [InlineData("ForEach-Object -Process { Get-Date } -Debug:")] + [InlineData( + "Trace-Command -Name ParameterBinding -Expression { Get-Date } " + + "-Option \"\" -PSHost")] + [InlineData("Trace-Command ParameterBinding { Get-Date } \"\" -PSHost")] + [InlineData( + "Trace-Command -Name ParameterBinding -Expression { Get-Date } " + + "-Option \"Error,\" -PSHost")] + [InlineData("ForEach-Object -Parallel { Get-Date } -TimeoutSeconds:-1")] + [InlineData("ForEach-Object -Parallel { Get-Date } -ThrottleLimit:0")] + [InlineData("ForEach-Object -Parallel { Get-Date } -ThrottleLimit:-1")] + [InlineData( + "Set-PSBreakpoint -Script ./script.ps1 -Line 0 -Action { Get-Date }")] + [InlineData("Start-Job -ScriptBlock { Get-Date } -PSVersion 7.6.4")] + [InlineData("ForEach-Object -Process { Get-Date } -ErrorAction Suspend")] + [InlineData("ForEach-Object -Process { Get-Date } -ErrorVariable \"\"")] + [InlineData("Start-Job -WorkingDirectory \" \" -ScriptBlock { Get-Date }")] + [InlineData("New-Module -ScriptBlock { Get-Date } -Cmdlet $null")] + [InlineData( + "Invoke-Command -HostName server -Options @{} -ScriptBlock { Get-Date }")] + [InlineData( + "Invoke-Command -HostName example.invalid -ScriptBlock { Get-Date } " + + "-SSHTransport:$false")] + [InlineData("Start-Job -ScriptBlock { Get-Date } -Authentication Basic")] + [InlineData("Start-Job -ScriptBlock { Get-Date } -RunAs32")] + [InlineData( + "ForEach-Object -Parallel { Get-Date } -AsJob -TimeoutSeconds 1")] + [InlineData( + "Register-EngineEvent -SourceIdentifier source -Action { Get-Date } -Forward")] + [InlineData( + "Register-ObjectEvent -InputObject $source -EventName Changed " + + "-Action { Get-Date } -Forward")] + public void Unsupported_parameter_spelling_and_scalar_arrays_remain_unknown(string source) + { + var result = Bind(source); + + Assert.Equal(PwshExecutionRegionBindingStatus.Ambiguous, result.Status); + Assert.All(result.Bindings, binding => Assert.False(binding.IsComplete)); + } + + [Theory] + [InlineData("ForEach-Object -Process { Get-Date } -Debug:$false")] + [InlineData("ForEach-Object -Parallel { Get-Date } -TimeoutSeconds:5")] + [InlineData( + "Trace-Command -Name ParameterBinding -Expression { Get-Date } " + + "-Option:ExecutionFlow -PSHost")] + [InlineData("Trace-Command ParameterBinding { Get-Date } ExecutionFlow -PSHost")] + [InlineData( + "ForEach-Object -Parallel { Get-Date } -TimeoutSeconds:0 -ThrottleLimit:1")] + [InlineData( + "Set-PSBreakpoint -Script ./script.ps1 -Line 1 -Action { Get-Date }")] + [InlineData("Start-Job -ScriptBlock { Get-Date } -PSVersion 5.1")] + [InlineData("Start-Job -ScriptBlock { Get-Date } -Authentication Default")] + public void Proved_literal_value_conversions_preserve_execution_binding(string source) + { + var result = Bind(source); + + Assert.Equal(PwshExecutionRegionBindingStatus.ProvedExecution, result.Status); + Assert.All(result.Bindings, binding => Assert.True(binding.IsComplete)); + } + + [Fact] + public void Ssh_transport_true_value_preserves_remote_incomplete_binding() + { + var result = Bind( + "Invoke-Command -HostName example.invalid -ScriptBlock { Get-Date } " + + "-SSHTransport:$true"); + + Assert.Equal(PwshExecutionRegionBindingStatus.ProvedExecution, result.Status); + Assert.Equal(PwshExecutionRegionParameterSet.InvokeRemote, result.ParameterSet); + Assert.False(Assert.Single(result.Bindings).IsComplete); + } + + [Fact] + public void As_job_without_remote_target_does_not_invent_local_job_semantics() + { + var result = Bind("Invoke-Command -AsJob -ScriptBlock { Get-Date }"); + + Assert.Equal(PwshExecutionRegionBindingStatus.Ambiguous, result.Status); + Assert.False(Assert.Single(result.Bindings).IsComplete); + } + + [Fact] + public void Start_job_binds_main_and_initialization_positions_independently() + { + var result = Bind("Start-Job { Write-Output main } { Write-Output init }"); + + Assert.Equal( + new[] { ExecutionRegionPhase.Main, ExecutionRegionPhase.Initialization }, + result.Bindings.Select(binding => binding.Phase)); + Assert.All(result.Bindings, binding => + Assert.Equal(ExecutionRegionTiming.Concurrent, binding.Timing)); + } + + [Fact] + public void Named_primary_parameters_shift_positional_script_block_slots() + { + var trace = Bind("Trace-Command -Name * { Get-Date }"); + var job = Bind("Start-Job -ScriptBlock { Get-Date } { Write-Output init }"); + var fileJob = Bind("Start-Job -FilePath script.ps1 { Write-Output init }"); + var engineEvent = Bind("Register-EngineEvent -SourceIdentifier source { Get-Date }"); + var objectEvent = Bind( + "Register-ObjectEvent -InputObject $source -EventName Changed " + + "-SourceIdentifier subscription { Get-Date }"); + + Assert.Equal(ExecutionRegionPhase.Main, Assert.Single(trace.Bindings).Phase); + Assert.Equal( + new[] { ExecutionRegionPhase.Main, ExecutionRegionPhase.Initialization }, + job.Bindings.Select(binding => binding.Phase)); + Assert.Equal( + ExecutionRegionPhase.Initialization, + Assert.Single(fileJob.Bindings).Phase); + Assert.Equal(ExecutionRegionPhase.Action, Assert.Single(engineEvent.Bindings).Phase); + Assert.Equal(ExecutionRegionPhase.Action, Assert.Single(objectEvent.Bindings).Phase); + } + + [Fact] + public void New_module_name_parameter_does_not_displace_its_script_block() + { + var result = Bind("New-Module -Name demo { Get-Date }"); + + Assert.Equal(ExecutionRegionPhase.Initialization, Assert.Single(result.Bindings).Phase); + } + + [Fact] + public void Canonical_write_output_script_block_is_proved_opaque_data() + { + var result = Bind("echo { Remove-Item victim.txt }"); + + Assert.Equal(PwshExecutionRegionBindingStatus.ProvedData, result.Status); + Assert.Equal(PwshExecutionRegionReceiver.WriteOutput, result.Receiver); + Assert.Empty(result.Bindings); + } + + [Fact] + public void Unknown_receiver_keeps_body_visible_with_unknown_binding_facts() + { + var result = Bind("Invoke-CustomAction { Remove-Item victim.txt }"); + + Assert.Equal(PwshExecutionRegionBindingStatus.Ambiguous, result.Status); + Assert.Equal(PwshExecutionRegionReceiver.Unknown, result.Receiver); + var binding = Assert.Single(result.Bindings); + Assert.Equal(ExecutionRegionTiming.Unknown, binding.Timing); + Assert.False(binding.IsComplete); + } + + [Fact] + public void Unproved_command_identity_cannot_claim_receiver_semantics_or_data() + { + var executingClause = ParseClause("ForEach-Object { Remove-Item victim.txt }"); + var dataClause = ParseClause("Write-Output { Remove-Item victim.txt }"); + + var executing = PwshExecutionRegionBindingCatalog.Bind( + executingClause, + commandIdentityProven: false); + var data = PwshExecutionRegionBindingCatalog.Bind( + dataClause, + commandIdentityProven: false); + + Assert.Equal(PwshExecutionRegionBindingStatus.Ambiguous, executing.Status); + Assert.Equal(PwshExecutionRegionBindingStatus.Ambiguous, data.Status); + Assert.False(Assert.Single(executing.Bindings).IsComplete); + Assert.False(Assert.Single(data.Bindings).IsComplete); + } + + [Fact] + public void Thread_job_requires_the_explicit_pinned_module_baseline() + { + var clause = ParseClause("Start-ThreadJob { Get-Date }"); + + var unpinned = PwshExecutionRegionBindingCatalog.Bind( + clause, + commandIdentityProven: true, + threadJobModuleProven: false); + var pinned = PwshExecutionRegionBindingCatalog.Bind( + clause, + commandIdentityProven: true, + threadJobModuleProven: true); + + Assert.Equal(PwshExecutionRegionBindingStatus.Ambiguous, unpinned.Status); + Assert.False(Assert.Single(unpinned.Bindings).IsComplete); + Assert.Equal(PwshExecutionRegionBindingStatus.ProvedExecution, pinned.Status); + Assert.Equal(PwshExecutionRegionReceiver.StartThreadJob, pinned.Receiver); + var binding = Assert.Single(pinned.Bindings); + Assert.Equal(ExecutionRegionTiming.Concurrent, binding.Timing); + Assert.Equal(ExecutionRegionCardinality.Once, binding.Cardinality); + } + + [Fact] + public void Pinned_thread_job_still_requires_a_main_script_or_file() + { + var clause = ParseClause( + "Start-ThreadJob -InitializationScript { Write-Output init }"); + + var result = PwshExecutionRegionBindingCatalog.Bind( + clause, + commandIdentityProven: true, + threadJobModuleProven: true); + + Assert.Equal(PwshExecutionRegionBindingStatus.Ambiguous, result.Status); + Assert.False(Assert.Single(result.Bindings).IsComplete); + } + + [Theory] + [InlineData("Start-ThreadJob -Name \"\" -ScriptBlock { Get-Date }")] + [InlineData( + "Start-ThreadJob -FilePath \"\" " + + "-InitializationScript { Write-Output init }")] + public void Pinned_thread_job_rejects_invalid_required_string_values(string source) + { + var result = PwshExecutionRegionBindingCatalog.Bind( + ParseClause(source), + commandIdentityProven: true, + threadJobModuleProven: true); + + Assert.Equal(PwshExecutionRegionBindingStatus.Ambiguous, result.Status); + Assert.All(result.Bindings, binding => Assert.False(binding.IsComplete)); + } + + [Fact] + public void Wrong_module_qualification_remains_atomically_unparseable() + { + var parsed = Parser.Parse("Contoso.Tools\\ForEach-Object { Get-Date }"); + + Assert.True(parsed.IsUnparseable); + Assert.Empty(parsed.Commands); + Assert.Empty(parsed.Clauses); + Assert.Contains("module-qualified cmdlet", parsed.UnparseableReason); + } + + private static PwshExecutionRegionBindingResult Bind(string source) + { + var clause = ParseClause(source); + var result = PwshExecutionRegionBindingCatalog.Bind( + clause, + commandIdentityProven: true); + Assert.All(result.Bindings, binding => + { + Assert.InRange(binding.HostClauseElementIndex, 0, clause.Elements.Count - 1); + var host = clause.Elements[binding.HostClauseElementIndex]; + Assert.Equal(ClauseElementRole.Argument, host.Role); + Assert.Equal(ArgKind.DynamicSkip, host.Kind); + }); + return result; + } + + private static Clause ParseClause(string source) + { + var parsed = Parser.Parse(source); + Assert.False(parsed.IsUnparseable, parsed.UnparseableReason); + return parsed.Clauses.Last(clause => clause.Elements.Any(element => + element.Kind == ArgKind.DynamicSkip && element.Raw.Contains('{'))); + } +}