completers/schtasks_completer/schtasks_completer.ps1

# schtasks tab completion for PowerShell
# Builds completion data from schtasks built-in help.

Set-StrictMode -Version 2.0

if (-not (Get-Variable -Name SchtasksCompletionCatalog -Scope Script -ErrorAction Ignore)) {
    $script:SchtasksCompletionCatalog = @{
        Initialized          = $false
        Subcommands          = @()
        OptionTokensByKey    = @{}
        ValueHintsByOption   = @{}
        TaskNameCache        = @()
        TaskNameCacheUpdated = $null
        ModifiersBySchedule  = @{}
    }
}

function Invoke-SchtasksHelpText {
    param([string[]]$Arguments)

    if (-not (Get-Command -Name schtasks.exe -ErrorAction SilentlyContinue)) {
        return @()
    }

    @($null | & schtasks.exe @Arguments '/?' 2>$null)
}

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

    $result = @{}
    $inParameterList = $false
    $currentToken = $null

    foreach ($line in $Lines) {
        if ($line -match '^\s*Parameter List:\s*$') {
            $inParameterList = $true
            continue
        }

        if (-not $inParameterList) {
            continue
        }

        if ($line -match '^\s*Examples?:\s*$') {
            break
        }

        # '?' is not a word character, so a \b anchor would drop the '/?' row.
        if ($line -match '^\s*(/(?:\?|[A-Za-z][A-Za-z0-9]*))(?=\s|$)') {
            $currentToken = $matches[1]
            if (-not $result.ContainsKey($currentToken)) {
                $result[$currentToken] = New-Object System.Collections.Generic.List[string]
            }

            $result[$currentToken].Add($line.Trim())
            continue
        }

        if ($currentToken -and -not [string]::IsNullOrWhiteSpace($line)) {
            $result[$currentToken].Add($line.Trim())
        }
    }

    $parameterMap = @{}
    foreach ($token in $result.Keys) {
        $parameterMap[$token] = @($result[$token])
    }

    $parameterMap
}

function Get-SchtasksStaticValueHints {
    @{
        '/sc'  = @('MINUTE', 'HOURLY', 'DAILY', 'WEEKLY', 'MONTHLY', 'ONCE', 'ONSTART', 'ONLOGON', 'ONIDLE', 'ONEVENT')
        '/fo'  = @('TABLE', 'LIST', 'CSV')
        '/rl'  = @('LIMITED', 'HIGHEST')
        '/d'   = @('MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN', '*')
        '/m'   = @('JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC', '*')
        '/xml' = @('ONE')
        '/ru'  = @(
            'SYSTEM',
            'NT AUTHORITY\SYSTEM',
            'NT AUTHORITY\LOCALSERVICE',
            'NT AUTHORITY\NETWORKSERVICE'
        )
    }
}

function Get-SchtasksValueOptionTable {
    # Value-bearing options and how each slot completes. '*' rows apply to every
    # subcommand; a subcommand row overrides them where the help differs
    # (/XML is a file under /Create but an [xml_type] under /Query; /I is a bare
    # switch under /Run). A $null entry marks a bare switch. Optional slots yield
    # to a following switch, as '[password]' and '[xml_type]' document.
    @{
        '*'      = @{
            '/s'     = @{ Kind = 'Placeholder'; Placeholder = '<system>'; ToolTip = 'Remote system to connect to.' }
            '/u'     = @{ Kind = 'Placeholder'; Placeholder = '<domain\user>'; ToolTip = 'User context under which schtasks.exe executes.' }
            '/p'     = @{ Kind = 'Placeholder'; Placeholder = '<password>'; ToolTip = 'Password for the /U user; prompts if omitted.'; Optional = $true }
            '/ru'    = @{ Kind = 'Hint'; ToolTip = 'Run-as user account.' }
            '/rp'    = @{ Kind = 'Placeholder'; Placeholder = '<password>'; ToolTip = 'Password for the run-as user.'; Optional = $true }
            '/tn'    = @{ Kind = 'TaskName'; Placeholder = '<path\taskname>'; ToolTip = 'Task path\name.' }
            '/tr'    = @{ Kind = 'Path'; Extensions = @(); ToolTip = 'Path and file name of the program to run.' }
            '/st'    = @{ Kind = 'Placeholder'; Placeholder = '<HH:mm>'; ToolTip = 'Start time in 24-hour HH:mm format.' }
            '/ri'    = @{ Kind = 'Placeholder'; Placeholder = '<minutes>'; ToolTip = 'Repetition interval in minutes (1 - 599940).' }
            '/et'    = @{ Kind = 'Placeholder'; Placeholder = '<HH:mm>'; ToolTip = 'End time in 24-hour HH:mm format.' }
            '/du'    = @{ Kind = 'Placeholder'; Placeholder = '<HH:mm>'; ToolTip = 'Duration to run the task in HH:mm format.' }
            '/sd'    = @{ Kind = 'Placeholder'; Placeholder = '<mm/dd/yyyy>'; ToolTip = 'First date on which the task runs.' }
            '/ed'    = @{ Kind = 'Placeholder'; Placeholder = '<mm/dd/yyyy>'; ToolTip = 'Last date on which the task runs.' }
            '/rl'    = @{ Kind = 'Hint'; ToolTip = 'Run level for the job.' }
            '/delay' = @{ Kind = 'Placeholder'; Placeholder = '<mmmm:ss>'; ToolTip = 'Delay after the trigger fires (ONSTART, ONLOGON, ONEVENT).' }
            '/fo'    = @{ Kind = 'Hint'; ToolTip = 'Output format.' }
            '/sc'    = @{ Kind = 'Hint'; ToolTip = 'Schedule frequency.' }
            '/mo'    = @{ Kind = 'Modifier'; ToolTip = 'Schedule modifier for the /SC type.' }
            '/d'     = @{ Kind = 'Hint'; ToolTip = 'Day(s) of the week (or day of the month with /SC MONTHLY).' }
            '/m'     = @{ Kind = 'Hint'; ToolTip = 'Month(s) of the year.' }
            '/ec'    = @{ Kind = 'Placeholder'; Placeholder = '<channel-name>'; ToolTip = 'Event channel for OnEvent triggers.' }
            '/i'     = @{ Kind = 'Placeholder'; Placeholder = '<idle-minutes>'; ToolTip = 'Idle time to wait before an ONIDLE task runs (1 - 999 minutes).' }
            '/xml'   = @{ Kind = 'Path'; Extensions = @('.xml'); ToolTip = 'Task XML file.' }
        }
        '/query' = @{
            '/xml' = @{ Kind = 'Hint'; ToolTip = 'ONE writes one valid XML file; omit it for the concatenation of all definitions.'; Optional = $true }
        }
        '/run'   = @{
            '/i' = $null
        }
    }
}

function Get-SchtasksValueSlot {
    param(
        [string]$Subcommand,
        [string]$Option
    )

    $table = Get-SchtasksValueOptionTable
    $optionKey = $Option.ToLowerInvariant()
    if (-not [string]::IsNullOrEmpty($Subcommand)) {
        $subcommandKey = $Subcommand.ToLowerInvariant()
        if ($table.ContainsKey($subcommandKey) -and $table[$subcommandKey].ContainsKey($optionKey)) {
            return $table[$subcommandKey][$optionKey]
        }
    }

    if ($table['*'].ContainsKey($optionKey)) {
        return $table['*'][$optionKey]
    }

    $null
}

function Get-SchtasksStaticModifierMap {
    @{
        'MINUTE'  = @('<1-1439>')
        'HOURLY'  = @('<1-23>')
        'DAILY'   = @('<1-365>')
        'WEEKLY'  = @('<1-52>')
        'MONTHLY' = @('<1-12>', 'FIRST', 'SECOND', 'THIRD', 'FOURTH', 'LAST', 'LASTDAY')
        'ONCE'    = @()
        'ONSTART' = @()
        'ONLOGON' = @()
        'ONIDLE'  = @()
        'ONEVENT' = @('<xpath-event-query>')
    }
}

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

    # Parses the 'Modifiers: Valid values for the /MO switch per schedule type:' block
    # of the /Create help. Numeric ranges become a <min-max> placeholder, keyword
    # lists are offered verbatim, 'No modifiers.' yields an empty list.
    $raw = @{}
    $inBlock = $false
    $currentType = $null

    foreach ($line in $Lines) {
        if ($line -match '^\s*Modifiers:') {
            $inBlock = $true
            continue
        }

        if (-not $inBlock) {
            continue
        }

        if ($line -match '^\s*Examples?:\s*$') {
            break
        }

        if ($line -match '^\s*(?<type>[A-Z]+):\s*(?<rest>.*)$') {
            $currentType = $matches['type']
            $raw[$currentType] = [string]$matches['rest']
            continue
        }

        if ($currentType -and -not [string]::IsNullOrWhiteSpace($line)) {
            $raw[$currentType] += ' ' + $line.Trim()
        }
    }

    $map = @{}
    foreach ($type in $raw.Keys) {
        $text = $raw[$type]
        $values = New-Object System.Collections.Generic.List[string]
        if ($text -match '(\d+)\s*-\s*(\d+)') {
            $values.Add('<' + $matches[1] + '-' + $matches[2] + '>')
        }

        foreach ($keyword in @([regex]::Matches($text, '\b[A-Z]{3,}\b') | ForEach-Object { $_.Value })) {
            $values.Add($keyword)
        }

        if ($values.Count -eq 0 -and $text -match 'XPath') {
            $values.Add('<xpath-event-query>')
        }

        $map[$type] = @($values)
    }

    $map
}

function Get-SchtasksScheduleType {
    param([string[]]$TokensBeforeCurrent)

    $tokens = @($TokensBeforeCurrent)
    for ($index = 0; $index -lt ($tokens.Count - 1); $index++) {
        if ($tokens[$index] -eq '/sc') {
            return $tokens[$index + 1].Trim('"').ToUpperInvariant()
        }
    }

    $null
}

function Get-SchtasksModifierValueList {
    param([string[]]$TokensBeforeCurrent)

    $map = $script:SchtasksCompletionCatalog.ModifiersBySchedule
    if (-not $map -or $map.Count -eq 0) {
        $map = Get-SchtasksStaticModifierMap
    }

    $scheduleType = Get-SchtasksScheduleType -TokensBeforeCurrent $TokensBeforeCurrent
    if ($scheduleType -and $map.ContainsKey($scheduleType)) {
        $values = @($map[$scheduleType])
        if ($values.Count -eq 0) {
            return @([pscustomobject]@{ Value = '<no-modifier>'; ToolTip = "/SC $scheduleType takes no /MO modifier." })
        }

        return @($values | ForEach-Object { [pscustomobject]@{ Value = $_; ToolTip = "/MO value for /SC $scheduleType." } })
    }

    # /SC not typed yet (or unknown): offer the union of every schedule type's modifiers.
    $union = New-Object System.Collections.Generic.List[object]
    foreach ($type in @($map.Keys | Sort-Object)) {
        foreach ($value in @($map[$type])) {
            [void]$union.Add([pscustomobject]@{ Value = $value; ToolTip = "/MO value for /SC $type." })
        }
    }

    @($union.ToArray())
}

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

    $topHelp = Invoke-SchtasksHelpText
    if (-not $topHelp -or $topHelp.Count -eq 0) {
        $script:SchtasksCompletionCatalog.Initialized = $true
        return
    }

    $topParameterMap = Get-SchtasksParameterMap -Lines $topHelp
    $subcommands = @($topParameterMap.Keys | Where-Object { $_ -ne '/?' } | Sort-Object -Unique)

    $script:SchtasksCompletionCatalog.Subcommands = $subcommands
    $script:SchtasksCompletionCatalog.OptionTokensByKey['__top__'] = @($topParameterMap.Keys | Sort-Object -Unique)
    $script:SchtasksCompletionCatalog.ValueHintsByOption = Get-SchtasksStaticValueHints

    foreach ($subcommand in $subcommands) {
        $helpLines = Invoke-SchtasksHelpText -Arguments @($subcommand)
        $parameterMap = Get-SchtasksParameterMap -Lines $helpLines
        $script:SchtasksCompletionCatalog.OptionTokensByKey[$subcommand.ToLowerInvariant()] =
            @($parameterMap.Keys | Sort-Object -Unique)

        if ($subcommand -eq '/Create') {
            $script:SchtasksCompletionCatalog.ModifiersBySchedule = Get-SchtasksModifierMap -Lines $helpLines
        }
    }

    $script:SchtasksCompletionCatalog.Initialized = $true
}

function Get-SchtasksActiveSubcommand {
    param(
        [string[]]$Tokens,
        [string[]]$KnownSubcommands
    )

    $known = @{}
    foreach ($subcommand in $KnownSubcommands) {
        $known[$subcommand.ToLowerInvariant()] = $subcommand
    }

    foreach ($token in $Tokens) {
        $lookup = $token.ToLowerInvariant()
        if ($known.ContainsKey($lookup)) {
            return $known[$lookup]
        }
    }

    $null
}

function ConvertTo-SchtasksQuotedValue {
    param(
        [string]$Value,
        [bool]$AlwaysQuote = $false
    )

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

    # Quote on whitespace or any argument-mode metacharacter ('{' would open a
    # script block, '$' would expand), escaping ` " $ inside the double quotes.
    if ($AlwaysQuote -or $Value -match '[\s{}();,|&<>''"`$]|^[@#]') {
        return '"' + ($Value -replace '([`"$])', '`$1') + '"'
    }

    $Value
}

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

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

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

function Get-SchtasksPathCompletions {
    param(
        [string]$InputPath,
        [string[]]$AllowedExtensions
    )

    $cleanInput = if ([string]::IsNullOrWhiteSpace($InputPath)) { '' } else { $InputPath.Trim('"') }
    $alwaysQuote = [bool]($InputPath -and $InputPath.StartsWith('"'))

    # Split-Path throws on '', and a trailing separator means "list this directory".
    $parent = '.'
    $leaf = ''
    if (-not [string]::IsNullOrWhiteSpace($cleanInput)) {
        if ($cleanInput.EndsWith('\') -or $cleanInput.EndsWith('/')) {
            $parent = $cleanInput
        } else {
            $candidateParent = Split-Path -Path $cleanInput -Parent
            if (-not [string]::IsNullOrWhiteSpace($candidateParent)) {
                $parent = $candidateParent
            }

            $leaf = Split-Path -Path $cleanInput -Leaf
        }
    }

    $items = @(Get-ChildItem -LiteralPath $parent -ErrorAction Ignore | Where-Object {
        [string]::IsNullOrWhiteSpace($leaf) -or $_.Name.StartsWith($leaf, [System.StringComparison]::OrdinalIgnoreCase)
    })
    if ($AllowedExtensions -and $AllowedExtensions.Count -gt 0) {
        $items = @($items | Where-Object {
            $_.PSIsContainer -or ($AllowedExtensions -contains $_.Extension.ToLowerInvariant())
        })
    }

    foreach ($item in $items) {
        $candidate = if ($parent -eq '.') { $item.Name } elseif ([System.IO.Path]::IsPathRooted($cleanInput)) { $item.FullName } else { Join-Path -Path $parent -ChildPath $item.Name }
        if ($item.PSIsContainer -and -not ($candidate.EndsWith('\') -or $candidate.EndsWith('/'))) {
            $candidate += '\'
        }

        ConvertTo-SchtasksQuotedValue -Value $candidate -AlwaysQuote $alwaysQuote
    }
}

function Update-SchtasksTaskNameCache {
    $lastUpdated = $script:SchtasksCompletionCatalog.TaskNameCacheUpdated
    if ($null -ne $lastUpdated) {
        $cacheAge = (Get-Date) - $lastUpdated
        if ($cacheAge.TotalSeconds -lt 60 -and $script:SchtasksCompletionCatalog.TaskNameCache.Count -gt 0) {
            return
        }
    }

    # /NH drops the header row that schtasks otherwise repeats once per task folder.
    $csvLines = @($null | & schtasks.exe /Query /FO CSV /NH 2>$null | ForEach-Object { $_ -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' })
    if ($csvLines.Count -eq 0) {
        $script:SchtasksCompletionCatalog.TaskNameCache = @()
        $script:SchtasksCompletionCatalog.TaskNameCacheUpdated = Get-Date
        return
    }

    $taskNames = $csvLines | ConvertFrom-Csv -Header TaskName, NextRunTime, Status | ForEach-Object { $_.TaskName }

    $script:SchtasksCompletionCatalog.TaskNameCache = @($taskNames | Where-Object { $_ } | Sort-Object -Unique)
    $script:SchtasksCompletionCatalog.TaskNameCacheUpdated = Get-Date
}

function Get-SchtasksTaskNameCompletions {
    param([string]$WordToComplete)

    Update-SchtasksTaskNameCache

    $cleanPrefix = $WordToComplete.Trim('"')
    $alwaysQuote = $WordToComplete.StartsWith('"')
    $pattern = [System.Management.Automation.WildcardPattern]::Escape($cleanPrefix) + '*'

    # Task names carry a leading '\'; match the typed prefix with or without it.
    $script:SchtasksCompletionCatalog.TaskNameCache |
        Where-Object { $_ -like $pattern -or $_.TrimStart('\') -like $pattern } |
        ForEach-Object { ConvertTo-SchtasksQuotedValue -Value $_ -AlwaysQuote $alwaysQuote }
}

function Get-SchtasksExpectedValueOption {
    param(
        [string[]]$TokensBeforeCurrent,
        [string[]]$KnownSubcommands
    )

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

    $lastToken = $TokensBeforeCurrent[-1]
    if (-not $lastToken.StartsWith('/')) {
        return $null
    }

    foreach ($subcommand in $KnownSubcommands) {
        if ($lastToken.Equals($subcommand, [System.StringComparison]::OrdinalIgnoreCase)) {
            return $null
        }
    }

    if ($lastToken -eq '/?') {
        return $null
    }

    $lastToken
}

function Get-SchtasksCurrentToken {
    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-SchtasksValueSlotCompletionList {
    param(
        [hashtable]$Slot,
        [string]$Option,
        [string]$Subcommand,
        [string]$CurrentWord,
        [string[]]$TokensBeforeCurrent
    )

    $word = if ($null -eq $CurrentWord) { '' } else { $CurrentWord }
    $pattern = [System.Management.Automation.WildcardPattern]::Escape($word.Trim('"')) + '*'

    switch ($Slot.Kind) {
        'TaskName' {
            $names = @()
            if ($Subcommand -and -not $Subcommand.Equals('/Create', [System.StringComparison]::OrdinalIgnoreCase)) {
                $names = @(Get-SchtasksTaskNameCompletions -WordToComplete $word)
            }

            if ($names.Count -gt 0) {
                return @($names | ForEach-Object { New-SchtasksCompletionResult -CompletionText $_ -ResultType 'ParameterValue' -ToolTip $_ })
            }

            if ([string]::IsNullOrWhiteSpace($word)) {
                return @(New-SchtasksCompletionResult -CompletionText $Slot.Placeholder -ResultType 'ParameterValue' -ToolTip $Slot.ToolTip)
            }

            return @()
        }
        'Path' {
            return @(Get-SchtasksPathCompletions -InputPath $word -AllowedExtensions $Slot.Extensions |
                ForEach-Object { New-SchtasksCompletionResult -CompletionText $_ -ResultType 'ParameterValue' -ToolTip $_ })
        }
        'Hint' {
            $optionKey = $Option.ToLowerInvariant()
            $hints = @()
            if ($script:SchtasksCompletionCatalog.ValueHintsByOption.ContainsKey($optionKey)) {
                $hints = @($script:SchtasksCompletionCatalog.ValueHintsByOption[$optionKey])
            }

            # Hints are stored unquoted: match past the user's opening quote, then
            # quote on emit with that quote character (double quotes when none was typed).
            $quote = if ($word.StartsWith("'")) { "'" } elseif ($word.StartsWith('"')) { '"' } else { '' }
            $typed = $word
            if ($quote) {
                $typed = $word.Substring(1)
                if ($typed.EndsWith($quote)) {
                    $typed = $typed.Substring(0, $typed.Length - 1)
                }
            }

            $hintPattern = [System.Management.Automation.WildcardPattern]::Escape($typed) + '*'
            return @($hints |
                Where-Object { $_ -like $hintPattern } |
                ForEach-Object {
                    $text = $_
                    if ($quote -eq "'") {
                        $text = "'" + $text.Replace("'", "''") + "'"
                    } elseif ($quote -or $text -match '[\s{}();,|&<>''"`$]|^[@#]') {
                        $text = '"' + ($text -replace '([`"$])', '`$1') + '"'
                    }

                    New-SchtasksCompletionResult -CompletionText $text -ResultType 'ParameterValue' -ToolTip $Slot.ToolTip
                })
        }
        'Modifier' {
            return @(Get-SchtasksModifierValueList -TokensBeforeCurrent $TokensBeforeCurrent |
                Where-Object { $_.Value -like $pattern } |
                ForEach-Object { New-SchtasksCompletionResult -CompletionText $_.Value -ResultType 'ParameterValue' -ToolTip $_.ToolTip })
        }
        'Placeholder' {
            $text = if ([string]::IsNullOrWhiteSpace($word)) { $Slot.Placeholder } else { $word }
            return @(New-SchtasksCompletionResult -CompletionText $text -ResultType 'ParameterValue' -ToolTip $Slot.ToolTip)
        }
    }

    @()
}

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

    Initialize-SchtasksCompletionCatalog

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

    # Only elements that end before the cursor are consumed, so completing inside an
    # earlier token sees the same context as typing it fresh.
    $tokensBeforeCurrent = @(
        $commandAst.CommandElements |
            Select-Object -Skip 1 |
            Where-Object { $_.Extent.EndOffset -lt $cursorPosition } |
            ForEach-Object { $_.Extent.Text }
    )

    $activeSubcommand = Get-SchtasksActiveSubcommand -Tokens $tokensBeforeCurrent -KnownSubcommands $script:SchtasksCompletionCatalog.Subcommands
    $expectedValueOption = Get-SchtasksExpectedValueOption -TokensBeforeCurrent $tokensBeforeCurrent -KnownSubcommands $script:SchtasksCompletionCatalog.Subcommands

    if ($expectedValueOption) {
        $slot = Get-SchtasksValueSlot -Subcommand $activeSubcommand -Option $expectedValueOption
        $yieldsToSwitch = $slot -and $slot.ContainsKey('Optional') -and $slot.Optional -and $currentWord.StartsWith('/')
        if ($slot -and -not $yieldsToSwitch) {
            # A value slot is terminal: never fall through to the option-name list.
            return @(Get-SchtasksValueSlotCompletionList -Slot $slot -Option $expectedValueOption -Subcommand $activeSubcommand -CurrentWord $currentWord -TokensBeforeCurrent $tokensBeforeCurrent)
        }
    }

    if (-not $activeSubcommand) {
        $topSuggestions = @($script:SchtasksCompletionCatalog.Subcommands + '/?')
        if ([string]::IsNullOrWhiteSpace($currentWord) -or $currentWord.StartsWith('/')) {
            return $topSuggestions |
                Sort-Object -Unique |
                Where-Object { $_ -like ([System.Management.Automation.WildcardPattern]::Escape($currentWord) + '*') } |
                ForEach-Object {
                    New-SchtasksCompletionResult -CompletionText $_ -ResultType 'ParameterName' -ToolTip $_
                }
        }

        return @()
    }

    if ([string]::IsNullOrWhiteSpace($currentWord) -or $currentWord.StartsWith('/')) {
        $optionKey = $activeSubcommand.ToLowerInvariant()
        $suggestions = @()

        if ($script:SchtasksCompletionCatalog.OptionTokensByKey.ContainsKey($optionKey)) {
            $suggestions = @($script:SchtasksCompletionCatalog.OptionTokensByKey[$optionKey])
        }

        return $suggestions |
            Sort-Object -Unique |
            Where-Object { $_ -like ([System.Management.Automation.WildcardPattern]::Escape($currentWord) + '*') } |
            ForEach-Object {
                New-SchtasksCompletionResult -CompletionText $_ -ResultType 'ParameterName' -ToolTip $_
            }
    }

    @()
}