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 Remove-TestOuterQuotes {
    param([string]$Value)

    if ($null -eq $Value) {
        return ''
    }

    $Value.Trim([char[]]@([char]34, [char]39))
}

function ConvertTo-TestQuotedValue {
    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-TestLineState {
    param(
        [System.Management.Automation.Language.CommandAst]$CommandAst,
        [string]$WordToComplete,
        [int]$CursorPosition
    )

    $line = $CommandAst.Extent.Text
    $relative = $CursorPosition - $CommandAst.Extent.StartOffset
    $safeCursor = [Math]::Min([Math]::Max($relative, 0), $line.Length)
    $prefix = $line.Substring(0, $safeCursor)
    $parts = @([regex]::Matches($prefix, '"[^"]*"|''[^'']*''|\S+') | ForEach-Object { $_.Value })

    $currentWord = ''
    if ($CursorPosition -le $CommandAst.Extent.EndOffset -and $prefix -notmatch '\s$') {
        if ($parts.Count -gt 0) {
            $currentWord = $parts[-1]
            $parts = @($parts | Select-Object -First ($parts.Count - 1))
        } else {
            $currentWord = $WordToComplete
        }
    }

    $operands = @()
    if ($parts.Count -gt 1) {
        $operands = @($parts | Select-Object -Skip 1)
    }

    [pscustomobject]@{
        CurrentWord = $currentWord
        Operands    = $operands
    }
}

function Get-TestPathParent {
    param([string]$Candidate)

    if ([string]::IsNullOrWhiteSpace($Candidate)) {
        return '.'
    }

    if ($Candidate -match '[\\/]+$') {
        return $Candidate
    }

    $parent = Split-Path -Path $Candidate -Parent
    if ([string]::IsNullOrWhiteSpace($parent)) {
        return '.'
    }

    $parent
}

function Test-TestPathContainer {
    param([string]$Candidate)

    if ([string]::IsNullOrWhiteSpace($Candidate)) {
        return $false
    }

    Test-Path -LiteralPath $Candidate -PathType Container
}

function Get-TestPathCompletions {
    param(
        [string]$InputPath,
        [string[]]$Operands = @()
    )

    $cleanInput = Remove-TestOuterQuotes -Value $InputPath
    $alwaysQuote = -not [string]::IsNullOrEmpty($InputPath) -and ($InputPath.StartsWith('"') -or $InputPath.StartsWith("'"))

    # An unquoted path containing a space arrives split across several words.
    # Walk left over the plain operands already on the line, rejoining them
    # until the candidate resolves to a real directory; the line prefix that
    # was rejoined is then stripped back off every completion, because the
    # engine only replaces the last whitespace-delimited fragment.
    $linePrefix = ''
    if (-not $alwaysQuote -and -not (Test-TestPathContainer -Candidate (Get-TestPathParent -Candidate $cleanInput))) {
        for ($i = $Operands.Count - 1; $i -ge 0; $i--) {
            $fragment = $Operands[$i]
            if ($fragment.StartsWith('-') -or $null -ne (Get-TestOperatorSpec -Token $fragment)) { break }
            $candidatePrefix = ((@($Operands | Select-Object -Skip $i)) -join ' ') + ' '
            $candidate = $candidatePrefix + $cleanInput
            if (Test-TestPathContainer -Candidate (Get-TestPathParent -Candidate $candidate)) {
                $linePrefix = $candidatePrefix
                $cleanInput = $candidate
                break
            }
        }
    }

    if ([string]::IsNullOrWhiteSpace($cleanInput)) {
        $parent = '.'
        $leaf = ''
    } elseif ($cleanInput -match '[\\/]+$') {
        $parent = $cleanInput
        $leaf = ''
    } else {
        $parent = Split-Path -Path $cleanInput -Parent
        if ([string]::IsNullOrWhiteSpace($parent)) {
            $parent = '.'
        }

        $leaf = Split-Path -Path $cleanInput -Leaf
    }

    if (-not (Test-Path -LiteralPath $parent -PathType Container)) {
        return @()
    }

    $items = @(Get-ChildItem -LiteralPath $parent -ErrorAction SilentlyContinue)
    $items = @($items | Where-Object { $_.Name -like ([System.Management.Automation.WildcardPattern]::Escape($leaf) + '*') } | Sort-Object -Property Name)

    foreach ($item in $items) {
        $pathText = if ($parent -eq '.' -or [string]::IsNullOrWhiteSpace($cleanInput)) {
            $item.Name
        } else {
            Join-Path -Path $parent -ChildPath $item.Name
        }

        if ($item.PSIsContainer -and -not $pathText.EndsWith([System.IO.Path]::DirectorySeparatorChar)) {
            $pathText += [System.IO.Path]::DirectorySeparatorChar
        }

        $completionText = if ($linePrefix) {
            $pathText.Substring($linePrefix.Length)
        } else {
            ConvertTo-TestQuotedValue -Value $pathText -AlwaysQuote $alwaysQuote
        }

        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 -WordToComplete $wordToComplete -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 = Remove-TestOuterQuotes -Value $currentWord
    if (-not [string]::IsNullOrEmpty($bareWord)) {
        $quoted = $bareWord -ne $currentWord
        $operatorMatches = @(
            foreach ($spec in Get-TestOperatorCatalog) {
                if ($spec.Token.StartsWith('-')) { continue }
                if (-not $spec.Token.StartsWith($bareWord, [System.StringComparison]::Ordinal)) { continue }
                $text = if ($quoted) { "'" + $spec.Token + "'" } else { $spec.Token }
                New-TestCompletionResult -CompletionText $text -ListItemText $spec.Token -ResultType 'ParameterName' -ToolTip $spec.Description
            }
        )
        if ($operatorMatches.Count -gt 0) {
            return $operatorMatches
        }
    }

    Get-TestPathCompletions -InputPath $currentWord -Operands $operands
}

Register-ArgumentCompleter -Native -CommandName 'test', 'test.exe' -ScriptBlock {
    param($wordToComplete, $commandAst, $cursorPosition)

    Complete-Test -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition
}