Have you ever wanted to use splatting outside it’s normal use with PowerShell commands? Splatting is great to pass (named) parameters to commands, but there are other elements that have named parameters. Perfect example: static methods that exist on .NET types. Unfortunately, in current version of PowerShell we can’t extend splatting concept to other language elements. But I guess a lot of people would agree with me: it would be great to be able to do something like:
$ToSplat = @{ format = "Using 'splatting' hashtable: {0:N2} and {1:N3}" args = [math]::PI, [math]::E } [string]::Format(@ToSplat)
It may happen one day, but can we do anything about it now? Well… sure!
I’ve consider few different approaches to this. I wanted to create a command, that would simulate running “call” operator in very specific way. In the end I decided to use PowerShell AST to get to the point when it works almost as desired. The only issue here is that it won’t accept @Hash syntax. To make this syntax possible we would probably need dynamic parameters created based on type/ method name. Next time, maybe.
Final result
I wanted to make it as close to call/ dot-source operators as possible. For that I’ve used function with alias “^”. This function works best with full scriptblock passed. But it will work also if you pass code as single string, or just type it in a way you would expect it to work:
$Pow = @{ x = 2 y = 3 } ^ { [math]::Pow($Pow) } ^ '[math]::Pow($Pow)' ^ [math]::Pow($Pow)
Any syntax will give you correct result: 2 to the power of 3 (8).
As you can see, if we do not use script block code is not formatted very well. I had to keep that in mind when I was writing function. Script block has also advantage of supporting several method calls in one go. Also: I wanted to make “positional” binding possible as well, so that @array splatting works same as it does for commands. So how did I get there?
AST – again
I could probably just use v2 parsing, but I felt that AST is the way forward, and it makes parsing any script block a lot easier. But because I wanted to support “simple” syntax too, I just tricked my PowerShell into creating script block from tokens passed:
param ( [Parameter( ParameterSetName = 'script', Position = 0, Mandatory )] [scriptblock]$ScriptBlock, [Parameter( ParameterSetName = 'string', Position = 0, Mandatory )] [string]$Expression, [Parameter( ParameterSetName = 'string', Position = 1 )]$Splatted ) if ($Expression) { if ($Splatted) { $Expression = "{0}({1})" -f $Expression, '$Splatted' } $ScriptBlock = [scriptblock]::Create($Expression) }
With two ParameterSets I can pass either script block or some code that will be translated into script block. $Splatted is something that will be set if you don’t put expression in quotes. Eventually – I will get ScriptBlock that I can work with, with AST property that I can take advantage of.
Pick and choose
AST gives you two great things: first of all, very structured view on code that you work with, and ability to select only some elements in your code that match your requirement. That’s what I wanted to do now. I wanted to constrain syntax that I will work with, so that things I plan to do with it will just work. How did I achieve it? Using FindAll method on AST:
foreach ($Invoke in $ScriptBlock.Ast.FindAll({ $args[0] -is [Management.Automation.Language.InvokeMemberExpressionAst] -and $args[0].Static -and $args[0].Arguments.Count -eq 1 -and $args[0].Expression -is [Management.Automation.Language.TypeExpressionAst] }, $false)) { <# ... #> }
What does it give me? Let’s try messy ScriptBlock and see what we get:
^ { $Dont = @{ Define = 'Anything' Inside = 'Block' } [string]::Format($ToSplat) [string]::Format($Array) $This::Silently($Fail) 'Everything else is simply ignored' Stop-Computer -Force }
In the end – only lines using [string]::Format will be invoked. Everything else will be ignored because of the requirements we specified:
- AST type of code fragment as a whole
- invocation using static (::) member invocation method
- single parameter passed
- AST type of expression before invocation operator
Some of that could be implemented as well, but because I wanted to focus on [type]::Method($Splat) syntax, I just got rid off anything that does not match.
Processssssss
Main effort went it gluing it all together. First – I worked on each token to prepare syntax elements:
$Type = [type]$Invoke.Expression.TypeName.FullName $Method = $Invoke.Member.Value $Name = $Invoke.Arguments.VariablePath.UserPath -replace '\w+:' $Argument = Get-Variable -Name $Name -ValueOnly
Once I know what is value of passed parameter – I can either invoke method without any additional work, or – if it’s hashtable – try to “guess” proper overload. First method is very easy:
$Type::$Method.Invoke($Argument)
For hashtable splatting – “guessing” is probably the weakest part. I just count method’s parameters, count keys in hashtable, and count number of parameters that are same as hashtable keys. If all of those are equal, I select such overload:
$Selected = $Type.GetMethods() | where { $_.Name -eq $Method -and ( $_.GetParameters().Name.Count -eq $Argument.Keys.Count ) -and ( $Argument.Keys.Count -eq ($_.GetParameters() | where { $_.Name -in $Argument.Keys }).Count ) }
Because for some methods there might be more than one (check out [math]::floor as example, it bit me during testing that’s why I know…), so I try to invoke each:
foreach ($Item in $Selected) { $ParamList = New-Object System.Collections.ArrayList $Item.GetParameters() | sort Position | ForEach-Object { $ParamList.Add($Argument[$_.Name] -as $_.ParameterType) | Out-Null } $Item.Invoke($Item,$ParamList.ToArray()) }
I’m running all that in try {} catch {} mainly to “silence” errors. Worse error handling ever Few examples of this function in action:
You can find whole script (together with above examples) on poshcode.
That’s pretty cool.
If you keep the input as a string, you can get the syntax that you want.
Here’s my v2 entry 🙂
function invoke-static([string]$cmd)
{
$tokens = [system.management.automation.psparser]::Tokenize($cmd, [ref]$null)
$typeName = $tokens[0].Content
$funcName = $tokens[2].Content
$hashName = $tokens[4].Content
$splatObj = Get-Variable -Name $hashName -ValueOnly -Scope 1
if ($cmd[$tokens[4].Start] -eq '$') {
try {return $([type]$typeName)::$funcName.Invoke(@(,$splatObj))}
catch {throw $Error[0]}
}
if ($splatObj -isnot [system.collections.idictionary]) {
try {return $([type]$typeName)::$funcName.Invoke($splatObj)}
catch {throw $Error[0]}
}
$func = $([type]($typeName)).GetMethods() |
Where-Object {$_.Name -eq $funcName} |
Where-Object {
$parameters = $_.GetParameters()
if ($parameters.Count -ne $splatObj.Count) {return $false}
foreach ($p in $parameters) {
if (-not $splatObj.ContainsKey($p.Name)) {
return $false
}
}
return $true
} |
Select-Object -First 1
if ($func -eq $null) {throw "could not find matching method"}
$funcArgs = New-Object System.Collections.ArrayList
$func.GetParameters() |
Sort-Object Position |
ForEach-Object {$funcArgs.Add($splatObj[$_.Name]) | Out-Null}
return $([type]$typeName)::$funcName.Invoke($funcArgs.ToArray())
}
$array = 2,3
$hash = @{x=2; y=3}
invoke-static '[math]::pow(@array)' #splat array
invoke-static '[math]::pow(@hash)' #splat hash table
invoke-static '[console]::writeline($array)' #array is single argument
invoke-static '[console]::writeline($hash)' #hash table is single argument
Nice.. 🙂 And your named parameter validation is much better, maybe just “fork” my poshcode entry and fix it with that code? 🙂
Anyways, regarding suggestion to quote only… For me that’s worse of the three I’ve used. I mean: I don’t feel like I’m running actual code if I quote it. It feels more like iex hack or smth. I started with ^ { } syntax because it felt most natural for me. And code looks like code all the way. 😉 Still: me likes your version. The problem with me: AST is my new shining hammer. And I see nails everywhere. 😀
Yeah, I don’t like to quote either, but that’s the only way I got it to work on v2. Also, my version doesn’t really fix anything.
You mentioned the [math]::floor problem:
static decimal Floor(decimal d)
static double Floor(double d)
There’s also the [math]::ceiling problem:
static decimal Ceiling(decimal d)
static double Ceiling(double a)
Which overload to call with this?
PS C:\> $hash = @{a = [decimal]3}
Or how to tell PowerShell which method to call with this?
PS C:\> $hash = @{d = ‘3’}
Now, combine those problems with the [math]::min problem:
static System.SByte Min(System.SByte val1, System.SByte val2)
static byte Min(byte val1, byte val2)
static System.Int16 Min(System.Int16 val1, System.Int16 val2)
static System.UInt16 Min(System.UInt16 val1, System.UInt16 val2)
static int Min(int val1, int val2)
static System.UInt32 Min(System.UInt32 val1, System.UInt32 val2)
static long Min(long val1, long val2)
static System.UInt64 Min(System.UInt64 val1, System.UInt64 val2)
static float Min(float val1, float val2)
static double Min(double val1, double val2)
static decimal Min(decimal val1, decimal val2)
It would be interesting to see how to solve this without having to figure out how PowerShell chooses which method overload to call when argument types don’t exactly match the parameter types.