completers/psloglist_completer/psloglist_completer.ps1

# psloglist tab completion for PowerShell
# Static syntax completer with cached local-only event log and provider hints when not targeting a remote system.

Set-StrictMode -Version 2.0

function Initialize-PsLogListCompletionCatalog {
    if (-not (Get-Variable -Name PsLogListCompletionCatalog -Scope Script -ErrorAction Ignore)) {
        $script:PsLogListCompletionCatalog = @{
            Switches = @(
                [pscustomobject]@{ Token = '-u'; Description = 'Optional user name for remote login.'; TakesValue = $true; ValueKind = 'User' }
                [pscustomobject]@{ Token = '-p'; Description = 'Optional password for remote login.'; TakesValue = $true; ValueKind = 'Password' }
                [pscustomobject]@{ Token = '-a'; Description = 'Dump records timestamped after the specified date.'; TakesValue = $true; ValueKind = 'Date' }
                [pscustomobject]@{ Token = '-b'; Description = 'Dump records timestamped before the specified date.'; TakesValue = $true; ValueKind = 'Date' }
                [pscustomobject]@{ Token = '-c'; Description = 'Clear event log after displaying it.'; TakesValue = $false }
                [pscustomobject]@{ Token = '-d'; Description = 'Display only records from the previous n days.'; TakesValue = $true; ValueKind = 'Number' }
                [pscustomobject]@{ Token = '-e'; Description = 'Exclude the specified event IDs (up to 10; not combinable with -i).'; TakesValue = $true; ValueKind = 'Ids' }
                [pscustomobject]@{ Token = '-f'; Description = 'Filter event types by starting letter.'; TakesValue = $true; ValueKind = 'Filter' }
                [pscustomobject]@{ Token = '-g'; Description = 'Export an event log as an evt file.'; TakesValue = $true; ValueKind = 'ExportPath' }
                [pscustomobject]@{ Token = '-h'; Description = 'Display only records from the previous n hours.'; TakesValue = $true; ValueKind = 'Number' }
                [pscustomobject]@{ Token = '-i'; Description = 'Show only the specified event IDs (up to 10; not combinable with -e).'; TakesValue = $true; ValueKind = 'Ids' }
                [pscustomobject]@{ Token = '-l'; Description = 'Dump the contents of the specified saved event log file.'; TakesValue = $true; ValueKind = 'SavedLogPath' }
                [pscustomobject]@{ Token = '-m'; Description = 'Display only records from the previous n minutes.'; TakesValue = $true; ValueKind = 'Number' }
                [pscustomobject]@{ Token = '-n'; Description = 'Display only the n most recent records.'; TakesValue = $true; ValueKind = 'Number' }
                [pscustomobject]@{ Token = '-o'; Description = 'Show only records from the specified event sources. Append * for a substring match.'; TakesValue = $true; ValueKind = 'Sources' }
                [pscustomobject]@{ Token = '-q'; Description = 'Omit records from the specified event sources. Append * for a substring match.'; TakesValue = $true; ValueKind = 'Sources' }
                [pscustomobject]@{ Token = '-r'; Description = 'Dump from least recent to most recent.'; TakesValue = $false }
                [pscustomobject]@{ Token = '-s'; Description = 'List records on one line each with delimited fields.'; TakesValue = $false }
                [pscustomobject]@{ Token = '-t'; Description = 'Delimiter used with -s. Use "\t" for tab.'; TakesValue = $true; ValueKind = 'Delimiter' }
                [pscustomobject]@{ Token = '-w'; Description = 'Wait for new events and dump them as they are generated (local only).'; TakesValue = $false }
                [pscustomobject]@{ Token = '-x'; Description = 'Dump extended data.'; TakesValue = $false }
                [pscustomobject]@{ Token = '-z'; Description = 'List event logs registered on the specified system.'; TakesValue = $false }
                [pscustomobject]@{ Token = '-nobanner'; Description = 'Do not display the startup banner and copyright message.'; TakesValue = $false }
                [pscustomobject]@{ Token = '-?'; Description = 'Display PsLogList help.'; TakesValue = $false; Terminal = $true }
                [pscustomobject]@{ Token = '/?'; Description = 'Display PsLogList help.'; TakesValue = $false; Terminal = $true }
            )
            NumberHints    = @('1', '5', '10', '30', '60', '100')
            DateHints      = @('<mm/dd/yy>', '01/01/24', '12/31/24')
            FilterHints    = @('e', 'w', 'we', 'i', 's', 'se')
            DelimiterHints = @(',', ';', '|', ':', '\t')
            EventIdHints   = @('1000', '4624', '4625', '6005', '6006', '<event-id>')
            StaticLogHints = @('System', 'Application', 'Security')
            StaticSourceHints = @('Service Control Manager', 'Application Error')
            DynamicCache   = @{
                TtlSeconds      = 120
                LogNames        = @()
                LogNamesUpdated = [datetime]::MinValue
                SourcesByLog    = @{}
            }
        }
    }
}

function New-PsLogListCompletionResult {
    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-PsLogListOuterQuotes {
    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 ConvertTo-PsLogListQuotedValue {
    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('"'))) {
        return '"' + $Value.Replace('`', '``').Replace('"', '`"') + '"'
    }
    $Value
}

function Get-PsLogListArgumentState {
    param([System.Management.Automation.Language.CommandAst]$CommandAst, [string]$WordToComplete, [int]$CursorPosition)
    # Tokens that end before the cursor are "before"; the element under the cursor is the current word, truncated at
    # the cursor; anything to the right of the cursor is ignored.
    $tokensBeforeCurrent = New-Object System.Collections.Generic.List[string]
    $currentWord = ''
    foreach ($element in @($CommandAst.CommandElements | Select-Object -Skip 1)) {
        $extent = $element.Extent
        if ($extent.EndOffset -lt $CursorPosition) {
            [void]$tokensBeforeCurrent.Add($extent.Text)
            continue
        }
        if ($extent.StartOffset -le $CursorPosition) {
            $currentWord = $extent.Text.Substring(0, $CursorPosition - $extent.StartOffset)
        }
        break
    }
    if ([string]::IsNullOrEmpty($currentWord) -and -not [string]::IsNullOrEmpty($WordToComplete)) { $currentWord = $WordToComplete }
    [pscustomobject]@{
        CurrentWord         = $currentWord
        TokensBeforeCurrent = @($tokensBeforeCurrent.ToArray())
    }
}

function Invoke-PsLogListCapture {
    param([string[]]$Arguments, [int]$TimeoutMilliseconds = 1500)
    # Only run the tool when its EULA has already been accepted, so completion can never pop the licence dialog.
    $eula = Get-ItemProperty -Path 'HKCU:\Software\Sysinternals\PsLoglist' -Name 'EulaAccepted' -ErrorAction Ignore
    if (-not $eula -or [int]$eula.EulaAccepted -ne 1) { return @() }
    $command = Get-Command -Name 'psloglist.exe', 'psloglist' -CommandType Application -ErrorAction Ignore | Select-Object -First 1
    if (-not $command) { return @() }
    try {
        $startInfo = [System.Diagnostics.ProcessStartInfo]::new()
        $startInfo.FileName = $command.Source
        $startInfo.UseShellExecute = $false
        $startInfo.RedirectStandardInput = $true
        $startInfo.RedirectStandardOutput = $true
        $startInfo.RedirectStandardError = $true
        $startInfo.CreateNoWindow = $true
        foreach ($argument in @($Arguments)) { [void]$startInfo.ArgumentList.Add($argument) }
        $process = [System.Diagnostics.Process]::new()
        $process.StartInfo = $startInfo
        [void]$process.Start()
        $process.StandardInput.Close()
        $outputTask = $process.StandardOutput.ReadToEndAsync()
        $errorTask = $process.StandardError.ReadToEndAsync()
        if (-not $process.WaitForExit($TimeoutMilliseconds)) {
            try { $process.Kill($true) } catch { Write-Debug -Message $_.Exception.Message }
            return @()
        }
        [void]$errorTask.Result
        @(($outputTask.Result -replace '\e\[[0-9;?]*[ -/]*[@-~]', '') -split '\r?\n')
    } catch {
        @()
    }
}

function Get-PsLogListLogNameList {
    # Local log names from the tool's own -z listing (about 40 ms, no elevation needed), cached behind a TTL that is
    # armed on every path so a failed harvest is not retried per keystroke.
    Initialize-PsLogListCompletionCatalog
    $cache = $script:PsLogListCompletionCatalog.DynamicCache
    $age = (Get-Date) - $cache.LogNamesUpdated
    if ($age.TotalSeconds -lt $cache.TtlSeconds) { return @($cache.LogNames) }

    $names = New-Object System.Collections.Generic.List[string]
    foreach ($line in @(Invoke-PsLogListCapture -Arguments @('-nobanner', '-z'))) {
        if ($line -match '^\s+(\S.*?)\s*$') { [void]$names.Add($matches[1]) }
    }
    if ($names.Count -eq 0) {
        foreach ($log in @(Get-WinEvent -ListLog * -ErrorAction Ignore)) {
            if (-not [string]::IsNullOrWhiteSpace($log.LogName)) { [void]$names.Add($log.LogName) }
        }
    }
    $cache.LogNames = @($names | Sort-Object -Unique)
    $cache.LogNamesUpdated = Get-Date
    @($cache.LogNames)
}

function Get-PsLogListSourceNameList {
    param([string]$LogName)
    # Provider names of the one log named on the line (default System) instead of a whole-machine sweep.
    Initialize-PsLogListCompletionCatalog
    $log = if ([string]::IsNullOrWhiteSpace($LogName)) { 'System' } else { $LogName }
    $key = $log.ToLowerInvariant()
    $cache = $script:PsLogListCompletionCatalog.DynamicCache
    if ($cache.SourcesByLog.ContainsKey($key)) { return @($cache.SourcesByLog[$key]) }

    $providers = @()
    try {
        $info = Get-WinEvent -ListLog $log -ErrorAction Ignore
        if ($info) {
            $providers = @($info.ProviderNames | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
        }
    } catch {
        $providers = @()
    }
    $cache.SourcesByLog[$key] = $providers
    @($providers)
}

function Select-PsLogListPrefixMatch {
    param([string[]]$Values, [string]$CurrentWord)
    $typed = Remove-PsLogListOuterQuotes -Value $CurrentWord
    if ([string]::IsNullOrEmpty($typed)) { return @($Values) }
    @($Values | Where-Object { $_.StartsWith($typed, [System.StringComparison]::OrdinalIgnoreCase) })
}

function Get-PsLogListPathCompletions {
    param([string]$CurrentWord, [string[]]$Extensions, [string]$ToolTip, [string]$Prefix = '', [string]$Placeholder = '<path>')
    $trimmed = Remove-PsLogListOuterQuotes -Value $CurrentWord
    if ($Prefix -and $trimmed.StartsWith($Prefix)) { $trimmed = $trimmed.Substring($Prefix.Length) }

    # Split on the last separator instead of Split-Path: '' and '.\' must list the current directory, not throw or
    # resolve to the directory's own name.
    $separatorIndex = $trimmed.LastIndexOfAny([char[]]@('\', '/'))
    if ($separatorIndex -ge 0) {
        $parentText = $trimmed.Substring(0, $separatorIndex + 1)
        $leaf = $trimmed.Substring($separatorIndex + 1)
    } else {
        $parentText = ''
        $leaf = $trimmed
    }
    $parent = if ([string]::IsNullOrEmpty($parentText)) { '.' } else { $parentText }
    $pattern = [System.Management.Automation.WildcardPattern]::Escape($leaf) + '*'
    $alwaysQuote = -not [string]::IsNullOrEmpty($CurrentWord) -and $CurrentWord.StartsWith('"')
    $allowedExtensions = @($Extensions | ForEach-Object { $_.ToLowerInvariant() })

    $results = foreach ($item in @(Get-ChildItem -LiteralPath $parent -ErrorAction SilentlyContinue | Where-Object { $_.Name -like $pattern } | Sort-Object -Property Name)) {
        if (-not $item.PSIsContainer -and $allowedExtensions.Count -gt 0 -and ($item.Extension.ToLowerInvariant() -notin $allowedExtensions)) { continue }
        $completionPath = $parentText + $item.Name
        if ($item.PSIsContainer) { $completionPath += [System.IO.Path]::DirectorySeparatorChar }
        New-PsLogListCompletionResult -CompletionText (ConvertTo-PsLogListQuotedValue -Value ($Prefix + $completionPath) -AlwaysQuote:$alwaysQuote) -ListItemText ($Prefix + $item.Name) -ResultType $(if ($item.PSIsContainer) { 'ProviderContainer' } else { 'ParameterValue' }) -ToolTip $(if ($ToolTip) { $ToolTip } else { $item.FullName })
    }

    if (@($results).Count -eq 0) {
        if ([string]::IsNullOrWhiteSpace($trimmed)) {
            return @(New-PsLogListCompletionResult -CompletionText ($Prefix + $Placeholder) -ResultType 'ParameterValue' -ToolTip $ToolTip)
        }
        return @()
    }

    @($results)
}

function Get-PsLogListAtFileCompletions {
    param([string]$CurrentWord)
    $trimmed = Remove-PsLogListOuterQuotes -Value $CurrentWord
    if (-not $trimmed.StartsWith('@')) { return @() }
    Get-PsLogListPathCompletions -CurrentWord $CurrentWord -Extensions @() -ToolTip 'File containing remote computer names for @file syntax.' -Prefix '@' -Placeholder 'file'
}

function Get-PsLogListCsvValueCompletions {
    param([string]$CurrentWord, [string[]]$Values, [string]$ToolTip)
    $typed = Remove-PsLogListOuterQuotes -Value $CurrentWord
    $commaIndex = $typed.LastIndexOf(',')
    $prefix = if ($commaIndex -ge 0) { $typed.Substring(0, $commaIndex + 1) } else { '' }
    $currentSegment = if ($commaIndex -ge 0) { $typed.Substring($commaIndex + 1) } else { $typed }
    $results = foreach ($value in @($Values)) {
        if (-not [string]::IsNullOrWhiteSpace($currentSegment) -and -not $value.StartsWith($currentSegment, [System.StringComparison]::OrdinalIgnoreCase)) { continue }
        New-PsLogListCompletionResult -CompletionText ($prefix + $value) -ListItemText $value -ResultType 'ParameterValue' -ToolTip $ToolTip
    }
    @($results)
}

function Complete-PsLogList {
    param(
        [string]$WordToComplete,
        [System.Management.Automation.Language.CommandAst]$CommandAst,
        [int]$CursorPosition
    )

    Initialize-PsLogListCompletionCatalog

    $state = Get-PsLogListArgumentState -CommandAst $CommandAst -WordToComplete $WordToComplete -CursorPosition $CursorPosition
    $currentWord = $state.CurrentWord
    $tokensBeforeCurrent = @($state.TokensBeforeCurrent)
    $switchLookup = @{}
    foreach ($spec in $script:PsLogListCompletionCatalog.Switches) { $switchLookup[$spec.Token.ToLowerInvariant()] = $spec }

    $used = @{}
    $valueContext = $null
    $remoteTarget = $null
    $eventLog = $null
    for ($i = 0; $i -lt $tokensBeforeCurrent.Count; $i++) {
        $token = $tokensBeforeCurrent[$i]
        $key = $token.ToLowerInvariant()
        if ($switchLookup.ContainsKey($key)) {
            $used[$key] = $true
            $spec = $switchLookup[$key]
            if ($spec.TakesValue) {
                if ($i -eq ($tokensBeforeCurrent.Count - 1)) { $valueContext = $spec.ValueKind; break }
                $i++
            }
            continue
        }

        if (-not $remoteTarget -and ((Remove-PsLogListOuterQuotes -Value $token).StartsWith('\\') -or (Remove-PsLogListOuterQuotes -Value $token).StartsWith('@'))) {
            $remoteTarget = $token
            continue
        }

        if (-not $eventLog) { $eventLog = Remove-PsLogListOuterQuotes -Value $token }
    }

    $remoteMode = [bool]$remoteTarget

    switch ($valueContext) {
        'User' {
            return @(
                New-PsLogListCompletionResult -CompletionText '<username>' -ResultType 'ParameterValue' -ToolTip 'Remote user name.'
                New-PsLogListCompletionResult -CompletionText '<domain\user>' -ResultType 'ParameterValue' -ToolTip 'Remote user name in Domain\User syntax.'
            )
        }
        'Password' {
            return @(New-PsLogListCompletionResult -CompletionText $(if ([string]::IsNullOrWhiteSpace($currentWord)) { '<password>' } else { $currentWord }) -ResultType 'ParameterValue' -ToolTip 'Remote password value.')
        }
        'Date' {
            return @(Select-PsLogListPrefixMatch -Values $script:PsLogListCompletionCatalog.DateHints -CurrentWord $currentWord | ForEach-Object { New-PsLogListCompletionResult -CompletionText $_ -ResultType 'ParameterValue' -ToolTip 'Date in mm/dd/yy form.' })
        }
        'Number' {
            return @(Select-PsLogListPrefixMatch -Values $script:PsLogListCompletionCatalog.NumberHints -CurrentWord $currentWord | ForEach-Object { New-PsLogListCompletionResult -CompletionText $_ -ResultType 'ParameterValue' -ToolTip 'Numeric PsLogList value.' })
        }
        'Ids' {
            return Get-PsLogListCsvValueCompletions -CurrentWord $currentWord -Values $script:PsLogListCompletionCatalog.EventIdHints -ToolTip 'Comma-separated event IDs (up to 10).'
        }
        'Filter' {
            return @(Select-PsLogListPrefixMatch -Values $script:PsLogListCompletionCatalog.FilterHints -CurrentWord $currentWord | ForEach-Object { New-PsLogListCompletionResult -CompletionText $_ -ResultType 'ParameterValue' -ToolTip 'Event type filter letters, for example "we" for warning and error.' })
        }
        'ExportPath' {
            return Get-PsLogListPathCompletions -CurrentWord $currentWord -Extensions @('.evt', '.evtx') -ToolTip 'Path for exported event log output.'
        }
        'SavedLogPath' {
            return Get-PsLogListPathCompletions -CurrentWord $currentWord -Extensions @('.evt', '.evtx') -ToolTip 'Saved event log file path.'
        }
        'Delimiter' {
            return @(Select-PsLogListPrefixMatch -Values $script:PsLogListCompletionCatalog.DelimiterHints -CurrentWord $currentWord | ForEach-Object { New-PsLogListCompletionResult -CompletionText $_ -ResultType 'ParameterValue' -ToolTip 'Delimiter used with -s.' })
        }
        'Sources' {
            $sourceValues = if ($remoteMode) {
                @('<event-source>') + $script:PsLogListCompletionCatalog.StaticSourceHints
            } else {
                @(@(Get-PsLogListSourceNameList -LogName $eventLog) + $script:PsLogListCompletionCatalog.StaticSourceHints | Sort-Object -Unique)
            }
            $sourceValues = @($sourceValues) + @('<source>*')
            return Get-PsLogListCsvValueCompletions -CurrentWord $currentWord -Values $sourceValues -ToolTip 'Comma-separated event source or publisher names; append * for a substring match.'
        }
    }

    if ($currentWord.StartsWith('@') -or $currentWord.StartsWith('"@')) {
        return Get-PsLogListAtFileCompletions -CurrentWord $currentWord
    }

    $results = New-Object System.Collections.Generic.List[object]
    if (-not $remoteTarget) {
        foreach ($target in @('\\<computer>', '\\<computer>,<computer2>', '\\localhost', '\\*', '@file')) {
            if ([string]::IsNullOrWhiteSpace($currentWord) -or $target.StartsWith((Remove-PsLogListOuterQuotes -Value $currentWord), [System.StringComparison]::OrdinalIgnoreCase)) {
                [void]$results.Add((New-PsLogListCompletionResult -CompletionText $target -ResultType 'ParameterValue' -ToolTip 'Remote target placeholder for PsLogList (comma-separated list allowed).'))
            }
        }
    }

    foreach ($spec in $script:PsLogListCompletionCatalog.Switches) {
        $key = $spec.Token.ToLowerInvariant()
        if ($used.ContainsKey($key)) { continue }
        if (($key -in @('-u', '-p')) -and -not $remoteTarget) { continue }
        if ($key -eq '-t' -and -not $used.ContainsKey('-s')) { continue }
        if ($key -eq '-e' -and $used.ContainsKey('-i')) { continue }
        if ($key -eq '-i' -and $used.ContainsKey('-e')) { continue }
        $isTerminal = [bool]($spec.PSObject.Properties['Terminal'] -and $spec.Terminal)
        if ($isTerminal -and $tokensBeforeCurrent.Count -gt 0) { continue }
        if (-not [string]::IsNullOrWhiteSpace($currentWord) -and -not $spec.Token.StartsWith($currentWord, [System.StringComparison]::OrdinalIgnoreCase)) { continue }
        [void]$results.Add((New-PsLogListCompletionResult -CompletionText $spec.Token -ResultType 'ParameterName' -ToolTip $spec.Description))
    }

    if (-not $eventLog) {
        $logHints = @($script:PsLogListCompletionCatalog.StaticLogHints)
        if (-not $remoteMode) { $logHints += Get-PsLogListLogNameList }
        $logToolTip = if ($used.ContainsKey('-l')) {
            'Event log name that tells PsLogList how to interpret the saved log file.'
        } elseif ($remoteMode) {
            'Event log name hint.'
        } else {
            'Local event log name.'
        }
        $typedLog = Remove-PsLogListOuterQuotes -Value $currentWord
        foreach ($log in ($logHints | Sort-Object -Unique)) {
            if ([string]::IsNullOrWhiteSpace($typedLog) -or $log.StartsWith($typedLog, [System.StringComparison]::OrdinalIgnoreCase)) {
                [void]$results.Add((New-PsLogListCompletionResult -CompletionText (ConvertTo-PsLogListQuotedValue -Value $log) -ListItemText $log -ResultType 'ParameterValue' -ToolTip $logToolTip))
            }
        }
    }

    @($results.ToArray())
}


Register-ArgumentCompleter -Native -CommandName @('psloglist', 'psloglist.exe') -ScriptBlock {
    param($wordToComplete, $commandAst, $cursorPosition)
    Complete-PsLogList -WordToComplete $wordToComplete -CommandAst $commandAst -CursorPosition $cursorPosition
}