completers/cargo_completer/cargo_completer.ps1
|
# cargo native argument completer for PowerShell # Help-driven completer for cargo root and subcommand switches with safe local discovery. Set-StrictMode -Version 2.0 function Get-CargoCompletionCache { $cache = Get-Variable -Name CargoCompletionCache -Scope Script -ErrorAction Ignore if ($cache) { return $cache.Value } $newCache = @{ Initialized = $false CommandName = $null CommandResolved = $false RootCommands = @() RootOptions = @() CommandMetadata = @{} Toolchains = @() Targets = @() UnstableFlags = @() RootValueMap = @{} CommonValueMap = @{} PathOptions = @('-C', '--config') CommandPathOptions = @{ '<root>' = @('-C', '--config') } Projects = @{} Installed = @{} } Set-Variable -Name CargoCompletionCache -Scope Script -Value $newCache $newCache } function Resolve-CargoCommandName { $cache = Get-CargoCompletionCache if ($cache.CommandResolved) { return $cache.CommandName } $command = Get-Command -Name cargo.exe, cargo -ErrorAction Ignore | Select-Object -First 1 if ($command) { $cache.CommandName = if ($command.Source) { $command.Source } else { $command.Name } } $cache.CommandResolved = $true $cache.CommandName } function Invoke-CargoText { param([string[]]$Arguments) $commandName = Resolve-CargoCommandName if (-not $commandName) { return @() } try { @($null | & $commandName @Arguments 2>&1 | ForEach-Object { $_.ToString() -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' }) } catch { @() } } function Invoke-CargoBoundedText { param( [string[]]$Arguments, [string]$WorkingDirectory ) $commandName = Resolve-CargoCommandName if (-not $commandName) { return $null } $startInfo = [System.Diagnostics.ProcessStartInfo]::new($commandName) foreach ($argument in $Arguments) { $startInfo.ArgumentList.Add($argument) } $startInfo.WorkingDirectory = $WorkingDirectory $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true $startInfo.RedirectStandardInput = $true $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true # A Tab must never install the toolchain a rust-toolchain.toml names or reach a registry. $startInfo.Environment['RUSTUP_AUTO_INSTALL'] = '0' $startInfo.Environment['CARGO_NET_OFFLINE'] = 'true' $startInfo.Environment['CARGO_TERM_COLOR'] = 'never' $process = [System.Diagnostics.Process]::Start($startInfo) try { $process.StandardInput.Close() $stdout = $process.StandardOutput.ReadToEndAsync() $null = $process.StandardError.ReadToEndAsync() if (-not $process.WaitForExit(5000)) { $process.Kill($true) return $null } $process.WaitForExit() if ($process.ExitCode -ne 0) { return $null } $stdout.Result -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' } finally { $process.Dispose() } } function New-CargoCompletionResult { param( [string]$CompletionText, [string]$ListItemText, [string]$ResultType, [string]$ToolTip ) if ([string]::IsNullOrWhiteSpace($ListItemText)) { $ListItemText = $CompletionText } if ([string]::IsNullOrWhiteSpace($ToolTip)) { $ToolTip = $CompletionText } [System.Management.Automation.CompletionResult]::new( $CompletionText, $ListItemText, $ResultType, $ToolTip ) } function Remove-CargoOuterQuotes { param([string]$Value) if ($null -eq $Value) { return '' } $Value.Trim([char[]]@([char]34, [char]39)) } function ConvertFrom-CargoQuotedPath { param([string]$Value) # CompleteFilename already quotes paths it considers unsafe; undo that so the # path is quoted exactly once, with the quote character the user typed. if ($Value.Length -ge 2 -and $Value.StartsWith("'") -and $Value.EndsWith("'")) { return $Value.Substring(1, $Value.Length - 2).Replace("''", "'") } if ($Value.Length -ge 2 -and $Value.StartsWith('"') -and $Value.EndsWith('"')) { return [regex]::Replace($Value.Substring(1, $Value.Length - 2), '`(.)', '$1') } $Value } function ConvertTo-CargoQuotedPath { param( [string]$Value, [string]$QuoteChar ) if ([string]::IsNullOrWhiteSpace($Value)) { return $Value } if (-not $QuoteChar -and $Value -notmatch '[\s{}();,|&<>''"`$]|^[@#]') { return $Value } if ($QuoteChar -eq '"') { return '"' + $Value.Replace('`', '``').Replace('"', '`"').Replace('$', '`$') + '"' } "'" + $Value.Replace("'", "''") + "'" } function Get-CargoPathCompletions { param([string]$InputPath) $cleanInput = Remove-CargoOuterQuotes -Value $InputPath $quoteChar = if (-not [string]::IsNullOrEmpty($InputPath) -and ($InputPath.StartsWith('"') -or $InputPath.StartsWith("'"))) { $InputPath.Substring(0, 1) } else { '' } [System.Management.Automation.CompletionCompleters]::CompleteFilename($cleanInput) | ForEach-Object { $completionText = ConvertTo-CargoQuotedPath -Value (ConvertFrom-CargoQuotedPath -Value $_.CompletionText) -QuoteChar $quoteChar New-CargoCompletionResult -CompletionText $completionText -ListItemText $_.ListItemText -ResultType $_.ResultType -ToolTip $_.ToolTip } } function Get-CargoCurrentWord { 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-CargoArgumentTokens { 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 += $element.Extent.Text } } $tokens } function Get-CargoRootValueMap { @{ '--config' = @('<KEY=VALUE>', '<path>') '--explain' = @('<error-code>') '-Z' = @() } } function Get-CargoCommonValueMap { @{ '--profile' = @('<profile>') '--target' = @() '--target-dir' = @('<path>') '--artifact-dir' = @('<path>') '--manifest-path' = @('<path>') '--config' = @('<KEY=VALUE>', '<path>') '-j' = @('<jobs>') '--jobs' = @('<jobs>') '-F' = @('<features>') '--features' = @('<features>') '-p' = @('<package-spec>') '--package' = @('<package-spec>') '--exclude' = @('<package-spec>') '--bin' = @('<target-name>') '--example' = @('<target-name>') '--test' = @('<target-name>') '--bench' = @('<target-name>') '--timings' = @('<format>') } } function Get-CargoCommandValueHints { param([string]$CommandName) switch ($CommandName) { 'add' { return @{ '--rename' = @('<crate-name>') '--registry' = @('<registry>') '--target' = @() '--path' = @('<path>') '--git' = @('<url>') '--branch' = @('<branch>') '--tag' = @('<tag>') '--rev' = @('<rev>') '--features' = @('<features>') '--public' = @('true', 'false') } } 'install' { return @{ '--version' = @('<version>') '--git' = @('<url>') '--branch' = @('<branch>') '--tag' = @('<tag>') '--rev' = @('<rev>') '--path' = @('<path>') '--root' = @('<path>') '--registry' = @('<registry>') '--target' = @() '--profile' = @('<profile>') '--bin' = @('<binary-name>') '--example' = @('<example-name>') '--features' = @('<features>') } } 'new' { return @{ '--name' = @('<package-name>') '--registry' = @('<registry>') } } 'init' { return @{ '--name' = @('<package-name>') '--registry' = @('<registry>') } } 'publish' { return @{ '--registry' = @('<registry>') '--token' = @('<token>') '--target' = @() } } 'login' { return @{ '--registry' = @('<registry>') '--token' = @('<token>') } } 'owner' { return @{ '--registry' = @('<registry>') '--token' = @('<token>') '--add' = @('<user>') '--remove' = @('<user>') } } 'search' { return @{ '--limit' = @('<count>') '--registry' = @('<registry>') '--index' = @('<url>') } } 'test' { return @{ '--target' = @() } } default { return @{} } } } # Value sets for options whose help names the choices only in prose instead of a clap # '[possible values: ...]' block (cargo-nextest's '--color', cargo-fmt's '--message-format'). # They fill in only where the live help lists no values, so a parsed list always wins. function Get-CargoEnumFallbackMap { param([string]$CommandName) $fallbacks = @{ '--color' = @('auto', 'always', 'never') } if ($CommandName -eq 'fmt') { $fallbacks['--message-format'] = @('short', 'json', 'human') } $fallbacks } function Get-CargoCommandPathOptions { param([string]$CommandName) switch ($CommandName) { 'build' { @('--manifest-path', '--target-dir', '--artifact-dir', '--config') } 'check' { @('--manifest-path', '--target-dir', '--artifact-dir', '--config') } 'clean' { @('--manifest-path', '--target-dir', '--config') } 'doc' { @('--manifest-path', '--target-dir', '--config') } 'run' { @('--manifest-path', '--target-dir', '--config') } 'test' { @('--manifest-path', '--target-dir', '--config') } 'bench' { @('--manifest-path', '--target-dir', '--config') } 'update' { @('--manifest-path', '--config') } 'search' { @('--config') } 'publish' { @('--manifest-path', '--config') } 'install' { @('--path', '--root', '--config') } 'uninstall' { @('--root', '--config') } 'metadata' { @('--manifest-path', '--config') } 'locate-project' { @('--manifest-path', '--config') } 'package' { @('--manifest-path', '--target-dir', '--config') } 'new' { @('--config') } 'init' { @('--config') } 'config' { @('--config') } default { @('--config') } } } function Get-CargoToolchainCompletions { param([string]$CurrentWord) $cache = Get-CargoCompletionCache if ($cache.Toolchains.Count -eq 0) { $lines = @(Invoke-CargoText -Arguments @('+stable', '--version')) if ($lines.Count -gt 0) { $cache.Toolchains += 'stable' } $rustup = Get-Command -Name rustup.exe, rustup -ErrorAction Ignore | Select-Object -First 1 if ($rustup) { try { $cache.Toolchains += @(& $rustup.Source 'toolchain' 'list' 2>$null | ForEach-Object { ($_ -replace '\s+\([^)]*\)$', '').Trim() } | Where-Object { $_ }) } catch { } } $cache.Toolchains = @($cache.Toolchains | Sort-Object -Unique) } foreach ($toolchain in $cache.Toolchains) { $completion = "+$toolchain" if ($completion.StartsWith($CurrentWord, [System.StringComparison]::OrdinalIgnoreCase)) { New-CargoCompletionResult -CompletionText $completion -ListItemText $completion -ResultType 'ParameterValue' -ToolTip 'Rustup toolchain override.' } } } function Get-CargoTargetValues { $cache = Get-CargoCompletionCache if ($cache.Targets.Count -eq 0) { $rustc = Get-Command -Name rustc.exe, rustc -ErrorAction Ignore | Select-Object -First 1 if ($rustc) { try { $cache.Targets = @(& $rustc.Source '--print' 'target-list' 2>$null | Where-Object { $_ } | Sort-Object -Unique) } catch { $cache.Targets = @() } } } $cache.Targets } function Get-CargoUnstableFlags { $cache = Get-CargoCompletionCache if ($cache.UnstableFlags.Count -eq 0) { $lines = Invoke-CargoText -Arguments @('-Z', 'help') $flags = foreach ($line in $lines) { if ($line -match '^\s+-Z\s+([a-z0-9][a-z0-9\-]*)\b') { $matches[1] } } $cache.UnstableFlags = @($flags | Sort-Object -Unique -CaseSensitive) } $cache.UnstableFlags } function Get-CargoOptionValue { param( [pscustomobject]$State, [string[]]$Names ) foreach ($name in $Names) { if ($State.OptionValues.ContainsKey($name)) { return $State.OptionValues[$name] } } $null } function Resolve-CargoManifestPath { param([pscustomobject]$State) $location = (Get-Location -PSProvider FileSystem).ProviderPath if ($State.RootDirectory) { $location = [System.IO.Path]::GetFullPath($State.RootDirectory, $location) } $manifestPath = Get-CargoOptionValue -State $State -Names @('--manifest-path', '-m') if ($manifestPath) { $candidate = [System.IO.Path]::GetFullPath($manifestPath, $location) if ([System.IO.File]::Exists($candidate)) { return $candidate } return $null } # cargo itself walks up from the working directory to the nearest Cargo.toml. $directory = [System.IO.DirectoryInfo]::new($location) while ($directory) { $candidate = [System.IO.Path]::Combine($directory.FullName, 'Cargo.toml') if ([System.IO.File]::Exists($candidate)) { return $candidate } $directory = $directory.Parent } $null } function Get-CargoProjectInfo { param([pscustomobject]$State) $manifest = Resolve-CargoManifestPath -State $State if (-not $manifest) { return $null } # Keyed by manifest and its write time, with a short TTL so auto-discovered targets # (a new src/bin/*.rs) and lockfile changes are picked up without restarting the shell. $cache = Get-CargoCompletionCache $key = '{0}|{1}' -f $manifest, [System.IO.File]::GetLastWriteTimeUtc($manifest).Ticks $entry = $cache.Projects[$manifest] if ($entry -and $entry.Key -eq $key -and $entry.Expires -gt [datetime]::UtcNow) { return $entry.Info } $info = $null $json = Invoke-CargoBoundedText -WorkingDirectory ([System.IO.Path]::GetDirectoryName($manifest)) -Arguments @( 'metadata', '--no-deps', '--format-version', '1', '--offline', '--manifest-path', $manifest ) if ($json -and $json.TrimStart().StartsWith('{')) { $metadata = $json | ConvertFrom-Json $packages = foreach ($package in @($metadata.packages)) { [pscustomobject]@{ Name = $package.name ManifestPath = $package.manifest_path Features = @($package.features.PSObject.Properties | ForEach-Object { $_.Name }) Targets = @($package.targets | ForEach-Object { [pscustomobject]@{ Name = $_.name; Kinds = @($_.kind) } }) Dependencies = @($package.dependencies | ForEach-Object { if ($_.rename) { $_.rename } else { $_.name } }) } } $rootManifest = [System.IO.Path]::Combine($metadata.workspace_root, 'Cargo.toml') $profiles = @() if ([System.IO.File]::Exists($rootManifest)) { $profiles = @([regex]::Matches([System.IO.File]::ReadAllText($rootManifest), '(?m)^\s*\[profile\.(?:"([^"]+)"|([A-Za-z0-9_-]+))\s*\]') | ForEach-Object { if ($_.Groups[1].Success) { $_.Groups[1].Value } else { $_.Groups[2].Value } }) } $lockFile = [System.IO.Path]::Combine($metadata.workspace_root, 'Cargo.lock') $lockNames = @() if ([System.IO.File]::Exists($lockFile)) { $lockNames = @([regex]::Matches([System.IO.File]::ReadAllText($lockFile), '(?m)^name = "([^"]+)"') | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique -CaseSensitive) } $info = [pscustomobject]@{ Manifest = $manifest Packages = @($packages) Profiles = $profiles LockNames = $lockNames } } # A failed or missing source is cached too, so a broken manifest is not re-run on every Tab. $cache.Projects[$manifest] = @{ Key = $key Expires = [datetime]::UtcNow.AddSeconds(30) Info = $info } $info } function Get-CargoSelectedPackage { param( [pscustomobject]$Info, [pscustomobject]$State ) $spec = Get-CargoOptionValue -State $State -Names @('-p', '--package') if ($spec) { $name = ($spec -split '@')[0] $picked = @($Info.Packages | Where-Object { $_.Name -ceq $name }) if ($picked.Count -gt 0) { return $picked } } $own = @($Info.Packages | Where-Object { $_.ManifestPath -eq $Info.Manifest }) if ($own.Count -gt 0) { return $own } # A virtual workspace root selects every member. @($Info.Packages) } function Get-CargoInstalledCrate { param([pscustomobject]$State) $root = Get-CargoOptionValue -State $State -Names @('--root') if (-not $root) { $root = if ($env:CARGO_INSTALL_ROOT) { $env:CARGO_INSTALL_ROOT } elseif ($env:CARGO_HOME) { $env:CARGO_HOME } else { [System.IO.Path]::Combine($HOME, '.cargo') } } $location = (Get-Location -PSProvider FileSystem).ProviderPath $registry = [System.IO.Path]::Combine([System.IO.Path]::GetFullPath($root, $location), '.crates2.json') if (-not [System.IO.File]::Exists($registry)) { return $null } $cache = Get-CargoCompletionCache $key = '{0}|{1}' -f $registry, [System.IO.File]::GetLastWriteTimeUtc($registry).Ticks $entry = $cache.Installed[$registry] if ($entry -and $entry.Key -eq $key) { return $entry.Info } $info = $null $text = [System.IO.File]::ReadAllText($registry) if ($text.TrimStart().StartsWith('{')) { # Keys read 'name version (source)'; bins carry the platform suffix that --bin does not need. $installs = @(($text | ConvertFrom-Json).installs.PSObject.Properties) $info = [pscustomobject]@{ Names = @($installs | ForEach-Object { ($_.Name -split ' ')[0] } | Sort-Object -Unique -CaseSensitive) Bins = @($installs | ForEach-Object { $_.Value.bins } | ForEach-Object { $_ -replace '\.exe$', '' } | Sort-Object -Unique -CaseSensitive) } } $cache.Installed[$registry] = @{ Key = $key Info = $info } $info } function Get-CargoLiveValue { param( [string]$OptionName, [pscustomobject]$State ) # Returns @{ Candidates; ToolTip } from the project underfoot or the install registry, or nothing # when there is no live source so the static hint stands. $commandName = $State.CommandName if ($commandName -eq 'uninstall') { $installed = Get-CargoInstalledCrate -State $State if (-not $installed) { return } switch -CaseSensitive ($OptionName) { { $_ -cin '<operand>', '-p', '--package' } { return @{ Candidates = $installed.Names; ToolTip = 'Installed crate.' } } '--bin' { return @{ Candidates = $installed.Bins; ToolTip = 'Installed binary.' } } } return } $builtInProfiles = @('dev', 'release', 'test', 'bench') if ($commandName -eq 'install') { if ($OptionName -ceq '--profile') { return @{ Candidates = $builtInProfiles; ToolTip = 'Cargo profile.' } } return } if ($commandName -eq 'add' -and $OptionName -cin @('-F', '--features')) { return } if ($OptionName -cnotin @('--profile', '-F', '--features', '-p', '--package', '--exclude', '--bin', '--example', '--test', '--bench', '<operand>')) { return } $info = Get-CargoProjectInfo -State $State if ($OptionName -ceq '--profile') { $profiles = $builtInProfiles if ($info) { $profiles = @($builtInProfiles + @($info.Profiles | Where-Object { $_ -cnotin $builtInProfiles })) } return @{ Candidates = $profiles; ToolTip = 'Cargo profile.' } } if (-not $info) { return } $targetKinds = @{ '--bin' = 'bin'; '--example' = 'example'; '--test' = 'test'; '--bench' = 'bench' } switch -CaseSensitive ($OptionName) { { $_ -cin '-F', '--features' } { $selected = @(Get-CargoSelectedPackage -Info $info -State $State) return @{ Candidates = @($selected | ForEach-Object { $_.Features } | Sort-Object -Unique -CaseSensitive); ToolTip = 'Package feature.' } } { $_ -cin '-p', '--package', '--exclude' } { return @{ Candidates = @($info.Packages | ForEach-Object { $_.Name } | Sort-Object -Unique -CaseSensitive); ToolTip = 'Workspace member.' } } { $targetKinds.ContainsKey($_) } { $kind = $targetKinds[$OptionName] $selected = @(Get-CargoSelectedPackage -Info $info -State $State) $names = @($selected | ForEach-Object { $_.Targets } | Where-Object { $_.Kinds -ccontains $kind } | ForEach-Object { $_.Name } | Sort-Object -Unique -CaseSensitive) return @{ Candidates = $names; ToolTip = "$kind target." } } '<operand>' { if ($commandName -eq 'remove') { $selected = @(Get-CargoSelectedPackage -Info $info -State $State) return @{ Candidates = @($selected | ForEach-Object { $_.Dependencies } | Sort-Object -Unique -CaseSensitive); ToolTip = 'Dependency.' } } if ($commandName -eq 'update') { if ($info.LockNames.Count -gt 0) { return @{ Candidates = $info.LockNames; ToolTip = 'Locked package.' } } return @{ Candidates = @(@($info.Packages | ForEach-Object { $_.Name; $_.Dependencies }) | Sort-Object -Unique -CaseSensitive); ToolTip = 'Package.' } } } } } function Get-CargoLiveValueCompletion { param( [string]$OptionName, [string]$CurrentWord, [hashtable]$Live ) $current = Remove-CargoOuterQuotes -Value $CurrentWord if ($OptionName -cin @('-F', '--features')) { # PowerShell splits 'a,b' itself and replaces only the segment after the last comma. $current = ($current -split ',')[-1] } foreach ($value in $Live.Candidates) { if ($value.StartsWith($current, [System.StringComparison]::OrdinalIgnoreCase)) { New-CargoCompletionResult -CompletionText $value -ListItemText $value -ResultType 'ParameterValue' -ToolTip $Live.ToolTip } } } function Get-CargoCommandNamesFromList { $lines = Invoke-CargoText -Arguments @('--list') $commands = foreach ($line in $lines) { # Third-party subcommands such as binstall and miri print no description at all. if ($line -match '^\s{4}([A-Za-z0-9][A-Za-z0-9\-_]*)(\s{2,}.*)?$') { $matches[1] } } @($commands | Sort-Object -Unique) } function Get-CargoRootCommandsFromHelp { $lines = Invoke-CargoText -Arguments @('--help') $commands = foreach ($line in $lines) { if ($line -match '^\s{4}([A-Za-z0-9][A-Za-z0-9\-_]*)\s*,?\s*([A-Za-z0-9][A-Za-z0-9\-_]*)?\s{2,}.*$') { $matches[1] if ($matches[2]) { $matches[2] } } } @($commands | Sort-Object -Unique) } function Get-CargoOptionSpecsFromHelp { param([string[]]$Lines) # clap indents every option line by 2-7 spaces and wraps descriptions further in, so the flag # segment is whatever precedes the first run of two spaces on such a line. Both layouts cargo # emits are covered: ' -F, --features <FEATURES> Desc' and ' --public' with the # description on the following line. Description lines indented past the flag column belong to # the option above them; clap wraps '[possible values: a, b,' across them, and in the long # layout a blank line separates the list from the description. $blocks = New-Object System.Collections.Generic.List[object] $block = $null foreach ($line in @($Lines)) { if ($line -match '^ {2,7}-\S') { $block = [pscustomobject]@{ Line = $line Text = [System.Text.StringBuilder]::new($line.Trim()) } [void]$blocks.Add($block) continue } if ([string]::IsNullOrWhiteSpace($line)) { continue } if ($block -and $line -match '^ {8,}\S') { [void]$block.Text.Append(' ').Append($line.Trim()) continue } $block = $null } $specs = New-Object System.Collections.Generic.List[object] $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) foreach ($block in $blocks) { $segment = (($block.Line.Trim() -split '\s{2,}')[0]) $metaVar = if ($segment -match '\[?<[^>]+>\]?') { $matches[0] } else { '' } $values = @() if ($metaVar -and $block.Text.ToString() -match '\[possible values:\s*([^\]]*)\]') { $values = @($matches[1] -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) } foreach ($match in [regex]::Matches($segment, '(?<!\S)(--?[A-Za-z0-9][A-Za-z0-9-]*)')) { $token = $match.Groups[1].Value if (-not $seen.Add($token)) { continue } [void]$specs.Add([pscustomobject]@{ Token = $token MetaVar = $metaVar Values = $values }) } } @($specs.ToArray()) } function Get-CargoValueHintTable { param( [object[]]$Specs, [hashtable]$Fallbacks = @{}, [hashtable[]]$Overlays ) $hints = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) foreach ($spec in @($Specs)) { if (-not $spec.MetaVar) { continue } if ($spec.Values.Count -gt 0) { $hints[$spec.Token] = @($spec.Values) continue } if ($Fallbacks.ContainsKey($spec.Token)) { $hints[$spec.Token] = @($Fallbacks[$spec.Token]) continue } $name = $spec.MetaVar.Trim([char[]]@('[', ']', '<', '>')).ToLowerInvariant() $hints[$spec.Token] = @("<$name>") } # Only options that help says carry a value get a curated hint, so a bare flag can never # swallow the following token. Enumerated values come from clap's own list above. foreach ($overlay in @($Overlays)) { foreach ($key in $overlay.Keys) { if ($hints.ContainsKey($key)) { $hints[$key] = @($overlay[$key]) } } } $hints } function Initialize-CargoCompletionCache { $cache = Get-CargoCompletionCache if ($cache.Initialized) { return } $cache.RootValueMap = Get-CargoRootValueMap $cache.CommonValueMap = Get-CargoCommonValueMap $rootHelp = Invoke-CargoText -Arguments @('--help') $rootSpecs = @(Get-CargoOptionSpecsFromHelp -Lines $rootHelp) $cache.RootOptions = @($rootSpecs | ForEach-Object { $_.Token }) $cache.RootCommands = @( Get-CargoRootCommandsFromHelp Get-CargoCommandNamesFromList 'help' ) | Sort-Object -Unique $cache.CommandMetadata['<root>'] = [pscustomobject]@{ Options = @($cache.RootOptions) ValueHints = Get-CargoValueHintTable -Specs $rootSpecs -Fallbacks (Get-CargoEnumFallbackMap) -Overlays @($cache.RootValueMap) PathOptions = @($cache.PathOptions) } $cache.Initialized = $true } function Get-CargoCommandMetadata { param([string]$CommandName) Initialize-CargoCompletionCache $cache = Get-CargoCompletionCache $lookupName = if ([string]::IsNullOrWhiteSpace($CommandName)) { '<root>' } else { $CommandName } if ($cache.CommandMetadata.ContainsKey($lookupName)) { return $cache.CommandMetadata[$lookupName] } # clap's own '<sub> --help' carries both option forms plus the metavar that says whether the # option takes a value; 'cargo help <sub>' is a man page that drops the long form of every pair. $helpLines = Invoke-CargoText -Arguments @($CommandName, '--help') # A subcommand whose help fails (an uninstalled third-party one, for example) yields no specs, # and the empty result has to stay an array or it reads back as a single $null element. $specs = @(Get-CargoOptionSpecsFromHelp -Lines $helpLines) $options = @($specs | ForEach-Object { $_.Token }) $valueHints = Get-CargoValueHintTable -Specs $specs -Fallbacks (Get-CargoEnumFallbackMap -CommandName $CommandName) -Overlays @( $cache.CommonValueMap (Get-CargoCommandValueHints -CommandName $CommandName) ) if ($options -contains '-Z') { $valueHints['-Z'] = @() } $metadata = [pscustomobject]@{ Options = @($options) ValueHints = $valueHints PathOptions = @(Get-CargoCommandPathOptions -CommandName $CommandName) } $cache.CommandMetadata[$lookupName] = $metadata $metadata } function Get-CargoState { param([string[]]$TokensBeforeCurrent) Initialize-CargoCompletionCache $cache = Get-CargoCompletionCache $globalOptionsWithValues = @('+toolchain', '--config', '--color', '--explain', '-Z', '-C') $rootCommands = @($cache.RootCommands) $commandName = $null $commandTokens = New-Object System.Collections.Generic.List[string] $pendingOption = $null $sawDoubleDash = $false $rootDirectory = $null for ($i = 0; $i -lt $TokensBeforeCurrent.Count; $i++) { $token = Remove-CargoOuterQuotes -Value $TokensBeforeCurrent[$i] if ([string]::IsNullOrWhiteSpace($token)) { continue } if ($pendingOption) { if (-not $commandName) { if ($pendingOption -ceq '-C') { $rootDirectory = $token } $pendingOption = $null continue } $commandTokens.Add($token) $pendingOption = $null continue } if ($sawDoubleDash) { $commandTokens.Add($token) continue } if ($token -eq '--') { if ($commandName) { # Hand '--' to the subcommand loop so it can stop treating cargo switches as # arguments of the program cargo is about to run. $commandTokens.Add($token) $sawDoubleDash = $true } else { $pendingOption = $null } continue } if (-not $commandName) { if ($token.StartsWith('+')) { continue } if ($token -match '^(--config|--color|--explain|-Z|-C)$') { $pendingOption = $token continue } if ($token -cmatch '^-C(.+)$') { $rootDirectory = $matches[1] continue } if ($token.StartsWith('-')) { continue } if ($rootCommands -contains $token) { $commandName = $token continue } continue } $commandTokens.Add($token) } $commandMetadata = Get-CargoCommandMetadata -CommandName $commandName $commandPendingOption = $null $positionals = New-Object System.Collections.Generic.List[string] $optionValues = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) $afterDoubleDash = $false foreach ($token in $commandTokens) { if ($commandPendingOption) { $positionals.Add($token) $optionValues[$commandPendingOption] = $token $commandPendingOption = $null continue } if ($token -eq '--') { $afterDoubleDash = $true continue } # '--opt=value' and '-Xvalue' carry their value in the same word, so neither is an operand. $attached = if ($afterDoubleDash) { $null } else { Split-CargoAttachedValue -Word $token -Metadata $commandMetadata } if ($attached) { $optionValues[$attached.Option] = $attached.Value continue } if (-not $afterDoubleDash -and $token -in $commandMetadata.Options) { if ($commandMetadata.ValueHints.ContainsKey($token) -or $commandMetadata.PathOptions -contains $token) { $commandPendingOption = $token continue } continue } $positionals.Add($token) } [pscustomobject]@{ CommandName = $commandName CommandMetadata = $commandMetadata PendingGlobalOption = $pendingOption PendingOption = $commandPendingOption Positionals = @($positionals) AfterDoubleDash = $afterDoubleDash OptionValues = $optionValues RootDirectory = $rootDirectory } } function Test-CargoValueOption { param( [pscustomobject]$Metadata, [string]$Option ) $Metadata.Options -ccontains $Option -and ($Metadata.ValueHints.ContainsKey($Option) -or $Metadata.PathOptions -ccontains $Option) } function Split-CargoAttachedValue { param( [string]$Word, [pscustomobject]$Metadata ) # clap accepts '--opt=value' for every value-bearing long option and '-Xvalue' for every # value-bearing short one ('cargo -Zbindeps', 'cargo build -prelease-crate'). if ($Word -cmatch '^(--[A-Za-z0-9][A-Za-z0-9-]*)=(.*)$') { $option = $matches[1] $value = $matches[2] $prefix = "$option=" } elseif ($Word -cmatch '^(-[A-Za-z0-9])(.+)$') { $option = $matches[1] $value = $matches[2] $prefix = $option } else { return $null } if (-not (Test-CargoValueOption -Metadata $Metadata -Option $option)) { return $null } [pscustomobject]@{ Option = $option Value = $value Prefix = $prefix } } function Get-CargoValueCompletions { param( [string]$OptionName, [string]$CurrentWord, [pscustomobject]$State ) $current = Remove-CargoOuterQuotes -Value $CurrentWord switch ($OptionName) { '+toolchain' { return @(Get-CargoToolchainCompletions -CurrentWord $CurrentWord) } '-C' { return @(Get-CargoPathCompletions -InputPath $CurrentWord) } '--config' { $results = @() $results += @(Get-CargoPathCompletions -InputPath $CurrentWord) foreach ($placeholder in @('<KEY=VALUE>')) { if ($placeholder.StartsWith($current, [System.StringComparison]::OrdinalIgnoreCase)) { $results += New-CargoCompletionResult -CompletionText $placeholder -ListItemText $placeholder -ResultType 'ParameterValue' -ToolTip 'Cargo configuration override.' } } return @($results) } '--target' { return @( Get-CargoTargetValues | Where-Object { $_.StartsWith($current, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-CargoCompletionResult -CompletionText $_ -ListItemText $_ -ResultType 'ParameterValue' -ToolTip 'Rust target triple.' } ) } '-Z' { return @( Get-CargoUnstableFlags | Where-Object { $_.StartsWith($current, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-CargoCompletionResult -CompletionText $_ -ListItemText $_ -ResultType 'ParameterValue' -ToolTip 'Cargo unstable flag.' } ) } } $live = Get-CargoLiveValue -OptionName $OptionName -State $State if ($live -and @($live.Candidates).Count -gt 0) { return @(Get-CargoLiveValueCompletion -OptionName $OptionName -CurrentWord $CurrentWord -Live $live) } if ($State.CommandMetadata.PathOptions -contains $OptionName) { return @(Get-CargoPathCompletions -InputPath $CurrentWord) } if ($State.CommandMetadata.ValueHints.ContainsKey($OptionName)) { return @( $State.CommandMetadata.ValueHints[$OptionName] | Where-Object { $_.StartsWith($current, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-CargoCompletionResult -CompletionText $_ -ListItemText $_ -ResultType 'ParameterValue' -ToolTip "$OptionName value" } ) } @() } function Get-CargoOptionCompletions { param( [string[]]$Options, [string]$CurrentWord ) $current = Remove-CargoOuterQuotes -Value $CurrentWord # cargo's short flags are case-significant (-V is --version, -v is --verbose), so both the # de-duplication and the prefix test have to stay ordinal for single-dash words. $comparison = if ($current -match '^-[^-]') { [System.StringComparison]::Ordinal } else { [System.StringComparison]::OrdinalIgnoreCase } foreach ($option in $Options | Sort-Object -Unique -CaseSensitive) { if ($option.StartsWith($current, $comparison)) { New-CargoCompletionResult -CompletionText $option -ListItemText $option -ResultType 'ParameterName' -ToolTip $option } } } function Get-CargoCommandCompletions { param([string]$CurrentWord) Initialize-CargoCompletionCache $cache = Get-CargoCompletionCache $current = Remove-CargoOuterQuotes -Value $CurrentWord foreach ($command in $cache.RootCommands) { if ($command.StartsWith($current, [System.StringComparison]::OrdinalIgnoreCase)) { New-CargoCompletionResult -CompletionText $command -ListItemText $command -ResultType 'ParameterValue' -ToolTip 'cargo subcommand' } } } function Complete-Cargo { param( [string]$wordToComplete, [System.Management.Automation.Language.CommandAst]$commandAst, [int]$cursorPosition ) Initialize-CargoCompletionCache $currentWord = if ($cursorPosition -gt $commandAst.Extent.EndOffset) { '' } else { Get-CargoCurrentWord -Line $commandAst.ToString() -CursorPosition ($cursorPosition - $commandAst.Extent.StartOffset) -Fallback $wordToComplete } $state = Get-CargoState -TokensBeforeCurrent @(Get-CargoArgumentTokens -CommandAst $commandAst -CursorPosition $cursorPosition) if (-not $state.PendingGlobalOption -and -not $state.PendingOption -and -not $state.AfterDoubleDash) { $attached = Split-CargoAttachedValue -Word $currentWord -Metadata $state.CommandMetadata if ($attached) { # After a comma PowerShell replaces only the last list segment, so the prefix stays put. $prefix = if ($attached.Value.Contains(',')) { '' } else { $attached.Prefix } return @( Get-CargoValueCompletions -OptionName $attached.Option -CurrentWord $attached.Value -State $state | ForEach-Object { New-CargoCompletionResult -CompletionText ($prefix + $_.CompletionText) -ListItemText $_.ListItemText -ResultType $_.ResultType -ToolTip $_.ToolTip } ) } } if (-not $state.CommandName) { if ($state.PendingGlobalOption) { return @(Get-CargoValueCompletions -OptionName $state.PendingGlobalOption -CurrentWord $currentWord -State $state) } if ($currentWord.StartsWith('+')) { return @(Get-CargoToolchainCompletions -CurrentWord $currentWord) } $results = @() if ($currentWord.StartsWith('-')) { return @(Get-CargoOptionCompletions -Options $state.CommandMetadata.Options -CurrentWord $currentWord) } if ([string]::IsNullOrWhiteSpace($currentWord)) { $results += @(Get-CargoOptionCompletions -Options $state.CommandMetadata.Options -CurrentWord '') } $results += @(Get-CargoCommandCompletions -CurrentWord $currentWord) if ([string]::IsNullOrWhiteSpace($currentWord)) { $results += @(Get-CargoToolchainCompletions -CurrentWord '') } return @($results) } if ($state.CommandName -eq 'help' -and $state.Positionals.Count -eq 0 -and -not $state.PendingOption) { if ($currentWord.StartsWith('-')) { return @(Get-CargoOptionCompletions -Options $state.CommandMetadata.Options -CurrentWord $currentWord) } $results = @() if ([string]::IsNullOrWhiteSpace($currentWord)) { $results += @(Get-CargoOptionCompletions -Options $state.CommandMetadata.Options -CurrentWord '') } $results += @(Get-CargoCommandCompletions -CurrentWord $currentWord) return @($results) } if ($state.PendingOption) { return @(Get-CargoValueCompletions -OptionName $state.PendingOption -CurrentWord $currentWord -State $state) } if ($state.AfterDoubleDash) { return @() } $optionResults = @() if ($currentWord.StartsWith('-')) { return @(Get-CargoOptionCompletions -Options $state.CommandMetadata.Options -CurrentWord $currentWord) } if ([string]::IsNullOrWhiteSpace($currentWord)) { $optionResults = @(Get-CargoOptionCompletions -Options $state.CommandMetadata.Options -CurrentWord '') } switch ($state.CommandName) { 'new' { if ($state.Positionals.Count -eq 0) { return @($optionResults + @(Get-CargoPathCompletions -InputPath $currentWord)) } } 'init' { if ($state.Positionals.Count -eq 0) { return @($optionResults + @(Get-CargoPathCompletions -InputPath $currentWord)) } } 'install' { if ($state.Positionals.Count -eq 0) { $results = @() $results += @(Get-CargoPathCompletions -InputPath $currentWord) if ('<crate>'.StartsWith((Remove-CargoOuterQuotes -Value $currentWord), [System.StringComparison]::OrdinalIgnoreCase)) { $results += New-CargoCompletionResult -CompletionText '<crate>' -ListItemText '<crate>' -ResultType 'ParameterValue' -ToolTip 'Crate name or path.' } return @($optionResults + $results) } } { $_ -in 'remove', 'update', 'uninstall' } { $live = Get-CargoLiveValue -OptionName '<operand>' -State $state if ($live -and @($live.Candidates).Count -gt 0) { return @($optionResults + @(Get-CargoLiveValueCompletion -OptionName '<operand>' -CurrentWord $currentWord -Live $live)) } } 'uninstall' { if ($state.Positionals.Count -eq 0) { if ('<crate>'.StartsWith((Remove-CargoOuterQuotes -Value $currentWord), [System.StringComparison]::OrdinalIgnoreCase)) { return @($optionResults + @(New-CargoCompletionResult -CompletionText '<crate>' -ListItemText '<crate>' -ResultType 'ParameterValue' -ToolTip 'Installed crate name.')) } } } 'search' { if ($state.Positionals.Count -eq 0) { if ('<query>'.StartsWith((Remove-CargoOuterQuotes -Value $currentWord), [System.StringComparison]::OrdinalIgnoreCase)) { return @($optionResults + @(New-CargoCompletionResult -CompletionText '<query>' -ListItemText '<query>' -ResultType 'ParameterValue' -ToolTip 'Search query.')) } } } 'test' { if ($state.Positionals.Count -eq 0) { if ('<test-filter>'.StartsWith((Remove-CargoOuterQuotes -Value $currentWord), [System.StringComparison]::OrdinalIgnoreCase)) { return @($optionResults + @(New-CargoCompletionResult -CompletionText '<test-filter>' -ListItemText '<test-filter>' -ResultType 'ParameterValue' -ToolTip 'Optional test name filter.')) } } } } @($optionResults) } Register-ArgumentCompleter -Native -CommandName @('cargo', 'cargo.exe') -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) Complete-Cargo -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition } |