diff --git a/Earthfile b/Earthfile index be47ddf..21b8d45 100644 --- a/Earthfile +++ b/Earthfile @@ -1,6 +1,6 @@ VERSION 0.7 IMPORT github.com/poshcode/tasks -FROM mcr.microsoft.com/dotnet/sdk:7.0 +FROM mcr.microsoft.com/dotnet/sdk:8.0 WORKDIR /work ARG --global EARTHLY_GIT_ORIGIN_URL 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/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. TextReplacement.ps1 b/Source/Classes/10. TextReplacement.ps1 new file mode 100644 index 0000000..e33e732 --- /dev/null +++ b/Source/Classes/10. TextReplacement.ps1 @@ -0,0 +1,5 @@ +class TextReplacement { + [int]$StartOffset = 0 + [int]$EndOffset = 0 + [string]$Text = '' +} diff --git a/Source/Classes/20. ParameterExtractor.ps1 b/Source/Classes/20. ParameterExtractor.ps1 new file mode 100644 index 0000000..f8c674e --- /dev/null +++ b/Source/Classes/20. ParameterExtractor.ps1 @@ -0,0 +1,51 @@ +using namespace System.Management.Automation.Language +using namespace System.Collections.Generic + +class ParameterExtractor : AstVisitor { + [System.Collections.Specialized.OrderedDictionary]$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 + foreach ($parameter in $ast.Parameters | Select-Object Name -Expand Extent) { + $this.Parameters.Add($parameter.Name, ([TextReplacement]@{ + 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/30. ModuleBuilderGenerator.ps1 b/Source/Classes/30. ModuleBuilderGenerator.ps1 new file mode 100644 index 0000000..e2551ad --- /dev/null +++ b/Source/Classes/30. ModuleBuilderGenerator.ps1 @@ -0,0 +1,62 @@ +using namespace System.Management.Automation.Language +using namespace System.Collections.Generic + +class ModuleBuilderGenerator { + hidden [List[TextReplacement]]$Replacements = @() + + [void] Replace($StartOffset, $EndOffset, $Text) { + $this.Replacements.Add([TextReplacement]@{ + StartOffset = $StartOffset + EndOffset = $EndOffset + Text = $Text + }) + } + + [void] Insert($StartOffset, $Text) { + $this.Replacements.Add([TextReplacement]@{ + 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 = [TextReplacement]@{ + 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[TextReplacement]]Generate([Ast]$ast) { + $ast.Visit($this) + return $this.Replacements + } +} diff --git a/Source/Classes/40. ParameterGenerator.ps1 b/Source/Classes/40. ParameterGenerator.ps1 new file mode 100644 index 0000000..c55d4b0 --- /dev/null +++ b/Source/Classes/40. ParameterGenerator.ps1 @@ -0,0 +1,42 @@ +using namespace System.Management.Automation.Language +using namespace System.Collections.Generic + +class ParameterGenerator : ModuleBuilderGenerator { + [System.Management.Automation.HiddenAttribute()] + [ParameterExtractor]$AdditionalParameterCache + + ParameterGenerator($Path) : base($Path) {} + + [System.Collections.Generic.Dictionary[string, TextReplacement]]GetAdditional() { + if (!$this.AdditionalParameterCache) { + $this.AdditionalParameterCache = [ParameterExtractor]$this.Ast + } + return $this.AdditionalParameterCache.Parameters + } + + [AstVisitAction] VisitFunctionDefinition([FunctionDefinitionAst]$ast) { + if (!$ast.Where($this.Where)) { + return [AstVisitAction]::SkipChildren + } + + $Existing = [ParameterExtractor]$ast + + $AdditionalParameters = $this.GetAdditional() + $Additional = $AdditionalParameters.Keys.Where{ $_.Name -notin $Existing.Parameters.Name } + if (($Text = $AdditionalParameters.Values.Text -join ",`n`n")) { + $Replacement = [TextReplacement]@{ + 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.Keys -join ', ')" + $this.Replacements.Add($Replacement) + } + return [AstVisitAction]::SkipChildren + } +} diff --git a/Source/Classes/50. BlockGenerator.ps1 b/Source/Classes/50. BlockGenerator.ps1 new file mode 100644 index 0000000..d835fe5 --- /dev/null +++ b/Source/Classes/50. BlockGenerator.ps1 @@ -0,0 +1,122 @@ +using namespace System.Management.Automation.Language +using namespace System.Collections.Generic + +class BlockGenerator : ModuleBuilderGenerator { + [System.Management.Automation.HiddenAttribute()] + [NamedBlockAst]$BeginBlockTemplate + + [System.Management.Automation.HiddenAttribute()] + [NamedBlockAst]$ProcessBlockTemplate + + [System.Management.Automation.HiddenAttribute()] + [NamedBlockAst]$EndBlockTemplate + + BlockGenerator($Path) : base($Path) {} + + [List[TextReplacement]]Generate([Ast]$ast) { + if (!($this.BeginBlockTemplate = $this.Ast.Find({ $args[0] -is [NamedBlockAst] -and $args[0].BlockKind -eq "Begin" }, $false))) { + Write-Debug "No Generator for BeginBlock" + } else { + Write-Debug "BeginBlock Generator: $($this.BeginBlockTemplate)" + } + if (!($this.ProcessBlockTemplate = $this.Ast.Find({ $args[0] -is [NamedBlockAst] -and $args[0].BlockKind -eq "Process" }, $false))) { + Write-Debug "No Generator for ProcessBlock" + } else { + Write-Debug "ProcessBlock Generator: $($this.ProcessBlockTemplate)" + } + if (!($this.EndBlockTemplate = $this.Ast.Find({ $args[0] -is [NamedBlockAst] -and $args[0].BlockKind -eq "End" }, $false))) { + Write-Debug "No Generator for EndBlock" + } else { + Write-Debug "EndBlock Generator: $($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 = [TextReplacement]@{ + 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 = [TextReplacement]@{ + 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 = [TextReplacement]@{ + 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/ModuleBuilder.psd1 b/Source/ModuleBuilder.psd1 index f9bcb72..843f37e 100644 --- a/Source/ModuleBuilder.psd1 +++ b/Source/ModuleBuilder.psd1 @@ -11,7 +11,8 @@ # Release Notes have to be here, so we can update them ReleaseNotes = ' - Fix case sensitivity of defaults for SourceDirectories and PublicFilter + Add support for Generators, so you can weave in solutions for cross-cutting concerns at "build" time. + Specifically targeting support for module-wide common parameters, standardized fall-back error handling, etc. ' # Tags applied to this module. These help with module discovery in online galleries. diff --git a/Source/Private/GetBuildInfo.ps1 b/Source/Private/GetBuildInfo.ps1 index b9cf51c..b01cff8 100644 --- a/Source/Private/GetBuildInfo.ps1 +++ b/Source/Private/GetBuildInfo.ps1 @@ -111,6 +111,17 @@ function GetBuildInfo { } } + # Make sure Generators is an array of objects (instead of hashtables) + if ($BuildInfo.Generators) { + $BuildInfo.Generators = $BuildInfo.Generators | ForEach-Object { + if ($_ -is [hashtable]) { + [PSCustomObject]$_ + } else { + $_ + } + } + } + $BuildInfo = $BuildInfo | Update-Object $ParameterValues Write-Debug "Using Module Manifest $($BuildInfo.SourcePath)" diff --git a/Source/Private/InvokeGenerator.ps1 b/Source/Private/InvokeGenerator.ps1 new file mode 100644 index 0000000..cc51f67 --- /dev/null +++ b/Source/Private/InvokeGenerator.ps1 @@ -0,0 +1,60 @@ +function InvokeGenerator { + <# + .SYNOPSIS + Generate code using a ModuleBuilderGenerator + .DESCRIPTION + This is an aspect-oriented programming approach for adding cross-cutting features to functions in a module. + + The [ModuleBuilderGenerator] implementations are [AstVisitors] that return [TextReplacement] objects representing modifications to be performed on the source. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Function', Justification = 'PSSA reads the AST wrong')] + [CmdletBinding()] + param( + # The path to the RootModule psm1 to apply the Generator to + [Parameter(Mandatory, Position = 0)] + [string]$RootModule, + + # The name of the ModuleBuilder Generator to invoke. + # There are only two Generators built in: + # - ParameterGenerator. Supports adding parameters to functions in the module. Parameters come from a template file, which must be a script with a param block. + # - BlockGenerator. Supports adding code before, after, and around existing blocks for generators like error handling, authentication, and implementations for common parameters. The added blocks come from a template file, which must be a script with named begin/process/end blocks. + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] + [ValidateScript({ (($_ -As [Type]), ("${_}Generator" -As [Type])).BaseType -eq [ModuleBuilderGenerator] })] + [string]$Generator, + + # The name(s) of functions in the RootModule to run the generator against. Supports wildcards. + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] + [string[]]$Function, + + # The name of a template file that customizes the generator + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] + [string]$Template + ) + 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 $Generator with $Template" + $Ast = ConvertToAst $RootModule + + $Generator = if ($Generator -As [Type]) { + $Generator + } elseif ("${Generator}Generator" -As [Type]) { + "${Generator}Generator" + } else { + throw "Can't find $Generator ModuleBuilderGenerator" + } + + $Generator = New-Object $Generator -Property @{ + Where = { $Func = $_; $Function.ForEach({ $Func.Name -like $_ }) -contains $true }.GetNewClosure() + Generator = @(Get-Command (Join-Path $GeneratorDirectory $Template), $Template -ErrorAction Ignore)[0].ScriptBlock.Ast + } + + #! Process replacements from the bottom up, so the line numbers work + $Content = Get-Content $RootModule -Raw + Write-Verbose "Generating $Generator in $RootModule" + foreach ($replacement in $Generator.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 f2c80c4..ab431e4 100644 --- a/Source/Public/Build-Module.ps1 +++ b/Source/Public/Build-Module.ps1 @@ -137,6 +137,24 @@ function Build-Module { [ValidateSet("Clean", "Build", "CleanBuild")] [string]$Target = "CleanBuild", + # A list of Generators to apply to the module. Each Generator has: + # - Function: The name(s) of functions in the RootModule to run the generator against. Supports wildcards. + # - Generator: The Typename of the ModuleBuilder Generator to invoke. + # - Template: the base name of a file that serves as input to the generator + # The template is technically optional, but the built-in generators both require a template. + # + # For example, if you wrote a template file "TraceBlocks.ps1", you could add it to the build like this: + # @{ Function = "*"; Generator = "Block"; Template = "TraceBlocks" } + # + # There are two Generators built-in: + # - ParameterGenerator. Supports adding parameters to functions in the module. Parameters come from a template file, which must be a script with a param block. + # - BlockGenerator. Supports adding code before, after, and around existing blocks for generators like error handling, authentication, and implementations for common parameters. The added blocks come from a template file, which must be a script with named begin/process/end blocks. + [PSCustomObject[]]$Generators, + + # The folder (relative to the module folder) which contains the scripts to be used as Source for Generators + # Defaults to "Generators" + [string]$GeneratorDirectory = "[Gg]enerators", + # Output the ModuleInfo of the "built" module [switch]$Passthru ) @@ -269,6 +287,12 @@ function Build-Module { } } + if ($ModuleInfo.Generators) { + $GeneratorDirectory = Join-Path -Path $ModuleInfo.ModuleBase -ChildPath $ModuleInfo.GeneratorDirectory | Convert-Path -ErrorAction SilentlyContinue + Write-Verbose "Apply $($ModuleInfo.Generators.Count) Generators from $GeneratorDirectory" + $ModuleInfo.Generators | InvokeGenerator $RootModule + } + # This is mostly for testing ... if ($ModuleInfo.Passthru) { Get-Module $OutputManifest -ListAvailable diff --git a/Tests/Integration/Source2.Tests.ps1 b/Tests/Integration/Source2.Tests.ps1 new file mode 100644 index 0000000..b457665 --- /dev/null +++ b/Tests/Integration/Source2.Tests.ps1 @@ -0,0 +1,14 @@ +#requires -Module ModuleBuilder +. $PSScriptRoot\..\Convert-FolderSeparator.ps1 + +Describe "Using Generators" -Tag Integration { + $Output = Build-Module $PSScriptRoot\Source2\build.psd1 -Passthru + $Module = [IO.Path]::ChangeExtension($Output.Path, "psm1") + + $Metadata = Import-Metadata $Output.Path + + It "Still builds the right functions and updates the manifest" { + $Metadata.FunctionsToExport | Should -Be @('Show-HistoryId', 'Show-HostName') + } + +} diff --git a/Tests/Integration/Source2/Generators/NewTerminalBlock.ps1 b/Tests/Integration/Source2/Generators/NewTerminalBlock.ps1 new file mode 100644 index 0000000..4c42740 --- /dev/null +++ b/Tests/Integration/Source2/Generators/NewTerminalBlock.ps1 @@ -0,0 +1,95 @@ +using module ModuleBuilder +class TerminalBlockGenerator : ModuleBuilderGenerator { + + [void] Generate() { + + $base.AddParameter({ + param( + [Alias("Prepend")] + [String]$Prefix, + + [Alias("Suffix", "Append")] + [String]$Postfix, + + # The separator character(s) are used between blocks of output by this scriptblock + # Pass two characters: the first for normal (Left aligned) blocks, the second for right-aligned blocks + [ArgumentCompleter({ + [System.Collections.Generic.List[System.Management.Automation.CompletionResult]]::new( + [System.Management.Automation.CompletionResult[]]@( + # The Consolas-friendly block characters ▌and▐ and ╲ followed by all the extended Terminal characters + @([string[]][char[]]@(@(0xe0b0..0xe0d4) + @(0x2588..0x259b) + @(0x256d..0x2572))).ForEach({ + [System.Management.Automation.CompletionResult]::new("'$_'", $_, "ParameterValue", $_) }) + )) + })] + [String]$Separator, + + # The cap character(s) are used on the ends of blocks of output + # Pass two characters: the first for the left side, the second for the right side. + [ArgumentCompleter({ + [System.Collections.Generic.List[System.Management.Automation.CompletionResult]]::new( + [System.Management.Automation.CompletionResult[]]@( + # The Consolas-friendly block characters ▌and▐ and ╲ followed by all the extended Terminal characters + @([string[]][char[]]@(@(0xe0b0..0xe0d4) + @(0x2588..0x259b) + @(0x256d..0x2572))).ForEach({ + [System.Management.Automation.CompletionResult]::new("'$_'", $_, "ParameterValue", $_) }) + )) + })] + [PoshCode.BlockCaps]$Caps, + + # The foreground color to use when the last command succeeded + [Alias("ForegroundColor", "Fg", "DFg")] + [AllowNull()][EmptyStringAsNull()] + [ArgumentCompleter([PoshCode.Pansies.Palettes.X11Palette])] + [PoshCode.Pansies.RgbColor]$DefaultForegroundColor, + + # The background color to use when the last command succeeded + [Alias("BackgroundColor", "Bg", "DBg")] + [AllowNull()][EmptyStringAsNull()] + [ArgumentCompleter([PoshCode.Pansies.Palettes.X11Palette])] + [PoshCode.Pansies.RgbColor]$DefaultBackgroundColor, + + # The foreground color to use when the process is elevated (running as administrator) + [Alias("AdminFg", "AFg")] + [AllowNull()][EmptyStringAsNull()] + [ArgumentCompleter([PoshCode.Pansies.Palettes.X11Palette])] + [PoshCode.Pansies.RgbColor]$AdminForegroundColor, + + # The background color to use when the process is elevated (running as administrator) + [Alias("AdminBg", "ABg")] + [AllowNull()][EmptyStringAsNull()] + [ArgumentCompleter([PoshCode.Pansies.Palettes.X11Palette])] + [PoshCode.Pansies.RgbColor]$AdminBackgroundColor, + + # The foreground color to use when the last command failed + [Alias("ErrorFg", "EFg")] + [AllowNull()][EmptyStringAsNull()] + [ArgumentCompleter([PoshCode.Pansies.Palettes.X11Palette])] + [PoshCode.Pansies.RgbColor]$ErrorForegroundColor, + + # The background color to use when the last command failed + [Alias("ErrorBg", "EBg")] + [AllowNull()][EmptyStringAsNull()] + [ArgumentCompleter([PoshCode.Pansies.Palettes.X11Palette])] + [PoshCode.Pansies.RgbColor]$ErrorBackgroundColor + ) + }) + + $base.AddBeforeEnd(@' + # Support default parameter values + $Parameters = Get-ParameterValue + $Parameters["Content"] = { +'@) + $base.AddAfterEnd(@' + }.GetNewClosure() + + # Strip common parameters if they're on here (so we can use -Verbose) + foreach ($name in @($Parameters.Keys.Where{ $_ -notin [PoshCode.TerminalBlock].GetProperties().Name })) { + $null = $Parameters.Remove($name) + } + + # Store the InvocationInfo for serialization + $Parameters["MyInvocation"] = [System.Management.Automation.InvocationInfo].GetProperty("ScriptPosition", [System.Reflection.BindingFlags]"Instance,NonPublic").GetValue($MyInvocation).Text + + [PoshCode.TerminalBlock]$Parameters +'@) + } +} diff --git a/Tests/Integration/Source2/Generators/TracingAndErrorHandling.ps1 b/Tests/Integration/Source2/Generators/TracingAndErrorHandling.ps1 new file mode 100644 index 0000000..2073f1f --- /dev/null +++ b/Tests/Integration/Source2/Generators/TracingAndErrorHandling.ps1 @@ -0,0 +1,36 @@ + [CmdletBinding()] + param() + begin { + Write-Information "Enter $($PSCmdlet.MyInvocation.MyCommand.Name)" -Tags "BeginBlock", "Enter" + try { + existingcode + } catch { + Write-Information $_ -Tags "BeginBlock", "Exception", "Unhandled" + throw + } finally { + Write-Information "Leave $($PSCmdlet.MyInvocation.MyCommand.Name)" -Tags "BeginBlock", "Leave" + } + } + process { + Write-Information "Enter $($PSCmdlet.MyInvocation.MyCommand.Name)" -Tags "ProcessBlock", "Enter" + try { + existingcode + } catch { + Write-Information $_ -Tags "ProcessBlock", "Exception", "Unhandled" + throw + } finally { + Write-Information "Leave $($PSCmdlet.MyInvocation.MyCommand.Name)" -Tags "ProcessBlock", "Leave" + } + } + end { + Write-Information "Enter $($PSCmdlet.MyInvocation.MyCommand.Name)" -Tags "EndBlock", "Enter" + try { + existingcode + } catch { + Write-Information $_ -Tags "EndBlock", "Exception", "Unhandled" + throw + } finally { + Write-Information "Leave $($PSCmdlet.MyInvocation.MyCommand.Name)" -Tags "EndBlock", "Leave" + } + } + diff --git a/Tests/Integration/Source2/Public/Show-HistoryId.ps1 b/Tests/Integration/Source2/Public/Show-HistoryId.ps1 new file mode 100644 index 0000000..f08abf4 --- /dev/null +++ b/Tests/Integration/Source2/Public/Show-HistoryId.ps1 @@ -0,0 +1,9 @@ +function Show-HistoryId { + <# + .SYNOPSIS + Shows the ID of the command you're about to type (this WILL BE the History ID of the command you run) + #> + [CmdletBinding()] + param() + $MyInvocation.HistoryId +} diff --git a/Tests/Integration/Source2/Public/Show-HostName.ps1 b/Tests/Integration/Source2/Public/Show-HostName.ps1 new file mode 100644 index 0000000..0ef5680 --- /dev/null +++ b/Tests/Integration/Source2/Public/Show-HostName.ps1 @@ -0,0 +1,12 @@ +function Show-HostName { + <# + .SYNOPSIS + Gets the hostname of the current machine + .DESCRIPTION + Calls [Environment]::MachineName + #> + [OutputType([string])] + [CmdletBinding(DefaultParameterSetName = "SimpleFormat")] + param() + [Environment]::MachineName +} diff --git a/Tests/Integration/Source2/Source2.psd1 b/Tests/Integration/Source2/Source2.psd1 new file mode 100644 index 0000000..f55bb44 --- /dev/null +++ b/Tests/Integration/Source2/Source2.psd1 @@ -0,0 +1,56 @@ +@{ + # The module version should be SemVer.org compatible + ModuleVersion = "1.0.0" + + # PrivateData is where all third-party metadata goes + PrivateData = @{ + # PrivateData.PSData is the PowerShell Gallery data + PSData = @{ + # Prerelease string should be here, so we can set it + Prerelease = '' + + # Release Notes have to be here, so we can update them + ReleaseNotes = ' + A test module with generators + ' + + # Tags applied to this module. These help with module discovery in online galleries. + Tags = 'Authoring','Build','Development','BestPractices' + + # A URL to the license for this module. + LicenseUri = 'https://github.com/PoshCode/ModuleBuilder/blob/master/LICENSE' + + # A URL to the main website for this project. + ProjectUri = 'https://github.com/PoshCode/ModuleBuilder' + + # A URL to an icon representing this module. + IconUri = 'https://github.com/PoshCode/ModuleBuilder/blob/resources/ModuleBuilder.png?raw=true' + } # End of PSData + } # End of PrivateData + + # The main script module that is automatically loaded as part of this module + RootModule = 'Source2.psm1' + + # Modules that must be imported into the global environment prior to importing this module + RequiredModules = @() + + # Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. + DefaultCommandPrefix = 'Source' + + # Always define FunctionsToExport as an empty @() which will be replaced on build + FunctionsToExport = @() + AliasesToExport = @() + + # ID used to uniquely identify this module + GUID = 'a264e183-e0f7-4219-bc80-c30d14e0e98e' + Description = 'A module for authoring and building PowerShell modules' + + # Common stuff for all our modules: + CompanyName = 'PoshCode' + Author = 'Joel Bennett' + Copyright = "Copyright 2018 Joel Bennett" + + # Minimum version of the Windows PowerShell engine required by this module + PowerShellVersion = '5.1' + CompatiblePSEditions = @('Core','Desktop') +} diff --git a/Tests/Integration/Source2/build.psd1 b/Tests/Integration/Source2/build.psd1 new file mode 100644 index 0000000..47482ad --- /dev/null +++ b/Tests/Integration/Source2/build.psd1 @@ -0,0 +1,4 @@ +@{ + Path = "Source2.psd1" + OutputDirectory = "..\Result1" +}