Skip to content

Commit

Permalink
Add Aspects (for cross-cutting concerns)
Browse files Browse the repository at this point in the history
semver:feature
  • Loading branch information
Jaykul committed Oct 23, 2023
1 parent d183d88 commit 61f2e25
Show file tree
Hide file tree
Showing 10 changed files with 310 additions and 0 deletions.
File renamed without changes.
5 changes: 5 additions & 0 deletions Source/Classes/10. ParameterPosition.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class ParameterPosition {
[string]$Name
[int]$StartOffset
[string]$Text
}
5 changes: 5 additions & 0 deletions Source/Classes/11. TextReplace.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class TextReplace {
[int]$StartOffset = 0
[int]$EndOffset = 0
[string]$Text = ''
}
10 changes: 10 additions & 0 deletions Source/Classes/20. ModuleBuilderAspect.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class ModuleBuilderAspect : AstVisitor {
[List[TextReplace]]$Replacements = @()
[ScriptBlock]$Where = { $true }
[Ast]$Aspect

[List[TextReplace]]Generate([Ast]$ast) {
$ast.Visit($this)
return $this.Replacements
}
}
51 changes: 51 additions & 0 deletions Source/Classes/21. ParameterExtractor.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
class ParameterExtractor : AstVisitor {
[ParameterPosition[]]$Parameters = @()
[int]$InsertLineNumber = -1
[int]$InsertColumnNumber = -1
[int]$InsertOffset = -1

ParameterExtractor([Ast]$Ast) {
$ast.Visit($this)
}

[AstVisitAction] VisitParamBlock([ParamBlockAst]$ast) {
if ($Ast.Parameters) {
$Text = $ast.Extent.Text -split "\r?\n"

$FirstLine = $ast.Extent.StartLineNumber
$NextLine = 1
$this.Parameters = @(
foreach ($parameter in $ast.Parameters | Select-Object Name -Expand Extent) {
[ParameterPosition]@{
Name = $parameter.Name
StartOffset = $parameter.StartOffset
Text = if (($parameter.StartLineNumber - $FirstLine) -ge $NextLine) {
Write-Debug "Extracted parameter $($Parameter.Name) with surrounding lines"
# Take lines after the last parameter
$Lines = @($Text[$NextLine..($parameter.EndLineNumber - $FirstLine)].Where{ ![string]::IsNullOrWhiteSpace($_) })
# If the last line extends past the end of the parameter, trim that line
if ($Lines.Length -gt 0 -and $parameter.EndColumnNumber -lt $Lines[-1].Length) {
$Lines[-1] = $Lines[-1].SubString($parameter.EndColumnNumber)
}
# Don't return the commas, we'll add them back later
($Lines -join "`n").TrimEnd(",")
} else {
Write-Debug "Extracted parameter $($Parameter.Name) text exactly"
$parameter.Text.TrimEnd(",")
}
}
$NextLine = 1 + $parameter.EndLineNumber - $FirstLine
}
)

$this.InsertLineNumber = $ast.Parameters[-1].Extent.EndLineNumber
$this.InsertColumnNumber = $ast.Parameters[-1].Extent.EndColumnNumber
$this.InsertOffset = $ast.Parameters[-1].Extent.EndOffset
} else {
$this.InsertLineNumber = $ast.Extent.EndLineNumber
$this.InsertColumnNumber = $ast.Extent.EndColumnNumber - 1
$this.InsertOffset = $ast.Extent.EndOffset - 1
}
return [AstVisitAction]::StopVisit
}
}
34 changes: 34 additions & 0 deletions Source/Classes/22. AddParameterAspect.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
class AddParameterAspect : ModuleBuilderAspect {
[System.Management.Automation.HiddenAttribute()]
[ParameterExtractor]$AdditionalParameterCache

[ParameterExtractor]GetAdditional() {
if (!$this.AdditionalParameterCache) {
$this.AdditionalParameterCache = $this.Aspect
}
return $this.AdditionalParameterCache
}

[AstVisitAction] VisitFunctionDefinition([FunctionDefinitionAst]$ast) {
if (!$ast.Where($this.Where)) {
return [AstVisitAction]::SkipChildren
}
$Existing = [ParameterExtractor]$ast
$Additional = $this.GetAdditional().Parameters.Where{ $_.Name -notin $Existing.Parameters.Name }
if (($Text = $Additional.Text -join ",`n`n")) {
$Replacement = [TextReplace]@{
StartOffset = $Existing.InsertOffset
EndOffset = $Existing.InsertOffset
Text = if ($Existing.Parameters.Count -gt 0) {
",`n`n" + $Text
} else {
"`n" + $Text
}
}

Write-Debug "Adding parameters to $($ast.name): $($Additional.Name -join ', ')"
$this.Replacements.Add($Replacement)
}
return [AstVisitAction]::SkipChildren
}
}
116 changes: 116 additions & 0 deletions Source/Classes/23. MergeBlocksAspect.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
class MergeBlocksAspect : ModuleBuilderAspect {
[System.Management.Automation.HiddenAttribute()]
[NamedBlockAst]$BeginBlockTemplate

[System.Management.Automation.HiddenAttribute()]
[NamedBlockAst]$ProcessBlockTemplate

[System.Management.Automation.HiddenAttribute()]
[NamedBlockAst]$EndBlockTemplate

[List[TextReplace]]Generate([Ast]$ast) {
if (!($this.BeginBlockTemplate = $this.Aspect.Find({ $args[0] -is [NamedBlockAst] -and $args[0].BlockKind -eq "Begin" }, $false))) {
Write-Debug "No Aspect for BeginBlock"
} else {
Write-Debug "BeginBlock Aspect: $($this.BeginBlockTemplate)"
}
if (!($this.ProcessBlockTemplate = $this.Aspect.Find({ $args[0] -is [NamedBlockAst] -and $args[0].BlockKind -eq "Process" }, $false))) {
Write-Debug "No Aspect for ProcessBlock"
} else {
Write-Debug "ProcessBlock Aspect: $($this.ProcessBlockTemplate)"
}
if (!($this.EndBlockTemplate = $this.Aspect.Find({ $args[0] -is [NamedBlockAst] -and $args[0].BlockKind -eq "End" }, $false))) {
Write-Debug "No Aspect for EndBlock"
} else {
Write-Debug "EndBlock Aspect: $($this.EndBlockTemplate)"
}

$ast.Visit($this)
return $this.Replacements
}

# The [Alias(...)] attribute on functions matters, but we can't export aliases that are defined inside a function
[AstVisitAction] VisitFunctionDefinition([FunctionDefinitionAst]$ast) {
if (!$ast.Where($this.Where)) {
return [AstVisitAction]::SkipChildren
}

if ($this.BeginBlockTemplate) {
if ($ast.Body.BeginBlock) {
$BeginExtent = $ast.Body.BeginBlock.Extent
$BeginBlockText = ($BeginExtent.Text -replace "^begin[\s\r\n]*{|}[\s\r\n]*$", "`n").Trim("`r`n").TrimEnd("`r`n ")

$Replacement = [TextReplace]@{
StartOffset = $BeginExtent.StartOffset
EndOffset = $BeginExtent.EndOffset
Text = $this.BeginBlockTemplate.Extent.Text.Replace("existingcode", $BeginBlockText)
}

$this.Replacements.Add( $Replacement )
} else {
Write-Debug "$($ast.Name) Missing BeginBlock"
}
}

if ($this.ProcessBlockTemplate) {
if ($ast.Body.ProcessBlock) {
# In a "filter" function, the process block may contain the param block
$ProcessBlockExtent = $ast.Body.ProcessBlock.Extent

if ($ast.Body.ProcessBlock.UnNamed -and $ast.Body.ParamBlock.Extent.Text) {
# Trim the paramBlock out of the end block
$ProcessBlockText = $ProcessBlockExtent.Text.Remove(
$ast.Body.ParamBlock.Extent.StartOffset - $ProcessBlockExtent.StartOffset,
$ast.Body.ParamBlock.Extent.EndOffset - $ast.Body.ParamBlock.Extent.StartOffset)
$StartOffset = $ast.Body.ParamBlock.Extent.EndOffset
} else {
# Trim the `process {` ... `}` because we're inserting it into the template process
$ProcessBlockText = ($ProcessBlockExtent.Text -replace "^process[\s\r\n]*{|}[\s\r\n]*$", "`n").Trim("`r`n").TrimEnd("`r`n ")
$StartOffset = $ProcessBlockExtent.StartOffset
}

$Replacement = [TextReplace]@{
StartOffset = $StartOffset
EndOffset = $ProcessBlockExtent.EndOffset
Text = $this.ProcessBlockTemplate.Extent.Text.Replace("existingcode", $ProcessBlockText)
}

$this.Replacements.Add( $Replacement )
} else {
Write-Debug "$($ast.Name) Missing ProcessBlock"
}
}

if ($this.EndBlockTemplate) {
if ($ast.Body.EndBlock) {
# The end block is a problem because it frequently contains the param block, which must be left alone
$EndBlockExtent = $ast.Body.EndBlock.Extent

$EndBlockText = $EndBlockExtent.Text
$StartOffset = $EndBlockExtent.StartOffset
if ($ast.Body.EndBlock.UnNamed -and $ast.Body.ParamBlock.Extent.Text) {
# Trim the paramBlock out of the end block
$EndBlockText = $EndBlockExtent.Text.Remove(
$ast.Body.ParamBlock.Extent.StartOffset - $EndBlockExtent.StartOffset,
$ast.Body.ParamBlock.Extent.EndOffset - $ast.Body.ParamBlock.Extent.StartOffset)
$StartOffset = $ast.Body.ParamBlock.Extent.EndOffset
} else {
# Trim the `end {` ... `}` because we're inserting it into the template end
$EndBlockText = ($EndBlockExtent.Text -replace "^end[\s\r\n]*{|}[\s\r\n]*$", "`n").Trim("`r`n").TrimEnd("`r`n ")
}

$Replacement = [TextReplace]@{
StartOffset = $StartOffset
EndOffset = $EndBlockExtent.EndOffset
Text = $this.EndBlockTemplate.Extent.Text.Replace("existingcode", $EndBlockText)
}

$this.Replacements.Add( $Replacement )
} else {
Write-Debug "$($ast.Name) Missing EndBlock"
}
}

return [AstVisitAction]::SkipChildren
}
}
11 changes: 11 additions & 0 deletions Source/Private/GetBuildInfo.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,17 @@ function GetBuildInfo {
}
}

# Make sure Aspects is an array of objects (instead of hashtables)
if ($BuildInfo.Aspects) {
$BuildInfo.Aspects = $BuildInfo.Aspects | ForEach-Object {
if ($_ -is [hashtable]) {
[PSCustomObject]$_
} else {
$_
}
}
}

$BuildInfo = $BuildInfo | Update-Object $ParameterValues
Write-Debug "Using Module Manifest $($BuildInfo.SourcePath)"

Expand Down
59 changes: 59 additions & 0 deletions Source/Private/MergeAspect.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
function MergeAspect {
<#
.SYNOPSIS
Merge features of a script into commands from a module, using a ModuleBuilderAspect
.DESCRIPTION
This is an aspect-oriented programming approach for adding cross-cutting features to functions in a module.
The [ModuleBuilderAspect] implementations are [AstVisitors] that return [TextReplace] object representing modifications to be performed on the source.
#>
[CmdletBinding()]
param(
# The path to the RootModule psm1 to merge the aspect into
[Parameter(Mandatory, Position = 0)]
[string]$RootModule,

# The name of the ModuleBuilder Generator to invoke.
# There are two built in:
# - MergeBlocks. Supports Before/After/Around blocks for aspects like error handling or authentication.
# - AddParameter. Supports adding common parameters to functions (usually in conjunction with MergeBlock that use those parameters)
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[ValidateScript({ (($_ -As [Type]), ("${_}Aspect" -As [Type])).BaseType -eq [ModuleBuilderAspect] })]
[string]$Action,

# The name(s) of functions in the module to run the generator against. Supports wildcards.
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[string[]]$Function,

# The name of the script path or function that contains the base which drives the generator
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[string]$Source
)
process {
#! We can't reuse the AST because it needs to be updated after we change it
#! But we can handle this in a wrapper
Write-Verbose "Parsing $RootModule for $Action with $Source"
$Ast = ConvertToAst $RootModule

$Action = if ($Action -As [Type]) {
$Action
} elseif ("${Action}Aspect" -As [Type]) {
"${Action}Aspect"
} else {
throw "Can't find $Action ModuleBuilderAspect"
}

$Aspect = New-Object $Action -Property @{
Where = { $Func = $_; $Function.ForEach({ $Func.Name -like $_ }) -contains $true }.GetNewClosure()
Aspect = @(Get-Command (Join-Path $AspectDirectory $Source), $Source -ErrorAction Ignore)[0].ScriptBlock.Ast
}

#! Process replacements from the bottom up, so the line numbers work
$Content = Get-Content $RootModule -Raw
Write-Verbose "Generating $Action in $RootModule"
foreach ($replacement in $Aspect.Generate($Ast.Ast) | Sort-Object StartOffset -Descending) {
$Content = $Content.Remove($replacement.StartOffset, ($replacement.EndOffset - $replacement.StartOffset)).Insert($replacement.StartOffset, $replacement.Text)
}
Set-Content $RootModule $Content
}
}
19 changes: 19 additions & 0 deletions Source/Public/Build-Module.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,19 @@ function Build-Module {
[ValidateSet("Clean", "Build", "CleanBuild")]
[string]$Target = "CleanBuild",

# A list of Aspects to apply to the module
# Each aspect contains a Function (pattern), Action and Source
# For example:
# @{ Function = "*"; Action = "MergeBlocks"; Source = "TraceBlocks" }
# There are only two Actions built in:
# - AddParameter. Supports adding common parameters to functions
# - MergeBlocks. Supports adding code Before/After/Around existing blocks for aspects like error handling or authentication.
[PSCustomObject[]]$Aspects,

# The folder (relative to the module folder) which contains the scripts to be used as Source for Aspects
# Defaults to "Aspects"
[string]$AspectDirectory = "[Aa]spects",

# Output the ModuleInfo of the "built" module
[switch]$Passthru
)
Expand Down Expand Up @@ -283,6 +296,12 @@ function Build-Module {
}
}

if ($ModuleInfo.Aspects) {
$AspectDirectory = Join-Path -Path $ModuleInfo.ModuleBase -ChildPath $ModuleInfo.AspectDirectory | Convert-Path -ErrorAction SilentlyContinue
Write-Verbose "Apply $($ModuleInfo.Aspects.Count) Aspects from $AspectDirectory"
$ModuleInfo.Aspects | MergeAspect $RootModule
}

# This is mostly for testing ...
if ($Passthru) {
Get-Module $OutputManifest -ListAvailable
Expand Down

0 comments on commit 61f2e25

Please sign in to comment.