PowerShell's Abstract Syntax Tree (AST) is a powerful yet underutilized feature that opens up sophisticated possibilities for code analysis, transformation, and tooling development. If you've ever wondered how PowerShell development tools work under the hood or wanted to build your own code analysis capabilities, understanding the AST is essential.
What is PowerShell AST?
The Abstract Syntax Tree represents PowerShell code as a hierarchical tree structure of objects, where each node corresponds to a different element of the code—commands, parameters, variables, expressions, and more. Rather than treating code as simple text, the AST provides a structured representation that makes it possible to programmatically inspect, analyze, and modify PowerShell scripts with precision.
Think of it as PowerShell's way of understanding your code the same way you do, but in a format that programs can easily work with.
Key Components
When working with PowerShell AST, you'll primarily interact with these essential classes:
System.Management.Automation.Language.Parser serves as your entry point for parsing code into AST format. This is where the magic begins—transforming raw PowerShell text into structured objects.
System.Management.Automation.Language.Ast acts as the base class for all AST nodes, providing common functionality and properties that all nodes share.
Specific AST node types include CommandAst
for command invocations, ParameterAst
for parameters, VariableExpressionAst
for variable references, ScriptBlockAst
for script blocks, and dozens of other specialized types that represent different language constructs.
Real-World Use Cases
The AST shines in several practical scenarios that every PowerShell developer encounters:
Code Analysis becomes surgical when you can traverse the AST to find security vulnerabilities, code quality issues, or gather detailed metrics about your scripts. Instead of using regex patterns that might miss edge cases, you can definitively identify all variables, commands, or parameters used throughout your codebase.
Static Analysis Tools leverage the AST to build sophisticated linters and code analyzers that examine PowerShell scripts without executing them. This enables safe analysis of potentially dangerous code and integration into CI/CD pipelines.
Code Transformation scenarios include automatically refactoring scripts, updating deprecated cmdlets across large codebases, renaming variables consistently, or injecting logging and monitoring code into existing scripts.
Security Scanning becomes more reliable when you can identify potentially dangerous operations, hunt for hardcoded credentials, or detect suspicious patterns with the precision that only comes from understanding code structure rather than just text patterns.
Documentation Generation can extract function definitions, parameters, help text, and usage examples to automatically generate comprehensive documentation for your PowerShell modules and scripts.
Basic Example: Getting Started
Here's how you can begin exploring PowerShell AST:
# Parse a simple script
$code = 'Get-Process | Where-Object {$_.CPU -gt 100}'
$ast = [System.Management.Automation.Language.Parser]::ParseInput($code, [ref]$null, [ref]$null)
# Find all command elements
$commands = $ast.FindAll({$args[0] -is [System.Management.Automation.Language.CommandAst]}, $true)
# Display the commands found
$commands | ForEach-Object {
Write-Host "Found command: $($_.GetCommandName())"
}
This simple example demonstrates the fundamental pattern: parse code into an AST, then use the FindAll
method with a filter predicate to locate specific types of nodes.
Advanced Example: Finding Matching Braces
One particularly useful application of AST analysis is finding matching pairs of braces, which is essential for code editors, formatters, and refactoring tools. Here's a comprehensive implementation that uses token analysis for precise brace matching:
function Find-MatchingBrace {
param(
[string]$Code,
[int]$Position
)
# First check if the position actually contains a brace
if ($Position -ge $Code.Length -or ($Code[$Position] -ne '{' -and $Code[$Position] -ne '}')) {
Write-Host "Position $Position does not contain a brace character" -ForegroundColor Red
return $null
}
# Parse the code into AST
$tokens = $null
$errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseInput($Code, [ref]$tokens, [ref]$errors)
# Find all tokens that are braces
$braceTokens = $tokens | Where-Object {
$_.Kind -eq [System.Management.Automation.Language.TokenKind]::LCurly -or
$_.Kind -eq [System.Management.Automation.Language.TokenKind]::RCurly
}
Write-Host "Debug: Found $($braceTokens.Count) brace tokens" -ForegroundColor Cyan
foreach ($token in $braceTokens) {
$braceChar = if ($token.Kind -eq [System.Management.Automation.Language.TokenKind]::LCurly) { "{" } else { "}" }
$color = if ($token.Kind -eq [System.Management.Automation.Language.TokenKind]::LCurly) { "Green" } else { "Magenta" }
Write-Host " Token at position $($token.Extent.StartOffset): '$braceChar' (Kind: $($token.Kind))" -ForegroundColor $color
}
# Create a stack to match braces
$braceStack = @()
$braceMap = @{}
foreach ($token in $braceTokens | Sort-Object { $_.Extent.StartOffset }) {
$tokenPos = $token.Extent.StartOffset
if ($token.Kind -eq [System.Management.Automation.Language.TokenKind]::LCurly) {
# Opening brace - push to stack
$braceStack += $tokenPos
}
elseif ($token.Kind -eq [System.Management.Automation.Language.TokenKind]::RCurly) {
# Closing brace - pop from stack and create mapping
if ($braceStack.Count -gt 0) {
$matchingOpen = $braceStack[-1]
$braceStack = $braceStack[0..($braceStack.Count-2)]
# Create bidirectional mapping
$braceMap[$matchingOpen] = $tokenPos
$braceMap[$tokenPos] = $matchingOpen
}
}
}
Write-Host "Debug: Brace mapping:" -ForegroundColor Cyan
foreach ($key in $braceMap.Keys | Sort-Object) {
$keyChar = $Code[$key]
$valueChar = $Code[$braceMap[$key]]
if ($keyChar -eq '{') {
Write-Host " Position " -NoNewline
Write-Host "$key" -ForegroundColor Green -NoNewline
Write-Host " ($keyChar) -> Position " -NoNewline
Write-Host "$($braceMap[$key])" -ForegroundColor Magenta -NoNewline
Write-Host " ($valueChar)"
}
}
# Check if our position has a match
if ($braceMap.ContainsKey($Position)) {
$matchingPos = $braceMap[$Position]
$isOpening = $Code[$Position] -eq '{'
# Find the AST node that contains this brace for additional context
$containingNode = $ast.FindAll({
$node = $args[0]
$node.Extent.StartOffset -le $Position -and $node.Extent.EndOffset -gt $Position
}, $true) | Where-Object {
$_ -is [System.Management.Automation.Language.ScriptBlockAst] -or
$_ -is [System.Management.Automation.Language.ScriptBlockExpressionAst] -or
$_ -is [System.Management.Automation.Language.HashtableAst] -or
$_ -is [System.Management.Automation.Language.ArrayLiteralAst]
} | Select-Object -First 1
return @{
Type = if ($isOpening) { 'Opening' } else { 'Closing' }
Position = $Position
MatchingPosition = $matchingPos
BlockType = if ($containingNode) { $containingNode.GetType().Name } else { 'Unknown' }
Character = $Code[$Position]
MatchingCharacter = $Code[$matchingPos]
}
}
return $null
}
# Helper function to find all brace positions
function Find-BracePositions {
param([string]$Code)
Write-Host "Code with position markers:" -ForegroundColor Yellow
Write-Host "==========================" -ForegroundColor Yellow
# Show the code with position numbers
$lines = $Code -split "`n"
$position = 0
for ($lineNum = 0; $lineNum -lt $lines.Count; $lineNum++) {
$line = $lines[$lineNum]
Write-Host ("Line {0,2}: " -f ($lineNum + 1)) -ForegroundColor Gray -NoNewline
Write-Host $line -ForegroundColor White
# Show positions of braces in this line
for ($charPos = 0; $charPos -lt $line.Length; $charPos++) {
if ($line[$charPos] -eq '{' -or $line[$charPos] -eq '}') {
$absolutePos = $position + $charPos
$braceColor = if ($line[$charPos] -eq '{') { "Green" } else { "Magenta" }
Write-Host " Brace " -ForegroundColor Gray -NoNewline
Write-Host "'$($line[$charPos])'" -ForegroundColor $braceColor -NoNewline
Write-Host " at position " -ForegroundColor Gray -NoNewline
Write-Host "$absolutePos" -ForegroundColor $braceColor
}
}
$position += $line.Length + 1 # +1 for newline character
}
Write-Host ""
}
# Example usage
$sampleCode = @'
Get-Process | Where-Object {
$_.CPU -gt 100 -and
$_.WorkingSet -gt 50MB
} | ForEach-Object {
Write-Host "High CPU process: $($_.Name)"
}
'@
Write-Host "Sample code:" -ForegroundColor Yellow
Write-Host $sampleCode -ForegroundColor White
Write-Host ""
# First, let's see where the braces actually are
Find-BracePositions -Code $sampleCode
Write-Host "Testing brace matching:" -ForegroundColor Yellow
Write-Host "=====================" -ForegroundColor Yellow
# Find all brace positions and test them
for ($i = 0; $i -lt $sampleCode.Length; $i++) {
if ($sampleCode[$i] -eq '{' -or $sampleCode[$i] -eq '}') {
$braceColor = if ($sampleCode[$i] -eq '{') { "Green" } else { "Magenta" }
Write-Host "Testing position " -ForegroundColor Gray -NoNewline
Write-Host "$i" -ForegroundColor $braceColor -NoNewline
Write-Host " (character: " -ForegroundColor Gray -NoNewline
Write-Host "'$($sampleCode[$i])'" -ForegroundColor $braceColor -NoNewline
Write-Host ")" -ForegroundColor Gray
$result = Find-MatchingBrace -Code $sampleCode -Position $i
if ($result) {
$matchColor = if ($result.MatchingCharacter -eq '{') { "Green" } else { "Magenta" }
Write-Host " âś“ Found " -ForegroundColor Green -NoNewline
Write-Host "$($result.Type)" -ForegroundColor $braceColor -NoNewline
Write-Host " brace at position " -ForegroundColor Green -NoNewline
Write-Host "$($result.Position)" -ForegroundColor $braceColor
Write-Host " âś“ Matching brace is at position " -ForegroundColor Green -NoNewline
Write-Host "$($result.MatchingPosition)" -ForegroundColor $matchColor
Write-Host " âś“ Block type: " -ForegroundColor Green -NoNewline
Write-Host "$($result.BlockType)" -ForegroundColor Cyan
} else {
Write-Host " âś— No match found" -ForegroundColor Red
}
Write-Host ""
}
}
This example demonstrates the power of using both AST analysis and token parsing together. The token approach provides precise character positions, while the AST gives us the semantic context of what type of block contains each brace.
Why AST Matters
The AST is particularly valuable for building PowerShell development tools, automated code review systems, and advanced scripting scenarios where you need to work with code as structured data rather than just executing it. Whether you're building the next great PowerShell IDE extension, creating organization-wide code standards enforcement, or developing sophisticated deployment automation, the AST provides the foundation for reliable, precise code manipulation.
The key insight is that the AST transforms code from text that humans read into data structures that programs can efficiently process, opening up possibilities that would be difficult or impossible with traditional text-based approaches.
Large Script Analysis: Two Essential Examples
When dealing with scripts that exceed 1500 lines, manual analysis becomes impractical. Here are two powerful examples that can save hours of work on large codebases:
Example 1: Finding Unused Variables and Functions
Large scripts often accumulate unused variables and functions over time. This analyzer identifies dead code automatically:
function Find-UnusedCode {
param(
[Parameter(ParameterSetName = 'File')]
[string]$ScriptPath,
[Parameter(ParameterSetName = 'String')]
[string]$ScriptContent,
[switch]$AnalyzeSelf
)
try {
# Determine what to analyze
if ($AnalyzeSelf) {
$content = $MyInvocation.ScriptName | Get-Content -Raw -ErrorAction Stop
$sourceName = "Current Script"
}
elseif ($PSCmdlet.ParameterSetName -eq 'File') {
if (-not (Test-Path $ScriptPath)) {
Write-Host "Error: File '$ScriptPath' does not exist." -ForegroundColor Red
return $null
}
$content = Get-Content -Path $ScriptPath -Raw -ErrorAction Stop
$sourceName = Split-Path $ScriptPath -Leaf
}
elseif ($PSCmdlet.ParameterSetName -eq 'String') {
$content = $ScriptContent
$sourceName = "Provided Script Content"
}
else {
Write-Host "Error: Must specify either -ScriptPath, -ScriptContent, or -AnalyzeSelf" -ForegroundColor Red
return $null
}
if ([string]::IsNullOrWhiteSpace($content)) {
Write-Host "Error: Script content is empty or null." -ForegroundColor Red
return $null
}
Write-Host "Analyzing: $sourceName" -ForegroundColor Cyan
Write-Host "Content length: $($content.Length) characters" -ForegroundColor Gray
# Parse the content
$tokens = $null
$errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseInput($content, [ref]$tokens, [ref]$errors)
if ($errors.Count -gt 0) {
Write-Host "Warning: Found $($errors.Count) parse errors:" -ForegroundColor Yellow
foreach ($error in $errors) {
Write-Host " - Line $($error.Extent.StartLineNumber): $($error.Message)" -ForegroundColor Yellow
}
}
# Find all variable assignments
$variableAssignments = $ast.FindAll({
$args[0] -is [System.Management.Automation.Language.AssignmentStatementAst]
}, $true)
Write-Host "Found $($variableAssignments.Count) variable assignments" -ForegroundColor Gray
# Find all variable references
$variableReferences = $ast.FindAll({
$args[0] -is [System.Management.Automation.Language.VariableExpressionAst]
}, $true)
Write-Host "Found $($variableReferences.Count) variable references" -ForegroundColor Gray
# Find all function definitions
$functionDefinitions = $ast.FindAll({
$args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst]
}, $true)
Write-Host "Found $($functionDefinitions.Count) function definitions" -ForegroundColor Gray
# Find all function calls (commands)
$functionCalls = $ast.FindAll({
$args[0] -is [System.Management.Automation.Language.CommandAst]
}, $true)
Write-Host "Found $($functionCalls.Count) command calls" -ForegroundColor Gray
$results = @{
UnusedVariables = @()
UnusedFunctions = @()
Statistics = @{}
SourceName = $sourceName
ParseErrors = $errors
}
# Analyze variables
$assignedVariables = @()
foreach ($assignment in $variableAssignments) {
if ($assignment.Left -is [System.Management.Automation.Language.VariableExpressionAst]) {
$varName = $assignment.Left.VariablePath.UserPath
$assignedVariables += @{
Name = $varName
Line = $assignment.Extent.StartLineNumber
Position = $assignment.Extent.StartOffset
}
}
}
Write-Host "Debug - Assigned variables:" -ForegroundColor Magenta
foreach ($var in $assignedVariables) {
Write-Host " $($var.Name) (Line $($var.Line))" -ForegroundColor Magenta
}
$referencedVariables = @()
foreach ($reference in $variableReferences) {
$varName = $reference.VariablePath.UserPath
$referencedVariables += $varName
}
$uniqueReferencedVars = $referencedVariables | Select-Object -Unique
Write-Host "Debug - Referenced variables:" -ForegroundColor Magenta
foreach ($var in $uniqueReferencedVars) {
Write-Host " $var" -ForegroundColor Magenta
}
# Find unused variables
$unusedVars = @()
foreach ($assignedVar in $assignedVariables) {
$varName = $assignedVar.Name
# Exclude automatic variables, parameters, and commonly used variables
$excludedVars = @('_', 'args', 'input', 'PSItem', 'this', 'matches', 'lastexitcode',
'error', 'warning', 'information', 'verbose', 'debug', 'progress',
'whatifpreference', 'confirmpreference', 'erroractionpreference')
if ($varName -notin $excludedVars -and $varName -notin $referencedVariables) {
$unusedVars += $assignedVar
}
}
$results.UnusedVariables = $unusedVars
# Analyze functions
$definedFunctions = @()
foreach ($funcDef in $functionDefinitions) {
$definedFunctions += @{
Name = $funcDef.Name
Line = $funcDef.Extent.StartLineNumber
Position = $funcDef.Extent.StartOffset
}
}
$calledFunctions = @()
foreach ($call in $functionCalls) {
$commandName = $call.GetCommandName()
if ($commandName) {
$calledFunctions += $commandName
}
}
# Find unused functions
$unusedFuncs = @()
foreach ($definedFunc in $definedFunctions) {
if ($definedFunc.Name -notin $calledFunctions) {
$unusedFuncs += $definedFunc
}
}
$results.UnusedFunctions = $unusedFuncs
# Generate statistics
$results.Statistics = @{
TotalLines = ($content -split "`n").Count
TotalVariables = $assignedVariables.Count
TotalFunctions = $definedFunctions.Count
UnusedVariableCount = $unusedVars.Count
UnusedFunctionCount = $unusedFuncs.Count
ReferencedVariables = ($referencedVariables | Select-Object -Unique).Count
CalledFunctions = ($calledFunctions | Select-Object -Unique).Count
ParseErrors = $errors.Count
}
return $results
}
catch {
Write-Host "Error analyzing script: $($_.Exception.Message)" -ForegroundColor Red
return $null
}
}
function Show-UnusedCodeReport {
param([object]$Analysis)
if (-not $Analysis) {
Write-Host "No analysis data provided." -ForegroundColor Red
return
}
$separator = "=" * 60
Write-Host "`n$separator" -ForegroundColor Cyan
Write-Host "Script Analysis Results: $($Analysis.SourceName)" -ForegroundColor Green
Write-Host "$separator" -ForegroundColor Cyan
# Statistics
Write-Host "`nStatistics:" -ForegroundColor Yellow
Write-Host " Total Lines: " -NoNewline -ForegroundColor Gray
Write-Host "$($Analysis.Statistics.TotalLines)" -ForegroundColor White
Write-Host " Variables: " -NoNewline -ForegroundColor Gray
Write-Host "$($Analysis.Statistics.UnusedVariableCount)" -ForegroundColor Red -NoNewline
Write-Host " unused / " -ForegroundColor Gray -NoNewline
Write-Host "$($Analysis.Statistics.TotalVariables)" -ForegroundColor Green -NoNewline
Write-Host " total" -ForegroundColor Gray
Write-Host " Functions: " -NoNewline -ForegroundColor Gray
Write-Host "$($Analysis.Statistics.UnusedFunctionCount)" -ForegroundColor Red -NoNewline
Write-Host " unused / " -ForegroundColor Gray -NoNewline
Write-Host "$($Analysis.Statistics.TotalFunctions)" -ForegroundColor Green -NoNewline
Write-Host " total" -ForegroundColor Gray
if ($Analysis.Statistics.ParseErrors -gt 0) {
Write-Host " Parse Errors: " -NoNewline -ForegroundColor Gray
Write-Host "$($Analysis.Statistics.ParseErrors)" -ForegroundColor Red
}
# Unused Variables
if ($Analysis.UnusedVariables.Count -gt 0) {
Write-Host "`nUnused Variables:" -ForegroundColor Yellow
foreach ($var in $Analysis.UnusedVariables) {
Write-Host " - " -NoNewline -ForegroundColor Gray
Write-Host "`$$($var.Name)" -ForegroundColor Red -NoNewline
Write-Host " (Line $($var.Line))" -ForegroundColor Gray
}
} else {
Write-Host "`nUnused Variables: " -NoNewline -ForegroundColor Yellow
Write-Host "None found âś“" -ForegroundColor Green
}
# Unused Functions
if ($Analysis.UnusedFunctions.Count -gt 0) {
Write-Host "`nUnused Functions:" -ForegroundColor Yellow
foreach ($func in $Analysis.UnusedFunctions) {
Write-Host " - " -NoNewline -ForegroundColor Gray
Write-Host "$($func.Name)" -ForegroundColor Red -NoNewline
Write-Host " (Line $($func.Line))" -ForegroundColor Gray
}
} else {
Write-Host "`nUnused Functions: " -NoNewline -ForegroundColor Yellow
Write-Host "None found âś“" -ForegroundColor Green
}
Write-Host "`n$separator" -ForegroundColor Cyan
}
# Example usage with sample code
$sampleScript = @'
# Sample PowerShell script for testing
function Get-UserInfo {
param($Username)
return Get-ADUser $Username
}
function Send-Email {
param($To, $Subject, $Body)
Send-MailMessage -To $To -Subject $Subject -Body $Body
}
function Process-Data {
# This function is never called
param($Data)
return $Data | Sort-Object
}
# Variables
$usedVariable = "Hello World"
$unusedVariable = "This is never used"
$anotherUnused = 42
# Usage
Write-Host $usedVariable
$userInfo = Get-UserInfo -Username "john.doe"
Send-Email -To "test@example.com" -Subject "Test" -Body $usedVariable
'@
Write-Host "Testing with sample script content:" -ForegroundColor Cyan
$analysis = Find-UnusedCode -ScriptContent $sampleScript
Show-UnusedCodeReport -Analysis $analysis
$dashSeparator = "-" * 60
Write-Host "`n$dashSeparator" -ForegroundColor Gray
Write-Host "To analyze a specific file, use:" -ForegroundColor Yellow
Write-Host ' $analysis = Find-UnusedCode -ScriptPath "C:\Path\To\Your\Script.ps1"' -ForegroundColor White
Write-Host ' Show-UnusedCodeReport -Analysis $analysis' -ForegroundColor White
Write-Host "`nTo analyze the current script file, use:" -ForegroundColor Yellow
Write-Host ' $analysis = Find-UnusedCode -AnalyzeSelf' -ForegroundColor White
Write-Host ' Show-UnusedCodeReport -Analysis $analysis' -ForegroundColor White
Example 2: Dependency Mapping and Impact Analysis
Understanding dependencies in large scripts is crucial for refactoring and maintenance. This example maps all function dependencies and identifies critical functions:
function Show-DependencyMap {
param(
[Parameter(ParameterSetName = 'File')]
[string]$ScriptPath,
[Parameter(ParameterSetName = 'String')]
[string]$ScriptContent,
[switch]$AnalyzeSelf,
[string]$ExportPath = $null
)
try {
# Determine what to analyze
if ($AnalyzeSelf) {
$content = $MyInvocation.ScriptName | Get-Content -Raw -ErrorAction Stop
$sourceName = "Current Script"
}
elseif ($PSCmdlet.ParameterSetName -eq 'File') {
if (-not (Test-Path $ScriptPath)) {
Write-Host "Error: File '$ScriptPath' does not exist." -ForegroundColor Red
return $null
}
$content = Get-Content -Path $ScriptPath -Raw -ErrorAction Stop
$sourceName = Split-Path $ScriptPath -Leaf
}
elseif ($PSCmdlet.ParameterSetName -eq 'String') {
$content = $ScriptContent
$sourceName = "Provided Script Content"
}
else {
Write-Host "Error: Must specify either -ScriptPath, -ScriptContent, or -AnalyzeSelf" -ForegroundColor Red
return $null
}
if ([string]::IsNullOrWhiteSpace($content)) {
Write-Host "Error: Script content is empty or null." -ForegroundColor Red
return $null
}
Write-Host "Analyzing dependencies in: $sourceName" -ForegroundColor Cyan
Write-Host "Content length: $($content.Length) characters" -ForegroundColor Gray
# Parse the content
$tokens = $null
$errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseInput($content, [ref]$tokens, [ref]$errors)
if ($errors.Count -gt 0) {
Write-Host "Warning: Found $($errors.Count) parse errors:" -ForegroundColor Yellow
foreach ($error in $errors) {
Write-Host " - Line $($error.Extent.StartLineNumber): $($error.Message)" -ForegroundColor Yellow
}
}
# Find all function definitions with their content
$functions = $ast.FindAll({
$args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst]
}, $true)
Write-Host "Found $($functions.Count) function definitions" -ForegroundColor Gray
$dependencyMap = @{}
$reverseDependencyMap = @{}
foreach ($function in $functions) {
$functionName = $function.Name
$functionBody = $function.Body.ToString()
Write-Host "Analyzing function: $functionName" -ForegroundColor Gray
# Find all commands called within this function
$commandsInFunction = $function.FindAll({
$args[0] -is [System.Management.Automation.Language.CommandAst]
}, $true)
$dependencies = @()
foreach ($command in $commandsInFunction) {
$commandName = $command.GetCommandName()
# Check if this command is a function defined in our script
if ($commandName -in $functions.Name) {
$dependencies += $commandName
Write-Host " - Calls: $commandName" -ForegroundColor DarkGray
}
}
$dependencyMap[$functionName] = $dependencies | Select-Object -Unique
# Build reverse dependency map
foreach ($dependency in ($dependencies | Select-Object -Unique)) {
if (-not $reverseDependencyMap.ContainsKey($dependency)) {
$reverseDependencyMap[$dependency] = @()
}
$reverseDependencyMap[$dependency] += $functionName
}
}
# Calculate dependency metrics
$analysis = @{
DependencyMap = $dependencyMap
ReverseDependencyMap = $reverseDependencyMap
CriticalFunctions = @()
IsolatedFunctions = @()
Statistics = @{}
SourceName = $sourceName
ParseErrors = $errors
}
# Identify critical functions (used by many others)
$analysis.CriticalFunctions = $reverseDependencyMap.Keys | Where-Object {
$reverseDependencyMap[$_].Count -ge 2 # Lowered threshold for demo
} | Sort-Object { $reverseDependencyMap[$_].Count } -Descending
# Identify isolated functions (no dependencies and not used by others)
$analysis.IsolatedFunctions = $dependencyMap.Keys | Where-Object {
$dependencyMap[$_].Count -eq 0 -and
(-not $reverseDependencyMap.ContainsKey($_) -or $reverseDependencyMap[$_].Count -eq 0)
}
$avgDependencies = if ($dependencyMap.Count -gt 0) {
($dependencyMap.Values | ForEach-Object { $_.Count } | Measure-Object -Average).Average
} else { 0 }
$analysis.Statistics = @{
TotalFunctions = $functions.Count
CriticalFunctionCount = $analysis.CriticalFunctions.Count
IsolatedFunctionCount = $analysis.IsolatedFunctions.Count
AverageDependencies = $avgDependencies
}
return $analysis
}
catch {
Write-Host "Error analyzing dependencies: $($_.Exception.Message)" -ForegroundColor Red
return $null
}
}
function Show-DependencyReport {
param($Analysis, [string]$ExportPath = $null)
if (-not $Analysis) {
Write-Host "No analysis data provided." -ForegroundColor Red
return
}
$separator = "=" * 50
Write-Host "`n$separator" -ForegroundColor Cyan
Write-Host "Function Dependency Analysis: $($Analysis.SourceName)" -ForegroundColor Green
Write-Host "$separator" -ForegroundColor Cyan
Write-Host "`nStatistics:" -ForegroundColor Yellow
Write-Host " Total Functions: " -NoNewline -ForegroundColor Gray
Write-Host "$($Analysis.Statistics.TotalFunctions)" -ForegroundColor White
Write-Host " Critical Functions: " -NoNewline -ForegroundColor Gray
Write-Host "$($Analysis.Statistics.CriticalFunctionCount)" -ForegroundColor Yellow
Write-Host " Isolated Functions: " -NoNewline -ForegroundColor Gray
Write-Host "$($Analysis.Statistics.IsolatedFunctionCount)" -ForegroundColor Cyan
Write-Host " Average Dependencies: " -NoNewline -ForegroundColor Gray
Write-Host "$([Math]::Round($Analysis.Statistics.AverageDependencies, 2))" -ForegroundColor White
# Show dependency map
if ($Analysis.DependencyMap.Count -gt 0) {
Write-Host "`nFunction Dependencies:" -ForegroundColor Yellow
foreach ($func in $Analysis.DependencyMap.GetEnumerator() | Sort-Object Key) {
if ($func.Value.Count -gt 0) {
Write-Host " $($func.Key) depends on:" -ForegroundColor White
foreach ($dep in $func.Value) {
Write-Host " - $dep" -ForegroundColor Gray
}
} else {
Write-Host " $($func.Key)" -ForegroundColor White -NoNewline
Write-Host " (no internal dependencies)" -ForegroundColor DarkGray
}
}
}
if ($Analysis.CriticalFunctions.Count -gt 0) {
Write-Host "`nCritical Functions (High Impact if Changed):" -ForegroundColor Red
foreach ($func in $Analysis.CriticalFunctions) {
$usageCount = $Analysis.ReverseDependencyMap[$func].Count
Write-Host " $func " -ForegroundColor Yellow -NoNewline
Write-Host "(used by $usageCount functions)" -ForegroundColor Gray
foreach ($user in $Analysis.ReverseDependencyMap[$func]) {
Write-Host " - $user" -ForegroundColor Gray
}
}
} else {
Write-Host "`nCritical Functions: " -NoNewline -ForegroundColor Red
Write-Host "None found" -ForegroundColor Green
}
if ($Analysis.IsolatedFunctions.Count -gt 0) {
Write-Host "`nIsolated Functions (Safe to Remove/Modify):" -ForegroundColor Green
foreach ($func in $Analysis.IsolatedFunctions) {
Write-Host " - $func" -ForegroundColor Cyan
}
} else {
Write-Host "`nIsolated Functions: " -NoNewline -ForegroundColor Green
Write-Host "None found" -ForegroundColor Yellow
}
# Show functions with most dependencies (potential complexity issues)
$complexFunctions = $Analysis.DependencyMap.GetEnumerator() |
Where-Object { $_.Value.Count -gt 3 } | # Lowered threshold for demo
Sort-Object { $_.Value.Count } -Descending
if ($complexFunctions.Count -gt 0) {
Write-Host "`nComplex Functions (Many Dependencies):" -ForegroundColor Magenta
foreach ($func in $complexFunctions) {
Write-Host " $($func.Key) " -ForegroundColor Yellow -NoNewline
Write-Host "depends on $($func.Value.Count) functions" -ForegroundColor Gray
}
} else {
Write-Host "`nComplex Functions: " -NoNewline -ForegroundColor Magenta
Write-Host "None found" -ForegroundColor Green
}
# Export to CSV if requested
if ($ExportPath) {
try {
$exportData = $Analysis.DependencyMap.GetEnumerator() | ForEach-Object {
[PSCustomObject]@{
Function = $_.Key
DependsOn = ($_.Value -join '; ')
DependencyCount = $_.Value.Count
UsedBy = if ($Analysis.ReverseDependencyMap[$_.Key]) {
($Analysis.ReverseDependencyMap[$_.Key] -join '; ')
} else { 'None' }
UsageCount = if ($Analysis.ReverseDependencyMap[$_.Key]) {
$Analysis.ReverseDependencyMap[$_.Key].Count
} else { 0 }
}
}
$exportData | Export-Csv -Path $ExportPath -NoTypeInformation
Write-Host "`nDetailed report exported to: " -ForegroundColor Green -NoNewline
Write-Host "$ExportPath" -ForegroundColor Cyan
}
catch {
Write-Host "`nError exporting to CSV: $($_.Exception.Message)" -ForegroundColor Red
}
}
Write-Host "`n$separator" -ForegroundColor Cyan
}
# Sample script for testing
$sampleScript = @'
# Sample PowerShell script with function dependencies
function Get-DatabaseConnection {
param($ConnectionString)
# Base utility function
return "Connection to $ConnectionString"
}
function Get-UserData {
param($UserId)
$connection = Get-DatabaseConnection -ConnectionString "UserDB"
return "User data for $UserId from $connection"
}
function Get-OrderData {
param($OrderId)
$connection = Get-DatabaseConnection -ConnectionString "OrderDB"
return "Order data for $OrderId from $connection"
}
function Generate-UserReport {
param($UserId)
$userData = Get-UserData -UserId $UserId
$orderData = Get-OrderData -OrderId "123"
return "Report: $userData, $orderData"
}
function Send-EmailNotification {
param($Message)
# Isolated function - no dependencies
return "Email sent: $Message"
}
function Process-ComplexWorkflow {
param($UserId, $OrderId)
$userData = Get-UserData -UserId $UserId
$orderData = Get-OrderData -OrderId $OrderId
$report = Generate-UserReport -UserId $UserId
return "Workflow complete: $userData, $orderData, $report"
}
function Unused-HelperFunction {
# This function is not called by anyone
return "I'm lonely"
}
'@
Write-Host "Testing with sample script content:" -ForegroundColor Cyan
$dependencyAnalysis = Show-DependencyMap -ScriptContent $sampleScript
Show-DependencyReport -Analysis $dependencyAnalysis
$dashSeparator = "-" * 60
Write-Host "`n$dashSeparator" -ForegroundColor Gray
Write-Host "Usage Examples:" -ForegroundColor Yellow
Write-Host "`nTo analyze a specific file:" -ForegroundColor Yellow
Write-Host ' $analysis = Show-DependencyMap -ScriptPath "C:\Path\To\Your\Script.ps1"' -ForegroundColor White
Write-Host ' Show-DependencyReport -Analysis $analysis' -ForegroundColor White
Write-Host "`nTo export results to CSV:" -ForegroundColor Yellow
Write-Host ' Show-DependencyReport -Analysis $analysis -ExportPath "C:\Reports\Dependencies.csv"' -ForegroundColor White
Write-Host "`nTo analyze the current script:" -ForegroundColor Yellow
Write-Host ' $analysis = Show-DependencyMap -AnalyzeSelf' -ForegroundColor White
Write-Host ' Show-DependencyReport -Analysis $analysis' -ForegroundColor White
These examples demonstrate the power of AST analysis for large-scale PowerShell development. The unused code finder can identify dead code that's been accumulating over months or years, while the dependency mapper reveals the hidden relationships that make refactoring risky without proper analysis.
Supercharging AST Analysis with PSScriptAnalyzer
While the raw AST functionality is powerful on its own, Microsoft's PSScriptAnalyzer module takes PowerShell code analysis to the next level. This static code checker leverages the same AST concepts we've discussed but provides over 50 built-in rules based on PowerShell best practices.
Installing PSScriptAnalyzer
Install-Module PSScriptAnalyzer -Scope CurrentUser
Immediate Value for Large Scripts
For the large script scenarios we discussed earlier, PSScriptAnalyzer can instantly identify many issues without writing custom AST code:
# Comprehensive analysis of a large script
Invoke-ScriptAnalyzer -Path "C:\Scripts\LargeScript.ps1" -Recurse
# Focus on specific rule categories
Invoke-ScriptAnalyzer -Path "C:\Scripts\LargeScript.ps1" -Settings PSGallery
# Automatically fix formatting and style issues
Invoke-ScriptAnalyzer -Path "C:\Scripts\LargeScript.ps1" -Fix
Simplified Unused Code Detection
Instead of the complex unused variable finder we built earlier, PSScriptAnalyzer provides built-in rules:
# Find unused variables with a single command
Invoke-ScriptAnalyzer -Path "C:\Scripts\LargeScript.ps1" -IncludeRule PSUseDeclaredVarsMoreThanAssignments
# Check for other code quality issues
Invoke-ScriptAnalyzer -Path "C:\Scripts\LargeScript.ps1" -IncludeRule PSAvoidUsingWriteHost,PSAvoidGlobalVars
Custom Rules for Specialized Analysis
PSScriptAnalyzer also supports custom rules, allowing you to extend its capabilities for organization-specific requirements:
# Custom PSScriptAnalyzer Rule for Company Standards
function Measure-CompanyStandards {
<#
.SYNOPSIS
Validates PowerShell code against company-specific standards.
.DESCRIPTION
This custom PSScriptAnalyzer rule checks for:
- Function naming conventions (must start with approved verbs + "Company")
- Variable naming standards (PascalCase for parameters, camelCase for local vars)
- Mandatory comment-based help for public functions
.PARAMETER ScriptBlockAst
The AST (Abstract Syntax Tree) of the script to analyze.
.OUTPUTS
[Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]]
Returns diagnostic records for any violations found.
.EXAMPLE
# This rule will be automatically called by PSScriptAnalyzer when the module is imported
Invoke-ScriptAnalyzer -Path "MyScript.ps1" -CustomRulePath "CompanyRules.psm1"
#>
[CmdletBinding()]
[OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])]
param(
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[System.Management.Automation.Language.ScriptBlockAst]$ScriptBlockAst
)
process {
$results = @()
try {
Write-Verbose "Analyzing script with company standards rule"
# Rule 1: Function Naming Convention
$functions = $ScriptBlockAst.FindAll({
$args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst]
}, $true)
Write-Verbose "Found $($functions.Count) functions to analyze"
foreach ($function in $functions) {
# Check naming convention: Must start with approved verb + "Company"
$approvedVerbs = @('Get', 'Set', 'New', 'Remove', 'Test', 'Start', 'Stop', 'Restart', 'Add', 'Clear', 'Copy', 'Move', 'Update', 'Import', 'Export')
$verbPattern = ($approvedVerbs -join '|')
$expectedPattern = "^($verbPattern)-Company"
if ($function.Name -notmatch $expectedPattern) {
$results += [Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]::new(
"Function '$($function.Name)' doesn't follow company naming convention. Expected format: ApprovedVerb-Company*",
$function.Extent,
'CompanyNamingConvention',
[Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticSeverity]::Warning,
$null
)
}
# Rule 2: Check for comment-based help in public functions
if ($function.Name -match '^(Get|Set|New|Remove)-Company') {
# Look for comment-based help before the function
$helpFound = $false
# Simple check: look for .SYNOPSIS in the function body or preceding comments
$functionText = $function.Extent.Text
if ($functionText -match '\.SYNOPSIS' -or $functionText -match '<#[\s\S]*\.SYNOPSIS[\s\S]*#>') {
$helpFound = $true
}
if (-not $helpFound) {
$results += [Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]::new(
"Public function '$($function.Name)' is missing comment-based help with .SYNOPSIS",
$function.Extent,
'CompanyPublicFunctionHelp',
[Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticSeverity]::Information,
$null
)
}
}
}
# Rule 3: Parameter Naming Convention (PascalCase)
$parameters = $ScriptBlockAst.FindAll({
$args[0] -is [System.Management.Automation.Language.ParameterAst]
}, $true)
Write-Verbose "Found $($parameters.Count) parameters to analyze"
foreach ($parameter in $parameters) {
$paramName = $parameter.Name.VariablePath.UserPath
# Skip common PowerShell automatic parameters
$skipParams = @('Verbose', 'Debug', 'ErrorAction', 'WarningAction', 'InformationAction', 'ErrorVariable', 'WarningVariable', 'InformationVariable', 'OutVariable', 'OutBuffer', 'PipelineVariable')
if ($paramName -notin $skipParams) {
# Check if parameter follows PascalCase convention
if ($paramName -cnotmatch '^[A-Z][a-zA-Z0-9]*$') {
$results += [Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]::new(
"Parameter '$paramName' should use PascalCase naming convention",
$parameter.Extent,
'CompanyParameterNaming',
[Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticSeverity]::Information,
$null
)
}
}
}
# Rule 4: Variable Assignment Conventions (camelCase for local variables)
$assignments = $ScriptBlockAst.FindAll({
$args[0] -is [System.Management.Automation.Language.AssignmentStatementAst]
}, $true)
Write-Verbose "Found $($assignments.Count) variable assignments to analyze"
foreach ($assignment in $assignments) {
if ($assignment.Left -is [System.Management.Automation.Language.VariableExpressionAst]) {
$varName = $assignment.Left.VariablePath.UserPath
# Skip special PowerShell variables and short variables
$skipVars = @('_', 'PSItem', 'args', 'input', 'matches', 'error', 'lastexitcode')
if ($varName -notin $skipVars -and $varName.Length -gt 2) {
# Check if local variable follows camelCase (starts with lowercase)
if ($varName -cnotmatch '^[a-z][a-zA-Z0-9]*$') {
$results += [Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]::new(
"Local variable '$varName' should use camelCase naming convention",
$assignment.Left.Extent,
'CompanyVariableNaming',
[Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticSeverity]::Information,
$null
)
}
}
}
}
Write-Verbose "Company standards analysis complete. Found $($results.Count) issues."
}
catch {
Write-Error "Error in Measure-CompanyStandards: $($_.Exception.Message)"
}
return $results
}
}
# Export the rule function (required for PSScriptAnalyzer to discover it)
Export-ModuleMember -Function Measure-CompanyStandards
# Debug-CompanyRules.ps1 - Test and debug the custom rules
function Test-CompanyRuleDebug {
Write-Host "=== DEBUGGING COMPANY RULES ===" -ForegroundColor Magenta
# First, let's test if the module loads correctly
try {
Import-Module ".\CompanyRules.psm1" -Force
Write-Host "âś… Module loaded successfully" -ForegroundColor Green
}
catch {
Write-Host "❌ Failed to load module: $($_.Exception.Message)" -ForegroundColor Red
return
}
# Test if the function exists
if (Get-Command Measure-CompanyStandards -ErrorAction SilentlyContinue) {
Write-Host "âś… Measure-CompanyStandards function found" -ForegroundColor Green
} else {
Write-Host "❌ Measure-CompanyStandards function not found" -ForegroundColor Red
return
}
Write-Host "`n=== TESTING WITH SAMPLE CODE ===" -ForegroundColor Cyan
# Sample code with clear violations
$testCode = @'
function BadFunction {
param($badParam)
$BadVariable = "test"
Write-Host "No help"
}
function Get-CompanyData {
param($Data)
$result = "missing help"
return $result
}
'@
Write-Host "Test code:" -ForegroundColor Yellow
Write-Host $testCode -ForegroundColor White
# Parse the code
Write-Host "`n=== PARSING CODE ===" -ForegroundColor Cyan
$ast = [System.Management.Automation.Language.Parser]::ParseInput($testCode, [ref]$null, [ref]$null)
Write-Host "âś… Code parsed successfully" -ForegroundColor Green
# Check what functions were found
$functions = $ast.FindAll({$args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst]}, $true)
Write-Host "Found $($functions.Count) functions:" -ForegroundColor Yellow
foreach ($func in $functions) {
Write-Host " - $($func.Name)" -ForegroundColor White
}
# Run the custom rule
Write-Host "`n=== RUNNING CUSTOM RULE ===" -ForegroundColor Cyan
try {
$violations = Measure-CompanyStandards -ScriptBlockAst $ast
Write-Host "âś… Rule executed successfully" -ForegroundColor Green
Write-Host "Found $($violations.Count) violations:" -ForegroundColor Yellow
if ($violations.Count -eq 0) {
Write-Host "❌ No violations found - this might indicate an issue with the rule logic" -ForegroundColor Red
} else {
foreach ($violation in $violations) {
Write-Host "`n[$($violation.Severity)] $($violation.RuleName)" -ForegroundColor Cyan
Write-Host "Message: $($violation.Message)" -ForegroundColor White
Write-Host "Line: $($violation.Extent.StartLineNumber)" -ForegroundColor Gray
}
}
}
catch {
Write-Host "❌ Error running rule: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Stack trace: $($_.ScriptStackTrace)" -ForegroundColor Red
}
Write-Host "`n=== TESTING WITH PSSCRIPTANALYZER ===" -ForegroundColor Cyan
# Test with actual PSScriptAnalyzer
if (Get-Module PSScriptAnalyzer -ListAvailable) {
Write-Host "âś… PSScriptAnalyzer module available" -ForegroundColor Green
# Create a temporary test file
$tempFile = "TempTestFile.ps1"
$testCode | Out-File -FilePath $tempFile -Encoding UTF8
try {
Write-Host "Running PSScriptAnalyzer with custom rule..." -ForegroundColor Yellow
$results = Invoke-ScriptAnalyzer -Path $tempFile -CustomRulePath ".\CompanyRules.psm1"
if ($results.Count -eq 0) {
Write-Host "❌ PSScriptAnalyzer found no violations" -ForegroundColor Red
Write-Host "This could mean:" -ForegroundColor Yellow
Write-Host " 1. The rule isn't being loaded properly" -ForegroundColor Yellow
Write-Host " 2. The rule logic isn't matching the violations" -ForegroundColor Yellow
Write-Host " 3. The test code doesn't actually violate the rules" -ForegroundColor Yellow
} else {
Write-Host "âś… PSScriptAnalyzer found $($results.Count) violations:" -ForegroundColor Green
foreach ($result in $results) {
Write-Host " - $($result.RuleName): $($result.Message)" -ForegroundColor White
}
}
}
catch {
Write-Host "❌ Error running PSScriptAnalyzer: $($_.Exception.Message)" -ForegroundColor Red
}
finally {
# Clean up temp file
if (Test-Path $tempFile) {
Remove-Item $tempFile -Force
}
}
} else {
Write-Host "❌ PSScriptAnalyzer module not available" -ForegroundColor Red
Write-Host "Install it with: Install-Module PSScriptAnalyzer" -ForegroundColor Yellow
}
}
# Run the debug test
Test-CompanyRuleDebug
# Instructions for using this as a PSScriptAnalyzer rule
$usageInstructions = @"
đź“‹ HOW TO USE THIS CUSTOM RULE:
1. Save this file as 'CompanyRules.psm1'
2. Use with PSScriptAnalyzer:
Invoke-ScriptAnalyzer -Path "YourScript.ps1" -CustomRulePath "CompanyRules.psm1"
3. Or include in your PSScriptAnalyzer settings file:
@{
CustomRulePath = 'CompanyRules.psm1'
Rules = @{
PSUseApprovedVerbs = @{Enable = $true}
CompanyNamingConvention = @{Enable = $true}
CompanyParameterNaming = @{Enable = $true}
CompanyVariableNaming = @{Enable = $true}
CompanyPublicFunctionHelp = @{Enable = $true}
}
}
4. Test the rule:
Test-CompanyRule
"@
Write-Host $usageInstructions -ForegroundColor Yellow
# Run the test automatically
Write-Host "`nRunning test automatically..." -ForegroundColor Cyan
Test-CompanyRule
As always, as I constantly tinker and update, the most up-to-date version of scripts used here can be found on the companion GitHub.
Integration with Development Workflow
PSScriptAnalyzer integrates seamlessly with popular editors like VS Code, providing real-time feedback as you write code. This transforms AST analysis from a separate step into an ongoing part of the development process.
When to Use Raw AST vs PSScriptAnalyzer
Use PSScriptAnalyzer when:
- Performing standard code quality checks
- Looking for common PowerShell anti-patterns
- Integrating analysis into CI/CD pipelines
- Need automatic code fixes
- Working with team standards and style guides
Use raw AST when:
- Building specialized analysis tools
- Creating custom development utilities
- Need precise control over parsing and analysis
- Developing your own static analysis rules
- Working with AST manipulation and transformation
The beauty is that these approaches complement each other perfectly. PSScriptAnalyzer handles the common cases efficiently, while raw AST provides the foundation for specialized tooling.
Getting Started
Begin with PSScriptAnalyzer to get immediate value from AST-based analysis, then gradually explore raw AST manipulation for more specialized needs. The PowerShell AST documentation provides comprehensive details about all available node types and their properties, making it an excellent reference as you develop your AST skills.
Remember that working with AST is about precision and reliability—you're leveraging PowerShell's own understanding of its language to build tools that work correctly across all the edge cases and complexities that make PowerShell powerful.
As you explore PowerShell AST and build your own analysis tools, I'll leave with these thought-provoking questions that could spark valuable discussions:
For Script Analysis:
- What's the largest PowerShell script you've had to maintain, and what AST analysis would have saved you the most time?
- Have you discovered any surprising patterns or anti-patterns in your organization's PowerShell codebases using AST analysis?
- Which PSScriptAnalyzer rules do you find most valuable in your daily work, and are there custom rules you wish existed?
For Tool Development:
- What PowerShell development pain points could be solved with better AST-based tooling?
- If you could build the perfect PowerShell code analysis dashboard, what metrics would it display?
- How do you balance the depth of analysis with performance when working with large scripts or modules?
For Team Collaboration:
- How has static code analysis changed your team's PowerShell development workflow?
- What coding standards or conventions has AST analysis helped you enforce across your organization?
- Have you used AST analysis to help onboard new team members or audit legacy scripts?
For Advanced Scenarios:
- What creative applications of PowerShell AST have you discovered beyond traditional code analysis?
- How do you handle AST analysis across different PowerShell versions (5.1 vs 7+) in mixed environments?
- What would you want to see in the next generation of PowerShell development tooling?
For Learning and Growth:
- Which AST concepts were most challenging to understand initially, and how did you overcome those hurdles?
- What resources or examples helped you transition from basic PowerShell scripting to AST manipulation?
- How has understanding AST changed the way you write PowerShell code?