completers/listdlls_completer/listdlls_completer.ps1

# listdlls.exe tab completion for PowerShell
# Static-first native completer for Sysinternals Listdlls with safe help parsing and local process hints.

Set-StrictMode -Version 2.0

if (-not (Get-Variable -Name ListdllsCompletionCatalog -Scope Script -ErrorAction Ignore)) {
    $script:ListdllsCompletionCatalog = @{
        Initialized             = $false
        SwitchOrder             = @('-r', '-v', '-u', '-d', '-accepteula', '-nobanner', '-?', '/?', '--help')
        SwitchInfo              = @{}
        ProcessEntries          = @()
        ProcessCacheUpdated     = $null
        ProcessCacheTtlSeconds  = 15
        DllPlaceholder          = '<dll-name>'
        DllEntries              = @()
        DllCacheUpdated         = $null
        DllCacheTtlSeconds      = 300
        QuotePattern            = '[\s{}();,|&<>''"`$]|^[@#]'
    }
}

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

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

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

function Get-ListdllsCurrentToken {
    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-ListdllsOuterQuotes {
    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 Get-ListdllsStaticSwitchCatalog {
    [ordered]@{
        '-r'     = 'Flag relocated DLLs.'
        '-v'     = 'Show DLL version information.'
        '-u'     = 'Only list unsigned DLLs.'
        '-d'     = 'Show only processes that loaded the specified DLL.'
        '-accepteula' = 'Accept the Sysinternals EULA (suppresses the first-run dialog).'
        '-nobanner'   = 'Do not display the startup banner and copyright message.'
        '-?'     = 'Display listdlls help.'
        '/?'     = 'Display listdlls help.'
        '--help' = 'Display listdlls help.'
    }
}

function Get-ListdllsCommandPath {
    foreach ($candidate in @('Listdlls.exe', 'Listdlls', 'listdlls.exe', 'listdlls')) {
        $command = Get-Command -Name $candidate -ErrorAction SilentlyContinue
        if ($command) {
            if ($command.Path) {
                return $command.Path
            }

            if ($command.Source) {
                return $command.Source
            }

            return $command.Name
        }
    }

    $null
}

function Invoke-ListdllsHelpText {
    $commandPath = Get-ListdllsCommandPath
    if ([string]::IsNullOrWhiteSpace($commandPath)) {
        return @()
    }

    try {
        $startInfo = [System.Diagnostics.ProcessStartInfo]::new()
        $startInfo.FileName = $commandPath
        foreach ($argument in @('-accepteula', '-nobanner', '/?')) {
            $startInfo.ArgumentList.Add($argument)
        }
        $startInfo.UseShellExecute = $false
        $startInfo.CreateNoWindow = $true
        $startInfo.RedirectStandardInput = $true
        $startInfo.RedirectStandardOutput = $true
        $startInfo.RedirectStandardError = $true

        $process = [System.Diagnostics.Process]::Start($startInfo)
        try {
            $process.StandardInput.Close()
            $outputTask = $process.StandardOutput.ReadToEndAsync()
            $errorTask = $process.StandardError.ReadToEndAsync()
            if (-not $process.WaitForExit(5000)) {
                $process.Kill()
                return @()
            }

            $text = ($outputTask.Result -replace '\e\[[0-9;?]*[ -/]*[@-~]', '') + ($errorTask.Result -replace '\e\[[0-9;?]*[ -/]*[@-~]', '')
            return @($text -split '\r?\n')
        } finally {
            $process.Dispose()
        }
    } catch {
        Write-Debug "listdlls help unavailable: $($_.Exception.Message)"
        @()
    }
}

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

    $result = @{}
    $currentKey = $null

    foreach ($line in $Lines) {
        if ($line -match '^\s*(processname|pid|dllname|-r|-u|-v)\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{12,}(\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-ListdllsCompletionCatalog {
    if ($script:ListdllsCompletionCatalog.Initialized) {
        return
    }

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

    $helpEntryMap = Get-ListdllsHelpEntryMap -Lines (Invoke-ListdllsHelpText)
    foreach ($entry in $helpEntryMap.GetEnumerator()) {
        if ($script:ListdllsCompletionCatalog.SwitchInfo.ContainsKey($entry.Key)) {
            $script:ListdllsCompletionCatalog.SwitchInfo[$entry.Key] = $entry.Value
        }
    }

    $script:ListdllsCompletionCatalog.Initialized = $true
}

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

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

    foreach ($process in @(Get-Process -ErrorAction Ignore)) {
        if ($process.ProcessName -and $nameSet.Add($process.ProcessName)) {
            $nameEntries.Add([pscustomobject]@{
                    CompletionText = $process.ProcessName
                    Kind           = 'Name'
                    ToolTip        = "Process name $($process.ProcessName)"
                })
        }

        # PIDs 0 (Idle) and 4 (System) cannot be opened by listdlls.
        if ($process.Id -gt 4) {
            $idEntries.Add([pscustomobject]@{
                    CompletionText = [string]$process.Id
                    Kind           = 'Pid'
                    SortKey        = [int]$process.Id
                    ToolTip        = "Process ID $($process.Id) ($($process.ProcessName))"
                })
        }
    }

    $script:ListdllsCompletionCatalog.ProcessEntries = @(
        @($nameEntries | Sort-Object -Property CompletionText) +
        @($idEntries | Sort-Object -Property SortKey)
    )
    $script:ListdllsCompletionCatalog.ProcessCacheUpdated = Get-Date
}

function Update-ListdllsDllCache {
    $lastUpdated = $script:ListdllsCompletionCatalog.DllCacheUpdated
    if ($null -ne $lastUpdated) {
        $cacheAge = (Get-Date) - $lastUpdated
        if ($cacheAge.TotalSeconds -lt $script:ListdllsCompletionCatalog.DllCacheTtlSeconds) {
            return
        }
    }

    # System32 file names are a passive, cheap source; walking (Get-Process).Modules takes seconds.
    $names = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    $systemDirectory = [System.Environment]::SystemDirectory
    if (-not [string]::IsNullOrEmpty($systemDirectory) -and [System.IO.Directory]::Exists($systemDirectory)) {
        try {
            foreach ($path in [System.IO.Directory]::EnumerateFiles($systemDirectory, '*.dll')) {
                [void]$names.Add([System.IO.Path]::GetFileName($path))
            }
        } catch {
            Write-Debug "listdlls DLL enumeration failed: $($_.Exception.Message)"
        }
    }

    # Prebuilt unquoted results keep a warm Tab over the 3700+ names cheap; ListItemText holds the bare name.
    $entries = [System.Collections.Generic.List[object]]::new()
    foreach ($name in $names) {
        $completionText = $name
        if ($name -match $script:ListdllsCompletionCatalog.QuotePattern) {
            $completionText = ConvertTo-ListdllsQuotedValue -Value $name -Quote ''
        }

        $entries.Add([System.Management.Automation.CompletionResult]::new(
                $completionText,
                $name,
                'ParameterValue',
                [System.IO.Path]::Combine($systemDirectory, $name)
            ))
    }

    $script:ListdllsCompletionCatalog.DllEntries = @($entries.ToArray())
    $script:ListdllsCompletionCatalog.DllCacheUpdated = Get-Date
}

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

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

    if ($Quote -eq "'" -or $Value -match $script:ListdllsCompletionCatalog.QuotePattern) {
        return "'" + $Value.Replace("'", "''") + "'"
    }

    $Value
}

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

    Update-ListdllsDllCache

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

    $results = [System.Collections.Generic.List[object]]::new()
    foreach ($entry in $script:ListdllsCompletionCatalog.DllEntries) {
        if (-not $entry.ListItemText.StartsWith($typedValue, [System.StringComparison]::OrdinalIgnoreCase)) {
            continue
        }

        if ([string]::IsNullOrEmpty($quote)) {
            $results.Add($entry)
        } else {
            $results.Add([System.Management.Automation.CompletionResult]::new(
                    (ConvertTo-ListdllsQuotedValue -Value $entry.ListItemText -Quote $quote),
                    $entry.ListItemText,
                    'ParameterValue',
                    $entry.ToolTip
                ))
        }
    }

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

    @(New-ListdllsLiteralValueResults -CurrentValue $CurrentWord -Placeholder $script:ListdllsCompletionCatalog.DllPlaceholder -ToolTip 'DLL name to search for.')
}

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

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

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

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

    Update-ListdllsProcessCache

    $typedValue = Remove-ListdllsOuterQuotes -Value $CurrentWord
    # PIDs are only useful once the user starts typing digits; otherwise they bury the process names.
    $wantsPids = $typedValue -match '^\d+$'
    $results = $script:ListdllsCompletionCatalog.ProcessEntries |
        Where-Object {
            ($_.Kind -eq 'Name' -or $wantsPids) -and
            ([string]::IsNullOrWhiteSpace($typedValue) -or $_.CompletionText.StartsWith($typedValue, [System.StringComparison]::OrdinalIgnoreCase))
        } |
        ForEach-Object {
            New-ListdllsCompletionResult -CompletionText $_.CompletionText -ResultType 'ParameterValue' -ToolTip $_.ToolTip
        }

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

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

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

    $TokensBeforeCurrent = @($TokensBeforeCurrent)

    $usedSwitchLookup = @{}
    $valueContext = $null
    $dllMode = $false
    $verboseUsed = $false
    $unsignedUsed = $false
    $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 -eq '-d') {
            $dllMode = $true
            $usedSwitchLookup[$lookup] = $true
            if ($index -eq ($TokensBeforeCurrent.Count - 1)) {
                $valueContext = '-d'
                break
            }

            $index++
            continue
        }

        if ($lookup.StartsWith('-')) {
            $usedSwitchLookup[$lookup] = $true
            if ($lookup -eq '-v') { $verboseUsed = $true }
            if ($lookup -eq '-u') { $unsignedUsed = $true }
            continue
        }

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

    [pscustomobject]@{
        UsedSwitchLookup = $usedSwitchLookup
        ValueContext     = $valueContext
        DllMode          = $dllMode
        VerboseUsed      = $verboseUsed
        UnsignedUsed     = $unsignedUsed
        ProcessTarget    = $processTarget
        HelpRequested    = $helpRequested
    }
}

function Get-ListdllsSwitchCompletions {
    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:ListdllsCompletionCatalog.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
        }

        # usage: listdlls [-r] [-v | -u] [processname|pid]
        # usage: listdlls [-r] [-v] [-d dllname]
        if ($token -eq '-u' -and ($State.DllMode -or $State.VerboseUsed)) {
            continue
        }

        if ($token -eq '-v' -and $State.UnsignedUsed) {
            continue
        }

        if ($token -eq '-d' -and ($State.ProcessTarget -or $State.UnsignedUsed)) {
            continue
        }

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

    @($results.ToArray())
}

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

    Initialize-ListdllsCompletionCatalog

    $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 })
    # Assign only in the non-empty branches: an empty @() flowing through an if-expression
    # collapses to $null, and @($null) is a one-element array.
    $argumentTokens = [object[]]@()
    if ($commandTokens.Count -gt 1) {
        $argumentTokens = [object[]]@($commandTokens | Select-Object -Skip 1)
    }

    $currentWord = if ([string]::IsNullOrEmpty($wordToComplete)) {
        Get-ListdllsCurrentToken -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))
    $tokensBeforeCurrent = [object[]]@()
    if ($hasTrailingSpace) {
        $tokensBeforeCurrent = [object[]]@($argumentTokens)
    } elseif ($argumentTokens.Count -gt 1) {
        $tokensBeforeCurrent = [object[]]@($argumentTokens | Select-Object -First ($argumentTokens.Count - 1))
    }

    $state = Get-ListdllsCommandState -TokensBeforeCurrent $tokensBeforeCurrent

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

    if ($state.ValueContext -eq '-d') {
        return @(Get-ListdllsDllCompletions -CurrentWord $currentWord)
    }

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

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

        if (-not $state.DllMode -and -not $state.ProcessTarget) {
            foreach ($item in @(Get-ListdllsProcessCompletions -CurrentWord '')) {
                $results.Add($item)
            }
        }

        return @($results.ToArray())
    }

    if (-not $state.DllMode -and -not $state.ProcessTarget) {
        return @(Get-ListdllsProcessCompletions -CurrentWord $currentWord)
    }

    @()
}

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

    Complete-Listdlls -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition
}