Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Tab-complete a parameter value based on another parameter's already specified value

This question addresses the following scenario:

  • Can custom tab-completion for a given command dynamically determine completions based on the value previously passed to another parameter on the same command line, using either a parameter-level [ArgumentCompleter()] attribute or the Register-ArgumentCompleter cmdlet?

  • If so, what are the limitations of this approach?

Example scenario:

A hypothetical Get-Property command has an -Object parameter that accepts an object of any type, and a -Property parameter that accepts the name of a property whose value to extract from the object.

Now, in the course of typing a Get-Property call, if a value is already specified for -Object, tab-completing -Property should cycle through the names of the specified object's (public) properties.

$obj = [pscustomobject] @{ foo = 1; bar = 2; baz = 3 }

Get-Property -Object $obj -Property # <- pressing <tab> here should cycle
                                    # through 'foo', 'bar', 'baz'
like image 488
mklement0 Avatar asked Nov 01 '25 16:11

mklement0


2 Answers

@mklement0, regarding first limitation stated in your answer

The custom-completion script block ({ ... }) invoked by PowerShell fundamentally only sees values specified via parameters, not via the pipeline.

I struggled with this, and after some stubbornness I got a working solution.
At least good enough for my tooling, and I hope it can make life easier for many others out there.

This solution has been verified to work with PowerShell versions 5.1 and 7.1.2.

Here I made use of $cmdAst (called $commandAst in the docs), which contains information about the pipeline. With this we can get to know the previous pipeline element and even differentiate between it containing only a variable or a command. Yes, A COMMAND, which with help of Get-Command and the command's OutputType() member method, we can get (suggested) property names for such as well!

Example usage

PS> $obj = [pscustomobject] @{ foo = 1; bar = 2; baz = 3 }
PS> $obj | Get-Property -Property # <tab>: bar, baz, foo
PS> "la", "na", "le" | Select-String "a" | Get-Property -Property # <tab>: Chars, Context, Filename, ...
PS> 2,5,2,2,6,3 | group | Get-Property -Property # <tab>: Count, Values, Group, ...

Function code

Note that apart from now using $cmdAst, I also added [Parameter(ValueFromPipeline=$true)] so we actually pick the object, and PROCESS {$Object.$Property} so that one can test and see the code actually working.

param(
    [Parameter(ValueFromPipeline=$true)]
    [object] $Object,
    [ArgumentCompleter({
        param($cmdName, $paramName, $wordToComplete, $cmdAst, $preBoundParameters)
        # Find out if we have pipeline input.
        $pipelineElements = $cmdAst.Parent.PipelineElements
        $thisPipelineElementAsString = $cmdAst.Extent.Text
        $thisPipelinePosition = [array]::IndexOf($pipelineElements.Extent.Text, $thisPipelineElementAsString)
        $hasPipelineInput = $thisPipelinePosition -ne 0

        $possibleArguments = @()
        if ($hasPipelineInput) {
            # If we are in a pipeline, find out if the previous pipeline element is a variable or a command.
            $previousPipelineElement = $pipelineElements[$thisPipelinePosition - 1]
            $pipelineInputVariable = $previousPipelineElement.Expression.VariablePath.UserPath
            if (-not [string]::IsNullOrEmpty($pipelineInputVariable)) {
                # If previous pipeline element is a variable, get the object.
                # Note that it can be a non-existent variable. In such case we simply get nothing.
                $detectedInputObject = Get-Variable |
                    Where-Object {$_.Name -eq $pipelineInputVariable} |
                        ForEach-Object Value
            } else {
                $pipelineInputCommand = $previousPipelineElement.CommandElements[0].Value
                if (-not [string]::IsNullOrEmpty($pipelineInputCommand)) {
                    # If previous pipeline element is a command, check if it exists as a command.
                    $possibleArguments += Get-Command -CommandType All |
                        Where-Object Name -Match "^$pipelineInputCommand$" |
                            # Collect properties for each documented output type.
                            ForEach-Object {$_.OutputType.Type} | ForEach-Object GetProperties |
                                # Group properties by Name to get unique ones, and sort them by
                                # the most frequent Name first. The sorting is a perk.
                                # A command can have multiple output types. If so, we might now
                                # have multiple properties with identical Name.
                                Group-Object Name -NoElement | Sort-Object Count -Descending |
                                    ForEach-Object Name
                }
            }
        } elseif ($preBoundParameters.ContainsKey("Object")) {
            # If not in pipeline, but object has been given, get the object.
            $detectedInputObject = $preBoundParameters["Object"]
        }
        if ($null -ne $detectedInputObject) {
            # The input object might be an array of objects, if so, select the first one.
            # We (at least I) are not interested in array properties, but the object element's properties.
            if ($detectedInputObject -is [array]) {
                $sampleInputObject = $detectedInputObject[0]
            } else {
                $sampleInputObject = $detectedInputObject
            }
            # Collect property names.
            $possibleArguments += $sampleInputObject | Get-Member -MemberType Properties | ForEach-Object Name
        }
        # Refering to about_Functions_Argument_Completion documentation.
        #   The ArgumentCompleter script block must unroll the values using the pipeline,
        #   such as ForEach-Object, Where-Object, or another suitable method.
        #   Returning an array of values causes PowerShell to treat the entire array as one tab completion value.
        $possibleArguments | Where-Object {$_ -like "$wordToComplete*"}
    })]
    [string] $Property
)

PROCESS {$Object.$Property}
like image 117
betoz Avatar answered Nov 03 '25 23:11

betoz


Update: See betoz's helpful answer for a more complete solution that also supports pipeline input.

The part of the answer below that clarifies the limitations of pre-execution detection of the input objects' data type still applies.


The following solution uses a parameter-specific [ArgumentCompleter()] attribute as part of the definition of the Get-Property function itself, but the solution analogously applies to separately defining custom-completion logic via the Register-CommandCompleter cmdlet.

Limitations:

  • [See betoz's answer for how to overcome this limitation] The custom-completion script block ({ ... }) invoked by PowerShell fundamentally only sees values specified via parameters, not via the pipeline.

    • That is, if you type Get-Property -Object $obj -Property <tab>, the script block can determine that the value of $obj is to be bound to the -Object parameter, but that wouldn't work with
      $obj | Get-Property -Property <tab> (even if -Object is declared as pipeline-binding).
  • Fundamentally, only values that can be evaluated without side effects are actually accessible in the script block; in concrete terms, this means:

    • Literal values (e.g., -Object ([pscustomobject] @{ foo = 1; bar = 2; baz = 3 })
    • Simple variable references (e.g., -Object $obj) or property-access or index-access expressions (e.g., -Object $obj.Foo or -Object $obj[0])
    • Notably, the following values are not accessible:
      • Method-call results (e.g., -Object $object.Foo())
      • Command output (via (...), $(...), or @(...), e.g.
        -Object (Invoke-RestMethod http://example.org))
      • The reason for this limitation is that evaluating such values before actually submitting the command could have undesirable side effects and / or could take a long time to complete.
function Get-Property {

  param(

    [object] $Object,

    [ArgumentCompleter({

      # A fixed list of parameters is passed to an argument-completer script block.
      # Here, only two are of interest:
      #  * $wordToComplete: 
      #      The part of the value that the user has typed so far, if any.
      #  * $preBoundParameters (called $fakeBoundParameters 
      #    in the docs):
      #      A hashtable of those (future) parameter values specified so 
      #      far that are side effect-free (see above).
      param($cmdName, $paramName, $wordToComplete, $cmdAst, $preBoundParameters)

        # Was a side effect-free value specified for -Object?
        if ($obj = $preBoundParameters['Object']) {

          # Get all property names of the objects and filter them
          # by the partial value already typed, if any, 
          # interpreted as a name prefix.
          @($obj.psobject.Properties.Name) -like "$wordToComplete*"

        }
      })]
    [string] $Property

  )

  # ...

}
like image 40
mklement0 Avatar answered Nov 03 '25 22:11

mklement0