Build/Build-Module.ps1

<#
.SYNOPSIS
    Module Builder
 
.DESCRIPTION
    Module Builder
 
#>


#Requires -Version 7.4
#Requires -Modules @{ ModuleName="Microsoft.PowerShell.PSResourceGet"; RequiredVersion="1.0.6" }

using namespace System.Collections
using namespace System.Collections.Generic
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

param(
    [Parameter(Mandatory = $true, ValueFromPipeline = $true)][String]$SourceFolder,

    [Parameter(Mandatory = $true)][String]$ModulePath,

    [Int]$Depth = 1,

    [Switch]$KeepExistingSettings
)

Begin {

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

    function Use-Script([Alias('Name')][String]$ScriptName, [Alias('Version')][Version]$ScriptVersion) {
        $Command = Get-Command $ScriptName -ErrorAction SilentlyContinue
        if (
            -not $Command -and
            -not ($ScriptVersion -and (Get-PSScriptFileInfo $Command.Source).Version -lt $ScriptVersion) -and
            -not (Install-Script $ScriptName -MinimumVersion $ScriptVersion -PassThru)
        ) {
            $MissingVersion = if ($ScriptVersion) { " version $ScriptVersion" }
            $ErrorRecord = [ErrorRecord]::new(
                "Missing command: '$ScriptName'$MissingVersion.",
                'MissingScript', 'InvalidArgument', $ScriptName
            )
            $PSCmdlet.ThrowTerminatingError($ErrorRecord)
        }
    }

    Use-Script -Name Use-ClassAccessors
    Use-Script -Name Sort-Topological -Version 0.1.2

    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 $ErrorRecord $FilePath $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 {
        static ModuleRequirements() { Use-ClassAccessors }

        [Version]$Version
        [String]$PSEdition
        [Ordered]$Modules = @{}
        [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 {
        static ModuleUsingStatements() { Use-ClassAccessors }

        [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
        static ModuleBuilder() { Use-ClassAccessors } # Doesn't work with Pester (and classes in process blocks?)

        [string] $Path
        [String] get_Name()   { return [Path]::GetFileNameWithoutExtension($this.Path) }

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

        [String]GetRelativePath([String]$Path) {
            $RelativePath = Resolve-Path -Path $Path -RelativeBasePath ([Path]::GetDirectoryName($this.Path)) -Relative
            if ($RelativePath.StartsWith('.\')) { $RelativePath = $RelativePath.SubString(2) }
            return $RelativePath
        }

        hidden [Ordered]$Sections = [Ordered]@{}

        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] = [Ordered]@{} }
            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 {
                    $Name = $Statement.Left.VariablePath.UserPath
                    $Expression = $Statement.Right.Extent.Text
                    if ($Name -eq 'Null' ) { throw [Omission]'Rejected assignment to $Null.' }
                    try { $this.AddStatement('Variable', $Name, $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) { 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'] = [Ordered]@{} }
            $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() {
            $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 '$($Aliases[$Name])' -Value '$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 = [Ordered]@{}
                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)
            }

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

            if ($ModuleMembers.Count) {
                $Statements = $(
                    '$ModuleMembers = @{'
                    $ModuleMembers.foreach{ "$([ModuleBuilder]::Tab)$_" }
                    '}'
                    'Export-ModuleMember @ModuleMembers'
                )
                $this.AppendRegion('Export', $Statements)
            }

            Write-Verbose "Saving module content to '$($this.Path)'"
            Set-Content -LiteralPath $this.Path -Value $this.Content -NoNewline
        }
    }

    function Select-Statements($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 $_ $Module $SourceFile $Statement)) }
            catch [Omission] { New-LocationMessage $_ $SourceFile $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-Statements $Ast.ScriptRequirements $RelativePath
                Select-Statements $Ast.UsingStatements $RelativePath
                if ($Ast.ParamBlock) { $Module.AddCmdlet($SourceFile.BaseName, $Content) }
                else { Select-Statements $Ast.EndBlock.Statements $RelativePath }
            }
            .ps1xml {
                $Module.AddFormat($SourceFile)
            }
        }
    }
}

end {
    $Module.Save()
}