Couldn't use predefined array inside Validateset - Powershell

2.5k Views Asked by At

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.

5

There are 5 best solutions below

10
On

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:("}
    }
} 
1
On

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
5
On
  • 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"
}
0
On

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

0
On

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