8

I'm looking for a way to make a cmdlet which receives parameter and while typing, it prompts suggestions for completion from a predefined array of options.

I was trying something like this:

$vf = @('Veg', 'Fruit')
function Test-ArgumentCompleter {
  [CmdletBinding()]
    param (
          [Parameter(Mandatory=$true)]
          [ValidateSet($vf)]
          $Arg
    )
}

The expected result should be:
When writing 'Test-ArgumentCompleter F', after clicking the tub button, the F autocompleted to Fruit.

elena.kim
  • 930
  • 4
  • 12
  • 22
barper
  • 99
  • 7
  • `[ValidateSet('Veg','Fruit')]` – Theo May 02 '21 at 13:22
  • @Theo Thank you for the reply, but its not the answer for my question. I 'm looking for answer which solves it with an array of values defined earlier in the code, so I can reuse it instead of writing 'Veg','Fruit' in every function I write because it requires much more maintenance in each value addition in the future. – barper May 02 '21 at 13:45
  • 2
    That was to say you can't do what you want with ValidateSet. You can use `ValidateScript` where you test yourself if the input var is found in an array, but then you lose intellisense. – Theo May 02 '21 at 13:50

5 Answers5

9

In addition to mklement0's excellent answer, I feel obligated to point out that in version 5 and up you have a slightly simpler alternative available: enum's

An enum, or an "enumeration type", is a static list of labels (strings) associated with an underlying integral value (a number) - and by constraining a parameter to an enum type, PowerShell will automatically validate the input value against it AND provide argument completion:

enum MyParameterType
{
  Veg
  Fruit
}

function Test-ArgumentCompleter {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [MyParameterType]$Arg
    )
}

Trying to tab complete the argument for -Arg will now cycle throw matching valid enum labels of MyParameterType:

PS ~> Test-ArgumentCompleter -Arg v[<TAB>]
# gives you
PS ~> Test-ArgumentCompleter -Arg Veg
Mathias R. Jessen
  • 157,619
  • 12
  • 148
  • 206
  • Actually, this kind of approach worked for me only when I executed the command inside the console but not as part of a script writing inside an IDE – barper May 02 '21 at 20:59
8
  • PowerShell generally requires that attribute properties be literals (e.g., 'Veg') or constants (e.g., $true).

  • Dynamic functionality requires use of a script block (itself specified as a literal, { ... }) or, in specific cases, a type literal.

  • However, the [ValidateSet()] attribute only accepts an array of string(ified-on-demand) literals or - in PowerShell (Core) v6 and above - a type literal (see below).


Update:


Caveat:

  • None of the solutions in this answer would work in a stand-alone script (*.ps1 file) that is invoked directly, i.e. a script that starts with a param(...) block.
    • The above approaches wouldn't work, because any class and enum definitions referenced in that block would need to be loaded before the script is invoked.
      • This is a long-standing limitation that affects other scenarios too. See GitHub issue #19676, which is a variation of this problem (instead of trying to define the referenced types inside the same script file, using module is attempted, which too doesn't work, up to at least PowerShell v7.3.x).
    • The approach below wouldn't work, because syntactically you're not allowed to place code before a script's param(...) block.

To get the desired functionality based on a non-literal array of values, you need to combine two other attributes:

# The array to use for tab-completion and validation.
[string[]] $vf = 'Veg', 'Fruit'

function Test-ArgumentCompleter {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)]
    # Tab-complete based on array $vf
    [ArgumentCompleter({
      param($cmd, $param, $wordToComplete) $vf -like "$wordToComplete*"
    })]
    # Validate based on array $vf.
    # NOTE: If validation fails, the (default) error message is unhelpful.
    #       You can work around that in *Windows PowerShell* with `throw`, and in
    #       PowerShell (Core) 7+, you can add an `ErrorMessage` property:
    #         [ValidateScript({ $_ -in $vf }, ErrorMessage = 'Unknown value: {0}')]
    [ValidateScript({
      if ($_ -in $vf) { return $true }
      throw "'$_' is not in the set of the supported values: $($vf -join ', ')"
    })]
    $Arg
  )

  "Arg passed: $Arg"
}
mklement0
  • 382,024
  • 64
  • 607
  • 775
  • thanks but it doesn't work for me. it autocompletes to the values {True,False} instead of {Veg,Fruit}. – barper May 02 '21 at 13:58
  • @barper, that would imply that your array contains just 1 element and is, in fact, not an array. Please try the code exactly as posted (I've added a `[string[]]` type constraint, just to be safe). – mklement0 May 02 '21 at 14:03
  • @barper, the code works in both Window PowerShell v5.1 and PowerShell Core v7.1.3. If it doesn't work for you, please tell us more about your environment. – mklement0 May 02 '21 at 14:04
  • I copied your code exactly as it written in your post but unfortunately, although I can run it with the right parameters and it gives the right value on screen (Arg passed: Veg for Veg input), the functionality of autocomplete the param name on tab click doesn't work for some reason.. do you know what might be the problem? btw: Thanks for your time and help, its appreciated :) – barper May 02 '21 at 14:10
  • @barper, does the _function name_ tab-complete? If not, then the problem may be that the `PSReadLine` module isn't loaded (it should be by default, at least in v5+). – mklement0 May 02 '21 at 15:12
8

To complement the answers from @mklement0 and @Mathias, using dynamic parameters:

$vf = 'Veg', 'Fruit'

function Test-ArgumentCompleter {
    [CmdletBinding()]
    param ()
    DynamicParam {
        $RuntimeParameterDictionary = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary
        $AttributeCollection = New-Object System.Collections.ObjectModel.Collection[System.Attribute]
        $ParameterAttribute = New-Object System.Management.Automation.ParameterAttribute
        $ParameterAttribute.Mandatory = $true
        $AttributeCollection.Add($ParameterAttribute)
        $ValidateSetAttribute = New-Object System.Management.Automation.ValidateSetAttribute($vf)
        $AttributeCollection.Add($ValidateSetAttribute)
        $RuntimeParameter = New-Object System.Management.Automation.RuntimeDefinedParameter('Arg', [string], $AttributeCollection)
        $RuntimeParameterDictionary.Add('Arg', $RuntimeParameter)
        return $RuntimeParameterDictionary
    }
}

Depending on how you want to predefine you argument values, you might also use dynamic validateSet values:

Class vfValues : System.Management.Automation.IValidateSetValuesGenerator {
    [String[]] GetValidValues() { return 'Veg', 'Fruit' }
}

function Test-ArgumentCompleter {
[CmdletBinding()]
param (
        [Parameter(Mandatory=$true)]
        [ValidateSet([vfValues])]$Arg
    )
}

note: The IValidateSetValuesGenerator class [read: interface] was introduced in PowerShell 6.0

iRon
  • 20,463
  • 10
  • 53
  • 79
4

To add to the other helpful answers, I use something similiar for a script I made for work:

$vf = @('Veg', 'Fruit','Apple','orange')

$ScriptBlock = {
    Foreach($v in $vf){
        New-Object -Type System.Management.Automation.CompletionResult -ArgumentList $v, 
            $v, 
            "ParameterValue",
            "This is the description for $v"
    }
}

Register-ArgumentCompleter -CommandName Test-ArgumentCompleter -ParameterName Arg -ScriptBlock $ScriptBlock


function Test-ArgumentCompleter {
[CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [String]$Arg )
}

Documentation for Register-ArgumentCompleter is well explained on Microsoft Docs. I personally don't like to use the enum statement as it didnt allow me to uses spaces in my Intellisense; same for the Validate parameter along with nice features to add a description.

Output:

enter image description here

EDIT:

@Mklement made a good point in validating the argument supplied to the parameter. This alone doesnt allow you to do so without using a little more powershell logic to do the validating for you (unfortunately, it would be done in the body of the function).

function Test-ArgumentCompleter {
[CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        $Arg )
      
   if($PSBoundParameters.ContainsKey('Arg')){
       if($VF -contains $PSBoundParameters.Values){ "It work:)" }
           else { "It no work:("}
    }
} 
Abraham Zinala
  • 4,267
  • 3
  • 9
  • 24
  • 1
    It's good to show `Register-ArgumentCompleter`, the command-external tab-completion alternative to the per-parameter [`System.Management.Automation.ArgumentCompleterAttribute`](https://learn.microsoft.com/en-US/dotnet/api/System.Management.Automation.ArgumentCompleterAttribute) (as used in my answer). Note, however, that this by itself doesn't provide argument _validation_; that is, you can type or paste arbitrary values, and the parameter binder won't complain. – mklement0 May 02 '21 at 17:23
  • One annoying thing about Register-ArgumentCompleter is when using it from console you can use tab to cycle through the values however you cannot use autocomplete. For example, if I have a list of colors and I start typing in "Ye" and hit tab instead of getting Yellow I get the first value of my list and have to hit tab repeatedly until I get to Yellow. The other answers here work better for autocompletion. – Daniel May 02 '21 at 17:33
  • @mklement0, maybe using some `if` logic applied to `$PSBoundParameters` can assist with that? That is a good point. – Abraham Zinala May 02 '21 at 17:39
  • @Daniel, thats odd. It works fine for me, on ise or direct posh console. Added another value of `'Appel'` and it tabbed through both typing just *app* and not the other values. – Abraham Zinala May 02 '21 at 17:40
  • The only place it kind of works for me is in ISE (which I never use). None of the consoles work for me. `Test-ArgumentCompleter -Arg Ap` and then tab changes to `Test-ArgumentCompleter -Arg Veg` – Daniel May 02 '21 at 17:48
  • @AbrahamZinala, the only solutions I see (short of defining a dynamic parameter, which requires a lot of obscure ceremony) are to either use a `ValidateScript` attribute, as in my answer, or to perform the validation manually in the function _body_. – mklement0 May 02 '21 at 18:14
  • Just edited the post to use some validation without making it too complicated. It's just as you said tho, having to add the logic in the body of the function which I personally dont like either and am not smart enough to incorporate yalls logic into mine for any `[validate()]` parameter. – Abraham Zinala May 02 '21 at 18:24
  • @Daniel, thats some strange behavior. Wouldn't know how to answer/fix that myself since I can't replicate it. – Abraham Zinala May 02 '21 at 18:27
  • @AbrahamZinala Thank you! it works perfect in my console. do you know a way how to make it works inside a script as well so I can call the function from different function and I'll still get the autocompletion? Thanks in advance! – barper May 03 '21 at 05:44
  • Hello. You can declare it a global function if you'd be loading the script for other purposes. Otherwise, you can make it into a module or, you can just add it to your `$Profile` which would load everytime you open up a new ps instance. – Abraham Zinala May 03 '21 at 12:49
0

I think it's worth sharing another alternative that complements the helpful answers from mklement0, Mathias, iRon and Abraham. This answer attempts to show the possibilities that PowerShell can offer when it comes to customization of a Class.

The Class used for this example offers:

For the example below I'll be using completion and validation on values from a current directory, the values are fed dynamically at runtime with Get-ChildItem -Name.

Class

When referring to the custom validation set I've decided to use the variable $this, however that can be easily change for a variable name of one's choice:

[psvariable]::new('this', (& $this.CompletionSet))

The completion set could be also a hardcoded set, i.e.:

[string[]] $CompletionSet = 'foo', 'bar', 'baz'

However that would also require some modifications in the class logic itself.

using namespace System.Management.Automation
using namespace System.Management.Automation.Language
using namespace System.Collections
using namespace System.Collections.Generic

class CustomValidationCompletion : ValidateEnumeratedArgumentsAttribute, IArgumentCompleter {
    [scriptblock] $CompletionSet = { Get-ChildItem -Name }
    [scriptblock] $Validation
    [scriptblock] $ErrorMessage

    CustomValidationCompletion() { }
    CustomValidationCompletion([scriptblock] $Validation, [scriptblock] $ErrorMessage) {
        $this.Validation    = $Validation
        $this.ErrorMessage  = $ErrorMessage
    }

    [void] ValidateElement([object] $Element) {
        $context = @(
            [psvariable]::new('_', $Element)
            [psvariable]::new('this', (& $this.CompletionSet))
        )

        if(-not $this.Validation.InvokeWithContext($null, $context)) {
            throw [MetadataException]::new(
                [string] $this.ErrorMessage.InvokeWithContext($null, $context)
            )
        }
    }

    [IEnumerable[CompletionResult]] CompleteArgument(
        [string] $CommandName,
        [string] $ParameterName,
        [string] $WordToComplete,
        [CommandAst] $CommandAst,
        [IDictionary] $FakeBoundParameters
    ) {
        [List[CompletionResult]] $result = foreach($item in & $this.CompletionSet) {
            if(-not $item.StartsWith($wordToComplete)) {
                continue
            }
            [CompletionResult]::new("'$item'", $item, [CompletionResultType]::ParameterValue, $item)
        }
        return $result
    }
}

Implementation

function Test-CompletionValidation {
    [alias('tcv')]
    [CmdletBinding()]
    param(
        [CustomValidationCompletion(
            Validation   = { $_ -in $this },
            ErrorMessage = { "Not in set! Must be one of these: $($this -join ', ')" }
        )]
        [ArgumentCompleter([CustomValidationCompletion])]
        [string] $Argument
    )

    $Argument
}

Demo

demo

Santiago Squarzon
  • 41,465
  • 5
  • 14
  • 37