AST ‘splatting’ with static methods.

PowerShell-Splatting-With-StaticHave 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 Puszczam oczko Few examples of this function in action:

PowerShell-AST-Static-Splatting-With-Regex-Replace

You can find whole script (together with above examples) on poshcode.

Advertisements

3 thoughts on “AST ‘splatting’ with static methods.

  1. 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.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s