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:
- Learning about the PowerShell Abstract Syntax Tree (AST)
- Learn about the PowerShell Abstract Syntax Tree (AST) – Part 2
- Learn about the PowerShell Abstract Syntax Tree (AST) – Part 3
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.
1#Requires -Version 3.0
2function Get-MrAstType {
3 [CmdletBinding()]
4 param ()
5
6 ([System.Management.Automation.Language.ArrayExpressionAst].Assembly.GetTypes() |
7 Where-Object {$_.Name.EndsWith('Ast') -and $_.Name -ne 'Ast'}).Name -replace '(?<!^)ExpressionAst$|Ast$' |
8 Sort-Object -Unique
9
10}
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.
1#Requires -Version 3.0
2function 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.
1<#
2.SYNOPSIS
3 Explores the Abstract Syntax Tree (AST).
4
5.DESCRIPTION
6 Get-MrAST is an advanced function that provides a mechanism for exploring the Abstract Syntax Tree (AST).
7
8 .PARAMETER Path
9 Specifies a path to one or more locations. Wildcards are permitted. The default location is the current directory.
10
11.PARAMETER Code
12 The code to view the AST for. If Get-Content is being used to obtain the code, use its -Raw parameter otherwise
13 the formating of the code will be lost.
14
15.PARAMETER ScriptBlock
16 An instance of System.Management.Automation.ScriptBlock Microsoft .NET Framework type to view the AST for.
17
18.PARAMETER AstType
19 The type of object to view the AST for. If this parameter is ommited, only the top level ScriptBlockAst is returned.
20
21.EXAMPLE
22 Get-MrAST -Path 'C:\Scripts' -AstType FunctionDefinition
23
24.EXAMPLE
25 Get-MrAST -Code 'function Get-PowerShellProcess {Get-Process -Name PowerShell}'
26
27.EXAMPLE
28 Get-MrAST -ScriptBlock ([scriptblock]::Create('function Get-PowerShellProcess {Get-Process -Name PowerShell}'))
29
30.NOTES
31 Author: Mike F Robbins
32 Website: http://mikefrobbins.com
33 Twitter: @mikefrobbins
34#>
CmdletBinding is used to turn the function into an advanced function. The default parameter set is defined within CmdletBinding.
1[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.
1param(
2 [Parameter(ValueFromPipeline,
3 ValueFromPipelineByPropertyName,
4 ValueFromRemainingArguments,
5 ParameterSetName = 'Path',
6 Position = 1)]
7 [ValidateNotNull()]
8 [Alias('FilePath')]
9 [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
.
1 [Parameter(Mandatory,
2 ValueFromPipelineByPropertyName,
3 ValueFromRemainingArguments,
4 ParameterSetName = 'Code')]
5 [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.
1 [Parameter(Mandatory,
2 ValueFromPipelineByPropertyName,
3 ValueFromRemainingArguments,
4 ParameterSetName = 'ScriptBlock')]
5 [scriptblock[]]$ScriptBlock
6)
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.
1DynamicParam {
2 $ParameterAttribute = New-Object -TypeName System.Management.Automation.ParameterAttribute
3 $ParameterAttribute.Position = 0
4
5 $ValidationValues = Get-MrAstType
6 $ValidateSetAttribute = New-Object -TypeName System.Management.Automation.ValidateSetAttribute($ValidationValues)
7
8 $AttributeCollection = New-Object -TypeName System.Collections.ObjectModel.Collection[System.Attribute]
9 $AttributeCollection.Add($ParameterAttribute)
10 $AttributeCollection.Add($ValidateSetAttribute)
11
12 $ParameterName = 'AstType'
13 $RuntimeParameter = New-Object -TypeName System.Management.Automation.RuntimeDefinedParameter($ParameterName, [string], $AttributeCollection)
14
15 $RuntimeParameterDictionary = New-Object -TypeName System.Management.Automation.RuntimeDefinedParameterDictionary
16 $RuntimeParameterDictionary.Add($ParameterName, $RuntimeParameter)
17 $RuntimeParameterDictionary
18}
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.
1BEGIN {
2 $AstType = $PsBoundParameters[$ParameterName]
3}
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.
1PROCESS {
2 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.
1'Path' {
2 Write-Verbose -Message 'Path Parameter Set Selected'
3 Write-Verbose "Path contains $Path"
4
5 $Files = Get-ChildItem -Path $Path -Exclude *tests.ps1, *profile.ps1 |
6 Select-Object -ExpandProperty FullName
7
8 if (-not ($Files)) {
9 Write-Warning -Message 'No validate files found.'
10 Return
11 }
12
13 $AST = foreach ($File in $Files) {
14 [System.Management.Automation.Language.Parser]::ParseFile($File, [ref]$null, [ref]$null)
15 }
16
17 break
18}
The process is similar, but simpler if the Code
parameter set is used.
1'Code' {
2 Write-Verbose -Message 'Code Parameter Set Selected'
3
4 $AST = foreach ($c in $Code) {
5 [System.Management.Automation.Language.Parser]::ParseInput($c, [ref]$null, [ref]$null)
6 }
7
8 break
9}
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".
1'ScriptBlock' {
2 Write-Verbose -Message 'ScriptBlock Parameter Set Selected'
3
4 $AST = $ScriptBlock.Ast
5
6 break
7}
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.
1 default {
2 Write-Warning -Message 'An unexpected error has occurred'
3 }
4}
If the AstType
parameter is specified, then the results are narrowed down to only the type
specified.
1if ($PsBoundParameters.AstType) {
2 Write-Verbose -Message 'AstType Parameter Entered'
3
4 $AST = $AST.FindAll({$args[0].GetType().Name -like "*$ASTType*Ast"}, $true)
5}
The results are outputted, the Process
block is closed, and the function ends.
1 Write-Output $AST
2 }
3
4}
The Get-MrAst
function is shown in its entirety in the following example.
1#Requires -Version 3.0
2function Get-MrAst {
3
4<#
5.SYNOPSIS
6 Explores the Abstract Syntax Tree (AST).
7
8.DESCRIPTION
9 Get-MrAST is an advanced function that provides a mechanism for exploring the Abstract Syntax Tree (AST).
10
11 .PARAMETER Path
12 Specifies a path to one or more locations. Wildcards are permitted. The default location is the current directory.
13
14.PARAMETER Code
15 The code to view the AST for. If Get-Content is being used to obtain the code, use its -Raw parameter otherwise
16 the formating of the code will be lost.
17
18.PARAMETER ScriptBlock
19 An instance of System.Management.Automation.ScriptBlock Microsoft .NET Framework type to view the AST for.
20
21.PARAMETER AstType
22 The type of object to view the AST for. If this parameter is ommited, only the top level ScriptBlockAst is returned.
23
24.EXAMPLE
25 Get-MrAST -Path 'C:\Scripts' -AstType FunctionDefinition
26
27.EXAMPLE
28 Get-MrAST -Code 'function Get-PowerShellProcess {Get-Process -Name PowerShell}'
29
30.EXAMPLE
31 Get-MrAST -ScriptBlock ([scriptblock]::Create('function Get-PowerShellProcess {Get-Process -Name PowerShell}'))
32
33.NOTES
34 Author: Mike F Robbins
35 Website: http://mikefrobbins.com
36 Twitter: @mikefrobbins
37#>
38
39 [CmdletBinding(DefaultParameterSetName='Path')]
40 param(
41 [Parameter(ValueFromPipeline,
42 ValueFromPipelineByPropertyName,
43 ValueFromRemainingArguments,
44 ParameterSetName = 'Path',
45 Position = 1)]
46 [ValidateNotNull()]
47 [Alias('FilePath')]
48 [string[]]$Path = ('.\*.ps1', '.\*.psm1'),
49
50 [Parameter(Mandatory,
51 ValueFromPipelineByPropertyName,
52 ValueFromRemainingArguments,
53 ParameterSetName = 'Code')]
54 [string[]]$Code,
55
56 [Parameter(Mandatory,
57 ValueFromPipelineByPropertyName,
58 ValueFromRemainingArguments,
59 ParameterSetName = 'ScriptBlock')]
60 [scriptblock[]]$ScriptBlock
61 )
62
63 DynamicParam {
64 $ParameterAttribute = New-Object -TypeName System.Management.Automation.ParameterAttribute
65 $ParameterAttribute.Position = 0
66
67 $ValidationValues = Get-MrAstType
68 $ValidateSetAttribute = New-Object -TypeName System.Management.Automation.ValidateSetAttribute($ValidationValues)
69
70 $AttributeCollection = New-Object -TypeName System.Collections.ObjectModel.Collection[System.Attribute]
71 $AttributeCollection.Add($ParameterAttribute)
72 $AttributeCollection.Add($ValidateSetAttribute)
73
74 $ParameterName = 'AstType'
75 $RuntimeParameter = New-Object -TypeName System.Management.Automation.RuntimeDefinedParameter($ParameterName, [string], $AttributeCollection)
76
77 $RuntimeParameterDictionary = New-Object -TypeName System.Management.Automation.RuntimeDefinedParameterDictionary
78 $RuntimeParameterDictionary.Add($ParameterName, $RuntimeParameter)
79 $RuntimeParameterDictionary
80 }
81
82 BEGIN {
83 $AstType = $PsBoundParameters[$ParameterName]
84 }
85
86 PROCESS {
87 switch ($PSCmdlet.ParameterSetName) {
88 'Path' {
89 Write-Verbose -Message 'Path Parameter Set Selected'
90 Write-Verbose "Path contains $Path"
91
92 $Files = Get-ChildItem -Path $Path -Exclude *tests.ps1, *profile.ps1 |
93 Select-Object -ExpandProperty FullName
94
95 if (-not ($Files)) {
96 Write-Warning -Message 'No validate files found.'
97 Return
98 }
99
100 $AST = foreach ($File in $Files) {
101 [System.Management.Automation.Language.Parser]::ParseFile($File, [ref]$null, [ref]$null)
102 }
103
104 break
105 }
106 'Code' {
107 Write-Verbose -Message 'Code Parameter Set Selected'
108
109 $AST = foreach ($c in $Code) {
110 [System.Management.Automation.Language.Parser]::ParseInput($c, [ref]$null, [ref]$null)
111 }
112
113 break
114 }
115 'ScriptBlock' {
116 Write-Verbose -Message 'ScriptBlock Parameter Set Selected'
117
118 $AST = $ScriptBlock.Ast
119
120 break
121 }
122 default {
123 Write-Warning -Message 'An unexpected error has occurred'
124 }
125 }
126
127 if ($PsBoundParameters.AstType) {
128 Write-Verbose -Message 'AstType Parameter Entered'
129
130 $AST = $AST.FindAll({$args[0].GetType().Name -like "*$ASTType*Ast"}, $true)
131 }
132
133 Write-Output $AST
134 }
135
136}
Now, let's take a look at the default output from the Get-MrAst
function.
1Get-MrAst -Path $ModulePath\private\Get-MrAstType.ps1
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.
1(Get-MrAst -Path $ModulePath\private\Get-MrAstType.ps1).ScriptRequirements
Specifying the AstType makes it easy to retrieve the name of the function.
1(Get-MrAst -Path $ModulePath\private\Get-MrAstType.ps1 -AstType FunctionDefinition).Name
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.
1(Get-MrAst -Path $ModulePath\private\Get-MrAstType.ps1 -AstType FunctionDefinition).Extent.Text
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.
µ