completers/strings_completer/strings_completer.ps1
|
# strings tab completion for PowerShell # Native completer for Strings with switch-aware numeric hints and path completion. Set-StrictMode -Version 2.0 if (-not (Get-Variable -Name StringsCompletionCatalog -Scope Script -ErrorAction Ignore)) { $script:StringsCompletionCatalog = @{ Switches = @( @{ Token = '-a'; Description = 'Ascii-only search.'; TakesValue = $false } @{ Token = '-b'; Description = 'Bytes of file to scan.'; TakesValue = $true; ValueKind = 'Bytes' } @{ Token = '-f'; Description = 'File offset at which to start scanning.'; TakesValue = $true; ValueKind = 'Offset' } @{ Token = '-n'; Description = 'Minimum string length.'; TakesValue = $true; ValueKind = 'Length' } @{ Token = '-o'; Description = 'Print offset where the string was located.'; TakesValue = $false } @{ Token = '-s'; Description = 'Recurse subdirectories.'; TakesValue = $false } @{ Token = '-u'; Description = 'Unicode-only search.'; TakesValue = $false } @{ Token = '-nobanner'; Description = 'Do not display the startup banner.'; TakesValue = $false } @{ Token = '-accepteula'; Description = 'Accept the Sysinternals license agreement (suppresses the first-run EULA dialog).'; TakesValue = $false } @{ Token = '-?'; Description = 'Show Strings help.'; TakesValue = $false } @{ Token = '/?'; Description = 'Show Strings help.'; TakesValue = $false } ) ByteHints = @('256', '512', '1024', '4096', '65536') OffsetHints = @('512', '4096', '65536', '1048576') LengthHints = @('3', '4', '8', '16', '32') } } function New-StringsCompletionResult { 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-StringsOuterQuotes { 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 ConvertFrom-StringsTypedWord { # The value of a typed word. A word opened with a quote (ASCII or typographic) is read by # the PowerShell tokenizer, which drops the quotes and undoes that quote style's escapes; # text glued after the closing quote ("C:\Program Files"\) joins the same argument. param([string]$Value) if ($Value -notmatch '^[''"\u2018-\u201E]') { return $Value } $tokens = $null $parseErrors = $null [void][System.Management.Automation.Language.Parser]::ParseInput('x ' + $Value, [ref]$tokens, [ref]$parseErrors) -join @($tokens | Select-Object -Skip 1 | Where-Object { $_.Kind -ne 'EndOfInput' } | ForEach-Object { if ($_ -is [System.Management.Automation.Language.StringToken]) { $_.Value } else { $_.Text } }) } function ConvertTo-StringsQuotedValue { # Renders a value as one PowerShell argument: bare when safe and no quote was typed, # otherwise in the quote the user typed (single by default). PowerShell reads ' and # U+2018-U+201B as single quotes and " and U+201C-U+201E as double quotes. A bare word # starting with a dash (- or U+2013-U+2015) would be read as a parameter. param( [string]$Value, [string]$QuoteChar = '' ) if ([string]::IsNullOrEmpty($Value)) { return $Value } if (-not $QuoteChar) { if ($Value -notmatch '[\s{}();,|&<>''"`$@#\u2018-\u201E]' -and $Value -notmatch '^[-\u2013-\u2015]') { return $Value } $QuoteChar = "'" } if ($QuoteChar -match '^[''\u2018-\u201B]$') { return $QuoteChar + ($Value -replace '([''\u2018-\u201B])', '$1$1') + $QuoteChar } $QuoteChar + ($Value -replace '([`"$\u201C-\u201E])', '`$1') + $QuoteChar } function Get-StringsCurrentWord { param( [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) # The parser keeps an unterminated quoted word (any quote style) as one element running # to the cursor, which is the word PowerShell replaces. A quoted string glued to a path # tail ("C:\Program Files"\x) is one argument, and PowerShell then replaces all of it. $elements = @($CommandAst.CommandElements) for ($index = 1; $index -lt $elements.Count; $index++) { $extent = $elements[$index].Extent if ($extent.StartOffset -lt $CursorPosition -and $CursorPosition -le $extent.EndOffset) { $start = $index while ($start -gt 1 -and $elements[$start - 1].Extent.EndOffset -eq $elements[$start].Extent.StartOffset -and $elements[$start].Extent.Text -match '^[\\/]' -and $elements[$start - 1].PSObject.Properties['StringConstantType'] -and $elements[$start - 1].StringConstantType -ne 'BareWord') { $start-- } $startOffset = $elements[$start].Extent.StartOffset return $CommandAst.Extent.Text.Substring($startOffset - $CommandAst.Extent.StartOffset, $CursorPosition - $startOffset) } } '' } function Get-StringsTokenState { 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()) { if (($character -eq [char]34) -or ($character -eq [char]39)) { if ($quoteChar -eq [char]0) { $quoteChar = $character } elseif ($quoteChar -eq $character) { $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) } $hasTrailingSpace = $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-StringsArgumentsFromTokenState { param([pscustomobject]$TokenState) [pscustomobject]@{ ArgumentsBeforeCurrent = @($TokenState.TokensBeforeCurrent | Select-Object -Skip 1) CurrentArgument = $TokenState.CurrentToken } } function Get-StringsUniqueCompletions { param([object[]]$Results) $seen = @{} $unique = New-Object System.Collections.Generic.List[object] foreach ($result in $Results) { if ($null -eq $result) { continue } if ($seen.ContainsKey($result.CompletionText)) { continue } $seen[$result.CompletionText] = $true [void]$unique.Add($result) } @($unique.ToArray()) } function Get-StringsStaticValueResults { param( [string]$CurrentWord, [string[]]$Values, [string]$ToolTip ) $typedValue = Remove-StringsOuterQuotes -Value $CurrentWord $results = New-Object System.Collections.Generic.List[object] foreach ($value in $Values) { if (-not [string]::IsNullOrWhiteSpace($typedValue) -and -not $value.StartsWith($typedValue, [System.StringComparison]::OrdinalIgnoreCase)) { continue } [void]$results.Add((New-StringsCompletionResult -CompletionText $value -ListItemText $value -ResultType 'ParameterValue' -ToolTip $ToolTip)) } if ($results.Count -eq 0) { if ([string]::IsNullOrWhiteSpace($CurrentWord)) { [void]$results.Add((New-StringsCompletionResult -CompletionText '<value>' -ListItemText '<value>' -ResultType 'ParameterValue' -ToolTip $ToolTip)) } else { [void]$results.Add((New-StringsCompletionResult -CompletionText $CurrentWord -ListItemText $CurrentWord -ResultType 'ParameterValue' -ToolTip $ToolTip)) } } @($results.ToArray()) } function Expand-StringsPathText { # Resolve the forms a user types at the prompt ($env:NAME, ${env:NAME}, %NAME%, ~) to a # filesystem path for enumeration, while the typed text itself stays in the completion. param([string]$Path) if ([string]::IsNullOrEmpty($Path)) { return $Path } $expanded = [regex]::Replace($Path, '\$\{?env:([A-Za-z_][A-Za-z0-9_]*)\}?', { param($match) $value = [System.Environment]::GetEnvironmentVariable($match.Groups[1].Value) if ($null -eq $value) { $match.Value } else { $value } }) $expanded = [System.Environment]::ExpandEnvironmentVariables($expanded) if ($expanded -eq '~' -or $expanded.StartsWith('~\') -or $expanded.StartsWith('~/')) { $expanded = $HOME + $expanded.Substring(1) } $expanded } function Get-StringsPathCompletions { param( [string]$CurrentWord, [string]$ToolTip, [string]$Placeholder = '<file-or-directory>' ) $typedValue = ConvertFrom-StringsTypedWord -Value $CurrentWord $quoteChar = if ($CurrentWord -match '^[''"\u2018-\u201E]') { $CurrentWord.Substring(0, 1) } else { '' } $results = New-Object System.Collections.Generic.List[object] # Split on the typed text (so the completion keeps the user's prefix verbatim) and # enumerate the expanded form. $typedParent = '' $leaf = $typedValue $separatorIndex = $typedValue.LastIndexOfAny([char[]]@('\', '/')) if ($separatorIndex -ge 0) { $typedParent = $typedValue.Substring(0, $separatorIndex + 1) $leaf = $typedValue.Substring($separatorIndex + 1) } # Single quotes keep $env: literal, so only an unquoted or double-quoted parent expands it. # A leading ~ resolves to the home folder in every quote style, as PowerShell's own path # completion does; quoted candidates then spell out the expanded parent. $expandedParent = if ($quoteChar -notmatch '^[''\u2018-\u201B]$') { Expand-StringsPathText -Path $typedParent } elseif ($typedParent -match '^~[\\/]') { $HOME + $typedParent.Substring(1) } else { $typedParent } $enumeratePath = if ([string]::IsNullOrEmpty($typedParent)) { '.' } else { $expandedParent } $items = @() try { $items = @(Get-ChildItem -LiteralPath $enumeratePath -ErrorAction Ignore) } catch { Write-Debug "strings path enumeration failed for '$enumeratePath': $($_.Exception.Message)" } foreach ($item in $items) { if (-not [string]::IsNullOrWhiteSpace($leaf) -and -not $item.Name.StartsWith($leaf, [System.StringComparison]::OrdinalIgnoreCase)) { continue } $name = $item.Name if ($item.PSIsContainer) { $name += '\' } $parent = $typedParent $valueParent = $expandedParent if (-not $typedParent -and $name -match '^[-\u2013-\u2015]') { # A name starting with a dash gets the current-directory prefix, as PowerShell's # own file completion does, so it cannot be read as a parameter. $parent = $valueParent = '.' + [System.IO.Path]::DirectorySeparatorChar } $candidate = $parent + $name # A bare $env:/~ prefix stays live while the name needs no quoting; a quoted word # would not expand it, so quoted candidates spell out the expanded parent instead. if (-not $quoteChar -and $parent -ne $valueParent -and (ConvertTo-StringsQuotedValue -Value $name) -eq $name) { $completionText = $candidate } else { $completionText = ConvertTo-StringsQuotedValue -Value ($valueParent + $name) -QuoteChar $quoteChar } [void]$results.Add((New-StringsCompletionResult -CompletionText $completionText -ListItemText $candidate -ResultType 'ParameterValue' -ToolTip $ToolTip)) } # With nothing typed, the placeholder keeps the slot visible; with a typed value that # matches nothing, return nothing so PowerShell's own path completion can take over. if ($results.Count -eq 0 -and [string]::IsNullOrWhiteSpace($CurrentWord)) { [void]$results.Add((New-StringsCompletionResult -CompletionText $Placeholder -ListItemText $Placeholder -ResultType 'ParameterValue' -ToolTip $ToolTip)) } @($results.ToArray()) } function Get-StringsCommandState { param([string[]]$ArgumentsBeforeCurrent) $usedTokens = @{} $valueContext = $null $positionals = New-Object System.Collections.Generic.List[string] for ($index = 0; $index -lt $ArgumentsBeforeCurrent.Count; $index++) { $token = $ArgumentsBeforeCurrent[$index] if ([string]::IsNullOrWhiteSpace($token)) { continue } $lookup = $token.ToLowerInvariant() $usedTokens[$lookup] = $true switch ($lookup) { '-b' { if ($index -eq ($ArgumentsBeforeCurrent.Count - 1)) { $valueContext = 'Bytes' break } $index++ continue } '-f' { if ($index -eq ($ArgumentsBeforeCurrent.Count - 1)) { $valueContext = 'Offset' break } $index++ continue } '-n' { if ($index -eq ($ArgumentsBeforeCurrent.Count - 1)) { $valueContext = 'Length' break } $index++ continue } default { if ($lookup.StartsWith('-') -or $lookup.StartsWith('/')) { continue } $positionals.Add($token) } } } [pscustomobject]@{ UsedTokens = $usedTokens ValueContext = $valueContext Positionals = @($positionals.ToArray()) } } function Complete-Strings { param( [string]$WordToComplete, [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) $line = if ($CommandAst.Extent -and $null -ne $CommandAst.Extent.Text) { $CommandAst.Extent.Text } else { $CommandAst.ToString() } # $CursorPosition is line-absolute; the extent text is command-relative. $relativeCursor = [Math]::Max(0, $CursorPosition - $CommandAst.Extent.StartOffset) if ($relativeCursor -gt $line.Length) { $line = $line.PadRight($relativeCursor) } $tokenState = Get-StringsTokenState -Line $line -CursorPosition $relativeCursor $argumentsState = Get-StringsArgumentsFromTokenState -TokenState $tokenState $state = Get-StringsCommandState -ArgumentsBeforeCurrent $argumentsState.ArgumentsBeforeCurrent $currentWord = Get-StringsCurrentWord -CommandAst $CommandAst -CursorPosition $CursorPosition switch ($state.ValueContext) { 'Bytes' { return Get-StringsStaticValueResults -CurrentWord $currentWord -Values $script:StringsCompletionCatalog.ByteHints -ToolTip 'Bytes of file to scan.' } 'Offset' { return Get-StringsStaticValueResults -CurrentWord $currentWord -Values $script:StringsCompletionCatalog.OffsetHints -ToolTip 'File offset at which to start scanning.' } 'Length' { return Get-StringsStaticValueResults -CurrentWord $currentWord -Values $script:StringsCompletionCatalog.LengthHints -ToolTip 'Minimum string length.' } } $results = New-Object System.Collections.Generic.List[object] $wantsSwitches = [string]::IsNullOrEmpty($currentWord) -or $currentWord.StartsWith('-') -or $currentWord.StartsWith('/') if ($wantsSwitches) { foreach ($switchSpec in $script:StringsCompletionCatalog.Switches) { if (-not $switchSpec.Token.StartsWith($currentWord, [System.StringComparison]::OrdinalIgnoreCase)) { continue } if ($state.UsedTokens.ContainsKey($switchSpec.Token.ToLowerInvariant()) -and $switchSpec.Token -notin @('-?', '/?')) { continue } [void]$results.Add((New-StringsCompletionResult -CompletionText $switchSpec.Token -ListItemText $switchSpec.Token -ResultType 'ParameterName' -ToolTip $switchSpec.Description)) } } if (-not $currentWord.StartsWith('-') -and -not $currentWord.StartsWith('/')) { foreach ($item in (Get-StringsPathCompletions -CurrentWord $currentWord -ToolTip 'File or directory to scan for strings.' -Placeholder '<file-or-directory>')) { [void]$results.Add($item) } } Get-StringsUniqueCompletions -Results @($results.ToArray()) } Register-ArgumentCompleter -Native -CommandName @('strings', 'strings.exe') -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) Complete-Strings -WordToComplete $wordToComplete -CommandAst $commandAst -CursorPosition $cursorPosition } |