PowerShell Script Module Design: Building Tools to Automate the Process

As I previously mentioned a little over a month ago in my blog article PowerShell Script Module Design Philosophy, I’m transitioning my module build process to a non-monolithic design in development and a monolithic design for production to take advantage of the best of both worlds. Be sure to read the previously referenced blog article for more details on the subject.

My goal is to write a reusable tool to retrieve the necessary information from a non-monolithic script module that’s necessary to create a monolithic version of the same module. Initially, I wrote some horrendous code to get the necessary information and finally decided that I needed to learn about the Abstract Syntax Tree (AST). Be sure to read the blog articles I’ve written about the AST over the past few weeks:

While there are multiple ways of retrieving the necessary information, the easiest is to simply use the PowerShell Abstract Syntax Tree (AST) otherwise this process could be more convoluted than trying to put Humpty Dumpty back together again.

First, I’ll create a helper function to get all of the AST types. This function is based off of some of the code I wrote in part 3 of my AST blog article series. Normally, I don’t recommend sorting your output from within a function because it slows down receiving the output, but in this case it was an easy way to make sure the returned values are unique.

#Requires -Version 3.0
function Get-MrAstType {
    [CmdletBinding()]
    param ()

    ([System.Management.Automation.Language.ArrayExpressionAst].Assembly.GetTypes() |
    Where-Object {$_.Name.EndsWith('Ast') -and $_.Name -ne 'Ast'}).Name -replace '(?<!^)ExpressionAst$|Ast$' |
    Sort-Object -Unique

}

ast1a.png

Although I’ve shown the results in the previous set of results, the Get-MrAstType function will remain private in the MrModuleBuildTools module. That function is used by my Get-MrAst function. While I could have added those results to a ValidateSet, it would have been a long list of static comma separated values. I decided to use a dynamic parameter to create a dynamic validate set based on the results of Get-MrAstType.

I’ll break the Get-MrAst function down into smaller pieces and elaborate a little about each one. The following section specifies the required version of PowerShell and the function declaration.

#Requires -Version 3.0
function Get-MrAst {

The next part is the function is its comment-based help. Although there’s a push by some to move to Microsoft Assistance Markup Language (MAML) based help, in my opinion, the documentation needs to reside as close as possible to the code otherwise there’s a greater chance it won’t be used or updated and that it could easily become separated if someone just grabs one function instead of the entire module. My recommendation is that if you need multilingual support for your help, use MAML, otherwise use comment-based help. I know, some would argue that MAML also gives you updatable help, but with the PowerShell Gallery, it’s just as easy to release a new minor version of a module to correct bugs in its help. If you’re going to use MAML, definitely check out PlatyPS.

<#
.SYNOPSIS
    Explores the Abstract Syntax Tree (AST).

.DESCRIPTION
    Get-MrAST is an advanced function that provides a mechanism for exploring the Abstract Syntax Tree (AST).

 .PARAMETER Path
    Specifies a path to one or more locations. Wildcards are permitted. The default location is the current directory.

.PARAMETER Code
    The code to view the AST for. If Get-Content is being used to obtain the code, use its -Raw parameter otherwise
    the formating of the code will be lost.

.PARAMETER ScriptBlock
    An instance of System.Management.Automation.ScriptBlock Microsoft .NET Framework type to view the AST for.

.PARAMETER AstType
    The type of object to view the AST for. If this parameter is ommited, only the top level ScriptBlockAst is returned.

.EXAMPLE
     Get-MrAST -Path 'C:\Scripts' -AstType FunctionDefinition

.EXAMPLE
     Get-MrAST -Code 'function Get-PowerShellProcess {Get-Process -Name PowerShell}'

.EXAMPLE
     Get-MrAST -ScriptBlock ([scriptblock]::Create('function Get-PowerShellProcess {Get-Process -Name PowerShell}'))

.NOTES
    Author:  Mike F Robbins
    Website: http://mikefrobbins.com
    Twitter: @mikefrobbins
#>

CmdletBinding is used to turn the function into an advanced function. The default parameter set is defined within CmdletBinding.

[CmdletBinding(DefaultParameterSetName='Path')]

The function has three parameters, each of which are in a different parameter set. One or more Path(s) is accepted via parameter input or via the pipeline. Pipeline input is accepted by both value (type) and property name for this parameter. If no parameters or values are specified, it defaults to all of the PS1 and PSM1 files in the current location. One thing that you may be wondering about is that all of the parameters are set to ValueFromRemainingArguments. That’s a trick I picked up from Stack Overflow where static and dynamic parameters only seem to honor positional binding for parameters of the same type and this was the only way to make it bind the dynamic parameter first.

param(
        [Parameter(ValueFromPipeline,
                   ValueFromPipelineByPropertyName,
                   ValueFromRemainingArguments,
                   ParameterSetName = 'Path',
                   Position = 1)]
        [ValidateNotNull()]
        [Alias('FilePath')]
        [string[]]$Path = ('.\*.ps1', '.\*.psm1'),

The next parameter, which is in a different parameter set, is the Code parameter. It accepts arbitrary code as its input. I originally set the Code parameter to ValidateNotNullOrEmpty instead of Mandatory. When I would retrieve the code from a function with Get-Content, it would generate an error when set to Mandatory. Although changing the parameter to ValidateNotNullOrEmpty made it run without error, the format was off for the results of the AST. The solution (from Rob Campbell on Stack Overflow) was to use the Raw parameter when retrieving the content of a function with Get-Content.

        [Parameter(Mandatory,
                   ValueFromPipelineByPropertyName,
                   ValueFromRemainingArguments,
                   ParameterSetName = 'Code')]
        [string[]]$Code,

The final standard parameter is ScriptBlock. As you could have probably guessed, accepts one or more script blocks. It exists in a separate parameter set from the two parameters.

    [Parameter(Mandatory,
               ValueFromPipelineByPropertyName,
               ValueFromRemainingArguments,
               ParameterSetName = 'ScriptBlock')]
    [scriptblock[]]$ScriptBlock
)

As previously mentioned, a dynamic parameter is used to create a dynamic validate set as this isn’t possible with the ValidateSet parameter validation attribute.

DynamicParam {
    $ParameterAttribute = New-Object -TypeName System.Management.Automation.ParameterAttribute
    $ParameterAttribute.Position = 0

    $ValidationValues = Get-MrAstType
    $ValidateSetAttribute = New-Object -TypeName System.Management.Automation.ValidateSetAttribute($ValidationValues)

    $AttributeCollection = New-Object -TypeName System.Collections.ObjectModel.Collection[System.Attribute]
    $AttributeCollection.Add($ParameterAttribute)
    $AttributeCollection.Add($ValidateSetAttribute)

    $ParameterName = 'AstType'
    $RuntimeParameter = New-Object -TypeName System.Management.Automation.RuntimeDefinedParameter($ParameterName, [string], $AttributeCollection)

    $RuntimeParameterDictionary = New-Object -TypeName System.Management.Automation.RuntimeDefinedParameterDictionary
    $RuntimeParameterDictionary.Add($ParameterName, $RuntimeParameter)
    $RuntimeParameterDictionary
}

The Begin block is simply used to assign whatever value is provided for the AstType parameter to a variable. It’s used later to narrow down the results if that particular parameter is specified.

BEGIN {
    $AstType = $PsBoundParameters[$ParameterName]
}

Last, but not least, there’s the Process block. The parameter set name that’s being used is determined via a switch statement. $PSCmdlet.ParameterSetName is used instead of $PSBoundParameters because the later does not work when using pipeline input, only when parameter input is used.

PROCESS {
    switch ($PSCmdlet.ParameterSetName) {

One of two different .NET static methods is used depending on whether or not the Path or Code parameter set is being used. See the Microsoft documentation for the Parser Class for more information.

If the Path parameter set is used, Get-ChildItem retrieves the FullName for each item (the specified files and/or paths). Then it runs those though a foreach loop and assigns the results from all of them to a variable. The first $null variable is used for the tokens and the second one is for errors. I don’t care about those so I’m sending them directly to the bit bucket by using the $null variable. This is one scenario where if you used actual variable names for those items, you’d need to initialize them first before using them otherwise an error would be generated.

'Path' {
    Write-Verbose -Message 'Path Parameter Set Selected'
    Write-Verbose "Path contains $Path"

    $Files = Get-ChildItem -Path $Path -Exclude *tests.ps1, *profile.ps1 |
             Select-Object -ExpandProperty FullName

    if (-not ($Files)) {
        Write-Warning -Message 'No validate files found.'
        Return
    }

    $AST = foreach ($File in $Files) {
        [System.Management.Automation.Language.Parser]::ParseFile($File, [ref]$null, [ref]$null)
    }

    break
}

The process is similar, but simpler if the Code parameter set is used.

'Code' {
    Write-Verbose -Message 'Code Parameter Set Selected'

    $AST = foreach ($c in $Code) {
        [System.Management.Automation.Language.Parser]::ParseInput($c, [ref]$null, [ref]$null)
    }

    break
}

The process is even simpler if the ScriptBlock parameter set is used. That’s because beginning with PowerShell version 3.0, the AST is already available from a script block via a property named “AST”.

'ScriptBlock' {
    Write-Verbose -Message 'ScriptBlock Parameter Set Selected'

    $AST = $ScriptBlock.Ast

    break
}

I also added a default value so the code always has an execution path. It also let’s me know that my code’s not working properly (if it reaches that point) which has been known to happen from time to time. And finally, the switch statement ends with the closing curly brace.

    default {
        Write-Warning -Message 'An unexpected error has occurred'
    }
}

If the AstType parameter is specified, then the results are narrowed down to only the type specified.

if ($PsBoundParameters.AstType) {
    Write-Verbose -Message 'AstType Parameter Entered'

    $AST = $AST.FindAll({$args[0].GetType().Name -like "*$ASTType*Ast"}, $true)
}

The results are outputted, the Process block is closed, and the function ends.

        Write-Output $AST
    }

}

The Get-MrAst function is shown in its entirety in the following example.

#Requires -Version 3.0
function Get-MrAst {

<#
.SYNOPSIS
    Explores the Abstract Syntax Tree (AST).

.DESCRIPTION
    Get-MrAST is an advanced function that provides a mechanism for exploring the Abstract Syntax Tree (AST).

 .PARAMETER Path
    Specifies a path to one or more locations. Wildcards are permitted. The default location is the current directory.

.PARAMETER Code
    The code to view the AST for. If Get-Content is being used to obtain the code, use its -Raw parameter otherwise
    the formating of the code will be lost.

.PARAMETER ScriptBlock
    An instance of System.Management.Automation.ScriptBlock Microsoft .NET Framework type to view the AST for.

.PARAMETER AstType
    The type of object to view the AST for. If this parameter is ommited, only the top level ScriptBlockAst is returned.

.EXAMPLE
     Get-MrAST -Path 'C:\Scripts' -AstType FunctionDefinition

.EXAMPLE
     Get-MrAST -Code 'function Get-PowerShellProcess {Get-Process -Name PowerShell}'

.EXAMPLE
     Get-MrAST -ScriptBlock ([scriptblock]::Create('function Get-PowerShellProcess {Get-Process -Name PowerShell}'))

.NOTES
    Author:  Mike F Robbins
    Website: http://mikefrobbins.com
    Twitter: @mikefrobbins
#>

    [CmdletBinding(DefaultParameterSetName='Path')]
    param(
        [Parameter(ValueFromPipeline,
                   ValueFromPipelineByPropertyName,
                   ValueFromRemainingArguments,
                   ParameterSetName = 'Path',
                   Position = 1)]
        [ValidateNotNull()]
        [Alias('FilePath')]
        [string[]]$Path = ('.\*.ps1', '.\*.psm1'),

        [Parameter(Mandatory,
                   ValueFromPipelineByPropertyName,
                   ValueFromRemainingArguments,
                   ParameterSetName = 'Code')]
        [string[]]$Code,

        [Parameter(Mandatory,
                   ValueFromPipelineByPropertyName,
                   ValueFromRemainingArguments,
                   ParameterSetName = 'ScriptBlock')]
        [scriptblock[]]$ScriptBlock
    )

    DynamicParam {
        $ParameterAttribute = New-Object -TypeName System.Management.Automation.ParameterAttribute
        $ParameterAttribute.Position = 0

        $ValidationValues = Get-MrAstType
        $ValidateSetAttribute = New-Object -TypeName System.Management.Automation.ValidateSetAttribute($ValidationValues)

        $AttributeCollection = New-Object -TypeName System.Collections.ObjectModel.Collection[System.Attribute]
        $AttributeCollection.Add($ParameterAttribute)
        $AttributeCollection.Add($ValidateSetAttribute)

        $ParameterName = 'AstType'
        $RuntimeParameter = New-Object -TypeName System.Management.Automation.RuntimeDefinedParameter($ParameterName, [string], $AttributeCollection)

        $RuntimeParameterDictionary = New-Object -TypeName System.Management.Automation.RuntimeDefinedParameterDictionary
        $RuntimeParameterDictionary.Add($ParameterName, $RuntimeParameter)
        $RuntimeParameterDictionary
    }

    BEGIN {
        $AstType = $PsBoundParameters[$ParameterName]
    }

    PROCESS {
        switch ($PSCmdlet.ParameterSetName) {
            'Path' {
                Write-Verbose -Message 'Path Parameter Set Selected'
                Write-Verbose "Path contains $Path"

                $Files = Get-ChildItem -Path $Path -Exclude *tests.ps1, *profile.ps1 |
                         Select-Object -ExpandProperty FullName

                if (-not ($Files)) {
                    Write-Warning -Message 'No validate files found.'
                    Return
                }

                $AST = foreach ($File in $Files) {
                    [System.Management.Automation.Language.Parser]::ParseFile($File, [ref]$null, [ref]$null)
                }

                break
            }
            'Code' {
                Write-Verbose -Message 'Code Parameter Set Selected'

                $AST = foreach ($c in $Code) {
                    [System.Management.Automation.Language.Parser]::ParseInput($c, [ref]$null, [ref]$null)
                }

                break
            }
            'ScriptBlock' {
                Write-Verbose -Message 'ScriptBlock Parameter Set Selected'

                $AST = $ScriptBlock.Ast

                break
            }
            default {
                Write-Warning -Message 'An unexpected error has occurred'
            }
        }

        if ($PsBoundParameters.AstType) {
            Write-Verbose -Message 'AstType Parameter Entered'

            $AST = $AST.FindAll({$args[0].GetType().Name -like "*$ASTType*Ast"}, $true)
        }

        Write-Output $AST
    }

}

Now, let’s take a look at the default output from the Get-MrAst function.

Get-MrAst -Path $ModulePath\private\Get-MrAstType.ps1

ast2a.png

Determine the PowerShell version and the modules (if any) specified via the requires statement in the function is simple and there’s no need to try to parse the file with a complicated regular expression.

(Get-MrAst -Path $ModulePath\private\Get-MrAstType.ps1).ScriptRequirements

ast3a.png

Specifying the AstType makes it easy to retrieve the name of the function.

(Get-MrAst -Path $ModulePath\private\Get-MrAstType.ps1 -AstType FunctionDefinition).Name

ast4a.png

As are the contents of the function itself. Notice that this allows you to retrieve the function without the requires statement being included since I’ll want to strip that information out when combining the functions in a PSM1 file.

(Get-MrAst -Path $ModulePath\private\Get-MrAstType.ps1 -AstType FunctionDefinition).Extent.Text

ast5a.png

Those should be all of the items that I’ll need to put the pieces back together.

The development version of the functions shown in this blog article can be downloaded from my ModuleBuildTools repository on GitHub.

µ