functions/refactor/Rename-PSMDParameter.ps1

function Rename-PSMDParameter
{
    <#
        .SYNOPSIS
            Renames a parameter of a function.
         
        .DESCRIPTION
            This command is designed to rename the parameter of a function within an entire module.
            By default it will add an alias for the previous command name.
             
            In order for this to work you need to consider to have the command / module imported.
            Hint: Import the psm1 file for best results.
             
            It will then search all files in the specified path (hint: Specify module root for best results), and update all psm1/ps1 files.
            At the same time it will force all commands to call the parameter by its new standard, even if they previously used an alias for the parameter.
             
            While this command was designed to work with a module, it is not restricted to that:
            You can load a standalone function and specify a path with loose script files for the same effect.
             
            Note:
            You can also use this to update your scripts, after a foreign module introduced a breaking change by renaming a parameter.
            In this case, import the foreign module to see the function, but point it at the base path of your scripts to update.
            The loaded function is only used for alias/parameter alias resolution
         
        .PARAMETER Path
            The path to the root folder where all the files are stored.
            It will search the folder recursively and ignore hidden files & folders.
         
        .PARAMETER Command
            The name of the function, whose parameter should be changed.
            Most be loaded into the current runtime.
         
        .PARAMETER Name
            The name of the parameter to change.
         
        .PARAMETER NewName
            The new name for the parameter.
            Do not specify "-" or the "$" symbol
         
        .PARAMETER NoAlias
            Avoid creating an alias for the old parameter name.
            This may cause a breaking change!
     
        .PARAMETER WhatIf
            Prevents the command from updating the files.
            Instead it will return the strings of all its changes.
         
        .PARAMETER EnableException
            Replaces user friendly yellow warnings with bloody red exceptions of doom!
            Use this if you want the function to throw terminating errors you want to catch.
         
        .PARAMETER DisableCache
            By default, this command caches the results of its execution in the PSFramework result cache.
            This information can then be retrieved for the last command to do so by running Get-PSFResultCache.
            Setting this switch disables the caching of data in the cache.
         
        .EXAMPLE
            PS C:\> Rename-PSMDParameter -Path 'C:\Scripts\Modules\MyModule' -Command 'Get-Test' -Name 'Foo' -NewName 'Bar'
             
            Renames the parameter 'Foo' of the command 'Get-Test' to 'Bar' for all scripts stored in 'C:\Scripts\Modules\MyModule'
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSupportsShouldProcess", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseDeclaredVarsMoreThanAssignments", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseOutputTypeCorrectly", "")]
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $Path,
        
        [Parameter(Mandatory = $true)]
        [string[]]
        $Command,
        
        [Parameter(Mandatory = $true)]
        [string]
        $Name,
        
        [Parameter(Mandatory = $true)]
        [string]
        $NewName,
        
        [switch]
        $NoAlias,
        
        [switch]
        $WhatIf,
        
        [switch]
        $EnableException,
        
        [switch]
        $DisableCache
    )
    
    # Global Store for pending file updates
    # Exempt from Scope Boundary violation rule, since only accessed using dedicated helper function
    $globalFunctionHash = @{ }
    
    #region Helper Functions
    function Invoke-AstWalk
    {
        [CmdletBinding()]
        Param (
            $Ast,
            
            [string[]]
            $Command,
            
            [string[]]
            $Name,
            
            [string]
            $NewName,
            
            [bool]
            $IsCommand,
            
            [bool]
            $NoAlias
        )
        
        #Write-PSFMessage -Level Host -Message "Processing $($Ast.Extent.StartLineNumber) | $($Ast.Extent.File) | $($Ast.GetType().FullName)"
        $typeName = $Ast.GetType().FullName
        
        switch ($typeName)
        {
            "System.Management.Automation.Language.CommandAst"
            {
                Write-PSFMessage -Level Verbose -Message "Line $($Ast.Extent.StartLineNumber): Processing Command Ast: <c='em'>$($Ast.Extent.ToString())</c>"
                
                $commandName = $Ast.CommandElements[0].Value
                $resolvedCommand = $commandName
                
                if (Test-Path function:\$commandName)
                {
                    $resolvedCommand = (Get-Item function:\$commandName).Name
                }
                if (Test-Path alias:\$commandName)
                {
                    $resolvedCommand = (Get-Item alias:\$commandName).ResolvedCommand.Name
                }
                
                if ($resolvedCommand -in $Command)
                {
                    $parameters = $Ast.CommandElements | Where-Object { $_.GetType().FullName -eq "System.Management.Automation.Language.CommandParameterAst" }
                    
                    foreach ($parameter in $parameters)
                    {
                        if ($parameter.ParameterName -in $Name)
                        {
                            Write-PSFMessage -Level SomewhatVerbose -Message "Found parameter: <c='em'>$($parameter.ParameterName)</c>"
                            Update-CommandParameter -Ast $parameter -NewName $NewName
                        }
                    }
                    
                    $splatted = $Ast.CommandElements | Where-Object Splatted
                    
                    if ($splatted)
                    {
                        foreach ($splat in $splatted)
                        {
                            Write-PSFMessage -Level Warning -FunctionName Rename-PSMDParameter -Message "Splat detected! Manually verify $($splat.Extent.Text) at line $($splat.Extent.StartLineNumber) in file $($splat.Extent.File)" -Tag 'splat','fail','manual'
                            Write-Issue -Extent $splat.Extent -Data $Ast -Type "SplattedParameter"
                        }
                    }
                }
                
                foreach ($element in $Ast.CommandElements)
                {
                    if ($element.GetType().FullName -ne "System.Management.Automation.Language.CommandParameterAst")
                    {
                        Invoke-AstWalk -Ast $element -Command $Command -Name $Name -NewName $NewName -IsCommand $IsCommand -NoAlias $NoAlias
                    }
                }
            }
            "System.Management.Automation.Language.FunctionDefinitionAst"
            {
                if ($Ast.Name -In $Command)
                {
                    foreach ($parameter in $Ast.Body.ParamBlock.Parameters)
                    {
                        if ($Name[0] -ne $parameter.Name.VariablePath.UserPath) { continue }
                        
                        $stringExtent = $parameter.Extent.ToString()
                        $lines = $stringExtent.Split("`n")
                        $multiLine = $lines -gt 1
                        $indent = 0
                        $indentStyle = "`t"
                        
                        if ($multiLine)
                        {
                            if ($lines[1][0] -eq " ") { $indentStyle = " " }
                            $indent = $lines[1].Length - $lines[1].Trim().Length
                        }
                        
                        $aliases = @()
                        foreach ($attribute in $parameter.Attributes)
                        {
                            if ($attribute.TypeName.FullName -eq "Alias") { $aliases += $attribute }
                        }
                        
                        $aliasNames = $aliases.PositionalArguments.Value
                        if ($aliasNames -contains $NewName) { $aliasNames = $aliasNames | Where-Object { $_ -ne $NewName } }
                        if (-not $NoAlias) { $aliasNames += $Name }
                        $aliasNames = $aliasNames | Select-Object -Unique | Sort-Object
                        
                        if ($aliasNames)
                        {
                            if ($aliases)
                            {
                                $newAlias = "[Alias($("'" + ($aliasNames -join "','")+ "'"))]"
                                Add-FileReplacement -Path $aliases[0].Extent.File -Start $aliases[0].Extent.StartOffset -Length ($aliases[0].Extent.EndOffset - $aliases[0].Extent.StartOffset) -NewContent $newAlias
                                Add-FileReplacement -Path $parameter.Name.Extent.File -Start $parameter.Name.Extent.StartOffset -Length ($parameter.Name.Extent.EndOffset - $parameter.Name.Extent.StartOffset) -NewContent "`$$NewName"
                            }
                            else
                            {
                                if ($multiLine)
                                {
                                    $newAliasAndName = "[Alias($("'" + ($aliasNames -join "','") + "'"))]`n$($indentStyle * $indent)`$$NewName"
                                }
                                else
                                {
                                    $newAliasAndName = "[Alias($("'" + ($aliasNames -join "','") + "'"))]`$$NewName"
                                }
                                Add-FileReplacement -Path $parameter.Name.Extent.File -Start $parameter.Name.Extent.StartOffset -Length ($parameter.Name.Extent.EndOffset - $parameter.Name.Extent.StartOffset) -NewContent $newAliasAndName
                            }
                        }
                        else
                        {
                            Add-FileReplacement -Path $parameter.Name.Extent.File -Start $parameter.Name.Extent.StartOffset -Length ($parameter.Name.Extent.EndOffset - $parameter.Name.Extent.StartOffset) -NewContent "`$$NewName"
                        }
                    }
                    
                    if ($Ast.Body.DynamicParamBlock) { Invoke-AstWalk -Ast $Ast.Body.DynamicParamBlock -Command $Command -Name $Name -NewName $NewName -IsCommand $true -NoAlias $NoAlias }
                    if ($Ast.Body.BeginBlock) { Invoke-AstWalk -Ast $Ast.Body.BeginBlock -Command $Command -Name $Name -NewName $NewName -IsCommand $true -NoAlias $NoAlias }
                    if ($Ast.Body.ProcessBlock) { Invoke-AstWalk -Ast $Ast.Body.ProcessBlock -Command $Command -Name $Name -NewName $NewName -IsCommand $true -NoAlias $NoAlias }
                    if ($Ast.Body.EndBlock) { Invoke-AstWalk -Ast $Ast.Body.EndBlock -Command $Command -Name $Name -NewName $NewName -IsCommand $true -NoAlias $NoAlias }
                    
                    Update-CommandParameterHelp -FunctionAst $Ast -ParameterName $Name[0] -NewName $NewName
                }
                else
                {
                    Invoke-AstWalk -Ast $Ast.Body -Command $Command -Name $Name -NewName $NewName -IsCommand $false -NoAlias $NoAlias
                }
            }
            "System.Management.Automation.Language.VariableExpressionAst"
            {
                if ($IsCommand -and ($Ast.VariablePath.UserPath -eq $Name))
                {
                    Add-FileReplacement -Path $Ast.Extent.File -Start $Ast.Extent.StartOffset -Length ($Ast.Extent.EndOffset - $Ast.Extent.StartOffset) -NewContent "`$$NewName"
                }
            }
            "System.Management.Automation.Language.IfStatementAst"
            {
                foreach ($clause in $Ast.Clauses)
                {
                    Invoke-AstWalk -Ast $clause.Item1 -Command $Command -Name $Name -NewName $NewName -IsCommand $IsCommand -NoAlias $NoAlias
                    Invoke-AstWalk -Ast $clause.Item2 -Command $Command -Name $Name -NewName $NewName -IsCommand $IsCommand -NoAlias $NoAlias
                }
                if ($Ast.ElseClause)
                {
                    Invoke-AstWalk -Ast $Ast.ElseClause -Command $Command -Name $Name -NewName $NewName -IsCommand $IsCommand -NoAlias $NoAlias
                }
            }
            default
            {
                foreach ($property in $Ast.PSObject.Properties)
                {
                    if ($property.Name -eq "Parent") { continue }
                    if ($null -eq $property.Value) { continue }
                    
                    if (Get-Member -InputObject $property.Value -Name GetEnumerator -MemberType Method)
                    {
                        foreach ($item in $property.Value)
                        {
                            if ($item.PSObject.TypeNames -contains "System.Management.Automation.Language.Ast")
                            {
                                Invoke-AstWalk -Ast $item -Command $Command -Name $Name -NewName $NewName -IsCommand $IsCommand -NoAlias $NoAlias
                            }
                        }
                        continue
                    }
                    
                    if ($property.Value.PSObject.TypeNames -contains "System.Management.Automation.Language.Ast")
                    {
                        Invoke-AstWalk -Ast $property.Value -Command $Command -Name $Name -NewName $NewName -IsCommand $IsCommand -NoAlias $NoAlias
                    }
                }
            }
        }
    }
    
    function Update-CommandParameter
    {
        [CmdletBinding()]
        Param (
            [System.Management.Automation.Language.CommandParameterAst]
            $Ast,
            
            [string]
            $NewName
        )
        
        $name = $NewName
        if ($name -notlike "-*") { $name = "-$name" }
        
        $length = $Ast.Extent.EndOffset - $Ast.Extent.StartOffset
        if ($null -ne $Ast.Argument) { $length = $Ast.Argument.Extent.StartOffset - $Ast.Extent.StartOffset - 1 }
        
        Add-FileReplacement -Path $Ast.Extent.File -Start $Ast.Extent.StartOffset -Length $length -NewContent $name
    }
    
    function Update-CommandParameterHelp
    {
        [CmdletBinding()]
        Param (
            [System.Management.Automation.Language.FunctionDefinitionAst]
            $FunctionAst,
            
            [string]
            $ParameterName,
            
            [string]
            $NewName
        )
        
        function Get-StartIndex
        {
            [CmdletBinding()]
            Param (
                [System.Management.Automation.Language.FunctionDefinitionAst]
                $FunctionAst,
                
                [string]
                $ParameterName,
                
                [int]
                $HelpEnd
            )
            
            if ($HelpEnd -lt 1) { return -1 }
            
            $index = -1
            $offset = 0
            
            while ($FunctionAst.Extent.Text.SubString(0, $HelpEnd).IndexOf(".PARAMETER $ParameterName", $offset, [System.StringComparison]::InvariantCultureIgnoreCase) -ne -1)
            {
                $tempIndex = $FunctionAst.Extent.Text.SubString(0, $HelpEnd).IndexOf(".PARAMETER $ParameterName", $offset, [System.StringComparison]::InvariantCultureIgnoreCase)
                $endOfLineIndex = $FunctionAst.Extent.Text.SubString(0, $HelpEnd).IndexOf("`n", $tempIndex, [System.StringComparison]::InvariantCultureIgnoreCase)
                if ($FunctionAst.Extent.Text.SubString($tempIndex, ($endOfLineIndex - $tempIndex)).Trim() -eq ".PARAMETER $ParameterName")
                {
                    return $tempIndex
                }
                $offset = $endOfLineIndex
            }
            
            return $index
        }
        
        $startIndex = $FunctionAst.Extent.StartOffset
        $endIndex = $FunctionAst.Body.ParamBlock.Extent.StartOffset
        foreach ($attribute in $FunctionAst.Body.ParamBlock.Attributes)
        {
            if ($attribute.Extent.StartOffset -lt $endIndex) { $endIndex = $attribute.Extent.StartOffset }
        }
        
        $index1 = Get-StartIndex -FunctionAst $FunctionAst -ParameterName $ParameterName -HelpEnd ($endIndex - $startIndex)
        if ($index1 -eq -1)
        {
            Write-PSFMessage -Level Warning -Message "Could not find Comment Based Help for parameter '$ParameterName' of command '$($FunctionAst.Name)' in '$($FunctionAst.Extent.File)'" -Tag 'cbh', 'fail' -FunctionName Rename-PSMDParameter
            Write-Issue -Extent $FunctionAst.Extent -Type "ParameterCBHNotFound" -Data "Parameter Help not found"
            return
        }
        $index2 = $FunctionAst.Extent.Text.SubString(0, ($endIndex - $startIndex)).IndexOf("$ParameterName", $index1, [System.StringComparison]::InvariantCultureIgnoreCase)
        
        Add-FileReplacement -Path $FunctionAst.Extent.File -Start ($index2 + $startIndex) -Length $ParameterName.Length -NewContent $NewName
    }
    
    function Add-FileReplacement
    {
        [CmdletBinding()]
        Param (
            [string]
            $Path,
            
            [int]
            $Start,
            
            [int]
            $Length,
            
            [string]
            $NewContent
        )
        Write-PSFMessage -Level Verbose -Message "Change Submitted: $Path | $Start | $Length | $NewContent" -Tag 'update','change','file'
        
        if (-not $globalFunctionHash.ContainsKey($Path))
        {
            $globalFunctionHash[$Path] = @()
        }
        
        $globalFunctionHash[$Path] += New-Object PSObject -Property @{
            Content  = $NewContent
            Start      = $Start
            Length    = $Length
        }
    }
    
    function Apply-FileReplacement
    {
        [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseApprovedVerbs", "")]
        [CmdletBinding()]
        Param (
            [bool]
            $WhatIf
        )
        
        foreach ($key in $globalFunctionHash.Keys)
        {
            $value = $globalFunctionHash[$key] | Sort-Object Start
            $content = [System.IO.File]::ReadAllText($key)
            
            $newString = ""
            $currentIndex = 0
            
            foreach ($item in $value)
            {
                $newString += $content.SubString($currentIndex, ($item.Start - $currentIndex))
                $newString += $item.Content
                $currentIndex = $item.Start + $item.Length
            }
            
            $newString += $content.SubString($currentIndex)
            
            if ($WhatIf) { $newString }
            else { [System.IO.File]::WriteAllText($key, $newString) }
        }
    }
    
    function Write-Issue
    {
        [CmdletBinding()]
        Param (
            $Extent,
            
            $Data,
            
            [string]
            $Type
        )
        
        New-Object PSObject -Property @{
            Type  = $Type
            Data   = $Data
            File     = $Extent.File
            StartLine = $Extent.StartLineNumber
            Text = $Extent.Text
        }
    }
    #endregion Helper Functions
    
    foreach ($item in $Command)
    {
        try { $com = Get-Item function:\$item -ErrorAction Stop }
        catch
        {
            Stop-PSFFunction -Message "Could not find command, please import the module using the psm1 file before starting a refactor" -EnableException $EnableException -Category ObjectNotFound -ErrorRecord $_ -OverrideExceptionMessage -Tag "fail", "input"
            return
        }
    }
    
    $files = Get-ChildItem -Path $Path -Recurse | Where-Object Extension -Match "\.ps1|\.psm1"
    
    $issues = @()
    
    foreach ($file in $files)
    {
        $tokens = $null
        $parsingError = $null
        $ast = [System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$tokens, [ref]$parsingError)
        
        Write-PSFMessage -Level VeryVerbose -Message "Replacing <c='sub'>$Command / $Name</c> with <c='em'>$NewName</c> | Scanning $($file.FullName)" -Tag 'start' -Target $Name
        $issues += Invoke-AstWalk -Ast $ast -Command $Command -Name $Name -NewName $NewName -IsCommand $false -NoAlias $NoAlias
    }
    
    Set-PSFResultCache -InputObject $issues -DisableCache $DisableCache
    Apply-FileReplacement -WhatIf $WhatIf
    $issues
}