PSMBuilder.psm1

using namespace System.Collections
using namespace System.Collections.Generic
using namespace System.Collections.Specialized
using namespace System.Collections.ObjectModel
using namespace System.IO
using namespace System.Link
using namespace System.Text
using namespace System.Management.Automation
using namespace System.Management.Automation.Language

<#
.SYNOPSIS
Module Builder
 
.DESCRIPTION
Build a new module (`.psm1`) file from a folder containing PowerShell scripts (`.ps1` files) and
other resources.
 
* Merging the statements (e.g. `#Requires` and `using` statements)
* Preventing duplicates and collisions (e.g. duplicate function names)
* Ordering the statements based on their dependencies (e.g. classes inheritance)
* Formatting the output.
* Automatically exporting variables, functions, cmdlets, aliases and types.
 
The module manifest (`psd1` file) is updated accordingly (unless the `-Standalone` switch is
supplied).
 
The general consensus behind this module builder is that the module author defines the items that
should be **loaded** (imported) by putting them in the module source folder and shouldn't be
concerned with *invoking* (dot-sourcing) any scripts knowing that this could lead to similar
concerns as using the [`Invoke-Expression`] cmdlet especially when working in a team.
See also [#18740: Best practice: import functions in a module][1].
 
This means that this module builder will only accept specific statements (blocks) and reject (with
a warning) statements that require any invocation which potentially could lead to conflicts with
other functions and types.
 
Anything that concerns a dynamic preparation of the module should be done by a specific module
manifest setting or scripted in the `ScriptsToProcess` setting of the module manifest.
 
## Accepted Statements
 
The accepted statements might be divided into different files and (sub)folders using any file name
or folder name with the exception of functions that need to be exported as cmdlets.
 
The accepted statement types are categorized and loaded in the following order:
* [Requirements]
* [using statements]
* [enum types]
* [Classes]
* [Variables assignments]
* [(private) Functions]
* [(public) Cmdlets]
* [Aliases]
* [Format files]
 
### Requirements
 
The module builder will merge the `#Requires` statements from the source scripts and will add them
to the top of the module file.
 
#### #Required -Version
 
If multiple `#required -version` statements are found, the highest version will be used.
 
#### #Required -PSEdition
 
If conflicting `#required -PSEdition` statements are found, a merge conflict exception is thrown.
 
#### #Required -Modules
 
If multiple `#required -Modules` statements are found, the module names will be merged and the
highest version
 
#### #Required -RunAsAdministrator
 
If set in any script, the module builder will add the `#Requires -RunAsAdministrator` statement to
the top of the module file.
 
> [!TIP]
> Consider to make your function [self-elevating](https://stackoverflow.com/q/60209449/1701026).
 
### Using statements
 
In general `using` statements are merged and added to the module file except for the `using module`
with will be rejected and a warning will be shown.
 
#### using namespace <.NET-namespace>
 
The module builder will use the full namespace name and added or merged them accordingly.
 
#### using module <module-name>
 
The module builder will reject this statement and will suggest to use the module manifest instead.
 
#### using assembly <assembly-name>
 
The module builder will reformat the assembly path and merge the assembly names and add them to
module file.
 
### Enum types
 
`Enum` and `Flags` types are reformatted using the explicit item value and added or merged them
accordingly.
 
> [!NOTE]
> All types are [automatically added to the TypeAccelerators list][2] to make sure they are
> publicly available.
 
### Classes
 
`Class` definitions are sorted based on any derived (custom) class dependency and added or
accordingly. If conflicting there are multiple classes with the same name a merge conflict
exception is thrown unless the content of the class is exactly the same.
 
> [!NOTE]
> All types are [automatically added to the TypeAccelerators list][2] to make sure they are
> publicly available.
 
> [!WARNING]
> PowerShell classes do have some known [limitation][3] and know [issues][4] that might cause
> problems when using a module builder. For example, when dividing classes that are depended
> on each other over multiple files would > lead to "*Unable to find type [<typename>]*"
> in the "PROBLEMS" tab. The only solution is to put these classes > in the same file or neglect
> the specific problem.
 
### Variables assignments
 
The module builder will merge the variable assignments and add export them when the module is
loaded.
 
> [!TIP]
> For variables that are dynamically assigned during module load time, consider to use the
> `ScriptsToProcess` setting in the module manifest instead or define the variable during the
> concerned function or class execution.
 
### (Private) Functions
 
Any function that is defined in the source scripts will be added to the module file as a private
function. Meaning the function will not be exported by the module builder and will not be available
to the user when the module is loaded. The function will only be available to other functions in the
module file. To export a function as a cmdlet, the function needs to be defined in a script file
with the `.ps1` extension, see: [(public) cmdlets].
 
### (Public) cmdlets
 
Any (public) function that needs to be exported by the module builder is called a [cmdlets][5] in
this design. The module builder will recognize any PowerShell script file (`.ps1`) that contains a
`param` block and will treat it as a cmdlet. The name of the script file will be used as the cmdlet
name. Any `Required` or `Using` statement will be merged and added to the module file.
 
This module builder design enforces the use of advanced functions and prevents coincidentally
interfering with other cmdlets or other items in the module framework.
See also [Add `ScriptsToInclude` to the Module Manifest][4].
 
#### Cmdlet Prototype Pester testing
 
This concept facilitates troubleshooting and testing prototypes without (re)building a new module:
 
    #Requires -Modules @{ModuleName="Pester"; ModuleVersion="5.5.0"}
 
    using module MyModule
 
    param([alias("Path")]$PrototypePath)
 
    Describe 'Test-Object' {
 
        BeforeAll {
 
            if ($PrototypePath) {
                $Content = Get-Content -Raw -LiteralPath $PrototypePath
                $CommandName = [io.path]::GetFileNameWithoutExtension($PSCommandPath) -replace '\.Tests$'
                Mock $CommandName ([ScriptBlock]::Create($Content))
            }
        }
 
        ...
 
Testing a prototype:
 
    . Tests\MyCmdlet.Tests.ps1 Source\Cmdlets\MyCmdlet.ps1
 
 
### Aliases
 
This module builder only supports aliases for (public) cmdlets (exported functions). A cmdlet alias
might be set using the [Alias Attribute Declaration][6], this will export the alias when the module
is loaded.
 
> [!NOTE]
> The [Set-Alias] command statement is rejected as aliases should be avoided for private functions
> as they can make code difficult to read, understand and impact availability
> (see: [AvoidUsingCmdletAliases][7]).
 
### Format files
 
The module builder will accept PowerShell format files (`.ps1xml`) and will merge the view
definitions. For more details on formatting views, see: [about Types.ps1xml][8].
 
## Rejected Statements
 
In general, any statement that requires any invocation (dot-sourcing) is rejected by the module
builder. This includes any native cmdlet (and is not limited to) the following specific statements:
 
### Install-Module
 
The [Install-Module] cmdlet is rejected along with other cmdlet commands, to specify scripts that
run in the module's session state, use the `NestedModules` manifest setting.
 
### New-Type
 
The [New-Type] cmdlet is rejected along with other cmdlet commands, to load any assembly or type
definitions written in a different language than PowerShell, use the `RequiredAssemblies` manifest
setting or the `ScriptsToProcess` manifest setting for any dynamic or conditional loading.
Or consider to load the required type just-in-time while executing the depended class or cmdlet.
 
.EXAMPLE
# (Re)build a new module file
 
Build a new module file from the scripts in the `.\Scripts` folder and save it to `.\MyModule.psm1`.
 
    Build-Module -SourceFolder .\Scripts -ModulePath .\MyModule.psm1
 
.PARAMETERS SourceFolder
 
The source folder containing the PowerShell scripts and resources to build the module from.
 
.PARAMETERS ModulePath
 
The path to the module file to create.
 
> [!WARNING]
> The module (`.psm1`) file will be overwritten and the module manifest (`.psd1`) will be updated.
 
.PARAMETERS Standalone
 
If supplied, the `Build-Module` cmdlet will only produce a module (.psm1) file module and the
manifest (.psd1) file will omitted. All module members will be exported from the module (`.psm1`)
file.
 
> [!WARNING]
> Aliases will not be automatically available prior the module is a loaded
> if the `Standalone` parameter is supplied.
 
.PARAMETERS Depth
 
The depth of the source folder structure to search for scripts and resources. Default is `1`.
 
.LINK
[1]: https://github.com/PowerShell/PowerShell/issues/18740 "#18740: Best practice: import functions in a module"
[2]: https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_classes#exporting-classes-with-type-accelerators "Exporting classes with type accelerators"
[3]: https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_classes#limitations "Class limitations"
[4]: https://github.com/PowerShell/PowerShell/issues/6652 "#6652:Various PowerShell Class Issues"
[5]: https://github.com/PowerShell/PowerShell/issues/24253 "#24253: Add ScriptsToInclude to the Module Manifest"
[6]: https://learn.microsoft.com/powershell/scripting/developer/cmdlet/alias-attribute-declaration "Alias Attribute Declaration"
[7]: https://learn.microsoft.com/powershell/utility-modules/psscriptanalyzer/rules/avoidusingcmdletaliases "Avoid using cmdlet aliases"
[8]: https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_types.ps1xml "about Types.ps1xml"
#>


function Build-Module {
    [Diagnostics.CodeAnalysis.SuppressMessage('PSUseShouldProcessForStateChangingFunctions', '', Scope = 'function', Target = '')]
    [Diagnostics.CodeAnalysis.SuppressMessage('PSUseApprovedVerbs', '', Scope = 'function', Target = '')]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)][String]$SourceFolder,
        [Parameter(Mandatory = $true)][String]$ModulePath,
        [Switch]$Standalone,
        [Int]$Depth = 1
    )

    begin {
        if (-not $PSBoundParameters.ContainsKey('ErrorAction')) { $ErrorActionPreference = 'Stop' }

        $Script:SourcePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($SourceFolder)

        function Sort-Topological {
            # https://github.com/iRon7/Sort-Topological
            [CmdletBinding()][OutputType([List[Object]])]param(
                [Parameter(ValueFromPipeline = $True, Mandatory = $True)]$InputObject,
                [Parameter(Position = 0, Mandatory = $True)][Alias('DependencyName')]$EdgeName,
                [Parameter(Position = 1)][Alias('NameId')][String]$IdName
            )

            begin {
                function Throw-Error($ErrorRecord) { $PSCmdlet.ThrowTerminatingError($ErrorRecord) }

                function FormatId ($Vertex) {
                    if ($Vertex -is [ValueType] -or $Vertex -is [String]) { $Value = $Vertex }
                    elseif (@($_.PSObject.Properties.Name).Contains($IdName)) { $Value = $_.PSObject.Properties[$IdName].Value }
                    else { return "[$(@($List).IndexOf($Vertex))]" }
                    if ($Value -is [String]) { """$Value""" } else { $Value }
                }

                if ($EdgeName -is [ScriptBlock]) {
                    # Prevent code injection
                    $Ast = [System.Management.Automation.Language.Parser]::ParseInput($EdgeName, [ref]$null, [ref]$null)
                    $Expression = $Ast.EndBlock.Statements.PipelineElements.Expression
                    while ($Expression -is [MemberExpressionAst] -and $Expression.Member -is [StringConstantExpressionAst]) {
                        $Expression = $Expression.Expression
                    }
                    if ($Expression -isnot [VariableExpressionAst] -or $Expression.VariablePath.UserPath -notin '_', 'PSItem') {
                        $Message = "The { $Expression } expression should contain safe path."
                        Throw-Error ([ErrorRecord]::new($Message, 'InvalidIdExpression', 'InvalidArgument', $Expression))
                    }
                }
                elseif ($Null -ne $IdName -and $IdName -isnot [String]) { $IdName = "$IdName" }
            }

            process {
                $ById = $Null
                $Sorted = [List[Object]]::new()
                if ($Input) { $List = $Input } else { $List = $InputObject }
                if ($List -isnot [iEnumerable]) { return $List }
                $EdgeCount = 0
                while ($Sorted.get_Count() -lt $List.get_Count()) {
                    $Stack = [Stack]::new()
                    $Enumerator = $List.GetEnumerator()
                    while ($Enumerator.MoveNext()) {
                        $Vertex = $Enumerator.Current
                        if ($Sorted.Contains($Vertex)) { continue }
                        $Edges = [List[Object]]::new()
                        if ($EdgeName -is [ScriptBlock]) { $Edges = $Vertex.foreach($EdgeName).where{ $Null -ne $_ } }
                        else { $Edges = $Vertex.PSObject.Properties[$EdgeName].Value }
                        if ($Null -eq $Edges) { $Edges = @() } elseif ($Edges -isnot [iList]) { $Edges = @($Edges) }
                        if ($Null -eq $ById -and $Edges.Count -gt 0) {
                            if ($Edges[0] -is [ValueType] -or $Edges[0] -is [String]) {
                                if (-not $IdName) {
                                    $Message = 'Dependencies by id require the IdName parameter.'
                                    Throw-Error ([ErrorRecord]::new($Message, 'MissingIdName', 'InvalidArgument', $Vertex))
                                }
                                $ById = @{}
                                foreach ($Item in $List) { $ById[$Item.PSObject.Properties[$IdName].Value] = $Item }
                            }
                            else { $ById = $False }
                        }
                        if ($ById) {
                            $Ids = $Edges; $Edges = [List[Object]]::new()
                            foreach ($Id in $Ids) {
                                if ($Null -eq $Id) { } elseif ($ById.contains($Id)) { $Edges.Add($ById[$Id]) }
                                else {
                                    $Message = "Unknown vertex id: $(FormatId $Id)."
                                    Write-Error ([ErrorRecord]::new($Message, 'UnknownVertex', 'InvalidArgument', $Vertex))
                                }

                            }
                        }
                        if ($Stack.Count -gt 0 -or $Edges.Count -eq $EdgeCount) {
                            $At = if ($Stack.Count -gt 0) { @($Stack.Current).IndexOf($Vertex) + 1 }
                            $Stack.Push($Enumerator)
                            if ($At -gt 0) {
                                $Message = "Circular dependency: $((@($Stack)[0..$At].Current).foreach{ FormatId $_ } -Join ', ')."
                                Throw-Error ([ErrorRecord]::new($Message, 'CircularDependency', 'InvalidArgument', $Vertex))
                            }
                            $Enumerator = $Edges.GetEnumerator()
                        }
                    }
                    if ($Stack.Count -gt 0) {
                        $Enumerator = $Stack.Pop()
                        $Vertex = $Enumerator.Current
                        if ($Vertex -is [ValueType] -or $Vertex -is [String]) { $Vertex = $ById[$Vertex] }
                        if (-not $Sorted.Contains($Vertex)) { $Sorted.Add($Vertex) }
                    }
                    else { $EdgeCount++ }
                }
                $Sorted
            }
        }

        function New-LocationMessage([String]$Message, [String]$FilePath, $Target) {
            if ($Message -like '*.' -and $Message -notlike '*..') { $Message = $Message.Remove($Message.Length - 1) }
            $Return = "$([char]0x1b)[7m$Message$([char]0x1b)[27m"
            $Extent = if ($Target -is [AST] -and $Target.Extent -is [IScriptExtent]) { $Target.Extent } else { $Target }
            $Text, $Column, $Line =
            if ($Extent -is [IScriptExtent]) { $Extent.Text, $Extent.StartColumnNumber, $Extent.StartLineNumber }
            elseif ($Extent -is [PSToken]) { $Extent.Content, $Extent.StartColumn, $Extent.StartLine }
            else { $Extent }
            $Location = $($FilePath, $line, $Column).where{ $_ } -join '.'
            if ($Null -ne $Text) {
                if ($Text.Length -gt 128) { $Text = $Text.SubString(0, 128) }
                $Text = $Text -replace '\s+', ' '
                if ($Text.Length -gt 64) { $Text = $Text.SubString(0, 61) + '...' }
                if ($Location) { $Location += ": $Text" }
            }
            if ($Location) { $Return += " $Location" }
            return $Return
        }

        function New-ModuleError($ErrorRecord, $Module, $FilePath, $Extent) {
            $Id = if ($ErrorRecord -is [ErrorRecord]) { $ErrorRecord.FullyQualifiedErrorId } else { 'ModuleBuildError' }
            $Category = if ($ErrorRecord -is [ErrorRecord]) { $ErrorRecord.CategoryInfo.Category } else { 'ParserError' }

            $Message = New-LocationMessage -Message $ErrorRecord -FilePath $FilePath -Target $Extent
            [ErrorRecord]::new($Message, $Id, $Category, $Module)
        }


        class NameSpaceName {
            hidden static [HashSet[String]]$SystemName = [HashSet[String]]::new([String[]]@(([Type]'Type').NameSpace), [StringComparer]::InvariantCultureIgnoreCase)
            hidden [String] $_Name

            NameSpaceName([String]$Name) { $this._Name = $Name }

            [String] ToString() {
                $Name = if ($this._Name -like 'System.*') { [NameSpaceName]::SystemName -eq $this._Name }
                else { [NameSpaceName]::SystemName -eq "System.$($this._Name)" }
                if ($Name) { return $Name }
                return (Get-Culture).TextInfo.ToTitleCase($this._Name)
            }
        }

        class Collision: Exception { Collision([string]$Message): base ($Message) {} }
        class Omission: Exception { Omission([string]$Message): base ($Message) {} }

        class ModuleRequirements {
            [Version]$Version
            [String]$PSEdition
            [OrderedDictionary]$Modules = [OrderedDictionary]::new([StringComparer]::InvariantCultureIgnoreCase)
            [Bool]$RunAsAdministrator

            hidden [String[]]get_Values() {
                return $(
                    if ($this.Version) { "#Requires -Version $($this.Version.ToString(2))" }
                    if ($this.PSEdition) { "#Requires -PSEdition $($this.PSEdition -join ', ')" }
                    if ($this.Modules) {
                        foreach ($Name in $this.Modules.Keys) {
                            if ($this.Modules[$Name].Count) {
                                # parse hashtable
                                "#Requires -Modules @{ ModuleName = '$Name'; $(
                                $(foreach ($Key in $this.Modules[$Name].Keys) {
                                    "$Key = '$($this.Modules[$Name][$Key])'"
                                }) -Join '; '
                            ) }"

                            }
                            else { "#Requires -Modules '$Name'" }
                        }
                    }
                    if ($this.RunAsAdministrator) { "#Requires -RunAsAdministrator" }
                )
            }

            Add([ScriptRequirements]$Requirements) {
                if ($Requirements.RequiredPSVersion -gt $this.Version) {
                    $this.Version = $Requirements.RequiredPSVersion
                }
                if ($Requirements.RequiredPSEditions) {
                    $Sorted = [Linq.Enumerable]::Order($Requirements.RequiredPSEditions)
                    if (
                        $this.PSEdition -and
                        -not [Linq.Enumerable]::SequenceEqual($this.PSEdition, $Sorted)
                    ) { throw [Collision]"Merge conflict with required edition '$($this.PSEdition)'" }
                    $this.PSEdition = $Sorted
                }
                if ($Requirements.RequiredModules) {
                    if (-not $this.Modules) { $this.Modules = @{} }
                    foreach ($RequiredModule in $Requirements.RequiredModules) {
                        $Name = $RequiredModule.Name
                        if (-not $this.Modules[$Name]) { $this.Modules[$Name] = @{} }
                        $Module = $this.Modules[$Name]
                        if ($RequiredModule.Guid) {
                            if ($Module['Guid'] -and $RequiredModule.Guid -ne $Module['Guid']) {
                                throw [Collision]"Merge conflict with required module guid: [$($Module['Guid'])]"
                            }
                            $Module['Guid'] = $RequiredModule.Guid
                        }
                        if ($RequiredModule.Version) {
                            if ($Module['RequiredVersion']) {
                                throw [Collision]"Merge conflict with required module version '$($Module['RequiredVersion'])'"
                            }
                            if (
                                -not $Module['ModuleVersion'] -or
                                $RequiredModule.Version -gt $Module['ModuleVersion']
                            ) { $Module['ModuleVersion'] = $RequiredModule.Version }
                        }
                        if ($RequiredModule.MaximumVersion) {
                            if ($Module['RequiredVersion']) {
                                throw [Collision]"Merge conflict with required module version '$($Module['RequiredVersion'])'"
                            }
                            if (
                                -not $Module['MaximumVersion'] -or
                                $RequiredModule.MaximumVersion -lt $Module['MaximumVersion']
                            ) { $Module['MaximumVersion'] = $RequiredModule.MaximumVersion }
                        }
                        if ($RequiredModule.RequiredVersion) {
                            if ($Module['Version']) {
                                throw [Collision]"Merge conflict with minimal module version '$($Module['Version'])'"
                            }
                            if ($Module['MaximalVersion']) {
                                throw [Collision]"Merge conflict with maximal module version '$($Module['MaximalVersion'])'"
                            }
                            if (
                                $Module['RequiredVersion'] -and
                                $Module['RequiredVersion'] -ne $RequiredModule.MaximumVersion)
                            { throw [Collision]"Merge conflict with required module version '$($Module['RequiredVersion'])'" }
                            $Module['RequiredVersion'] = $RequiredModule.RequiredVersion
                        }
                    }
                }
                if ($Requirements.IsElevationRequired) { $this.RunAsAdministrator = $true }
                if ($Requirements.Assembly) { throw 'The "#Requires -Assembly" syntax is deprecated.' }
            }
        }

        class ModuleUsingStatements {
            [HashSet[String]]$Namespace = [HashSet[String]]::new([StringComparer]::InvariantCultureIgnoreCase)
            [HashSet[String]]$Assembly = [HashSet[String]]::new([StringComparer]::InvariantCultureIgnoreCase)

            hidden [String[]]get_Values() {
                return $(
                    $this.Assembly.foreach{ "using assembly $_" }
                    $this.Namespace.foreach{ "using namespace $_" }
                )
            }

            Add([UsingStatementAst]$UsingStatement) {
                # Try to unify similar items so that they will better merge.
                $Kind = $UsingStatement.UsingStatementKind.ToString()
                switch ($Kind) {
                    Assembly {
                        $Name, $Details = $UsingStatement.Name.Value -split '\s*,\s*'
                        if ($Details) {
                            # Order details to merge duplicates
                            $Name += ', ' + (($Details -replace '\s*=\s*', ' = ' | Sort-Object) -join ', ')
                        }
                        $null = $this.Assembly.Add($Name)
                    }
                    Command { throw 'Not implemented.' }
                    Module { throw [Omission]"Rejected 'using module' statement (use manifest instead)." }
                    Namespace { $Null = $this.Namespace.Add([NameSpaceName]$UsingStatement.Name.Value) }
                    default { throw [Omission]"Rejected unknown using statement." }
                }
            }
        }

        class ModuleBuilder {
            static [String]$Tab = ' ' # Used for indenting cmdlet contents

            [string] $psd1
            [string] $psm1
            [String] $Name

            ModuleBuilder($Path) {
                $FullPath = [Path]::GetFullPath($Path)
                $Extension = [Path]::GetExtension($FullPath)
                if ($Extension -eq '.psm1') { $this.psm1 = $FullPath }
                elseif ([Directory]::Exists($FullPath)) {
                    $this.psm1 = [Path]::Combine($FullPath, "$([Path]::GetFileName($Path)).psm1")
                }
                else { throw "The module path '$Path' is not a folder or doesn't have a '.psm1' extension." }
                $this.psd1 = [System.IO.Path]::ChangeExtension($this.psm1, '.psd1')
                $this.Name = [Path]::GetFileNameWithoutExtension($this.psm1)
            }

            [String]GetRelativePath([String]$Path) {
                $ToPath = $Path -split '[\\\/]'
                $BasePath = [Path]::GetDirectoryName($this.psm1) -split '[\\\/]'
                for ($i = 0; $i -lt $BasePath.Length; $i++) { if ($ToPath[$i] -ne $BasePath[$i]) { break } }
                $RelativePath = '..\' * ($BasePath.Length - $i)
                $RelativePath += $ToPath[$i..($ToPath.Length - 1)] -join [IO.Path]::DirectorySeparatorChar
                return $RelativePath
            }

            hidden [OrderedDictionary]$Sections = [OrderedDictionary]::new([StringComparer]::InvariantCultureIgnoreCase)

            AddRequirement([ScriptRequirements]$Requires) {
                if (-not $this.Sections['Requires']) { $this.Sections['Requires'] = [ModuleRequirements]::new() }
                try { $this.Sections['Requires'].Add($Requires) } catch { throw }
            }
            hidden CheckDuplicate([String]$Type, [String]$Name, $Value) {
                if ($this.Sections[$Type].Contains($Name)) {
                    if ($this.Sections[$Type][$Name] -eq $Value) { throw [Omission]"Rejected duplicate: $Name." }
                    else { throw [Collision]"Merge conflict with $Type $Name" }
                }
            }
            hidden AddStatement([String]$SectionName, [String]$StatementId, $Definition) {
                if (-not $this.Sections[$SectionName]) {
                    $this.Sections[$SectionName] = [OrderedDictionary]::new([StringComparer]::InvariantCultureIgnoreCase)
                }
                try { $this.CheckDuplicate($SectionName, $StatementId, $Definition) } catch { throw }
                $this.Sections[$SectionName][$StatementId] = $Definition
            }
            AddStatement([StatementAst]$Statement) {
                switch ($Statement.GetType().Name) {
                    UsingStatementAst {
                        if (-not $this.Sections['Using']) { $this.Sections['Using'] = [ModuleUsingStatements]::new() }
                        try { $this.Sections['Using'].Add($Statement) } catch { throw }
                    }
                    TypeDefinitionAst {
                        if ($Statement.TypeAttributes -band 'Enum') {
                            $Flags = $Statement.Attributes.count -and $Statement.Attributes.TypeName.Name -eq 'Flags'
                            $MaxLength = [Linq.Enumerable]::max($Statement.Members.Name.foreach{ $_.Length })
                            $Value = 0
                            $Expression = $( # consistently format expression to reveal duplicates
                                if ($Flags) { "[Flags()] enum $($Statement.Name) {" } else { "enum $($Statement.Name) {" }
                                foreach ($Member in $Statement.Members) {
                                    if ($Member.InitialValue) { $Value = $Member.InitialValue.Value }
                                    "$([ModuleBuilder]::Tab)$($Member.Name)$(' ' * ($MaxLength - $member.Name.Length)) = $Value"
                                    $Value++
                                }
                                '}'
                            ) -join [Environment]::Newline
                            try { $this.AddStatement('Enum', $Statement.Name, $Expression) } catch { throw }
                        }
                        elseif ($Statement.TypeAttributes -band 'Class') {
                            try { $this.AddStatement('Class', $Statement.Name, $Statement) } catch { throw }
                        }
                        else { throw [Omission]"Rejected type (use manifest instead)." }
                    }
                    AssignmentStatementAst {
                        $VariableName = $Statement.Left.VariablePath.UserPath
                        $Expression = $Statement.Right.Extent.Text
                        if ($VariableName -eq 'Null' ) { throw [Omission]'Rejected assignment to $Null.' }
                        try { $this.AddStatement('Variable', $VariableName, $Expression) } catch { throw }
                    }
                    FunctionDefinitionAst {
                        try { $this.AddStatement('Function', $Statement.Name, $Statement) } catch { throw }
                    }
                    default { throw [Omission]"Rejected invalid module statement." }
                }
            }
            AddCmdlet([String]$Name, $Content) {
                $Tokens = [PSParser]::Tokenize($Content, [ref]$null)
                $AliasToken, $AliasGroupToken = $null
                $FunctionContent = [StringBuilder]::new()
                $Null = $FunctionContent.AppendLine("function $Name {")
                $Start = $Null
                for ($Index = 0; $Index -lt $Tokens.Count; $Index++) {
                    if ($Null -eq $Start) {
                        while ($Index -lt $Tokens.Count -and $Tokens[$Index].Type -eq 'NewLine') { $Index++ }
                        $Start = $Tokens[$Index].Start
                    }
                    $Token = $Tokens[$Index]
                    if ($Token.Type -eq 'Keyword' -and $Token.Content -eq 'param') { break }
                    if ( # Omit the following tokens from the function content
                        ($Token.Type -eq 'Keyword' -and $Token.Content -eq 'using') -or
                        ($Token.Type -eq 'Comment' -and $Token.Content -match '^#Requires\s+-')
                    ) {
                        $Null = $FunctionContent.Append($Content.SubString($Start, ($Token.Start - $Start)))
                        while ($Index -lt $Tokens.Count -and $Tokens[$Index].Type -ne 'NewLine') { $Index++ }
                        $Start = $Null
                        continue
                    }
                    if ($AliasToken) {
                        if ($AliasGroupToken) {
                            if ($Token.Type -eq 'String') {
                                $this.AddStatement('Alias', $Token.Content, $Name)
                                $AliasExists = Get-Alias $Token.Content -ErrorAction SilentlyContinue
                                if ($AliasExists -and $AliasExists.Source -ne $this.Name) {
                                    Write-Warning "The alias '$($Token.Content)' ($($AliasExists.ResolvedCommand)) already exists."
                                }
                            }
                            elseif ($Token.Type -eq 'Operator' -and $Token.Content -eq ',') { <# continue #> }
                            elseif ($Token.Type -eq 'GroupEnd') { $AliasGroupToken = $null }
                            else { throw "Expected Group-end token (')') in $($Name), line $($Token.StartLine), column $($Token.StartColumn)." }
                        }
                        elseif ($Token.Type -eq 'GroupStart') { $AliasGroupToken = $Token }
                        elseif ($Token.Type -eq 'Operator' -and $Token.Content -eq ']') { $AliasToken = $null }
                        else { throw "Expected Attribute-end token (']') in $($Name), line $($Token.StartLine), column $($Token.StartColumn)." }
                    }
                    elseif ($Token.Type -eq 'Attribute' -and $Token.Content -eq 'Alias') { $AliasToken = $Token }
                }
                $Index = $Tokens.Count - 1
                while ($Index -gt 0 -and $Tokens[$Index].Type -eq 'NewLine') { $Index-- }
                $Length = $Tokens[$Index].Start + $Tokens[$Index].Length - $Start
                $Null = $FunctionContent.AppendLine($Content.SubString($Start, $Length))
                $Null = $FunctionContent.AppendLine('}')
                try { $this.AddStatement('Cmdlet', $Name, $FunctionContent.ToString()) } catch { throw }
            }
            AddFormat($SourceFile) {
                $RelativePath = $this.GetRelativePath($SourceFile)
                if (-not $this.Sections['Format']) {
                    $this.Sections['Format'] = [OrderedDictionary]::new([StringComparer]::InvariantCultureIgnoreCase)
                }
                $Xml = [xml](Get-Content $SourceFile)
                foreach ($Name in $Xml.Configuration.ViewDefinitions.View.Name) {
                    if ($this.Sections['Format'].Contains($Name)) {
                        throw [Collision]"Merge conflict with format '$Name'"
                    }
                    $this.Sections['Format'][$Name] = $RelativePath
                }
            }

            hidden [Bool]$SkipLine
            hidden [String]$CurrentRegion
            hidden [StringBuilder]$Content = [StringBuilder]::new()
            hidden AppendLine() { $null = $this.Content.AppendLine() }
            hidden AppendLine([String]$Line) {
                if ($Line.EndsWith([Char]10) -or $Line.EndsWith([Char]13)) { $null = $this.Content.Append($Line) }
                else { $null = $this.Content.AppendLine($Line) }
            }
            hidden AppendRegion ([String]$Name, [String[]]$Statements) {
                if ($this.Content.Length) { $this.AppendLine() } # Add line between sections
                $this.AppendLine("#Region $Name")
                $this.AppendLine()
                $Statements.foreach{ $this.AppendLine($_) }
                $this.AppendLine()
                $this.AppendLine("#EndRegion $Name")
            }
            Save([Bool]$Standalone) {
                $S = $this.Sections
                if ($S.Contains('Requires')) { $this.AppendRegion('Requires', $S.Requires.get_Values()) }
                if ($S.Contains('Using')) { $this.AppendRegion('Using', $S.Using.get_Values()) }
                if ($S.Contains('Variable')) {
                    # https://github.com/PowerShell/PSScriptAnalyzer/issues/1950
                    $Statements = $(
                        $this.AppendLine("[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification='https://github.com/PowerShell/PSScriptAnalyzer/issues/1950')]")
                        $this.AppendLine('param()')
                    )
                    $this.AppendRegion('Fix #1950', $Statements)
                }
                if ($S.Contains('Enum')) { $this.AppendRegion('Enum', $S.Enum.get_Values()) }
                if ($S.Contains('Class')) {
                    $SortParams = @{
                        IdName         = 'Name'
                        DependencyName = { $_.BaseTypes.TypeName.Name }
                        ErrorAction    = 'SilentlyContinue'
                    }
                    $Classes = $S.Class.get_Values() | Sort-Topological @SortParams
                    $this.AppendRegion('Class', $Classes.Extent.Text)
                }
                if ($S.Contains('Variable')) {
                    $Statements = foreach ($Name in $S.Variable.get_Keys()) {
                        "`$${Name} = $($S.Variable[$Name])"
                    }
                    $this.AppendRegion('Variable', $Statements)
                }
                if ($S.Contains('Function')) { $this.AppendRegion('Function', $S.Function.get_Values()) }
                if ($S.Contains('Cmdlet')) { $this.AppendRegion('Cmdlet', $S.Cmdlet.get_Values()) }
                if ($S.Contains('Alias')) {
                    $Aliases = [SortedDictionary[String, Object]]::new()
                    foreach ($Name in $S.Alias.get_Keys()) {
                        if (-not $Aliases.ContainsKey($Name)) { $Aliases[$Name] = [List[String]]::new() }
                        $Aliases[$Name].Add($S.Alias[$Name])
                    }
                    $Statements = foreach ($Name in $Aliases.Keys) {
                        "Set-Alias -Name '$Name' -Value '$($Aliases[$Name])'"
                    }
                    $this.AppendRegion('Alias', $Statements)
                }
                if ($S.Contains('Format')) {
                    # https://github.com/PowerShell/PowerShell/issues/17345
                    # if (-not (Get-FormatData -ErrorAction Ignore $etsTypeName)) {
                    # See: https://stackoverflow.com/a/67991167/1701026
                    $Files = [OrderedDictionary]::new([StringComparer]::InvariantCultureIgnoreCase)
                    foreach ($Name in $S.Format.get_Keys()) {
                        $FileName = $S.Format[$Name]
                        if (-not $S.Format.Contains($FileName)) { $Files[$FileName] = [List[String]]::new() }
                        $Files[$FileName].Add($Name)
                    }
                    $Formats = foreach ($FileName in $Files.get_Keys()) {
                        $Names = $Files[$FileName]
                        if ($Names.Count -le 1) {
                            "if (-not (Get-FormatData '$Names' -ErrorAction Ignore)) {"
                        }
                        else {
                            $Names = @($Names).foreach{ "'$_'" } -join ', '
                            "if (-not @($Names).where({ Get-FormatData '`$_' -ErrorAction Ignore }, 'first')) {"
                        }
                        " Update-FormatData -PrependPath `$PSScriptRoot\$FileName"
                        '}'
                    }
                    $this.AppendRegion('Format', $Formats)
                }

                $ExportMembers = @{ Cmdlet = 'Function'; Alias = 'Alias'; Variable = 'Variable' }
                $ModuleMembers = if ($Standalone) {
                    foreach ($Name in $ExportMembers.Keys) {
                        $Member = $this.Sections[$Name]
                        if ($Member -and $Member.Count) {
                            $ExportMembers[$Name] + ' = ' + ($Member.Keys.foreach{ "'$_'" } -join ', ')
                        }
                    }
                }

                $ExportTypes = $(
                    if ($this.Sections.Contains('Enum')) { $this.Sections.Enum.get_Keys() }
                    if ($this.Sections.Contains('Class')) { $this.Sections.Class.get_Keys() }
                )

                $ExportSection = $(
                    if ($ModuleMembers) {
                        '$ModuleMembers = @{'
                        $ModuleMembers.foreach{ "$([ModuleBuilder]::Tab)$_" }
                        '}'
                        'Export-ModuleMember @ModuleMembers'
                    }

                    if ($ExportTypes) {

                        '# https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_classes#exporting-classes-with-type-accelerators'
                        '# Define the types to export with type accelerators.'
                        '$ExportableTypes = @('
                        $ExportTypes.foreach{ "$([ModuleBuilder]::Tab)[$_]" }
                        ')'

                        {
                            $TypeAcceleratorsClass = [PSObject].Assembly.GetType(
                                'System.Management.Automation.TypeAccelerators'
                            )

                            foreach ($Type in $ExportableTypes) {
                                if ($Type.FullName -notin $ExistingTypeAccelerators.Keys) {
                                    $TypeAcceleratorsClass::Add($Type.FullName, $Type)
                                }
                            }

                            $MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = {
                                foreach ($Type in $ExportableTypes) {
                                    $TypeAcceleratorsClass::Remove($Type.FullName)
                                }
                            }.GetNewClosure() }.ToString()
                    }
                )

                if ($ExportSection) { $this.AppendRegion('Export', $ExportSection) }

                Write-Verbose "Saving module content to '$($this.psm1)'"
                if ($Standalone) {
                    if (Test-Path $this.psd1) {
                        Write-Warning "The manifest ($($this.GetRelativePath($this.psd1))) already exist, make sure the exported members are aligned."
                    }
                }
                else {
                    $Manifest = @{
                        Path              = $this.psd1
                        RootModule        = [System.io.Path]::GetFileName($this.psm1)
                        FunctionsToExport = if($this.Sections.Contains('Cmdlet'))   { $this.Sections['Cmdlet'].get_Keys() }
                        AliasesToExport   = if($this.Sections.Contains('Alias'))    { $this.Sections['Alias'].get_Keys() }
                        VariablesToExport = if($this.Sections.Contains('Variable')) { $this.Sections['Variable'].get_Keys() } else { '*' }
                        FormatsToProcess  = if($this.Sections.Contains('Format'))   { $this.Sections['Format'].get_Values() }
                    }
                    if (Test-Path $this.psd1) { Update-ModuleManifest @Manifest }
                    else { New-ModuleManifest -Path @Manifest }

                }
                Set-Content -LiteralPath $this.psm1 -Value $this.Content -NoNewline
            }
            Save() { Save($False) }
        }

        function Select-Statement($Statements, $SourceFile) {
            if (-not $Statements) { return }
            foreach ($Statement in $Statements) {
                try {
                    if ($Statement -is [ScriptRequirements]) { $Module.AddRequirement($Statement) }
                    else { $Module.AddStatement($Statement) }
                }
                catch [Collision] {
                    $PSCmdlet.ThrowTerminatingError((
                        New-ModuleError -ErrorRecord $_ -Module $Module -FilePath $SourceFile -Extent $Statement
                    ))
                }
                catch [Omission] {
                    New-LocationMessage -Message $_ -FilePath $SourceFile -Target $Statement | Write-Warning
                }
            }
        }

        $Module = try { [ModuleBuilder]::new($ModulePath) } catch { $PSCmdlet.ThrowTerminatingError($_) }
    }

    process {

        $SourceFiles = Get-ChildItem -Path $SourcePath -Depth $Depth -Include '*.ps1', '*.ps1xml'
        if (-not $SourceFiles) { $PSCmdlet.ThrowTerminatingError([ErrorRecord]::new(
            "No valid script (.ps1) files found for '$SourcePath'",
            'InvalidSourcePath',
            [ErrorCategory]::InvalidArgument,
            $null
        )) }

        foreach ($SourceFile in $SourceFiles) {
            $RelativePath = $Module.GetRelativePath($SourceFile)
            Write-Verbose "Processing '$RelativePath'"
            switch ([Path]::GetExtension($SourceFile)) {
                .ps1 {
                    $Content = Get-Content -Raw $SourceFile.FullName
                    $Ast = [Parser]::ParseInput($Content, [ref]$Null, [ref]$Null)
                    Select-Statement $Ast.ScriptRequirements $RelativePath
                    Select-Statement $Ast.UsingStatements $RelativePath
                    if ($Ast.ParamBlock) { $Module.AddCmdlet($SourceFile.BaseName, $Content) }
                    else { Select-Statement $Ast.EndBlock.Statements $RelativePath }
                }
                .ps1xml {
                    $Module.AddFormat($SourceFile)
                }
            }
        }
    }

    end {
        $Module.Save($Standalone)
    }
}