completers/rustfmt_completer/rustfmt_completer.ps1
|
<# .SYNOPSIS Registers a native PowerShell argument completer for rustfmt. .DESCRIPTION Provides a help-driven native completer for `rustfmt` and `rustfmt.exe`. The completer keeps its top level compatible with `Import-CompleterScript` by limiting top-level content to `Set-StrictMode`, function definitions, and one literal `Register-ArgumentCompleter -Native` call. #> Set-StrictMode -Version 2.0 function New-RustfmtCompletionResult { param( [string]$CompletionText, [string]$ResultType = 'ParameterValue', [string]$ToolTip = $CompletionText, [string]$ListItemText = $CompletionText ) if ([string]::IsNullOrWhiteSpace($ListItemText)) { $ListItemText = $CompletionText } if ([string]::IsNullOrWhiteSpace($ToolTip)) { $ToolTip = $ListItemText } [System.Management.Automation.CompletionResult]::new( $CompletionText, $ListItemText, $ResultType, $ToolTip ) } function Get-RustfmtTokenState { param( [string]$Line, [int]$CursorPosition ) if ($null -eq $Line) { $Line = '' } $safeCursor = [Math]::Min([Math]::Max($CursorPosition, 0), $Line.Length) $prefix = $Line.Substring(0, $safeCursor) $tokens = New-Object System.Collections.Generic.List[string] $builder = New-Object System.Text.StringBuilder $quoteChar = [char]0 foreach ($character in $prefix.ToCharArray()) { if (($character -eq [char]34) -or ($character -eq [char]39)) { if ($quoteChar -eq [char]0) { $quoteChar = $character } elseif ($quoteChar -eq $character) { $quoteChar = [char]0 } [void]$builder.Append($character) continue } if ([char]::IsWhiteSpace($character) -and $quoteChar -eq [char]0) { if ($builder.Length -gt 0) { [void]$tokens.Add($builder.ToString()) [void]$builder.Clear() } continue } [void]$builder.Append($character) } $hasTrailingSpace = $prefix -match '\s$' if ($builder.Length -gt 0) { [void]$tokens.Add($builder.ToString()) } if ($hasTrailingSpace) { return [pscustomobject]@{ TokensBeforeCurrent = @($tokens.ToArray()) CurrentToken = '' } } if ($tokens.Count -gt 0) { return [pscustomobject]@{ TokensBeforeCurrent = @($tokens | Select-Object -First ($tokens.Count - 1)) CurrentToken = $tokens[$tokens.Count - 1] } } [pscustomobject]@{ TokensBeforeCurrent = @() CurrentToken = '' } } function Remove-RustfmtOuterQuotes { param([string]$Value) if ([string]::IsNullOrEmpty($Value)) { return '' } if ($Value.Length -ge 2) { if ($Value.StartsWith('"') -and $Value.EndsWith('"')) { return $Value.Substring(1, $Value.Length - 2).Replace('`"', '"') } if ($Value.StartsWith("'") -and $Value.EndsWith("'")) { return $Value.Substring(1, $Value.Length - 2).Replace("''", "'") } } $Value.TrimStart('"', "'") } function ConvertTo-RustfmtQuotedValue { param( [string]$Value, [string]$OriginalToken = '' ) if ([string]::IsNullOrWhiteSpace($Value)) { return $Value } $needsQuote = $Value -match '\s' $preferSingle = $OriginalToken.StartsWith("'") if (-not $needsQuote -and -not $OriginalToken.StartsWith('"') -and -not $preferSingle) { return $Value } if ($preferSingle -and -not $Value.Contains("'")) { return "'" + $Value + "'" } '"' + $Value.Replace('`', '``').Replace('"', '`"') + '"' } function Split-RustfmtPathToken { param([string]$Value) # Split on the last separator instead of Split-Path: Split-Path throws on an # empty string, and on a trailing separator it returns the directory itself # as the leaf, which makes tab-walking a tree impossible. if ([string]::IsNullOrEmpty($Value)) { return [pscustomobject]@{ Prefix = ''; Parent = '.'; Leaf = '' } } if ($Value -match '^[A-Za-z]:$') { return [pscustomobject]@{ Prefix = $Value; Parent = ($Value + [IO.Path]::DirectorySeparatorChar); Leaf = '' } } $index = $Value.LastIndexOfAny([char[]]@('\', '/')) if ($index -lt 0) { return [pscustomobject]@{ Prefix = ''; Parent = '.'; Leaf = $Value } } $prefix = $Value.Substring(0, $index + 1) [pscustomobject]@{ Prefix = $prefix; Parent = $prefix; Leaf = $Value.Substring($index + 1) } } function Get-RustfmtPathCompletions { param( [string]$CurrentToken, [bool]$DirectoriesOnly = $false, [bool]$FilesOnly = $false, [bool]$RustSourceOnly = $false ) $raw = if ($null -eq $CurrentToken) { '' } else { $CurrentToken } $clean = Remove-RustfmtOuterQuotes -Value $raw $split = Split-RustfmtPathToken -Value $clean $leaf = $split.Leaf $results = New-Object System.Collections.Generic.List[System.Management.Automation.CompletionResult] try { $items = Get-ChildItem -LiteralPath $split.Parent -Force -ErrorAction Stop } catch { return @() } foreach ($item in $items) { if ($DirectoriesOnly -and -not $item.PSIsContainer) { continue } if ($FilesOnly -and $item.PSIsContainer) { continue } if ($RustSourceOnly -and -not $item.PSIsContainer -and $item.Extension -ne '.rs') { continue } if ($leaf -and -not $item.Name.StartsWith($leaf, [System.StringComparison]::OrdinalIgnoreCase)) { continue } $completionPath = $split.Prefix + $item.Name if ($item.PSIsContainer) { $completionPath += [IO.Path]::DirectorySeparatorChar } $quoted = ConvertTo-RustfmtQuotedValue -Value $completionPath -OriginalToken $raw [void]$results.Add((New-RustfmtCompletionResult -CompletionText $quoted -ResultType 'ParameterValue' -ToolTip $item.FullName -ListItemText $item.Name)) } @($results.ToArray() | Sort-Object ListItemText) } function Get-RustfmtCommandPath { if (Test-Path -LiteralPath variable:script:RustfmtCommandPath) { return $script:RustfmtCommandPath } $command = Get-Command -Name rustfmt.exe, rustfmt -ErrorAction SilentlyContinue | Select-Object -First 1 $script:RustfmtCommandPath = if ($command) { if ($command.Source) { $command.Source } else { $command.Name } } else { $null } $script:RustfmtCommandPath } function Invoke-RustfmtText { param([string[]]$Arguments) $commandPath = Get-RustfmtCommandPath if (-not $commandPath) { return @() } try { @(& $commandPath @Arguments 2>$null | ForEach-Object { $_ -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' }) } catch { @() } } function Get-RustfmtCatalog { if (Test-Path -LiteralPath variable:script:RustfmtCompletionCatalog) { return $script:RustfmtCompletionCatalog } $switches = @( [pscustomobject]@{ Token = '--check'; Aliases = @('--check'); Description = 'Run in check mode.'; ValueKinds = @(); AttachedValueKind = '' } [pscustomobject]@{ Token = '--emit'; Aliases = @('--emit'); Description = 'Choose emitted output.'; ValueKinds = @('Emit'); AttachedValueKind = 'Emit' } [pscustomobject]@{ Token = '--backup'; Aliases = @('--backup'); Description = 'Backup modified files.'; ValueKinds = @(); AttachedValueKind = '' } [pscustomobject]@{ Token = '--config-path'; Aliases = @('--config-path'); Description = 'Path used to search for rustfmt.toml.'; ValueKinds = @('ConfigPath'); AttachedValueKind = 'ConfigPath' } [pscustomobject]@{ Token = '--edition'; Aliases = @('--edition'); Description = 'Rust edition to use.'; ValueKinds = @('Edition'); AttachedValueKind = 'Edition' } [pscustomobject]@{ Token = '--style-edition'; Aliases = @('--style-edition'); Description = 'Style Guide edition.'; ValueKinds = @('StyleEdition'); AttachedValueKind = 'StyleEdition' } [pscustomobject]@{ Token = '--color'; Aliases = @('--color'); Description = 'Colored output mode.'; ValueKinds = @('Color'); AttachedValueKind = 'Color' } [pscustomobject]@{ Token = '--print-config'; Aliases = @('--print-config'); Description = 'Print config to a path or stdout.'; ValueKinds = @('PrintConfigMode', 'PrintConfigPath'); AttachedValueKind = 'PrintConfigMode' } [pscustomobject]@{ Token = '-l'; Aliases = @('-l', '--files-with-diff'); Description = 'Print names of mismatched files.'; ValueKinds = @(); AttachedValueKind = '' } [pscustomobject]@{ Token = '--config'; Aliases = @('--config'); Description = 'Set config key/value pairs on the command line.'; ValueKinds = @('ConfigOverride'); AttachedValueKind = 'ConfigOverride' } [pscustomobject]@{ Token = '-v'; Aliases = @('-v', '--verbose'); Description = 'Verbose output.'; ValueKinds = @(); AttachedValueKind = '' } [pscustomobject]@{ Token = '-q'; Aliases = @('-q', '--quiet'); Description = 'Less output.'; ValueKinds = @(); AttachedValueKind = '' } [pscustomobject]@{ Token = '-V'; Aliases = @('-V', '--version'); Description = 'Show version information.'; ValueKinds = @(); AttachedValueKind = '' } # rustfmt spells the help topic `-h [=TOPIC]`: it is attached-only, so it # must not be offered as a detached token. [pscustomobject]@{ Token = '-h'; Aliases = @('-h', '--help'); Description = 'Show help or help about a topic.'; ValueKinds = @(); AttachedValueKind = 'HelpTopic' } ) # Ordinal: -v (verbose) and -V (version) are different options, and a # case-insensitive map silently keeps only one of them. $aliasLookup = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) foreach ($switch in $switches) { foreach ($alias in $switch.Aliases) { $aliasLookup[$alias] = $switch } } $topHelp = Invoke-RustfmtText -Arguments @('--help') foreach ($line in $topHelp) { if ($line -match '^\s+(-\w(?:,\s+--[A-Za-z0-9\-]+)?|--[A-Za-z0-9\-]+)\b') { $tokenGroup = $matches[1] foreach ($token in ($tokenGroup -split ',\s*')) { if ($aliasLookup.ContainsKey($token)) { $spec = $aliasLookup[$token] if ($line -match '\s{2,}(\S.*)$') { $spec.Description = $matches[1].Trim() } } } } } # `rustfmt --help=config` right-aligns each key against its type and default: # newline_style [Auto|Windows|Unix|Native] Default: Auto # Unix or Windows line endings # Anchoring on the type and the Default column is what separates a real key # from the first word of a wrapped description line. $configKeys = New-Object System.Collections.Generic.List[object] foreach ($line in (Invoke-RustfmtText -Arguments @('--help=config'))) { if ($line -notmatch '^\s*(?<key>[a-z_][a-z0-9_]*)\s+(?<type>\[[^\]]+\]|<[^>]+>)\s+Default:\s*(?<default>.*)$') { continue } # Capture before any further -match call replaces $matches. $keyName = $matches.key $type = $matches.type $keyDefault = $matches.default.Trim() $values = @() if ($type -match '^\[(?<alts>.+)\]$') { # Only single-token values are insertable; rustfmt annotates some # enum members, for example "2027 (unstable)". $values = @($matches.alts -split '\|' | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '^[A-Za-z0-9_.-]+$' }) } elseif ($type -match '(?i)boolean') { $values = @('true', 'false') } [void]$configKeys.Add([pscustomobject]@{ Name = $keyName Type = $type Values = $values Default = $keyDefault }) } $script:RustfmtCompletionCatalog = [pscustomobject]@{ Switches = $switches AliasLookup = $aliasLookup EmitModes = @('files', 'stdout') Editions = @('2015', '2018', '2021', '2024') StyleEditions = @('2015', '2018', '2021', '2024') Colors = @('always', 'never', 'auto') PrintConfigModes = @('default', 'minimal', 'current') HelpTopics = @('config') ConfigKeys = @($configKeys.ToArray() | Sort-Object -Property Name -Unique) } $script:RustfmtCompletionCatalog } function Get-RustfmtValueSuggestions { param( [string]$ValueKind, [string]$CurrentToken ) $catalog = Get-RustfmtCatalog $raw = if ($null -eq $CurrentToken) { '' } else { $CurrentToken } $clean = Remove-RustfmtOuterQuotes -Value $raw $results = New-Object System.Collections.Generic.List[System.Management.Automation.CompletionResult] switch ($ValueKind) { 'Emit' { foreach ($value in $catalog.EmitModes) { if ($value.StartsWith($clean, [System.StringComparison]::OrdinalIgnoreCase)) { [void]$results.Add((New-RustfmtCompletionResult -CompletionText $value -ToolTip 'rustfmt emit mode')) } } } 'Edition' { foreach ($value in $catalog.Editions) { if ($value.StartsWith($clean, [System.StringComparison]::OrdinalIgnoreCase)) { [void]$results.Add((New-RustfmtCompletionResult -CompletionText $value -ToolTip 'Rust edition')) } } } 'StyleEdition' { foreach ($value in $catalog.StyleEditions) { if ($value.StartsWith($clean, [System.StringComparison]::OrdinalIgnoreCase)) { [void]$results.Add((New-RustfmtCompletionResult -CompletionText $value -ToolTip 'Style Guide edition')) } } } 'Color' { foreach ($value in $catalog.Colors) { if ($value.StartsWith($clean, [System.StringComparison]::OrdinalIgnoreCase)) { [void]$results.Add((New-RustfmtCompletionResult -CompletionText $value -ToolTip 'Color mode')) } } } 'PrintConfigMode' { foreach ($value in $catalog.PrintConfigModes) { if ($value.StartsWith($clean, [System.StringComparison]::OrdinalIgnoreCase)) { [void]$results.Add((New-RustfmtCompletionResult -CompletionText $value -ToolTip 'rustfmt --print-config mode')) } } } 'HelpTopic' { foreach ($value in $catalog.HelpTopics) { if ($value.StartsWith($clean, [System.StringComparison]::OrdinalIgnoreCase)) { [void]$results.Add((New-RustfmtCompletionResult -CompletionText $value -ToolTip 'rustfmt help topic')) } } } 'ConfigPath' { return Get-RustfmtPathCompletions -CurrentToken $raw -DirectoriesOnly $true } 'PrintConfigPath' { return Get-RustfmtPathCompletions -CurrentToken $raw } 'InputFile' { return Get-RustfmtPathCompletions -CurrentToken $raw -RustSourceOnly $true } 'ConfigOverride' { # `--config` takes key1=val1,key2=val2...: only the segment after the # last comma is being completed, and every completion has to carry the # segments the user already typed. $commaIndex = $clean.LastIndexOf(',') $carried = if ($commaIndex -ge 0) { $clean.Substring(0, $commaIndex + 1) } else { '' } $segment = if ($commaIndex -ge 0) { $clean.Substring($commaIndex + 1) } else { $clean } if ($segment -match '^(?<key>[A-Za-z_][A-Za-z0-9_]*)=(?<value>.*)$') { foreach ($suggestion in (Get-RustfmtConfigValueSuggestions -Key $matches.key -ValuePrefix $matches.value -Carried $carried)) { [void]$results.Add($suggestion) } } else { foreach ($entry in $catalog.ConfigKeys) { if ($entry.Name.StartsWith($segment, [System.StringComparison]::OrdinalIgnoreCase)) { $toolTip = "$($entry.Name) $($entry.Type) Default: $($entry.Default)" [void]$results.Add((New-RustfmtCompletionResult -CompletionText ($carried + $entry.Name + '=') -ToolTip $toolTip -ListItemText ($entry.Name + '='))) } } } } } @($results.ToArray()) } function Get-RustfmtConfigValueSuggestions { param( [string]$Key, [string]$ValuePrefix, [string]$Carried = '' ) $catalog = Get-RustfmtCatalog $entry = $null foreach ($candidate in $catalog.ConfigKeys) { if ([string]::Equals($candidate.Name, $Key, [System.StringComparison]::OrdinalIgnoreCase)) { $entry = $candidate break } } $results = New-Object System.Collections.Generic.List[System.Management.Automation.CompletionResult] $values = if ($null -eq $entry) { @() } else { @($entry.Values) } foreach ($value in $values) { if ($value.StartsWith($ValuePrefix, [System.StringComparison]::OrdinalIgnoreCase)) { [void]$results.Add((New-RustfmtCompletionResult -CompletionText ($Carried + $Key + '=' + $value) -ToolTip "rustfmt config value for $Key" -ListItemText $value)) } } # A free-form key (an integer or a string) only gets a placeholder, and only # while the value is still empty, so a partly typed value is never replaced. if ($results.Count -eq 0 -and [string]::IsNullOrEmpty($ValuePrefix) -and -not [string]::IsNullOrWhiteSpace($Key)) { $toolTip = if ($null -eq $entry) { "rustfmt config value for $Key" } else { "$($entry.Name) $($entry.Type) Default: $($entry.Default)" } [void]$results.Add((New-RustfmtCompletionResult -CompletionText ($Carried + $Key + '=<value>') -ToolTip $toolTip -ListItemText '<value>')) } @($results.ToArray()) } function Get-RustfmtState { param([string[]]$TokensBeforeCurrent) $catalog = Get-RustfmtCatalog $pendingKinds = New-Object System.Collections.Generic.Queue[string] $operandCount = 0 foreach ($token in $TokensBeforeCurrent) { $clean = Remove-RustfmtOuterQuotes -Value $token if ([string]::IsNullOrWhiteSpace($clean)) { continue } if ($pendingKinds.Count -gt 0) { [void]$pendingKinds.Dequeue() continue } if ($clean -match '^(--?[A-Za-z0-9][A-Za-z0-9\-]*)=(.*)$') { $optionName = $matches[1] if ($catalog.AliasLookup.ContainsKey($optionName)) { $spec = $catalog.AliasLookup[$optionName] if ($spec.ValueKinds.Count -gt 1) { for ($index = 1; $index -lt $spec.ValueKinds.Count; $index++) { $pendingKinds.Enqueue($spec.ValueKinds[$index]) } } } continue } if ($catalog.AliasLookup.ContainsKey($clean)) { $spec = $catalog.AliasLookup[$clean] foreach ($kind in $spec.ValueKinds) { $pendingKinds.Enqueue($kind) } continue } if ($clean.StartsWith('-')) { continue } $operandCount++ } [pscustomobject]@{ PendingKinds = $pendingKinds OperandCount = $operandCount } } function Get-RustfmtSwitchSuggestions { param([string]$CurrentToken) $catalog = Get-RustfmtCatalog $clean = Remove-RustfmtOuterQuotes -Value $CurrentToken $results = New-Object System.Collections.Generic.List[System.Management.Automation.CompletionResult] $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::Ordinal) foreach ($switch in $catalog.Switches) { foreach ($alias in $switch.Aliases) { # Ordinal: -V must not be rewritten to -v on the way out. if ($alias.StartsWith($clean, [System.StringComparison]::Ordinal) -and $seen.Add($alias)) { [void]$results.Add((New-RustfmtCompletionResult -CompletionText $alias -ResultType 'ParameterName' -ToolTip $switch.Description -ListItemText $alias)) } } } @($results.ToArray() | Sort-Object -Property CompletionText -CaseSensitive) } Register-ArgumentCompleter -Native -CommandName @('rustfmt', 'rustfmt.exe') -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) # $cursorPosition is an offset into the whole input line, while # $commandAst.Extent.Text is command-relative. $relativeCursor = $cursorPosition - $commandAst.Extent.StartOffset $tokenState = Get-RustfmtTokenState -Line $commandAst.Extent.Text -CursorPosition $relativeCursor $currentToken = if ($null -eq $tokenState.CurrentToken) { $wordToComplete } else { $tokenState.CurrentToken } $tokensBeforeCurrent = @($tokenState.TokensBeforeCurrent) if ([string]::IsNullOrEmpty($wordToComplete) -and -not [string]::IsNullOrEmpty($currentToken)) { $tokensBeforeCurrent = @($tokensBeforeCurrent + $currentToken) $currentToken = '' } $tokensBeforeCurrent = @($tokensBeforeCurrent | Select-Object -Skip 1) $state = Get-RustfmtState -TokensBeforeCurrent $tokensBeforeCurrent if ($state.PendingKinds.Count -gt 0) { return Get-RustfmtValueSuggestions -ValueKind $state.PendingKinds.Peek() -CurrentToken $currentToken } $cleanCurrent = Remove-RustfmtOuterQuotes -Value $currentToken if ($cleanCurrent -match '^(--?[A-Za-z0-9][A-Za-z0-9\-]*)=(.*)$') { $catalog = Get-RustfmtCatalog $optionName = $matches[1] $attachedValue = $matches[2] if ($catalog.AliasLookup.ContainsKey($optionName)) { $spec = $catalog.AliasLookup[$optionName] if (-not [string]::IsNullOrEmpty($spec.AttachedValueKind)) { # Keep the "--opt=" prefix on every suggestion so accepting one # does not delete the option name. return @( Get-RustfmtValueSuggestions -ValueKind $spec.AttachedValueKind -CurrentToken $attachedValue | ForEach-Object { New-RustfmtCompletionResult -CompletionText ($optionName + '=' + $_.CompletionText) -ResultType $_.ResultType -ToolTip $_.ToolTip -ListItemText $_.ListItemText } ) } } return @() } if ($cleanCurrent.StartsWith('-')) { return Get-RustfmtSwitchSuggestions -CurrentToken $currentToken } if ([string]::IsNullOrEmpty($cleanCurrent)) { # The operand slot is reachable from a bare TAB: offer the switches and # the files rustfmt actually formats. return @( @(Get-RustfmtSwitchSuggestions -CurrentToken $currentToken) + @(Get-RustfmtValueSuggestions -ValueKind 'InputFile' -CurrentToken $currentToken) ) } Get-RustfmtValueSuggestions -ValueKind 'InputFile' -CurrentToken $currentToken } |