Public/ConvertTo-SplatExpression.ps1

using namespace Microsoft.PowerShell.EditorServices.Extensions
using namespace System.Collections.Generic
using namespace System.Management.Automation.Language

function ConvertTo-SplatExpression {
    <#
    .EXTERNALHELP EditorServicesCommandSuite-help.xml
    #>

    [CmdletBinding()]
    [EditorCommand(DisplayName='Convert Command to Splat Expression')]
    param(
        [System.Management.Automation.Language.Ast]
        $Ast
    )
    begin {
        function ConvertFromExpressionAst($expression) {
            $isStringExpression = $expression -is [StringConstantExpressionAst] -or
                                  $expression -is [ExpandableStringExpressionAst]

            if ($isStringExpression) {
                # If kind isn't BareWord then it's already enclosed in quotes.
                if ('BareWord' -ne $expression.StringConstantType) {
                    return $expression.Extent.Text
                }
                $enclosure = "'"
                if ($expression.NestedExpressions) {
                    $enclosure = '"'
                }

                return '{0}{1}{0}' -f $enclosure, $expression.Value
            }
            # When we handle switch parameters we don't create an AST.
            if ($pair.Value -isnot [Ast]) {
                return $expression
            }

            return $expression.Extent.Text
        }
    }
    end {
        $Ast = GetAncestorOrThrow $Ast -AstTypeName CommandAst -ErrorContext $PSCmdlet

        $commandName, $elements = $Ast.CommandElements.Where({ $true }, 'Split', 1)

        $splat           = @{}
        $retainedArgs    = [List[Ast]]::new()
        $elementsExtent  = $elements.Extent | Join-ScriptExtent
        $boundParameters = [StaticParameterBinder]::BindCommand($Ast).BoundParameters

        # Start building the hash table of named parameters and values
        foreach ($parameter in $boundParameters.GetEnumerator()) {
            # If the command isn't loaded positional parameters come through as their numeric position.
            if ($parameter.Key -match '\d+' -and -not $parameter.Value.Parameter) {
                $retainedArgs.Add($parameter.Value.Value)
                continue
            }
            # The "Value" property for switches is the parameter AST (e.g. -Force) so we need to
            # manually build the expression.
            if ($parameter.Value.ConstantValue -is [bool]) {
                $splat.($parameter.Key) = '${0}' -f $parameter.Value.ConstantValue.ToString().ToLower()
                continue
            }
            $splat.($parameter.Key) = $parameter.Value.Value
        }

        # Remove the hypen, change to camelCase and add 'Splat'
        $variableName = [regex]::Replace(
            ($commandName.Extent.Text -replace '-'),
            '^[A-Z]',
            { $args[0].Value.ToLower() }) +
            'Splat'

        $sb = [System.Text.StringBuilder]::
            new('${0}' -f $variableName).
            AppendLine(' = @{')

        # All StringBuilder methods return itself so it can be chained. We null the whole scriptblock
        # here so unchained method calls don't add to our output.
        $null = & {
            foreach($pair in $splat.GetEnumerator()) {
                $sb.Append(' ').
                    Append($pair.Key).
                    Append(' = ')
                if ($pair.Value -is [ArrayLiteralAst]) {
                    $sb.AppendLine($pair.Value.Elements.ForEach{
                        ConvertFromExpressionAst $PSItem
                    } -join ', ')
                } else {
                    $sb.AppendLine((ConvertFromExpressionAst $pair.Value))
                }
            }
            $sb.Append('}')
        }
        $splatText = $sb.ToString()

        # New CommandAst will be `Command @splatvar [PositionalArguments]`
        $newCommandParameters = '@' + $variableName
        if ($retainedArgs) {
            $newCommandParameters += ' ' + ($retainedArgs.Extent.Text -join ' ')
        }

        # Change the command expression first so we don't need to track it's position.
        $elementsExtent | Set-ScriptExtent -Text $newCommandParameters

        # Get the parent PipelineAst so we don't add the splat in the middle of a pipeline.
        $pipeline = $Ast | Find-Ast -Ancestor -First { $PSItem -is [PipelineAst] }

        # Prepend the existing indent.
        $lineText = ($psEditor.GetEditorContext().
            CurrentFile.
            Ast.
            Extent.
            Text -split '\r?\n')[$pipeline.Extent.StartLineNumber - 1]

        $lineIndent  = $lineText -match '^\s*' | ForEach-Object { $matches[0] }
        $splatText   = $lineIndent + (
                       $splatText -split '\r?\n' -join ([Environment]::NewLine + $lineIndent))

        # HACK: Temporary workaround until https://github.com/PowerShell/PowerShellEditorServices/pull/541
        #$splatTarget = ConvertTo-ScriptExtent -Line $pipeline.Extent.StartLineNumber
        $splatTarget = [Microsoft.PowerShell.EditorServices.FullScriptExtent]::new(
            $psEditor.GetEditorContext().CurrentFile,
            [Microsoft.PowerShell.EditorServices.BufferRange]::new(
                $pipeline.Extent.StartLineNumber,
                1,
                $pipeline.Extent.StartLineNumber,
                1))

        $splatTarget | Set-ScriptExtent -Text ($splatText + [Environment]::NewLine)
    }
}