completers/curl_completer/curl_completer.ps1

# curl tab completion for PowerShell
# Builds a help-driven option catalog for curl.exe and adds value-aware completion.

Set-StrictMode -Version 2.0

function New-CurlCompletionResult {
    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-CurlUniqueStrings {
    param([string[]]$Items)

    $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    $results = New-Object System.Collections.Generic.List[string]

    foreach ($item in @($Items)) {
        if ([string]::IsNullOrWhiteSpace($item)) {
            continue
        }

        if ($seen.Add($item)) {
            [void]$results.Add($item)
        }
    }

    @($results.ToArray())
}

function Get-CurlDefaultHelpSubjects {
    @(
        'all',
        'auth',
        'category',
        'connection',
        'curl',
        'deprecated',
        'dns',
        'file',
        'ftp',
        'global',
        'http',
        'imap',
        'ldap',
        'output',
        'pop3',
        'post',
        'proxy',
        'scp',
        'sftp',
        'smtp',
        'ssh',
        'telnet',
        'tftp',
        'timeout',
        'tls',
        'upload',
        'verbose',
        'manual'
    )
}

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

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

    $script:CurlCompletionCatalog
}

function Resolve-CurlCommandName {
    $catalog = Get-CurlCompletionCatalog
    if ($catalog.CommandName) {
        return $catalog.CommandName
    }

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

    $catalog.CommandName
}

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

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

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

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

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

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

function ConvertTo-CurlQuotedValue {
    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 ConvertTo-CurlQuotedArgument {
    # Renders a value as one quoted PowerShell argument in the given quote style.
    param(
        [string]$Value,
        [string]$Quote
    )

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

    "'" + ($Value -replace '([''\u2018-\u201B])', '$1$1') + "'"
}

function Test-CurlPathLikeInput {
    param([string]$Value)

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

    $cleanValue = Remove-CurlOuterQuotes -Value $Value
    $cleanValue -match '^(?:\.{1,2}[\\/]|[\\/]|~[\\/]|[A-Za-z]:|\\\\)'
}

function Get-CurlTokenText {
    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-CurlCurrentToken {
    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-CurlArgumentTokens {
    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-CurlTokenText -Element $element
        }
    }

    $tokens
}

function Get-CurlAtFileWord {
    # Reads an '@file' value under the cursor from the parsed command elements:
    # $wordToComplete loses the '@' when PowerShell splits a bare '@.\x' into
    # '@' and '.\x', and it keeps a typed quote. Returns $null for other words.
    param(
        [System.Management.Automation.Language.CommandAst]$CommandAst,
        [int]$CursorPosition
    )

    $elements = @($CommandAst.CommandElements | Select-Object -Skip 1)
    for ($index = 0; $index -lt $elements.Count; $index++) {
        $extent = $elements[$index].Extent
        if ($CursorPosition -le $extent.StartOffset -or $CursorPosition -gt $extent.EndOffset) {
            continue
        }

        $text = $extent.Text.Substring(0, $CursorPosition - $extent.StartOffset)
        $quote = ''
        $splitAt = $false
        if ($text -match '^[''"]') {
            $quote = $text.Substring(0, 1)
            $text = $text.Substring(1)
            if ($text.EndsWith($quote)) {
                $text = $text.Substring(0, $text.Length - 1)
            }

            $text = if ($quote -eq "'") { $text.Replace("''", "'") } else { $text -replace '`(.)', '$1' }
        } elseif ($index -gt 0 -and $elements[$index - 1].Extent.Text -eq '@' -and $elements[$index - 1].Extent.EndOffset -eq $extent.StartOffset) {
            $text = '@' + $text
            $splitAt = $true
        } else {
            $text = $text -replace '`(.)', '$1'
        }

        if (-not $text.StartsWith('@')) {
            return $null
        }

        return [pscustomobject]@{ Value = $text; Quote = $quote; SplitAt = $splitAt }
    }

    $null
}

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

    $placeholderKey = if ([string]::IsNullOrWhiteSpace($Placeholder)) { '' } else { $Placeholder.ToLowerInvariant() }

    switch -CaseSensitive ($Token) {
        '-h' { return 'HelpSubject' }
        '--help' { return 'HelpSubject' }
        '--proto' { return 'ProtocolList' }
        '--proto-redir' { return 'ProtocolList' }
        '--proto-default' { return 'Protocol' }
        '--proxy' { return 'ProxyUrl' }
        '--preproxy' { return 'ProxyUrl' }
        '--proxy1.0' { return 'ProxyUrl' }
        '--url' { return 'Url' }
        '--doh-url' { return 'Url' }
        '--ipfs-gateway' { return 'Url' }
        '--cert-type' { return 'CertType' }
        '--proxy-cert-type' { return 'CertType' }
        '--key-type' { return 'KeyType' }
        '--proxy-key-type' { return 'KeyType' }
        '--delegation' { return 'Delegation' }
        '--ftp-method' { return 'FtpMethod' }
        '--ftp-ssl-ccc-mode' { return 'FtpSslCccMode' }
        '--tlsauthtype' { return 'TlsAuthType' }
        '--proxy-tlsauthtype' { return 'TlsAuthType' }
        '--tls-max' { return 'TlsVersion' }
        '--create-file-mode' { return 'FileMode' }
        '--upload-flags' { return 'UploadFlags' }
        '--krb' { return 'KerberosLevel' }
        '--variable' { return 'VariableSpec' }
        '-d' { return 'DataValue' }
        '--data' { return 'DataValue' }
        '--data-ascii' { return 'DataValue' }
        '--data-binary' { return 'DataValue' }
        '-H' { return 'HeaderOrFile' }
        '--header' { return 'HeaderOrFile' }
        '--proxy-header' { return 'HeaderOrFile' }
        '-b' { return 'CookieValue' }
        '--cookie' { return 'CookieValue' }
        '-E' { return 'CertificatePath' }
        '--cert' { return 'CertificatePath' }
        '--proxy-cert' { return 'CertificatePath' }
    }

    switch ($placeholderKey) {
        'subject' { return 'HelpSubject' }
        'protocols' { return 'ProtocolList' }
        'protocol' { return 'Protocol' }
        'url' { return 'Url' }
        'url/file' { return 'Url' }
        '[protocol://]host[:port]' { return 'ProxyUrl' }
        'header/@file' { return 'HeaderOrFile' }
        '[%]name=text/@file' { return 'VariableSpec' }
        'data|filename' { return 'DataOrFilename' }
        'file' { return 'FilePath' }
        'filename' { return 'FilePath' }
        'key' { return 'FilePath' }
        'dir' { return 'DirectoryPath' }
        'path' { return 'Path' }
        'certificate[:password]' { return 'CertificatePath' }
        'cert[:passwd]' { return 'CertificatePath' }
        'type' { return 'Text' }
        'version' { return 'TlsVersion' }
        'seconds' { return 'Number' }
        'ms' { return 'Number' }
        'num' { return 'Number' }
        'integer' { return 'Number' }
        'bytes' { return 'Number' }
        'offset' { return 'Number' }
        'priority' { return 'Number' }
        'value' { return 'Number' }
        'speed' { return 'Number' }
    }

    if ($placeholderKey) {
        return 'Text'
    }

    $null
}

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

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

    $catalog = Get-CurlCompletionCatalog
    if ($catalog.OptionByToken.ContainsKey($Token)) {
        return $catalog.OptionByToken[$Token]
    }

    # curl spells every long option in lower case, so a long option may be matched case-insensitively.
    if ($Token.StartsWith('--')) {
        $lower = $Token.ToLowerInvariant()
        if ($catalog.OptionByToken.ContainsKey($lower)) {
            return $catalog.OptionByToken[$lower]
        }
    }

    $null
}

function Add-CurlOptionSpec {
    param(
        [string]$Token,
        [string]$Description,
        [string]$ValuePlaceholder
    )

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

    $catalog = Get-CurlCompletionCatalog
    $key = $Token
    if ($catalog.OptionByToken.ContainsKey($key)) {
        return
    }

    $displayText = if ([string]::IsNullOrWhiteSpace($ValuePlaceholder)) {
        $Token
    } else {
        "$Token <$ValuePlaceholder>"
    }

    $catalog.OptionByToken[$key] = [pscustomobject]@{
        Token            = $Token
        DisplayText      = $displayText
        Description      = $Description
        ValuePlaceholder = $ValuePlaceholder
        ValueKind        = Get-CurlValueKind -Token $Token -Placeholder $ValuePlaceholder
    }

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

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

    $categoryLines = Invoke-CurlCapture -Arguments @('--help', 'category')
    $subjects = foreach ($line in @($categoryLines)) {
        if ($line -match '^\s*([a-z][a-z0-9-]+)\s{2,}') {
            $matches[1]
        }
    }
    $catalog.HelpSubjects = if ($subjects) {
        Get-CurlUniqueStrings -Items (@($subjects) + @(Get-CurlDefaultHelpSubjects))
    } else {
        Get-CurlDefaultHelpSubjects
    }

    $versionLines = Invoke-CurlCapture -Arguments @('--version')
    $protocolLine = $versionLines | Where-Object { $_ -like 'Protocols:*' } | Select-Object -First 1
    if ($protocolLine -and $protocolLine -match '^Protocols:\s+(.+)$') {
        $catalog.Protocols = Get-CurlUniqueStrings -Items ($matches[1] -split '\s+')
    }

    $helpLines = Invoke-CurlCapture -Arguments @('--help', 'all')
    foreach ($line in @($helpLines)) {
        if ($line -match '^\s*(?:(-\S+),\s+)?(--[A-Za-z0-9][A-Za-z0-9.\-]*)(?:\s+<([^>]+)>)?\s{2,}(.*)$') {
            $shortToken = $matches[1]
            $longToken = $matches[2]
            $valuePlaceholder = $matches[3]
            $description = $matches[4].Trim()

            if ($shortToken) {
                Add-CurlOptionSpec -Token $shortToken -Description $description -ValuePlaceholder $valuePlaceholder
            }

            Add-CurlOptionSpec -Token $longToken -Description $description -ValuePlaceholder $valuePlaceholder
        }
    }

    $catalog.Initialized = $true
}

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

    $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 ($cleanInput -and -not [System.IO.Path]::IsPathRooted($cleanInput)) {
            if ($parent -eq '.') {
                $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
        }

        if ($Quote) {
            # The prefix and path form one argument quoted as a whole.
            $completionText = ConvertTo-CurlQuotedArgument -Value ($Prefix + $completionText) -Quote $Quote
        } else {
            $completionText = ConvertTo-CurlQuotedValue -Value $completionText -AlwaysQuote $alwaysQuote
            $completionText = $Prefix + $completionText
        }

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

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

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

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

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

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

    foreach ($value in @($Values)) {
        # An empty value list (curl absent, so no protocols) binds as a single $null.
        if ([string]::IsNullOrEmpty($value)) {
            continue
        }

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

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

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

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

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

    $results = @(Get-CurlPathCompletions -InputPath $CurrentValue -Prefix $Prefix -DirectoriesOnly:$DirectoriesOnly)
    if ($results.Count -gt 0) {
        return $results
    }

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

function Get-CurlUrlValueResults {
    param(
        [string]$CurrentValue,
        [string]$Placeholder,
        [string]$ToolTip,
        [string]$Prefix = '',
        [switch]$Proxy
    )

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

    $values = if ($Proxy) {
        @('http://', 'https://', 'socks4://', 'socks4a://', 'socks5://', 'socks5h://')
    } else {
        @($catalog.Protocols | ForEach-Object { "${_}://" })
    }

    $results = @(Get-CurlEnumValueResults -Values (Get-CurlUniqueStrings -Items $values) -CurrentValue $typedValue -ToolTip $ToolTip -Prefix $Prefix)
    if ($results.Count -gt 0) {
        return $results
    }

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

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

    $catalog = Get-CurlCompletionCatalog
    $typedValue = if ($null -eq $CurrentValue) { '' } else { $CurrentValue }
    $basePrefix = ''
    $segment = $typedValue

    if ($typedValue -match '^(.*?,)([^,]*)$') {
        $basePrefix = $matches[1]
        $segment = $matches[2]
    }

    $modifier = ''
    $namePrefix = $segment
    if ($segment -match '^([+=-])(.*)$') {
        $modifier = $matches[1]
        $namePrefix = $matches[2]
    }

    $results = New-Object System.Collections.Generic.List[object]
    foreach ($value in @('all') + @($catalog.Protocols)) {
        if ($value -notlike "$namePrefix*") {
            continue
        }

        $completionText = $Prefix + $basePrefix + $modifier + $value
        [void]$results.Add((New-CurlCompletionResult -CompletionText $completionText -ResultType 'ParameterValue' -ToolTip $ToolTip))
    }

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

    New-CurlLiteralValueResults -CurrentValue $typedValue -Placeholder '<protocols>' -ToolTip $ToolTip -Prefix $Prefix
}

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

    $typedValue = if ($null -eq $CurrentValue) { '' } else { $CurrentValue }
    if ($typedValue -match '^[''"]') {
        return New-CurlLiteralValueResults -CurrentValue $typedValue -Placeholder '<flags>' -ToolTip $ToolTip -Prefix $Prefix
    }

    $segment = $typedValue
    if ($typedValue -match ',([^,]*)$') {
        # PowerShell parses 'a,b' as an array and replaces only the segment after the last comma.
        $segment = $matches[1]
        $Prefix = ''
    }

    # curl negates a flag with a leading '-'; flag names are case-sensitive.
    $modifier = ''
    $namePrefix = $segment
    if ($segment.StartsWith('-')) {
        $modifier = '-'
        $namePrefix = $segment.Substring(1)
    }

    $results = @(foreach ($value in @('answered', 'deleted', 'draft', 'flagged', 'seen')) {
            if ($value.StartsWith($namePrefix, [System.StringComparison]::Ordinal)) {
                New-CurlCompletionResult -CompletionText ($Prefix + $modifier + $value) -ResultType 'ParameterValue' -ToolTip $ToolTip
            }
        })
    if ($results.Count -gt 0) {
        return $results
    }

    New-CurlLiteralValueResults -CurrentValue $segment -Placeholder '<flags>' -ToolTip $ToolTip -Prefix $Prefix
}

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

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

    if ($typedValue -match '^(.*@)([^@]*)$') {
        $variablePrefix = $matches[1]
        $pathPrefix = $matches[2]
        $results = @(Get-CurlPathCompletions -InputPath $pathPrefix -Prefix ($Prefix + $variablePrefix))
        if ($results.Count -gt 0) {
            return $results
        }
    }

    $results = New-Object System.Collections.Generic.List[object]
    if ([string]::IsNullOrWhiteSpace($typedValue) -or '%' -like ([System.Management.Automation.WildcardPattern]::Escape($typedValue) + '*')) {
        foreach ($envName in (Get-ChildItem Env: | Select-Object -ExpandProperty Name | Sort-Object -Unique)) {
            $candidate = "%$envName"
            if ($candidate -like ([System.Management.Automation.WildcardPattern]::Escape($typedValue) + '*')) {
                [void]$results.Add((New-CurlCompletionResult -CompletionText ($Prefix + $candidate) -ResultType 'ParameterValue' -ToolTip 'Import environment variable into a curl variable.'))
            }
        }
    }

    foreach ($item in @(New-CurlLiteralValueResults -CurrentValue $typedValue -Placeholder 'name=text' -ToolTip $ToolTip -Prefix $Prefix)) {
        [void]$results.Add($item)
    }

    if ([string]::IsNullOrWhiteSpace($typedValue) -or 'name@file' -like ([System.Management.Automation.WildcardPattern]::Escape($typedValue) + '*')) {
        [void]$results.Add((New-CurlCompletionResult -CompletionText ($Prefix + 'name@file') -ResultType 'ParameterValue' -ToolTip 'Set a curl variable from a file.'))
    }

    @($results.ToArray())
}

function Get-CurlAtFileValueResults {
    param(
        [string]$CurrentValue,
        [string]$Placeholder,
        [string]$ToolTip,
        [string]$Prefix = '',
        [string]$Quote = '',
        [switch]$SplitAt
    )

    $typedValue = if ($null -eq $CurrentValue) { '' } else { $CurrentValue }
    if ($typedValue.StartsWith('@')) {
        $pathInput = $typedValue.Substring(1)
        $results = @(if ($SplitAt) {
            # The '@' already sits in the buffer as a token of its own; complete the path after it.
            Get-CurlPathCompletions -InputPath $pathInput
        } elseif ($Prefix) {
            Get-CurlPathCompletions -InputPath $pathInput -Prefix ($Prefix + '@')
        } else {
            # A leading '@' would start a splat, so the whole value is emitted quoted.
            $valueQuote = if ($Quote) { $Quote } else { "'" }
            Get-CurlPathCompletions -InputPath $pathInput -Prefix '@' -Quote $valueQuote
        })

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

        if ($SplitAt) {
            return New-CurlLiteralValueResults -CurrentValue $pathInput -Placeholder $Placeholder -ToolTip $ToolTip
        }

        if (-not $Prefix) {
            return @(New-CurlCompletionResult -CompletionText (ConvertTo-CurlQuotedArgument -Value $typedValue -Quote $valueQuote) -ListItemText $typedValue -ResultType 'ParameterValue' -ToolTip $ToolTip)
        }
    }

    $results = New-Object System.Collections.Generic.List[object]
    if ([string]::IsNullOrWhiteSpace($typedValue) -or '@' -like ([System.Management.Automation.WildcardPattern]::Escape($typedValue) + '*')) {
        [void]$results.Add((New-CurlCompletionResult -CompletionText ($Prefix + '@') -ResultType 'ParameterValue' -ToolTip 'Prefix a file path with @ to load content from disk.'))
    }

    foreach ($item in @(New-CurlLiteralValueResults -CurrentValue $typedValue -Placeholder $Placeholder -ToolTip $ToolTip -Prefix $Prefix)) {
        [void]$results.Add($item)
    }

    @($results.ToArray())
}

function Get-CurlValueCompletions {
    param(
        [pscustomobject]$OptionSpec,
        [string]$CurrentValue,
        [string]$Prefix = '',
        [string]$Quote = '',
        [switch]$SplitAt
    )

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

    switch ($OptionSpec.ValueKind) {
        'HelpSubject' {
            return @(Get-CurlEnumOrLiteralValueResults -Values (Get-CurlCompletionCatalog).HelpSubjects -CurrentValue $typedValue -Placeholder '<subject>' -ToolTip $toolTip -Prefix $Prefix)
        }
        'Protocol' {
            return @(Get-CurlEnumOrLiteralValueResults -Values ((Get-CurlUniqueStrings -Items (@('all') + @((Get-CurlCompletionCatalog).Protocols)))) -CurrentValue $typedValue -Placeholder '<protocol>' -ToolTip $toolTip -Prefix $Prefix)
        }
        'ProtocolList' {
            return @(Get-CurlProtocolListValueResults -CurrentValue $typedValue -ToolTip $toolTip -Prefix $Prefix)
        }
        'Url' {
            return @(Get-CurlUrlValueResults -CurrentValue $typedValue -Placeholder '<url>' -ToolTip $toolTip -Prefix $Prefix)
        }
        'ProxyUrl' {
            return @(Get-CurlUrlValueResults -CurrentValue $typedValue -Placeholder '<proxy-url>' -ToolTip $toolTip -Prefix $Prefix -Proxy)
        }
        'DirectoryPath' {
            return @(Get-CurlPathValueResults -CurrentValue $typedValue -Placeholder '<dir>' -ToolTip $toolTip -Prefix $Prefix -DirectoriesOnly)
        }
        'FilePath' {
            return @(Get-CurlPathValueResults -CurrentValue $typedValue -Placeholder '<file>' -ToolTip $toolTip -Prefix $Prefix)
        }
        'Path' {
            return @(Get-CurlPathValueResults -CurrentValue $typedValue -Placeholder '<path>' -ToolTip $toolTip -Prefix $Prefix)
        }
        'CertificatePath' {
            if (Test-CurlPathLikeInput -Value $typedValue) {
                return @(Get-CurlPathValueResults -CurrentValue $typedValue -Placeholder '<certificate>' -ToolTip $toolTip -Prefix $Prefix)
            }

            if ($typedValue -match '^([^:]+):(.*)$') {
                return New-CurlLiteralValueResults -CurrentValue $typedValue -Placeholder '<certificate[:password]>' -ToolTip $toolTip -Prefix $Prefix
            }

            return @(Get-CurlPathValueResults -CurrentValue $typedValue -Placeholder '<certificate>' -ToolTip $toolTip -Prefix $Prefix)
        }
        'HeaderOrFile' {
            return @(Get-CurlAtFileValueResults -CurrentValue $typedValue -Placeholder '<header-or-@file>' -ToolTip $toolTip -Prefix $Prefix -Quote $Quote -SplitAt:$SplitAt)
        }
        'VariableSpec' {
            return @(Get-CurlVariableValueResults -CurrentValue $typedValue -ToolTip $toolTip -Prefix $Prefix)
        }
        'DataValue' {
            return @(Get-CurlAtFileValueResults -CurrentValue $typedValue -Placeholder '<data-or-@file>' -ToolTip $toolTip -Prefix $Prefix -Quote $Quote -SplitAt:$SplitAt)
        }
        'DataOrFilename' {
            if (Test-CurlPathLikeInput -Value $typedValue) {
                return @(Get-CurlPathValueResults -CurrentValue $typedValue -Placeholder '<data-or-file>' -ToolTip $toolTip -Prefix $Prefix)
            }

            return New-CurlLiteralValueResults -CurrentValue $typedValue -Placeholder '<data-or-file>' -ToolTip $toolTip -Prefix $Prefix
        }
        'CookieValue' {
            if (Test-CurlPathLikeInput -Value $typedValue) {
                return @(Get-CurlPathValueResults -CurrentValue $typedValue -Placeholder '<cookie-data-or-file>' -ToolTip $toolTip -Prefix $Prefix)
            }

            return New-CurlLiteralValueResults -CurrentValue $typedValue -Placeholder '<cookie-data-or-file>' -ToolTip $toolTip -Prefix $Prefix
        }
        'CertType' {
            return @(Get-CurlEnumOrLiteralValueResults -Values @('DER', 'PEM', 'ENG', 'PROV', 'P12') -CurrentValue $typedValue -Placeholder '<type>' -ToolTip $toolTip -Prefix $Prefix)
        }
        'KeyType' {
            return @(Get-CurlEnumOrLiteralValueResults -Values @('DER', 'PEM', 'ENG') -CurrentValue $typedValue -Placeholder '<type>' -ToolTip $toolTip -Prefix $Prefix)
        }
        'Delegation' {
            return @(Get-CurlEnumOrLiteralValueResults -Values @('none', 'policy', 'always') -CurrentValue $typedValue -Placeholder '<level>' -ToolTip $toolTip -Prefix $Prefix)
        }
        'FtpMethod' {
            return @(Get-CurlEnumOrLiteralValueResults -Values @('multicwd', 'nocwd', 'singlecwd') -CurrentValue $typedValue -Placeholder '<method>' -ToolTip $toolTip -Prefix $Prefix)
        }
        'FtpSslCccMode' {
            return @(Get-CurlEnumOrLiteralValueResults -Values @('active', 'passive') -CurrentValue $typedValue -Placeholder '<mode>' -ToolTip $toolTip -Prefix $Prefix)
        }
        'TlsAuthType' {
            return @(Get-CurlEnumOrLiteralValueResults -Values @('SRP') -CurrentValue $typedValue -Placeholder '<type>' -ToolTip $toolTip -Prefix $Prefix)
        }
        'TlsVersion' {
            return @(Get-CurlEnumOrLiteralValueResults -Values @('default', '1.0', '1.1', '1.2', '1.3') -CurrentValue $typedValue -Placeholder '<VERSION>' -ToolTip $toolTip -Prefix $Prefix)
        }
        'UploadFlags' {
            return @(Get-CurlUploadFlagValueResults -CurrentValue $typedValue -ToolTip $toolTip -Prefix $Prefix)
        }
        'KerberosLevel' {
            return @(Get-CurlEnumOrLiteralValueResults -Values @('clear', 'safe', 'confidential', 'private') -CurrentValue $typedValue -Placeholder '<level>' -ToolTip $toolTip -Prefix $Prefix)
        }
        'FileMode' {
            return @(Get-CurlEnumOrLiteralValueResults -Values @('0600', '0644', '0660', '0755') -CurrentValue $typedValue -Placeholder '<mode>' -ToolTip $toolTip -Prefix $Prefix)
        }
        'Number' {
            return @(Get-CurlEnumOrLiteralValueResults -Values @('0', '1', '5', '10', '30', '60', '300') -CurrentValue $typedValue -Placeholder '<number>' -ToolTip $toolTip -Prefix $Prefix)
        }
        default {
            return New-CurlLiteralValueResults -CurrentValue $typedValue -Placeholder ('<' + $OptionSpec.ValuePlaceholder + '>') -ToolTip $toolTip -Prefix $Prefix
        }
    }
}

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

    Initialize-CurlCompletionCatalog
    $pendingOption = $null

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

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

        if ($cleanToken -match '^(--[^=]+)=') {
            continue
        }

        $optionSpec = Get-CurlOptionSpecByToken -Token $cleanToken
        if ($optionSpec -and $optionSpec.ValueKind) {
            $pendingOption = $optionSpec
        }
    }

    $pendingOption
}

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

    Initialize-CurlCompletionCatalog
    $catalog = Get-CurlCompletionCatalog
    $cleanCurrent = Remove-CurlOuterQuotes -Value $CurrentWord

    # Short options are case-significant (-s and -S are different options), so they must match ordinally.
    $ordinalMatch = $cleanCurrent -match '^-[^-]'

    foreach ($option in $catalog.Options) {
        $isMatch = if ($ordinalMatch) {
            $option.Token.StartsWith($cleanCurrent, [System.StringComparison]::Ordinal)
        } else {
            $option.Token -like ([System.Management.Automation.WildcardPattern]::Escape($cleanCurrent) + '*')
        }

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

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

    Initialize-CurlCompletionCatalog
    Get-CurlUrlValueResults -CurrentValue $CurrentWord -Placeholder '<url>' -ToolTip 'URL to transfer.'
}

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

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

    Initialize-CurlCompletionCatalog

    $currentToken = Get-CurlCurrentToken -Line $commandAst.Extent.Text -CursorPosition ($cursorPosition - $commandAst.Extent.StartOffset) -Fallback $wordToComplete
    $tokensBeforeCurrent = Get-CurlArgumentTokens -CommandAst $commandAst -CursorPosition $cursorPosition

    if ($currentToken -match '^(--[^=]+)=(.*)$') {
        $optionName = $matches[1]
        $valuePrefix = $matches[2]
        $optionSpec = Get-CurlOptionSpecByToken -Token $optionName
        if ($optionSpec -and $optionSpec.ValueKind) {
            return @(Get-CurlValueCompletions -OptionSpec $optionSpec -CurrentValue $valuePrefix -Prefix ($optionName + '='))
        }
    }

    $atWord = Get-CurlAtFileWord -CommandAst $commandAst -CursorPosition $cursorPosition
    if ($atWord) {
        # A split-off '@' is part of the value being typed, not a value of its own.
        $atTokens = if ($atWord.SplitAt) { @($tokensBeforeCurrent | Select-Object -First (@($tokensBeforeCurrent).Count - 1)) } else { $tokensBeforeCurrent }
        $atOption = Get-CurlPendingOption -TokensBeforeCurrent $atTokens
        if ($atOption -and $atOption.ValueKind -in 'DataValue', 'HeaderOrFile') {
            return @(Get-CurlValueCompletions -OptionSpec $atOption -CurrentValue $atWord.Value -Quote $atWord.Quote -SplitAt:$atWord.SplitAt)
        }
    }

    $pendingOption = Get-CurlPendingOption -TokensBeforeCurrent $tokensBeforeCurrent
    if ($pendingOption) {
        return @(Get-CurlValueCompletions -OptionSpec $pendingOption -CurrentValue $wordToComplete)
    }

    $results = New-Object System.Collections.Generic.List[System.Management.Automation.CompletionResult]

    if ([string]::IsNullOrWhiteSpace($currentToken) -or $currentToken.StartsWith('-')) {
        foreach ($result in @(Get-CurlOptionCompletions -CurrentWord $wordToComplete)) {
            [void]$results.Add($result)
        }
    }

    if (-not $currentToken.StartsWith('-')) {
        foreach ($result in @(Get-CurlPositionalCompletions -CurrentWord $wordToComplete)) {
            [void]$results.Add($result)
        }
    }

    @($results.ToArray())
}