KnowIT.Builder.psm1


#region === Source functions ===

### Source file: 'Build-KnowITModule.ps1' ###
function Build-KnowITModule {

    [CmdletBinding(DefaultParameterSetName = 'BuildNumber')]
    [Alias('build')]
    param(
        [Parameter(Position = 0)]
        [ValidateNotNullOrWhiteSpace()]
        [string]$Path = '.',

        [Parameter(ParameterSetName = 'Version')]
        [ValidateScript({ ValidateVersion $_ })]
        [string]$Version,

        [Parameter(ParameterSetName = 'BuildNumber')]
        [int]$BuildNumber = -1,

        [switch]$MergePSM
    )

    $ErrorActionPreference = 'Stop'

    try {
        Write-Build 'Procesing module data file...'
        $script:ModuleData = GetModuleFileData $Path

        if($Version) {
            $ModuleData.Version = $Version
        }
        else {
            $null = ValidateVersion $ModuleData.Version
        }

        $output = $ModuleData.OutputFolder
        Write-Build "Module output location: '$output'"
        if(Test-Path $output) {
            $null = Remove-Item $output -Recurse -Force
        }
        BuildPSM -Merge:$MergePSM
        BuildManifest $BuildNumber
    }
    catch {
        $PSCmdlet.WriteError($_)
    }
}

### Source file: 'Get-KnowITModuleInfo.ps1' ###

function Get-KnowITModuleInfo {

    [CmdletBinding()]
    [Alias('moduleinfo')]
    param(
        [string]$Path = '.'
    )

    $ErrorActionPreference = 'Stop'

    try {
        $rootFolder = Convert-Path $Path

        $data = GetModuleFileData $rootFolder
        [PSCustomObject]$data
        $null = ValidateVersion $data.Version
    }
    catch {
        $PSCmdlet.WriteError($_)
    }
}

### Source file: 'New-KnowITModule.ps1' ###
function New-KnowITModule {

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Name,

        [string]$Path = $PWD.Path
    )

    $ErrorActionPreference = 'Stop'

    try {
        $modulePath = Join-Path $Path $Name
        if(Test-Path $modulePath) {
            throw "Directory '$modulePath' already exists. Can't create a new module in an existing folder!"
        }
        $templatePath = Join-Path $PSModuleRoot 'template'
        Copy-Item $templatePath $modulePath -Recurse
        Rename-Item "$modulePath/src/Module.psm1" -NewName "$Name.psm1"
        Get-ChildItem $modulePath -Filter 'dot.*' | Rename-Item -NewName { $_.Name -replace 'dot.', '.' }

        $moduleData = Import-PowerShellDataFile $templatePath/module.psd1
        $moduleData.ModuleName = $Name
        $moduleData.ModuleId = New-Guid

        $moduleFileContent = Get-Content $templatePath/module.psd1 -Raw
        ReplaceModuleData $moduleFileContent $moduleData
        | Set-Content $modulePath/module.psd1 -Encoding utf8BOM -Force

        "Import-Module `$PSScriptRoot/src/$Name.psm1 -Force -DisableNameChecking"
        | Set-Content $modulePath/run.ps1 -Encoding utf8BOM

        $gitCommand = Get-Command git -CommandType Application -ErrorAction Ignore
        if($gitCommand -and !$SkipGitInit) {
            Push-Location $modulePath
            & $gitCommand init
            & $gitCommand add . 2>&1 | Where-Object { $_ -notlike 'warning: *' }
            Pop-Location
        }
    }
    catch {
        $PSCmdlet.WriteError($_)
    }
}

### Source file: 'New-KnowITModuleFunction.ps1' ###
function New-KnowITModuleFunction {

    [CmdletBinding()]
    [Alias('nfunc')]
    param(
        [Parameter(Mandatory)]
        [string]$Name
    )

    $ErrorActionPreference = 'Stop'

    try {
        $moduleData = GetModuleFileData (FindProjectRoot)
        $functionFile = Join-Path $moduleData.ProjectFolder 'src/public' "$Name.ps1"
        if(Test-Path $functionFile) {
            throw "Function file '$Name.ps1' already exists!"
        }

        $templateFile = Join-Path $moduleData.ProjectFolder 'src/public/_function.template'
        if(!(Test-Path $templateFile)) {
            $templateFile = Join-Path $PSModuleRoot 'template/src/public/_function.template'
        }

        $template = Get-Content $templateFile -Raw
        $template.Replace('{{FunctionName}}', $Name) |
            Set-Content $functionFile -Encoding utf8BOM

        Write-Build "New public function file created: $functionFile"
    }
    catch {
        $PSCmdlet.WriteError($_)
    }
}

### Source file: 'BuildPSM.ps1' ###
function BuildPSM ([switch]$Merge)
{
    $ErrorActionPreference = 'Stop'

    try {
        $moduleName = $ModuleData.ModuleName
        Write-Build " Building module file: '$moduleName.psm1'..."
        Push-Location (Join-Path $ModuleData.ProjectFolder 'src')

        $output = $ModuleData.OutputFolder
        $null = New-Item $output -ItemType Directory -Force

        $sourceBuilder = [Text.StringBuilder]::new()
        $usings = [Collections.Generic.SortedSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
        $requires = [Collections.Generic.SortedSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
        $sourceFiles = ProcessSourceFolders

        [void]$sourceBuilder.AppendLine("`n#region === Source functions ===")
        foreach($source in $sourceFiles) {
            [void]$sourceBuilder.AppendLine("`n### Source file: '$($source.Name)' ###")
            Get-Content $source | ParseSource $sourceBuilder $usings $requires
        }
        [void]$sourceBuilder.AppendLine("`n#endregion")

        $currentPSM = "$moduleName.psm1"
        if($Merge -and (Test-Path $currentPSM)) {
            Write-Build ' Merging current PSM file...'
            [void]$sourceBuilder.AppendLine("`n#region === Source .psm1 file ===")
            Get-Content $currentPSM | ParseSource $sourceBuilder $usings $requires -SkipRegion '=== .Source files ==='
            [void]$sourceBuilder.AppendLine("`n#endregion")
        }

        # using directives must be at the top of the file
        if($usings.Count -gt 0) {
            [void]$sourceBuilder.Insert(0, "$($usings -join "`n")`n")
        }

        if($requires.Count -gt 0) {
            Write-Build ' Procesing Required Modules...'
            $ModuleData.ExternalModules.ForEach({ [void]$requires.Add($_) })
            $ModuleData.ExternalModules = $requires
        }

        $sourceCode = $sourceBuilder.ToString()

        #TODO:External help
        if($script:HelpFile) {
            $helpPattern = "(?ms)(\<#.*?\.SYNOPSIS.*?#>)"
            $externalHelp = "# .ExternalHelp $ModuleName-help.xml`n"
            $sourceCode = $sourceCode -replace $helpPattern, $externalHelp
        }

        $sourceCode | Set-Content "$output/$moduleName.psm1" -Encoding utf8BOM

        if($extra = $ModuleData.ExtraContent) {
            Write-Build " Copying extra content: ($($extra -join ', '))..."
            Copy-Item $extra -Destination $output -Recurse -Force
        }
    }
    finally {
        Pop-Location
    }
}

function ProcessSourceFolders
{
    Write-Build " Processing source files in folders: ($($ModuleData.PSSourceFiles -join ', '))..."
    foreach($path in $ModuleData.PSSourceFiles) {
        $files = Get-ChildItem -Filter $path -Directory |
            Get-ChildItem -Filter '*.ps1' -Recurse
        if($path -eq 'public') {
            $ModuleData.PublicFunctions = $files.BaseName
        }
        $files
    }
}

filter ParseSource ($Builder, $Usings, $Requires, $SkipRegion)
{
begin {
    $skipPattern = [string]::IsNullOrWhiteSpace($SkipRegion) ?
        '^#region\ SKIP_BUILD' :
        "^#region\ (SKIP_BUILD|$([regex]::Escape($SkipRegion)))"
    $skipping = $false
    $lineNumber = 0
}

process {
    $lineNumber++
    switch -Regex ($_) {
        '^\s*using' {
            [void]$Usings.Add($_.Trim())
            break
        }
        '^\s*#requires -Modules\s*(.*)' {
            $Matches[1].Split(',').
                ForEach({ [void]$Requires.Add($_.Trim()) })
            break
        }
        $skipPattern {
            if($skipping) { throw "Nested skipped regions are not supported. Line: $lineNumber" }
            $skipping = $true
            break
        }
        '^#endregion' {
            if(!$skipping) {
                [void]$SourceBuilder.AppendLine($_)
            }
            else { $skipping = $false }
        }
        default {
            if(!$skipping) { [void]$SourceBuilder.AppendLine($_) }
        }
    }
}
}

### Source file: 'Common.ps1' ###
function Write-Build ([string]$Message, $Color)
{
    Write-Verbose $Message -Verbose
}

### Source file: 'Manifest.ps1' ###
function BuildManifest ([int]$BuildNumber = -1) {

    $moduleName = $ModuleData.ModuleName
    Write-Build " Building Module Manifest: '$moduleName.psd1'..."

    $manifest = $ModuleData.Manifest
    $allowedParams = (Get-Command New-ModuleManifest).Parameters.Keys
    $invalidKeys = $manifest.Keys.Where({ $_ -notin $allowedParams -or !$manifest[$_] })
    if($invalidKeys.Count -gt 0) {
        Write-Warning " Removing invalid or empty keys in Manifest ($($invalidKeys -join ', '))..."
        $invalidKeys.ForEach({ $manifest.Remove($_) })
    }
    # Manifest default values
    $manifest.FunctionsToExport = ''
    $manifest.CmdletsToExport = ''
    $manifest.VariablesToExport = ''
    $manifest.PrivateData ??= @{}

    $version, $prerelease = $ModuleData.Version.Split('-', 2)
    $buildVersion = GetBuildVersion $version $BuildNumber
    $manifest.ModuleVersion = $buildVersion
    if($prerelease) {
        $manifest.PreRelease = $prerelease
        $fullVersion = "$buildVersion-$prerelease"
    }
    else {
        $fullVersion = $buildVersion
    }
    Write-Build "Module final version: [$fullVersion]"
    $manifest.PrivateData.FullVersion = $fullVersion
    $manifest.PrivateData.Builder = 'KnowIT.Builder'

    $manifest.GUID = $ModuleData.ModuleId
    $manifest.Author = $ModuleData.Author
    $manifest.Description = $ModuleData.Description
    $manifest.RootModule = "$moduleName.psm1"

    if($ModuleData.PublicFunctions) {
        $manifest.FunctionsToExport = $ModuleData.PublicFunctions
    }
    if($ModuleData.ExternalModules) {
        $manifest.RequiredModules = $ModuleData.ExternalModules
        $manifest.ExternalModuleDependencies = $ModuleData.ExternalModules
    }
    # $manifest.NestedModules = $NestedModules
    # $manifest.RequiredAssemblies = $Assemblies.ForEach({"bin/$_.dll"})

    $manifest.Path = Join-Path $ModuleData.OutputFolder "$moduleName.psd1"

    Write-Debug 'Module Manifest Parameters:'
    Write-Debug ($manifest | Out-String)
    New-ModuleManifest @manifest
}

### Source file: 'ModuleData.ps1' ###
function GetModuleFileData ([string]$RootFolder)
{
    $ErrorActionPreference = 'Stop'

    $RootFolder = Convert-Path $RootFolder
    $moduleFile = Join-Path $RootFolder 'module.psd1'

    if(!(Test-Path $moduleFile -PathType Leaf)) {
        throw "Not found 'module.psd1' file in project folder [$RootFolder]"
    }
    $data = Import-PowerShellDataFile $moduleFile -ErrorAction Stop

    $requiredKeys = 'ModuleName', 'ModuleId', 'Description', 'Author', 'Version', 'PSSourceFiles'
    $missingKeys = $requiredKeys.Where({ $_ -notin $data.Keys })
    if($missingKeys) {
        throw "Missing required keys in module.psd1: ($($missingKeys -join ', '))"
    }

    $data.ProjectFolder = $RootFolder
    $outDir = $data.OutputFolder ?? 'out'
    $data.OutputFolder = Join-Path $RootFolder $outDir $data.ModuleName

    $data
}

function FindProjectRoot
{
    if(Test-Path 'module.psd1') {
        return $PWD.Path
    }
    $current = $PWD.Path
    while ($current) {
        if((Split-Path $current -Leaf) -eq 'src') {
           return Split-Path $current
        }
        $current = Split-Path $current
    }

    throw 'Project Root Folder not found!'
}

function ReplaceModuleData ([string[]]$Content, [hashtable]$Data)
{
    $Content -replace '(?:\#+\s*)?(\w+\s*)=\s*(.+)', {
        $key = $_.Groups[1].Value.Trim()
        if(!$Data.ContainsKey($key) -or $_.Groups[2].Value -eq '@{') {
            return $_.Value
        }

        $value = switch ($Data[$key]) {
            { $_ -is [int] -or $_ -is [double] } { $_ }
            { $_ -is [bool] } { $_ ? '$true' : '$false' }
            default { "'$_'" }
        }
        '{0}= {1}' -f $_.Groups[1].Value, ($value -join ', ')
   }
}

### Source file: 'Version.ps1' ###
function ValidateVersion ([string]$Version)
{
    $regex = '^(0|[1-9]\d*)(\.(0|[1-9]\d*))?\.(?:x|X|0|[1-9]\d*)(?:-[A-Za-z0-9]+)?$'

    if($Version -notmatch $regex) {
        throw "Invalid version format ($Version). Please use 'Major[.Minor].Build' and an optional prerelease (only alphanumeric characters).
Last segment can be 'x' to indicate an incremental build number."

    }
    return $true
}

function GetBuildVersion ([string]$Version, [int]$BuildNumber = -1)
{
    # Fixed version whithout a build number in parameters return as.is
    if($BuildNumber -eq (-1) -and -not $Version.Contains('x')) {
        return [version]$Version
    }

    Write-Build ' Applying Build Number:'
    if($BuildNumber -eq -1) {
        $BuildNumber = [Math]::Floor([DateTimeOffset]::Now.ToUnixTimeSeconds() / 60)
        Write-Build " |$BuildNumber| (UnixMinutes)"
    }
    else {
        Write-Build " |$BuildNumber| (Parameter)"
    }

    $segments = $Version.Replace('.x', '.0').Split('.')
    [version]::new($segments[0], $segments[1], $BuildNumber)
}

#endregion