Skip to content

Commit 8296a5b

Browse files
committed
Add Aspects (for cross-cutting concerns)
semver:feature
1 parent fe0d086 commit 8296a5b

10 files changed

+310
-0
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class ParameterPosition {
2+
[string]$Name
3+
[int]$StartOffset
4+
[string]$Text
5+
}

Source/Classes/11. TextReplace.ps1

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class TextReplace {
2+
[int]$StartOffset = 0
3+
[int]$EndOffset = 0
4+
[string]$Text = ''
5+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
class ModuleBuilderAspect : AstVisitor {
2+
[List[TextReplace]]$Replacements = @()
3+
[ScriptBlock]$Where = { $true }
4+
[Ast]$Aspect
5+
6+
[List[TextReplace]]Generate([Ast]$ast) {
7+
$ast.Visit($this)
8+
return $this.Replacements
9+
}
10+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
class ParameterExtractor : AstVisitor {
2+
[ParameterPosition[]]$Parameters = @()
3+
[int]$InsertLineNumber = -1
4+
[int]$InsertColumnNumber = -1
5+
[int]$InsertOffset = -1
6+
7+
ParameterExtractor([Ast]$Ast) {
8+
$ast.Visit($this)
9+
}
10+
11+
[AstVisitAction] VisitParamBlock([ParamBlockAst]$ast) {
12+
if ($Ast.Parameters) {
13+
$Text = $ast.Extent.Text -split "\r?\n"
14+
15+
$FirstLine = $ast.Extent.StartLineNumber
16+
$NextLine = 1
17+
$this.Parameters = @(
18+
foreach ($parameter in $ast.Parameters | Select-Object Name -Expand Extent) {
19+
[ParameterPosition]@{
20+
Name = $parameter.Name
21+
StartOffset = $parameter.StartOffset
22+
Text = if (($parameter.StartLineNumber - $FirstLine) -ge $NextLine) {
23+
Write-Debug "Extracted parameter $($Parameter.Name) with surrounding lines"
24+
# Take lines after the last parameter
25+
$Lines = @($Text[$NextLine..($parameter.EndLineNumber - $FirstLine)].Where{ ![string]::IsNullOrWhiteSpace($_) })
26+
# If the last line extends past the end of the parameter, trim that line
27+
if ($Lines.Length -gt 0 -and $parameter.EndColumnNumber -lt $Lines[-1].Length) {
28+
$Lines[-1] = $Lines[-1].SubString($parameter.EndColumnNumber)
29+
}
30+
# Don't return the commas, we'll add them back later
31+
($Lines -join "`n").TrimEnd(",")
32+
} else {
33+
Write-Debug "Extracted parameter $($Parameter.Name) text exactly"
34+
$parameter.Text.TrimEnd(",")
35+
}
36+
}
37+
$NextLine = 1 + $parameter.EndLineNumber - $FirstLine
38+
}
39+
)
40+
41+
$this.InsertLineNumber = $ast.Parameters[-1].Extent.EndLineNumber
42+
$this.InsertColumnNumber = $ast.Parameters[-1].Extent.EndColumnNumber
43+
$this.InsertOffset = $ast.Parameters[-1].Extent.EndOffset
44+
} else {
45+
$this.InsertLineNumber = $ast.Extent.EndLineNumber
46+
$this.InsertColumnNumber = $ast.Extent.EndColumnNumber - 1
47+
$this.InsertOffset = $ast.Extent.EndOffset - 1
48+
}
49+
return [AstVisitAction]::StopVisit
50+
}
51+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
class AddParameterAspect : ModuleBuilderAspect {
2+
[System.Management.Automation.HiddenAttribute()]
3+
[ParameterExtractor]$AdditionalParameterCache
4+
5+
[ParameterExtractor]GetAdditional() {
6+
if (!$this.AdditionalParameterCache) {
7+
$this.AdditionalParameterCache = $this.Aspect
8+
}
9+
return $this.AdditionalParameterCache
10+
}
11+
12+
[AstVisitAction] VisitFunctionDefinition([FunctionDefinitionAst]$ast) {
13+
if (!$ast.Where($this.Where)) {
14+
return [AstVisitAction]::SkipChildren
15+
}
16+
$Existing = [ParameterExtractor]$ast
17+
$Additional = $this.GetAdditional().Parameters.Where{ $_.Name -notin $Existing.Parameters.Name }
18+
if (($Text = $Additional.Text -join ",`n`n")) {
19+
$Replacement = [TextReplace]@{
20+
StartOffset = $Existing.InsertOffset
21+
EndOffset = $Existing.InsertOffset
22+
Text = if ($Existing.Parameters.Count -gt 0) {
23+
",`n`n" + $Text
24+
} else {
25+
"`n" + $Text
26+
}
27+
}
28+
29+
Write-Debug "Adding parameters to $($ast.name): $($Additional.Name -join ', ')"
30+
$this.Replacements.Add($Replacement)
31+
}
32+
return [AstVisitAction]::SkipChildren
33+
}
34+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
class MergeBlocksAspect : ModuleBuilderAspect {
2+
[System.Management.Automation.HiddenAttribute()]
3+
[NamedBlockAst]$BeginBlockTemplate
4+
5+
[System.Management.Automation.HiddenAttribute()]
6+
[NamedBlockAst]$ProcessBlockTemplate
7+
8+
[System.Management.Automation.HiddenAttribute()]
9+
[NamedBlockAst]$EndBlockTemplate
10+
11+
[List[TextReplace]]Generate([Ast]$ast) {
12+
if (!($this.BeginBlockTemplate = $this.Aspect.Find({ $args[0] -is [NamedBlockAst] -and $args[0].BlockKind -eq "Begin" }, $false))) {
13+
Write-Debug "No Aspect for BeginBlock"
14+
} else {
15+
Write-Debug "BeginBlock Aspect: $($this.BeginBlockTemplate)"
16+
}
17+
if (!($this.ProcessBlockTemplate = $this.Aspect.Find({ $args[0] -is [NamedBlockAst] -and $args[0].BlockKind -eq "Process" }, $false))) {
18+
Write-Debug "No Aspect for ProcessBlock"
19+
} else {
20+
Write-Debug "ProcessBlock Aspect: $($this.ProcessBlockTemplate)"
21+
}
22+
if (!($this.EndBlockTemplate = $this.Aspect.Find({ $args[0] -is [NamedBlockAst] -and $args[0].BlockKind -eq "End" }, $false))) {
23+
Write-Debug "No Aspect for EndBlock"
24+
} else {
25+
Write-Debug "EndBlock Aspect: $($this.EndBlockTemplate)"
26+
}
27+
28+
$ast.Visit($this)
29+
return $this.Replacements
30+
}
31+
32+
# The [Alias(...)] attribute on functions matters, but we can't export aliases that are defined inside a function
33+
[AstVisitAction] VisitFunctionDefinition([FunctionDefinitionAst]$ast) {
34+
if (!$ast.Where($this.Where)) {
35+
return [AstVisitAction]::SkipChildren
36+
}
37+
38+
if ($this.BeginBlockTemplate) {
39+
if ($ast.Body.BeginBlock) {
40+
$BeginExtent = $ast.Body.BeginBlock.Extent
41+
$BeginBlockText = ($BeginExtent.Text -replace "^begin[\s\r\n]*{|}[\s\r\n]*$", "`n").Trim("`r`n").TrimEnd("`r`n ")
42+
43+
$Replacement = [TextReplace]@{
44+
StartOffset = $BeginExtent.StartOffset
45+
EndOffset = $BeginExtent.EndOffset
46+
Text = $this.BeginBlockTemplate.Extent.Text.Replace("existingcode", $BeginBlockText)
47+
}
48+
49+
$this.Replacements.Add( $Replacement )
50+
} else {
51+
Write-Debug "$($ast.Name) Missing BeginBlock"
52+
}
53+
}
54+
55+
if ($this.ProcessBlockTemplate) {
56+
if ($ast.Body.ProcessBlock) {
57+
# In a "filter" function, the process block may contain the param block
58+
$ProcessBlockExtent = $ast.Body.ProcessBlock.Extent
59+
60+
if ($ast.Body.ProcessBlock.UnNamed -and $ast.Body.ParamBlock.Extent.Text) {
61+
# Trim the paramBlock out of the end block
62+
$ProcessBlockText = $ProcessBlockExtent.Text.Remove(
63+
$ast.Body.ParamBlock.Extent.StartOffset - $ProcessBlockExtent.StartOffset,
64+
$ast.Body.ParamBlock.Extent.EndOffset - $ast.Body.ParamBlock.Extent.StartOffset)
65+
$StartOffset = $ast.Body.ParamBlock.Extent.EndOffset
66+
} else {
67+
# Trim the `process {` ... `}` because we're inserting it into the template process
68+
$ProcessBlockText = ($ProcessBlockExtent.Text -replace "^process[\s\r\n]*{|}[\s\r\n]*$", "`n").Trim("`r`n").TrimEnd("`r`n ")
69+
$StartOffset = $ProcessBlockExtent.StartOffset
70+
}
71+
72+
$Replacement = [TextReplace]@{
73+
StartOffset = $StartOffset
74+
EndOffset = $ProcessBlockExtent.EndOffset
75+
Text = $this.ProcessBlockTemplate.Extent.Text.Replace("existingcode", $ProcessBlockText)
76+
}
77+
78+
$this.Replacements.Add( $Replacement )
79+
} else {
80+
Write-Debug "$($ast.Name) Missing ProcessBlock"
81+
}
82+
}
83+
84+
if ($this.EndBlockTemplate) {
85+
if ($ast.Body.EndBlock) {
86+
# The end block is a problem because it frequently contains the param block, which must be left alone
87+
$EndBlockExtent = $ast.Body.EndBlock.Extent
88+
89+
$EndBlockText = $EndBlockExtent.Text
90+
$StartOffset = $EndBlockExtent.StartOffset
91+
if ($ast.Body.EndBlock.UnNamed -and $ast.Body.ParamBlock.Extent.Text) {
92+
# Trim the paramBlock out of the end block
93+
$EndBlockText = $EndBlockExtent.Text.Remove(
94+
$ast.Body.ParamBlock.Extent.StartOffset - $EndBlockExtent.StartOffset,
95+
$ast.Body.ParamBlock.Extent.EndOffset - $ast.Body.ParamBlock.Extent.StartOffset)
96+
$StartOffset = $ast.Body.ParamBlock.Extent.EndOffset
97+
} else {
98+
# Trim the `end {` ... `}` because we're inserting it into the template end
99+
$EndBlockText = ($EndBlockExtent.Text -replace "^end[\s\r\n]*{|}[\s\r\n]*$", "`n").Trim("`r`n").TrimEnd("`r`n ")
100+
}
101+
102+
$Replacement = [TextReplace]@{
103+
StartOffset = $StartOffset
104+
EndOffset = $EndBlockExtent.EndOffset
105+
Text = $this.EndBlockTemplate.Extent.Text.Replace("existingcode", $EndBlockText)
106+
}
107+
108+
$this.Replacements.Add( $Replacement )
109+
} else {
110+
Write-Debug "$($ast.Name) Missing EndBlock"
111+
}
112+
}
113+
114+
return [AstVisitAction]::SkipChildren
115+
}
116+
}

Source/Private/GetBuildInfo.ps1

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,17 @@ function GetBuildInfo {
111111
}
112112
}
113113

114+
# Make sure Aspects is an array of objects (instead of hashtables)
115+
if ($BuildInfo.Aspects) {
116+
$BuildInfo.Aspects = $BuildInfo.Aspects | ForEach-Object {
117+
if ($_ -is [hashtable]) {
118+
[PSCustomObject]$_
119+
} else {
120+
$_
121+
}
122+
}
123+
}
124+
114125
$BuildInfo = $BuildInfo | Update-Object $ParameterValues
115126
Write-Debug "Using Module Manifest $($BuildInfo.SourcePath)"
116127

Source/Private/MergeAspect.ps1

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
function MergeAspect {
2+
<#
3+
.SYNOPSIS
4+
Merge features of a script into commands from a module, using a ModuleBuilderAspect
5+
.DESCRIPTION
6+
This is an aspect-oriented programming approach for adding cross-cutting features to functions in a module.
7+
8+
The [ModuleBuilderAspect] implementations are [AstVisitors] that return [TextReplace] object representing modifications to be performed on the source.
9+
#>
10+
[CmdletBinding()]
11+
param(
12+
# The path to the RootModule psm1 to merge the aspect into
13+
[Parameter(Mandatory, Position = 0)]
14+
[string]$RootModule,
15+
16+
# The name of the ModuleBuilder Generator to invoke.
17+
# There are two built in:
18+
# - MergeBlocks. Supports Before/After/Around blocks for aspects like error handling or authentication.
19+
# - AddParameter. Supports adding common parameters to functions (usually in conjunction with MergeBlock that use those parameters)
20+
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
21+
[ValidateScript({ (($_ -As [Type]), ("${_}Aspect" -As [Type])).BaseType -eq [ModuleBuilderAspect] })]
22+
[string]$Action,
23+
24+
# The name(s) of functions in the module to run the generator against. Supports wildcards.
25+
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
26+
[string[]]$Function,
27+
28+
# The name of the script path or function that contains the base which drives the generator
29+
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
30+
[string]$Source
31+
)
32+
process {
33+
#! We can't reuse the AST because it needs to be updated after we change it
34+
#! But we can handle this in a wrapper
35+
Write-Verbose "Parsing $RootModule for $Action with $Source"
36+
$Ast = ConvertToAst $RootModule
37+
38+
$Action = if ($Action -As [Type]) {
39+
$Action
40+
} elseif ("${Action}Aspect" -As [Type]) {
41+
"${Action}Aspect"
42+
} else {
43+
throw "Can't find $Action ModuleBuilderAspect"
44+
}
45+
46+
$Aspect = New-Object $Action -Property @{
47+
Where = { $Func = $_; $Function.ForEach({ $Func.Name -like $_ }) -contains $true }.GetNewClosure()
48+
Aspect = @(Get-Command (Join-Path $AspectDirectory $Source), $Source -ErrorAction Ignore)[0].ScriptBlock.Ast
49+
}
50+
51+
#! Process replacements from the bottom up, so the line numbers work
52+
$Content = Get-Content $RootModule -Raw
53+
Write-Verbose "Generating $Action in $RootModule"
54+
foreach ($replacement in $Aspect.Generate($Ast.Ast) | Sort-Object StartOffset -Descending) {
55+
$Content = $Content.Remove($replacement.StartOffset, ($replacement.EndOffset - $replacement.StartOffset)).Insert($replacement.StartOffset, $replacement.Text)
56+
}
57+
Set-Content $RootModule $Content
58+
}
59+
}

Source/Public/Build-Module.ps1

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,19 @@ function Build-Module {
139139
[ValidateSet("Clean", "Build", "CleanBuild")]
140140
[string]$Target = "CleanBuild",
141141

142+
# A list of Aspects to apply to the module
143+
# Each aspect contains a Function (pattern), Action and Source
144+
# For example:
145+
# @{ Function = "*"; Action = "MergeBlocks"; Source = "TraceBlocks" }
146+
# There are only two Actions built in:
147+
# - AddParameter. Supports adding common parameters to functions
148+
# - MergeBlocks. Supports adding code Before/After/Around existing blocks for aspects like error handling or authentication.
149+
[PSCustomObject[]]$Aspects,
150+
151+
# The folder (relative to the module folder) which contains the scripts to be used as Source for Aspects
152+
# Defaults to "Aspects"
153+
[string]$AspectDirectory = "[Aa]spects",
154+
142155
# Output the ModuleInfo of the "built" module
143156
[switch]$Passthru
144157
)
@@ -283,6 +296,12 @@ function Build-Module {
283296
}
284297
}
285298

299+
if ($ModuleInfo.Aspects) {
300+
$AspectDirectory = Join-Path -Path $ModuleInfo.ModuleBase -ChildPath $ModuleInfo.AspectDirectory | Convert-Path -ErrorAction SilentlyContinue
301+
Write-Verbose "Apply $($ModuleInfo.Aspects.Count) Aspects from $AspectDirectory"
302+
$ModuleInfo.Aspects | MergeAspect $RootModule
303+
}
304+
286305
# This is mostly for testing ...
287306
if ($Passthru) {
288307
Get-Module $OutputManifest -ListAvailable

0 commit comments

Comments
 (0)