completers/wecutil_completer/wecutil_completer.ps1
|
<# .SYNOPSIS Registers a native PowerShell argument completer for wecutil. .DESCRIPTION Provides a help-driven native completer for `wecutil` and `wecutil.exe`. The completer covers: - top-level command aliases and long names - slash-style `/option:value` completion parsed from command help - dynamic local subscription-name completion when enumeration succeeds - XML/path completion for config-file slots - enum and placeholder value completion for selected high-value option slots The script keeps its top level compatible with `Import-CompleterScript`. #> Set-StrictMode -Version 2.0 function New-WecutilCompletionResult { param( [string]$CompletionText, [string]$ResultType, [string]$ToolTip, [string]$ListItemText ) if ([string]::IsNullOrWhiteSpace($ListItemText)) { $ListItemText = $CompletionText } if ([string]::IsNullOrWhiteSpace($ToolTip)) { $ToolTip = $ListItemText } [System.Management.Automation.CompletionResult]::new( $CompletionText, $ListItemText, $ResultType, $ToolTip ) } function Remove-WecutilOuterQuotes { param([string]$Value) if ([string]::IsNullOrEmpty($Value)) { return '' } # PowerShell reads the typographic quotes as quotes too. $Value.Trim([char[]]@([char]34, [char]39, [char]0x2018, [char]0x2019, [char]0x201A, [char]0x201B, [char]0x201C, [char]0x201D, [char]0x201E)) } function Get-WecutilTypedQuote { # The quote style the user opened the word with: "'" or '"', or '' when bare. param([string]$Value) if ($Value -match '^[''\u2018-\u201B]') { return "'" } if ($Value -match '^["\u201C-\u201E]') { return '"' } '' } function ConvertFrom-WecutilTypedWord { # The value of a typed word without its quotes and that quote style's escapes. param([string]$Value) $quote = Get-WecutilTypedQuote -Value $Value $clean = Remove-WecutilOuterQuotes -Value $Value if ($quote -eq "'") { # A doubled single quote stands for its second character. return $clean -replace '[''\u2018-\u201B]([''\u2018-\u201B])', '$1' } if ($quote) { return $clean -replace '`(.)', '$1' } $clean } function ConvertTo-WecutilQuotedValue { # Renders a value as one PowerShell argument: bare when safe and no quote was typed, # otherwise in the typed quote style (single by default). Whitespace and argument-mode # metacharacters (including the typographic quotes) end or split a bare word, and a # leading '@' or '#' would start a splat or a comment, and a leading dash a parameter. param( [string]$Value, [string]$Quote ) if ([string]::IsNullOrEmpty($Value)) { return $Value } if (-not $Quote) { if ($Value -notmatch '[\s{}();,|&<>''"`$@#\u2018-\u201E]' -and $Value -notmatch '^[-\u2013-\u2015]') { return $Value } $Quote = "'" } if ($Quote -eq "'") { return "'" + ($Value -replace '([''\u2018-\u201B])', '$1$1') + "'" } '"' + ($Value -replace '([`"$\u201C-\u201E])', '`$1') + '"' } function Get-WecutilTokenState { param( [string]$Line, [int]$CursorPosition ) if ($null -eq $Line) { $Line = '' } $safeCursor = [Math]::Min([Math]::Max($CursorPosition, 0), $Line.Length) $prefix = $Line.Substring(0, $safeCursor) $tokens = New-Object System.Collections.Generic.List[string] $builder = New-Object System.Text.StringBuilder $quoteChar = [char]0 foreach ($character in $prefix.ToCharArray()) { # Any quote of a class (straight or typographic) closes a quote of the same class. $quoteClass = Get-WecutilTypedQuote -Value ([string]$character) if ($quoteClass) { if ($quoteChar -eq [char]0) { $quoteChar = [char]$quoteClass } elseif ($quoteChar -eq [char]$quoteClass) { $quoteChar = [char]0 } [void]$builder.Append($character) continue } if ([char]::IsWhiteSpace($character) -and $quoteChar -eq [char]0) { if ($builder.Length -gt 0) { $tokens.Add($builder.ToString()) [void]$builder.Clear() } continue } [void]$builder.Append($character) } # Whitespace inside an open quote is part of the word, not a separator. $hasTrailingSpace = $quoteChar -eq [char]0 -and $prefix -match '\s$' if ($builder.Length -gt 0) { $tokens.Add($builder.ToString()) } if ($hasTrailingSpace) { return [pscustomobject]@{ TokensBeforeCurrent = @($tokens); CurrentToken = '' } } if ($tokens.Count -gt 0) { return [pscustomobject]@{ TokensBeforeCurrent = @($tokens | Select-Object -First ($tokens.Count - 1)); CurrentToken = $tokens[$tokens.Count - 1] } } [pscustomobject]@{ TokensBeforeCurrent = @(); CurrentToken = '' } } function Get-WecutilArgumentsFromTokenState { param([pscustomobject]$TokenState) $tokensBeforeCurrent = @($TokenState.TokensBeforeCurrent) $currentArgument = if ($null -eq $TokenState.CurrentToken) { '' } else { $TokenState.CurrentToken } if ($tokensBeforeCurrent.Count -gt 0) { $argumentsBeforeCurrent = @($tokensBeforeCurrent | Select-Object -Skip 1) } else { $argumentsBeforeCurrent = @() } if ($tokensBeforeCurrent.Count -eq 0 -and $currentArgument -match '^(?i)wecutil(?:\.exe)?$') { $currentArgument = '' } [pscustomobject]@{ ArgumentsBeforeCurrent = $argumentsBeforeCurrent CurrentArgument = $currentArgument } } function Get-WecutilCatalog { if (Get-Variable -Name WecutilCompletionCatalog -Scope Script -ErrorAction Ignore) { return $script:WecutilCompletionCatalog } $commands = @( [pscustomobject]@{ Canonical='es'; Long='enum-subscription'; Description='List existing subscriptions.'; Positional='None' } [pscustomobject]@{ Canonical='gs'; Long='get-subscription'; Description='Get subscription configuration.'; Positional='SubscriptionId' } [pscustomobject]@{ Canonical='gr'; Long='get-subscriptionruntimestatus'; Description='Get subscription runtime status.'; Positional='SubscriptionThenEventSource' } [pscustomobject]@{ Canonical='ss'; Long='set-subscription'; Description='Set subscription configuration.'; Positional='SubscriptionIdOrConfig' } [pscustomobject]@{ Canonical='cs'; Long='create-subscription'; Description='Create a subscription from an XML config file.'; Positional='ConfigFile' } [pscustomobject]@{ Canonical='ds'; Long='delete-subscription'; Description='Delete a subscription.'; Positional='SubscriptionId' } [pscustomobject]@{ Canonical='rs'; Long='retry-subscription'; Description='Retry one or more event sources for a subscription.'; Positional='SubscriptionThenEventSource' } [pscustomobject]@{ Canonical='qc'; Long='quick-config'; Description='Configure the Windows Event Collector service.'; Positional='None' } ) $aliasLookup = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) $commandSuggestions = New-Object System.Collections.Generic.List[object] foreach ($command in $commands) { $aliasLookup[$command.Canonical] = $command $aliasLookup[$command.Long] = $command [void]$commandSuggestions.Add([pscustomobject]@{ CompletionText = $command.Canonical; ToolTip = $command.Description }) [void]$commandSuggestions.Add([pscustomobject]@{ CompletionText = $command.Long; ToolTip = $command.Description }) } $script:WecutilCompletionCatalog = [pscustomobject]@{ Commands = $commands CommandLookup = $aliasLookup CommandSuggestions = @($commandSuggestions.ToArray()) HelpTokens = @('/?', '-?', '-h', '-help') FallbackOptions = @{ 'es' = @() 'gs' = @('/f:', '/format:', '/u:', '/unicode:') 'gr' = @('/PurgeInactiveES:') 'ss' = @('/c:', '/config:', '/e', '/e:', '/enabled', '/enabled:', '/d:', '/description:', '/ex:', '/expires:', '/uri:', '/cm:', '/configurationmode:', '/q:', '/query:', '/dia:', '/dialect:', '/cf:', '/contentformat:', '/l:', '/locale:', '/ree', '/ree:', '/readexistingevents', '/readexistingevents:', '/lf:', '/logfile:', '/pn:', '/publishername:', '/dm:', '/deliverymode:', '/dmi:', '/deliverymaxitems:', '/dmlt:', '/deliverymaxlatencytime:', '/hi:', '/heartbeatinterval:', '/tn:', '/transportname:', '/esa:', '/eventsourceaddress:', '/ese', '/ese:', '/eventsourceenabled', '/eventsourceenabled:', '/un:', '/username:', '/up:', '/userpassword:', '/cun:', '/commonusername:', '/cup:', '/commonuserpassword:') 'cs' = @('/cun:', '/commonusername:', '/cup:', '/commonuserpassword:') 'ds' = @() 'rs' = @() 'qc' = @('/q', '/q:', '/quiet', '/quiet:') } EnumValues = @{ '/f:' = @('XML', 'Terse') '/format:' = @('XML', 'Terse') '/u:' = @('true', 'false') '/unicode:' = @('true', 'false') '/e:' = @('true', 'false') '/enabled:' = @('true', 'false') '/cm:' = @('Normal', 'Custom', 'MinLatency', 'MinBandwidth') '/configurationmode:' = @('Normal', 'Custom', 'MinLatency', 'MinBandwidth') '/cf:' = @('Events', 'RenderedText') '/contentformat:' = @('Events', 'RenderedText') '/ree:' = @('true', 'false') '/readexistingevents:' = @('true', 'false') '/dm:' = @('pull', 'push') '/deliverymode:' = @('pull', 'push') '/tn:' = @('http', 'https') '/transportname:' = @('http', 'https') '/ese:' = @('true', 'false') '/eventsourceenabled:' = @('true', 'false') '/quiet:' = @('true', 'false') '/ct:' = @('default', 'negotiate', 'digest', 'basic', 'localmachine') '/credentialstype:' = @('default', 'negotiate', 'digest', 'basic', 'localmachine') '/tp:' = @('80', '443', '5985', '5986') '/transportport:' = @('80', '443', '5985', '5986') # /q: means Quiet for quick-config and Query for set-subscription, # so the quick-config meaning is scoped to that command. 'qc:/q:' = @('true', 'false') } PathOptions = @{ '/c:' = '.xml' '/config:' = '.xml' } PlaceholderOptions = @{ '/d:' = '<description>' '/description:' = '<description>' '/ex:' = '<iso8601-date>' '/expires:' = '<iso8601-date>' '/uri:' = '<uri>' '/q:' = '<query>' '/query:' = '<query>' '/dia:' = '<dialect>' '/dialect:' = '<dialect>' '/l:' = '<locale>' '/locale:' = '<locale>' '/lf:' = '<log-file>' '/logfile:' = '<log-file>' '/pn:' = '<publisher-name>' '/publishername:' = '<publisher-name>' '/dmi:' = '<number>' '/deliverymaxitems:' = '<number>' '/dmlt:' = '<milliseconds>' '/deliverymaxlatencytime:' = '<milliseconds>' '/hi:' = '<milliseconds>' '/heartbeatinterval:' = '<milliseconds>' '/esa:' = '<event-source>' '/eventsourceaddress:' = '<event-source>' '/un:' = '<username>' '/username:' = '<username>' '/up:' = '<password>' '/userpassword:' = '<password>' '/cun:' = '<username>' '/commonusername:' = '<username>' '/cup:' = '<password>' '/commonuserpassword:' = '<password>' '/purgeinactivees:' = '<days>' '/hn:' = '<fqdn>' '/hostname:' = '<fqdn>' '/ica:' = '<thumbprint[,thumbprint]>' '/allowedissuerca:' = '<thumbprint[,thumbprint]>' '/as:' = '<dns-name[,dns-name]>' '/allowedsubjects:' = '<dns-name[,dns-name]>' '/ds:' = '<dns-name[,dns-name]>' '/deniedsubjects:' = '<dns-name[,dns-name]>' '/adc:' = '<sddl>' '/allowedsourcedomaincomputers:' = '<sddl>' } } $script:WecutilCompletionCatalog } function Invoke-WecutilCommandHelp { param([string]$CommandName) if (-not (Get-Command -Name wecutil.exe -ErrorAction Ignore)) { return @() } try { @(& wecutil.exe $CommandName '-?' 2>&1) } catch { @() } } function ConvertFrom-WecutilOptionSpec { param( [string]$Spec, [string]$LongName, [string]$Description ) $spec = $Spec.Trim() if ([string]::IsNullOrWhiteSpace($spec)) { return @() } $tokens = New-Object System.Collections.Generic.List[object] foreach ($part in @($spec -split '\|')) { $cleanPart = $part.Trim() if ([string]::IsNullOrWhiteSpace($cleanPart)) { continue } if ($cleanPart -eq '?') { continue } $completionTokens = New-Object System.Collections.Generic.List[string] if ($cleanPart -match '^(?<name>[^\[]+)\[:') { $root = '/' + $matches['name'] $completionTokens.Add($root) $completionTokens.Add($root + ':') } elseif ($cleanPart.Contains(':')) { $root = '/' + $cleanPart.Split(':', 2)[0] $completionTokens.Add($root + ':') } else { $completionTokens.Add('/' + $cleanPart) } if (-not [string]::IsNullOrWhiteSpace($LongName)) { $longRoot = '/' + $LongName.Trim() if ($cleanPart -match '\[:' -or $cleanPart.Contains(':')) { if ($cleanPart -match '\[:') { $completionTokens.Add($longRoot) } $completionTokens.Add($longRoot + ':') } else { $completionTokens.Add($longRoot) } } foreach ($token in @($completionTokens | Select-Object -Unique)) { $tokens.Add([pscustomobject]@{ Token = $token Description = $Description }) } } @($tokens.ToArray()) } function Get-WecutilParsedOptionsForCommand { param([string]$CanonicalCommand) $cacheName = 'WecutilParsedOptionCache' if (-not (Get-Variable -Name $cacheName -Scope Script -ErrorAction Ignore)) { $script:WecutilParsedOptionCache = @{} } if ($script:WecutilParsedOptionCache.ContainsKey($CanonicalCommand)) { return $script:WecutilParsedOptionCache[$CanonicalCommand] } $catalog = Get-WecutilCatalog $options = New-Object System.Collections.Generic.List[object] $helpLines = Invoke-WecutilCommandHelp -CommandName $CanonicalCommand $currentDescription = $null foreach ($line in @($helpLines)) { $text = [string]$line $match = [regex]::Match($text, '^\s*/(?<spec>[^\s]+)\s*(?:\((?<long>[^)]+)\))?\s*(?<rest>.*)$') # A real option header is a bare '/spec (LongName)' line and nothing # else; wecutil's prose wraps onto lines that also start with a slash # ('/hi (HeartbeatInterval) or /dmlt ... may only be specified'), and # treating those as headers manufactures bare options the tool rejects. if ($match.Success -and [string]::IsNullOrWhiteSpace($match.Groups['rest'].Value)) { $spec = $match.Groups['spec'].Value $longName = $match.Groups['long'].Value $description = $spec foreach ($parsed in @(ConvertFrom-WecutilOptionSpec -Spec $spec -LongName $longName -Description $description)) { [void]$options.Add($parsed) } $currentDescription = $description continue } if ($currentDescription -and $text -match '^\s{2,}(?<continuation>\S.*)$') { continue } } if ($options.Count -eq 0) { foreach ($fallbackToken in @($catalog.FallbackOptions[$CanonicalCommand])) { [void]$options.Add([pscustomobject]@{ Token = $fallbackToken; Description = $fallbackToken }) } } $unique = @($options | Group-Object Token | ForEach-Object { $_.Group[0] }) $script:WecutilParsedOptionCache[$CanonicalCommand] = $unique @($unique) } function Get-WecutilCommandOptionLookup { param([string]$CanonicalCommand) $lookup = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($option in @(Get-WecutilParsedOptionsForCommand -CanonicalCommand $CanonicalCommand)) { $lookup[$option.Token] = $option } $lookup } function Get-WecutilSubscriptionNames { if (Get-Variable -Name WecutilSubscriptionCache -Scope Script -ErrorAction Ignore) { $cache = $script:WecutilSubscriptionCache if (((Get-Date) - $cache.UpdatedAt).TotalSeconds -lt 15) { return $cache.Values } } $values = @() if (Get-Command -Name wecutil.exe -ErrorAction Ignore) { try { $lines = @(& wecutil.exe es 2>$null) $values = @( $lines | ForEach-Object { ([string]$_).Replace([string][char]65279, '').Replace([string][char]0, '').Trim() } | Where-Object { $_ -and $_ -match '[A-Za-z0-9]' -and $_ -notmatch '^Failed to open' -and $_ -notmatch '^The RPC server is unavailable' } ) | Sort-Object -Unique } catch { $values = @() } } $script:WecutilSubscriptionCache = [pscustomobject]@{ UpdatedAt = Get-Date; Values = $values } @($values) } function Get-WecutilCommandCompletions { param([string]$CurrentWord) foreach ($suggestion in (Get-WecutilCatalog).CommandSuggestions) { if (-not [string]::IsNullOrWhiteSpace($CurrentWord) -and -not $suggestion.CompletionText.StartsWith($CurrentWord, [System.StringComparison]::OrdinalIgnoreCase)) { continue } New-WecutilCompletionResult -CompletionText $suggestion.CompletionText -ResultType 'ParameterValue' -ToolTip $suggestion.ToolTip -ListItemText $suggestion.CompletionText } } function Get-WecutilHelpCompletions { param([string]$CurrentWord) foreach ($token in (Get-WecutilCatalog).HelpTokens) { if (-not [string]::IsNullOrWhiteSpace($CurrentWord) -and -not $token.StartsWith($CurrentWord, [System.StringComparison]::OrdinalIgnoreCase)) { continue } New-WecutilCompletionResult -CompletionText $token -ResultType 'ParameterName' -ToolTip 'Show wecutil help.' -ListItemText $token } } function Get-WecutilOptionCompletions { param( [string]$CanonicalCommand, [string]$CurrentWord ) foreach ($option in @(Get-WecutilParsedOptionsForCommand -CanonicalCommand $CanonicalCommand)) { if (-not [string]::IsNullOrWhiteSpace($CurrentWord) -and -not $option.Token.StartsWith($CurrentWord, [System.StringComparison]::OrdinalIgnoreCase)) { continue } New-WecutilCompletionResult -CompletionText $option.Token -ResultType 'ParameterName' -ToolTip $option.Description -ListItemText $option.Token } } function Get-WecutilEnumCompletions { param( [string]$CurrentWord, [string[]]$Values, [string]$Prefix, [string]$ToolTip ) $typed = Remove-WecutilOuterQuotes -Value $CurrentWord $results = New-Object System.Collections.Generic.List[object] foreach ($value in @($Values)) { if (-not [string]::IsNullOrWhiteSpace($typed) -and -not $value.StartsWith($typed, [System.StringComparison]::OrdinalIgnoreCase)) { continue } [void]$results.Add((New-WecutilCompletionResult -CompletionText ($Prefix + $value) -ResultType 'ParameterValue' -ToolTip $ToolTip -ListItemText ($Prefix + $value))) } if ($results.Count -eq 0) { $fallback = if ([string]::IsNullOrWhiteSpace($CurrentWord)) { $Prefix + '<value>' } else { $Prefix + $CurrentWord } [void]$results.Add((New-WecutilCompletionResult -CompletionText $fallback -ResultType 'ParameterValue' -ToolTip $ToolTip -ListItemText $fallback)) } @($results.ToArray()) } function Get-WecutilPathCompletions { param( [string]$CurrentWord, [string]$Prefix, [string]$ToolTip, [string]$Placeholder, [ValidateSet('File','Directory','Any')] [string]$Kind = 'File', [string[]]$PreferExtension = @() ) $typedValue = if ($null -eq $CurrentWord) { '' } else { $CurrentWord } $cleanValue = ConvertFrom-WecutilTypedWord -Value $typedValue $quote = Get-WecutilTypedQuote -Value $typedValue $results = New-Object System.Collections.Generic.List[object] # The typed folder part (through the last separator, or a drive's colon) is kept # verbatim, so a typed '.\' or './' and its separator style survive. $separatorIndex = $cleanValue.LastIndexOfAny([char[]]@('\', '/', ':')) $typedFolder = $cleanValue.Substring(0, $separatorIndex + 1) $leaf = $cleanValue.Substring($separatorIndex + 1) $parentPath = if ($typedFolder) { $typedFolder } else { '.' } # A missing or protected folder is an expected miss, not an error record. $items = @(Get-ChildItem -LiteralPath $parentPath -ErrorAction Ignore) # Directories come first so the tree stays navigable, then the preferred # file kind, then anything else. $containers = New-Object System.Collections.Generic.List[object] $preferred = New-Object System.Collections.Generic.List[object] $others = New-Object System.Collections.Generic.List[object] foreach ($item in $items) { if ($Kind -eq 'Directory' -and -not $item.PSIsContainer) { continue } if ($Kind -eq 'File' -and $item.PSIsContainer) { continue } if (-not [string]::IsNullOrWhiteSpace($leaf) -and -not $item.Name.StartsWith($leaf, [System.StringComparison]::OrdinalIgnoreCase)) { continue } $candidate = $typedFolder + $item.Name if (-not $typedFolder -and $candidate -match '^[-\u2013-\u2015]') { # A bare leading dash would be read as a parameter; anchor it like PowerShell does. $candidate = '.' + [System.IO.Path]::DirectorySeparatorChar + $candidate } if ($item.PSIsContainer -and -not ($candidate.EndsWith('\') -or $candidate.EndsWith('/'))) { $candidate += '\' } $completionText = ConvertTo-WecutilQuotedValue -Value $candidate -Quote $quote $result = New-WecutilCompletionResult -CompletionText ($Prefix + $completionText) -ResultType 'ParameterValue' -ToolTip $item.FullName -ListItemText ($Prefix + $completionText) if ($item.PSIsContainer) { [void]$containers.Add($result) } elseif ($PreferExtension.Count -eq 0 -or $PreferExtension -contains $item.Extension) { [void]$preferred.Add($result) } else { [void]$others.Add($result) } } foreach ($result in @($containers.ToArray()) + @($preferred.ToArray()) + @($others.ToArray())) { [void]$results.Add($result) } if ($results.Count -eq 0) { $fallback = if ([string]::IsNullOrWhiteSpace($typedValue)) { $Prefix + $Placeholder } else { $Prefix + $typedValue } [void]$results.Add((New-WecutilCompletionResult -CompletionText $fallback -ResultType 'ParameterValue' -ToolTip $ToolTip -ListItemText $fallback)) } @($results.ToArray()) } function Get-WecutilPlaceholderCompletions { param( [string]$CurrentWord, [string]$Prefix, [string]$Placeholder, [string]$ToolTip ) if ([string]::IsNullOrWhiteSpace($CurrentWord)) { return @( New-WecutilCompletionResult -CompletionText ($Prefix + $Placeholder) -ResultType 'ParameterValue' -ToolTip $ToolTip -ListItemText ($Prefix + $Placeholder) ) } @( New-WecutilCompletionResult -CompletionText ($Prefix + $CurrentWord) -ResultType 'ParameterValue' -ToolTip $ToolTip -ListItemText ($Prefix + $CurrentWord) ) } function Get-WecutilInlineOptionInfo { param( [string]$Token, [string]$CanonicalCommand ) if ([string]::IsNullOrWhiteSpace($Token) -or -not $Token.StartsWith('/')) { return $null } $match = [regex]::Match($Token, '^(?<name>/[^:]+:)(?<value>.*)$') if (-not $match.Success) { return $null } $lookup = Get-WecutilCommandOptionLookup -CanonicalCommand $CanonicalCommand $name = $match.Groups['name'].Value if (-not $lookup.ContainsKey($name)) { return $null } [pscustomobject]@{ Prefix = $name Value = $match.Groups['value'].Value Option = $lookup[$name] } } function Get-WecutilSubscriptionCompletions { param([string]$CurrentWord) $results = New-Object System.Collections.Generic.List[object] $typed = ConvertFrom-WecutilTypedWord -Value $CurrentWord $quote = Get-WecutilTypedQuote -Value $CurrentWord foreach ($name in @(Get-WecutilSubscriptionNames)) { if (-not [string]::IsNullOrWhiteSpace($typed) -and -not $name.StartsWith($typed, [System.StringComparison]::OrdinalIgnoreCase)) { continue } # Subscription IDs come from a user-authored XML tag and often contain # spaces, so they have to be quoted to stay one operand. $completionText = ConvertTo-WecutilQuotedValue -Value $name -Quote $quote [void]$results.Add((New-WecutilCompletionResult -CompletionText $completionText -ResultType 'ParameterValue' -ToolTip 'Subscription ID.' -ListItemText $name)) } if ($results.Count -eq 0) { $fallback = if ([string]::IsNullOrWhiteSpace($CurrentWord)) { '<subscription-id>' } else { $CurrentWord } [void]$results.Add((New-WecutilCompletionResult -CompletionText $fallback -ResultType 'ParameterValue' -ToolTip 'Subscription ID.' -ListItemText $fallback)) } @($results.ToArray()) } function Get-WecutilTerminalCompletions { param([string]$CurrentWord) $completionText = if ([string]::IsNullOrEmpty($CurrentWord)) { ' ' } else { $CurrentWord } @( New-WecutilCompletionResult -CompletionText $completionText -ResultType 'ParameterValue' -ToolTip 'No further arguments are valid after help.' -ListItemText $completionText ) } function Complete-Wecutil { param( [string]$wordToComplete, [System.Management.Automation.Language.CommandAst]$commandAst, [int]$cursorPosition ) $catalog = Get-WecutilCatalog # The cursor is an offset into the whole input, the AST text starts at the # command name, so rebase the cursor onto the AST text. $tokenState = Get-WecutilTokenState -Line $commandAst.ToString() -CursorPosition ($cursorPosition - $commandAst.Extent.StartOffset) $argumentState = Get-WecutilArgumentsFromTokenState -TokenState $tokenState $hasTrailingSpace = [string]::IsNullOrEmpty($wordToComplete) # PowerShell replaces the whole word under the cursor, including the text # after the cursor. $wordSuffix = '' if (-not $hasTrailingSpace) { foreach ($element in $commandAst.CommandElements) { if ($element.Extent.StartOffset -lt $cursorPosition -and $element.Extent.EndOffset -gt $cursorPosition) { $wordSuffix = $element.Extent.Text.Substring($cursorPosition - $element.Extent.StartOffset) } } } if ($hasTrailingSpace -and -not [string]::IsNullOrEmpty($argumentState.CurrentArgument)) { $currentWord = '' $argumentsBeforeCurrent = @($argumentState.ArgumentsBeforeCurrent + $argumentState.CurrentArgument) } else { $currentWord = if ($null -eq $argumentState.CurrentArgument) { '' } else { $argumentState.CurrentArgument } $argumentsBeforeCurrent = @($argumentState.ArgumentsBeforeCurrent) } # Every candidate replaces the whole word, so match and echo the whole word, # not just the text before the cursor. $currentWord += $wordSuffix $activeCommand = $null $commandIndex = -1 for ($i = 0; $i -lt $argumentsBeforeCurrent.Count; $i++) { $token = $argumentsBeforeCurrent[$i] if ($catalog.CommandLookup.ContainsKey($token)) { $activeCommand = $catalog.CommandLookup[$token] $commandIndex = $i break } } $helpRequested = @(($argumentsBeforeCurrent + $currentWord) | Where-Object { $_ -in $catalog.HelpTokens }).Count -gt 0 if ($helpRequested) { return @(Get-WecutilTerminalCompletions -CurrentWord $currentWord) } if ($null -eq $activeCommand) { $results = New-Object System.Collections.Generic.List[object] if ([string]::IsNullOrWhiteSpace($currentWord) -or -not ($currentWord.StartsWith('-') -or $currentWord.StartsWith('/'))) { foreach ($commandCompletion in @(Get-WecutilCommandCompletions -CurrentWord $currentWord)) { [void]$results.Add($commandCompletion) } } foreach ($helpCompletion in @(Get-WecutilHelpCompletions -CurrentWord $currentWord)) { [void]$results.Add($helpCompletion) } @($results.ToArray()) | Group-Object CompletionText | ForEach-Object { $_.Group[0] } return } $tokensAfterCommand = @( if ($commandIndex -ge 0 -and $commandIndex -lt ($argumentsBeforeCurrent.Count - 1)) { $argumentsBeforeCurrent[($commandIndex + 1)..($argumentsBeforeCurrent.Count - 1)] } ) if (-not [string]::IsNullOrWhiteSpace($currentWord) -and $currentWord.StartsWith('/')) { $inline = Get-WecutilInlineOptionInfo -Token $currentWord -CanonicalCommand $activeCommand.Canonical if ($null -ne $inline) { # The same option token can mean different things under different # commands (/q: is Quiet for qc and Query for ss), so a # '<command>:<option>' key wins over the shared one. $optionKey = $inline.Prefix.ToLowerInvariant() foreach ($key in @(($activeCommand.Canonical.ToLowerInvariant() + ':' + $optionKey), $optionKey)) { if ($catalog.EnumValues.ContainsKey($key)) { return @(Get-WecutilEnumCompletions -CurrentWord $inline.Value -Values $catalog.EnumValues[$key] -Prefix $inline.Prefix -ToolTip $inline.Option.Description) } if ($catalog.PathOptions.ContainsKey($key)) { return @(Get-WecutilPathCompletions -CurrentWord $inline.Value -Prefix $inline.Prefix -ToolTip $inline.Option.Description -Placeholder $catalog.PathOptions[$key] -Kind 'Any' -PreferExtension @('.xml')) } if ($catalog.PlaceholderOptions.ContainsKey($key)) { return @(Get-WecutilPlaceholderCompletions -CurrentWord $inline.Value -Prefix $inline.Prefix -Placeholder $catalog.PlaceholderOptions[$key] -ToolTip $inline.Option.Description) } } } return @(Get-WecutilOptionCompletions -CanonicalCommand $activeCommand.Canonical -CurrentWord $currentWord) } $positionals = @($tokensAfterCommand | Where-Object { -not ($_ -in $catalog.HelpTokens) -and -not $_.StartsWith('/') -and -not $_.StartsWith('-') }) switch ($activeCommand.Positional) { 'SubscriptionId' { if ($positionals.Count -eq 0) { return @(Get-WecutilSubscriptionCompletions -CurrentWord $currentWord) } } 'SubscriptionThenEventSource' { if ($positionals.Count -eq 0) { return @(Get-WecutilSubscriptionCompletions -CurrentWord $currentWord) } return @(Get-WecutilPlaceholderCompletions -CurrentWord $currentWord -Prefix '' -Placeholder '<event-source>' -ToolTip 'Event source computer name or IP address.') } 'SubscriptionIdOrConfig' { if ($positionals.Count -eq 0) { $results = New-Object System.Collections.Generic.List[object] foreach ($subscription in @(Get-WecutilSubscriptionCompletions -CurrentWord $currentWord)) { [void]$results.Add($subscription) } foreach ($pathResult in @(Get-WecutilPathCompletions -CurrentWord $currentWord -Prefix '' -ToolTip 'Subscription XML config file.' -Placeholder '<config.xml>' -Kind 'Any' -PreferExtension @('.xml'))) { [void]$results.Add($pathResult) } return @($results.ToArray()) | Group-Object CompletionText | ForEach-Object { $_.Group[0] } } } 'ConfigFile' { if ($positionals.Count -eq 0) { return @(Get-WecutilPathCompletions -CurrentWord $currentWord -Prefix '' -ToolTip 'Subscription XML config file.' -Placeholder '<config.xml>' -Kind 'Any' -PreferExtension @('.xml')) } } } if ([string]::IsNullOrWhiteSpace($currentWord)) { return @(Get-WecutilOptionCompletions -CanonicalCommand $activeCommand.Canonical -CurrentWord $currentWord) } @() } Register-ArgumentCompleter -Native -CommandName @('wecutil', 'wecutil.exe') -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) Complete-Wecutil -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition } |