completers/netsh_completer/netsh_completer.ps1
|
# netsh tab completion for PowerShell # Builds a lazy hierarchical catalog from netsh built-in help. Set-StrictMode -Version 2.0 if (-not (Get-Variable -Name NetshCompletionCatalog -Scope Script -ErrorAction Ignore)) { $script:NetshCompletionCatalog = @{ Initialized = $false NodesByKey = @{} LoadedKeys = @{} ContextPathsByKey = @{} GlobalOptions = @() GlobalOptionMap = @{} DynamicValues = @{} } } function New-NetshCompletionResult { param( [string]$CompletionText, [string]$ResultType, [string]$ToolTip ) if ([string]::IsNullOrWhiteSpace($ToolTip)) { $ToolTip = $CompletionText } [System.Management.Automation.CompletionResult]::new( $CompletionText, $CompletionText, $ResultType, $ToolTip ) } function Test-NetshCommandAvailable { [bool](Get-Command -Name netsh.exe -ErrorAction Ignore) } function Get-NetshPathKey { param([string[]]$PathTokens) $PathTokens = @($PathTokens) if (-not $PathTokens -or $PathTokens.Count -eq 0) { return '__ROOT__' } (($PathTokens | ForEach-Object { $_.ToLowerInvariant() }) -join [string][char]31) } function Get-NetshPathText { param([string[]]$PathTokens) (@($PathTokens) -join ' ').Trim() } function Get-NetshNode { param( [string[]]$PathTokens, [switch]$Create ) $key = Get-NetshPathKey -PathTokens $PathTokens if (-not $script:NetshCompletionCatalog.NodesByKey.ContainsKey($key)) { if (-not $Create) { return $null } $script:NetshCompletionCatalog.NodesByKey[$key] = @{ PathTokens = @($PathTokens) NextTokens = [ordered]@{} UsageSuggestions = [ordered]@{} ValueHintsByTag = @{} } } $script:NetshCompletionCatalog.NodesByKey[$key] } function Add-NetshSuggestion { param( [string[]]$PathTokens, [ValidateSet('NextTokens', 'UsageSuggestions')] [string]$CollectionName, [string]$CompletionText, [string]$ToolTip, [string]$ResultType = 'ParameterValue' ) if ([string]::IsNullOrWhiteSpace($CompletionText)) { return } $node = Get-NetshNode -PathTokens $PathTokens -Create $collection = $node[$CollectionName] $key = $CompletionText.ToLowerInvariant() if (-not $collection.Contains($key)) { $collection[$key] = [pscustomobject]@{ CompletionText = $CompletionText ToolTip = $ToolTip ResultType = $ResultType } } } function Add-NetshValueHints { param( [string[]]$PathTokens, [string]$Tag, [string[]]$Values ) if ([string]::IsNullOrWhiteSpace($Tag) -or -not $Values -or $Values.Count -eq 0) { return } $node = Get-NetshNode -PathTokens $PathTokens -Create $tagKey = $Tag.ToLowerInvariant() if (-not $node.ValueHintsByTag.ContainsKey($tagKey)) { $node.ValueHintsByTag[$tagKey] = @() } $node.ValueHintsByTag[$tagKey] = @( $node.ValueHintsByTag[$tagKey] + $Values | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique ) } function Add-NetshContextPath { param([string[]]$PathTokens) if (-not $PathTokens -or $PathTokens.Count -eq 0) { return } $key = Get-NetshPathKey -PathTokens $PathTokens if (-not $script:NetshCompletionCatalog.ContextPathsByKey.ContainsKey($key)) { $script:NetshCompletionCatalog.ContextPathsByKey[$key] = @($PathTokens) } } function Invoke-NetshHelpText { param([string[]]$PathTokens) Invoke-NetshProcess -Arguments @(@($PathTokens) + '/?') } function Invoke-NetshProcess { param([string[]]$Arguments) if (-not (Test-NetshCommandAvailable)) { return @() } # netsh help and listings never need stdin, but some contexts are slow or may prompt, # so the spawn keeps stdin closed, reads asynchronously and is bounded by a timeout. try { $startInfo = [System.Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = 'netsh.exe' foreach ($argument in @($Arguments)) { $startInfo.ArgumentList.Add($argument) } $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true $startInfo.RedirectStandardInput = $true $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true $startInfo.StandardOutputEncoding = [Console]::OutputEncoding $startInfo.StandardErrorEncoding = [Console]::OutputEncoding $process = [System.Diagnostics.Process]::Start($startInfo) try { $process.StandardInput.Close() $outputTask = $process.StandardOutput.ReadToEndAsync() $errorTask = $process.StandardError.ReadToEndAsync() if (-not $process.WaitForExit(5000)) { $process.Kill() return @() } [void]$errorTask.Result @(($outputTask.Result -replace '\e\[[0-9;?]*[ -/]*[@-~]', '') -split '\r?\n') } finally { $process.Dispose() } } catch { Write-Debug "netsh failed for '$(@($Arguments) -join ' ')': $($_.Exception.Message)" @() } } function ConvertTo-NetshLogicalLines { param([string[]]$Lines) if (-not $Lines -or $Lines.Count -eq 0) { return @() } $text = ($Lines -join [Environment]::NewLine) foreach ($marker in @( 'Commands in this context:', 'The following sub-contexts are available:', 'Usage:', 'Parameters:', 'Remarks:', 'Examples:', 'To view help for a command,' )) { $pattern = '(?<![\r\n])' + [regex]::Escape($marker) $text = [regex]::Replace($text, $pattern, [Environment]::NewLine + $marker) } @( $text -split '\r?\n' | ForEach-Object { $_.TrimEnd() } ) } function Get-NetshHelpSections { param([string[]]$HelpLines) $sections = @{ CommandEntries = @() Subcontexts = @() UsageLines = @() ParameterLines = @() } $logicalLines = ConvertTo-NetshLogicalLines -Lines $HelpLines $section = '' foreach ($line in $logicalLines) { if ($line -match '^\s*Commands in this context:\s*$') { $section = 'commands' continue } if ($line -match '^\s*The following sub-contexts are available:\s*$') { $section = 'subcontexts' continue } if ($line -match '^\s*Usage:\s*(.*)$') { $section = 'usage' $sections.UsageLines += ('Usage: ' + $matches[1].TrimEnd()) continue } if ($line -match '^\s*Parameters:\s*$') { $section = 'parameters' continue } # A header may carry its prose on the same physical line ('Remarks: Used to ...'). if ($line -match '^\s*(Remarks|Examples)\s*:' -or $line -match '^\s*To view help for a command,') { $section = '' continue } switch ($section) { 'commands' { # The description column may be empty ('postreset - '). if ($line -match '^\s*(\S.*?)\s+-\s*(.*?)\s*$') { $sections.CommandEntries += [pscustomobject]@{ Phrase = $matches[1].Trim() Description = $matches[2].Trim() } } } 'subcontexts' { if (-not [string]::IsNullOrWhiteSpace($line)) { $sections.Subcontexts += @( $line.Trim() -split '\s+' | Where-Object { $_ } ) } } 'usage' { if (-not [string]::IsNullOrWhiteSpace($line)) { $sections.UsageLines += $line.TrimEnd() } } 'parameters' { if (-not [string]::IsNullOrWhiteSpace($line)) { $sections.ParameterLines += $line.TrimEnd() } } } } $sections.Subcontexts = @($sections.Subcontexts | Sort-Object -Unique) $sections } function Test-NetshContextDescription { param([string]$Description) if ([string]::IsNullOrWhiteSpace($Description)) { return $false } $Description -match "Changes to the [`'‘’]?netsh .+[`'‘’]? context\." } function Add-NetshCommandPhrase { param( [string[]]$BasePathTokens, [string[]]$PhraseTokens, [string]$Description ) $BasePathTokens = @($BasePathTokens) $PhraseTokens = @($PhraseTokens) if (-not $PhraseTokens -or $PhraseTokens.Count -eq 0) { return } for ($index = 0; $index -lt $PhraseTokens.Count; $index++) { $parentPath = @($BasePathTokens) if ($index -gt 0) { $parentPath += $PhraseTokens[0..($index - 1)] } Add-NetshSuggestion -PathTokens $parentPath -CollectionName NextTokens -CompletionText $PhraseTokens[$index] -ToolTip $Description } } function Get-NetshRelativePhraseTokens { param( [string[]]$PathTokens, [string[]]$PhraseTokens ) $PathTokens = @($PathTokens) $PhraseTokens = @($PhraseTokens) if ($PathTokens.Count -eq 0 -or $PhraseTokens.Count -eq 0) { return @($PhraseTokens) } $maxOverlap = [Math]::Min($PathTokens.Count, $PhraseTokens.Count - 1) for ($length = $maxOverlap; $length -ge 1; $length--) { $pathSlice = @($PathTokens[($PathTokens.Count - $length)..($PathTokens.Count - 1)]) $phraseSlice = @($PhraseTokens[0..($length - 1)]) if ((($pathSlice | ForEach-Object { $_.ToLowerInvariant() }) -join [char]31) -eq (($phraseSlice | ForEach-Object { $_.ToLowerInvariant() }) -join [char]31)) { return @($PhraseTokens[$length..($PhraseTokens.Count - 1)]) } } @($PhraseTokens) } function Get-NetshUsageTags { param( [string[]]$UsageLines, [string[]]$ParameterLines ) $tags = New-Object System.Collections.Generic.List[string] foreach ($line in @($UsageLines + $ParameterLines)) { foreach ($match in [regex]::Matches($line, '([A-Za-z][A-Za-z0-9-]*)=')) { $tags.Add(($match.Groups[1].Value + '=')) } } @($tags | Sort-Object -Unique) } function Get-NetshUsageLiteralValues { param( [string[]]$UsageLines, [string[]]$PathTokens ) $UsageLines = @($UsageLines) $PathTokens = @($PathTokens) if (-not $UsageLines -or $UsageLines.Count -eq 0) { return @() } $excluded = @{} foreach ($token in $PathTokens) { $excluded[$token.ToLowerInvariant()] = $true } foreach ($token in @('usage', 'parameters', 'remarks', 'examples')) { $excluded[$token] = $true } # Only bare alternations (not attached to a 'tag=') are loose operands; tagged # ones belong to Get-NetshUsageTagValueMap. The '[,...]' repetition marker is # removed on its own so the enum it follows survives. $normalizedText = ($UsageLines -join ' ') $normalizedText = $normalizedText -replace '\[\s*,\s*\.\.\.\s*\]', ' ' $normalizedText = [regex]::Replace($normalizedText, '<[^>]+>', ' ') $normalizedText = $normalizedText -replace '\s*\|\s*', '|' $normalizedText = $normalizedText -replace '\[?[A-Za-z][A-Za-z0-9-]*=\]?[^\s\[\]]+', ' ' $values = New-Object System.Collections.Generic.List[string] foreach ($match in [regex]::Matches($normalizedText, '(?<![A-Za-z0-9-])([A-Za-z][A-Za-z0-9-]*(?:\|[A-Za-z][A-Za-z0-9-]*)+)(?![A-Za-z0-9-])')) { foreach ($value in ($match.Groups[1].Value -split '\|')) { if (-not [string]::IsNullOrWhiteSpace($value)) { $candidate = $value.Trim() $lookup = $candidate.ToLowerInvariant() if (-not $excluded.ContainsKey($lookup)) { $values.Add($candidate) } } } } @($values | Sort-Object -Unique) } function Get-NetshUsageTagValueMap { param([string[]]$UsageLines) # Attaches usage-block enums to their tag: 'dir=in|out', '[profile=public|private|domain|any[,...]]', # '[[capture=]yes|no]'. Placeholders (<IPv4 address>), numeric ranges (0-65535) and # templates (icmpv4:type,code) are not literal values and are dropped. $UsageLines = @($UsageLines) if (-not $UsageLines -or $UsageLines.Count -eq 0) { return @{} } $text = ($UsageLines -join ' ') $text = $text -replace '\[\s*,\s*\.\.\.\s*\]', ' ' $text = [regex]::Replace($text, '<[^>]+>', '__placeholder__') $text = $text -replace '\s*\|\s*', '|' $valuesByTag = @{} foreach ($match in [regex]::Matches($text, '(?<![A-Za-z0-9-])(?<Tag>[A-Za-z][A-Za-z0-9-]*)=\]?(?<Alt>[^\s\[\]|]+(?:\|[^\s\[\]|]+)+)')) { $tag = $match.Groups['Tag'].Value if ($tag -eq 'default') { continue } $members = @( $match.Groups['Alt'].Value -split '\|' | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '^[A-Za-z][A-Za-z0-9-]*$' } ) if ($members.Count -eq 0) { continue } $tagKey = $tag.ToLowerInvariant() if (-not $valuesByTag.ContainsKey($tagKey)) { $valuesByTag[$tagKey] = @() } $valuesByTag[$tagKey] = @($valuesByTag[$tagKey] + $members | Sort-Object -Unique) } $valuesByTag } function Get-NetshParameterValueHints { param([string[]]$ParameterLines) $valueHints = @{} $currentTag = $null foreach ($line in $ParameterLines) { if ($line -match '^\s*([A-Za-z][A-Za-z0-9-]*)\s+-\s+One of the following values:\s*$') { $currentTag = $matches[1].ToLowerInvariant() if (-not $valueHints.ContainsKey($currentTag)) { $valueHints[$currentTag] = @() } continue } if ($line -match '^\s*([A-Za-z][A-Za-z0-9-]*)\s+-') { $currentTag = $null continue } if (-not $currentTag) { continue } if ($line -match '^\s*([A-Za-z][A-Za-z0-9-]*)\s*:' -and $matches[1] -notin @('Remarks', 'Examples', 'Usage', 'Parameters')) { $valueHints[$currentTag] += $matches[1] } } foreach ($key in @($valueHints.Keys)) { $valueHints[$key] = @($valueHints[$key] | Sort-Object -Unique) } $valueHints } function Update-NetshCatalogFromHelp { param( [string[]]$PathTokens, [string[]]$HelpLines ) $node = Get-NetshNode -PathTokens $PathTokens -Create if (-not $HelpLines -or $HelpLines.Count -eq 0) { return } $sections = Get-NetshHelpSections -HelpLines $HelpLines $subcontextLookup = @{} foreach ($subcontext in $sections.Subcontexts) { $subcontextLookup[$subcontext.ToLowerInvariant()] = $true $contextPath = @($PathTokens + $subcontext) Add-NetshSuggestion -PathTokens $PathTokens -CollectionName NextTokens -CompletionText $subcontext -ToolTip ("Changes to the 'netsh {0}' context." -f (Get-NetshPathText -PathTokens $contextPath)) Add-NetshContextPath -PathTokens $contextPath } foreach ($entry in $sections.CommandEntries) { $phraseTokens = @( Get-NetshRelativePhraseTokens -PathTokens $PathTokens -PhraseTokens @( $entry.Phrase -split '\s+' | Where-Object { $_ } ) ) if ($phraseTokens.Count -eq 0) { continue } Add-NetshCommandPhrase -BasePathTokens $PathTokens -PhraseTokens $phraseTokens -Description $entry.Description $isContext = $false if ($subcontextLookup.ContainsKey($phraseTokens[-1].ToLowerInvariant())) { $isContext = $true } elseif (Test-NetshContextDescription -Description $entry.Description) { $isContext = $true } if ($isContext) { Add-NetshContextPath -PathTokens @($PathTokens + $phraseTokens) } } $tags = Get-NetshUsageTags -UsageLines $sections.UsageLines -ParameterLines $sections.ParameterLines foreach ($tag in $tags) { Add-NetshSuggestion -PathTokens $PathTokens -CollectionName UsageSuggestions -CompletionText $tag -ToolTip ("Parameter tag for netsh {0}." -f (Get-NetshPathText -PathTokens $PathTokens)) } $literals = Get-NetshUsageLiteralValues -UsageLines $sections.UsageLines -PathTokens $PathTokens foreach ($literal in $literals) { Add-NetshSuggestion -PathTokens $PathTokens -CollectionName UsageSuggestions -CompletionText $literal -ToolTip ("Literal value accepted by netsh {0}." -f (Get-NetshPathText -PathTokens $PathTokens)) } $valueHints = Get-NetshParameterValueHints -ParameterLines $sections.ParameterLines foreach ($tagKey in $valueHints.Keys) { Add-NetshValueHints -PathTokens $PathTokens -Tag $tagKey -Values $valueHints[$tagKey] } $usageValues = Get-NetshUsageTagValueMap -UsageLines $sections.UsageLines foreach ($tagKey in $usageValues.Keys) { Add-NetshValueHints -PathTokens $PathTokens -Tag $tagKey -Values $usageValues[$tagKey] } } function Ensure-NetshPathLoaded { param([string[]]$PathTokens) $key = Get-NetshPathKey -PathTokens $PathTokens if ($script:NetshCompletionCatalog.LoadedKeys.ContainsKey($key)) { return } $helpLines = Invoke-NetshHelpText -PathTokens $PathTokens Update-NetshCatalogFromHelp -PathTokens $PathTokens -HelpLines $helpLines $script:NetshCompletionCatalog.LoadedKeys[$key] = $true } function Initialize-NetshCompletionCatalog { if ($script:NetshCompletionCatalog.Initialized) { return } $script:NetshCompletionCatalog.GlobalOptions = @( [pscustomobject]@{ Token = '-a'; ExpectsValue = $true; ValueKind = 'Path'; ToolTip = 'Specifies an alias file to use.' }, [pscustomobject]@{ Token = '-c'; ExpectsValue = $true; ValueKind = 'Context'; ToolTip = 'Changes to the specified netsh context.' }, [pscustomobject]@{ Token = '-r'; ExpectsValue = $true; ValueKind = 'Text'; ToolTip = 'Runs the command on a remote machine.'; Placeholder = '<RemoteMachine>' }, [pscustomobject]@{ Token = '-u'; ExpectsValue = $true; ValueKind = 'Text'; ToolTip = 'Specifies the user name for a remote connection.'; Placeholder = '<DomainName\UserName>' }, [pscustomobject]@{ Token = '-p'; ExpectsValue = $true; ValueKind = 'Password'; ToolTip = 'Specifies the password for a remote connection, or * to prompt.' }, [pscustomobject]@{ Token = '-f'; ExpectsValue = $true; ValueKind = 'Path'; ToolTip = 'Runs commands from a script file.' }, [pscustomobject]@{ Token = '/?'; ExpectsValue = $false; ValueKind = 'None'; ToolTip = 'Displays netsh help.' }, [pscustomobject]@{ Token = '-?'; ExpectsValue = $false; ValueKind = 'None'; ToolTip = 'Displays netsh help.' } ) $optionMap = @{} foreach ($option in $script:NetshCompletionCatalog.GlobalOptions) { $optionMap[$option.Token.ToLowerInvariant()] = $option } $script:NetshCompletionCatalog.GlobalOptionMap = $optionMap Ensure-NetshPathLoaded -PathTokens @() $script:NetshCompletionCatalog.Initialized = $true } function ConvertTo-NetshQuotedValue { param( [string]$Value, [bool]$AlwaysQuote = $false ) if ([string]::IsNullOrWhiteSpace($Value)) { return $Value } if (($AlwaysQuote -or $Value -match '\s') -and -not ($Value.StartsWith('"') -and $Value.EndsWith('"'))) { return '"' + $Value + '"' } $Value } function ConvertTo-NetshQuotedPath { param([string]$Path) ConvertTo-NetshQuotedValue -Value $Path } function Get-NetshCurrentToken { param( [string]$Line, [int]$CursorPosition, [string]$Fallback ) if ([string]::IsNullOrWhiteSpace($Line)) { return $Fallback } $safeCursor = [Math]::Min([Math]::Max($CursorPosition, 0), $Line.Length) $prefix = $Line.Substring(0, $safeCursor) if ($prefix -match '\s$') { return '' } $parts = @([regex]::Matches($prefix, '"[^"]*"|"[^"]*$|\S+') | ForEach-Object { $_.Value }) if ($parts.Count -gt 0) { return $parts[-1] } $Fallback } function Get-NetshTokensBeforeCurrent { param( [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) # Extent offsets are absolute like $CursorPosition. Only elements that end before # the cursor are already typed; the element under the cursor and anything after it # are not, which handles mid-token, end-of-token and trailing-space uniformly. # Callers wrap the result in @() because an empty array unrolls to nothing. @( $CommandAst.CommandElements | Select-Object -Skip 1 | Where-Object { $_.Extent.EndOffset -lt $CursorPosition } | ForEach-Object { $_.Extent.Text } ) } function ConvertTo-NetshContextTokens { param([string]$Value) if ([string]::IsNullOrWhiteSpace($Value)) { return @() } @( $Value.Trim('"') -split '\s+' | Where-Object { $_ } ) } function Get-NetshExpectedGlobalOption { param([string[]]$TokensBeforeCurrent) if (-not $TokensBeforeCurrent -or $TokensBeforeCurrent.Count -eq 0) { return $null } $lastToken = $TokensBeforeCurrent[-1].ToLowerInvariant() if ($script:NetshCompletionCatalog.GlobalOptionMap.ContainsKey($lastToken)) { $option = $script:NetshCompletionCatalog.GlobalOptionMap[$lastToken] if ($option.ExpectsValue) { return $option } } $null } function Get-NetshParsedState { param([string[]]$Tokens) $Tokens = @($Tokens | Where-Object { -not [string]::IsNullOrEmpty($_) }) $contextTokens = New-Object System.Collections.Generic.List[string] $commandTokens = New-Object System.Collections.Generic.List[string] for ($index = 0; $index -lt $Tokens.Count; $index++) { $token = $Tokens[$index] $lookup = $token.ToLowerInvariant() if (-not $script:NetshCompletionCatalog.GlobalOptionMap.ContainsKey($lookup)) { $commandTokens.Add($token) continue } $option = $script:NetshCompletionCatalog.GlobalOptionMap[$lookup] if (-not $option.ExpectsValue) { $commandTokens.Add($token) continue } if ($index -ge ($Tokens.Count - 1)) { continue } $valueToken = $Tokens[$index + 1] if ($lookup -eq '-c') { foreach ($contextToken in (ConvertTo-NetshContextTokens -Value $valueToken)) { $contextTokens.Add($contextToken) } } $index++ } [pscustomobject]@{ ContextTokens = @($contextTokens.ToArray()) CommandTokens = @($commandTokens.ToArray()) } } function Resolve-NetshCommandPath { param( [string[]]$BasePathTokens, [string[]]$Tokens ) $BasePathTokens = @($BasePathTokens) $Tokens = @($Tokens) $path = @($BasePathTokens) $consumedCount = 0 while ($consumedCount -lt $Tokens.Count) { Ensure-NetshPathLoaded -PathTokens $path $node = Get-NetshNode -PathTokens $path if (-not $node -or $node.NextTokens.Count -eq 0) { break } $lookup = $Tokens[$consumedCount].ToLowerInvariant() if (-not $node.NextTokens.Contains($lookup)) { break } $path += $node.NextTokens[$lookup].CompletionText $consumedCount++ } $remaining = @() if ($consumedCount -lt $Tokens.Count) { $remaining = @($Tokens[$consumedCount..($Tokens.Count - 1)]) } [pscustomobject]@{ PathTokens = @($path) ConsumedCount = $consumedCount Remaining = @($remaining) } } function Get-NetshFilePathCompletions { param([string]$InputPath) $cleanInput = if ([string]::IsNullOrWhiteSpace($InputPath)) { '' } else { $InputPath.Trim('"') } $parentPath = '.' $leaf = '' if (-not [string]::IsNullOrWhiteSpace($cleanInput)) { $candidateParent = Split-Path -Path $cleanInput -Parent if (-not [string]::IsNullOrWhiteSpace($candidateParent)) { $parentPath = $candidateParent } $leaf = Split-Path -Path $cleanInput -Leaf } $filter = if ([string]::IsNullOrWhiteSpace($leaf)) { '*' } else { "$leaf*" } @( Get-ChildItem -Path $parentPath -Filter $filter -ErrorAction SilentlyContinue | ForEach-Object { ConvertTo-NetshQuotedPath -Path $_.FullName } ) } function Get-NetshContextValueCompletions { param([string]$WordToComplete) $alwaysQuote = $false $prefix = $WordToComplete if ($prefix.StartsWith('"')) { $alwaysQuote = $true } $prefix = $prefix.Trim('"') $results = New-Object System.Collections.Generic.List[object] foreach ($contextPath in $script:NetshCompletionCatalog.ContextPathsByKey.Values) { $contextPath = @($contextPath) $pathText = Get-NetshPathText -PathTokens $contextPath if ([string]::IsNullOrWhiteSpace($prefix) -or $pathText -like ([System.Management.Automation.WildcardPattern]::Escape($prefix) + '*')) { $completionText = ConvertTo-NetshQuotedValue -Value $pathText -AlwaysQuote:$alwaysQuote if ($contextPath.Count -gt 1) { $completionText = ConvertTo-NetshQuotedValue -Value $pathText -AlwaysQuote:$true } $results.Add([pscustomobject]@{ CompletionText = $completionText ToolTip = "Changes to the 'netsh $pathText' context." ResultType = 'ParameterValue' }) } } @( $results | Sort-Object -Property CompletionText -Unique ) } function Get-NetshGlobalOptionSuggestions { param([string]$WordToComplete) $results = foreach ($option in $script:NetshCompletionCatalog.GlobalOptions) { if ([string]::IsNullOrWhiteSpace($WordToComplete) -or $option.Token -like ([System.Management.Automation.WildcardPattern]::Escape($WordToComplete) + '*')) { [pscustomobject]@{ CompletionText = $option.Token ToolTip = $option.ToolTip ResultType = 'ParameterName' } } } @($results | Sort-Object -Property CompletionText -Unique) } function Get-NetshCollectionSuggestions { param( [System.Collections.IDictionary]$Collection, [string]$WordToComplete ) if (-not $Collection) { return @() } $results = New-Object System.Collections.Generic.List[object] foreach ($entry in $Collection.Values) { if ([string]::IsNullOrWhiteSpace($WordToComplete) -or $entry.CompletionText -like ([System.Management.Automation.WildcardPattern]::Escape($WordToComplete) + '*')) { $results.Add($entry) } } @($results.ToArray()) } function Get-NetshInlineTagValueSuggestions { param( [hashtable]$ValueHintsByTag, [string]$WordToComplete ) if (-not $ValueHintsByTag -or [string]::IsNullOrWhiteSpace($WordToComplete)) { return @() } if ($WordToComplete -notmatch '^(?<Tag>[A-Za-z][A-Za-z0-9-]*)=(?<Value>.*)$') { return @() } $tagKey = $matches.Tag.ToLowerInvariant() $valuePrefix = $matches.Value if (-not $ValueHintsByTag.ContainsKey($tagKey)) { return @() } $results = foreach ($value in $ValueHintsByTag[$tagKey]) { if ([string]::IsNullOrWhiteSpace($valuePrefix) -or $value -like ([System.Management.Automation.WildcardPattern]::Escape($valuePrefix) + '*')) { [pscustomobject]@{ CompletionText = "$($matches.Tag)=$value" ToolTip = "Accepted value for $($matches.Tag)= in netsh." ResultType = 'ParameterValue' } } } @($results | Sort-Object -Property CompletionText -Unique) } function Get-NetshCursorTagValue { param( [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) # The word comes from the parser, so an open quote ('name="Ethernet 2') stays one # word. An unquoted comma list parses as an array and PowerShell replaces only the # segment after the last comma (SegmentOnly). $element = $CommandAst.CommandElements | Select-Object -Skip 1 | Where-Object { $_.Extent.StartOffset -lt $CursorPosition -and $_.Extent.EndOffset -ge $CursorPosition } | Select-Object -First 1 if (-not $element) { return $null } $text = $element.Extent.Text.Substring(0, $CursorPosition - $element.Extent.StartOffset) if ($text -notmatch '^(?<Tag>[A-Za-z][A-Za-z0-9-]*)=(?<Raw>(?<Quote>[''"]?)(?<Value>.*))$') { return $null } $quote = $matches.Quote $value = $matches.Value if ($quote -and $value.EndsWith($quote)) { $value = $value.Substring(0, $value.Length - 1) } if ($quote -eq "'") { $value = $value.Replace("''", "'") } elseif ($quote -eq '"') { $value = $value -replace '`(.)', '$1' } $cut = $value.LastIndexOf(',') + 1 [pscustomobject]@{ Tag = $matches.Tag RawValue = $matches.Raw Quote = $quote Value = $value ListPrefix = $value.Substring(0, $cut) Segment = $value.Substring($cut) SegmentOnly = (-not $quote) -and $cut -gt 0 } } function ConvertTo-NetshArgumentText { param( [string]$Value, [string]$Quote ) # Keep the user's quote; otherwise single-quote anything an argument-mode # metacharacter or whitespace would split ('vEthernet (Default Switch)'). if ($Quote -eq '"') { return '"' + ($Value -replace '([`"$])', '`$1') + '"' } if ($Quote -eq "'" -or $Value -match '[\s{}();,|&<>''"`$]' -or $Value -match '^[@#]') { return "'" + $Value.Replace("'", "''") + "'" } $Value } function Get-NetshTagValueSource { param( [string[]]$CommandWords, [string]$Tag ) $words = @($CommandWords | ForEach-Object { $_.ToLowerInvariant() }) if ($words.Count -eq 0) { return $null } $tagKey = $Tag.ToLowerInvariant() switch ($words[0]) { 'interface' { # 6to4 'set relay' and isatap 'set router' name= is a host, and # 'add v6v4tunnel' interface= names a tunnel that does not exist yet. $notInterface = @($words | Where-Object { $_ -in @('relay', 'router', 'v6v4tunnel') }) if ($tagKey -in @('name', 'interface') -and $notInterface.Count -eq 0) { return 'Interface' } } 'dnsclient' { if ($tagKey -eq 'name') { return 'Interface' } } 'wlan' { if ($tagKey -eq 'name') { return 'WlanProfile' } } 'trace' { if ($tagKey -eq 'scenario' -or ($tagKey -eq 'name' -and $words -contains 'scenario')) { return 'TraceScenario' } if ($tagKey -eq 'tracefile') { return 'Path' } } 'advfirewall' { # Firewall rule names stay out: Get-NetFirewallRule takes seconds. if ($words.Count -gt 1 -and $words[1] -eq 'firewall') { if ($tagKey -eq 'service') { return 'Service' } if ($tagKey -eq 'program') { return 'Path' } } } } $null } function Get-NetshInterfaceName { # Fixed-width table with the name last; names contain spaces, so the name column # starts where the header's last label does (the header sits above the dashes). $lines = @(Invoke-NetshProcess -Arguments @('interface', 'show', 'interface')) $offset = -1 for ($index = 1; $index -lt $lines.Count; $index++) { $line = $lines[$index] if ($offset -lt 0) { if ($line -match '^-{10,}\s*$') { $header = $lines[$index - 1].TrimEnd() if ([string]::IsNullOrWhiteSpace($header)) { return } $offset = $header.LastIndexOf(@($header.Trim() -split '\s{2,}')[-1]) } continue } if ($line.Length -gt $offset) { $name = $line.Substring($offset).Trim() if ($name) { $name } } } } function Get-NetshWlanProfileName { foreach ($line in @(Invoke-NetshProcess -Arguments @('wlan', 'show', 'profiles'))) { # ' All User Profile : <name>'; section headers and '<None>' have no ' : '. if ($line -match '^\s+[^:]+?\s:\s(?<Name>.*\S)\s*$') { $matches.Name } } } function Get-NetshTraceScenarioName { # Passive registry read; 'netsh trace show scenarios' lists exactly these keys. $key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SYSTEM\CurrentControlSet\Control\NetTrace\Scenarios') if (-not $key) { return } try { $key.GetSubKeyNames() } finally { $key.Dispose() } } function Get-NetshServiceName { foreach ($service in [System.ServiceProcess.ServiceController]::GetServices()) { $service.ServiceName $service.Dispose() } } function Get-NetshDynamicValue { param([string]$Source) # Machine state can change mid-session, so entries live for 60 s; an empty # answer (no netsh, no WLAN service) is cached the same way. $cache = $script:NetshCompletionCatalog.DynamicValues $now = [DateTime]::UtcNow if ($cache.ContainsKey($Source) -and $cache[$Source].ExpiresUtc -gt $now) { return $cache[$Source].Names } $names = switch ($Source) { 'Interface' { Get-NetshInterfaceName } 'WlanProfile' { Get-NetshWlanProfileName } 'TraceScenario' { Get-NetshTraceScenarioName } 'Service' { Get-NetshServiceName } } $names = @($names | Where-Object { $_ } | Sort-Object -Unique) $cache[$Source] = [pscustomobject]@{ Names = $names ExpiresUtc = $now.AddSeconds(60) } $names } function Get-NetshTagValueSuggestion { param( [string]$Source, [psobject]$TagValue ) $results = if ($Source -eq 'Path') { # Paths start once something is typed: an empty value would list the whole # current directory, so 'program=' stays the bare tag (a lone quote is echoed). if (-not $TagValue.Value) { [pscustomobject]@{ CompletionText = $TagValue.Tag + '=' + $TagValue.RawValue ToolTip = "File path for $($TagValue.Tag)=." ResultType = 'ParameterName' } } elseif (-not $TagValue.SegmentOnly) { foreach ($item in [System.Management.Automation.CompletionCompleters]::CompleteFilename($TagValue.RawValue)) { [pscustomobject]@{ CompletionText = $TagValue.Tag + '=' + $item.CompletionText ToolTip = $item.ToolTip ResultType = [string]$item.ResultType } } } } else { # Only scenario= takes a comma list. Elsewhere an unquoted comma is part of the # value, but PowerShell replaces only the text after it, so a match on the full # value is offered as its remainder when that needs no quoting. $isList = $Source -eq 'TraceScenario' $listPrefix = if ($isList) { $TagValue.ListPrefix } else { '' } $partial = if ($isList) { $TagValue.Segment } else { $TagValue.Value } $chosen = @($listPrefix -split ',' | Where-Object { $_ }) $pattern = [System.Management.Automation.WildcardPattern]::Escape($partial) + '*' $sourceLabel = switch ($Source) { 'WlanProfile' { 'WLAN profile' } 'TraceScenario' { 'Trace scenario' } default { $Source } } foreach ($name in @(Get-NetshDynamicValue -Source $Source)) { if ($name -notlike $pattern -or $chosen -contains $name) { continue } $completionText = if (-not $TagValue.SegmentOnly) { $TagValue.Tag + '=' + (ConvertTo-NetshArgumentText -Value ($listPrefix + $name) -Quote $TagValue.Quote) } elseif ($isList) { ConvertTo-NetshArgumentText -Value $name -Quote '' } else { $rest = $name.Substring($TagValue.ListPrefix.Length) if ($rest -and (ConvertTo-NetshArgumentText -Value $rest -Quote '') -ceq $rest) { $rest } } if ($completionText) { [pscustomobject]@{ CompletionText = $completionText ToolTip = "$sourceLabel on this machine: $name" ResultType = 'ParameterValue' } } } } $results = @($results) # After a comma PowerShell falls back to the filesystem when nothing comes back, so # an unmatched segment is echoed. An empty segment returns nothing and the caller # offers the command's tags instead. if ($results.Count -eq 0 -and $TagValue.SegmentOnly -and $TagValue.Segment) { $results = @([pscustomobject]@{ CompletionText = $TagValue.Segment ToolTip = "Typed value for $($TagValue.Tag)=: $($TagValue.Value)" ResultType = 'ParameterValue' }) } $results } Register-ArgumentCompleter -Native -CommandName 'netsh', 'netsh.exe' -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) Initialize-NetshCompletionCatalog $line = $commandAst.Extent.Text $currentWord = if ([string]::IsNullOrEmpty($wordToComplete)) { '' } else { Get-NetshCurrentToken -Line $line -CursorPosition ($cursorPosition - $commandAst.Extent.StartOffset) -Fallback $wordToComplete } $tokensBeforeCurrent = @(Get-NetshTokensBeforeCurrent -CommandAst $commandAst -CursorPosition $cursorPosition) $expectedOption = Get-NetshExpectedGlobalOption -TokensBeforeCurrent $tokensBeforeCurrent if ($expectedOption) { switch ($expectedOption.ValueKind) { 'Path' { foreach ($path in (Get-NetshFilePathCompletions -InputPath $currentWord)) { [System.Management.Automation.CompletionResult]::new($path, $path, 'ProviderItem', $path) } return } 'Context' { foreach ($item in (Get-NetshContextValueCompletions -WordToComplete $currentWord)) { New-NetshCompletionResult -CompletionText $item.CompletionText -ResultType $item.ResultType -ToolTip $item.ToolTip } return } 'Password' { if ([string]::IsNullOrWhiteSpace($currentWord) -or '*' -like ([System.Management.Automation.WildcardPattern]::Escape($currentWord) + '*')) { New-NetshCompletionResult -CompletionText '*' -ResultType 'ParameterValue' -ToolTip 'Prompt for the password.' } return } 'Text' { # Free-form remote machine / user name: a placeholder (and the local # machine name for -r) instead of the command tree or the filesystem. $candidates = @($expectedOption.Placeholder) if ($expectedOption.Token -eq '-r' -and -not [string]::IsNullOrWhiteSpace($env:COMPUTERNAME)) { $candidates = @($env:COMPUTERNAME) + $candidates } foreach ($candidate in $candidates) { if ([string]::IsNullOrWhiteSpace($currentWord) -or $candidate -like ([System.Management.Automation.WildcardPattern]::Escape($currentWord) + '*')) { New-NetshCompletionResult -CompletionText $candidate -ResultType 'ParameterValue' -ToolTip $expectedOption.ToolTip } } return } default { return } } } $parsedState = Get-NetshParsedState -Tokens $tokensBeforeCurrent Ensure-NetshPathLoaded -PathTokens $parsedState.ContextTokens $resolved = Resolve-NetshCommandPath -BasePathTokens $parsedState.ContextTokens -Tokens $parsedState.CommandTokens Ensure-NetshPathLoaded -PathTokens $resolved.PathTokens $activeNode = Get-NetshNode -PathTokens $resolved.PathTokens if (-not $activeNode) { $activeNode = Get-NetshNode -PathTokens $resolved.PathTokens -Create } # Machine-state and path values for tags such as name=, scenario=, program=. $tagValueSource = $null $tagValue = Get-NetshCursorTagValue -CommandAst $commandAst -CursorPosition $cursorPosition if ($tagValue) { $commandWords = @($resolved.PathTokens) + @($resolved.Remaining | Where-Object { $_ -notmatch '=' }) $tagValueSource = Get-NetshTagValueSource -CommandWords $commandWords -Tag $tagValue.Tag } # After an unquoted comma PowerShell replaces only the segment past it, so tag values # stand alone there; with nothing to offer the command's tags follow, as without a source. if ($tagValueSource -and $tagValue.SegmentOnly) { $segmentItems = @(Get-NetshTagValueSuggestion -Source $tagValueSource -TagValue $tagValue) if ($segmentItems.Count -gt 0) { foreach ($item in $segmentItems) { New-NetshCompletionResult -CompletionText $item.CompletionText -ResultType $item.ResultType -ToolTip $item.ToolTip } return } $tagValueSource = $null } $resultMap = [ordered]@{} $candidateItems = New-Object System.Collections.Generic.List[object] $isOptionWord = ($currentWord -like '-*') -or ($currentWord -like '/*') if ($isOptionWord -or $resolved.PathTokens.Count -eq 0) { foreach ($item in (Get-NetshGlobalOptionSuggestions -WordToComplete $currentWord)) { $candidateItems.Add($item) } } if (-not $isOptionWord) { foreach ($item in (Get-NetshCollectionSuggestions -Collection $activeNode.NextTokens -WordToComplete $currentWord)) { $candidateItems.Add($item) } foreach ($item in (Get-NetshCollectionSuggestions -Collection $activeNode.UsageSuggestions -WordToComplete $currentWord)) { $candidateItems.Add($item) } foreach ($item in (Get-NetshInlineTagValueSuggestions -ValueHintsByTag $activeNode.ValueHintsByTag -WordToComplete $currentWord)) { $candidateItems.Add($item) } if ($tagValueSource) { foreach ($item in (Get-NetshTagValueSuggestion -Source $tagValueSource -TagValue $tagValue)) { $candidateItems.Add($item) } } } foreach ($item in $candidateItems) { $key = $item.CompletionText.ToLowerInvariant() if (-not $resultMap.Contains($key)) { $resultMap[$key] = New-NetshCompletionResult -CompletionText $item.CompletionText -ResultType $item.ResultType -ToolTip $item.ToolTip } } $resultMap.Values } |