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

Lance U. Matthews
  • 15,725
  • 6
  • 48
  • 68
mark
  • 59,016
  • 79
  • 296
  • 580
  • 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. – Santiago Squarzon Feb 10 '22 at 22:16
  • Does not matter if I rename it. Let me update the question. The result is the same anyway – mark Feb 10 '22 at 22:21
  • 1
    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://stackoverflow.com/a/71037345/15339544 (at the end) – Santiago Squarzon Feb 10 '22 at 22:22
  • But the default `$args` in a simple function is an array, not a hashtable and it works just fine there. – mark Feb 10 '22 at 22:23
  • 1
    yes, but there is some magic that a normal array is not doing _automatically_ – Santiago Squarzon Feb 10 '22 at 22:24
  • I must admit I did not understand the obscure part. Could you provide an answer with more details, please? – mark Feb 10 '22 at 22:27
  • although not recommended, you can use `Invoke-Expression` to get your expected results: `Invoke-Expression "Write-Host $args"`. – Abraham Zinala Feb 10 '22 at 22:32
  • @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. – mark Feb 10 '22 at 22:38
  • @mark, can you elaborate on that? An argument with spaces would have to be quoted regardless. – Abraham Zinala Feb 10 '22 at 22:41
  • @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? – mark Feb 10 '22 at 23:23

2 Answers2

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)]
    [object[]]$MagicArgs
)
    $params = @{
        NotePropertyName = '<CommandParameterName>'
        PassThru = $true
        InputObject = ''
    }
    $z = foreach($i in $MagicArgs) {
        if($i.StartsWith('-')) {
            $params.NotePropertyValue = $i
            Add-Member @params
            continue
        }
        $i
    }

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

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>
Santiago Squarzon
  • 41,465
  • 5
  • 14
  • 37
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.

mklement0
  • 382,024
  • 64
  • 607
  • 775
  • 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. – mark Feb 12 '22 at 00:05
  • @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. – mklement0 Feb 12 '22 at 00:18
  • @mark, in short: To avoid headaches, and for consistency between functions and scripts: use the body-internal `param(...)` syntax to declare your parameters. – mklement0 Feb 12 '22 at 00:20
  • @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. – mklement0 Feb 12 '22 at 01:04
  • @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_. – mklement0 Feb 12 '22 at 14:56
  • Oh boy.......... – mark Feb 12 '22 at 15:25