From 8296a5b606e986679b045eccc8ca761ff43628dd Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Mon, 23 Oct 2023 00:47:31 -0400 Subject: [PATCH 1/2] Add Aspects (for cross-cutting concerns) semver:feature --- ...{AliasVisitor.ps1 => 00. AliasVisitor.ps1} | 0 Source/Classes/10. ParameterPosition.ps1 | 5 + Source/Classes/11. TextReplace.ps1 | 5 + Source/Classes/20. ModuleBuilderAspect.ps1 | 10 ++ Source/Classes/21. ParameterExtractor.ps1 | 51 ++++++++ Source/Classes/22. AddParameterAspect.ps1 | 34 +++++ Source/Classes/23. MergeBlocksAspect.ps1 | 116 ++++++++++++++++++ Source/Private/GetBuildInfo.ps1 | 11 ++ Source/Private/MergeAspect.ps1 | 59 +++++++++ Source/Public/Build-Module.ps1 | 19 +++ 10 files changed, 310 insertions(+) rename Source/Classes/{AliasVisitor.ps1 => 00. AliasVisitor.ps1} (100%) create mode 100644 Source/Classes/10. ParameterPosition.ps1 create mode 100644 Source/Classes/11. TextReplace.ps1 create mode 100644 Source/Classes/20. ModuleBuilderAspect.ps1 create mode 100644 Source/Classes/21. ParameterExtractor.ps1 create mode 100644 Source/Classes/22. AddParameterAspect.ps1 create mode 100644 Source/Classes/23. MergeBlocksAspect.ps1 create mode 100644 Source/Private/MergeAspect.ps1 diff --git a/Source/Classes/AliasVisitor.ps1 b/Source/Classes/00. AliasVisitor.ps1 similarity index 100% rename from Source/Classes/AliasVisitor.ps1 rename to Source/Classes/00. AliasVisitor.ps1 diff --git a/Source/Classes/10. ParameterPosition.ps1 b/Source/Classes/10. ParameterPosition.ps1 new file mode 100644 index 0000000..e760cc8 --- /dev/null +++ b/Source/Classes/10. ParameterPosition.ps1 @@ -0,0 +1,5 @@ +class ParameterPosition { + [string]$Name + [int]$StartOffset + [string]$Text +} diff --git a/Source/Classes/11. TextReplace.ps1 b/Source/Classes/11. TextReplace.ps1 new file mode 100644 index 0000000..1cc0356 --- /dev/null +++ b/Source/Classes/11. TextReplace.ps1 @@ -0,0 +1,5 @@ +class TextReplace { + [int]$StartOffset = 0 + [int]$EndOffset = 0 + [string]$Text = '' +} diff --git a/Source/Classes/20. ModuleBuilderAspect.ps1 b/Source/Classes/20. ModuleBuilderAspect.ps1 new file mode 100644 index 0000000..749e4ac --- /dev/null +++ b/Source/Classes/20. ModuleBuilderAspect.ps1 @@ -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 + } +} diff --git a/Source/Classes/21. ParameterExtractor.ps1 b/Source/Classes/21. ParameterExtractor.ps1 new file mode 100644 index 0000000..a8194eb --- /dev/null +++ b/Source/Classes/21. ParameterExtractor.ps1 @@ -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 + } +} diff --git a/Source/Classes/22. AddParameterAspect.ps1 b/Source/Classes/22. AddParameterAspect.ps1 new file mode 100644 index 0000000..fddf7b3 --- /dev/null +++ b/Source/Classes/22. AddParameterAspect.ps1 @@ -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 + } +} diff --git a/Source/Classes/23. MergeBlocksAspect.ps1 b/Source/Classes/23. MergeBlocksAspect.ps1 new file mode 100644 index 0000000..f65be72 --- /dev/null +++ b/Source/Classes/23. MergeBlocksAspect.ps1 @@ -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 + } +} diff --git a/Source/Private/GetBuildInfo.ps1 b/Source/Private/GetBuildInfo.ps1 index b9cf51c..e4f1ecd 100644 --- a/Source/Private/GetBuildInfo.ps1 +++ b/Source/Private/GetBuildInfo.ps1 @@ -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)" diff --git a/Source/Private/MergeAspect.ps1 b/Source/Private/MergeAspect.ps1 new file mode 100644 index 0000000..a9c879d --- /dev/null +++ b/Source/Private/MergeAspect.ps1 @@ -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 + } +} diff --git a/Source/Public/Build-Module.ps1 b/Source/Public/Build-Module.ps1 index 88bf6ef..1c49c15 100644 --- a/Source/Public/Build-Module.ps1 +++ b/Source/Public/Build-Module.ps1 @@ -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 ) @@ -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 From 532c4c2666e863331c12278c65fc18fba4439365 Mon Sep 17 00:00:00 2001 From: Joel Bennett Date: Sun, 14 Jan 2024 11:18:58 -0500 Subject: [PATCH 2/2] WIP: Modern .NET calls them Generators --- .../ModuleBuilderExtensions.ps1 | 215 ++++++++++++++++++ Source/Classes/20. ModuleBuilderAspect.ps1 | 10 - Source/Classes/20. ModuleBuilderGenerator.ps1 | 67 ++++++ Source/Classes/21. ParameterExtractor.ps1 | 9 + ...rAspect.ps1 => 22. ParameterGenerator.ps1} | 5 +- ...locksAspect.ps1 => 23. BlockGenerator.ps1} | 6 +- Source/ModuleBuilder.psd1 | 2 +- .../{MergeAspect.ps1 => InvokeGenerator.ps1} | 10 +- Source/Public/Build-Module.ps1 | 2 +- 9 files changed, 307 insertions(+), 19 deletions(-) create mode 100644 PotentialContribution/ModuleBuilderExtensions.ps1 delete mode 100644 Source/Classes/20. ModuleBuilderAspect.ps1 create mode 100644 Source/Classes/20. ModuleBuilderGenerator.ps1 rename Source/Classes/{22. AddParameterAspect.ps1 => 22. ParameterGenerator.ps1} (89%) rename Source/Classes/{23. MergeBlocksAspect.ps1 => 23. BlockGenerator.ps1} (97%) rename Source/Private/{MergeAspect.ps1 => InvokeGenerator.ps1} (86%) diff --git a/PotentialContribution/ModuleBuilderExtensions.ps1 b/PotentialContribution/ModuleBuilderExtensions.ps1 new file mode 100644 index 0000000..718a810 --- /dev/null +++ b/PotentialContribution/ModuleBuilderExtensions.ps1 @@ -0,0 +1,215 @@ +using namespace System.Management.Automation.Language +using namespace System.Collections.Generic + + + + + +# There should be an abstract class for ModuleBuilderGenerator that has a contract for this: + + +# Should be called on a block to extract the (first) parameters from that block +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 + } +} + +class AddParameter : ModuleBuilderGenerator { + [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 + } +} + +class MergeBlocks : ModuleBuilderGenerator { + [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 + } +} + diff --git a/Source/Classes/20. ModuleBuilderAspect.ps1 b/Source/Classes/20. ModuleBuilderAspect.ps1 deleted file mode 100644 index 749e4ac..0000000 --- a/Source/Classes/20. ModuleBuilderAspect.ps1 +++ /dev/null @@ -1,10 +0,0 @@ -class ModuleBuilderAspect : AstVisitor { - [List[TextReplace]]$Replacements = @() - [ScriptBlock]$Where = { $true } - [Ast]$Aspect - - [List[TextReplace]]Generate([Ast]$ast) { - $ast.Visit($this) - return $this.Replacements - } -} diff --git a/Source/Classes/20. ModuleBuilderGenerator.ps1 b/Source/Classes/20. ModuleBuilderGenerator.ps1 new file mode 100644 index 0000000..d45103b --- /dev/null +++ b/Source/Classes/20. ModuleBuilderGenerator.ps1 @@ -0,0 +1,67 @@ +using namespace System.Management.Automation.Language +using namespace System.Collections.Generic +class TextReplace { + [int]$StartOffset = 0 + [int]$EndOffset = 0 + [string]$Text = '' +} + +class ModuleBuilderGenerator { + hidden [List[TextReplace]]$Replacements = @() + + [void] Replace($StartOffset, $EndOffset, $Text) { + $this.Replacements.Add([TextReplace]@{ + StartOffset = $StartOffset + EndOffset = $EndOffset + Text = $Text + }) + } + + [void] Insert($StartOffset, $Text) { + $this.Replacements.Add([TextReplace]@{ + StartOffset = $StartOffset + EndOffset = $StartOffset + Text = $Text + }) + } + + [ScriptBlock]$Filter = { $true } + + [Ast]$Ast + + hidden [string]$Path + + ModuleBuilderGenerator($Path) { + $this.Path = $Path + $this.Ast = ConvertToAst $Path + + } + + AddParameter([ScriptBlock]$FromScriptBlock) { + [ParameterExtractor]$ExistingParameters = $this.Ast + [ParameterExtractor]$AdditionalParameters = $FromScriptBlock.Ast + + $Additional = $AdditionalParameters.Parameters.Where{ $_.Name -notin $ExistingParameters.Parameters.Name } + if (($Text = $Additional.Text -join ",`n`n")) { + $Replacement = [TextReplace]@{ + StartOffset = $ExistingParameters.InsertOffset + EndOffset = $ExistingParameters.InsertOffset + Text = if ($ExistingParameters.Parameters.Count -gt 0) { + ",`n`n" + $Text + } else { + "`n" + $Text + } + } + + Write-Debug "Adding parameters to $($this.Ast.name): $($Additional.Name -join ', ')" + $this.Replacements.Add($Replacement) + } + } + + + + [List[TextReplace]]Generate([Ast]$ast) { + $ast.Visit($this) + return $this.Replacements + } +} diff --git a/Source/Classes/21. ParameterExtractor.ps1 b/Source/Classes/21. ParameterExtractor.ps1 index a8194eb..cd70bff 100644 --- a/Source/Classes/21. ParameterExtractor.ps1 +++ b/Source/Classes/21. ParameterExtractor.ps1 @@ -1,3 +1,12 @@ +using namespace System.Management.Automation.Language +using namespace System.Collections.Generic + +class ParameterPosition { + [string]$Name + [int]$StartOffset + [string]$Text +} + class ParameterExtractor : AstVisitor { [ParameterPosition[]]$Parameters = @() [int]$InsertLineNumber = -1 diff --git a/Source/Classes/22. AddParameterAspect.ps1 b/Source/Classes/22. ParameterGenerator.ps1 similarity index 89% rename from Source/Classes/22. AddParameterAspect.ps1 rename to Source/Classes/22. ParameterGenerator.ps1 index fddf7b3..4359a8c 100644 --- a/Source/Classes/22. AddParameterAspect.ps1 +++ b/Source/Classes/22. ParameterGenerator.ps1 @@ -1,4 +1,7 @@ -class AddParameterAspect : ModuleBuilderAspect { +using namespace System.Management.Automation.Language +using namespace System.Collections.Generic + +class ParameterGenerator : ModuleBuilderGenerator { [System.Management.Automation.HiddenAttribute()] [ParameterExtractor]$AdditionalParameterCache diff --git a/Source/Classes/23. MergeBlocksAspect.ps1 b/Source/Classes/23. BlockGenerator.ps1 similarity index 97% rename from Source/Classes/23. MergeBlocksAspect.ps1 rename to Source/Classes/23. BlockGenerator.ps1 index f65be72..7c7c2eb 100644 --- a/Source/Classes/23. MergeBlocksAspect.ps1 +++ b/Source/Classes/23. BlockGenerator.ps1 @@ -1,4 +1,7 @@ -class MergeBlocksAspect : ModuleBuilderAspect { +using namespace System.Management.Automation.Language +using namespace System.Collections.Generic + +class BlockGenerator : ModuleBuilderGenerator { [System.Management.Automation.HiddenAttribute()] [NamedBlockAst]$BeginBlockTemplate @@ -40,6 +43,7 @@ class MergeBlocksAspect : ModuleBuilderAspect { $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 diff --git a/Source/ModuleBuilder.psd1 b/Source/ModuleBuilder.psd1 index f9bcb72..514c879 100644 --- a/Source/ModuleBuilder.psd1 +++ b/Source/ModuleBuilder.psd1 @@ -11,7 +11,7 @@ # Release Notes have to be here, so we can update them ReleaseNotes = ' - Fix case sensitivity of defaults for SourceDirectories and PublicFilter + Add support for Aspect Oriented Programming (AOP) with the new `Aspects` parameter. ' # Tags applied to this module. These help with module discovery in online galleries. diff --git a/Source/Private/MergeAspect.ps1 b/Source/Private/InvokeGenerator.ps1 similarity index 86% rename from Source/Private/MergeAspect.ps1 rename to Source/Private/InvokeGenerator.ps1 index a9c879d..6f42e6e 100644 --- a/Source/Private/MergeAspect.ps1 +++ b/Source/Private/InvokeGenerator.ps1 @@ -1,11 +1,11 @@ -function MergeAspect { +function InvokeGenerator { <# .SYNOPSIS - Merge features of a script into commands from a module, using a ModuleBuilderAspect + Generate code using a ModuleBuilderGenerator .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. + The [ModuleBuilderGenerator] implementations are [AstVisitors] that return [TextReplace] object representing modifications to be performed on the source. #> [CmdletBinding()] param( @@ -18,7 +18,7 @@ function MergeAspect { # - 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] })] + [ValidateScript({ (($_ -As [Type]), ("${_}Aspect" -As [Type])).BaseType -eq [ModuleBuilderGenerator] })] [string]$Action, # The name(s) of functions in the module to run the generator against. Supports wildcards. @@ -40,7 +40,7 @@ function MergeAspect { } elseif ("${Action}Aspect" -As [Type]) { "${Action}Aspect" } else { - throw "Can't find $Action ModuleBuilderAspect" + throw "Can't find $Action ModuleBuilderGenerator" } $Aspect = New-Object $Action -Property @{ diff --git a/Source/Public/Build-Module.ps1 b/Source/Public/Build-Module.ps1 index 1c49c15..d94c449 100644 --- a/Source/Public/Build-Module.ps1 +++ b/Source/Public/Build-Module.ps1 @@ -299,7 +299,7 @@ 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 + $ModuleInfo.Aspects | InvokeGenerator $RootModule } # This is mostly for testing ...