completers/regjump_completer/regjump_completer.ps1
|
# regjump tab completion for PowerShell # Small native completer for regjump with local registry-path completion and terminal clipboard handling. Set-StrictMode -Version 2.0 if (-not (Get-Variable -Name RegJumpCompletionCatalog -Scope Script -ErrorAction Ignore)) { $script:RegJumpCompletionCatalog = @{ RootKeys = @('HKLM', 'HKCU', 'HKCR', 'HKU', 'HKCC') RootLongNames = @{ 'HKLM' = 'HKEY_LOCAL_MACHINE' 'HKCU' = 'HKEY_CURRENT_USER' 'HKCR' = 'HKEY_CLASSES_ROOT' 'HKU' = 'HKEY_USERS' 'HKCC' = 'HKEY_CURRENT_CONFIG' } RootCanonicalByAlias = @{ 'HKLM' = 'HKLM' 'HKCU' = 'HKCU' 'HKCR' = 'HKCR' 'HKU' = 'HKU' 'HKCC' = 'HKCC' 'HKEY_LOCAL_MACHINE' = 'HKLM' 'HKEY_CURRENT_USER' = 'HKCU' 'HKEY_CLASSES_ROOT' = 'HKCR' 'HKEY_USERS' = 'HKU' 'HKEY_CURRENT_CONFIG' = 'HKCC' } ChildCache = @{} MaxChildResults = 300 } } function New-RegJumpCompletionResult { param( [string]$CompletionText, [string]$ResultType, [string]$ToolTip, [string]$ListItemText ) if ([string]::IsNullOrWhiteSpace($ListItemText)) { $ListItemText = $CompletionText } if ([string]::IsNullOrWhiteSpace($ToolTip)) { $ToolTip = $CompletionText } [System.Management.Automation.CompletionResult]::new( $CompletionText, $ListItemText, $ResultType, $ToolTip ) } function Remove-RegJumpOuterQuotes { param([string]$Value) if ($null -eq $Value) { return '' } $Value.Trim([char[]]@([char]34, [char]39)) } function ConvertTo-RegJumpQuotedValue { 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('"'))) { $escaped = $Value.Replace('`', '``').Replace('"', '`"') return '"' + $escaped + '"' } $Value } function Get-RegJumpCurrentToken { 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-RegJumpArgumentTokens { param( [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) $tokens = @() foreach ($element in $CommandAst.CommandElements | Select-Object -Skip 1) { if ($element.Extent.EndOffset -lt $CursorPosition) { $tokens += $element.Extent.Text } } $tokens } function Get-RegJumpRootSuggestions { param([string]$CurrentValue) $cleanCurrent = Remove-RegJumpOuterQuotes -Value $CurrentValue foreach ($root in $script:RegJumpCompletionCatalog.RootKeys) { # regjump accepts both the abbreviated (HKLM) and standard (HKEY_LOCAL_MACHINE) # root form, so offer whichever one the typed text is a prefix of. $longName = $script:RegJumpCompletionCatalog.RootLongNames[$root] $displayRoot = if ($root.StartsWith($cleanCurrent, [System.StringComparison]::OrdinalIgnoreCase)) { $root } elseif ($longName.StartsWith($cleanCurrent, [System.StringComparison]::OrdinalIgnoreCase)) { $longName } else { continue } $candidate = $displayRoot + '\' New-RegJumpCompletionResult -CompletionText $candidate -ListItemText $candidate -ResultType 'ProviderContainer' -ToolTip ('Registry path to open in Regedit: ' + $displayRoot) } } function Get-RegJumpChildName { param( [string]$CanonicalRoot, [string]$SubKeyPath ) $cacheKey = $CanonicalRoot + '\' + $SubKeyPath if ($script:RegJumpCompletionCatalog.ChildCache.ContainsKey($cacheKey)) { return @($script:RegJumpCompletionCatalog.ChildCache[$cacheKey]) } # RegistryKey.GetSubKeyNames lists the names held by the parent without opening # each child, so it is ~40x faster than the Registry provider on HKCR, never # shows the provider's merged-view duplicates, and still lists keys the session # cannot open (SECURITY, BCD00000000). $baseKey = switch ($CanonicalRoot) { 'HKLM' { [Microsoft.Win32.Registry]::LocalMachine } 'HKCU' { [Microsoft.Win32.Registry]::CurrentUser } 'HKCR' { [Microsoft.Win32.Registry]::ClassesRoot } 'HKU' { [Microsoft.Win32.Registry]::Users } 'HKCC' { [Microsoft.Win32.Registry]::CurrentConfig } default { $null } } $names = @() $subKey = $null $errorCountBefore = $Error.Count try { if ($null -ne $baseKey) { if ([string]::IsNullOrEmpty($SubKeyPath)) { $names = @($baseKey.GetSubKeyNames()) } else { $subKey = $baseKey.OpenSubKey($SubKeyPath, $false) if ($null -ne $subKey) { $names = @($subKey.GetSubKeyNames()) } } } } catch { Write-Debug ('regjump completer: cannot enumerate ' + $cacheKey + ': ' + $_.Exception.Message) $names = @() } finally { if ($null -ne $subKey) { $subKey.Dispose() } # An unreadable key is an expected outcome while completing, not a fault to # leave behind in $Error. while ($Error.Count -gt $errorCountBefore) { $Error.RemoveAt(0) } } $script:RegJumpCompletionCatalog.ChildCache[$cacheKey] = $names @($names) } function Get-RegJumpRegistryPathCompletions { param([string]$CurrentValue) $cleanCurrent = Remove-RegJumpOuterQuotes -Value $CurrentValue $alwaysQuote = -not [string]::IsNullOrEmpty($CurrentValue) -and ($CurrentValue.StartsWith('"') -or $CurrentValue.StartsWith("'")) if ([string]::IsNullOrWhiteSpace($cleanCurrent)) { return @(Get-RegJumpRootSuggestions -CurrentValue '') } if ($cleanCurrent -notmatch '\\') { return @(Get-RegJumpRootSuggestions -CurrentValue $cleanCurrent) } $segments = $cleanCurrent -split '\\', 2 $typedRoot = $segments[0].ToUpperInvariant() if (-not $script:RegJumpCompletionCatalog.RootCanonicalByAlias.ContainsKey($typedRoot)) { return @(Get-RegJumpRootSuggestions -CurrentValue $cleanCurrent) } $canonicalRoot = $script:RegJumpCompletionCatalog.RootCanonicalByAlias[$typedRoot] $displayRoot = if ($typedRoot.StartsWith('HKEY_', [System.StringComparison]::OrdinalIgnoreCase)) { $script:RegJumpCompletionCatalog.RootLongNames[$canonicalRoot] } else { $canonicalRoot } $remainder = if ($segments.Count -gt 1) { $segments[1] } else { '' } if ([string]::IsNullOrWhiteSpace($remainder)) { $prefixPath = '' $leaf = '' } elseif ($remainder.EndsWith('\')) { $prefixPath = $remainder.TrimEnd('\') $leaf = '' } else { $lastSeparator = $remainder.LastIndexOf('\') if ($lastSeparator -lt 0) { $prefixPath = '' $leaf = $remainder } else { $prefixPath = $remainder.Substring(0, $lastSeparator) $leaf = $remainder.Substring($lastSeparator + 1) } } # Filter on the typed leaf before sorting, and cap the emitted count so a hive # the size of HKCR cannot stall the completion thread. $matchedNames = @( Get-RegJumpChildName -CanonicalRoot $canonicalRoot -SubKeyPath $prefixPath | Where-Object { $_.StartsWith($leaf, [System.StringComparison]::OrdinalIgnoreCase) } | Sort-Object | Select-Object -First $script:RegJumpCompletionCatalog.MaxChildResults ) foreach ($childName in $matchedNames) { $candidate = if ([string]::IsNullOrWhiteSpace($prefixPath)) { $displayRoot + '\' + $childName + '\' } else { $displayRoot + '\' + $prefixPath + '\' + $childName + '\' } # Keys are emitted as ProviderContainer so the accepted text keeps inviting # the next level: an unquoted key already ends in '\', and for a quoted key # PSReadLine inserts the separator before the closing quote and parks the # cursor there, exactly as it does for a quoted directory. $completionText = if ($alwaysQuote -or $candidate -match '\s') { ConvertTo-RegJumpQuotedValue -Value $candidate.TrimEnd('\') -AlwaysQuote $true } else { $candidate } New-RegJumpCompletionResult -CompletionText $completionText -ListItemText $candidate -ResultType 'ProviderContainer' -ToolTip ('Registry path to open in Regedit: ' + $candidate.TrimEnd('\')) } } function Complete-RegJump { param( [string]$wordToComplete, [System.Management.Automation.Language.CommandAst]$commandAst, [int]$cursorPosition ) $currentWord = if ($cursorPosition -gt $commandAst.Extent.EndOffset) { '' } else { Get-RegJumpCurrentToken -Line $commandAst.ToString() -CursorPosition ($cursorPosition - $commandAst.Extent.StartOffset) -Fallback $wordToComplete } $arguments = @(Get-RegJumpArgumentTokens -CommandAst $commandAst -CursorPosition $cursorPosition) $clipboardMode = $false $pathProvided = $false foreach ($argument in $arguments) { $cleanArgument = Remove-RegJumpOuterQuotes -Value $argument if ([string]::IsNullOrWhiteSpace($cleanArgument)) { continue } if ($cleanArgument.ToLowerInvariant() -eq '-c') { $clipboardMode = $true continue } if (-not ($cleanArgument.StartsWith('-') -or $cleanArgument.StartsWith('/'))) { $pathProvided = $true } } if ($clipboardMode) { return @( New-RegJumpCompletionResult -CompletionText ' ' -ListItemText '<complete>' -ResultType 'ParameterValue' -ToolTip 'regjump -c copies the path from the clipboard and takes no further arguments.' ) } if ($pathProvided) { # Usage is 'regjump <<path>|-c>': once a path is present nothing else is valid. return @() } if (-not [string]::IsNullOrEmpty($currentWord) -and $currentWord.StartsWith('-')) { if ('-c'.StartsWith($currentWord, [System.StringComparison]::OrdinalIgnoreCase)) { return @( New-RegJumpCompletionResult -CompletionText '-c' -ListItemText '-c' -ResultType 'ParameterName' -ToolTip 'Copy the path from the clipboard.' ) } return @() } $results = @() if ('-c'.StartsWith($currentWord, [System.StringComparison]::OrdinalIgnoreCase)) { $results += New-RegJumpCompletionResult -CompletionText '-c' -ListItemText '-c' -ResultType 'ParameterName' -ToolTip 'Copy the path from the clipboard.' } $results += @(Get-RegJumpRegistryPathCompletions -CurrentValue $currentWord) @($results) } Register-ArgumentCompleter -Native -CommandName 'regjump', 'regjump.exe' -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) Complete-RegJump -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition } |