powershelldynamictab-completion

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


This question addresses the following scenario:

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'

Solution

  • @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}