completers/pslist_completer/pslist_completer.ps1

# pslist tab completion for PowerShell
# Builds a static-first native completer for pslist with safe help capture and local process hints.
# Usage: . .\pslist_completer.ps1

Set-StrictMode -Version 2.0

if (-not (Get-Variable -Name PslistCompletionCatalog -Scope Script -ErrorAction Ignore)) {
    $script:PslistCompletionCatalog = @{
        Initialized             = $false
        SwitchOrder             = @('-d', '-m', '-x', '-t', '-s', '-r', '-nobanner', '-accepteula', '-u', '-p', '-e', '-?', '/?')
        SwitchInfo              = @{}
        PositionalInfo          = @{}
        SampleSecondsHints      = @('1', '2', '5', '10')
        RefreshSecondsHints     = @('1', '2', '5', '10')
        ProcessEntries          = @()
        ProcessCacheUpdated     = $null
        ProcessCacheTtlSeconds  = 2
    }
}

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

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

    if ([string]::IsNullOrEmpty($ListItemText)) {
        $ListItemText = $CompletionText
    }

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

function Get-PslistStaticSwitchCatalog {
    [ordered]@{
        '-d'        = 'Show thread detail.'
        '-m'        = 'Show memory detail.'
        '-x'        = 'Show processes, memory information, and threads.'
        '-t'        = 'Show process tree.'
        '-s'        = 'Run in task-manager mode, optionally specifying sample seconds.'
        '-r'        = 'Task-manager mode refresh rate in seconds (default is 1).'
        '-nobanner' = 'Do not display the startup banner and copyright message.'
        '-accepteula' = 'Accept the Sysinternals license agreement silently (undocumented in pslist /?, accepted by every Sysinternals tool).'
        '-u'        = 'Optional user name for remote login.'
        '-p'        = 'Optional password for remote login. Prompts if omitted when needed.'
        '-e'        = 'Exact-match the process name. Valid only with a process name target.'
        '-?'        = 'Display pslist help.'
        '/?'        = 'Display pslist help.'
    }
}

function Get-PslistStaticPositionalCatalog {
    [ordered]@{
        '\\computer' = 'Remote computer placeholder.'
        'name'       = 'Process name prefix to query.'
        'pid'        = 'Process ID to query.'
    }
}

function Get-PslistCommandPath {
    $command = Get-Command -Name pslist.exe -ErrorAction Ignore
    if ($command) {
        return $command.Source
    }

    $command = Get-Command -Name pslist -ErrorAction Ignore
    if ($command) {
        return $command.Source
    }

    $null
}

function Invoke-PslistHelpText {
    $commandPath = Get-PslistCommandPath
    if (-not $commandPath) {
        return @()
    }

    try {
        @(
            $null | & $commandPath '/?' 2>&1 |
                ForEach-Object { $_.ToString() -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' }
        )
    } catch {
        @()
    }
}

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

    $result = @{}
    $currentKey = $null

    foreach ($line in $Lines) {
        if ($line -match '^\s*(-d|-m|-x|-t|-s|-r|-u|-p|-e|-nobanner|-\?|/\?|\\\\computer|name|pid)\s{2,}(.*)$') {
            $currentKey = $matches[1].ToLowerInvariant()
            $result[$currentKey] = [System.Collections.Generic.List[string]]::new()

            if (-not [string]::IsNullOrWhiteSpace($matches[2])) {
                $result[$currentKey].Add($matches[2].Trim())
            }

            continue
        }

        if ($currentKey -and $line -match '^\s{10,}(\S.*)$') {
            $result[$currentKey].Add($matches[1].Trim())
            continue
        }

        $currentKey = $null
    }

    $map = @{}
    foreach ($entry in $result.GetEnumerator()) {
        $map[$entry.Key] = ($entry.Value -join ' ')
    }

    $map
}

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

    $script:PslistCompletionCatalog.SwitchInfo = @{}
    foreach ($entry in (Get-PslistStaticSwitchCatalog).GetEnumerator()) {
        $script:PslistCompletionCatalog.SwitchInfo[$entry.Key] = $entry.Value
    }

    $script:PslistCompletionCatalog.PositionalInfo = @{}
    foreach ($entry in (Get-PslistStaticPositionalCatalog).GetEnumerator()) {
        $script:PslistCompletionCatalog.PositionalInfo[$entry.Key] = $entry.Value
    }

    $helpEntryMap = Get-PslistHelpEntryMap -Lines (Invoke-PslistHelpText)
    foreach ($entry in $helpEntryMap.GetEnumerator()) {
        if ($script:PslistCompletionCatalog.SwitchInfo.ContainsKey($entry.Key)) {
            $script:PslistCompletionCatalog.SwitchInfo[$entry.Key] = $entry.Value
            continue
        }

        if ($script:PslistCompletionCatalog.PositionalInfo.ContainsKey($entry.Key)) {
            $script:PslistCompletionCatalog.PositionalInfo[$entry.Key] = $entry.Value
        }
    }

    $script:PslistCompletionCatalog.Initialized = $true
}

function ConvertFrom-PslistTypedWord {
    # Splits the text typed so far into its value and the opening quote the
    # user typed ('' when bare), undoing that quote style's escapes.
    param([string]$Text)

    $quote = ''
    if ($Text.StartsWith("'") -or $Text.StartsWith('"')) {
        $quote = $Text.Substring(0, 1)
        $Text = $Text.Substring(1)
        if ($Text.EndsWith($quote)) {
            $Text = $Text.Substring(0, $Text.Length - 1)
        }

        $Text = if ($quote -eq "'") { $Text.Replace("''", "'") } else { $Text -replace '`(.)', '$1' }
    }

    [pscustomobject]@{ Value = $Text; Quote = $quote }
}

function ConvertTo-PslistArgument {
    # Renders a value as one PowerShell argument: bare when safe and no quote
    # was typed, otherwise in the typed quote style (single by default).
    # Whitespace and argument-mode metacharacters end or split a bare word; a
    # leading '@' or '#' would start a splat or a comment.
    param(
        [string]$Value,
        [string]$Quote
    )

    if (-not $Quote) {
        if ($Value -notmatch '[\s{}();,|&<>''"`$\u2018-\u201E]' -and $Value -notmatch '^[@#]') {
            return $Value
        }

        $Quote = "'"
    }

    if ($Quote -eq "'") {
        return "'" + ($Value -replace '([''\u2018-\u201B])', '$1$1') + "'"
    }

    '"' + ($Value -replace '([`"$\u201C-\u201E])', '`$1') + '"'
}

function Remove-PslistOuterQuotes {
    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 Test-PslistNumericToken {
    param([string]$Value)

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

    $Value -match '^\d+$'
}

function Test-PslistHelpAlias {
    param([string]$Value)

    $Value -in @('-?', '/?')
}

function Test-PslistSwitchLikeToken {
    param([string]$Value)

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

    if (Test-PslistHelpAlias -Value $Value) {
        return $true
    }

    $Value.StartsWith('-')
}

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

    $TokensBeforeCurrent = @($TokensBeforeCurrent)

    $usedSwitchLookup = @{}
    $valuesBySwitch = @{}
    $remoteTarget = $null
    $processTarget = $null

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

        if (Test-PslistSwitchLikeToken -Value $token) {
            $switchKey = $token.ToLowerInvariant()
            $usedSwitchLookup[$switchKey] = $true

            switch ($switchKey) {
                '-s' {
                    if ($index + 1 -lt $TokensBeforeCurrent.Count -and
                        (Test-PslistNumericToken -Value $TokensBeforeCurrent[$index + 1])) {
                        $valuesBySwitch[$switchKey] = $TokensBeforeCurrent[$index + 1]
                        $index++
                    }
                }
                '-r' {
                    if ($index + 1 -lt $TokensBeforeCurrent.Count -and
                        -not (Test-PslistSwitchLikeToken -Value $TokensBeforeCurrent[$index + 1])) {
                        $valuesBySwitch[$switchKey] = $TokensBeforeCurrent[$index + 1]
                        $index++
                    }
                }
                '-u' {
                    if ($index + 1 -lt $TokensBeforeCurrent.Count -and
                        -not (Test-PslistSwitchLikeToken -Value $TokensBeforeCurrent[$index + 1])) {
                        $valuesBySwitch[$switchKey] = $TokensBeforeCurrent[$index + 1]
                        $index++
                    }
                }
                '-p' {
                    if ($index + 1 -lt $TokensBeforeCurrent.Count -and
                        -not (Test-PslistSwitchLikeToken -Value $TokensBeforeCurrent[$index + 1])) {
                        $valuesBySwitch[$switchKey] = $TokensBeforeCurrent[$index + 1]
                        $index++
                    }
                }
            }

            continue
        }

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

            continue
        }

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

    $valueContext = $null
    if ($TokensBeforeCurrent.Count -gt 0) {
        $lastToken = $TokensBeforeCurrent[-1]
        if (Test-PslistSwitchLikeToken -Value $lastToken) {
            $lastKey = $lastToken.ToLowerInvariant()
            if ($lastKey -in @('-s', '-r', '-u', '-p')) {
                $valueContext = $lastKey
            }
        }
    }

    $processTargetKind = $null
    if ($processTarget) {
        if (Test-PslistNumericToken -Value (ConvertFrom-PslistTypedWord -Text $processTarget).Value) {
            $processTargetKind = 'Pid'
        } else {
            $processTargetKind = 'Name'
        }
    }

    [pscustomobject]@{
        UsedSwitchLookup = $usedSwitchLookup
        ValuesBySwitch   = $valuesBySwitch
        ValueContext     = $valueContext
        RemoteTarget     = $remoteTarget
        ProcessTarget    = $processTarget
        ProcessTargetKind = $processTargetKind
        HasHelp          = ($usedSwitchLookup.ContainsKey('-?') -or $usedSwitchLookup.ContainsKey('/?'))
    }
}

function Get-PslistPlaceholderValueCompletions {
    param(
        [string]$CurrentWord,
        [string[]]$Candidates,
        [string]$GenericToolTip
    )

    $typedValue = Remove-PslistOuterQuotes -Value $CurrentWord
    $results = New-Object System.Collections.Generic.List[object]

    foreach ($candidate in $Candidates) {
        if (-not [string]::IsNullOrWhiteSpace($typedValue) -and
            -not $candidate.StartsWith($typedValue, [System.StringComparison]::OrdinalIgnoreCase)) {
            continue
        }

        $results.Add((New-PslistCompletionResult -CompletionText $candidate -ResultType 'ParameterValue' -ToolTip $GenericToolTip))
    }

    if ($results.Count -eq 0 -and -not [string]::IsNullOrWhiteSpace($CurrentWord)) {
        $results.Add((New-PslistCompletionResult -CompletionText $CurrentWord -ResultType 'ParameterValue' -ToolTip $GenericToolTip))
    }

    @($results.ToArray())
}

function Get-PslistNumericValueCompletions {
    param(
        [string]$CurrentWord,
        [string[]]$Candidates,
        [string]$ToolTip
    )

    if (Test-PslistSwitchLikeToken -Value $CurrentWord) {
        return @()
    }

    $typedValue = Remove-PslistOuterQuotes -Value $CurrentWord
    $results = New-Object System.Collections.Generic.List[object]

    foreach ($candidate in $Candidates) {
        if (-not [string]::IsNullOrWhiteSpace($typedValue) -and
            -not $candidate.StartsWith($typedValue, [System.StringComparison]::OrdinalIgnoreCase)) {
            continue
        }

        $results.Add((New-PslistCompletionResult -CompletionText $candidate -ResultType 'ParameterValue' -ToolTip $ToolTip))
    }

    if ($results.Count -eq 0 -and (Test-PslistNumericToken -Value $typedValue)) {
        $results.Add((New-PslistCompletionResult -CompletionText $typedValue -ResultType 'ParameterValue' -ToolTip $ToolTip))
    }

    @($results.ToArray())
}

function Update-PslistProcessCache {
    if ($null -ne $script:PslistCompletionCatalog.ProcessCacheUpdated) {
        $cacheAge = (Get-Date) - $script:PslistCompletionCatalog.ProcessCacheUpdated
        if ($cacheAge.TotalSeconds -lt $script:PslistCompletionCatalog.ProcessCacheTtlSeconds) {
            return
        }
    }

    $nameSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    $idSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    $processNames = [System.Collections.Generic.List[string]]::new()
    $processIds = [System.Collections.Generic.List[int]]::new()

    foreach ($process in @(Get-Process -ErrorAction SilentlyContinue)) {
        if ($process.ProcessName -and $nameSet.Add($process.ProcessName)) {
            $processNames.Add($process.ProcessName)
        }

        $processIdText = [string]$process.Id
        if ($processIdText -and $idSet.Add($processIdText)) {
            $processIds.Add($process.Id)
        }
    }

    $entries = [System.Collections.Generic.List[object]]::new()
    foreach ($processName in @($processNames | Sort-Object)) {
        $entries.Add([pscustomobject]@{
                CompletionText = $processName
                ResultType     = 'ParameterValue'
                ToolTip        = "Local process name $processName"
            })
    }

    foreach ($processId in @($processIds | Sort-Object)) {
        $processIdText = [string]$processId
        $entries.Add([pscustomobject]@{
                CompletionText = $processIdText
                ResultType     = 'ParameterValue'
                ToolTip        = "Local process ID $processIdText"
            })
    }

    $script:PslistCompletionCatalog.ProcessEntries = @($entries)
    $script:PslistCompletionCatalog.ProcessCacheUpdated = Get-Date
}

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

    Update-PslistProcessCache

    # Names such as 'Docker Desktop' must reach pslist as one operand, so they
    # are quoted on insertion (in the quote style the user typed, if any).
    $typed = ConvertFrom-PslistTypedWord -Text $CurrentWord
    $script:PslistCompletionCatalog.ProcessEntries |
        Where-Object {
            [string]::IsNullOrWhiteSpace($typed.Value) -or
            $_.CompletionText.StartsWith($typed.Value, [System.StringComparison]::OrdinalIgnoreCase)
        } |
        ForEach-Object {
            New-PslistCompletionResult `
                -CompletionText (ConvertTo-PslistArgument -Value $_.CompletionText -Quote $typed.Quote) `
                -ListItemText $_.CompletionText `
                -ResultType $_.ResultType `
                -ToolTip $_.ToolTip
        }
}

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

    Get-PslistPlaceholderValueCompletions `
        -CurrentWord $CurrentWord `
        -Candidates @('<process-name>', '<pid>') `
        -GenericToolTip 'Remote process target. pslist completion does not enumerate remote process names or PIDs.'
}

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

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

    Get-PslistPlaceholderValueCompletions `
        -CurrentWord $CurrentWord `
        -Candidates @('\\computer') `
        -GenericToolTip $script:PslistCompletionCatalog.PositionalInfo['\\computer']
}

function Get-PslistSwitchCompletions {
    param(
        [string]$CurrentWord,
        [psobject]$State
    )

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

    foreach ($switchText in $script:PslistCompletionCatalog.SwitchOrder) {
        $key = $switchText.ToLowerInvariant()
        if ($State.UsedSwitchLookup.ContainsKey($key)) {
            continue
        }

        $includeSwitch = $true
        switch ($key) {
            '-u' {
                if (-not $State.RemoteTarget) {
                    $includeSwitch = $false
                }
            }
            '-p' {
                if (-not $State.RemoteTarget) {
                    $includeSwitch = $false
                }
            }
            '-r' {
                if (-not $State.UsedSwitchLookup.ContainsKey('-s')) {
                    $includeSwitch = $false
                }
            }
            '-e' {
                # -e exact-matches a process NAME; it is valid before the operand
                # ('pslist -e notepad') and only meaningless once a PID is given.
                if ($State.ProcessTargetKind -eq 'Pid') {
                    $includeSwitch = $false
                }
            }
            '-?' {
                if ($State.UsedSwitchLookup.Count -gt 0 -or $State.RemoteTarget -or $State.ProcessTarget) {
                    $includeSwitch = $false
                }
            }
            '/?' {
                if ($State.UsedSwitchLookup.Count -gt 0 -or $State.RemoteTarget -or $State.ProcessTarget) {
                    $includeSwitch = $false
                }
            }
        }

        if (-not $includeSwitch) {
            continue
        }

        if (-not [string]::IsNullOrWhiteSpace($CurrentWord) -and
            -not $switchText.StartsWith($CurrentWord, [System.StringComparison]::OrdinalIgnoreCase)) {
            continue
        }

        $results.Add((
                New-PslistCompletionResult `
                    -CompletionText $switchText `
                    -ResultType 'ParameterName' `
                    -ToolTip $script:PslistCompletionCatalog.SwitchInfo[$key]
            ))
    }

    @($results.ToArray())
}

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

    $completionText = if ([string]::IsNullOrEmpty($CurrentWord)) { ' ' } else { $CurrentWord }
    @(
        New-PslistCompletionResult `
            -CompletionText $completionText `
            -ResultType 'ParameterValue' `
            -ToolTip 'No further arguments are valid after pslist help.'
    )
}

function Complete-Pslist {
    [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
    )

    Initialize-PslistCompletionCatalog

    # Tokens are split at the cursor rather than at the end of the line, so a
    # cursor inside an earlier token completes that token and ignores the rest.
    # The word comes from the parser, so an unterminated quote is one word
    # running to the cursor, spaces included.
    $currentElement = $commandAst.CommandElements |
        Select-Object -Skip 1 |
        Where-Object { $_.Extent.StartOffset -lt $cursorPosition -and $_.Extent.EndOffset -ge $cursorPosition } |
        Select-Object -First 1
    $currentWord = if ($currentElement) {
        $currentElement.Extent.Text.Substring(0, $cursorPosition - $currentElement.Extent.StartOffset)
    } else {
        ''
    }

    [object[]]$tokensBeforeCurrent = @(
        $commandAst.CommandElements |
            Select-Object -Skip 1 |
            Where-Object { $_.Extent.EndOffset -lt $cursorPosition } |
            ForEach-Object { $_.Extent.Text }
    )

    $state = Get-PslistCommandState -TokensBeforeCurrent $tokensBeforeCurrent
    if ($state.HasHelp) {
        return @(Get-PslistTerminalCompletions -CurrentWord $currentWord)
    }

    switch ($state.ValueContext) {
        '-s' {
            if (Test-PslistSwitchLikeToken -Value $currentWord) {
                return @(Get-PslistSwitchCompletions -CurrentWord $currentWord -State $state)
            }

            $numericResults = @(Get-PslistNumericValueCompletions -CurrentWord $currentWord -Candidates $script:PslistCompletionCatalog.SampleSecondsHints -ToolTip 'Optional task-manager mode sample interval in seconds.')
            if ([string]::IsNullOrWhiteSpace($currentWord)) {
                return @(
                    $numericResults
                    Get-PslistSwitchCompletions -CurrentWord '' -State $state
                )
            }

            return $numericResults
        }
        '-r' {
            if (Test-PslistSwitchLikeToken -Value $currentWord) {
                return @(Get-PslistSwitchCompletions -CurrentWord $currentWord -State $state)
            }

            return @(Get-PslistNumericValueCompletions -CurrentWord $currentWord -Candidates $script:PslistCompletionCatalog.RefreshSecondsHints -ToolTip 'Task-manager mode refresh rate in seconds.')
        }
        '-u' {
            if (Test-PslistSwitchLikeToken -Value $currentWord) {
                return @(Get-PslistSwitchCompletions -CurrentWord $currentWord -State $state)
            }

            return @(Get-PslistPlaceholderValueCompletions -CurrentWord $currentWord -Candidates @('<username>') -GenericToolTip 'Remote user name.')
        }
        '-p' {
            if (Test-PslistSwitchLikeToken -Value $currentWord) {
                return @(Get-PslistSwitchCompletions -CurrentWord $currentWord -State $state)
            }

            return @(Get-PslistPlaceholderValueCompletions -CurrentWord $currentWord -Candidates @('<password>') -GenericToolTip 'Remote password. If omitted, pslist may prompt interactively.')
        }
    }

    if (Test-PslistSwitchLikeToken -Value $currentWord) {
        return @(Get-PslistSwitchCompletions -CurrentWord $currentWord -State $state)
    }

    $results = New-Object System.Collections.Generic.List[object]
    foreach ($completion in @(Get-PslistSwitchCompletions -CurrentWord $currentWord -State $state)) {
        $results.Add($completion)
    }

    if (-not $state.ProcessTarget) {
        if ($state.RemoteTarget) {
            foreach ($completion in @(Get-PslistRemoteProcessTargetCompletions -CurrentWord $currentWord)) {
                $results.Add($completion)
            }
        } else {
            foreach ($completion in @(Get-PslistRemoteComputerCompletions -CurrentWord $currentWord)) {
                $results.Add($completion)
            }

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

            foreach ($completion in @(Get-PslistLocalProcessTargetCompletions -CurrentWord $currentWord)) {
                $results.Add($completion)
            }
        }
    }

    @($results.ToArray())
}


Register-ArgumentCompleter -Native -CommandName @('pslist', 'pslist.exe') -ScriptBlock {
    param($wordToComplete, $commandAst, $cursorPosition)
    Complete-Pslist -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition
}