completers/where_completer/where_completer.ps1

# where.exe tab completion for PowerShell
# Static switch catalog seeded from the documented where.exe /? surface, enriched from live help.
# Usage: . .\where_completer.ps1
# Note: PowerShell's built-in "where" alias resolves to Where-Object, so completion needs the where.exe spelling.

Set-StrictMode -Version 2.0

if (-not (Get-Variable -Name WhereCompletionCatalog -Scope Script -ErrorAction Ignore)) {
    $script:WhereCompletionCatalog = @{
        Initialized     = $false
        Switches        = @()
        PathOptions     = @('/R')
        ExecutablePath  = $null
        Executables     = @()
    }
}

function Get-WhereStaticSwitchTable {
    @(
        [pscustomobject]@{ Token = '/R'; Description = 'Recursively searches and displays the files that match the given pattern starting from the specified directory.' }
        [pscustomobject]@{ Token = '/Q'; Description = 'Returns only the exit code, without displaying the list of matched files. (Quiet mode)' }
        [pscustomobject]@{ Token = '/F'; Description = 'Displays the matched filename in double quotes.' }
        [pscustomobject]@{ Token = '/T'; Description = 'Displays the file size, last modified date and time for all matched files.' }
        [pscustomobject]@{ Token = '/?'; Description = 'Displays this help message.' }
    )
}

function Invoke-WhereHelpText {
    if (-not (Get-Command -Name where.exe -CommandType Application -ErrorAction SilentlyContinue)) {
        return @()
    }

    try {
        @($null | & where.exe '/?' 2>$null)
    } catch {
        @()
    }
}

function Get-WhereSwitchTokensFromLines {
    param([string[]]$Lines)

    $tokens = foreach ($line in @($Lines)) {
        foreach ($match in [regex]::Matches($line, '(?<!\w)(/(?:\?|[A-Za-z][A-Za-z0-9]*):?)(?=[\s\]\}\|,]|$|<)')) {
            $match.Groups[1].Value
        }
    }

    @($tokens | Sort-Object -Unique)
}

function New-WhereCompletionResult {
    param(
        [string]$CompletionText,
        [string]$ResultType,
        [string]$ToolTip
    )

    if ([string]::IsNullOrWhiteSpace($ToolTip)) {
        $ToolTip = $CompletionText
    }

    [System.Management.Automation.CompletionResult]::new(
        $CompletionText,
        $CompletionText,
        $ResultType,
        $ToolTip
    )
}

function Get-WhereCurrentToken {
    param(
        [string]$Line,
        [int]$CursorPosition,
        [string]$Fallback
    )

    if ([string]::IsNullOrWhiteSpace($Line)) {
        return $Fallback
    }

    if ($CursorPosition -gt $Line.Length) {
        return ''
    }

    $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-WhereExpectedValueOption {
    param([string[]]$TokensBeforeCurrent)

    if (-not $TokensBeforeCurrent -or $TokensBeforeCurrent.Count -eq 0) {
        return $null
    }

    $lastToken = $TokensBeforeCurrent[$TokensBeforeCurrent.Count - 1]
    if ($lastToken.Equals('/R', [System.StringComparison]::OrdinalIgnoreCase)) {
        return '/R'
    }

    $null
}

function ConvertTo-WhereQuotedText {
    param(
        [string]$Text,
        [bool]$AlwaysQuote
    )

    if (($AlwaysQuote -or $Text -match '\s') -and -not ($Text.StartsWith('"') -and $Text.EndsWith('"'))) {
        return '"' + $Text + '"'
    }

    $Text
}

function Get-WherePathCompletions {
    param([string]$InputPath)

    # /R takes a directory; keep whatever prefix the user typed (relative stays relative, C:\ stays C:\).
    $alwaysQuote = -not [string]::IsNullOrEmpty($InputPath) -and $InputPath.StartsWith('"')
    $cleanInput = if ([string]::IsNullOrWhiteSpace($InputPath)) { '' } else { $InputPath.Trim('"') }

    $separatorIndex = $cleanInput.LastIndexOfAny([char[]]@('\', '/'))
    if ($separatorIndex -ge 0) {
        $parentText = $cleanInput.Substring(0, $separatorIndex + 1)
        $leaf = $cleanInput.Substring($separatorIndex + 1)
    } else {
        $parentText = ''
        $leaf = $cleanInput
    }

    $parent = if ([string]::IsNullOrEmpty($parentText)) { '.' } else { $parentText }
    $pattern = [System.Management.Automation.WildcardPattern]::Escape($leaf) + '*'

    $directories = @(Get-ChildItem -LiteralPath $parent -Directory -ErrorAction SilentlyContinue |
        Where-Object { $_.Name -like $pattern } |
        Sort-Object -Property Name)

    if ($directories.Count -eq 0) {
        return @(New-WhereCompletionResult -CompletionText '<directory>' -ResultType 'ParameterValue' -ToolTip 'Directory to start the recursive search from.')
    }

    foreach ($directory in $directories) {
        $completionText = $parentText + $directory.Name + [System.IO.Path]::DirectorySeparatorChar
        New-WhereCompletionResult -CompletionText (ConvertTo-WhereQuotedText -Text $completionText -AlwaysQuote $alwaysQuote) -ResultType 'ParameterValue' -ToolTip $directory.FullName
    }
}

function Get-WherePathExtensionList {
    $extensions = @(([string]$env:PATHEXT).Split(';') |
        ForEach-Object { $_.Trim().ToLowerInvariant() } |
        Where-Object { $_ -match '^\.[a-z0-9]+$' })

    if ($extensions.Count -eq 0) {
        $extensions = @('.com', '.exe', '.bat', '.cmd')
    }

    $extensions
}

function Get-WhereExecutableNameList {
    # Names where.exe can resolve: files on %PATH% whose extension is in %PATHEXT%, cached per PATH value.
    $pathValue = [string]$env:PATH
    if ($script:WhereCompletionCatalog.ExecutablePath -eq $pathValue -and $script:WhereCompletionCatalog.Executables.Count -gt 0) {
        return $script:WhereCompletionCatalog.Executables
    }

    $extensions = Get-WherePathExtensionList
    $names = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($entry in @($pathValue.Split(';'))) {
        $directory = [System.Environment]::ExpandEnvironmentVariables($entry.Trim().Trim('"'))
        if ([string]::IsNullOrWhiteSpace($directory) -or -not [System.IO.Directory]::Exists($directory)) {
            continue
        }

        try {
            foreach ($file in [System.IO.Directory]::EnumerateFiles($directory)) {
                $extension = [System.IO.Path]::GetExtension($file).ToLowerInvariant()
                if ($extensions -contains $extension) {
                    [void]$names.Add([System.IO.Path]::GetFileName($file))
                }
            }
        } catch {
            continue
        }
    }

    $script:WhereCompletionCatalog.Executables = @($names | Sort-Object)
    $script:WhereCompletionCatalog.ExecutablePath = $pathValue
    $script:WhereCompletionCatalog.Executables
}

function Get-WhereOperandCompletion {
    param([string]$CurrentWord)

    $cleanWord = if ([string]::IsNullOrEmpty($CurrentWord)) { '' } else { $CurrentWord.Trim('"') }

    # "$env:pattern", "path:pattern" and explicit paths are left to PowerShell's own completion.
    if ($cleanWord -match '[\\/:$]') {
        return @()
    }

    $results = New-Object System.Collections.Generic.List[object]

    # PATHEXT patterns plus *.dll, the pattern where.exe's own help uses as its example.
    foreach ($extension in @(Get-WherePathExtensionList) + @('.dll')) {
        $wildcard = '*' + $extension
        if ([string]::IsNullOrEmpty($cleanWord) -or $wildcard.StartsWith($cleanWord, [System.StringComparison]::OrdinalIgnoreCase)) {
            [void]$results.Add((New-WhereCompletionResult -CompletionText $wildcard -ResultType 'ParameterValue' -ToolTip "Every $extension file on the search path."))
        }
    }

    if (-not [string]::IsNullOrEmpty($cleanWord) -and -not $cleanWord.StartsWith('*')) {
        foreach ($name in Get-WhereExecutableNameList) {
            if ($name.StartsWith($cleanWord, [System.StringComparison]::OrdinalIgnoreCase)) {
                [void]$results.Add((New-WhereCompletionResult -CompletionText $name -ResultType 'ParameterValue' -ToolTip 'Executable found on %PATH%.'))
            }
        }
    }

    @($results.ToArray())
}

function Initialize-WhereCompletion {
    if ($script:WhereCompletionCatalog.Initialized) {
        return
    }

    # The documented surface is always available; live help can only add to it.
    $switches = New-Object System.Collections.Generic.List[object]
    foreach ($switch in Get-WhereStaticSwitchTable) {
        [void]$switches.Add($switch)
    }

    foreach ($token in Get-WhereSwitchTokensFromLines -Lines (Invoke-WhereHelpText)) {
        $known = $false
        foreach ($switch in $switches) {
            if ($switch.Token.Equals($token, [System.StringComparison]::OrdinalIgnoreCase)) {
                $known = $true
                break
            }
        }

        if (-not $known) {
            [void]$switches.Add([pscustomobject]@{ Token = $token; Description = "where.exe switch $token" })
        }
    }

    $script:WhereCompletionCatalog.Switches = @($switches.ToArray())
    $script:WhereCompletionCatalog.Initialized = $true
}

function Get-WhereSwitchCompletion {
    param([string]$CurrentWord)

    $pattern = if ([string]::IsNullOrEmpty($CurrentWord)) { '*' } else { [System.Management.Automation.WildcardPattern]::Escape($CurrentWord) + '*' }

    foreach ($switch in @($script:WhereCompletionCatalog.Switches)) {
        if ($switch.Token -like $pattern) {
            New-WhereCompletionResult -CompletionText $switch.Token -ResultType 'ParameterName' -ToolTip $switch.Description
        }
    }
}

function Complete-Where {
    param(
        [string]$wordToComplete,
        [System.Management.Automation.Language.CommandAst]$commandAst,
        [int]$cursorPosition
    )

    Initialize-WhereCompletion

    $allTokens = @($commandAst.CommandElements | ForEach-Object { $_.Extent.Text })
    $tokens = @($allTokens | Select-Object -Skip 1)
    $line = $commandAst.ToString()
    $currentWord = if ([string]::IsNullOrWhiteSpace($wordToComplete)) {
        Get-WhereCurrentToken -Line $line -CursorPosition ($cursorPosition - $commandAst.Extent.StartOffset) -Fallback $wordToComplete
    } else {
        $wordToComplete
    }
    $hasTrailingSpace = [string]::IsNullOrEmpty($wordToComplete)

    if ($hasTrailingSpace) {
        $tokensBeforeCurrent = @($tokens)
    } elseif ($tokens.Count -gt 1) {
        $tokensBeforeCurrent = @($tokens | Select-Object -First ($tokens.Count - 1))
    } else {
        $tokensBeforeCurrent = @()
    }

    $expectedValueOption = Get-WhereExpectedValueOption -TokensBeforeCurrent $tokensBeforeCurrent
    if ($expectedValueOption -and ($script:WhereCompletionCatalog.PathOptions -contains $expectedValueOption)) {
        return @(Get-WherePathCompletions -InputPath $currentWord)
    }

    if (-not [string]::IsNullOrEmpty($currentWord) -and $currentWord.StartsWith('/')) {
        return @(Get-WhereSwitchCompletion -CurrentWord $currentWord)
    }

    if ([string]::IsNullOrWhiteSpace($currentWord)) {
        return @(Get-WhereSwitchCompletion -CurrentWord '') + @(Get-WhereOperandCompletion -CurrentWord '')
    }

    @(Get-WhereOperandCompletion -CurrentWord $currentWord)
}

# Register the completer for both where.exe and bare where.
# The built-in read-only alias named "where" still resolves to Where-Object,
# so the native completer only engages for bare where when that alias is absent.
Register-ArgumentCompleter -Native -CommandName @('where.exe', 'where') -ScriptBlock {
    param($wordToComplete, $commandAst, $cursorPosition)
    Complete-Where -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition
}