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 ConvertFrom-FdTypedWord { # The value of a typed word. A word holding a quote (ASCII or typographic, at the start or # inside, as in .\'sp) is read by the PowerShell tokenizer, which drops the quotes and # undoes that quote style's escapes. param([string]$Value) if ($Value -notmatch '[''"\u2018-\u201E]') { return $Value } $tokens = $null $parseErrors = $null [void][System.Management.Automation.Language.Parser]::ParseInput($Value, [ref]$tokens, [ref]$parseErrors) $tokens[0].Value } function ConvertTo-FdQuotedValue { # Renders a value as one PowerShell argument: bare when safe and no quote was typed, # otherwise in the quote the user typed (single by default). PowerShell reads ' and # U+2018-U+201B as single quotes and " and U+201C-U+201E as double quotes. param( [string]$Value, [string]$QuoteChar = '' ) if ([string]::IsNullOrEmpty($Value)) { return $Value } if (-not $QuoteChar) { # A bare word led by a dash is read as a parameter (-a.txt splits into -a and .txt). if ($Value -notmatch '[\s{}();,|&<>''"`$@#\u2018-\u201E]' -and $Value -notmatch '^[-\u2013-\u2015]') { return $Value } $QuoteChar = "'" } if ($QuoteChar -match '^[''\u2018-\u201B]$') { return $QuoteChar + ($Value -replace '([''\u2018-\u201B])', '$1$1') + $QuoteChar } $QuoteChar + ($Value -replace '([`"$\u201C-\u201E])', '`$1') + $QuoteChar } function Test-FdPathLikeInput { param([string]$Value) if ([string]::IsNullOrWhiteSpace($Value)) { return $false } (ConvertFrom-FdTypedWord -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 = ConvertFrom-FdTypedWord -Value $InputPath $quoteChar = if ($InputPath -match '[''"\u2018-\u201E]') { $Matches[0] } else { '' } # The typed directory part (.\, ./, ..\, C:, dir/) is kept exactly as typed. $separatorIndex = $typedValue.LastIndexOfAny([char[]]@('\', '/')) $prefix = if ($separatorIndex -ge 0) { $typedValue.Substring(0, $separatorIndex + 1) } elseif ($typedValue -match '^[A-Za-z]:') { $typedValue.Substring(0, 2) } else { '' } $leaf = $typedValue.Substring($prefix.Length) $parent = if ($prefix) { $prefix } else { '.' } $directorySuffix = if ($prefix.EndsWith('/')) { '/' } else { '\' } if (-not (Test-Path -LiteralPath $parent -PathType Container -ErrorAction Ignore)) { return @() } $results = New-Object System.Collections.Generic.List[object] foreach ($item in @(Get-ChildItem -LiteralPath $parent -ErrorAction Ignore | Where-Object { $_.Name -like ([System.Management.Automation.WildcardPattern]::Escape($leaf) + '*') -and (-not $DirectoriesOnly -or $_.PSIsContainer) } | Sort-Object -Property Name)) { $pathText = $prefix + $item.Name if (-not $prefix -and $item.Name -match '^[-\u2013-\u2015]') { # Like PowerShell's own file completion: .\-a.txt, never a bare parameter-like word. $pathText = '.' + [System.IO.Path]::DirectorySeparatorChar + $pathText } if ($item.PSIsContainer) { $pathText += $directorySuffix } [void]$results.Add((New-FdCompletionResult -CompletionText (ConvertTo-FdQuotedValue -Value $pathText -QuoteChar $quoteChar) -ListItemText $pathText -ToolTip $item.FullName)) } @($results.ToArray()) } function Get-FdPlaceholderCompletions { param([string]$CurrentWord) # A bare brace token is a script block to PowerShell, so the placeholders are quoted. $typedValue = ConvertFrom-FdTypedWord -Value $CurrentWord $quoteChar = if ($CurrentWord -match '^[''"\u2018-\u201E]') { $CurrentWord.Substring(0, 1) } else { '' } @( foreach ($placeholder in Get-FdPlaceholderValues) { if ($placeholder.Text -like ([System.Management.Automation.WildcardPattern]::Escape($typedValue) + '*')) { New-FdCompletionResult -CompletionText (ConvertTo-FdQuotedValue -Value $placeholder.Text -QuoteChar $quoteChar) -ListItemText $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 (ConvertTo-FdQuotedValue -Value $placeholder) -ListItemText $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 @() } $typedValue = ConvertFrom-FdTypedWord -Value $CurrentWord $quoteChar = if ($CurrentWord -match '^[''"\u2018-\u201E]') { $CurrentWord.Substring(0, 1) } else { '' } @( foreach ($value in $values.Values) { if ($value -like ([System.Management.Automation.WildcardPattern]::Escape($typedValue) + '*')) { New-FdCompletionResult -CompletionText (ConvertTo-FdQuotedValue -Value $value -QuoteChar $quoteChar) -ListItemText $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 Get-FdArgumentValue { # The value a native command receives for $Text written as one argument: a string's value, # or an unquoted comma list joined with commas. Anything else yields $null. param([string]$Text) $ast = [System.Management.Automation.Language.Parser]::ParseInput("fd $Text", [ref]$null, [ref]$null) $command = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true) $elements = @($command.CommandElements | Select-Object -Skip 1) if ($elements.Count -ne 1) { return $null } $element = $elements[0] if ($element -is [System.Management.Automation.Language.StringConstantExpressionAst]) { return $element.Value } if ($element -is [System.Management.Automation.Language.ArrayLiteralAst]) { $parts = @($element.Elements) if (@($parts | Where-Object { $_ -isnot [System.Management.Automation.Language.StringConstantExpressionAst] -or $_.StringConstantType -ne 'BareWord' }).Count -eq 0) { return (($parts | ForEach-Object Value) -join ',') } } $null } function Select-FdSegmentCompletion { # Inside an unquoted comma list PowerShell replaces only the segment after the last comma # and hands fd the list joined with commas. A candidate is kept when its value extends the # typed list and the rest stays bare, so the accepted line still carries that value. param( [object[]]$Results, [string]$SegmentPrefix ) foreach ($item in $Results) { $value = Get-FdArgumentValue -Text $item.CompletionText if ($null -eq $value -or -not $value.StartsWith($SegmentPrefix, [System.StringComparison]::Ordinal)) { continue } $rest = $value.Substring($SegmentPrefix.Length) $unsafe = @($rest.Split(',') | Where-Object { -not $_ -or (ConvertTo-FdQuotedValue -Value $_) -cne $_ }) if ($unsafe.Count -gt 0) { continue } New-FdCompletionResult -CompletionText $rest -ListItemText $item.ListItemText -ResultType $item.ResultType -ToolTip $item.ToolTip } } function Get-FdWordCompletion { param( [string]$CurrentWord, [string[]]$TokensBeforeCurrent ) Initialize-FdCompletionCatalog $catalog = Get-FdCompletionCatalog # -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') { $typedValue = ConvertFrom-FdTypedWord -Value $currentWord $quoteChar = if ($currentWord -match '^[''"\u2018-\u201E]') { $currentWord.Substring(0, 1) } else { '' } return @( Get-FdPlaceholderCompletions -CurrentWord $currentWord foreach ($name in Get-FdCommandNames) { if ($name.StartsWith($typedValue, [System.StringComparison]::OrdinalIgnoreCase)) { New-FdCompletionResult -CompletionText (ConvertTo-FdQuotedValue -Value $name -QuoteChar $quoteChar) -ListItemText $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 (ConvertTo-FdQuotedValue -Value '<pattern>') -ListItemText '<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) } @() } function Complete-Fd { param( [string]$WordToComplete, [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) $currentWord = if ($null -eq $WordToComplete) { Get-FdCurrentToken -Line $CommandAst.ToString() -CursorPosition $CursorPosition -Fallback $WordToComplete } else { $WordToComplete } # PowerShell hands a quoted word over re-quoted ("'it" arrives as "'it'") and drops the # quote inside an attached "--opt='it", while it replaces the whole word as typed; so the # word is read from the element under the cursor. An unquoted comma list ('a,b', or 'a,' # which parses as an error expression) is the exception: PowerShell replaces only the # segment after the last comma, so the whole element is completed and the candidates are # cut back to that segment. $segmentPrefix = '' $cursorElement = $CommandAst.CommandElements | Select-Object -Skip 1 | Where-Object { $_.Extent.StartOffset -lt $CursorPosition -and $_.Extent.EndOffset -ge $CursorPosition } | Select-Object -First 1 if ($cursorElement) { $elementText = $cursorElement.Extent.Text.Substring(0, $CursorPosition - $cursorElement.Extent.StartOffset) $isList = $cursorElement -is [System.Management.Automation.Language.ArrayLiteralAst] -or $cursorElement -is [System.Management.Automation.Language.ErrorExpressionAst] if (-not $isList) { $currentWord = $elementText } elseif ($elementText.EndsWith(',' + $WordToComplete, [System.StringComparison]::Ordinal)) { $currentWord = $elementText $segmentPrefix = $elementText.Substring(0, $elementText.Length - $WordToComplete.Length) } } $tokensBeforeCurrent = @(Get-FdArgumentTokens -CommandAst $CommandAst -CursorPosition $CursorPosition) $results = @(Get-FdWordCompletion -CurrentWord $currentWord -TokensBeforeCurrent $tokensBeforeCurrent) if (-not $segmentPrefix) { return $results } $segmentResults = @(Select-FdSegmentCompletion -Results $results -SegmentPrefix $segmentPrefix) if ($segmentResults.Count -eq 0 -and $WordToComplete) { # Echo the typed segment so PowerShell's filename fallback adds no candidate that would # break the list; an empty segment cannot be echoed (PowerShell rejects an empty text). return @(New-FdCompletionResult -CompletionText $WordToComplete -ToolTip 'Nothing continues this comma list; quote the whole value to complete a name that contains a comma.') } $segmentResults } } Register-ArgumentCompleter -Native -CommandName @('fd', 'fd.exe') -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) Complete-Fd -WordToComplete $wordToComplete -CommandAst $commandAst -CursorPosition $cursorPosition } |