completers/printf_completer/printf_completer.ps1
|
# printf tab completion for PowerShell # Static option completion for printf.exe and printf. Set-StrictMode -Version 2.0 function Get-PrintfCompletionOptions { $cache = Get-Variable -Name 'PrintfCompletionOptions' -Scope Script -ErrorAction Ignore if ($null -ne $cache -and $null -ne $cache.Value) { return $cache.Value } $fallbackOptions = @('--help', '--version') $commandCandidates = @('printf.exe', 'printf') foreach ($candidate in $commandCandidates) { $command = Get-Command -Name $candidate -ErrorAction Ignore if ($null -eq $command) { continue } try { $helpOutput = $null | & $command.Source --help 2>&1 | ForEach-Object { $_ -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' } | Out-String } catch { continue } if ([string]::IsNullOrWhiteSpace($helpOutput)) { continue } # Only the indented option column counts, and the scan stops at the first # unindented paragraph after it, so dash words in prose ('-max places ...', # 'ls --quoting=shell-escape') are never taken for options. $options = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) $descriptions = [System.Collections.Hashtable]::new([System.StringComparer]::Ordinal) $optionPattern = '^\s{2,}(?<opts>-{1,2}[A-Za-z0-9][A-Za-z0-9-]*(?:[=\[]\S*)?(?:,\s*-{1,2}[A-Za-z0-9][A-Za-z0-9-]*(?:[=\[]\S*)?)*)(?:\s{2,}(?<desc>\S.*?))?\s*$' foreach ($line in ([regex]::Split($helpOutput, '\r?\n'))) { if ($options.Count -gt 0 -and $line -match '^\S') { break } $match = [regex]::Match($line, $optionPattern) if (-not $match.Success) { continue } foreach ($rawOption in $match.Groups['opts'].Value.Split(',')) { $normalized = ($rawOption.Trim() -replace '[=\[].*$', '') [void]$options.Add($normalized) if ($match.Groups['desc'].Success -and -not $descriptions.ContainsKey($normalized)) { $descriptions[$normalized] = $match.Groups['desc'].Value } } } if ($options.Count -gt 0) { foreach ($option in $fallbackOptions) { [void]$options.Add($option) } Set-Variable -Name 'PrintfFormatCatalog' -Value (Get-PrintfHelpFormatCatalog -HelpText $helpOutput) -Scope Script Set-Variable -Name 'PrintfCompletionOptions' -Value (@($options | Sort-Object)) -Scope Script Set-Variable -Name 'PrintfCompletionDescriptions' -Value $descriptions -Scope Script return (Get-Variable -Name 'PrintfCompletionOptions' -Scope Script).Value } } Set-Variable -Name 'PrintfFormatCatalog' -Value (Get-PrintfHelpFormatCatalog -HelpText '') -Scope Script Set-Variable -Name 'PrintfCompletionOptions' -Value $fallbackOptions -Scope Script return (Get-Variable -Name 'PrintfCompletionOptions' -Scope Script).Value } function Get-PrintfHelpFormatCatalog { param([string]$HelpText) # GNU prints the interpreted sequences and %%/%b/%q as a two-column list and the # remaining conversions as 'ending with one of diouxXfeEgGcs'. Other builds (uutils) # use a different layout, so each half falls back to the static GNU table. $staticEscapes = @( @('\"', 'double quote'), @('\\', 'backslash'), @('\a', 'alert (BEL)'), @('\b', 'backspace'), @('\c', 'produce no further output'), @('\e', 'escape'), @('\f', 'form feed'), @('\n', 'new line'), @('\r', 'carriage return'), @('\t', 'horizontal tab'), @('\v', 'vertical tab'), @('\xHH', 'byte with hexadecimal value HH (1 to 2 digits)'), @('\uHHHH', 'Unicode (ISO/IEC 10646) character with hex value HHHH (4 digits)'), @('\UHHHHHHHH', 'Unicode character with hex value HHHHHHHH (8 digits)') ) $staticBareFields = @( @('%%', 'a single %'), @('%b', 'ARGUMENT as a string with ''\'' escapes interpreted'), @('%q', 'ARGUMENT is printed in a format that can be reused as shell input') ) $conversionDescriptions = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) $conversionDescriptions['d'] = 'ARGUMENT as a signed decimal integer' $conversionDescriptions['i'] = 'ARGUMENT as a signed decimal integer' $conversionDescriptions['o'] = 'ARGUMENT as an unsigned octal integer' $conversionDescriptions['u'] = 'ARGUMENT as an unsigned decimal integer' $conversionDescriptions['x'] = 'ARGUMENT as an unsigned hexadecimal integer (lowercase)' $conversionDescriptions['X'] = 'ARGUMENT as an unsigned hexadecimal integer (uppercase)' $conversionDescriptions['f'] = 'ARGUMENT as a decimal floating point number' $conversionDescriptions['e'] = 'ARGUMENT in scientific notation (lowercase e)' $conversionDescriptions['E'] = 'ARGUMENT in scientific notation (uppercase E)' $conversionDescriptions['g'] = 'ARGUMENT as the shorter of %f and %e' $conversionDescriptions['G'] = 'ARGUMENT as the shorter of %f and %E' $conversionDescriptions['c'] = 'first character of ARGUMENT' $conversionDescriptions['s'] = 'ARGUMENT as a string' $conversionLetters = 'diouxXfeEgGcs' $escapes = [System.Collections.Generic.List[object]]::new() $bareFields = [System.Collections.Generic.List[object]]::new() $last = $null foreach ($line in ([regex]::Split($HelpText, '\r?\n'))) { $entry = [regex]::Match($line, '^\s{2}(?<key>\\\S+|%\S)\s+(?<desc>\S.*?)\s*$') if ($entry.Success) { $last = [pscustomobject]@{ Key = $entry.Groups['key'].Value; Description = $entry.Groups['desc'].Value } if ($last.Key.StartsWith('\')) { $escapes.Add($last) } else { $bareFields.Add($last) } continue } if ($null -ne $last -and $line -match '^\s{4,}(\S.*?)\s*$') { $last.Description = $last.Description.TrimEnd(',') + ' ' + $Matches[1] continue } $last = $null $letters = [regex]::Match($line, 'ending with one of\s+(?<letters>[A-Za-z]+)') if ($letters.Success) { $conversionLetters = $letters.Groups['letters'].Value } } if ($escapes.Count -eq 0) { foreach ($pair in $staticEscapes) { $escapes.Add([pscustomobject]@{ Key = $pair[0]; Description = $pair[1] }) } } if ($bareFields.Count -eq 0) { foreach ($pair in $staticBareFields) { $bareFields.Add([pscustomobject]@{ Key = $pair[0]; Description = $pair[1] }) } } # Text inserted after the trailing backslash: the literal part of each sequence. # \NNN is all placeholder, so it has nothing to insert and is not offered. $escapeCandidates = foreach ($escape in $escapes) { $insert = if ($escape.Key -cmatch '^\\(.)$') { $Matches[1] } elseif ($escape.Key -cmatch '^\\([xuU])H+$') { $Matches[1] } else { $null } if ($null -ne $insert) { [pscustomobject]@{ ListItem = $escape.Key; Insert = $insert; Description = $escape.Description } } } # %%, %b and %q reject flags, width and precision; the C conversions accept them. $fieldCandidates = @( foreach ($field in $bareFields) { [pscustomobject]@{ ListItem = $field.Key; Insert = $field.Key.Substring(1); Description = $field.Description; BareOnly = $true } } foreach ($letter in $conversionLetters.ToCharArray()) { $key = [string]$letter $description = if ($conversionDescriptions.ContainsKey($key)) { $conversionDescriptions[$key] } else { 'C format specification' } [pscustomobject]@{ ListItem = '%' + $key; Insert = $key; Description = $description; BareOnly = $false } } ) [pscustomobject]@{ Escapes = @($escapeCandidates) Fields = $fieldCandidates } } function Get-PrintfFormatCatalog { $cache = Get-Variable -Name 'PrintfFormatCatalog' -Scope Script -ErrorAction Ignore if ($null -eq $cache -or $null -eq $cache.Value) { [void](Get-PrintfCompletionOptions) $cache = Get-Variable -Name 'PrintfFormatCatalog' -Scope Script -ErrorAction Ignore } $cache.Value } function New-PrintfCompletionResult { 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 ConvertTo-PrintfQuotedValue { param( [string]$Value, [string]$Quote = '' ) if ([string]::IsNullOrWhiteSpace($Value)) { return $Value } # Keep the quote the user typed; otherwise single-quote anything argument mode # would split or expand. PowerShell reads the typographic quotes as quotes too, so # they are escaped (U+201C-U+201E) or doubled (U+2018-U+201B) like their ASCII forms. if ($Quote -eq '"') { return '"' + ($Value -replace '([`"$\u201C-\u201E])', '`$1') + '"' } if ($Quote -eq "'" -or $Value -match '[\s{}();,|&<>''"`$\u2018-\u201E]' -or $Value -match '^[@#]') { return "'" + ($Value -replace '([''\u2018-\u201B])', '$1$1') + "'" } $Value } function Get-PrintfCurrentToken { param( [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) # The parser keeps an unterminated quote as one element running to the cursor, # so '"C:\Program Fi' is one word, not 'Fi'. No element under the cursor means a # new, empty word. foreach ($element in $CommandAst.CommandElements) { if ($CursorPosition -gt $element.Extent.StartOffset -and $CursorPosition -le $element.Extent.EndOffset) { return $element.Extent.Text.Substring(0, $CursorPosition - $element.Extent.StartOffset) } } '' } function Get-PrintfPathCompletions { param([string]$InputPath) # Read the typed word through the parser: an opening ASCII or typographic quote is # recognised, and doubled quotes and backtick escapes resolve to the real name. $tokens = $null $errors = $null [void][System.Management.Automation.Language.Parser]::ParseInput('x ' + $InputPath, [ref]$tokens, [ref]$errors) if ($tokens.Count -lt 2 -or $tokens[1].Extent.EndOffset -ne ($InputPath.Length + 2)) { return @() } $cleanInput = if ($tokens[1] -is [System.Management.Automation.Language.StringToken]) { $tokens[1].Value } else { $tokens[1].Text } $quote = switch ([string]$tokens[1].Kind) { 'StringLiteral' { "'" } 'StringExpandable' { '"' } default { '' } } # The typed directory part (through the last separator) is kept verbatim, so a # typed .\ or ./ prefix and the user's separator style survive. $prefix = $cleanInput.Substring(0, $cleanInput.LastIndexOfAny([char[]]'\/:') + 1) $leaf = $cleanInput.Substring($prefix.Length) $parent = if ($prefix) { $prefix } else { '.' } if (-not (Test-Path -LiteralPath $parent -PathType Container)) { return @() } $items = @(Get-ChildItem -LiteralPath $parent -ErrorAction Ignore) $items = $items | Where-Object { $_.Name -like ([System.Management.Automation.WildcardPattern]::Escape($leaf) + '*') } | Sort-Object -Property Name foreach ($item in $items) { # A bare name starting with a dash would be read as a parameter, so it gets the # current-directory prefix, as PowerShell's own file completion does. $pathText = if (-not $prefix -and $item.Name -match '^[-\u2013-\u2015]') { '.' + [System.IO.Path]::DirectorySeparatorChar + $item.Name } else { $prefix + $item.Name } if ($item.PSIsContainer -and -not $pathText.EndsWith([System.IO.Path]::DirectorySeparatorChar)) { $pathText += [System.IO.Path]::DirectorySeparatorChar } $quotedPath = ConvertTo-PrintfQuotedValue -Value $pathText -Quote $quote if ($item.PSIsContainer) { New-PrintfCompletionResult -CompletionText $quotedPath -ListItemText $pathText -ResultType 'ProviderContainer' -ToolTip $item.FullName } else { New-PrintfCompletionResult -CompletionText $quotedPath -ListItemText $pathText -ResultType 'ProviderItem' -ToolTip $item.FullName } } } function Get-PrintfOptionDescription { param([string]$Option) $cache = Get-Variable -Name 'PrintfCompletionDescriptions' -Scope Script -ErrorAction Ignore if ($null -ne $cache -and $null -ne $cache.Value -and $cache.Value.ContainsKey($Option)) { return $cache.Value[$Option] } 'Option for printf.' } function Get-PrintfFormatContext { param( [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) # FORMAT is the first operand; GNU printf and uutils both skip a leading '--'. $elements = $CommandAst.CommandElements $formatIndex = if ($elements.Count -gt 2 -and $elements[1].Extent.Text -eq '--') { 2 } else { 1 } if ($elements.Count -le $formatIndex) { return $null } $element = $elements[$formatIndex] if ($CursorPosition -le $element.Extent.StartOffset -or $CursorPosition -gt $element.Extent.EndOffset) { return $null } # Re-parse the typed text as an argument: an unterminated quote is one token # running to the cursor, and the token value has PowerShell's escapes resolved. $typed = $element.Extent.Text.Substring(0, $CursorPosition - $element.Extent.StartOffset) $tokens = $null $errors = $null [void][System.Management.Automation.Language.Parser]::ParseInput('x ' + $typed, [ref]$tokens, [ref]$errors) if ($tokens.Count -lt 2 -or $tokens[1].Extent.StartOffset -ne 2 -or $tokens[1].Extent.EndOffset -ne ($typed.Length + 2)) { return $null } $token = $tokens[1] $tokenKind = [string]$token.Kind if ($tokenKind -notin @('Generic', 'StringLiteral', 'StringExpandable')) { return $null } # "$var %" expands at run time, so its text cannot be read as FORMAT here. if ($token -is [System.Management.Automation.Language.StringExpandableToken] -and $null -ne $token.NestedTokens -and $token.NestedTokens.Count -gt 0) { return $null } $value = if ($token -is [System.Management.Automation.Language.StringToken]) { $token.Value } else { $token.Text } $quote = '' $raw = '' if ($tokenKind -ne 'Generic') { $quote = [string]$typed[0] $raw = $typed.Substring(1) if (-not @($errors | Where-Object { $_.ErrorId -eq 'TerminatorExpectedAtEndOfString' })) { $raw = $raw.Substring(0, $raw.Length - 1) } } $pieces = @([regex]::Matches($value, '\\[\s\S]?|%(?:%|[-+ #0'']*(?:\*|[0-9]+)?(?:\.(?:\*|[0-9]*))?[\s\S]?)|[^\\%]+') | ForEach-Object { $_.Value }) if ($pieces.Count -eq 0) { return $null } $tail = $pieces[-1] $kind = if ($tail -cmatch '^%[-+ #0'']*(?:\*|[0-9]+)?(?:\.(?:\*|[0-9]*))?$') { 'Field' } elseif ($tail -eq '\') { 'Escape' } else { return $null } [pscustomobject]@{ Kind = $kind Spec = $tail TokenKind = $tokenKind Quote = $quote Raw = $raw Value = $value } } function ConvertTo-PrintfFormatArgument { param( [object]$Context, [string]$Insert ) # Keep the user's quote and typed text, escaping only what is appended; a bare word # is single-quoted once the result needs it. if ($Context.TokenKind -eq 'StringLiteral') { return $Context.Quote + $Context.Raw + ($Insert -replace '([''\u2018-\u201B])', '$1$1') + $Context.Quote } if ($Context.TokenKind -eq 'StringExpandable') { return $Context.Quote + $Context.Raw + ($Insert -replace '([`"$\u201C-\u201E])', '`$1') + $Context.Quote } ConvertTo-PrintfQuotedValue -Value ($Context.Value + $Insert) } function Get-PrintfFormatCompletion { param([object]$Context) $catalog = Get-PrintfFormatCatalog if ($Context.Kind -eq 'Escape') { foreach ($escape in $catalog.Escapes) { New-PrintfCompletionResult -CompletionText (ConvertTo-PrintfFormatArgument -Context $Context -Insert $escape.Insert) -ListItemText $escape.ListItem -ResultType 'ParameterValue' -ToolTip $escape.Description } return } $bare = $Context.Spec -eq '%' foreach ($field in $catalog.Fields) { if ($field.BareOnly -and -not $bare) { continue } New-PrintfCompletionResult -CompletionText (ConvertTo-PrintfFormatArgument -Context $Context -Insert $field.Insert) -ListItemText ($Context.Spec + $field.Insert) -ResultType 'ParameterValue' -ToolTip $field.Description } } function Complete-Printf { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'wordToComplete', Justification = 'The word is cut from the CommandAst element at the cursor; wordToComplete closes an open quote and spans past the cursor.')] param( [string]$wordToComplete, [System.Management.Automation.Language.CommandAst]$commandAst, [int]$cursorPosition ) $currentWord = Get-PrintfCurrentToken -CommandAst $commandAst -CursorPosition $cursorPosition if ([string]::IsNullOrEmpty($currentWord)) { return @() } # A FORMAT word ending in an open '%' spec gets the conversions; one ending in an # unpaired backslash gets the escapes, unless it names a directory (.\, C:\). A lone # backslash is the start of an escape, not the drive root. $format = Get-PrintfFormatContext -CommandAst $commandAst -CursorPosition $cursorPosition if ($null -ne $format -and -not ($format.Kind -eq 'Escape' -and $format.Value.Length -gt 1 -and (Test-Path -LiteralPath $format.Value -PathType Container))) { return @(Get-PrintfFormatCompletion -Context $format) } if ($currentWord.StartsWith('-')) { return @( foreach ($option in Get-PrintfCompletionOptions) { if ($option.StartsWith($currentWord, [System.StringComparison]::Ordinal)) { New-PrintfCompletionResult -CompletionText $option -ListItemText $option -ResultType 'ParameterName' -ToolTip (Get-PrintfOptionDescription -Option $option) } } ) } Get-PrintfPathCompletions -InputPath $currentWord } Register-ArgumentCompleter -Native -CommandName 'printf', 'printf.exe' -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) Complete-Printf -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition } |