completers/pskill_completer/pskill_completer.ps1

# pskill.exe tab completion for PowerShell
# Native completer for PsKill with static switches, remote placeholders, and local process hints.

Set-StrictMode -Version 2.0

if (-not (Get-Variable -Name PsKillCompletionCatalog -Scope Script -ErrorAction Ignore)) {
    $script:PsKillCompletionCatalog = @{
        SwitchOrder             = @('-t', '-u', '-p', '-nobanner', '-?', '/?', '--help')
        SwitchInfo              = @{
            '-t'        = 'Kill the process and its descendants.'
            '-u'        = 'Optional user name for remote login.'
            '-p'        = 'Optional password for the remote login.'
            '-nobanner' = 'Do not display the startup banner and copyright message.'
            '-?'        = 'Display pskill help.'
            '/?'        = 'Display pskill help.'
            '--help'    = 'Display pskill help.'
        }
        ProcessEntries          = @()
        ProcessCacheUpdated     = $null
        ProcessCacheTtlSeconds  = 2
        LocalAccounts           = $null
    }
}

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

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

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

function Get-PsKillCurrentToken {
    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 Remove-PsKillOuterQuotes {
    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 Update-PsKillProcessCache {
    $lastUpdated = $script:PsKillCompletionCatalog.ProcessCacheUpdated
    if ($null -ne $lastUpdated) {
        $cacheAge = (Get-Date) - $lastUpdated
        if ($cacheAge.TotalSeconds -lt $script:PsKillCompletionCatalog.ProcessCacheTtlSeconds) {
            return
        }
    }

    $nameSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    $idSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    $entries = [System.Collections.Generic.List[object]]::new()

    foreach ($process in @(Get-Process -ErrorAction SilentlyContinue)) {
        if ($process.ProcessName -and $nameSet.Add($process.ProcessName)) {
            $entries.Add([pscustomobject]@{
                    CompletionText = $process.ProcessName
                    ResultType     = 'ParameterValue'
                    ToolTip        = "Process name $($process.ProcessName)"
                })
        }

        $processIdText = [string]$process.Id
        if ($processIdText -and $idSet.Add($processIdText)) {
            $entries.Add([pscustomobject]@{
                    CompletionText = $processIdText
                    ResultType     = 'ParameterValue'
                    ToolTip        = "Process ID $processIdText"
                })
        }
    }

    $script:PsKillCompletionCatalog.ProcessEntries = @(
        $entries |
            Sort-Object -Property CompletionText
    )
    $script:PsKillCompletionCatalog.ProcessCacheUpdated = Get-Date
}

function New-PsKillLiteralValueResults {
    param(
        [string]$CurrentValue,
        [string]$Placeholder,
        [string]$ToolTip
    )

    if ([string]::IsNullOrWhiteSpace($CurrentValue)) {
        return @(
            New-PsKillCompletionResult -CompletionText $Placeholder -ResultType 'ParameterValue' -ToolTip $ToolTip
        )
    }

    @(
        New-PsKillCompletionResult -CompletionText $CurrentValue -ResultType 'ParameterValue' -ToolTip $ToolTip
    )
}

function ConvertFrom-PsKillTypedValue {
    param([string]$Value)

    $quote = ''
    $text = [string]$Value
    if ($text.Length -gt 0 -and ($text[0] -eq [char]"'" -or $text[0] -eq [char]'"')) {
        $quote = [string]$text[0]
        $text = $text.Substring(1)
        if ($text.EndsWith($quote)) {
            $text = $text.Substring(0, $text.Length - 1)
        }
    }

    [pscustomobject]@{
        Text  = $text
        Quote = $quote
    }
}

function ConvertTo-PsKillArgument {
    param(
        [string]$Value,
        [string]$Quote
    )

    if ($Quote -eq '"') {
        return '"' + $Value.Replace('`', '``').Replace('"', '`"').Replace('$', '`$') + '"'
    }

    if ($Quote -eq "'" -or $Value -match '[\s{}();,|&<>''"`$]' -or $Value -match '^[@#]') {
        return "'" + $Value.Replace("'", "''") + "'"
    }

    $Value
}

function Get-PsKillKnownHost {
    # Local, passive sources only: this computer, the logon server and the
    # servers behind persistent mapped drives. Nothing here touches the network.
    $candidates = [System.Collections.Generic.List[string]]::new()
    $candidates.Add([string]$env:COMPUTERNAME)
    $candidates.Add([string]$env:LOGONSERVER)

    $networkKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Network')
    if ($null -ne $networkKey) {
        try {
            foreach ($driveName in $networkKey.GetSubKeyNames()) {
                $driveKey = $networkKey.OpenSubKey($driveName)
                if ($null -eq $driveKey) {
                    continue
                }

                try {
                    $candidates.Add([string]$driveKey.GetValue('RemotePath'))
                } finally {
                    $driveKey.Dispose()
                }
            }
        } finally {
            $networkKey.Dispose()
        }
    }

    $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    $hostNames = [System.Collections.Generic.List[string]]::new()
    foreach ($candidate in $candidates) {
        $hostName = $candidate.TrimStart('\').Split('\')[0]
        if ($hostName -match '^[A-Za-z0-9._-]+$' -and $seen.Add($hostName)) {
            $hostNames.Add('\\' + $hostName)
        }
    }

    @($hostNames.ToArray())
}

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

    $typedHost = [string]$CurrentWord
    $results = [System.Collections.Generic.List[object]]::new()
    foreach ($hostName in @(Get-PsKillKnownHost)) {
        if ($typedHost.Length -gt 0 -and -not $hostName.StartsWith($typedHost, [System.StringComparison]::OrdinalIgnoreCase)) {
            continue
        }

        $results.Add((New-PsKillCompletionResult -CompletionText $hostName -ResultType 'ParameterValue' -ToolTip "Remote computer $hostName"))
    }

    if ($typedHost.Length -le 2) {
        $results.Add((New-PsKillCompletionResult -CompletionText '\\computer' -ResultType 'ParameterValue' -ToolTip 'Remote computer placeholder.'))
    }

    if ($results.Count -eq 0) {
        # Keep the typed host as a no-op: an empty answer hands '\\host' to
        # PowerShell's UNC fallback, which goes to the network for share names.
        $results.Add((New-PsKillCompletionResult -CompletionText $typedHost -ResultType 'ParameterValue' -ToolTip 'Remote computer.'))
    }

    @($results.ToArray())
}

function Get-PsKillLocalAccount {
    if ($null -ne $script:PsKillCompletionCatalog['LocalAccounts']) {
        return @($script:PsKillCompletionCatalog['LocalAccounts'])
    }

    $names = [System.Collections.Generic.List[string]]::new()
    $errorCountBefore = $Error.Count
    try {
        # The local SAM through ADSI: a fraction of Get-LocalUser's module-load cost.
        $computer = [ADSI]('WinNT://' + $env:COMPUTERNAME + ',computer')
        $null = $computer.Children.SchemaFilter.Add('user')
        foreach ($entry in $computer.Children) {
            # 0x2 is ADS_UF_ACCOUNTDISABLE.
            if (-not ([int]$entry.Properties['UserFlags'].Value -band 2)) {
                $names.Add([string]$entry.Name)
            }
        }
    } catch {
        Write-Debug ('pskill completer: cannot list local accounts: ' + $_.Exception.Message)
    } finally {
        while ($Error.Count -gt $errorCountBefore) {
            $Error.RemoveAt(0)
        }
    }

    $script:PsKillCompletionCatalog['LocalAccounts'] = @($names.ToArray())
    @($script:PsKillCompletionCatalog['LocalAccounts'])
}

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

    $typed = ConvertFrom-PsKillTypedValue -Value $CurrentWord
    $candidates = [System.Collections.Generic.List[string]]::new()
    $candidates.Add([string]$env:USERNAME)
    if (-not [string]::IsNullOrEmpty($env:USERDOMAIN) -and -not [string]::IsNullOrEmpty($env:USERNAME)) {
        $candidates.Add($env:USERDOMAIN + '\' + $env:USERNAME)
    }

    foreach ($accountName in @(Get-PsKillLocalAccount)) {
        $candidates.Add($accountName)
    }

    $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    $results = [System.Collections.Generic.List[object]]::new()
    foreach ($userName in $candidates) {
        if ([string]::IsNullOrWhiteSpace($userName) -or -not $seen.Add($userName)) {
            continue
        }

        if ($typed.Text.Length -gt 0 -and -not $userName.StartsWith($typed.Text, [System.StringComparison]::OrdinalIgnoreCase)) {
            continue
        }

        $results.Add((New-PsKillCompletionResult -CompletionText (ConvertTo-PsKillArgument -Value $userName -Quote $typed.Quote) -ResultType 'ParameterValue' -ToolTip "User name $userName"))
    }

    if ([string]::IsNullOrWhiteSpace($CurrentWord)) {
        $results.Add((New-PsKillCompletionResult -CompletionText '<username>' -ResultType 'ParameterValue' -ToolTip 'Remote user name.'))
    }

    if ($results.Count -eq 0) {
        return @(New-PsKillLiteralValueResults -CurrentValue $CurrentWord -Placeholder '<username>' -ToolTip 'Remote user name.')
    }

    @($results.ToArray())
}

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

    Update-PsKillProcessCache

    $typedValue = Remove-PsKillOuterQuotes -Value $CurrentWord
    $results = $script:PsKillCompletionCatalog.ProcessEntries |
        Where-Object {
            [string]::IsNullOrWhiteSpace($typedValue) -or $_.CompletionText.StartsWith($typedValue, [System.StringComparison]::OrdinalIgnoreCase)
        } |
        ForEach-Object {
            New-PsKillCompletionResult -CompletionText $_.CompletionText -ResultType $_.ResultType -ToolTip $_.ToolTip
        }

    if (@($results).Count -gt 0) {
        return @($results)
    }

    @(New-PsKillLiteralValueResults -CurrentValue $CurrentWord -Placeholder '<process-or-pid>' -ToolTip 'Process name or PID.')
}

function Get-PsKillCommandState {
    param([object[]]$TokensBeforeCurrent)

    $TokensBeforeCurrent = @($TokensBeforeCurrent)

    $usedSwitchLookup = @{}
    $valueContext = $null
    $remoteTarget = $null
    $remoteUser = $null
    $remotePassword = $null
    $processTarget = $null
    $helpRequested = $false

    for ($index = 0; $index -lt $TokensBeforeCurrent.Count; $index++) {
        $token = [string]$TokensBeforeCurrent[$index]
        if ([string]::IsNullOrWhiteSpace($token)) {
            continue
        }

        $lookup = $token.ToLowerInvariant()

        if ($lookup -in @('-?', '/?', '--help')) {
            $helpRequested = $true
            $usedSwitchLookup[$lookup] = $true
            continue
        }

        if ($lookup -in @('-u', '-p')) {
            $usedSwitchLookup[$lookup] = $true
            if ($index -eq ($TokensBeforeCurrent.Count - 1)) {
                $valueContext = $lookup
                break
            }

            if ($lookup -eq '-u') {
                $remoteUser = [string]$TokensBeforeCurrent[$index + 1]
            } else {
                $remotePassword = [string]$TokensBeforeCurrent[$index + 1]
            }

            $index++
            continue
        }

        if ($lookup.StartsWith('-')) {
            $usedSwitchLookup[$lookup] = $true
            continue
        }

        if (-not $remoteTarget -and $token.StartsWith('\\')) {
            $remoteTarget = $token
            continue
        }

        if (-not $processTarget) {
            $processTarget = $token
        }
    }

    [pscustomobject]@{
        UsedSwitchLookup = $usedSwitchLookup
        ValueContext     = $valueContext
        RemoteTarget     = $remoteTarget
        RemoteUser       = $remoteUser
        RemotePassword   = $remotePassword
        ProcessTarget    = $processTarget
        HelpRequested    = $helpRequested
    }
}

function Get-PsKillSwitchCompletions {
    param(
        [string]$CurrentWord,
        [pscustomobject]$State,
        [bool]$NoArgumentsYet
    )

    $prefix = if ([string]::IsNullOrWhiteSpace($CurrentWord)) { '' } else { $CurrentWord }
    $results = [System.Collections.Generic.List[object]]::new()

    foreach ($token in $script:PsKillCompletionCatalog.SwitchOrder) {
        if (-not [string]::IsNullOrWhiteSpace($prefix) -and
            -not $token.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) {
            continue
        }

        if ($token -in @('-?', '/?', '--help')) {
            if (-not $NoArgumentsYet) {
                continue
            }
        } elseif ($State.UsedSwitchLookup.ContainsKey($token)) {
            continue
        }

        $results.Add((New-PsKillCompletionResult -CompletionText $token -ResultType 'ParameterName' -ToolTip $script:PsKillCompletionCatalog.SwitchInfo[$token]))
    }

    @($results.ToArray())
}

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

    $line = $commandAst.ToString()
    $safeCursor = [Math]::Min([Math]::Max($cursorPosition - $commandAst.Extent.StartOffset, 0), $line.Length)
    $linePrefix = $line.Substring(0, $safeCursor)
    $commandTokens = @([regex]::Matches($linePrefix, '"[^"]*"|\S+') | ForEach-Object { $_.Value })
    [object[]]$argumentTokens = if ($commandTokens.Count -gt 1) {
        @($commandTokens | Select-Object -Skip 1)
    } else {
        @()
    }
    $argumentTokens = @($argumentTokens)

    $currentWord = if ([string]::IsNullOrEmpty($wordToComplete)) {
        Get-PsKillCurrentToken -Line $line -CursorPosition ($cursorPosition - $commandAst.Extent.StartOffset) -Fallback $wordToComplete
    } else {
        $wordToComplete
    }

    $hasTrailingSpace = [string]::IsNullOrEmpty($currentWord) -and (($linePrefix -match '\s$') -or (($cursorPosition - $commandAst.Extent.StartOffset) -gt $line.Length))
    [object[]]$tokensBeforeCurrent = if ($hasTrailingSpace) {
        @($argumentTokens)
    } elseif ($argumentTokens.Count -gt 0) {
        @($argumentTokens | Select-Object -First ($argumentTokens.Count - 1))
    } else {
        @()
    }
    $tokensBeforeCurrent = @($tokensBeforeCurrent)

    $state = Get-PsKillCommandState -TokensBeforeCurrent $tokensBeforeCurrent

    if ($state.HelpRequested) {
        return @(
            New-PsKillCompletionResult -CompletionText '-?' -ResultType 'ParameterName' -ToolTip 'Display pskill help.'
        )
    }

    switch ($state.ValueContext) {
        '-u' {
            return @(Get-PsKillUserCompletions -CurrentWord $currentWord)
        }
        '-p' {
            return @(New-PsKillLiteralValueResults -CurrentValue $currentWord -Placeholder '<password>' -ToolTip 'Remote password.')
        }
    }

    if (-not [string]::IsNullOrWhiteSpace($currentWord) -and $currentWord.StartsWith('\\')) {
        return @(Get-PsKillHostCompletions -CurrentWord $currentWord)
    }

    if (-not [string]::IsNullOrWhiteSpace($currentWord) -and $currentWord.StartsWith('-')) {
        return @(Get-PsKillSwitchCompletions -CurrentWord $currentWord -State $state -NoArgumentsYet:($tokensBeforeCurrent.Count -eq 0))
    }

    if ([string]::IsNullOrWhiteSpace($currentWord)) {
        $results = [System.Collections.Generic.List[object]]::new()
        foreach ($item in @(Get-PsKillSwitchCompletions -CurrentWord $currentWord -State $state -NoArgumentsYet:($tokensBeforeCurrent.Count -eq 0))) {
            $results.Add($item)
        }

        if (-not $state.ProcessTarget) {
            if ($state.RemoteTarget) {
                $results.Add((New-PsKillCompletionResult -CompletionText '<process-name>' -ResultType 'ParameterValue' -ToolTip 'Remote process name.'))
                $results.Add((New-PsKillCompletionResult -CompletionText '<pid>' -ResultType 'ParameterValue' -ToolTip 'Remote process ID.'))
            } else {
                foreach ($item in @(Get-PsKillHostCompletions -CurrentWord '')) {
                    $results.Add($item)
                }

                foreach ($item in @(Get-PsKillProcessCompletions -CurrentWord '')) {
                    $results.Add($item)
                }
            }
        }

        return @($results.ToArray())
    }

    if (-not $state.ProcessTarget) {
        if ($state.RemoteTarget) {
            return @(
                New-PsKillLiteralValueResults -CurrentValue $currentWord -Placeholder '<process-or-pid>' -ToolTip 'Remote process name or PID.'
            )
        }

        return @(Get-PsKillProcessCompletions -CurrentWord $currentWord)
    }

    @()
}

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

    Complete-PsKill -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition
}