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 } } |