completers/fd_completer/fd_completer.ps1

Set-StrictMode -Version 2.0

if ($true) {
function New-FdCompletionResult {
    param(
        [string]$CompletionText,
        [string]$ResultType = 'ParameterValue',
        [string]$ToolTip,
        [string]$ListItemText
    )

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

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

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

function Get-FdCompletionCatalog {
    $existingCatalog = Get-Variable -Name FdCompletionCatalog -Scope Script -ErrorAction Ignore
    if ($null -ne $existingCatalog) {
        return $existingCatalog.Value
    }

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

    $script:FdCompletionCatalog
}

function Resolve-FdCommandName {
    $catalog = Get-FdCompletionCatalog
    if ($catalog.CommandName) {
        return $catalog.CommandName
    }

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

    $catalog.CommandName
}

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

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

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

function Get-FdValueKindMap {
    $map = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal)
    $entries = @(
        [pscustomobject]@{ Token = '-c'; Kind = 'ColorWhen' }
        [pscustomobject]@{ Token = '--color'; Kind = 'ColorWhen' }
        [pscustomobject]@{ Token = '--hyperlink'; Kind = 'HyperlinkWhen' }
        [pscustomobject]@{ Token = '-t'; Kind = 'FileType' }
        [pscustomobject]@{ Token = '--type'; Kind = 'FileType' }
        [pscustomobject]@{ Token = '-d'; Kind = 'Integer' }
        [pscustomobject]@{ Token = '--max-depth'; Kind = 'Integer' }
        [pscustomobject]@{ Token = '--min-depth'; Kind = 'Integer' }
        [pscustomobject]@{ Token = '--exact-depth'; Kind = 'Integer' }
        [pscustomobject]@{ Token = '-E'; Kind = 'GlobPattern' }
        [pscustomobject]@{ Token = '--exclude'; Kind = 'GlobPattern' }
        [pscustomobject]@{ Token = '-e'; Kind = 'Extension' }
        [pscustomobject]@{ Token = '--extension'; Kind = 'Extension' }
        [pscustomobject]@{ Token = '-S'; Kind = 'Size' }
        [pscustomobject]@{ Token = '--size'; Kind = 'Size' }
        [pscustomobject]@{ Token = '--changed-within'; Kind = 'DateOrDuration' }
        [pscustomobject]@{ Token = '--change-newer-than'; Kind = 'DateOrDuration' }
        [pscustomobject]@{ Token = '--newer'; Kind = 'DateOrDuration' }
        [pscustomobject]@{ Token = '--changed-after'; Kind = 'DateOrDuration' }
        [pscustomobject]@{ Token = '--changed-before'; Kind = 'DateOrDuration' }
        [pscustomobject]@{ Token = '--change-older-than'; Kind = 'DateOrDuration' }
        [pscustomobject]@{ Token = '--older'; Kind = 'DateOrDuration' }
        [pscustomobject]@{ Token = '--format'; Kind = 'Format' }
        [pscustomobject]@{ Token = '-x'; Kind = 'CommandTail' }
        [pscustomobject]@{ Token = '--exec'; Kind = 'CommandTail' }
        [pscustomobject]@{ Token = '-X'; Kind = 'CommandTail' }
        [pscustomobject]@{ Token = '--exec-batch'; Kind = 'CommandTail' }
        [pscustomobject]@{ Token = '--batch-size'; Kind = 'Integer' }
        [pscustomobject]@{ Token = '--ignore-file'; Kind = 'Path' }
        [pscustomobject]@{ Token = '--ignore-contain'; Kind = 'Name' }
        [pscustomobject]@{ Token = '-j'; Kind = 'Integer' }
        [pscustomobject]@{ Token = '--threads'; Kind = 'Integer' }
        [pscustomobject]@{ Token = '--max-results'; Kind = 'Integer' }
        [pscustomobject]@{ Token = '-C'; Kind = 'Directory' }
        [pscustomobject]@{ Token = '--base-directory'; Kind = 'Directory' }
        [pscustomobject]@{ Token = '--path-separator'; Kind = 'PathSeparator' }
        [pscustomobject]@{ Token = '--search-path'; Kind = 'Directory' }
        [pscustomobject]@{ Token = '--strip-cwd-prefix'; Kind = 'StripMode' }
        [pscustomobject]@{ Token = '--and'; Kind = 'Pattern' }
    )

    foreach ($entry in $entries) {
        $map[$entry.Token] = $entry.Kind
    }

    $map
}

function Get-FdPlaceholderValues {
    # The template placeholders documented under -x/--exec; --format uses the same set (fd
    # prints any other brace token literally).
    @(
        [pscustomobject]@{ Text = '{}'; Tip = 'Path of the search result.' }
        [pscustomobject]@{ Text = '{/}'; Tip = 'Basename of the search result.' }
        [pscustomobject]@{ Text = '{//}'; Tip = 'Parent directory of the search result.' }
        [pscustomobject]@{ Text = '{.}'; Tip = 'Path without the file extension.' }
        [pscustomobject]@{ Text = '{/.}'; Tip = 'Basename without the file extension.' }
        [pscustomobject]@{ Text = '{{'; Tip = 'Literal { (escaping).' }
        [pscustomobject]@{ Text = '}}'; Tip = 'Literal } (escaping).' }
    )
}

function Get-FdStaticOptionSpecs {
    # fd 10.5.0 surface, used as the floor under the help parse and as the only source for the
    # override/alias flags clap does not print (Alias = $true sorts them after canonical names).
    @(
        @{ Tokens = @('-H', '--hidden'); Description = 'Include hidden directories and files in the search results.' }
        @{ Tokens = @('--no-hidden'); Description = 'Override --hidden.'; Alias = $true }
        @{ Tokens = @('-I', '--no-ignore'); Description = 'Show results from files and directories that would otherwise be ignored by .gitignore, .ignore, .fdignore or the global ignore file.' }
        @{ Tokens = @('--ignore'); Description = 'Override --no-ignore.'; Alias = $true }
        @{ Tokens = @('--no-ignore-vcs'); Description = 'Show results that would otherwise be ignored by .gitignore files.' }
        @{ Tokens = @('--ignore-vcs'); Description = 'Override --no-ignore-vcs.'; Alias = $true }
        @{ Tokens = @('--no-require-git'); Description = 'Do not require a git repository to respect gitignores.' }
        @{ Tokens = @('--require-git'); Description = 'Override --no-require-git.'; Alias = $true }
        @{ Tokens = @('--no-ignore-parent'); Description = 'Show results that would otherwise be ignored by ignore files in parent directories.' }
        @{ Tokens = @('--ignore-parent'); Description = 'Override --no-ignore-parent.'; Alias = $true }
        @{ Tokens = @('-u', '--unrestricted'); Description = 'Unrestricted search, including ignored and hidden files (alias for --no-ignore --hidden).' }
        @{ Tokens = @('-s', '--case-sensitive'); Description = 'Case-sensitive search (default: smart case).' }
        @{ Tokens = @('-i', '--ignore-case'); Description = 'Case-insensitive search (default: smart case).' }
        @{ Tokens = @('-g', '--glob'); Description = 'Glob-based search instead of a regular expression search.' }
        @{ Tokens = @('--regex'); Description = 'Regular-expression based search (default); overrides --glob.' }
        @{ Tokens = @('-F', '--fixed-strings'); Description = 'Treat the pattern as a literal string instead of a regular expression.' }
        @{ Tokens = @('--exact'); Description = 'Exact match: like --fixed-strings but the whole filename must match.' }
        @{ Tokens = @('--and'); Description = 'Add additional required search patterns, all of which must be matched.' }
        @{ Tokens = @('-a', '--absolute-path'); Description = 'Show the full path starting from the root.' }
        @{ Tokens = @('--relative-path'); Description = 'Override --absolute-path.'; Alias = $true }
        @{ Tokens = @('-l', '--list-details'); Description = 'Use a detailed listing format like ls -l.' }
        @{ Tokens = @('-L', '--follow'); Description = 'Traverse symbolic links.' }
        @{ Tokens = @('--no-follow'); Description = 'Override --follow.'; Alias = $true }
        @{ Tokens = @('-p', '--full-path'); Description = 'Match the pattern against the full (absolute) path.' }
        @{ Tokens = @('-0', '--print0'); Description = 'Separate search results by the null character.' }
        @{ Tokens = @('-d', '--max-depth'); Description = 'Limit the directory traversal to a given depth.' }
        @{ Tokens = @('--min-depth'); Description = 'Only show search results starting at the given depth.' }
        @{ Tokens = @('--exact-depth'); Description = 'Only show search results at the exact given depth.' }
        @{ Tokens = @('-E', '--exclude'); Description = 'Exclude files/directories that match the given glob pattern.' }
        @{ Tokens = @('--prune'); Description = 'Do not traverse into directories that match the search criteria.' }
        @{ Tokens = @('-t', '--type'); Description = 'Filter the search by type (f/file, d/dir, l/symlink, s/socket, p/pipe, b/block-device, c/char-device, x/executable, e/empty).' }
        @{ Tokens = @('-e', '--extension'); Description = 'Filter results by extension.' }
        @{ Tokens = @('-S', '--size'); Description = 'Limit results based on the size of files (<+-><NUM><UNIT>).' }
        @{ Tokens = @('--changed-within'); Description = 'Filter by modification time: files changed after the given date or within the given duration.' }
        @{ Tokens = @('--change-newer-than'); Description = 'Alias of --changed-within.'; Alias = $true }
        @{ Tokens = @('--newer'); Description = 'Alias of --changed-within.'; Alias = $true }
        @{ Tokens = @('--changed-after'); Description = 'Alias of --changed-within.'; Alias = $true }
        @{ Tokens = @('--changed-before'); Description = 'Filter by modification time: files changed before the given date or duration.' }
        @{ Tokens = @('--change-older-than'); Description = 'Alias of --changed-before.'; Alias = $true }
        @{ Tokens = @('--older'); Description = 'Alias of --changed-before.'; Alias = $true }
        @{ Tokens = @('--format'); Description = 'Print results according to template.' }
        @{ Tokens = @('-x', '--exec'); Description = 'Execute a command for each search result in parallel.' }
        @{ Tokens = @('-X', '--exec-batch'); Description = 'Execute the given command once, with all search results as arguments.' }
        @{ Tokens = @('--batch-size'); Description = 'Maximum number of arguments to pass to the command given with -X.' }
        @{ Tokens = @('--ignore-file'); Description = 'Add a custom ignore-file in .gitignore format.' }
        @{ Tokens = @('-c', '--color'); Description = 'Declare when to use color for the pattern match output (auto, always, never).' }
        @{ Tokens = @('--hyperlink'); Description = 'Add a terminal hyperlink to a file:// url for each path in the output (auto, always, never; no value means always).'; OptionalValue = $true }
        @{ Tokens = @('--ignore-contain'); Description = 'Ignore directories containing the named entry.' }
        @{ Tokens = @('-j', '--threads'); Description = 'Set number of threads to use for searching and executing.' }
        @{ Tokens = @('--max-results'); Description = 'Limit the number of search results and quit immediately.' }
        @{ Tokens = @('-1'); Description = 'Limit the search to a single result and quit immediately.' }
        @{ Tokens = @('-q', '--quiet'); Description = 'Print nothing; exit code 0 if there is at least one match.' }
        @{ Tokens = @('--has-results'); Description = 'Alias of --quiet.'; Alias = $true }
        @{ Tokens = @('--show-errors'); Description = 'Enable the display of filesystem errors.' }
        @{ Tokens = @('-C', '--base-directory'); Description = 'Change the current working directory of fd to the provided path.' }
        @{ Tokens = @('--path-separator'); Description = 'Set the path separator to use when printing file paths.' }
        @{ Tokens = @('--search-path'); Description = 'Provide paths to search as an alternative to the positional <path> argument.' }
        @{ Tokens = @('--strip-cwd-prefix'); Description = 'Control the ./ prefix on relative paths (auto, always, never; no value means always).'; OptionalValue = $true }
        @{ Tokens = @('--one-file-system'); Description = 'Do not descend into a different file system than the one the search started in.' }
        @{ Tokens = @('-h', '--help'); Description = 'Print help.' }
        @{ Tokens = @('-V', '--version'); Description = 'Print version.' }
    )
}

function New-FdOptionSpec {
    param(
        [string[]]$Tokens,
        [string]$Description,
        [bool]$OptionalValue = $false,
        [bool]$Alias = $false
    )

    $valueKindMap = Get-FdValueKindMap
    $valueKind = $null
    foreach ($token in $Tokens) {
        if ($valueKindMap.ContainsKey($token)) {
            $valueKind = $valueKindMap[$token]
            break
        }
    }

    [pscustomobject]@{
        Tokens        = @($Tokens | Select-Object -Unique)
        Description   = $Description
        ValueKind     = $valueKind
        OptionalValue = $OptionalValue
        Alias         = $Alias
    }
}

function Add-FdCatalogSpec {
    param(
        [hashtable]$Catalog,
        [object]$Spec
    )

    # A later spec for the same token (the live help over the static seed) replaces the earlier one.
    $retained = @()
    foreach ($existing in @($Catalog.Options)) {
        $overlaps = $false
        foreach ($token in $Spec.Tokens) {
            if ($token -cin $existing.Tokens) {
                $overlaps = $true
                break
            }
        }

        if ($overlaps) {
            foreach ($token in $existing.Tokens) {
                [void]$Catalog.OptionByToken.Remove($token)
            }
        } else {
            $retained += $existing
        }
    }

    $Catalog.Options = @($retained + $Spec)
    foreach ($token in $Spec.Tokens) {
        $Catalog.OptionByToken[$token] = $Spec
    }
}

function ConvertFrom-FdHelpTokenPart {
    param([string]$TokenPart)

    # '-c, --color <when>' -> -c, --color; '--hyperlink[=<when>]' -> --hyperlink with an optional value.
    $tokens = @()
    $optionalValue = $false
    foreach ($fragment in ($TokenPart -split ',\s*')) {
        $token = $fragment.Trim()
        if ($token -notmatch '^-') {
            continue
        }

        if ($token -match '^(--[A-Za-z0-9-]+)(?:\.\.\.)?(\[=.*\])?(?:[ =<].*)?$') {
            if ($matches[2]) {
                $optionalValue = $true
            }

            $token = $matches[1]
        } elseif ($token -match '^(-[A-Za-z0-9?])(?:[ =<].*)?$') {
            $token = $matches[1]
        } elseif ($token -match '^(-\d)$') {
            $token = $matches[1]
        } else {
            continue
        }

        $tokens += $token
    }

    [pscustomobject]@{ Tokens = $tokens; OptionalValue = $optionalValue }
}

function Add-FdHelpSpec {
    param(
        [hashtable]$Catalog,
        [string]$TokenPart,
        [string[]]$DescriptionLines
    )

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

    $parsed = ConvertFrom-FdHelpTokenPart -TokenPart $TokenPart
    if (@($parsed.Tokens).Count -eq 0) {
        return 0
    }

    $description = (@($DescriptionLines) -join ' ').Trim()
    if ([string]::IsNullOrWhiteSpace($description)) {
        $description = $TokenPart
    }

    $alias = $false
    foreach ($token in $parsed.Tokens) {
        if ($Catalog.OptionByToken.ContainsKey($token) -and $Catalog.OptionByToken[$token].Alias) {
            $alias = $true
        }
    }

    Add-FdCatalogSpec -Catalog $Catalog -Spec (New-FdOptionSpec -Tokens $parsed.Tokens -Description $description -OptionalValue $parsed.OptionalValue -Alias $alias)
    1
}

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

    if (-not $catalog.Seeded) {
        foreach ($static in Get-FdStaticOptionSpecs) {
            $optionalValue = $static.ContainsKey('OptionalValue') -and $static.OptionalValue
            $alias = $static.ContainsKey('Alias') -and $static.Alias
            Add-FdCatalogSpec -Catalog $catalog -Spec (New-FdOptionSpec -Tokens $static.Tokens -Description $static.Description -OptionalValue $optionalValue -Alias $alias)
        }

        $catalog.Seeded = $true
    }

    $helpLines = Invoke-FdCapture -Arguments @('--help')
    $pendingTokenPart = $null
    $descriptionLines = [System.Collections.Generic.List[string]]::new()
    $inOptions = $false

    foreach ($line in @($helpLines)) {
        if (-not $inOptions) {
            if ($line -eq 'Options:') {
                $inOptions = $true
            }

            continue
        }

        # clap prints option rows at 2 or 6 spaces; the example lines inside descriptions are
        # indented 10 or more, so they never start a spec.
        if ($line -match '^\s{2,7}(?<tokenPart>--?[A-Za-z0-9?].*?)\s*$') {
            $null = Add-FdHelpSpec -Catalog $catalog -TokenPart $pendingTokenPart -DescriptionLines $descriptionLines.ToArray()
            $pendingTokenPart = $matches.tokenPart.Trim()
            $descriptionLines.Clear()
            continue
        }

        if ($null -ne $pendingTokenPart -and $line -match '^\s{10,}(?<description>\S.*)$') {
            $descriptionLines.Add($matches.description.Trim())
            continue
        }

        if (-not [string]::IsNullOrWhiteSpace($line) -and $null -ne $pendingTokenPart) {
            $null = Add-FdHelpSpec -Catalog $catalog -TokenPart $pendingTokenPart -DescriptionLines $descriptionLines.ToArray()
            $pendingTokenPart = $null
            $descriptionLines.Clear()
        }
    }

    $null = Add-FdHelpSpec -Catalog $catalog -TokenPart $pendingTokenPart -DescriptionLines $descriptionLines.ToArray()

    $catalog.Initialized = $true
}

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

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

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

function ConvertTo-FdQuotedValue {
    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 Test-FdPathLikeInput {
    param([string]$Value)

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

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

function Get-FdCurrentToken {
    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-FdTokenText {
    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-FdArgumentTokens {
    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-FdTokenText -Element $element
        }
    }

    $tokens
}

function Test-FdExecOption {
    param([string]$Token)

    $Token -cin @('-x', '--exec', '-X', '--exec-batch')
}

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

    # 'Command' when the cursor sits right after -x/-X (the program name), 'Tail' for the
    # command's own arguments up to a '\;' terminator, otherwise $null.
    $context = $null
    foreach ($token in @($TokensBeforeCurrent)) {
        if ($null -eq $context) {
            if (Test-FdExecOption -Token $token) {
                $context = 'Command'
            }

            continue
        }

        if ($token -ceq '\;' -or $token -ceq ';') {
            $context = $null
            continue
        }

        $context = 'Tail'
    }

    $context
}

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

    Initialize-FdCompletionCatalog
    $catalog = Get-FdCompletionCatalog
    if (-not $TokensBeforeCurrent -or $TokensBeforeCurrent.Count -eq 0) {
        return $null
    }

    $lastToken = $TokensBeforeCurrent[-1]
    if ($catalog.OptionByToken.ContainsKey($lastToken)) {
        $spec = $catalog.OptionByToken[$lastToken]
        # An optional value ('--hyperlink[=<when>]') is only accepted attached, so the next token
        # is a positional, not the value.
        if ($spec.ValueKind -and -not $spec.OptionalValue) {
            return $spec
        }
    }

    $null
}

function Get-FdCommandNames {
    $cache = Get-Variable -Name 'FdCommandNameCache' -Scope Script -ErrorAction Ignore
    if ($null -ne $cache -and $null -ne $cache.Value -and $cache.Value.Path -eq $env:PATH) {
        return $cache.Value.Names
    }

    $names = @(
        Get-Command -CommandType Application -ErrorAction Ignore |
            ForEach-Object { [System.IO.Path]::GetFileNameWithoutExtension($_.Name) } |
            Where-Object { $_ -and $_ -ne 'fd' } |
            Sort-Object -Unique
    )
    Set-Variable -Name 'FdCommandNameCache' -Value @{ Path = $env:PATH; Names = $names } -Scope Script
    $names
}

function Get-FdPathCompletions {
    param(
        [string]$InputPath,
        [switch]$DirectoriesOnly
    )

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

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

    $results = New-Object System.Collections.Generic.List[object]
    foreach ($item in @(Get-ChildItem -LiteralPath $parent -ErrorAction SilentlyContinue | Where-Object { $_.Name -like ([System.Management.Automation.WildcardPattern]::Escape($leaf) + '*') -and (-not $DirectoriesOnly -or $_.PSIsContainer) } | Sort-Object -Property Name)) {
        $pathText = if ($parent -eq '.') { $item.Name } else { Join-Path -Path $parent -ChildPath $item.Name }
        if ($item.PSIsContainer -and -not $pathText.EndsWith('\')) {
            $pathText += '\'
        }

        [void]$results.Add((New-FdCompletionResult -CompletionText (ConvertTo-FdQuotedValue -Value $pathText -AlwaysQuote $alwaysQuote) -ToolTip $item.FullName))
    }

    @($results.ToArray())
}

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

    @(
        foreach ($placeholder in Get-FdPlaceholderValues) {
            if ($placeholder.Text -like ([System.Management.Automation.WildcardPattern]::Escape($CurrentWord) + '*')) {
                New-FdCompletionResult -CompletionText $placeholder.Text -ToolTip $placeholder.Tip
            }
        }
    )
}

function Get-FdValueCompletions {
    param(
        [object]$Spec,
        [string]$CurrentWord
    )

    $kind = [string]$Spec.ValueKind
    if ($kind -eq 'Path' -or $kind -eq 'Directory') {
        # -C/--base-directory and --search-path reject a file, --ignore-file takes one.
        $results = @(Get-FdPathCompletions -InputPath $CurrentWord -DirectoriesOnly:($kind -eq 'Directory'))
        if ($results.Count -eq 0) {
            $placeholder = if ($kind -eq 'Directory') { '<dir>' } else { '<path>' }
            return @(New-FdCompletionResult -CompletionText $placeholder -ToolTip 'Filesystem path value.')
        }

        return $results
    }

    if ($kind -eq 'Format' -or $kind -eq 'CommandTail') {
        return Get-FdPlaceholderCompletions -CurrentWord $CurrentWord
    }

    $values = switch ($kind) {
        'ColorWhen' { @{ Values = @('auto', 'always', 'never'); Tip = 'Color output mode.' } }
        'HyperlinkWhen' { @{ Values = @('auto', 'always', 'never'); Tip = 'Hyperlink output mode.' } }
        'StripMode' { @{ Values = @('auto', 'always', 'never'); Tip = 'Strip ./ prefix behavior.' } }
        'PathSeparator' { @{ Values = @('\', '/'); Tip = 'Path separator in printed output.' } }
        'FileType' { @{ Values = @('f', 'file', 'd', 'dir', 'directory', 'l', 'symlink', 's', 'socket', 'p', 'pipe', 'b', 'block-device', 'c', 'char-device', 'x', 'executable', 'e', 'empty'); Tip = 'File type filter.' } }
        'Extension' { @{ Values = @('ps1', 'md', 'json', 'yaml', 'yml', 'txt', 'ts', 'js', 'py', 'rs', 'go', 'zip'); Tip = 'Allowed file extension.' } }
        'Size' { @{ Values = @('+1k', '-10m', '500b', '1mi', '2g'); Tip = 'File size expression.' } }
        'DateOrDuration' { @{ Values = @('1day', '2weeks', '10h', '35min', (Get-Date).ToString('yyyy-MM-dd')); Tip = 'Date or duration filter.' } }
        'Integer' { @{ Values = @('1', '2', '4', '8', '16'); Tip = 'Integer value.' } }
        'GlobPattern' { @{ Values = @('*.log', '*.tmp', 'node_modules', 'bin'); Tip = 'Glob pattern.' } }
        'Pattern' { @{ Values = @('<pattern>'); Tip = 'Additional required search pattern.' } }
        'Name' { @{ Values = @('.git', 'node_modules', '.venv'); Tip = 'Directory marker name.' } }
        default { $null }
    }

    if ($null -eq $values) {
        return @()
    }

    @(
        foreach ($value in $values.Values) {
            if ($value -like ([System.Management.Automation.WildcardPattern]::Escape($CurrentWord) + '*')) {
                New-FdCompletionResult -CompletionText $value -ToolTip $values.Tip
            }
        }
    )
}

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

    Initialize-FdCompletionCatalog
    $catalog = Get-FdCompletionCatalog
    # Each token once, canonical names before the override/alias flags.
    $tokens = @($catalog.OptionByToken.Keys | Sort-Object -Property @{ Expression = { $catalog.OptionByToken[$_].Alias } }, @{ Expression = { $_ } })
    foreach ($token in $tokens) {
        if ($token -like ([System.Management.Automation.WildcardPattern]::Escape($CurrentWord) + '*')) {
            New-FdCompletionResult -CompletionText $token -ResultType 'ParameterName' -ToolTip $catalog.OptionByToken[$token].Description
        }
    }
}

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

    Initialize-FdCompletionCatalog
    $catalog = Get-FdCompletionCatalog
    $count = 0
    $skipNext = $false

    foreach ($token in @($TokensBeforeCurrent)) {
        if ($skipNext) {
            $skipNext = $false
            continue
        }

        # Everything after -x/-X belongs to the command, not to fd's positionals.
        if (Test-FdExecOption -Token $token) {
            break
        }

        if ($catalog.OptionByToken.ContainsKey($token)) {
            $spec = $catalog.OptionByToken[$token]
            if ($spec.ValueKind -and -not $spec.OptionalValue) {
                $skipNext = $true
            }
            continue
        }

        if (-not [string]::IsNullOrWhiteSpace($token) -and -not $token.StartsWith('-')) {
            $count++
        }
    }

    $count
}

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

    Initialize-FdCompletionCatalog
    $catalog = Get-FdCompletionCatalog

    $currentWord = if ($null -eq $WordToComplete) {
        Get-FdCurrentToken -Line $CommandAst.ToString() -CursorPosition $CursorPosition -Fallback $WordToComplete
    } else {
        $WordToComplete
    }

    $tokensBeforeCurrent = @(Get-FdArgumentTokens -CommandAst $CommandAst -CursorPosition $CursorPosition)

    # -x/-X: the next token is a program name, the rest of the line its arguments.
    $execContext = Get-FdExecContext -TokensBeforeCurrent $tokensBeforeCurrent
    if ($execContext -eq 'Command') {
        return @(
            Get-FdPlaceholderCompletions -CurrentWord $currentWord
            foreach ($name in Get-FdCommandNames) {
                if ($name.StartsWith($currentWord, [System.StringComparison]::OrdinalIgnoreCase)) {
                    New-FdCompletionResult -CompletionText $name -ResultType 'Command' -ToolTip 'Command to execute for each search result.'
                }
            }
        )
    }

    if ($execContext -eq 'Tail') {
        return @(
            Get-FdPlaceholderCompletions -CurrentWord $currentWord
            Get-FdPathCompletions -InputPath $currentWord
        )
    }

    # Attached '--opt=value' / '-o=value': serve the option's values with the prefix kept.
    if ($currentWord -match '^(?<option>--?[A-Za-z0-9?][A-Za-z0-9-]*)=(?<value>.*)$') {
        $optionText = $Matches['option']
        $valueText = $Matches['value']
        if ($catalog.OptionByToken.ContainsKey($optionText) -and $catalog.OptionByToken[$optionText].ValueKind) {
            return @(
                foreach ($result in Get-FdValueCompletions -Spec $catalog.OptionByToken[$optionText] -CurrentWord $valueText) {
                    New-FdCompletionResult -CompletionText ($optionText + '=' + $result.CompletionText) -ListItemText $result.ListItemText -ResultType $result.ResultType -ToolTip $result.ToolTip
                }
            )
        }

        return @(Get-FdOptionCompletions -CurrentWord $optionText)
    }

    $expectedValue = Get-FdExpectedValueSpec -TokensBeforeCurrent $tokensBeforeCurrent
    if ($expectedValue) {
        return @(Get-FdValueCompletions -Spec $expectedValue -CurrentWord $currentWord)
    }

    if (-not [string]::IsNullOrEmpty($currentWord) -and $currentWord.StartsWith('-')) {
        return @(Get-FdOptionCompletions -CurrentWord $currentWord)
    }

    $positionalCount = Get-FdPositionalCount -TokensBeforeCurrent $tokensBeforeCurrent
    if ($positionalCount -ge 1 -or (Test-FdPathLikeInput -Value $currentWord)) {
        $pathResults = @(Get-FdPathCompletions -InputPath $currentWord)
        if ($pathResults.Count -gt 0) {
            return $pathResults
        }
    }

    if ($positionalCount -eq 0) {
        $results = New-Object System.Collections.Generic.List[object]
        if ([string]::IsNullOrWhiteSpace($currentWord)) {
            [void]$results.Add((New-FdCompletionResult -CompletionText '<pattern>' -ToolTip 'Search pattern (regex by default, glob with --glob).'))
        }

        foreach ($item in @(Get-FdOptionCompletions -CurrentWord $currentWord)) {
            [void]$results.Add($item)
        }

        return @($results.ToArray())
    }

    if ([string]::IsNullOrWhiteSpace($currentWord)) {
        return @(Get-FdOptionCompletions -CurrentWord $currentWord)
    }

    @()
}
}

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

    Complete-Fd -WordToComplete $wordToComplete -CommandAst $commandAst -CursorPosition $cursorPosition
}