Search code examples
windowspowershelltab-completion

Using Tab Complete After an Established Parameter in Powershell


I found some code that allows you to output text one character at a time and I am trying to make it fit my use case better by allowing you to select the foreground color as well. I have already added the foreground color to my parameters and it responds correctly, but the tab complete does not cycle trough the colors as it does for Write-Host.

function get-easyview{
    param(
    [int]$Milliseconds= 20,
    [string]$Foregroundcolor="RED")
    $text = $input | Out-String
   
    [char[]]$text | ForEach-Object{
        Write-Host -nonewline -Foregroundcolor $Foregroundcolor $_
        # Only break for a non-whitespace character.
        if($_ -notmatch "\s"){Sleep -Milliseconds $Milliseconds}

    }

 }


$words="my salsa...  my salsa" | get-easyview

This outputs the text in red and $words="my salsa... my salsa" | get-easyview -foregroundcolor green would output it in green, but I want tab to cycle through the colors once I type -foregroundcolor.

Any ideas?


Solution

  • As PowerShell Core evolves, more options become available for Argument Completion in your functions, below you can find some examples of their implementation.

    All of these options allows you to cycle through the set with the Tab key.

    about Functions Advanced Parameters has listed these alternatives with some fine examples, I would also personally recommend you this excellent article from vexx32.


    ValidateSet attribute

    Validates and throw an exception if the argument used is not in the set.

    [ValidateSet('Red','White','Blue','Yellow')]
    [string] $ForegroundColor = 'Red'
    

    ArgumentCompletions attribute

    This attribute was introduced in PowerShell 6, it's not compatible with Windows PowerShell and as the docs states, the main difference is:

    However, unlike ValidateSet, the values are not validated and more like suggestions. Therefore the user can supply any value, not just the values in the list.

    [ArgumentCompletions('Red','White','Blue','Yellow')]
    [string] $ForegroundColor = 'Red'
    

    PowerShell 6 also introduced the IValidateSetValuesGenerator Interface and with it the ability to create custom classes that, when implementing this interface, can offer completion and validation at the same time:

    class ColorCompleter : System.Management.Automation.IValidateSetValuesGenerator {
        static [string[]] $Colors
    
        static ColorCompleter() {
            [ColorCompleter]::Colors = [System.Drawing.Color].GetProperties(
                [System.Reflection.BindingFlags] 'Static, Public').Name
        }
    
        [string[]] GetValidValues() {
            return [ColorCompleter]::Colors
        }
    }
    
    function Test-IValidateSetValuesGenerator {
        [CmdletBinding()]
        param([ValidateSet([ColorCompleter])] $Color)
    
    }
    
    Test-IValidateSetValuesGenerator <TAB>
    

    For other alternatives compatible with Windows PowerShell 5.1 see below.


    Register-ArgumentCompleter

    This cmdlet allows us to define a custom argument completer for a function, cmdlet or command.

    Steps for registering a custom argument completer

    1. Define our Function:
    function Test-ArgumentCompleter {
        [cmdletbinding()]
        param([string] $ForegroundColor)
    
        $ForegroundColor
    }
    
    1. Define the ScriptBlock which will dynamically generate the Tab auto completion:

    The script block you provide should return the values that complete the input. The script block must unroll the values using the pipeline (ForEach-Object, Where-Object, etc.), or another suitable method. Returning an array of values causes PowerShell to treat the entire array as one tab completion value.

    $scriptBlock = {
        param(
            $commandName,
            $parameterName,
            $wordToComplete,
            $commandAst,
            $fakeBoundParameters
        )
    
        'Red', 'White', 'Blue', 'Yellow' |
            Where-Object { $_ -like "*$wordToComplete*" }
    }
    
    1. Lastly, register the Argument Completer:
    $params = @{
        CommandName   = 'Test-ArgumentCompleter'
        ParameterName = 'ForegroundColor'
        ScriptBlock   = $scriptBlock
    }
    Register-ArgumentCompleter @params
    

    Using a custom PowerShell Class

    Since PowerShell 5.1, we can define our own PowerShell Class that implements the IArgumentCompleter Interface.

    1. We define our class:
    using namespace System.Management.Automation
    using namespace System.Management.Automation.Language
    using namespace System.Collections
    using namespace System.Collections.Generic
    
    class Completer : IArgumentCompleter {
        static [string[]] $Set
    
        [IEnumerable[CompletionResult]] CompleteArgument(
            [string] $CommandName,
            [string] $ParameterName,
            [string] $WordToComplete,
            [CommandAst] $CommandAst,
            [IDictionary] $FakeBoundParameters
        ) {
            [CompletionResult[]] $result = foreach($item in $this.Set) {
                if($item -like "*$wordToComplete*") {
                    [CompletionResult]::new("'$item'")
                }
            }
            return $result
        }
    }
    
    1. We decorate the desired parameter with ArgumentCompleterAttribute passing the custom class type as argument, essentially, the ArgumentCompleterAttribute(Type) overload:
    # define the new set here (can be also hardcoded in the class)
    [Completer]::Set = 'foo', 'bar', 'baz'
    
    function Test-ArgumentCompleter {
        [cmdletbinding()]
        param(
            [ArgumentCompleter([Completer])]
            [string] $Argument
        )
    
        $Argument
    }
    

    It's also worth noting that ArgumentCompleterAttribute has another overload for Script Blocks, thus, something like this is also perfectly valid:

    function Test-ArgumentCompleter {
        [cmdletbinding()]
        param(
            [ArgumentCompleter({
                param(
                    $commandName,
                    $parameterName,
                    $wordToComplete,
                    $commandAst,
                    $fakeBoundParameters
                )
    
                'Red', 'White', 'Blue', 'Yellow' |
                    Where-Object { $_ -like "*$wordToComplete*" }
            })]
            [string] $Argument
        )
    
        $Argument
    }
    

    The main advantage of using a custom Class is that it gives us more room for customizations, for example we could have a Class that can handle Completion and Validation by inheriting from ValidateEnumeratedArgumentsAttribute and IArgumentCompleter.

    For example, the class definition could look like this:

    using namespace System.Management.Automation
    using namespace System.Management.Automation.Language
    using namespace System.Collections
    using namespace System.Collections.Generic
    
    class ValidateCustomSet : ValidateEnumeratedArgumentsAttribute, IArgumentCompleter {
        static [string[]] $Set
        static [string] $ErrorMessage
    
        static ValidateCustomSet() {
            if (-not [ValidateCustomSet]::ErrorMessage) {
                [ValidateCustomSet]::ErrorMessage = "'{0}' is not in set! Valid Values '{1}'"
            }
        }
    
        ValidateCustomSet() { }
    
        ValidateCustomSet([string] $ErrorMessage) {
            [ValidateCustomSet]::ErrorMessage = $ErrorMessage
        }
    
        ValidateCustomSet([scriptblock] $Set, [string] $ErrorMessage) {
            [ValidateCustomSet]::Set = $Set.InvokeReturnAsIs()
            [ValidateCustomSet]::ErrorMessage = $ErrorMessage
        }
    
        ValidateCustomSet([scriptblock] $Set) {
            [ValidateCustomSet]::Set = $Set.InvokeReturnAsIs()
        }
    
        [void] ValidateElement([object] $Element) {
            if ($Element -notin [ValidateCustomSet]::Set) {
                throw [MetadataException]::new(
                    [string]::Format([ValidateCustomSet]::ErrorMessage,
                        $Element, [string]::Join(',', [ValidateCustomSet]::Set)))
            }
        }
    
        [IEnumerable[CompletionResult]] CompleteArgument(
            [string] $CommandName,
            [string] $ParameterName,
            [string] $WordToComplete,
            [CommandAst] $CommandAst,
            [IDictionary] $FakeBoundParameters
        ) {
            [List[CompletionResult]] $result = foreach ($i in [ValidateCustomSet]::Set) {
                if ($i.StartsWith($wordToComplete, [System.StringComparison]::InvariantCultureIgnoreCase)) {
                    [CompletionResult]::new(
                        "'" + [CodeGeneration]::EscapeSingleQuotedStringContent($i) + "'",
                        $i,
                        [CompletionResultType]::ParameterValue,
                        $i)
                }
            }
            return $result
        }
    }
    

    Then the implementation would require to decorate the same parameter with ArgumentCompleter passing our custom class and also to decorate it with the custom class (for the validation part):

    function Test-Set {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [ValidateCustomSet(
                # Positional binding here!
                # If using the (scriptblock, string) overload must be in that order!
                { 'foo', 'bar', 'baz' }, 
                'Invalid Value: {0}. Must be one of the following: {1}.'
            )]
            [ArgumentCompleter([ValidateCustomSet])]
            [string] $Item
        )
    
        $Item
    }
    

    demo