completers/printf_completer/printf_completer.ps1

# printf tab completion for PowerShell
# Static option completion for printf.exe and printf.

Set-StrictMode -Version 2.0

function Get-PrintfCompletionOptions {
    $cache = Get-Variable -Name 'PrintfCompletionOptions' -Scope Script -ErrorAction Ignore
    if ($null -ne $cache -and $null -ne $cache.Value) {
        return $cache.Value
    }

    $fallbackOptions = @('--help', '--version')
    $commandCandidates = @('printf.exe', 'printf')
    foreach ($candidate in $commandCandidates) {
        $command = Get-Command -Name $candidate -ErrorAction Ignore
        if ($null -eq $command) {
            continue
        }

        try {
            $helpOutput = $null | & $command.Source --help 2>&1 | ForEach-Object { $_ -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' } | Out-String
        } catch {
            continue
        }

        if ([string]::IsNullOrWhiteSpace($helpOutput)) {
            continue
        }

        # Only the indented option column counts, and the scan stops at the first
        # unindented paragraph after it, so dash words in prose ('-max places ...',
        # 'ls --quoting=shell-escape') are never taken for options.
        $options = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
        $descriptions = [System.Collections.Hashtable]::new([System.StringComparer]::Ordinal)
        $optionPattern = '^\s{2,}(?<opts>-{1,2}[A-Za-z0-9][A-Za-z0-9-]*(?:[=\[]\S*)?(?:,\s*-{1,2}[A-Za-z0-9][A-Za-z0-9-]*(?:[=\[]\S*)?)*)(?:\s{2,}(?<desc>\S.*?))?\s*$'
        foreach ($line in ([regex]::Split($helpOutput, '\r?\n'))) {
            if ($options.Count -gt 0 -and $line -match '^\S') {
                break
            }

            $match = [regex]::Match($line, $optionPattern)
            if (-not $match.Success) {
                continue
            }

            foreach ($rawOption in $match.Groups['opts'].Value.Split(',')) {
                $normalized = ($rawOption.Trim() -replace '[=\[].*$', '')
                [void]$options.Add($normalized)
                if ($match.Groups['desc'].Success -and -not $descriptions.ContainsKey($normalized)) {
                    $descriptions[$normalized] = $match.Groups['desc'].Value
                }
            }
        }

        if ($options.Count -gt 0) {
            foreach ($option in $fallbackOptions) {
                [void]$options.Add($option)
            }

            Set-Variable -Name 'PrintfFormatCatalog' -Value (Get-PrintfHelpFormatCatalog -HelpText $helpOutput) -Scope Script
            Set-Variable -Name 'PrintfCompletionOptions' -Value (@($options | Sort-Object)) -Scope Script
            Set-Variable -Name 'PrintfCompletionDescriptions' -Value $descriptions -Scope Script
            return (Get-Variable -Name 'PrintfCompletionOptions' -Scope Script).Value
        }
    }

    Set-Variable -Name 'PrintfFormatCatalog' -Value (Get-PrintfHelpFormatCatalog -HelpText '') -Scope Script
    Set-Variable -Name 'PrintfCompletionOptions' -Value $fallbackOptions -Scope Script
    return (Get-Variable -Name 'PrintfCompletionOptions' -Scope Script).Value
}

function Get-PrintfHelpFormatCatalog {
    param([string]$HelpText)

    # GNU prints the interpreted sequences and %%/%b/%q as a two-column list and the
    # remaining conversions as 'ending with one of diouxXfeEgGcs'. Other builds (uutils)
    # use a different layout, so each half falls back to the static GNU table.
    $staticEscapes = @(
        @('\"', 'double quote'),
        @('\\', 'backslash'),
        @('\a', 'alert (BEL)'),
        @('\b', 'backspace'),
        @('\c', 'produce no further output'),
        @('\e', 'escape'),
        @('\f', 'form feed'),
        @('\n', 'new line'),
        @('\r', 'carriage return'),
        @('\t', 'horizontal tab'),
        @('\v', 'vertical tab'),
        @('\xHH', 'byte with hexadecimal value HH (1 to 2 digits)'),
        @('\uHHHH', 'Unicode (ISO/IEC 10646) character with hex value HHHH (4 digits)'),
        @('\UHHHHHHHH', 'Unicode character with hex value HHHHHHHH (8 digits)')
    )
    $staticBareFields = @(
        @('%%', 'a single %'),
        @('%b', 'ARGUMENT as a string with ''\'' escapes interpreted'),
        @('%q', 'ARGUMENT is printed in a format that can be reused as shell input')
    )
    $conversionDescriptions = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal)
    $conversionDescriptions['d'] = 'ARGUMENT as a signed decimal integer'
    $conversionDescriptions['i'] = 'ARGUMENT as a signed decimal integer'
    $conversionDescriptions['o'] = 'ARGUMENT as an unsigned octal integer'
    $conversionDescriptions['u'] = 'ARGUMENT as an unsigned decimal integer'
    $conversionDescriptions['x'] = 'ARGUMENT as an unsigned hexadecimal integer (lowercase)'
    $conversionDescriptions['X'] = 'ARGUMENT as an unsigned hexadecimal integer (uppercase)'
    $conversionDescriptions['f'] = 'ARGUMENT as a decimal floating point number'
    $conversionDescriptions['e'] = 'ARGUMENT in scientific notation (lowercase e)'
    $conversionDescriptions['E'] = 'ARGUMENT in scientific notation (uppercase E)'
    $conversionDescriptions['g'] = 'ARGUMENT as the shorter of %f and %e'
    $conversionDescriptions['G'] = 'ARGUMENT as the shorter of %f and %E'
    $conversionDescriptions['c'] = 'first character of ARGUMENT'
    $conversionDescriptions['s'] = 'ARGUMENT as a string'
    $conversionLetters = 'diouxXfeEgGcs'

    $escapes = [System.Collections.Generic.List[object]]::new()
    $bareFields = [System.Collections.Generic.List[object]]::new()
    $last = $null
    foreach ($line in ([regex]::Split($HelpText, '\r?\n'))) {
        $entry = [regex]::Match($line, '^\s{2}(?<key>\\\S+|%\S)\s+(?<desc>\S.*?)\s*$')
        if ($entry.Success) {
            $last = [pscustomobject]@{ Key = $entry.Groups['key'].Value; Description = $entry.Groups['desc'].Value }
            if ($last.Key.StartsWith('\')) {
                $escapes.Add($last)
            } else {
                $bareFields.Add($last)
            }
            continue
        }

        if ($null -ne $last -and $line -match '^\s{4,}(\S.*?)\s*$') {
            $last.Description = $last.Description.TrimEnd(',') + ' ' + $Matches[1]
            continue
        }

        $last = $null
        $letters = [regex]::Match($line, 'ending with one of\s+(?<letters>[A-Za-z]+)')
        if ($letters.Success) {
            $conversionLetters = $letters.Groups['letters'].Value
        }
    }

    if ($escapes.Count -eq 0) {
        foreach ($pair in $staticEscapes) {
            $escapes.Add([pscustomobject]@{ Key = $pair[0]; Description = $pair[1] })
        }
    }

    if ($bareFields.Count -eq 0) {
        foreach ($pair in $staticBareFields) {
            $bareFields.Add([pscustomobject]@{ Key = $pair[0]; Description = $pair[1] })
        }
    }

    # Text inserted after the trailing backslash: the literal part of each sequence.
    # \NNN is all placeholder, so it has nothing to insert and is not offered.
    $escapeCandidates = foreach ($escape in $escapes) {
        $insert = if ($escape.Key -cmatch '^\\(.)$') {
            $Matches[1]
        } elseif ($escape.Key -cmatch '^\\([xuU])H+$') {
            $Matches[1]
        } else {
            $null
        }

        if ($null -ne $insert) {
            [pscustomobject]@{ ListItem = $escape.Key; Insert = $insert; Description = $escape.Description }
        }
    }

    # %%, %b and %q reject flags, width and precision; the C conversions accept them.
    $fieldCandidates = @(
        foreach ($field in $bareFields) {
            [pscustomobject]@{ ListItem = $field.Key; Insert = $field.Key.Substring(1); Description = $field.Description; BareOnly = $true }
        }
        foreach ($letter in $conversionLetters.ToCharArray()) {
            $key = [string]$letter
            $description = if ($conversionDescriptions.ContainsKey($key)) { $conversionDescriptions[$key] } else { 'C format specification' }
            [pscustomobject]@{ ListItem = '%' + $key; Insert = $key; Description = $description; BareOnly = $false }
        }
    )

    [pscustomobject]@{
        Escapes = @($escapeCandidates)
        Fields  = $fieldCandidates
    }
}

function Get-PrintfFormatCatalog {
    $cache = Get-Variable -Name 'PrintfFormatCatalog' -Scope Script -ErrorAction Ignore
    if ($null -eq $cache -or $null -eq $cache.Value) {
        [void](Get-PrintfCompletionOptions)
        $cache = Get-Variable -Name 'PrintfFormatCatalog' -Scope Script -ErrorAction Ignore
    }

    $cache.Value
}

function New-PrintfCompletionResult {
    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-PrintfOuterQuotes {
    param([string]$Value)

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

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

function ConvertTo-PrintfQuotedValue {
    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-PrintfCurrentToken {
    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-PrintfPathCompletions {
    param([string]$InputPath)

    $cleanInput = Remove-PrintfOuterQuotes -Value $InputPath
    $alwaysQuote = -not [string]::IsNullOrEmpty($InputPath) -and ($InputPath.StartsWith('"') -or $InputPath.StartsWith("'"))

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

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

    if (-not (Test-Path -LiteralPath $parent -PathType Container)) {
        return @()
    }

    $items = @(Get-ChildItem -LiteralPath $parent -ErrorAction SilentlyContinue)
    $items = $items | Where-Object { $_.Name -like ([System.Management.Automation.WildcardPattern]::Escape($leaf) + '*') } | Sort-Object -Property Name

    foreach ($item in $items) {
        $pathText = if ($parent -eq '.' -or [string]::IsNullOrWhiteSpace($cleanInput)) {
            $item.Name
        } elseif ([System.IO.Path]::IsPathRooted($cleanInput)) {
            Join-Path -Path $parent -ChildPath $item.Name
        } else {
            Join-Path -Path $parent -ChildPath $item.Name
        }

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

        $quotedPath = ConvertTo-PrintfQuotedValue -Value $pathText -AlwaysQuote $alwaysQuote
        if ($item.PSIsContainer) {
            New-PrintfCompletionResult -CompletionText $quotedPath -ListItemText $pathText -ResultType 'ProviderContainer' -ToolTip $item.FullName
        } else {
            New-PrintfCompletionResult -CompletionText $quotedPath -ListItemText $pathText -ResultType 'ProviderItem' -ToolTip $item.FullName
        }
    }
}

function Get-PrintfOptionDescription {
    param([string]$Option)

    $cache = Get-Variable -Name 'PrintfCompletionDescriptions' -Scope Script -ErrorAction Ignore
    if ($null -ne $cache -and $null -ne $cache.Value -and $cache.Value.ContainsKey($Option)) {
        return $cache.Value[$Option]
    }

    'Option for printf.'
}

function Get-PrintfFormatContext {
    param(
        [System.Management.Automation.Language.CommandAst]$CommandAst,
        [int]$CursorPosition
    )

    # FORMAT is the first operand; GNU printf and uutils both skip a leading '--'.
    $elements = $CommandAst.CommandElements
    $formatIndex = if ($elements.Count -gt 2 -and $elements[1].Extent.Text -eq '--') { 2 } else { 1 }
    if ($elements.Count -le $formatIndex) {
        return $null
    }

    $element = $elements[$formatIndex]
    if ($CursorPosition -le $element.Extent.StartOffset -or $CursorPosition -gt $element.Extent.EndOffset) {
        return $null
    }

    # Re-parse the typed text as an argument: an unterminated quote is one token
    # running to the cursor, and the token value has PowerShell's escapes resolved.
    $typed = $element.Extent.Text.Substring(0, $CursorPosition - $element.Extent.StartOffset)
    $tokens = $null
    $errors = $null
    [void][System.Management.Automation.Language.Parser]::ParseInput('x ' + $typed, [ref]$tokens, [ref]$errors)
    if ($tokens.Count -lt 2 -or $tokens[1].Extent.StartOffset -ne 2 -or $tokens[1].Extent.EndOffset -ne ($typed.Length + 2)) {
        return $null
    }

    $token = $tokens[1]
    $tokenKind = [string]$token.Kind
    if ($tokenKind -notin @('Generic', 'StringLiteral', 'StringExpandable')) {
        return $null
    }

    # "$var %" expands at run time, so its text cannot be read as FORMAT here.
    if ($token -is [System.Management.Automation.Language.StringExpandableToken] -and $null -ne $token.NestedTokens -and $token.NestedTokens.Count -gt 0) {
        return $null
    }

    $value = if ($token -is [System.Management.Automation.Language.StringToken]) { $token.Value } else { $token.Text }
    $quote = ''
    $raw = ''
    if ($tokenKind -ne 'Generic') {
        $quote = [string]$typed[0]
        $raw = $typed.Substring(1)
        if (-not @($errors | Where-Object { $_.ErrorId -eq 'TerminatorExpectedAtEndOfString' })) {
            $raw = $raw.Substring(0, $raw.Length - 1)
        }
    }

    $pieces = @([regex]::Matches($value, '\\[\s\S]?|%(?:%|[-+ #0'']*(?:\*|[0-9]+)?(?:\.(?:\*|[0-9]*))?[\s\S]?)|[^\\%]+') | ForEach-Object { $_.Value })
    if ($pieces.Count -eq 0) {
        return $null
    }

    $tail = $pieces[-1]
    $kind = if ($tail -cmatch '^%[-+ #0'']*(?:\*|[0-9]+)?(?:\.(?:\*|[0-9]*))?$') {
        'Field'
    } elseif ($tail -eq '\') {
        'Escape'
    } else {
        return $null
    }

    [pscustomobject]@{
        Kind      = $kind
        Spec      = $tail
        TokenKind = $tokenKind
        Quote     = $quote
        Raw       = $raw
        Value     = $value
    }
}

function ConvertTo-PrintfFormatArgument {
    param(
        [object]$Context,
        [string]$Insert
    )

    # Keep the user's quote and typed text, escaping only what is appended; a bare word
    # is single-quoted once the result needs it.
    if ($Context.TokenKind -eq 'StringLiteral') {
        return $Context.Quote + $Context.Raw + $Insert.Replace("'", "''") + $Context.Quote
    }

    if ($Context.TokenKind -eq 'StringExpandable') {
        return $Context.Quote + $Context.Raw + ($Insert -replace '([`"$])', '`$1') + $Context.Quote
    }

    $text = $Context.Value + $Insert
    if ($text -match '[\s{}();,|&<>''"`$]' -or $text -match '^[@#]') {
        return "'" + $text.Replace("'", "''") + "'"
    }

    $text
}

function Get-PrintfFormatCompletion {
    param([object]$Context)

    $catalog = Get-PrintfFormatCatalog
    if ($Context.Kind -eq 'Escape') {
        foreach ($escape in $catalog.Escapes) {
            New-PrintfCompletionResult -CompletionText (ConvertTo-PrintfFormatArgument -Context $Context -Insert $escape.Insert) -ListItemText $escape.ListItem -ResultType 'ParameterValue' -ToolTip $escape.Description
        }

        return
    }

    $bare = $Context.Spec -eq '%'
    foreach ($field in $catalog.Fields) {
        if ($field.BareOnly -and -not $bare) {
            continue
        }

        New-PrintfCompletionResult -CompletionText (ConvertTo-PrintfFormatArgument -Context $Context -Insert $field.Insert) -ListItemText ($Context.Spec + $field.Insert) -ResultType 'ParameterValue' -ToolTip $field.Description
    }
}

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

    $currentWord = if ($cursorPosition -gt $commandAst.Extent.EndOffset) {
        ''
    } else {
        Get-PrintfCurrentToken -Line $commandAst.ToString() -CursorPosition ($cursorPosition - $commandAst.Extent.StartOffset) -Fallback $wordToComplete
    }

    if ([string]::IsNullOrEmpty($currentWord)) {
        return @()
    }

    # A FORMAT word ending in an open '%' spec gets the conversions; one ending in an
    # unpaired backslash gets the escapes, unless it names a directory (.\, C:\).
    $format = Get-PrintfFormatContext -CommandAst $commandAst -CursorPosition $cursorPosition
    if ($null -ne $format -and -not ($format.Kind -eq 'Escape' -and (Test-Path -LiteralPath $format.Value -PathType Container))) {
        return @(Get-PrintfFormatCompletion -Context $format)
    }

    if ($currentWord.StartsWith('-')) {
        return @(
            foreach ($option in Get-PrintfCompletionOptions) {
                if ($option.StartsWith($currentWord, [System.StringComparison]::Ordinal)) {
                    New-PrintfCompletionResult -CompletionText $option -ListItemText $option -ResultType 'ParameterName' -ToolTip (Get-PrintfOptionDescription -Option $option)
                }
            }
        )
    }

    Get-PrintfPathCompletions -InputPath $currentWord
}

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

    Complete-Printf -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition
}