Public/Update-ModuleManifestExports.ps1

<#
.SYNOPSIS
Sets FunctionsToExport in a module manifest to the functions defined in the module's Public folder.

.DESCRIPTION
Update-ModuleManifestExports parses every .ps1 file under <ModulePath>/Public (including subfolders such as
Public/Generated/Functions) and collects the names of the functions defined at the top level of each file. Pester
test files (*.Tests.ps1) and files in Tests folders are ignored. The FunctionsToExport entry of the manifest is then
replaced with that sorted list (or added when the manifest has none). The rest of the manifest, its line endings and
its UTF-8 byte order mark are left as they are. The manifest is only written when the list changes.

.PARAMETER ModulePath
The module folder (the folder that contains the manifest and the Public folder), or the path of the manifest itself.
Accepts pipeline input, including objects with a FullName or Path property.

.PARAMETER ManifestPath
The manifest to update. Defaults to <folder name>.psd1 in ModulePath.

.EXAMPLE
Update-ModuleManifestExports -ModulePath ./modules/my.module

Sets FunctionsToExport in ./modules/my.module/my.module.psd1 to the functions in ./modules/my.module/Public.

.EXAMPLE
Update-ModuleManifestExports -ModulePath ./modules/my.module -WhatIf

Shows whether the manifest would change without writing it.

.INPUTS
System.String. The module path can be passed by pipeline.

.OUTPUTS
System.Management.Automation.PSCustomObject with ManifestPath, FunctionsToExport (the new list), Changed (the list
differs from the manifest) and Updated (the manifest was written).

.NOTES
Author: TheCodeSaiyan
Uses the PowerShell parser, so it needs Full Language Mode. New-FunctionsFromSwagger calls it after generating functions.
#>

function Update-ModuleManifestExports {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '',
        Justification = 'FunctionsToExport is a list; the name describes the manifest field it updates.')]
    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('FullName', 'Path')]
        [ValidateNotNullOrEmpty()]
        [string]$ModulePath,

        [string]$ManifestPath
    )

    begin {
        $TelemetryArgs = @{
            ModuleName    = $MyInvocation.MyCommand.Module.Name
            ModuleVersion = [string]$MyInvocation.MyCommand.Module.Version
            CommandName   = $MyInvocation.MyCommand.Name
            ExecutionID   = [guid]::NewGuid().ToString()
        }
        Invoke-TelemetryCollection @TelemetryArgs -Stage Start -ClearTimer
        $Failed = $false
    }
    process {
        try {
            $targetManifest = $ManifestPath
            $resolvedPath = (Resolve-Path -LiteralPath $ModulePath -ErrorAction Stop).ProviderPath
            if (Test-Path -LiteralPath $resolvedPath -PathType Leaf) {
                if (-not $targetManifest) { $targetManifest = $resolvedPath }
                $moduleFolder = Split-Path -Path $resolvedPath -Parent
            }
            else {
                $moduleFolder = $resolvedPath
                if (-not $targetManifest) {
                    $targetManifest = Join-Path -Path $moduleFolder -ChildPath ((Split-Path -Path $moduleFolder -Leaf) + '.psd1')
                }
            }
            if (-not (Test-Path -LiteralPath $targetManifest -PathType Leaf)) {
                throw "Module manifest not found: $targetManifest"
            }
            $targetManifest = (Resolve-Path -LiteralPath $targetManifest).ProviderPath

            # Functions defined at the top level of each public script (nested helper functions are not exported)
            $names = New-Object System.Collections.Generic.List[string]
            $publicPath = Join-Path -Path $moduleFolder -ChildPath 'Public'
            if (Test-Path -LiteralPath $publicPath -PathType Container) {
                $publicFiles = Get-ChildItem -LiteralPath $publicPath -Filter '*.ps1' -File -Recurse |
                    Where-Object {
                        $_.Name -notlike '*.Tests.ps1' -and
                        ($_.FullName.Substring($publicPath.Length) -split '[\\/]' | Where-Object { $_ -eq 'Tests' }).Count -eq 0
                    }
                foreach ($file in $publicFiles) {
                    $tokens = $null
                    $parseErrors = $null
                    $ast = [System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$tokens, [ref]$parseErrors)
                    if (@($parseErrors).Count -gt 0) {
                        Write-Warning "Skipping '$($file.FullName)': it does not parse ($($parseErrors[0].Message))."
                        continue
                    }
                    $definitions = $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false)
                    foreach ($definition in $definitions) {
                        if (-not $names.Contains($definition.Name)) { $names.Add($definition.Name) }
                    }
                }
            }
            $functionNames = @($names | Sort-Object)

            # Locate the FunctionsToExport value in the manifest hashtable
            $content = Get-Content -LiteralPath $targetManifest -Raw -Encoding UTF8
            if ($null -eq $content) { $content = '' }
            $tokens = $null
            $parseErrors = $null
            $manifestAst = [System.Management.Automation.Language.Parser]::ParseInput($content, [ref]$tokens, [ref]$parseErrors)
            if (@($parseErrors).Count -gt 0) { throw "The manifest '$targetManifest' does not parse: $($parseErrors[0].Message)" }
            $hashtable = $manifestAst.Find({ param($node) $node -is [System.Management.Automation.Language.HashtableAst] }, $false)
            if (-not $hashtable) { throw "The manifest '$targetManifest' does not contain a hashtable." }

            $newLine = if ($content.Contains("`r`n")) { "`r`n" } else { "`n" }
            $entry = $null
            foreach ($pair in $hashtable.KeyValuePairs) {
                if ($pair.Item1.Extent.Text.Trim("'", '"') -eq 'FunctionsToExport') { $entry = $pair; break }
            }

            $current = @()
            if ($entry) {
                $current = @($entry.Item2.FindAll({ param($node) $node -is [System.Management.Automation.Language.StringConstantExpressionAst] }, $true) | ForEach-Object Value)
                $keyLine = $content.Substring(0, $entry.Item1.Extent.StartOffset)
                $indent = ($keyLine.Substring($keyLine.LastIndexOf("`n") + 1)) -replace '\S.*$', ''
            }
            else {
                $indent = ' '
            }

            if ($functionNames.Count -eq 0) {
                $valueText = '@()'
            }
            else {
                $itemLines = @($functionNames | ForEach-Object { $indent + ' ' + "'" + $_.Replace("'", "''") + "'" })
                $valueText = '@(' + $newLine + ($itemLines -join (',' + $newLine)) + $newLine + $indent + ')'
            }

            if ($entry) {
                $start = $entry.Item2.Extent.StartOffset
                $end = $entry.Item2.Extent.EndOffset
                $updatedContent = $content.Substring(0, $start) + $valueText + $content.Substring($end)
            }
            else {
                # Add the entry before the closing brace of the manifest hashtable
                $closing = $hashtable.Extent.EndOffset - 1
                $before = $content.Substring(0, $closing).TrimEnd()
                $updatedContent = $before + $newLine + $indent + 'FunctionsToExport = ' + $valueText + $newLine + $content.Substring($closing)
            }

            $changed = -not $entry -or ((@($current) -join "`n") -cne ($functionNames -join "`n"))
            $updated = $false
            if ($changed -and $PSCmdlet.ShouldProcess($targetManifest, "Set FunctionsToExport to $($functionNames.Count) function(s)")) {
                Write-Utf8TextFile -Path $targetManifest -Content $updatedContent -Utf8Bom:(Test-Utf8Bom -Path $targetManifest)
                $updated = $true
            }

            [pscustomobject]@{
                ManifestPath      = $targetManifest
                FunctionsToExport = $functionNames
                Changed           = [bool]$changed
                Updated           = $updated
            }
        }
        catch {
            $Failed = $true
            Write-Error -ErrorRecord $_
        }
    }
    end {
        Invoke-TelemetryCollection @TelemetryArgs -Stage End -Failed $Failed
    }
}