Skip to content

Commit

Permalink
WIP: Modern .NET calls them Generators
Browse files Browse the repository at this point in the history
  • Loading branch information
Jaykul committed Jan 28, 2024
1 parent 8296a5b commit 532c4c2
Show file tree
Hide file tree
Showing 9 changed files with 307 additions and 19 deletions.
215 changes: 215 additions & 0 deletions PotentialContribution/ModuleBuilderExtensions.ps1
Original file line number Diff line number Diff line change
@@ -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
}
}

10 changes: 0 additions & 10 deletions Source/Classes/20. ModuleBuilderAspect.ps1

This file was deleted.

67 changes: 67 additions & 0 deletions Source/Classes/20. ModuleBuilderGenerator.ps1
Original file line number Diff line number Diff line change
@@ -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
}
}
9 changes: 9 additions & 0 deletions Source/Classes/21. ParameterExtractor.ps1
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Source/ModuleBuilder.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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.
Expand All @@ -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 @{
Expand Down
2 changes: 1 addition & 1 deletion Source/Public/Build-Module.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 ...
Expand Down

0 comments on commit 532c4c2

Please sign in to comment.