Search code examples
powershellparameter-setspowershell-7

PowerShell fails to pick correct ParameterSet based on number of positional parameters


I am attempting to write a PowerShell function Set-MyRef which uses ParameterSets to expose its available parameters. I would like the function to have parameter sets similar to:

Set-MyRef -Latest

Set-MyRef -LocalBranch

Set-MyRef [-Tag] <string>

Set-MyRef [-Env] <string> -LocalBranch

Set-MyRef [-Env] <string> -Latest

Set-MyRef [-Env] <string> [-Tag] <string>

That is, exactly one of the options -Latest, -LocalBranch or -Tag may be given, with an optional -Env as first positional parameter.
Importantly, I would expect Set-MyRef 'foo' to be parsed as -Tag 'foo', and Set-MyRef 'foo' 'bar' to be parsed as -Env 'foo' -Tag 'bar'.

I attempted to implement it using ParameterSets:

function Set-MyRef {
    [CmdletBinding()]
    param (
        [Parameter(Position = 0, Mandatory = $true, ParameterSetName = 'EnvTag')]
        [Parameter(Position = 0, Mandatory = $true, ParameterSetName = 'EnvLatest')]
        [Parameter(Position = 0, Mandatory = $true, ParameterSetName = 'EnvLocal')]
        [string]$Env,
        [Parameter(Position = 0, Mandatory = $true, ParameterSetName = 'Tag')]
        [Parameter(Position = 1, Mandatory = $true, ParameterSetName = 'EnvTag')]
        [string]$Tag,
        [Parameter(Mandatory = $true, ParameterSetName = 'Latest')]
        [Parameter(Mandatory = $true, ParameterSetName = 'EnvLatest')]
        [switch]$Latest,
        [Parameter(Mandatory = $true, ParameterSetName = 'Local')]
        [Parameter(Mandatory = $true, ParameterSetName = 'EnvLocal')]
        [switch]$LocalBranch
    )

    Write-Host "{ Env: $Env, Tag: $Tag, Latest: $Latest, LocalBranch: $LocalBranch }"
}

Which gives the correct parsed syntax:

$ Get-Command Set-MyRef -Syntax

Set-MyRef [-Env] <string> -LocalBranch [<CommonParameters>]

Set-MyRef [-Env] <string> -Latest [<CommonParameters>]

Set-MyRef [-Env] <string> [-Tag] <string> [<CommonParameters>]

Set-MyRef [-Tag] <string> [<CommonParameters>]

Set-MyRef -Latest [<CommonParameters>]

Set-MyRef -LocalBranch [<CommonParameters>]

But fails to correctly parse the parameters. When calling it using only one position parameter it uses it for both $Env and $Tag:

$ Set-MyRef 'foo' # incorrect - expect Env = $null, Tag = 'foo'
{ Env: foo, Tag: foo, Latest: False, LocalBranch: False }
$ Set-MyRef -Tag 'foo' # correct
{ Env: , Tag: foo, Latest: False, LocalBranch: False }

How do I change my ParameterSet specification so PowerShell can correctly pick the Set-MyRef [Tag] <string> ParameterSet, when only one positional parameter is given?


Solution

  • It looks like you've run into a bug, where a single positional argument is mistakenly bound to both -Tag and -Env, seemingly due to both having a Position=0 parameter-attribute property, despite those properties belonging to different parameter sets.

    • Note: While your scenario is unusual in that what parameter the first positional argument should bind to to depends on the target parameter set, it is logically unequivocal, and the de-facto behavior doesn't make sense - see GitHub issue #20405.

    Workarounds:

    • Preferably, change the order of the positional parameters to bind -Tag first, before -Env:
    function Set-MyRef {
      [CmdletBinding(DefaultParameterSetName='Tag')]
      param (
          [Parameter(Position = 1, Mandatory, ParameterSetName = 'EnvTag')]
          [Parameter(Position = 0, Mandatory, ParameterSetName = 'EnvLatest')]
          [Parameter(Position = 0, Mandatory, ParameterSetName = 'EnvLocal')]
          [string]$Env,
          [Parameter(Position = 0, Mandatory, ParameterSetName = 'Tag')]
          [Parameter(Position = 0, Mandatory, ParameterSetName = 'EnvTag')]
          [string]$Tag,
          [Parameter(Mandatory, ParameterSetName = 'Latest')]
          [Parameter(Mandatory, ParameterSetName = 'EnvLatest')]
          [switch]$Latest,
          [Parameter(Mandatory, ParameterSetName = 'Local')]
          [Parameter(Mandatory, ParameterSetName = 'EnvLocal')]
          [switch]$LocalBranch
      )
    
      Write-Host "{ Env: $Env, Tag: $Tag, Latest: $Latest, LocalBranch: $LocalBranch }"
    }
    
    • Otherwise, the workaround gets cumbersome:
    function Set-MyRef {
      [CmdletBinding(DefaultParameterSetName='Env')]
      param (
          [Parameter(Position = 0, Mandatory, ParameterSetName = 'EnvTag')]
          [Parameter(Position = 0, Mandatory, ParameterSetName = 'EnvLatest')]
          [Parameter(Position = 0, Mandatory, ParameterSetName = 'EnvLocal')]
          [string]$Env,
          [Parameter(Position = 0, Mandatory, ParameterSetName = 'Tag')]
          [Parameter(Position = 1, Mandatory = $false, ParameterSetName = 'EnvTag')]
          [string]$Tag,
          [Parameter(Mandatory, ParameterSetName = 'Latest')]
          [Parameter(Mandatory, ParameterSetName = 'EnvLatest')]
          [switch]$Latest,
          [Parameter(Mandatory, ParameterSetName = 'Local')]
          [Parameter(Mandatory, ParameterSetName = 'EnvLocal')]
          [switch]$LocalBranch
      )
    
      # Compensate for the broken parameter binding, by inferring
      # from the fact that the -Tag argument is the same as the -Env argument
      # that only *one* positional argument was passed and that it was meant to
      # bind to -Tag.
      if ($PSCmdlet.ParameterSetName -eq 'EnvTag' -and $Env -eq $Tag) {
        $Env = $null
      }
      Write-Host "{ Env: $Env, Tag: $Tag, Latest: $Latest, LocalBranch: $LocalBranch }"
    }
    
    • Note:

      • The above assumes that there's no legitimate use case where the -Env and -Target arguments happen to have the same value. With substantially more effort, the latter case could be handled too.