completers/handle_completer/handle_completer.ps1
|
# handle.exe tab completion for PowerShell # Static-first native completer for Sysinternals Handle with safe help parsing and local process hints. Set-StrictMode -Version 2.0 if (-not (Get-Variable -Name HandleCompletionCatalog -Scope Script -ErrorAction Ignore)) { $script:HandleCompletionCatalog = @{ Initialized = $false SwitchOrder = @('-a', '-l', '-c', '-y', '-s', '-g', '-u', '-v', '-vt', '-p', '-nobanner', '-?', '/?', '--help') # handle's usage line is three alternative groups: # handle [[-a [-l]] [-v|-vt] [-u] | [-c <handle> [-y]] | [-s]] [-p ...] [name] [-nobanner] # Switches outside this table (-g, -p, -nobanner, the help aliases) belong to every group. SwitchGroups = @{ '-a' = 'all'; '-l' = 'all'; '-v' = 'all'; '-vt' = 'all'; '-u' = 'all' '-c' = 'close'; '-y' = 'close' '-s' = 'summary' } SwitchInfo = @{} SearchPlaceholder = '<name-fragment>' HandleValuePlaceholder = '<hex-handle>' ProcessEntries = @() ProcessCacheUpdated = $null ProcessCacheTtlSeconds = 2 } } function New-HandleCompletionResult { param( [string]$CompletionText, [string]$ResultType, [string]$ToolTip ) if ([string]::IsNullOrWhiteSpace($ToolTip)) { $ToolTip = $CompletionText } [System.Management.Automation.CompletionResult]::new( $CompletionText, $CompletionText, $ResultType, $ToolTip ) } function Get-HandleCurrentToken { param( [string]$Line, [int]$CursorPosition, [string]$Fallback ) if ([string]::IsNullOrWhiteSpace($Line)) { return $Fallback } if ($CursorPosition -gt $Line.Length) { return '' } $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 Remove-HandleOuterQuotes { param([string]$Value) if ([string]::IsNullOrEmpty($Value)) { return '' } if ($Value.Length -ge 2 -and $Value.StartsWith('"') -and $Value.EndsWith('"')) { return $Value.Substring(1, $Value.Length - 2) } $Value.TrimStart('"') } function Get-HandleStaticSwitchCatalog { [ordered]@{ '-a' = 'Dump all handle information.' '-l' = 'Just show pagefile-backed section handles.' '-c' = 'Close the specified handle. Requires -p with the owning PID.' '-y' = 'Do not prompt for close handle confirmation.' '-s' = 'Print counts by handle type.' '-g' = 'Print granted access.' '-u' = 'Show the owning user name.' '-v' = 'CSV output with comma delimiter.' '-vt' = 'CSV output with tab delimiter.' '-p' = 'Dump handles belonging to a process (partial name accepted).' '-nobanner' = 'Do not display the startup banner and copyright message.' '-?' = 'Display handle help.' '/?' = 'Display handle help.' '--help' = 'Display handle help.' } } function Get-HandleCommandPath { foreach ($candidate in @('handle.exe', 'handle')) { $command = Get-Command -Name $candidate -ErrorAction SilentlyContinue if ($command) { if ($command.Path) { return $command.Path } if ($command.Source) { return $command.Source } return $command.Name } } $null } function Invoke-HandleHelpText { $commandPath = Get-HandleCommandPath if ([string]::IsNullOrWhiteSpace($commandPath)) { return @() } try { @( $null | & $commandPath '/?' 2>&1 | ForEach-Object { $_.ToString() -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' } ) } catch { @() } } function Get-HandleHelpEntryMap { param([string[]]$Lines) $result = @{} $currentKey = $null foreach ($line in $Lines) { if ($line -match '^\s*(-a|-l|-c|-g|-y|-s|-u|-v|-vt|-p|name|-nobanner)\s{2,}(.*)$') { $currentKey = $matches[1].ToLowerInvariant() $result[$currentKey] = [System.Collections.Generic.List[string]]::new() if (-not [string]::IsNullOrWhiteSpace($matches[2])) { $result[$currentKey].Add($matches[2].Trim()) } continue } if ($currentKey -and $line -match '^\s{10,}(\S.*)$') { $result[$currentKey].Add($matches[1].Trim()) continue } $currentKey = $null } $map = @{} foreach ($entry in $result.GetEnumerator()) { $map[$entry.Key] = ($entry.Value -join ' ') } $map } function Initialize-HandleCompletionCatalog { if ($script:HandleCompletionCatalog.Initialized) { return } $script:HandleCompletionCatalog.SwitchInfo = @{} foreach ($entry in (Get-HandleStaticSwitchCatalog).GetEnumerator()) { $script:HandleCompletionCatalog.SwitchInfo[$entry.Key] = $entry.Value } $helpEntryMap = Get-HandleHelpEntryMap -Lines (Invoke-HandleHelpText) foreach ($entry in $helpEntryMap.GetEnumerator()) { if ($entry.Key -eq 'name') { $script:HandleCompletionCatalog.SearchPlaceholder = '<name-fragment>' continue } if ($script:HandleCompletionCatalog.SwitchInfo.ContainsKey($entry.Key)) { $script:HandleCompletionCatalog.SwitchInfo[$entry.Key] = $entry.Value } } $script:HandleCompletionCatalog.Initialized = $true } function Update-HandleProcessCache { if ($null -ne $script:HandleCompletionCatalog.ProcessCacheUpdated) { $cacheAge = (Get-Date) - $script:HandleCompletionCatalog.ProcessCacheUpdated if ($cacheAge.TotalSeconds -lt $script:HandleCompletionCatalog.ProcessCacheTtlSeconds) { return } } $nameSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $idSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $nameEntries = [System.Collections.Generic.List[object]]::new() $idEntries = [System.Collections.Generic.List[object]]::new() foreach ($process in @(Get-Process -ErrorAction SilentlyContinue)) { if ($process.ProcessName -and $nameSet.Add($process.ProcessName)) { $nameEntries.Add([pscustomobject]@{ CompletionText = $process.ProcessName ResultType = 'ParameterValue' ToolTip = "Process name $($process.ProcessName)" }) } # PID 0 is the Idle pseudo-process and cannot be targeted. if ($process.Id -eq 0) { continue } $processIdText = [string]$process.Id if ($processIdText -and $idSet.Add($processIdText)) { $idEntries.Add([pscustomobject]@{ CompletionText = $processIdText ResultType = 'ParameterValue' ToolTip = "Process ID $processIdText ($($process.ProcessName))" }) } } # handle's own help for -p says 'partial name accepted', so names are the primary # input; keeping them in a separate list stops 500+ digits sorting ahead of them. $script:HandleCompletionCatalog.ProcessEntries = @( @($nameEntries | Sort-Object -Property CompletionText) + @($idEntries | Sort-Object -Property @{ Expression = { [int]$_.CompletionText } }) ) $script:HandleCompletionCatalog.ProcessCacheUpdated = Get-Date } function New-HandleLiteralValueResults { param( [string]$CurrentValue, [string]$Placeholder, [string]$ToolTip ) if ([string]::IsNullOrWhiteSpace($CurrentValue)) { return @( New-HandleCompletionResult -CompletionText $Placeholder -ResultType 'ParameterValue' -ToolTip $ToolTip ) } @( New-HandleCompletionResult -CompletionText $CurrentValue -ResultType 'ParameterValue' -ToolTip $ToolTip ) } function Get-HandleSampleValueResults { param( [string]$CurrentValue, [string[]]$Samples, [string]$Placeholder, [string]$ToolTip ) $typedValue = Remove-HandleOuterQuotes -Value $CurrentValue $results = [System.Collections.Generic.List[object]]::new() foreach ($sample in $Samples) { if (-not [string]::IsNullOrWhiteSpace($typedValue) -and -not $sample.StartsWith($typedValue, [System.StringComparison]::OrdinalIgnoreCase)) { continue } $results.Add((New-HandleCompletionResult -CompletionText $sample -ResultType 'ParameterValue' -ToolTip $ToolTip)) } if ([string]::IsNullOrWhiteSpace($typedValue) -or $Placeholder.StartsWith($typedValue, [System.StringComparison]::OrdinalIgnoreCase)) { $results.Add((New-HandleCompletionResult -CompletionText $Placeholder -ResultType 'ParameterValue' -ToolTip $ToolTip)) } if ($results.Count -eq 0) { return @(New-HandleLiteralValueResults -CurrentValue $CurrentValue -Placeholder $Placeholder -ToolTip $ToolTip) } @($results.ToArray()) } function Get-HandleProcessCompletions { param( [string]$CurrentWord, [bool]$IdsOnly = $false ) Update-HandleProcessCache $typedValue = Remove-HandleOuterQuotes -Value $CurrentWord $results = $script:HandleCompletionCatalog.ProcessEntries | Where-Object { (-not $IdsOnly -or $_.CompletionText -match '^\d+$') -and ([string]::IsNullOrWhiteSpace($typedValue) -or $_.CompletionText.StartsWith($typedValue, [System.StringComparison]::OrdinalIgnoreCase)) } | ForEach-Object { New-HandleCompletionResult -CompletionText $_.CompletionText -ResultType $_.ResultType -ToolTip $_.ToolTip } if (@($results).Count -gt 0) { return @($results) } if ($IdsOnly) { return @(New-HandleLiteralValueResults -CurrentValue $CurrentWord -Placeholder '<pid>' -ToolTip 'Owning process ID required for -c close-handle workflow.') } @( New-HandleLiteralValueResults -CurrentValue $CurrentWord -Placeholder '<process-or-pid>' -ToolTip 'Process name or PID.' ) } function Get-HandleCommandState { param([object[]]$TokensBeforeCurrent) $TokensBeforeCurrent = @($TokensBeforeCurrent) $usedSwitchLookup = @{} $valueContext = $null $mode = 'search' $processTarget = $null $nameTarget = $null $helpRequested = $false for ($index = 0; $index -lt $TokensBeforeCurrent.Count; $index++) { $token = [string]$TokensBeforeCurrent[$index] if ([string]::IsNullOrWhiteSpace($token)) { continue } $lookup = $token.ToLowerInvariant() if ($lookup -in @('-?', '/?', '--help')) { $helpRequested = $true $usedSwitchLookup[$lookup] = $true continue } if ($lookup -eq '-c') { $usedSwitchLookup[$lookup] = $true if ($index -eq ($TokensBeforeCurrent.Count - 1)) { $valueContext = '-c' break } $index++ continue } if ($lookup -eq '-p') { $usedSwitchLookup[$lookup] = $true if ($index -eq ($TokensBeforeCurrent.Count - 1)) { $valueContext = '-p' break } $processTarget = [string]$TokensBeforeCurrent[$index + 1] $index++ continue } if ($lookup.StartsWith('-')) { $usedSwitchLookup[$lookup] = $true continue } if (-not $nameTarget) { $nameTarget = $token } } # The first group-exclusive switch on the line, in catalog order, fixes the # alternative group; everything from the other two groups is then out of grammar. foreach ($token in $script:HandleCompletionCatalog.SwitchOrder) { if ($usedSwitchLookup.ContainsKey($token) -and $script:HandleCompletionCatalog.SwitchGroups.ContainsKey($token)) { $mode = $script:HandleCompletionCatalog.SwitchGroups[$token] break } } [pscustomobject]@{ UsedSwitchLookup = $usedSwitchLookup ValueContext = $valueContext Mode = $mode ProcessTarget = $processTarget NameTarget = $nameTarget HelpRequested = $helpRequested } } function Get-HandleSwitchCompletions { param( [string]$CurrentWord, [pscustomobject]$State, [bool]$NoArgumentsYet ) $results = [System.Collections.Generic.List[object]]::new() $currentPrefix = if ([string]::IsNullOrWhiteSpace($CurrentWord)) { '' } else { $CurrentWord } foreach ($token in $script:HandleCompletionCatalog.SwitchOrder) { if (-not [string]::IsNullOrWhiteSpace($currentPrefix) -and -not $token.StartsWith($currentPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { continue } if ($token -in @('-?', '/?', '--help')) { if (-not $NoArgumentsYet) { continue } } elseif ($State.UsedSwitchLookup.ContainsKey($token)) { continue } if ($script:HandleCompletionCatalog.SwitchGroups.ContainsKey($token) -and $State.Mode -ne 'search' -and $script:HandleCompletionCatalog.SwitchGroups[$token] -ne $State.Mode) { continue } if ($token -eq '-l' -and -not $State.UsedSwitchLookup.ContainsKey('-a')) { continue } if ($token -eq '-y' -and -not $State.UsedSwitchLookup.ContainsKey('-c')) { continue } if ($token -eq '-vt' -and $State.UsedSwitchLookup.ContainsKey('-v')) { continue } if ($token -eq '-v' -and $State.UsedSwitchLookup.ContainsKey('-vt')) { continue } $toolTip = $script:HandleCompletionCatalog.SwitchInfo[$token] $results.Add((New-HandleCompletionResult -CompletionText $token -ResultType 'ParameterName' -ToolTip $toolTip)) } @($results.ToArray()) } function Complete-Handle { param( [string]$wordToComplete, [System.Management.Automation.Language.CommandAst]$commandAst, [int]$cursorPosition ) Initialize-HandleCompletionCatalog $line = $commandAst.ToString() $safeCursor = [Math]::Min([Math]::Max($cursorPosition - $commandAst.Extent.StartOffset, 0), $line.Length) $linePrefix = $line.Substring(0, $safeCursor) $commandTokens = @([regex]::Matches($linePrefix, '"[^"]*"|\S+') | ForEach-Object { $_.Value }) # Wrap the whole if-expression in @(): an [object[]]-constrained assignment from a # branch that yields nothing binds $null, and re-wrapping that $null manufactures a # one-element array holding $null, which would make every Count -eq 0 test false. $argumentTokens = @(if ($commandTokens.Count -gt 1) { $commandTokens | Select-Object -Skip 1 }) $currentWord = if ([string]::IsNullOrEmpty($wordToComplete)) { Get-HandleCurrentToken -Line $line -CursorPosition ($cursorPosition - $commandAst.Extent.StartOffset) -Fallback $wordToComplete } else { $wordToComplete } $hasTrailingSpace = [string]::IsNullOrEmpty($currentWord) -and (($linePrefix -match '\s$') -or (($cursorPosition - $commandAst.Extent.StartOffset) -gt $line.Length)) $tokensBeforeCurrent = @(if ($hasTrailingSpace) { $argumentTokens } elseif ($argumentTokens.Count -gt 0) { $argumentTokens | Select-Object -First ($argumentTokens.Count - 1) }) $state = Get-HandleCommandState -TokensBeforeCurrent $tokensBeforeCurrent if ($state.HelpRequested) { return @( New-HandleCompletionResult -CompletionText '-?' -ResultType 'ParameterName' -ToolTip 'Display handle help.' ) } switch ($state.ValueContext) { '-c' { # No sample handle values: -c closes an arbitrary handle in another process # and handle's own help warns it can destabilise the system, so an invented # value that looks real must never be offered as a completion. return @(New-HandleLiteralValueResults -CurrentValue $currentWord -Placeholder $script:HandleCompletionCatalog.HandleValuePlaceholder -ToolTip $script:HandleCompletionCatalog.SwitchInfo['-c']) } '-p' { $idsOnly = ($state.Mode -eq 'close') return @(Get-HandleProcessCompletions -CurrentWord $currentWord -IdsOnly:$idsOnly) } } if (-not [string]::IsNullOrWhiteSpace($currentWord) -and ($currentWord.StartsWith('-') -or $currentWord.StartsWith('/'))) { return @(Get-HandleSwitchCompletions -CurrentWord $currentWord -State $state -NoArgumentsYet:($tokensBeforeCurrent.Count -eq 0)) } if ([string]::IsNullOrWhiteSpace($currentWord)) { $results = [System.Collections.Generic.List[object]]::new() foreach ($item in @(Get-HandleSwitchCompletions -CurrentWord $currentWord -State $state -NoArgumentsYet:($tokensBeforeCurrent.Count -eq 0))) { $results.Add($item) } if ($state.Mode -ne 'close' -and -not $state.NameTarget) { $results.Add((New-HandleCompletionResult -CompletionText $script:HandleCompletionCatalog.SearchPlaceholder -ResultType 'ParameterValue' -ToolTip 'Object name fragment to search for.')) } return @($results.ToArray()) } if (-not $state.NameTarget -and $state.Mode -ne 'close') { # handle's canonical example searches a path fragment, so the filesystem is the # only real source of candidates here; echoing the typed word back is a no-op # that merely suppresses the completion the slot actually wants. return @( [System.Management.Automation.CompletionCompleters]::CompleteFilename($currentWord) | ForEach-Object { New-HandleCompletionResult -CompletionText $_.CompletionText -ResultType $_.ResultType -ToolTip 'Object name fragment to search for.' } ) } @() } Register-ArgumentCompleter -Native -CommandName @('handle', 'handle.exe') -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) Complete-Handle -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition } |