completers/pnpm_completer/pnpm_completer.ps1
|
<# .SYNOPSIS Registers PowerShell tab completion for the local `pnpm` CLI. .DESCRIPTION Help-driven completer for pnpm 12, whose CLI is built on clap and prints a long-form help page for every command and subcommand. `pnpm --help` supplies the root command list (with aliases) and the universal rc-options; `pnpm help <command> [<subcommand> ...]` supplies the same shape for every deeper node. Each parsed page is cached in script scope for the session, including the negative result, so a missing or failing pnpm costs one probe. Option values are completed from the help page itself: clap's `[possible values: ...]` lists and `Possible values:` blocks become enum completions, `<DIR>` slots become directory completions, and everything else becomes a `<PLACEHOLDER>` so the engine's filename fallback does not take over a non-path slot. Both the separate (`--reporter silent`) and the attached (`--reporter=silent`) forms are handled. This script registers completion for `pnpm`, `pnpm.cmd`, `pnpm.ps1`, and the `pn` short alias, matching the names pnpm's own completion script registers. #> Set-StrictMode -Version Latest if (-not (Get-Variable -Name PnpmCompletionCache -Scope Script -ErrorAction Ignore)) { $script:PnpmCompletionCache = @{ ExecutableProbed = $false ExecutablePath = $null Catalogs = @{} } } function Get-PnpmCompletionCache { $script:PnpmCompletionCache } function Get-PnpmExecutablePath { $cache = Get-PnpmCompletionCache if ($cache.ExecutableProbed) { return $cache.ExecutablePath } $cache.ExecutableProbed = $true $cache.ExecutablePath = $null foreach ($candidate in @( @{ Name = 'pnpm.cmd'; CommandType = 'Application' } @{ Name = 'pnpm'; CommandType = 'Application' } @{ Name = 'pnpm.ps1'; CommandType = 'ExternalScript' } )) { $command = @(Get-Command -Name $candidate.Name -CommandType $candidate.CommandType -ErrorAction SilentlyContinue) | Select-Object -First 1 if ($null -eq $command) { continue } $cache.ExecutablePath = if ($command.Source) { $command.Source } else { $command.Path } break } $cache.ExecutablePath } function Get-PnpmHelpText { param([string[]]$CommandPath) $executablePath = Get-PnpmExecutablePath if ([string]::IsNullOrWhiteSpace($executablePath)) { return '' } $arguments = New-Object System.Collections.Generic.List[string] [void]$arguments.Add('help') foreach ($segment in @($CommandPath)) { if (-not [string]::IsNullOrWhiteSpace($segment)) { [void]$arguments.Add($segment) } } try { $helpText = $null | & $executablePath @arguments 2>$null | ForEach-Object { $_ -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' } | Out-String } catch { return '' } if ([string]::IsNullOrWhiteSpace($helpText)) { return '' } $helpText } function New-PnpmCompletionResult { param( [string]$CompletionText, [string]$ListItemText, [System.Management.Automation.CompletionResultType]$ResultType, [string]$ToolTip ) if ([string]::IsNullOrWhiteSpace($CompletionText)) { return $null } if ([string]::IsNullOrWhiteSpace($ListItemText)) { $ListItemText = $CompletionText } if ([string]::IsNullOrWhiteSpace($ToolTip)) { $ToolTip = $ListItemText } [System.Management.Automation.CompletionResult]::new($CompletionText, $ListItemText, $ResultType, $ToolTip) } function Get-PnpmValueKind { param([string]$ValueName) if ([string]::IsNullOrWhiteSpace($ValueName)) { return 'None' } if ($ValueName -match '(?i)(^|_)dir$') { return 'Directory' } if ($ValueName -match '(?i)(^|_)(file|path)$') { return 'File' } 'Text' } function ConvertFrom-PnpmHelpText { param([string]$HelpText) $commands = New-Object System.Collections.Generic.List[object] $options = New-Object System.Collections.Generic.List[object] $commandSeen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::Ordinal) if ([string]::IsNullOrWhiteSpace($HelpText)) { return [pscustomobject]@{ Commands = @(); Options = @() } } $section = '' $current = $null $inPossibleValuesBlock = $false foreach ($line in [regex]::Split($HelpText, '\r?\n')) { if ($line -match '^(?<name>[A-Za-z][A-Za-z ]*):\s*$') { $section = $Matches.name $current = $null $inPossibleValuesBlock = $false continue } if ([string]::IsNullOrWhiteSpace($line)) { continue } $indent = $line.Length - $line.TrimStart(' ').Length $trimmed = $line.Trim() if ($section -eq 'Commands') { # Command rows are indented two spaces: " add Add a package [alias: a]". if ($indent -le 6 -and $line -match '^\s{2,}(?<name>[A-Za-z0-9][A-Za-z0-9_.-]*)(?:\s{2,}(?<description>\S.*))?$') { $name = $Matches.name $description = if ($Matches.ContainsKey('description')) { $Matches.description } else { '' } $aliases = New-Object System.Collections.Generic.List[string] if ($description -match '\[alias(?:es)?:\s*(?<list>[^\]]+)\]') { foreach ($alias in ($Matches.list -split ',')) { $aliasName = $alias.Trim() if (-not [string]::IsNullOrWhiteSpace($aliasName)) { [void]$aliases.Add($aliasName) } } } foreach ($spelling in @(@($name) + $aliases.ToArray())) { if ($commandSeen.Add($spelling)) { [void]$commands.Add([pscustomobject]@{ Name = $spelling CanonicalName = $name Description = $description }) } } } continue } if ($section -ne 'Options') { continue } # Option definition rows are indented at most six spaces and start with a dash; # descriptions and clap metadata are indented ten or more. if ($indent -le 6 -and $trimmed.StartsWith('-')) { $spellings = @( [regex]::Matches($trimmed, '(?<!\S)(--?[A-Za-z0-9][A-Za-z0-9-]*)') | ForEach-Object { $_.Value } ) if ($spellings.Count -eq 0) { $current = $null $inPossibleValuesBlock = $false continue } $valueName = '' if ($trimmed -match '<(?<value>[^>]+)>') { $valueName = $Matches.value } $current = [pscustomobject]@{ Names = [string[]]$spellings ValueName = $valueName ValueKind = (Get-PnpmValueKind -ValueName $valueName) AttachedOnly = ($trimmed -match '\[=<') ValueList = (New-Object System.Collections.Generic.List[object]) Description = '' } [void]$options.Add($current) $inPossibleValuesBlock = $false continue } if ($null -eq $current) { continue } if ($inPossibleValuesBlock) { if ($trimmed -match '^-\s+(?<name>[^:\s]+):\s*(?<description>.*)$') { [void]$current.ValueList.Add([pscustomobject]@{ Name = $Matches.name; Description = $Matches.description.Trim() }) continue } $inPossibleValuesBlock = $false } if ($trimmed -match '^Possible values:$') { $inPossibleValuesBlock = $true continue } if ($trimmed -match '^\[possible values:\s*(?<list>[^\]]+)\]$') { foreach ($value in ($Matches.list -split ',')) { $valueText = $value.Trim() if (-not [string]::IsNullOrWhiteSpace($valueText)) { [void]$current.ValueList.Add([pscustomobject]@{ Name = $valueText; Description = '' }) } } continue } if ($trimmed -match '^\[alias(?:es)?:\s*(?<list>[^\]]+)\]$') { $extra = New-Object System.Collections.Generic.List[string] $extra.AddRange([string[]]$current.Names) foreach ($alias in ($Matches.list -split ',')) { $aliasName = $alias.Trim() if ($aliasName -match '^--?[A-Za-z0-9]') { [void]$extra.Add($aliasName) } } $current.Names = [string[]]$extra.ToArray() continue } if ($trimmed.StartsWith('[')) { continue } if ([string]::IsNullOrWhiteSpace($current.Description)) { $current.Description = $trimmed } } [pscustomobject]@{ Commands = @($commands.ToArray()) Options = @($options.ToArray()) } } function Get-PnpmCatalog { param([string[]]$CommandPath) $cache = Get-PnpmCompletionCache $segments = @(@($CommandPath) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) $key = if ($segments.Count -gt 0) { $segments -join ' ' } else { '<root>' } if ($cache.Catalogs.ContainsKey($key)) { return $cache.Catalogs[$key] } $catalog = ConvertFrom-PnpmHelpText -HelpText (Get-PnpmHelpText -CommandPath $segments) $cache.Catalogs[$key] = $catalog $catalog } function Find-PnpmOption { param( [object]$Catalog, [string]$Name ) if ([string]::IsNullOrWhiteSpace($Name)) { return $null } foreach ($option in @($Catalog.Options)) { foreach ($spelling in $option.Names) { if ([string]::Equals($spelling, $Name, [System.StringComparison]::Ordinal)) { return $option } } } $null } function Test-PnpmOptionTakesValue { param([object]$Option) $null -ne $Option -and -not [string]::IsNullOrWhiteSpace($Option.ValueName) -and -not $Option.AttachedOnly } function Get-PnpmArgumentToken { param( [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) $tokens = New-Object System.Collections.Generic.List[string] if ($null -eq $CommandAst) { return @() } foreach ($element in @($CommandAst.CommandElements | Select-Object -Skip 1)) { if ($null -eq $element -or $null -eq $element.Extent) { continue } # Offsets from the AST and $cursorPosition are both absolute in the input # line. A token that ends at or after the cursor is the word being # completed, not a settled argument. if ($element.Extent.EndOffset -ge $CursorPosition) { continue } $text = $element.Extent.Text if (-not [string]::IsNullOrWhiteSpace($text)) { [void]$tokens.Add($text.Trim()) } } @($tokens.ToArray()) } function Resolve-PnpmCommandPath { param([string[]]$Tokens) $path = New-Object System.Collections.Generic.List[string] $catalog = Get-PnpmCatalog -CommandPath @() $skipNext = $false foreach ($token in @($Tokens)) { if ($skipNext) { $skipNext = $false continue } if ($token.StartsWith('-')) { if ($token.Contains('=')) { continue } $option = Find-PnpmOption -Catalog $catalog -Name $token if (Test-PnpmOptionTakesValue -Option $option) { $skipNext = $true } continue } $match = $null foreach ($command in @($catalog.Commands)) { if ([string]::Equals($command.Name, $token, [System.StringComparison]::Ordinal)) { $match = $command break } } if ($null -eq $match) { break } [void]$path.Add($match.CanonicalName) $catalog = Get-PnpmCatalog -CommandPath @($path.ToArray()) } [pscustomobject]@{ Path = @($path.ToArray()) Catalog = $catalog } } function Get-PnpmDirectoryCompletion { param([string]$WordToComplete) @([System.Management.Automation.CompletionCompleters]::CompleteFilename($WordToComplete)) | Where-Object { $_.ResultType -eq [System.Management.Automation.CompletionResultType]::ProviderContainer } } function Get-PnpmOptionValueCompletion { param( [object]$Option, [string]$WordToComplete, [string]$Prefix ) $results = New-Object System.Collections.Generic.List[object] if ($null -eq $Option) { return @() } # .ToArray() is deliberate: @() over a List[object] throws on PowerShell 7.6. $values = @($Option.ValueList.ToArray()) if ($values.Count -gt 0) { foreach ($value in $values) { if ($value.Name.StartsWith($WordToComplete, [System.StringComparison]::OrdinalIgnoreCase)) { $toolTip = if ([string]::IsNullOrWhiteSpace($value.Description)) { "$($Option.Names[-1]) $($value.Name)" } else { $value.Description } [void]$results.Add((New-PnpmCompletionResult -CompletionText ($Prefix + $value.Name) -ListItemText $value.Name -ResultType ParameterValue -ToolTip $toolTip)) } } return @($results.ToArray()) } if ($Option.ValueKind -eq 'Directory' -and [string]::IsNullOrEmpty($Prefix)) { return @(Get-PnpmDirectoryCompletion -WordToComplete $WordToComplete) } if ($Option.ValueKind -eq 'File' -and [string]::IsNullOrEmpty($Prefix)) { return @([System.Management.Automation.CompletionCompleters]::CompleteFilename($WordToComplete)) } if ([string]::IsNullOrEmpty($WordToComplete)) { $placeholder = "<$($Option.ValueName)>" [void]$results.Add((New-PnpmCompletionResult -CompletionText ($Prefix + $placeholder) -ListItemText $placeholder -ResultType ParameterValue -ToolTip $Option.Description)) } @($results.ToArray()) } function Get-PnpmOptionNameCompletion { param( [object]$Catalog, [string]$WordToComplete ) $results = New-Object System.Collections.Generic.List[object] $pattern = [System.Management.Automation.WildcardPattern]::Escape($WordToComplete) + '*' foreach ($option in @($Catalog.Options)) { foreach ($spelling in $option.Names) { if ($spelling -clike $pattern) { $toolTip = if ([string]::IsNullOrWhiteSpace($option.Description)) { $spelling } else { $option.Description } if (-not [string]::IsNullOrWhiteSpace($option.ValueName)) { $toolTip = "$toolTip [<$($option.ValueName)>]" } [void]$results.Add((New-PnpmCompletionResult -CompletionText $spelling -ListItemText $spelling -ResultType ParameterName -ToolTip $toolTip)) } } } @($results.ToArray()) } function Get-PnpmCommandCompletion { param( [object]$Catalog, [string[]]$Path, [string]$WordToComplete ) $results = New-Object System.Collections.Generic.List[object] $pattern = [System.Management.Automation.WildcardPattern]::Escape($WordToComplete) + '*' $prefix = @(@('pnpm') + @($Path)) -join ' ' foreach ($command in @($Catalog.Commands)) { if ($command.Name -like $pattern) { $toolTip = if ([string]::IsNullOrWhiteSpace($command.Description)) { "$prefix $($command.Name)" } else { $command.Description } [void]$results.Add((New-PnpmCompletionResult -CompletionText $command.Name -ListItemText $command.Name -ResultType ParameterValue -ToolTip $toolTip)) } } @($results.ToArray()) } function Invoke-PnpmCompletion { [CmdletBinding()] [OutputType([System.Object[]], [System.Array])] param( [string]$WordToComplete, [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) $word = if ($null -eq $WordToComplete) { '' } else { $WordToComplete } $tokens = @(Get-PnpmArgumentToken -CommandAst $CommandAst -CursorPosition $CursorPosition) $resolved = Resolve-PnpmCommandPath -Tokens $tokens $catalog = $resolved.Catalog # Attached form: --reporter=sil if ($word -match '^(?<name>--?[A-Za-z0-9][A-Za-z0-9-]*)=(?<value>.*)$') { $option = Find-PnpmOption -Catalog $catalog -Name $Matches.name if ($null -ne $option -and -not [string]::IsNullOrWhiteSpace($option.ValueName)) { return @(Get-PnpmOptionValueCompletion -Option $option -WordToComplete $Matches.value -Prefix "$($Matches.name)=") } return @() } if ($word.StartsWith('-')) { return @(Get-PnpmOptionNameCompletion -Catalog $catalog -WordToComplete $word) } # Separate form: the settled token before the cursor is a value-bearing option. $previous = if ($tokens.Count -gt 0) { $tokens[-1] } else { '' } if ($previous.StartsWith('-') -and -not $previous.Contains('=')) { $option = Find-PnpmOption -Catalog $catalog -Name $previous if (Test-PnpmOptionTakesValue -Option $option) { return @(Get-PnpmOptionValueCompletion -Option $option -WordToComplete $word -Prefix '') } } # A pnpm node either dispatches to subcommands or takes operands. When it takes # operands, return nothing so the engine's own path completion runs. @(Get-PnpmCommandCompletion -Catalog $catalog -Path $resolved.Path -WordToComplete $word) } Register-ArgumentCompleter -Native -CommandName @('pnpm', 'pnpm.cmd', 'pnpm.ps1', 'pn') -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) Invoke-PnpmCompletion -WordToComplete $wordToComplete -CommandAst $commandAst -CursorPosition $cursorPosition } |