completers/test_completer/test_completer.ps1
|
# test tab completion for PowerShell # Static operator completion for the GNU/coreutils test(1) binary. # # test(1) has no help output by design: POSIX requires it to treat --help and # --version as ordinary nonempty STRINGs, so both installed builds answer them # with an empty stdout and exit 0. The catalog below is transcribed from the # GNU coreutils test help printed by the sibling '[' binary and verified # against the test.exe that Get-Command resolves. Set-StrictMode -Version 2.0 function Get-TestOperatorCatalog { $cache = Get-Variable -Name 'TestOperatorCatalog' -Scope Script -ErrorAction Ignore if ($null -ne $cache -and $null -ne $cache.Value) { return $cache.Value } $catalog = @( @{ Token = '-b'; ValueKind = 'File'; Description = 'FILE exists and is block special.' } @{ Token = '-c'; ValueKind = 'File'; Description = 'FILE exists and is character special.' } @{ Token = '-d'; ValueKind = 'File'; Description = 'FILE exists and is a directory.' } @{ Token = '-e'; ValueKind = 'File'; Description = 'FILE exists.' } @{ Token = '-f'; ValueKind = 'File'; Description = 'FILE exists and is a regular file.' } @{ Token = '-g'; ValueKind = 'File'; Description = 'FILE exists and is set-group-ID.' } @{ Token = '-h'; ValueKind = 'File'; Description = 'FILE exists and is a symbolic link (same as -L).' } @{ Token = '-L'; ValueKind = 'File'; Description = 'FILE exists and is a symbolic link (same as -h).' } @{ Token = '-n'; ValueKind = 'String'; Description = 'The length of STRING is nonzero.' } @{ Token = '-p'; ValueKind = 'File'; Description = 'FILE exists and is a named pipe.' } @{ Token = '-r'; ValueKind = 'File'; Description = 'FILE exists and read permission is granted.' } @{ Token = '-s'; ValueKind = 'File'; Description = 'FILE exists and has a size greater than zero.' } @{ Token = '-S'; ValueKind = 'File'; Description = 'FILE exists and is a socket.' } @{ Token = '-t'; ValueKind = 'Fd'; Description = 'File descriptor FD is opened on a terminal.' } @{ Token = '-u'; ValueKind = 'File'; Description = 'FILE exists and its set-user-ID bit is set.' } @{ Token = '-w'; ValueKind = 'File'; Description = 'FILE exists and write permission is granted.' } @{ Token = '-x'; ValueKind = 'File'; Description = 'FILE exists and execute (or search) permission is granted.' } @{ Token = '-z'; ValueKind = 'String'; Description = 'The length of STRING is zero.' } @{ Token = '-G'; ValueKind = 'File'; Description = 'FILE exists and is owned by the effective group ID.' } @{ Token = '-k'; ValueKind = 'File'; Description = 'FILE exists and has its sticky bit set.' } @{ Token = '-N'; ValueKind = 'File'; Description = 'FILE exists and has been modified since it was last read.' } @{ Token = '-O'; ValueKind = 'File'; Description = 'FILE exists and is owned by the effective user ID.' } @{ Token = '-ef'; ValueKind = 'File'; Description = 'FILE1 and FILE2 have the same device and inode numbers.' } @{ Token = '-nt'; ValueKind = 'File'; Description = 'FILE1 is newer (modification date) than FILE2.' } @{ Token = '-ot'; ValueKind = 'File'; Description = 'FILE1 is older than FILE2.' } @{ Token = '-eq'; ValueKind = 'Integer'; Description = 'INTEGER1 is equal to INTEGER2.' } @{ Token = '-ne'; ValueKind = 'Integer'; Description = 'INTEGER1 is not equal to INTEGER2.' } @{ Token = '-ge'; ValueKind = 'Integer'; Description = 'INTEGER1 is greater than or equal to INTEGER2.' } @{ Token = '-gt'; ValueKind = 'Integer'; Description = 'INTEGER1 is greater than INTEGER2.' } @{ Token = '-le'; ValueKind = 'Integer'; Description = 'INTEGER1 is less than or equal to INTEGER2.' } @{ Token = '-lt'; ValueKind = 'Integer'; Description = 'INTEGER1 is less than INTEGER2.' } @{ Token = '-a'; ValueKind = 'Expression'; Description = 'Both EXPRESSION1 and EXPRESSION2 are true.' } @{ Token = '-o'; ValueKind = 'Expression'; Description = 'Either EXPRESSION1 or EXPRESSION2 is true.' } @{ Token = '!'; ValueKind = 'Expression'; Description = 'EXPRESSION is false.' } @{ Token = '='; ValueKind = 'String'; Description = 'The strings are equal.' } @{ Token = '!='; ValueKind = 'String'; Description = 'The strings are not equal.' } @{ Token = '('; ValueKind = 'Expression'; Description = 'Start a grouped EXPRESSION (escape it for the shell).' } @{ Token = ')'; ValueKind = 'Expression'; Description = 'End a grouped EXPRESSION (escape it for the shell).' } ) Set-Variable -Name 'TestOperatorCatalog' -Value $catalog -Scope Script $catalog } function Get-TestOperatorSpec { param([string]$Token) if ([string]::IsNullOrEmpty($Token)) { return $null } foreach ($spec in Get-TestOperatorCatalog) { if ([string]::Equals($spec.Token, $Token, [System.StringComparison]::Ordinal)) { return $spec } } $null } function New-TestCompletionResult { 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 ConvertFrom-TestTypedWord { # 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. param([string]$Value) if ($Value -notmatch '^[''"\u2018-\u201E]') { return $Value } $tokens = $null $parseErrors = $null [void][System.Management.Automation.Language.Parser]::ParseInput($Value, [ref]$tokens, [ref]$parseErrors) $tokens[0].Value } function ConvertTo-TestQuotedValue { # 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. 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-TestLineState { param( [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) # Words come from the parser, so a quoted word with spaces (even an unterminated # one, which runs to the cursor) is one word. $currentWord = '' $operands = @() foreach ($element in ($CommandAst.CommandElements | Select-Object -Skip 1)) { $extent = $element.Extent if ($extent.EndOffset -lt $CursorPosition) { $operands += $extent.Text continue } if ($extent.StartOffset -lt $CursorPosition) { $currentWord = $extent.Text.Substring(0, $CursorPosition - $extent.StartOffset) } break } [pscustomobject]@{ CurrentWord = $currentWord Operands = $operands } } function Get-TestPathCompletions { param([string]$InputPath) $cleanInput = ConvertFrom-TestTypedWord -Value $InputPath $quoteChar = if ($InputPath -match '^[''"\u2018-\u201E]') { $InputPath.Substring(0, 1) } else { '' } # The typed directory part (up to the last separator) is kept exactly as typed, # so a typed .\ or ./ prefix survives. $lastSeparator = $cleanInput.LastIndexOfAny([char[]]@('\', '/')) if ($lastSeparator -ge 0) { $directoryText = $cleanInput.Substring(0, $lastSeparator + 1) $parent = $directoryText $leaf = $cleanInput.Substring($lastSeparator + 1) } else { $directoryText = '' $parent = '.' $leaf = $cleanInput } if (-not (Test-Path -LiteralPath $parent -PathType Container -ErrorAction Ignore)) { 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 relative name starting with a dash would parse as a parameter, so it # gets the current-directory prefix, as PowerShell's own file completion does. $pathText = if ($directoryText -eq '' -and $item.Name -match '^[-\u2013-\u2015]') { '.' + [System.IO.Path]::DirectorySeparatorChar + $item.Name } else { $directoryText + $item.Name } if ($item.PSIsContainer -and -not $pathText.EndsWith([System.IO.Path]::DirectorySeparatorChar)) { $pathText += [System.IO.Path]::DirectorySeparatorChar } $completionText = ConvertTo-TestQuotedValue -Value $pathText -QuoteChar $quoteChar if ($item.PSIsContainer) { New-TestCompletionResult -CompletionText $completionText -ListItemText $pathText -ResultType 'ProviderContainer' -ToolTip $item.FullName } else { New-TestCompletionResult -CompletionText $completionText -ListItemText $pathText -ResultType 'ProviderItem' -ToolTip $item.FullName } } } function Get-TestOperandValueCompletions { param( [string]$ValueKind, [string]$CurrentWord ) $values = switch ($ValueKind) { 'Fd' { @( @{ Text = '0'; ToolTip = 'Standard input is opened on a terminal.' } @{ Text = '1'; ToolTip = 'Standard output is opened on a terminal.' } @{ Text = '2'; ToolTip = 'Standard error is opened on a terminal.' } @{ Text = '<fd>'; ToolTip = 'File descriptor number.' } ) } 'Integer' { @(@{ Text = '<integer>'; ToolTip = 'INTEGER operand.' }) } 'String' { @(@{ Text = '<string>'; ToolTip = 'STRING operand.' }) } default { @() } } foreach ($value in $values) { if (-not [string]::IsNullOrEmpty($CurrentWord) -and -not $value.Text.StartsWith($CurrentWord, [System.StringComparison]::Ordinal)) { continue } New-TestCompletionResult -CompletionText $value.Text -ListItemText $value.Text -ResultType 'ParameterValue' -ToolTip $value.ToolTip } } function Complete-Test { param( [string]$wordToComplete, [System.Management.Automation.Language.CommandAst]$commandAst, [int]$cursorPosition ) $state = Get-TestLineState -CommandAst $commandAst -CursorPosition $cursorPosition $currentWord = $state.CurrentWord $operands = @($state.Operands) # An operator that takes a typed (non-path) operand owns the slot after it. if ($operands.Count -gt 0) { $previous = Get-TestOperatorSpec -Token $operands[-1] if ($null -ne $previous -and $previous.ValueKind -in @('Fd', 'Integer', 'String')) { return @(Get-TestOperandValueCompletions -ValueKind $previous.ValueKind -CurrentWord $currentWord) } } if ([string]::IsNullOrEmpty($currentWord)) { return @() } if ($currentWord.StartsWith('-')) { return @( foreach ($spec in Get-TestOperatorCatalog) { if ($spec.Token.StartsWith('-') -and $spec.Token.StartsWith($currentWord, [System.StringComparison]::Ordinal)) { New-TestCompletionResult -CompletionText $spec.Token -ListItemText $spec.Token -ResultType 'ParameterName' -ToolTip $spec.Description } } ) } # '!', '=', '!=', '(' and ')' are operators too, and never start with '-'. # '(' and ')' only reach a native completer in their quoted form, because # PowerShell parses a bare parenthesis as a sub-expression, so the quoted # spelling is echoed back when the typed word is quoted. $bareWord = ConvertFrom-TestTypedWord -Value $currentWord if (-not [string]::IsNullOrEmpty($bareWord)) { $quoteChar = if ($currentWord -match '^[''"\u2018-\u201E]') { $currentWord.Substring(0, 1) } else { '' } $operatorMatches = @( foreach ($spec in Get-TestOperatorCatalog) { if ($spec.Token.StartsWith('-')) { continue } if (-not $spec.Token.StartsWith($bareWord, [System.StringComparison]::Ordinal)) { continue } $text = ConvertTo-TestQuotedValue -Value $spec.Token -QuoteChar $quoteChar New-TestCompletionResult -CompletionText $text -ListItemText $spec.Token -ResultType 'ParameterName' -ToolTip $spec.Description } ) if ($operatorMatches.Count -gt 0) { return $operatorMatches } } Get-TestPathCompletions -InputPath $currentWord } Register-ArgumentCompleter -Native -CommandName 'test', 'test.exe' -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) Complete-Test -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition } |