Public/Convert-ModuleNameAndReferences.ps1

<#
.SYNOPSIS
Copies a PowerShell module to a new location, optionally renaming the module and updating all references to the new name.

.DESCRIPTION
This function copies one or more PowerShell module directories to a specified output path. If the module name contains a dot (.), you can replace the first segment of the module name with a specified string. All references to the old module name in the copied text files are updated to the new name, and files named after the module (for example the .psd1 and .psm1) are renamed. Function names (Verb-OldSegmentNoun) are renamed where they are defined and wherever they are used (calls, strings such as FunctionsToExport entries, and comments; whole names only), and file names containing the segment are also updated. Each file keeps its UTF-8 byte order mark. Binary files (files containing NUL characters) are copied unchanged.

.PARAMETER ModulePaths
An array of paths to the module directories to convert.

.PARAMETER OutputPath
The directory where the converted modules will be placed. Created if it does not exist.

.PARAMETER ReplaceFirstSegmentWith
If specified and the module name contains a dot, replaces the first segment of the module name (before the first dot) with this value. The segment is capitalized (PascalCase) when updating function and file names. Empty or whitespace is treated as "no replacement".

.PARAMETER Force
Overwrites the destination directory if it already exists.

.EXAMPLE
Convert-ModuleNameAndReferences -ModulePaths 'C:\git\tcs.confluence-1\ps\modules\tcs.confluence' -OutputPath 'C:\working\ModuleConversion' -ReplaceFirstSegmentWith 'X'

Copies the module and renames it so that the first segment is replaced with 'X', updating all references in the files.

.EXAMPLE
Convert-ModuleNameAndReferences -ModulePaths 'C:\modules\my.module' -OutputPath 'C:\out' -ReplaceFirstSegmentWith 'tcs' -Force

Overwrites the destination folder if C:\out\tcs.module already exists.

.INPUTS
System.String[]. Paths to module directories to convert (ModulePaths).

.OUTPUTS
None. Writes verbose output and creates files and folders under OutputPath.

.NOTES
Author: TheCodeSaiyan
Date: 2025-09-11
Function names are found by parsing the module's .ps1 and .psm1 files; calls, strings and comments that
use them are updated with the PowerShell tokenizer, so this command needs Full Language Mode.
Each file keeps its UTF-8 byte order mark (or lack of one).
#>

function Convert-ModuleNameAndReferences {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '',
        Justification = 'Public command name kept for backward compatibility.')]
    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([void])]
    param(
        [Parameter(Mandatory = $true)]
        [string[]]$ModulePaths,

        [Parameter(Mandatory = $true)]
        [string]$OutputPath,

        [string]$ReplaceFirstSegmentWith,

        [switch]$Force
    )

    $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

    try {
        # Replaces whole-word occurrences of each old function name (not part of a longer name or variable)
        $replaceWholeWords = {
            param([string]$Text, [hashtable]$Names)
            foreach ($oldName in $Names.Keys) {
                $pattern = '(?<![\w-])' + [regex]::Escape($oldName) + '(?![\w-])'
                $Text = [regex]::Replace($Text, $pattern, $Names[$oldName].Replace('$', '$$'), 'IgnoreCase')
            }
            $Text
        }

        if (-not (Test-Path -Path $OutputPath -PathType Container)) {
            if ($PSCmdlet.ShouldProcess($OutputPath, 'Create output folder')) {
                $null = New-Item -Path $OutputPath -ItemType Directory -Force -ErrorAction Stop
            }
        }

        foreach ($modulePath in $ModulePaths) {
            if (-not (Test-Path -Path $modulePath -PathType Container)) {
                Write-Warning "Module path not found: $modulePath"
                continue
            }
            $moduleName = Split-Path -Path $modulePath -Leaf
            $hasReplacement = $ReplaceFirstSegmentWith -and ($ReplaceFirstSegmentWith.Trim().Length -gt 0) -and ($moduleName -match '\.')
            if ($hasReplacement) {
                $oldFirstSegment = $moduleName -replace '\..*', ''
                $seg = $ReplaceFirstSegmentWith.Trim()
                $newModuleName = $moduleName -replace '^[^.]+', $seg
                $newSegmentCap = $seg.Substring(0, 1).ToUpper() + $seg.Substring(1)
                $oldFirstSegmentEscaped = [regex]::Escape($oldFirstSegment)
            }
            else {
                $newModuleName = $moduleName
                $newSegmentCap = $null
                $oldFirstSegmentEscaped = $null
            }
            $moduleNameEscaped = [regex]::Escape($moduleName)
            $destModulePath = Join-Path -Path $OutputPath -ChildPath $newModuleName

            if (Test-Path -Path $destModulePath) {
                if ($Force) {
                    if ($PSCmdlet.ShouldProcess($destModulePath, 'Remove-Item')) {
                        Remove-Item -Path $destModulePath -Recurse -Force
                    }
                }
                else {
                    Write-Error "Destination module already exists: $destModulePath. Use -Force to overwrite. Skipping."
                    continue
                }
            }

            if ($PSCmdlet.ShouldProcess($destModulePath, 'Copy-Item')) {
                Copy-Item -Path $modulePath -Destination $destModulePath -Recurse -Force

                $files = @(Get-ChildItem -Path $destModulePath -Recurse -File)
                $scriptExtensions = @('.ps1', '.psm1', '.psd1')

                # Function renames (Verb-OldSegmentNoun -> Verb-NewSegmentNoun), taken from the function
                # definitions in every script file so that call sites can be renamed as well
                $functionRenames = @{}
                if ($hasReplacement) {
                    foreach ($file in $files | Where-Object { $_.Extension -in @('.ps1', '.psm1') }) {
                        $tokens = $null
                        $parseErrors = $null
                        $ast = [System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$tokens, [ref]$parseErrors)
                        $definitions = $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)
                        foreach ($definition in $definitions) {
                            $name = $definition.Name
                            if ($name -notmatch '^(?<verb>[^-]+)-(?<noun>.*)$') { continue }
                            $verb = $Matches['verb']
                            $noun = $Matches['noun']
                            if ($noun -eq '') {
                                # Verb-only name: add the new segment as the noun
                                $functionRenames[$name] = "$verb-$newSegmentCap"
                            }
                            elseif ($noun -match "^$oldFirstSegmentEscaped") {
                                $functionRenames[$name] = "$verb-$newSegmentCap" + $noun.Substring($oldFirstSegment.Length)
                            }
                        }
                    }
                }

                # Replace references to the old module name and the renamed functions in all text files
                foreach ($file in $files) {
                    $fullName = $file.FullName
                    $content = Get-Content -Path $fullName -Raw -Encoding UTF8
                    if ($null -eq $content -or $content -match "`0") {
                        # Empty or binary file: nothing to update
                        continue
                    }
                    $updatedContent = $content -replace $moduleNameEscaped, $newModuleName

                    if ($functionRenames.Count -gt 0) {
                        if ($file.Extension -in $scriptExtensions) {
                            # Use the tokenizer so only command names, strings and comments are changed
                            $tokens = $null
                            $parseErrors = $null
                            $null = [System.Management.Automation.Language.Parser]::ParseInput($updatedContent, [ref]$tokens, [ref]$parseErrors)
                            $edits = foreach ($token in $tokens) {
                                $kind = $token.Kind.ToString()
                                $newText = $null
                                if ($kind -in @('Generic', 'Identifier') -and $functionRenames.ContainsKey($token.Text)) {
                                    $newText = $functionRenames[$token.Text]
                                }
                                elseif ($kind -in @('StringLiteral', 'StringExpandable', 'HereStringLiteral', 'HereStringExpandable', 'Comment')) {
                                    $newText = & $replaceWholeWords $token.Text $functionRenames
                                    if ($newText -ceq $token.Text) { $newText = $null }
                                }
                                if ($null -ne $newText) {
                                    [PSCustomObject]@{ Start = $token.Extent.StartOffset; End = $token.Extent.EndOffset; Text = $newText }
                                }
                            }
                            # Apply from the end so earlier offsets stay valid
                            foreach ($edit in @($edits | Sort-Object -Property Start -Descending)) {
                                $updatedContent = $updatedContent.Substring(0, $edit.Start) + $edit.Text + $updatedContent.Substring($edit.End)
                            }
                        }
                        else {
                            $updatedContent = & $replaceWholeWords $updatedContent $functionRenames
                        }
                    }
                    if ($updatedContent -cne $content -and $PSCmdlet.ShouldProcess($fullName, 'Update references')) {
                        # Keep the file's byte order mark (Get-Content drops it)
                        Write-Utf8TextFile -Path $fullName -Content $updatedContent -Utf8Bom:(Test-Utf8Bom -Path $fullName)
                    }
                }

                # Rename files named after the module, e.g. old.name.psd1 -> new.name.psd1
                if ($newModuleName -ne $moduleName) {
                    $filesToRename = Get-ChildItem -Path $destModulePath -Recurse -File | Where-Object { $_.Name -match $moduleNameEscaped }
                    foreach ($file in $filesToRename) {
                        $newName = $file.Name -replace $moduleNameEscaped, $newModuleName
                        if ($PSCmdlet.ShouldProcess($file.FullName, "Rename to $newName")) {
                            Rename-Item -Path $file.FullName -NewName $newName -Force
                        }
                    }
                }

                # Rename files that contain the old first segment after dash
                if ($hasReplacement) {
                    $filesToRename = Get-ChildItem -Path $destModulePath -Recurse -File | Where-Object { $_.Name -match "-$oldFirstSegmentEscaped" }
                    foreach ($file in $filesToRename) {
                        $newName = $file.Name -replace "-$oldFirstSegmentEscaped", "-$newSegmentCap"
                        if ($PSCmdlet.ShouldProcess($file.FullName, "Rename to $newName")) {
                            Rename-Item -Path $file.FullName -NewName $newName -Force
                        }
                    }
                    # If file name is just Verb-.ps1 (no segment), add new segment
                    $filesToAddSegment = Get-ChildItem -Path $destModulePath -Recurse -File | Where-Object { $_.Name -match '-\.ps1$' }
                    foreach ($file in $filesToAddSegment) {
                        $newName = $file.Name -replace '-\.ps1$', "-$newSegmentCap.ps1"
                        if ($PSCmdlet.ShouldProcess($file.FullName, "Rename to $newName")) {
                            Rename-Item -Path $file.FullName -NewName $newName -Force
                        }
                    }
                }

                Write-Verbose "Converted $moduleName to $newModuleName at $destModulePath"
            }
        }
        Invoke-TelemetryCollection @TelemetryArgs -Stage End
    }
    catch {
        Invoke-TelemetryCollection @TelemetryArgs -Stage End -Failed $true -Exception $_
        throw
    }
}