How to achieve @args splatting in an advanced function in Powershell?
Asked Answered
S

2

5

Consider the following simple function:

function Write-HostIfNotVerbose()
{
    if ($VerbosePreference -eq 'SilentlyContinue')
    {
        Write-Host @args
    }
}

And it works fine:

enter image description here

Now I want to make it an advanced function, because I want it to inherit the verbosity preference:

function Write-HostIfNotVerbose([Parameter(ValueFromRemainingArguments)]$MyArgs)
{
    if ($VerbosePreference -eq 'SilentlyContinue')
    {
        Write-Host @MyArgs
    }
}

But it does not work:

enter image description here

And what drives me nuts is that I am unable to identify how $args in the first example is different from $args in the second.

I know that the native @args splatting does not work for advanced functions by default - https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_splatting?view=powershell-7.2#notes

But I hoped it could be simulated, yet it does not work either. My question is - what is wrong with the way I am trying to simulate it and whether it is possible to fix my code without surfacing all the Write-Host parameters at Write-HostIfNotVerbose

Sternforemost answered 10/2, 2022 at 22:7 Comment(10)
The first problem you have is that on your second function you're using $args as a parameter name, which you can't do, it's an automatic variable.Artiodactyl
Does not matter if I rename it. Let me update the question. The result is the same anywaySternforemost
Then, once you have replaced $args for a different parameter name, if you want to use splatting with an array instead of with a hash table (i.e.: Write-HostIfNotVerbose @{ForegroundColor='Green';Object='Hello'}) you would need to do some obscure things, see his answer: https://mcmap.net/q/2035907/-in-powershell-how-to-pass-args-of-type-string-to-a-quot-function-param-quot-i-dont-want-to-pass-a-of-type-hashtable-to-the-function (at the end)Artiodactyl
But the default $args in a simple function is an array, not a hashtable and it works just fine there.Sternforemost
yes, but there is some magic that a normal array is not doing automaticallyArtiodactyl
I must admit I did not understand the obscure part. Could you provide an answer with more details, please?Sternforemost
although not recommended, you can use Invoke-Expression to get your expected results: Invoke-Expression "Write-Host $args".Segovia
@AbrahamZinala - that does not work if one of the args is text with spaces, which it would definitely be. So it becomes of paramount importance to identify the text parameter and inject quotes. Which is quite a pain, because all the parameters would look like text.Sternforemost
@mark, can you elaborate on that? An argument with spaces would have to be quoted regardless.Segovia
@AbrahamZinala - only when you pass it from outside. Then it becomes a string object and as such the quotes are not part of it. "a b c" is a string object a b c of length 5, it does not include quotes. If you want it to include quotes, you need to include them explicitly - '"a b c"'. Have you tried your approach?Sternforemost
H
3

Santiago Squarzon's helpful answer contains some excellent sleuthing that reveals the hidden magic behind @args, i.e. splatting using the automatic $args variable, which is available in simple (non-advanced) functions only.

The solution in Santiago's answer isn't just complex, it also isn't fully robust, as it wouldn't be able to distinguish -ForegroundColor (a parameter name) from '-ForegroundColor' a parameter value that happens to look like a parameter name, but is distinguished from it by quoting.

  • As an aside: even the built-in @args magic has a limitation: it doesn't correctly pass a [switch] parameter specified with an explicit value through, such as
    -NoNewLine:$false[1]

A robust solution requires splatting via the automatic $PSBoundParameters variable, which in turn requires that the wrapping function itself also declare all potential pass-through parameters.

Such a wrapping function is called a proxy function, and the PowerShell SDK facilitates scaffolding such functions via the PowerShell SDK, as explained in this answer.

In your case, you'd have to define your function as follows:

function Write-HostIfNotVerbose {
  [CmdletBinding()]
  param(
    [Parameter(Position = 0, ValueFromPipeline, ValueFromRemainingArguments)]
    [Alias('Msg', 'Message')]
    $Object,
    [switch] $NoNewline,
    $Separator,
    [System.ConsoleColor] $ForegroundColor,
    [System.ConsoleColor] $BackgroundColor
  )

  begin {
    $scriptCmd = 
      if ($VerbosePreference -eq 'SilentlyContinue') { { Write-Host @PSBoundParameters } } 
      else                                           { { Out-Null } }
    $steppablePipeline = $scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin)
    $steppablePipeline.Begin($PSCmdlet)
  }

  process {
    $steppablePipeline.Process($_)
  }

  end {
    $steppablePipeline.End()
  }

}

[1] Such an argument is invariably passed through as two arguments, namely as parameter name -NoNewLine by itself, followed by a separate argument, $false. The problem is that at the time the original arguments are parsed into $args, it isn't yet known what formally declared parameters they will bind to. The NoteProperty tagging applied to $args for marking elements as parameter names doesn't preserve the information as to whether the subsequent argument was separated from the parameter name with :, which for a [switch] parameter is necessary to identify that argument as belonging to the switch. In the absence of this information, two separate arguments are always passed during splatting.

Hyla answered 11/2, 2022 at 3:33 Comment(6)
Could you explain why this code does not work if I omit Position = 0 ? Also, can I use the function(...) syntax? It would still be an advanced function. It seems to work, but I might be misunderstanding something.Sternforemost
@mark, however, note that use of a [Parameter()] attribute is supported with the function foo(...) syntax and that such an attribute is implicitly tantamount to using [CmdletBinding()] with its defaults, i.e. it implicitly makes the function/script an advanced one - however, if you need to use properties of the [CmdletBinding()] attribute for non-default opt-in behaviors, a body-internal param(...) block must be used.Hyla
@mark, in short: To avoid headaches, and for consistency between functions and scripts: use the body-internal param(...) syntax to declare your parameters.Hyla
@mark, as for Position = 0 (I misspoke earlier): The [CmdletBinding()] attribute should be defined with PositionalBinding=$false (see my update), so as to make only those parameters marked explicitly with Position properties as positional. That makes any unnamed == positional argument passed implicitly bind to -Object in this case, as with Write-Host. The tricky part is that binary cmdlets default to (the equivalent of) PositionalBinding=$false , while in PowerShell functions / script it is the inverse: all parameters (other than [switch]) are positional by default.Hyla
@mark, I didn't paint the full picture again: Turns out that as soon as you use a Position attribute on any of the parameters, PositionalBinding=$false is implied. Thus, in the absence of an explicit PositionalBinding=$false in the [CmdletBinding()] (I've removed it again), removing all Position attributes then makes all non-switch parameters positional, and, due to use of ValueFromRemainingArguments in the $Object parameter, it is $Separator that becomes the first positional parameter, and so on, with the ValueFromRemainingArguments parameter getting bound last.Hyla
Oh boy..........Sternforemost
A
3

This is too obscure for me to explain, but for the sake of answering what PowerShell could be doing with $args you can test this:

function Write-HostIfNotVerbose {
    param(
        [parameter(ValueFromRemainingArguments)]
        [psobject[]] $MagicArgs
    )

    $params = foreach ($arg in $MagicArgs) {
        if ($arg.StartsWith('-')) {
            $arg.PSObject.Properties.Add(
                [psnoteproperty]::new('<CommandParameterName>', $arg))
        }

        $arg
    }

    if ($VerbosePreference -eq 'SilentlyContinue') {
        Write-Host @params
    }
}

Write-HostIfNotVerbose -ForegroundColor Green Hello world! -BackgroundColor Yellow

A way of seeing what $args is doing automatically for us could be to serialize the variable:

function Test-Args {
    [System.Management.Automation.PSSerializer]::Serialize($args)
}

Test-Args -Argument1 Hello -Argument2 World

Above would give us the serialized representation of $args where we would observe the following:

<LST>
  <Obj RefId="1">
    <S>-Argument1</S>
    <MS>
      <S N="&lt;CommandParameterName&gt;">Argument1</S>
    </MS>
  </Obj>
  <S>Hello</S>
  <Obj RefId="2">
    <S>-Argument2</S>
    <MS>
      <S N="&lt;CommandParameterName&gt;">Argument2</S>
    </MS>
  </Obj>
  <S>World</S>
</LST>
Artiodactyl answered 10/2, 2022 at 22:39 Comment(2)
OMG, I am not sure I am better off knowing it at all...Sternforemost
@Sternforemost I told you so lolArtiodactyl
H
3

Santiago Squarzon's helpful answer contains some excellent sleuthing that reveals the hidden magic behind @args, i.e. splatting using the automatic $args variable, which is available in simple (non-advanced) functions only.

The solution in Santiago's answer isn't just complex, it also isn't fully robust, as it wouldn't be able to distinguish -ForegroundColor (a parameter name) from '-ForegroundColor' a parameter value that happens to look like a parameter name, but is distinguished from it by quoting.

  • As an aside: even the built-in @args magic has a limitation: it doesn't correctly pass a [switch] parameter specified with an explicit value through, such as
    -NoNewLine:$false[1]

A robust solution requires splatting via the automatic $PSBoundParameters variable, which in turn requires that the wrapping function itself also declare all potential pass-through parameters.

Such a wrapping function is called a proxy function, and the PowerShell SDK facilitates scaffolding such functions via the PowerShell SDK, as explained in this answer.

In your case, you'd have to define your function as follows:

function Write-HostIfNotVerbose {
  [CmdletBinding()]
  param(
    [Parameter(Position = 0, ValueFromPipeline, ValueFromRemainingArguments)]
    [Alias('Msg', 'Message')]
    $Object,
    [switch] $NoNewline,
    $Separator,
    [System.ConsoleColor] $ForegroundColor,
    [System.ConsoleColor] $BackgroundColor
  )

  begin {
    $scriptCmd = 
      if ($VerbosePreference -eq 'SilentlyContinue') { { Write-Host @PSBoundParameters } } 
      else                                           { { Out-Null } }
    $steppablePipeline = $scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin)
    $steppablePipeline.Begin($PSCmdlet)
  }

  process {
    $steppablePipeline.Process($_)
  }

  end {
    $steppablePipeline.End()
  }

}

[1] Such an argument is invariably passed through as two arguments, namely as parameter name -NoNewLine by itself, followed by a separate argument, $false. The problem is that at the time the original arguments are parsed into $args, it isn't yet known what formally declared parameters they will bind to. The NoteProperty tagging applied to $args for marking elements as parameter names doesn't preserve the information as to whether the subsequent argument was separated from the parameter name with :, which for a [switch] parameter is necessary to identify that argument as belonging to the switch. In the absence of this information, two separate arguments are always passed during splatting.

Hyla answered 11/2, 2022 at 3:33 Comment(6)
Could you explain why this code does not work if I omit Position = 0 ? Also, can I use the function(...) syntax? It would still be an advanced function. It seems to work, but I might be misunderstanding something.Sternforemost
@mark, however, note that use of a [Parameter()] attribute is supported with the function foo(...) syntax and that such an attribute is implicitly tantamount to using [CmdletBinding()] with its defaults, i.e. it implicitly makes the function/script an advanced one - however, if you need to use properties of the [CmdletBinding()] attribute for non-default opt-in behaviors, a body-internal param(...) block must be used.Hyla
@mark, in short: To avoid headaches, and for consistency between functions and scripts: use the body-internal param(...) syntax to declare your parameters.Hyla
@mark, as for Position = 0 (I misspoke earlier): The [CmdletBinding()] attribute should be defined with PositionalBinding=$false (see my update), so as to make only those parameters marked explicitly with Position properties as positional. That makes any unnamed == positional argument passed implicitly bind to -Object in this case, as with Write-Host. The tricky part is that binary cmdlets default to (the equivalent of) PositionalBinding=$false , while in PowerShell functions / script it is the inverse: all parameters (other than [switch]) are positional by default.Hyla
@mark, I didn't paint the full picture again: Turns out that as soon as you use a Position attribute on any of the parameters, PositionalBinding=$false is implied. Thus, in the absence of an explicit PositionalBinding=$false in the [CmdletBinding()] (I've removed it again), removing all Position attributes then makes all non-switch parameters positional, and, due to use of ValueFromRemainingArguments in the $Object parameter, it is $Separator that becomes the first positional parameter, and so on, with the ValueFromRemainingArguments parameter getting bound last.Hyla
Oh boy..........Sternforemost

© 2022 - 2024 — McMap. All rights reserved.