Home > Software design >  How to get tokenized command in powershell completion helper
How to get tokenized command in powershell completion helper

Time:07-01

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.

  • Related