Creating AST-Based PowerShell Tools
Over the last few articles, we've been exploring the world of Abstract Syntax Trees (ASTs) in PowerShell. We've seen how to parse PowerShell scripts into ASTs and how to leverage the information in the AST to discover how PowerShell code is structured. I want to return to a scripting perspective and do a little more with a function I showed in an earlier article that will get script requirement information.
#requires -version 5.1
Function Get-RequiredMetadata {
[cmdletbinding()]
[OutputType('ScriptRequirementMetadata')]
Param(
[Parameter(
Position = 0,
Mandatory,
ValueFromPipeline,
HelpMessage = 'The path to the .PS1 script file'
)]
[ValidatePattern('.*\.ps1$')]
[ValidateScript({ Test-Path $_ })]
[string]$Path
)
Begin {
Write-Verbose "Starting $($MyInvocation.MyCommand)"
New-Variable astTokens -Force
New-Variable astErr -Force
$progSplat = @{
Activity = $MyInvocation.MyCommand
Status = 'Starting'
}
Write-Progress @progSplat
}
Process {
Write-Verbose "Processing $Path"
$progSplat.Status = "Processing $Path"
Write-Progress @progSplat
$AST = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$astTokens, [ref]$astErr)
$hash = [ordered]@{
PSTypeName = 'ScriptRequirementMetadata'
Path = $Path
RequiredVersion = $Null
RequiresAdmin = $False
IsCore = $False
LastWriteTime = (Get-Item $Path).LastWriteTime
}
If ($AST.ScriptRequirements) {
#save requiredVersion as a string if found
if ($AST.ScriptRequirements.RequiredPSVersion -match '\d') {
$hash.RequiredVersion = $AST.ScriptRequirements.RequiredPSVersion -as [string]
}
$hash.RequiresAdmin = $AST.ScriptRequirements.IsElevationRequired
if ($ast.ScriptRequirements.RequiredPSVersion -gt '5.1') {
$hash.IsCore = $True
}
}
#write a custom object to the pipeline
[PSCustomObject]$hash
}
End {
$progSplat['Completed'] = $True
Write-Progress @progSplat
Write-Verbose "Ending $($MyInvocation.MyCommand)"
}
}
This function uses the AST to create custom object that contains information about the script file's requirements.
PS C:\> Get-RequiredMetadata C:\scripts\SharedProfileDefault.ps1
Path : C:\scripts\SharedProfileDefault.ps1
RequiredVersion : 5.1
RequiresAdmin : False
IsCore : False
LastWriteTime : 12/12/2024 3:05:16 PM
Let's take this idea and run with it. You could use the concepts and techniques with other scripting projects, not just AST-related commands.
Defining a Class
My function writes an object to the pipeline. I am going to convert this to a PowerShell class.
Class ScriptRequirementMetadata {
[string]$Path
[string]$RequiredVersion
[bool]$RequiresAdmin
[string[]]$RequiredModules
[bool]$IsCore
[bool]$IsEmpty
[datetime]$LastWriteTime
[string]$ComputerName = [System.Environment]::MachineName
ScriptRequirementMetadata($Path) {
$this.Path = $Path
$this.LastWriteTime = (Get-Item $Path).LastWriteTime
$ast = _getAST -Path $Path
$this.RequiredVersion = $ast.RequiredVersion
$this.RequiresAdmin = $ast.RequiresAdmin
$this.RequiredModules = $ast.RequiredModules
$this.IsEmpty = ($this.RequiredModules.Count -eq 0) -AND ($null -eq $this.RequiredVersion) -AND (-not $this.RequiresAdmin)
$this.IsCore = [version]$ast.RequiredVersion -gt [version]'5.1'
}
} #class
I've added a few more properties to the class, such as required modules, the computername, and if there are any requirements at all. I can validate the properties with the Get-TypeMember
command.
PS C:\> Get-TypeMember ScriptRequirementMetadata
Type: ScriptRequirementMetadata
Name MemberType ResultType IsStatic IsEnum
---- ---------- ---------- -------- ------
GetType Method Type
ComputerName Property String
IsCore Property Boolean
IsEmpty Property Boolean
LastWriteTime Property DateTime
Path Property String
RequiredModules Property String[]
RequiredVersion Property String
RequiresAdmin Property Boolean
The constructor only needs a file path.
PS D:\OneDrive...\AST-Intro\> [ScriptRequirementMetadata]::new.OverloadDefinitions
ScriptRequirementMetadata new(System.Object Path)
Instead of hard-coding the AST code in the constructor, I have defined it in a private helper function.
Function _getAST {
Param([string]$Path)
New-Variable astTokens -Force
New-Variable astErr -Force
$AST = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$astTokens, [ref]$astErr)
$hash = @{
RequiredVersion = $Null
RequiresAdmin = $False
RequiredModules = @()
}
If ($AST.ScriptRequirements) {
#save requiredVersion as a string if found
if ($AST.ScriptRequirements.RequiredPSVersion -match '\d') {
$hash.RequiredVersion = $AST.ScriptRequirements.RequiredPSVersion -as [string]
}
$hash.RequiresAdmin = $AST.ScriptRequirements.IsElevationRequired
$hash.RequiredModules = $AST.ScriptRequirements.RequiredModules.Name
}
$hash
}
This approach lets me update the AST code without touching the class. It also makes it easier to Pester test because I can mock the _getAST
function. I'll run a simple test.
PS C:\> [ScriptRequirementMetadata]::new($profile.CurrentUserAllHosts)
Path : C:\Users\Jeff\Documents\PowerShell\profile.ps1
RequiredVersion : 7.4
RequiresAdmin : False
RequiredModules :
IsCore : True
IsEmpty : False
LastWriteTime : 10/5/2020 3:50:19 PM
ComputerName : JEFF11
Defining Usage
To see how I might use this, I can prototype a command in the console.
$t = dir c:\scripts\*.ps1 | Get-Random -Count 5 |
ForEach-Object {
[ScriptRequirementMetadata]::new($_.FullName)
}
I assume I would want to pipe a bunch of files to something that will create the desired output.
PS C:\> $t
Path : C:\scripts\Get-MyADComputerAccount.ps1
RequiredVersion :
RequiresAdmin : False
RequiredModules : {}
IsCore : False
IsEmpty : True
LastWriteTime : 10/15/2014 8:43:09 AM
ComputerName : JEFF11
Path : C:\scripts\Get-ZeroSize.ps1
RequiredVersion :
RequiresAdmin : False
RequiredModules : {CIMCmdlets}
IsCore : False
IsEmpty : False
LastWriteTime : 4/4/2022 1:30:18 PM
ComputerName : JEFF11
Path : C:\scripts\JDH-Functions.ps1
RequiredVersion : 5.1
RequiresAdmin : False
RequiredModules :
IsCore : False
IsEmpty : False
LastWriteTime : 12/28/2024 10:35:41 AM
ComputerName : JEFF11
Path : C:\scripts\dev-WPFDataBinding2.ps1
RequiredVersion :
RequiresAdmin : False
RequiredModules : {}
IsCore : False
IsEmpty : True
LastWriteTime : 9/29/2016 3:30:32 PM
ComputerName : JEFF11
Path : C:\scripts\new-Nano.ps1
RequiredVersion :
RequiresAdmin : False
RequiredModules : {}
IsCore : False
IsEmpty : True
LastWriteTime : 4/19/2018 4:11:26 PM
ComputerName : JEFF11
The custom object makes it easy to sort and filter the results.
PS C:\> $t | Where IsEmpty | Select Path,LastWriteTime
Path LastWriteTime
---- -------------
C:\scripts\Get-MyADComputerAccount.ps1 10/15/2014 8:43:09 AM
C:\scripts\dev-WPFDataBinding2.ps1 9/29/2016 3:30:32 PM
C:\scripts\new-Nano.ps1 4/19/2018 4:11:26 PM
These scripts have no requirements.
Function Wrappers
I don't want to be forced to use the class directly. The better approach is to create a function that will use the class. The function let's me add features like verbose output and parameter validation. The ScriptRequirementMetadata
only makes sense with PowerShell script files. Instead of validating the path in the class, or even the helper function, I can do it in the wrapper function.
#requires -version 7.4
Function New-ScriptRequirementMetadata {
[cmdletbinding()]
[OutputType('ScriptRequirementMetadata')]
[Alias('srm')]
Param(
[Parameter(
Position = 0,
Mandatory,
ValueFromPipeline,
HelpMessage = 'The path to a .PS1 or .PSM1 script file.'
)]
[ValidatePattern('.*\.ps(m)?1$',ErrorMessage = 'The file must have a .PS1 or .PSM1 extension')]
[ValidateScript({ Test-Path $_ },ErrorMessage = 'Cannot verify the path to {0}')]
[string]$Path
)
Begin {
Write-Verbose "Starting $($MyInvocation.MyCommand)"
$progSplat = @{
Activity = $MyInvocation.MyCommand
Status = 'Starting'
}
Write-Progress @progSplat
}
Process {
Write-Verbose "Processing $Path"
$progSplat.Status = "Processing $Path"
Write-Progress @progSplat
[ScriptRequirementMetadata]::new($Path)
}
End {
$progSplat['Completed'] = $True
Write-Progress @progSplat
Write-Verbose "Ending $($MyInvocation.MyCommand)"
}
}
The function requires PowerShell 7 because I am using custom error messages for parameter validation failures.
PS C:\> New-ScriptRequirementMetadata NotReal.ps1
New-ScriptRequirementMetadata: Cannot validate argument on parameter 'Path'. Cannot verify the path to NotReal.ps1
PS C:\> New-ScriptRequirementMetadata C:\scripts\sample-docker-compose.yml
New-ScriptRequirementMetadata: Cannot validate argument on parameter 'Path'. The file must have a .PS1 or .PSM1 extension
PS C:\> srm C:\scripts\New-ToastAlarm.ps1
Path : C:\scripts\New-ToastAlarm.ps1
RequiredVersion : 5.1
RequiresAdmin : False
RequiredModules : {BurntToast, PSScheduledJob}
IsCore : False
IsEmpty : False
LastWriteTime : 5/28/2024 8:39:24 AM
ComputerName : JEFF11
The last example is using the function's alias. The function also let's me add features like progress bars.
See how easy this is to use now?
PS C:\> dir c:\scripts\*.ps1 | srm | group IsEmpty
Count Name Group
----- ---- -----
1399 False {ScriptRequirementMetadata, ScriptRequirementMetadata, ScriptRequir…
2006 True {ScriptRequirementMetadata, ScriptRequirementMetadata, ScriptRequir…
I can easily save my script requirements metadata to a file.
dir c:\scripts\*.ps1 | srm | ConvertTo-Json | Out-File c:\scripts\srm.json
To bring this data back into PowerShell I can use code like this:
$srm = Get-Content c:\scripts\srm.json | ConvertFrom-Json |
Foreach-Object {
$_.PSObject.TypeNames.Insert(0,'ScriptRequirementMetadata')
#write the updated object back to the pipeline
$_
}
Normally, ConvertFrom-Json
would create a PSCustomObject
. I am using the PSObject.TypeNames.Insert
method to convert the object to my custom class. I don't have one yet, but this would make a difference if I were using a custom formatting file.
The final step to all of this would be to wrap everything into a module.
Summary
I hope you'll try the code out for yourself. I have one other AST-related script project I want to share, and I'll do that next time.