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