I am attempting to write a completion function for powershell. My strategy is to defer to an executable to return the completions given information about the command line.
Currently I have:
$scriptblock = {
param($wordToComplete, $commandAst, $cursorPosition)
$command = $commandAst.toString();
test-command-completions powershell from-json $cursorPosition -- $command | ForEach-Object {
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
}
}
Register-ArgumentCompleter -Native -CommandName test-command -ScriptBlock $scriptblock
Where it is attempting to set up completions for test-command
and it defers to the test-command-completions
executable to return the completions given the information provided.
Unfortunately $command
is a single string with the command, eg. test-command install --he
but that means test-command-completions
receives it as a single argument rather than as a set of arguments and the current implementation relies on having a set of arguments as that is how bash & zsh provide the information.
There are other ways I can work around it but I'd be keen to know if I can get an array of values from the command AST or the command string constructed from the AST. I could do $command.Split(" ")
but I want an approach that will handle quoting and escapes in the original command.
CodePudding user response:
As mentioned in the comments, you'll want to interact with the $commandAst
argument - PowerShell will pass an Abstract Syntax Tree (aka. AST) representing the parsed command expression - I'll try and show how to inspect it!
All AST types in PowerShell come with a search mechanism you can use to discover the individual elements of the command - use Find()
to dispatch a search for the first matching sub-tree element, use FindAll()
to search for multiple elements - both methods accept a scriptblock-based predicate as their first argument:
# This part is just to emulate `$commandAst`
$commandAst = { Some-Command -ParamName argument $anotherArgument -SwitchParam }.Find({$args[0] -is [System.Management.Automation.Language.CommandAst]}, $false)
# Now you have a `CommandAst` object you can explore - this will give you all descendant syntax elements for example
$commandAst.FindAll({ $true }, $false)
# But you can also inspect the arguments of the command expression as a list via the `CommandElements` property:
$commandName,$arguments = $commandAst.CommandElements
# As indicated by the variable name, the first element in the expression is the command name
Write-Host "Command name is '$commandName'"
# ... but the rest are the arguments:
$arguments |ForEach-Object {
Write-Host "Found command element of type '$($_.GetType().Name)' with input text '$($_.Extent)'"
}
If working directly with the AST is giving you a headache, you can also manually re-parse the input and have PowerShell provide you with a flat list of tokens:
$tokens = @()
$null = [System.Management.Automation.Language.Parser]::ParseInput($commandAst.Extent.ToString(), [ref]$tokens, [ref]$null)
$tokens
now holds a list of all individual tokens recognized by the parser.
Good luck!
CodePudding user response:
I ended up with the following code thanks to Mathias R. Jessen's help and dumping out the kubectrl powershell completion script.
$scriptblock = {
param($wordToComplete, $commandAst, $cursorPosition)
# Get command line elements as string array
$elements = $commandAst.CommandElements | ForEach-Object { "$_" };
# Search the ast of the command to find the node that contains the
# cursor position provided to this completion function. We increment
# the script-scope index variable to track the index of the node that
# matches the cursor position so that we can provide that to the
# completion code. This is because the completion executable expects
# the index of the word in the command line to be completed whereas
# PowerShell provides the character index itno the string of the
# command line where the cursor is.
#
# We start the index at -2 as there is a parent node to the AST which
# we visit and there we increment to -1 and then there is the command
# name itself where we increment to 0 which is the appropriate index
# for the command name.
$script:index = -2;
$target = $commandAst.FindAll({
if ( $args[0].Extent.StartColumnNumber -le $cursorPosition ) {
$script:index ;
}
$args[0].Extent.StartColumnNumber -le $cursorPosition -and $args[0].Extent.EndColumnNumber -ge $cursorPosition
}, $false)
# Run the completion helper and create completion items from the result
# "@" splats the array as arguments to the command
test-command-completions power-shell from-json $wordToComplete $script:index -- @elements | ForEach-Object {
[System.Management.Automation.CompletionResult]::new($_)
}
}
Register-ArgumentCompleter -Native -CommandName test-command -ScriptBlock $scriptblock
I imagine there are cleaner ways to achieve it but this is my first ever interaction with PowerShell so I'm not well placed to judge.