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. Three operand slots have live values: `run` script names and the dependency names of `remove` / `why` / `update` / `unlink` come from the nearest package.json, and config keys for `config get|set|delete` and `get` / `set` come from `pnpm config list --json`. 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 = @{} Manifests = @{} ConfigKeys = @{} } } 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 Ignore) | 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 $operandCount = 0 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 } # Once the first operand is seen, every later word is an operand too. $match = $null if ($operandCount -eq 0) { foreach ($command in @($catalog.Commands)) { if ([string]::Equals($command.Name, $token, [System.StringComparison]::Ordinal)) { $match = $command break } } } if ($null -eq $match) { $operandCount++ continue } [void]$path.Add($match.CanonicalName) $catalog = Get-PnpmCatalog -CommandPath @($path.ToArray()) } [pscustomobject]@{ Path = @($path.ToArray()) Catalog = $catalog OperandCount = $operandCount } } function Get-PnpmWorkingDirectory { param([string[]]$Tokens) $location = Get-Location if ($location.Provider.Name -ne 'FileSystem') { return $null } # -C / --dir is accepted anywhere on the command line; the last one wins. $directory = '' $settled = @($Tokens) for ($index = 0; $index -lt $settled.Count; $index++) { $token = $settled[$index] if ($token -ceq '-C' -or $token -ceq '--dir') { if ($index + 1 -lt $settled.Count) { $index++ $directory = $settled[$index] } } elseif ($token.StartsWith('--dir=', [System.StringComparison]::Ordinal)) { $directory = $token.Substring(6) } } $directory = $directory.Trim([char[]]@([char]34, [char]39)) if ([string]::IsNullOrWhiteSpace($directory)) { return $location.ProviderPath } [System.IO.Path]::GetFullPath($directory, $location.ProviderPath) } function Get-PnpmManifestName { param( [string]$Directory, [ValidateSet('Scripts', 'Dependencies')] [string]$Kind ) # The nearest package.json walking up from the working directory is the # project pnpm acts on. It is read passively and re-parsed only when its # timestamp changes. $manifestPath = $null $current = $Directory while (-not [string]::IsNullOrEmpty($current)) { $candidate = [System.IO.Path]::Combine($current, 'package.json') if ([System.IO.File]::Exists($candidate)) { $manifestPath = $candidate break } $current = [System.IO.Path]::GetDirectoryName($current) } if ($null -eq $manifestPath) { return @() } $cache = Get-PnpmCompletionCache $stamp = [System.IO.File]::GetLastWriteTimeUtc($manifestPath) $entry = $cache.Manifests[$manifestPath] if ($null -eq $entry -or $entry.Stamp -ne $stamp) { $scripts = New-Object System.Collections.Generic.List[object] $dependencies = New-Object System.Collections.Generic.List[object] $errorCountBefore = $Error.Count try { $manifest = [System.IO.File]::ReadAllText($manifestPath) | ConvertFrom-Json -AsHashtable -ErrorAction Stop if ($manifest -is [System.Collections.IDictionary]) { if ($manifest['scripts'] -is [System.Collections.IDictionary]) { foreach ($name in $manifest['scripts'].Keys) { [void]$scripts.Add([pscustomobject]@{ Name = [string]$name; ToolTip = "${name}: $($manifest['scripts'][$name])" }) } } foreach ($group in @('dependencies', 'devDependencies', 'optionalDependencies')) { if ($manifest[$group] -is [System.Collections.IDictionary]) { foreach ($name in $manifest[$group].Keys) { [void]$dependencies.Add([pscustomobject]@{ Name = [string]$name; ToolTip = "$group ${name}@$($manifest[$group][$name])" }) } } } } } catch { Write-Debug "pnpm completer: cannot parse ${manifestPath}: $($_.Exception.Message)" } finally { # A package.json mid-edit is an expected miss, not a fault to leave in $Error. while ($Error.Count -gt $errorCountBefore) { $Error.RemoveAt(0) } } $entry = [pscustomobject]@{ Stamp = $stamp Scripts = @($scripts.ToArray()) Dependencies = @($dependencies.ToArray()) } $cache.Manifests[$manifestPath] = $entry } @($entry.$Kind) } function Get-PnpmConfigKey { param([string]$Directory) $executablePath = Get-PnpmExecutablePath if ([string]::IsNullOrWhiteSpace($executablePath) -or -not [System.IO.Directory]::Exists($Directory)) { return @() } # The key set changes with the project's .npmrc / pnpm-workspace.yaml and with # `pnpm config set`, so it is cached per launcher and directory for 30 seconds. $cache = Get-PnpmCompletionCache $cacheKey = "$executablePath|$Directory" $entry = $cache.ConfigKeys[$cacheKey] if ($null -ne $entry -and ([datetime]::UtcNow - $entry.LoadedAt).TotalSeconds -lt 30) { return @($entry.Keys) } # `config list` is read-only and, unlike most commands, does not switch to the # project's pinned packageManager version, so it never reaches the network. # Stdin is closed, output is drained asynchronously and a hung child is killed. $output = '' $process = [System.Diagnostics.Process]::new() try { $process.StartInfo = [System.Diagnostics.ProcessStartInfo]::new($executablePath) $process.StartInfo.UseShellExecute = $false $process.StartInfo.CreateNoWindow = $true $process.StartInfo.WorkingDirectory = $Directory $process.StartInfo.RedirectStandardInput = $true $process.StartInfo.RedirectStandardOutput = $true $process.StartInfo.RedirectStandardError = $true foreach ($argument in @('config', 'list', '--json')) { [void]$process.StartInfo.ArgumentList.Add($argument) } [void]$process.Start() $process.StandardInput.Close() $standardOutput = $process.StandardOutput.ReadToEndAsync() $standardError = $process.StandardError.ReadToEndAsync() if ($process.WaitForExit(5000)) { $output = $standardOutput.GetAwaiter().GetResult() -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' [void]$standardError.GetAwaiter().GetResult() } else { $process.Kill($true) } } catch { $output = '' } finally { $process.Dispose() } # Only key names are kept; values (auth tokens among them) are never emitted. $keys = New-Object System.Collections.Generic.List[object] if (-not [string]::IsNullOrWhiteSpace($output)) { $errorCountBefore = $Error.Count try { $config = $output | ConvertFrom-Json -AsHashtable -ErrorAction Stop if ($config -is [System.Collections.IDictionary]) { foreach ($name in $config.Keys) { [void]$keys.Add([pscustomobject]@{ Name = [string]$name; ToolTip = "pnpm config key $name" }) } } } catch { Write-Debug "pnpm completer: cannot parse 'pnpm config list --json': $($_.Exception.Message)" } finally { while ($Error.Count -gt $errorCountBefore) { $Error.RemoveAt(0) } } } $cache.ConfigKeys[$cacheKey] = [pscustomobject]@{ LoadedAt = [datetime]::UtcNow Keys = @($keys.ToArray()) } @($keys.ToArray()) } function ConvertTo-PnpmQuotedValue { param( [string]$Value, [string]$Quote ) if ([string]::IsNullOrEmpty($Quote) -and $Value -notmatch '[\s{}();,|&<>''"`$]|^[@#]') { return $Value } if ($Quote -eq '"') { return '"' + ($Value -replace '([`"$])', '`$1') + '"' } "'" + $Value.Replace("'", "''") + "'" } function Get-PnpmOperandCompletion { param( [object[]]$Candidates, [string]$WordToComplete ) # An opening quote the user typed is kept on the inserted text; a closing one # is dropped before matching. $word = $WordToComplete $quote = '' if ($word.Length -gt 0 -and ($word[0] -eq [char]39 -or $word[0] -eq [char]34)) { $quote = [string]$word[0] $word = $word.Substring(1) if ($word.EndsWith($quote, [System.StringComparison]::Ordinal)) { $word = $word.Substring(0, $word.Length - 1) } } $results = New-Object System.Collections.Generic.List[object] foreach ($candidate in @($Candidates)) { if ($candidate.Name.StartsWith($word, [System.StringComparison]::OrdinalIgnoreCase)) { $completionText = ConvertTo-PnpmQuotedValue -Value $candidate.Name -Quote $quote [void]$results.Add((New-PnpmCompletionResult -CompletionText $completionText -ListItemText $candidate.Name -ResultType ParameterValue -ToolTip $candidate.ToolTip)) } } @($results.ToArray()) } 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 '') } } # Operands with a live value source: the script name for `run`, dependency # names for the commands that act on installed packages, and the config key. $operands = switch -CaseSensitive ($resolved.Path -join ' ') { 'run' { if ($resolved.OperandCount -eq 0) { 'Scripts' } } { $_ -cin @('remove', 'why', 'update', 'unlink') } { 'Dependencies' } { $_ -cin @('get', 'set','config get', 'config set', 'config delete') } { if ($resolved.OperandCount -eq 0) { 'ConfigKeys' } } } if ($null -ne $operands) { $directory = Get-PnpmWorkingDirectory -Tokens $tokens if ($null -eq $directory) { return @() } $candidates = if ($operands -eq 'ConfigKeys') { Get-PnpmConfigKey -Directory $directory } else { Get-PnpmManifestName -Directory $directory -Kind $operands } return @(Get-PnpmOperandCompletion -Candidates @($candidates) -WordToComplete $word) } # 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 } |