completers/wc_completer/wc_completer.ps1

# wc tab completion for PowerShell
# Builds a help-driven option catalog for wc.exe and adds value-aware completion.
# The catalog parser is format-agnostic: it understands both the clap-based coreutils
# synopsis style (`-x, --long <PLACEHOLDER>` with separate-token placeholders) and the
# GNU wc style (`--long=PLACEHOLDER` with attached placeholders), so the same script
# adapts to whichever build (uutils coreutils or GNU coreutils) resolves first on PATH.

Set-StrictMode -Version 2.0

function New-WcCompletionResult {
    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 Get-WcTotalValues {
    @('auto', 'always', 'only', 'never')
}

function Get-WcCompletionCatalog {
    if (Get-Variable -Name WcCompletionCatalog -Scope Script -ErrorAction Ignore) {
        return $script:WcCompletionCatalog
    }

    $script:WcCompletionCatalog = @{
        Initialized   = $false
        CommandName   = $null
        Options       = @()
        OptionByToken = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal)
        TotalValues   = Get-WcTotalValues
    }

    $script:WcCompletionCatalog
}

function Resolve-WcCommandName {
    $catalog = Get-WcCompletionCatalog
    if ($catalog.CommandName) {
        return $catalog.CommandName
    }

    $command = Get-Command -Name wc.exe, wc -ErrorAction Ignore | Select-Object -First 1
    if ($command) {
        $catalog.CommandName = if ($command.Source) { $command.Source } else { $command.Name }
    }

    $catalog.CommandName
}

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

    $commandName = Resolve-WcCommandName
    if (-not $commandName) {
        return @()
    }

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

function Remove-WcOuterQuotes {
    param([string]$Value)

    if ($null -eq $Value) {
        return ''
    }

    $Value.Trim([char[]]@([char]34, [char]39))
}

function ConvertTo-WcQuotedValue {
    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('"'))) {
        $escaped = $Value.Replace('`', '``').Replace('"', '`"')
        return '"' + $escaped + '"'
    }

    $Value
}

function Get-WcTokenText {
    param([System.Management.Automation.Language.Ast]$Element)

    if ($Element -is [System.Management.Automation.Language.StringConstantExpressionAst]) {
        return $Element.Value
    }

    if ($Element -is [System.Management.Automation.Language.CommandParameterAst]) {
        return $Element.Extent.Text
    }

    $Element.Extent.Text
}

function Get-WcCurrentToken {
    param(
        [string]$Line,
        [int]$CursorPosition,
        [string]$Fallback
    )

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

    $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-WcArgumentTokens {
    param(
        [System.Management.Automation.Language.CommandAst]$CommandAst,
        [int]$CursorPosition
    )

    $tokens = @()
    foreach ($element in $CommandAst.CommandElements | Select-Object -Skip 1) {
        if ($element.Extent.EndOffset -lt $CursorPosition) {
            $tokens += Get-WcTokenText -Element $element
        }
    }

    $tokens
}

function Get-WcValueKind {
    param(
        [string]$Token,
        [string]$Placeholder
    )

    $tokenKey = if ($Token.StartsWith('--')) { $Token.ToLowerInvariant() } else { $Token }
    $placeholderKey = if ([string]::IsNullOrWhiteSpace($Placeholder)) { '' } else { $Placeholder.ToUpperInvariant() }

    switch -CaseSensitive ($tokenKey) {
        '--files0-from' { return 'FilesFrom' }
        '--total' { return 'Total' }
    }

    switch ($placeholderKey) {
        'F' { return 'FilesFrom' }
        'WHEN' { return 'Total' }
    }

    $null
}

function Get-WcCanonicalOptionKey {
    param([string]$Token)

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

    if ($Token.StartsWith('--')) {
        return 'long:' + $Token.ToLowerInvariant()
    }

    if ($Token.StartsWith('-')) {
        return 'short:' + $Token
    }

    $null
}

function ConvertFrom-WcOptionSpecLine {
    param([string]$Line)

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

    # Separate the option synopsis from any inline description. Both GNU and clap help
    # put the description after a run of two or more spaces, so cut at the first such gap.
    $specPart = ($Line -replace '^\s+', '')
    $description = ''
    if ($specPart -match '^(?<spec>.*?\S)\s{2,}(?<desc>\S.*)$') {
        $specPart = $matches['spec']
        $description = $matches['desc'].Trim()
    }
    $specPart = $specPart.TrimEnd()

    # Trim a trailing comma left by split alias lines.
    $specLine = $specPart.TrimEnd(',').Trim()
    if ([string]::IsNullOrWhiteSpace($specLine)) {
        return @($results.ToArray())
    }

    $parts = $specLine -split ',\s+', 2

    if ($parts.Count -eq 2 -and $parts[0].Trim().StartsWith('-') -and -not $parts[0].Trim().StartsWith('--')) {
        $shortPart = $parts[0].Trim()
        $longPart = $parts[1].Trim()

        if ($shortPart -match '^(?<token>-[A-Za-z0-9])(?:[= ]\[?(?<value>[^\]\s]+)\]?)?$') {
            [void]$results.Add([pscustomobject]@{
                    Token       = $matches['token']
                    DisplayText = $shortPart
                    Placeholder = $matches['value']
                    Description = $description
                })
        }

        if ($longPart -match '^(?<token>--[A-Za-z0-9][A-Za-z0-9\-]*)(?:\[?[= ]\<?(?<value>[^\]\s>]+)\>?\]?)?$') {
            [void]$results.Add([pscustomobject]@{
                    Token       = $matches['token']
                    DisplayText = $longPart
                    Placeholder = $matches['value']
                    Description = $description
                })
        }
    } elseif ($specLine -match '^(?<token>--[A-Za-z0-9][A-Za-z0-9\-]*)(?:\[?[= ]\<?(?<value>[^\]\s>]+)\>?\]?)?$') {
        [void]$results.Add([pscustomobject]@{
                Token       = $matches['token']
                DisplayText = $specLine
                Placeholder = $matches['value']
                Description = $description
            })
    } elseif ($specLine -match '^(?<token>-[A-Za-z0-9])(?:[= ]\[?(?<value>[^\]\s]+)\]?)?$') {
        # Short-only option line.
        [void]$results.Add([pscustomobject]@{
                Token       = $matches['token']
                DisplayText = $specLine
                Placeholder = $matches['value']
                Description = $description
            })
    }

    @($results.ToArray())
}

function Add-WcOptionSpec {
    param(
        [string]$Token,
        [string]$DisplayText,
        [string]$Description,
        [string]$Placeholder
    )

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

    $catalog = Get-WcCompletionCatalog
    $key = Get-WcCanonicalOptionKey -Token $Token
    if ($catalog.OptionByToken.ContainsKey($key)) {
        if (-not [string]::IsNullOrWhiteSpace($Description)) {
            $catalog.OptionByToken[$key].Description = $Description
        }
        return
    }

    $catalog.OptionByToken[$key] = [pscustomobject]@{
        Token       = $Token
        DisplayText = if ([string]::IsNullOrWhiteSpace($DisplayText)) { $Token } else { $DisplayText }
        Description = $Description
        Placeholder = $Placeholder
        ValueKind   = Get-WcValueKind -Token $Token -Placeholder $Placeholder
    }

    $catalog.Options += $catalog.OptionByToken[$key]
}

function Initialize-WcCompletionCatalog {
    $catalog = Get-WcCompletionCatalog
    if ($catalog.Initialized) {
        return
    }

    $helpLines = Invoke-WcCapture -Arguments @('--help')
    $currentKeys = @()

    foreach ($line in @($helpLines)) {
        # A synopsis line is indented and begins (after indent) with an option token:
        # short pair "-x, --long", long-only " --long", or short-only " -I".
        $isSynopsis = ($line -match '^\s{2,}-[A-Za-z](?:[, =\[]|$)' -or $line -match '^\s{2,}--[A-Za-z0-9]')

        if ($isSynopsis) {
            $parsed = @(ConvertFrom-WcOptionSpecLine -Line $line)
            if ($parsed.Count -gt 0) {
                $currentKeys = @()

                foreach ($spec in $parsed) {
                    Add-WcOptionSpec -Token $spec.Token -DisplayText $spec.DisplayText -Description $spec.Description -Placeholder $spec.Placeholder
                    $currentKeys += Get-WcCanonicalOptionKey -Token $spec.Token
                }

                continue
            }
        }

        if ($currentKeys.Count -gt 0 -and $line -match '^\s{8,}(?<text>\S.*)$') {
            $continuation = $matches['text'].Trim()
            foreach ($key in $currentKeys) {
                $option = $catalog.OptionByToken[$key]
                if ([string]::IsNullOrWhiteSpace($option.Description)) {
                    $option.Description = $continuation
                } else {
                    $option.Description += ' ' + $continuation
                }
            }

            continue
        }

        if ([string]::IsNullOrWhiteSpace($line) -or $line -match '^\S') {
            $currentKeys = @()
        }
    }

    $catalog.Initialized = $true
}

function Get-WcPathCompletions {
    param(
        [string]$InputPath,
        [string]$Prefix = '',
        [switch]$DirectoriesOnly
    )

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

    if ([string]::IsNullOrWhiteSpace($cleanInput)) {
        $parent = '.'
        $leaf = ''
    } elseif ($cleanInput.EndsWith('\') -or $cleanInput.EndsWith('/')) {
        $parent = $cleanInput
        $leaf = ''
    } else {
        $parent = Split-Path -Path $cleanInput -Parent
        if ([string]::IsNullOrWhiteSpace($parent)) {
            $parent = '.'
        }
        $leaf = Split-Path -Path $cleanInput -Leaf
    }

    $filter = if ([string]::IsNullOrWhiteSpace($leaf)) { '*' } else { "$leaf*" }
    $items = @(Get-ChildItem -Path $parent -Filter $filter -ErrorAction Ignore)
    if ($DirectoriesOnly) {
        $items = @($items | Where-Object { $_.PSIsContainer })
    }

    foreach ($item in $items) {
        $completionText = if (-not [System.IO.Path]::IsPathRooted($cleanInput)) {
            if ($parent -eq '.') {
                # A bare name that starts with '-', '@' or '#' would read as an option,
                # a splat or a comment, so anchor it to the current directory.
                if ($item.Name -match '^[-@#]') { '.' + [System.IO.Path]::DirectorySeparatorChar + $item.Name } else { $item.Name }
            } else {
                Join-Path -Path $parent -ChildPath $item.Name
            }
        } else {
            $item.FullName
        }

        if ($item.PSIsContainer -and -not $completionText.EndsWith([System.IO.Path]::DirectorySeparatorChar)) {
            $completionText += [System.IO.Path]::DirectorySeparatorChar
        }

        $completionText = ConvertTo-WcQuotedValue -Value $completionText -AlwaysQuote $alwaysQuote
        $completionText = $Prefix + $completionText

        New-WcCompletionResult -CompletionText $completionText -ListItemText $item.Name -ResultType 'ParameterValue' -ToolTip $item.FullName
    }
}

function New-WcLiteralValueResults {
    param(
        [string]$CurrentValue,
        [string]$Placeholder,
        [string]$ToolTip,
        [string]$Prefix = ''
    )

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

    @(
        New-WcCompletionResult -CompletionText ($Prefix + $CurrentValue) -ListItemText $CurrentValue -ResultType 'ParameterValue' -ToolTip $ToolTip
    )
}

function Get-WcEnumValueResults {
    param(
        [string[]]$Values,
        [string]$CurrentValue,
        [string]$ToolTip,
        [string]$Prefix = ''
    )

    $typedValue = if ($null -eq $CurrentValue) { '' } else { $CurrentValue }

    foreach ($value in @($Values)) {
        if ($value -like ([System.Management.Automation.WildcardPattern]::Escape($typedValue) + '*')) {
            New-WcCompletionResult -CompletionText ($Prefix + $value) -ResultType 'ParameterValue' -ToolTip $ToolTip
        }
    }
}

function Get-WcEnumOrLiteralValueResults {
    param(
        [string[]]$Values,
        [string]$CurrentValue,
        [string]$Placeholder,
        [string]$ToolTip,
        [string]$Prefix = ''
    )

    $results = @(Get-WcEnumValueResults -Values $Values -CurrentValue $CurrentValue -ToolTip $ToolTip -Prefix $Prefix)
    if ($results.Count -gt 0) {
        return $results
    }

    New-WcLiteralValueResults -CurrentValue $CurrentValue -Placeholder $Placeholder -ToolTip $ToolTip -Prefix $Prefix
}

function Get-WcPathValueResults {
    param(
        [string]$CurrentValue,
        [string]$Placeholder,
        [string]$ToolTip,
        [string]$Prefix = '',
        [switch]$DirectoriesOnly,
        [switch]$AllowStdinSentinel
    )

    $results = New-Object System.Collections.Generic.List[object]
    $typedValue = if ($null -eq $CurrentValue) { '' } else { $CurrentValue }

    if ($AllowStdinSentinel -and ('-' -like ([System.Management.Automation.WildcardPattern]::Escape($typedValue) + '*'))) {
        [void]$results.Add((New-WcCompletionResult -CompletionText ($Prefix + '-') -ResultType 'ParameterValue' -ToolTip 'Read this value from standard input.'))
    }

    foreach ($item in @(Get-WcPathCompletions -InputPath $typedValue -Prefix $Prefix -DirectoriesOnly:$DirectoriesOnly)) {
        [void]$results.Add($item)
    }

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

    New-WcLiteralValueResults -CurrentValue $typedValue -Placeholder $Placeholder -ToolTip $ToolTip -Prefix $Prefix
}

function Get-WcValueCompletions {
    param(
        [pscustomobject]$OptionSpec,
        [string]$CurrentValue,
        [string]$Prefix = ''
    )

    $catalog = Get-WcCompletionCatalog
    $typedValue = if ($null -eq $CurrentValue) { '' } else { $CurrentValue }
    $toolTip = if ([string]::IsNullOrWhiteSpace($OptionSpec.Description)) { $OptionSpec.DisplayText } else { $OptionSpec.Description }

    switch ($OptionSpec.ValueKind) {
        'Total' {
            return @(Get-WcEnumOrLiteralValueResults -Values $catalog.TotalValues -CurrentValue $typedValue -Placeholder '<when>' -ToolTip $toolTip -Prefix $Prefix)
        }
        'FilesFrom' {
            return @(Get-WcPathValueResults -CurrentValue $typedValue -Placeholder '<F>' -ToolTip $toolTip -Prefix $Prefix -AllowStdinSentinel)
        }
        default {
            return New-WcLiteralValueResults -CurrentValue $typedValue -Placeholder '<value>' -ToolTip $toolTip -Prefix $Prefix
        }
    }
}

function Get-WcOptionKey {
    param([string]$Token)

    $cleanToken = Remove-WcOuterQuotes -Value $Token
    if ($cleanToken -match '^(--[A-Za-z0-9][A-Za-z0-9\-]*)') {
        return Get-WcCanonicalOptionKey -Token $matches[1]
    }

    if ($cleanToken -match '^(-[^=\s]+)$') {
        return Get-WcCanonicalOptionKey -Token $matches[1]
    }

    $null
}

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

    Initialize-WcCompletionCatalog
    $catalog = Get-WcCompletionCatalog

    $positionals = New-Object System.Collections.Generic.List[string]
    $pendingOption = $null
    $endOfOptions = $false

    foreach ($token in @($TokensBeforeCurrent)) {
        $cleanToken = Remove-WcOuterQuotes -Value $token
        if ([string]::IsNullOrWhiteSpace($cleanToken)) {
            continue
        }

        if ($pendingOption) {
            $pendingOption = $null
            continue
        }

        if ($endOfOptions) {
            $positionals.Add($cleanToken)
            continue
        }

        if ($cleanToken -eq '--') {
            $endOfOptions = $true
            continue
        }

        if ($cleanToken -match '^(--[^=]+)=(.*)$') {
            $lookup = Get-WcCanonicalOptionKey -Token $matches[1]
            if ($catalog.OptionByToken.ContainsKey($lookup)) {
                continue
            }
        }

        $lookup = Get-WcOptionKey -Token $cleanToken
        if ($lookup -and $catalog.OptionByToken.ContainsKey($lookup)) {
            $option = $catalog.OptionByToken[$lookup]

            # Both value-bearing wc options (--total and --files0-from) always consume their
            # value, so a separate-token form opens a pending value slot.
            if ($option.ValueKind) {
                $pendingOption = $option
            }
            continue
        }

        # Unknown leading-`-` tokens (short clusters such as -lw, unknown flags) are treated as
        # consumed boolean switches: they neither become positionals nor open a pending value slot.
        if ($cleanToken.StartsWith('-')) {
            continue
        }

        $positionals.Add($cleanToken)
    }

    [pscustomobject]@{
        PendingOption = $pendingOption
        EndOfOptions  = $endOfOptions
        Positionals   = @($positionals)
    }
}

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

    Initialize-WcCompletionCatalog
    $catalog = Get-WcCompletionCatalog
    $cleanCurrent = Remove-WcOuterQuotes -Value $CurrentWord

    foreach ($option in $catalog.Options) {
        $matchesPrefix = $false
        if ([string]::IsNullOrWhiteSpace($cleanCurrent)) {
            $matchesPrefix = $true
        } elseif ($cleanCurrent.StartsWith('--')) {
            $matchesPrefix = $option.Token.StartsWith($cleanCurrent, [System.StringComparison]::OrdinalIgnoreCase)
        } else {
            $matchesPrefix = $option.Token.StartsWith($cleanCurrent, [System.StringComparison]::Ordinal)
        }

        if (-not $matchesPrefix) {
            continue
        }

        $toolTip = if ([string]::IsNullOrWhiteSpace($option.Description)) { $option.DisplayText } else { $option.Description }
        New-WcCompletionResult -CompletionText $option.Token -ListItemText $option.DisplayText -ResultType 'ParameterName' -ToolTip $toolTip
    }
}

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

    # wc operands are uniformly file paths; the stdin `-` sentinel is also valid here.
    @(Get-WcPathValueResults -CurrentValue $CurrentWord -Placeholder '<file>' -ToolTip 'File to count (or - for standard input).' -AllowStdinSentinel)
}

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

    if ($wordToComplete -isnot [string]) {
        $wordToComplete = [string]$wordToComplete
    }

    Initialize-WcCompletionCatalog
    $catalog = Get-WcCompletionCatalog

    # When the cursor sits past the parsed command extent the user is on a fresh
    # token after trailing whitespace, which $commandAst.Extent.Text has trimmed
    # away. Treat that as an empty current token so terminal/positional routing
    # runs instead of falling through to the option-name branch.
    $currentToken = if ($cursorPosition -gt $commandAst.Extent.EndOffset) {
        ''
    } else {
        Get-WcCurrentToken -Line $commandAst.Extent.Text -CursorPosition ($cursorPosition - $commandAst.Extent.StartOffset) -Fallback $wordToComplete
    }
    $tokensBeforeCurrent = Get-WcArgumentTokens -CommandAst $commandAst -CursorPosition $cursorPosition
    $context = Get-WcCompletionContext -TokensBeforeCurrent $tokensBeforeCurrent

    if ($currentToken -match '^(--[^=]+)=(.*)$') {
        $optionKey = Get-WcCanonicalOptionKey -Token $matches[1]
        $valuePrefix = $matches[2]
        if ($catalog.OptionByToken.ContainsKey($optionKey)) {
            $optionSpec = $catalog.OptionByToken[$optionKey]
            if ($optionSpec.ValueKind) {
                return @(Get-WcValueCompletions -OptionSpec $optionSpec -CurrentValue $valuePrefix -Prefix ($matches[1] + '='))
            }
        }
    }

    if ($context.PendingOption) {
        return @(Get-WcValueCompletions -OptionSpec $context.PendingOption -CurrentValue $wordToComplete)
    }

    if ($currentToken.StartsWith('-')) {
        return @(Get-WcOptionCompletions -CurrentWord $wordToComplete)
    }

    @(Get-WcPositionalCompletions -CurrentWord $wordToComplete)
}