Public/ConvertTo-FunctionDefinition.ps1

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

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

    [EditorCommand(DisplayName='Create New Function From Selection')]
    [CmdletBinding(DefaultParameterSetName='__AllParameterSets')]
    param(
        [ValidateNotNullOrEmpty()]
        [System.Management.Automation.Language.IScriptExtent] $Extent,

        [ValidateNotNullOrEmpty()]
        [string] $FunctionName,

        [Parameter(ParameterSetName='ExternalFile')]
        [ValidateNotNull()]
        [string] $DestinationPath,

        [Parameter(ParameterSetName='BeginBlock')]
        [switch] $BeginBlock,

        [Parameter(ParameterSetName='Inline')]
        [switch] $Inline
    )
    begin {
        # Ensure a script extent includes the entire starting line including whitespace.
        function ExpandExtent {
            param(
                [Parameter(ValueFromPipeline)]
                [IScriptExtent] $ExtentToExpand
            )
            process {
                if (-not $ExtentToExpand -or $ExtentToExpand.StartColumnNumber -eq 1) {
                    return $ExtentToExpand
                }

                return [Microsoft.PowerShell.EditorServices.FullScriptExtent]::new(
                    $psEditor.GetEditorContext().CurrentFile,
                    [Microsoft.PowerShell.EditorServices.BufferRange]::new(
                        $ExtentToExpand.StartLineNumber,
                        1,
                        $ExtentToExpand.EndLineNumber,
                        $ExtentToExpand.EndColumnNumber))
            }
        }

        # Create an named end block from the default unnamed end block.
        function CreateEndBlock {
            param([NamedBlockAst] $Ast)
            end {
                $statements = $Ast.Statements | Join-ScriptExtent
                $endBlockIndent = $statements.StartColumnNumber - 1
                $statements = $statements | ExpandExtent

                $endBlockText = 'end {',
                                ($statements | NormalizeIndent | AddIndent -Amount 4),
                                '}' |
                                AddIndent -Amount $endBlockIndent

                $statements | Set-ScriptExtent -Text $endBlockText
            }
        }

        # Get specified extent, selected text, or throw.
        function GetTargetExtent {
            if ($Extent) {
                return $Extent | ExpandExtent
            }

            $selectedRange = $psEditor.GetEditorContext().SelectedRange
            if ($selectedRange.Start -ne $selectedRange.End) {
                return $selectedRange | ConvertTo-ScriptExtent | ExpandExtent
            }

            ThrowError -Exception ([PSArgumentException]::new($Strings.NoExtentSelected)) `
                       -Id NoExtentSelected `
                       -Category InvalidArgument `
                       -Target $Extent `
                       -Show
        }

        # Prompt for destination if not specified, throw if no selection is made.
        function ValidateDestination {
            if ($PSCmdlet.ParameterSetName -in 'BeginBlock', 'Inline', 'ExternalFile') {
                return $PSCmdlet.ParameterSetName
            }

            $choices = [Host.ChoiceDescription]::new('BeginBlock', $Strings.ExportFunctionBeginDescription),
                       [Host.ChoiceDescription]::new('Inline', $Strings.ExportFunctionInlineDescription),
                       [Host.ChoiceDescription]::new('ExternalFile', $Strings.ExportFunctionExternalFileDescription)

            $choice = ReadChoicePrompt -Prompt $Strings.ExportFunctionPrompt -Choices $choices
            return $choices[$choice].Label
        }

        # Prompt for file path if selected from the menu, throw if not specified.
        function ValidateDestinationFile {
            if (-not [string]::IsNullOrWhiteSpace($DestinationPath)) {
                return $DestinationPath
            }

            $file = ReadInputPrompt -Prompt $Strings.EnterDestinationFilePrompt
            if (-not [string]::IsNullOrWhiteSpace($file)) {
                return $file
            }

            ThrowError -Exception ([PSArgumentException]::new($Strings.NoDestinationFile)) `
                       -Id NoDestinationFile `
                       -Category InvalidArgument `
                       -Target $file `
                       -Show
        }

        # Prompt for function name if not specified in parameters. Throw if still null.
        function ValidateFunctionName {
            if (-not [string]::IsNullOrWhitespace($FunctionName)) {
                return $FunctionName
            }

            $FunctionName = ReadInputPrompt -Prompt $Strings.ExportFunctionNamePrompt

            if (-not [string]::IsNullOrWhiteSpace($FunctionName)) {
                return $FunctionName
            }

            ThrowError -Exception ([PSArgumentException]::new($Strings.MissingFunctionName)) `
                       -Id MissingFunctionName `
                       -Category InvalidArgument `
                       -Target $FunctionName `
                       -Show
        }

        # Safely captialize the first character if a string. If the string is two or less characters
        # then capitialize the whole string.
        function ToPascalCase {
            param([string] $String)
            end {
                if ($String.Length -le 2) {
                    return $String.ToUpperInvariant()
                }

                return $String.Substring(0, 1).ToUpperInvariant() +
                       ($String[1..$String.Length] -join '')
            }
        }

        # Compile a dictionary of unique variables that should be parameters, along with their
        # inferred type if possible.
        function GetInferredParameters {
            param([VariableExpressionAst[]] $Variables)
            end {
                $parameters = [Dictionary[string, Tuple[string, string, type, bool]]]::new(
                    [StringComparer]::InvariantCultureIgnoreCase)

                if (-not $Variables.Count) {
                    return $parameters
                }

                foreach ($variable in $Variables) {
                    $asPascalCase = ToPascalCase $variable.VariablePath.UserPath

                    $existingParameter = $null
                    if ($parameters.TryGetValue($asPascalCase, [ref]$existingParameter)) {
                        if ($existingParameter.Item3 -ne [object]) {
                            continue
                        }

                        $inferredType = GetInferredType -Ast $variable -ErrorAction Ignore
                        if ($inferredType -ne [object]) {
                            $parameters[$asPascalCase] = [Tuple[string, string, type, bool]]::new(
                                $asPascalCase,
                                $variable.VariablePath.UserPath,
                                $inferredType,
                                $existingParameter.Item4)
                        }

                        continue
                    }

                    $inferredType = GetInferredType -Ast $variable -ErrorAction Ignore
                    if (-not $inferredType) {
                        $inferredType = [object]
                    }

                    $parseErrors = $null
                    $parsedVariableName = [Parser]::ParseInput(
                        '${0}' -f $variable.VariablePath.UserPath,
                        [ref]$null,
                        [ref]$parseErrors)

                    $shouldEscape = $parseErrors.Count -or
                        $parsedVariableName.EndBlock.Statements.PipelineElements.Count -gt 1

                    $parameters.Add(
                        $asPascalCase,
                        [Tuple[string, string, type, bool]]::new(
                            $asPascalCase,
                            $variable.VariablePath.UserPath,
                            $inferredType,
                            $shouldEscape))
                }

                return $parameters
            }
        }

        # Get variable names for the scope that are considered for our purposes as "locals".
        # Include variables that are:
        # 1 - Assigned within in the target AST
        # 2 - Assigned from language constructs like foreach statements
        # 3 - Special variables like $_/$ExecutionContext/etc
        # 4 - Have a scope in the user path (i.e $global:varName)
        function GetLocalVariables {
            param([Ast] $Ast)
            end {
                $localVariables = [List[string]]::new()
                $assignmentAsts = Find-Ast -Ast $targetAst -Family {
                    # Find variable assignments, exlude member/index expression assignments.
                    $PSItem -is [AssignmentStatementAst] -and (
                    $PSItem.Left -is [VariableExpressionAst] -or (
                    $PSItem.Left -is [ConvertExpressionAst] -and
                    $PSItem.Left.Child -is [VariableExpressionAst]))
                }

                if ($assignmentAsts.Count) {
                    $assignmentAsts.Left.ForEach{
                        if ($PSItem -is [VariableExpressionAst]) {
                            $localVariables.Add($PSItem.VariablePath.UserPath)
                            return
                        }

                        $localVariables.Add($PSItem.Child.VariablePath.UserPath)
                    }
                }

                $forEachStatements = Find-Ast -Ast $targetAst -Family { $PSItem -is [ForEachStatementAst] }
                if ($forEachStatements.Count) {
                    $localVariables.AddRange(
                        $forEachStatements.Variable.VariablePath.UserPath -as [string[]])
                }

                return $localVariables
            }
        }

        # Create the function definition expression.
        function NewFunctionDefinition {
            end {
                $function = [System.Text.StringBuilder]::new()
                $null = & {
                    $indent = ' '
                    $function.
                        AppendFormat('function {0} {{', $FunctionName).
                        AppendLine().
                        Append($indent).
                        Append('param(')

                    $paramText = $parameters.Values.ForEach{
                        $parameterType = [Microsoft.PowerShell.ToStringCodeMethods]::Type($PSItem.Item3)

                        $variableName = $PSItem.Item1
                        if ($PSItem.Item4) {
                            $variableName = '{' +
                                [CodeGeneration]::EscapeVariableName($PSItem.Item1) +
                                '}'
                        }

                        # Ensure the parameter type is not too generic and is resolvable.
                        if ($parameterType -ne 'System.Object' -and $parameterType -as [type]) {
                            return '[{0}] ${1}' -f $parameterType, $variableName
                        }

                        return '${0}' -f $variableName
                    }

                    if ($paramText.Count) {
                        $shouldMultiline = $paramText.Count -gt 3
                        $delim = ', '
                        if ($shouldMultiline) {
                            $function.AppendLine().Append($indent + $indent)
                            $delim = ',', [Environment]::NewLine, $indent, $indent -join ''
                        }

                        $function.Append($paramText -join $delim)

                        if ($shouldMultiline) {
                            $function.AppendLine().Append($indent)
                        }
                    }

                    $function.
                        AppendLine(')').
                        Append($indent).
                        AppendLine('end {')

                    $targetWithCorrections = [System.Text.StringBuilder]::new($targetExtent.Text)

                    $targetStartOffset = $targetExtent.StartOffset
                    foreach ($expression in $variableExpressions) {
                        $variableName = $expression.VariablePath.UserPath
                        $asPascalCase = ToPascalCase $variableName
                        $variableOffset = $expression.Extent.StartOffset - $targetStartOffset

                        # Account for escaped variabled names (e.g ${my strange var name})
                        if ($expression.ToString().IndexOf('{') -ne -1) {
                            $variableOffset++
                        }

                        $targetWithCorrections.
                            Remove(
                                $variableOffset,
                                [CodeGeneration]::EscapeVariableName($variableName).Length).
                            Insert(
                                $variableOffset,
                                [CodeGeneration]::EscapeVariableName($asPascalCase))
                    }

                    $targetWithIndent = $targetWithCorrections |
                        NormalizeIndent |
                        AddIndent -Amount 8

                    $function.
                        AppendLine($targetWithIndent).
                        Append($indent).
                        AppendLine('}').
                        Append('}')
                }

                return $function.ToString()
            }
        }

        # Handle exporting the generated function to an external file.
        function ExportFunctionExternalFile {
            param()
            end {
                $currentFolder = [System.IO.Path]::GetDirectoryName(
                    $psEditor.GetEditorContext().CurrentFile.Path)

                # If the file is untitled, use the workspace path instead.
                if ([string]::IsNullOrWhiteSpace($currentFolder)) {
                    $currentFolder = $psEditor.Workspace.Path

                    # If untitled workspace, use current provider path.
                    if ([string]::IsNullOrWhiteSpace($currentFolder)) {
                        $currentFolder = $PSCmdlet.CurrentProviderLocation('FileSystem').Path
                    }
                }

                $path = $PSCmdlet.SessionState.Path
                $targetFile = $path.
                    GetUnresolvedProviderPathFromPSPath(
                        $path.Combine(
                            $currentFolder,
                            $targetFile))

                if (-not [System.IO.Path]::GetExtension($targetFile)) {
                    $targetFile = Join-Path $targetFile "$FunctionName.ps1"
                }

                if (-not (Test-Path $targetFile)) {
                    $directory = Split-Path $targetFile
                    if (-not (Test-Path $directory)) {
                        $null = New-Item $directory -ItemType Directory -Force
                    }

                    $null = New-Item $targetFile -ItemType File
                }

                $targetFile = Resolve-Path $targetFile

                $psEditor.Workspace.OpenFile($targetFile)
                WaitUntil { $psEditor.GetEditorContext().CurrentFile.Path -eq $targetFile }

                $psEditor.GetEditorContext().CurrentFile.InsertText($functionText)
            }
        }

        # Handle exporting the generated function to the line directly above the selection.
        function ExportFunctionInline {
            param()
            end {
                $indentedFunction = $functionText | AddIndent -Amount ($targetExtentIndent - 1)
                $psEditor.GetEditorContext().CurrentFile.InsertText(
                    ($indentedFunction + [Environment]::NewLine + [Environment]::NewLine),
                    $targetExtent.StartLineNumber,
                    1)
            }
        }

        # Handle exporting the generated function to the begin block of the closest ancestor function
        # definition. If there is no ancestor function definition then export to the begin block of
        # the main script AST. This also handles creating a begin block if it doesn't exit, and creating
        # a named end block if there are no named blocks.
        function ExportFunctionBegin {
            param()
            end {
                $findAstSplat = @{
                    Ast          = $targetAst
                    Ancestor     = $true
                    FilterScript = {
                        $PSItem -is [FunctionDefinitionAst] -and
                        $PSItem.Parent -isnot [FunctionMemberAst]
                    }
                }

                # Find the parent function definition from before we removed the target extent
                $targetBlock = Find-Ast @findAstSplat | ForEach-Object Body
                if (-not $targetBlock) {
                    $targetBlock = $psEditor.GetEditorContext().CurrentFile.Ast
                }

                if ($targetBegin = $targetBlock.BeginBlock) {
                    $entryLine         = $targetBegin.Extent.StartLineNumber
                    $beginIndent       = $targetBegin.Extent.StartColumnNumber + 3
                    $fullScriptAsLines = $fullScript -split '\r?\n'
                    $beginLineText     = $fullScriptAsLines[$entryLine - 1]
                    $braceOffset       = $beginLineText.IndexOf(
                        '{',
                        $beginLineText.IndexOf(
                            'begin',
                            [StringComparison]::InvariantCultureIgnoreCase))

                    # If we couldn't find the begin text and brace, they are probably on different lines
                    if ($braceOffset -eq -1) {
                        $entryLine++
                        $braceOffset = $fullScriptAsLines[$entryLine - 1].IndexOf('{')
                    }

                    $entryColumn = $braceOffset + 2

                    $indentedFunctionText = $functionText | AddIndent -Amount $beginIndent
                    $psEditor.GetEditorContext().CurrentFile.InsertText(
                        [Environment]::NewLine + $indentedFunctionText,
                        $entryLine,
                        $entryColumn)
                    return
                }

                if ($targetBlock.EndBlock.Unnamed) {
                    # We have to wrap the unnamed block, so we need to get the updated AST. If the block wasn't
                    # nested then we already have the new one.
                    if ($targetBlock.Parent -is [FunctionDefinitionAst]) {
                        $targetBlock = Find-Ast -First {
                            $PSItem -is [ScriptBlockAst] -and
                            $PSItem.Parent -is [FunctionDefinitionAst] -and
                            $PSItem.Extent.StartOffset -eq $targetBlock.Extent.StartOffset
                        }
                    }

                    CreateEndBlock -Ast $targetBlock.EndBlock
                }

                $beginText = 'begin {',
                             (AddIndent -Source $FunctionText),
                             '}' -join
                             [Environment]::NewLine

                $fullScriptAsLines = $fullScript -split '\r?\n'

                [int] $parentBlockIndent = $fullScriptAsLines[$targetBlock.Extent.StartLine - 1] |
                    Select-String '^\s+' |
                    ForEach-Object { $PSItem.Matches[0].Length }

                $entryLine = $targetBlock.Extent.StartLineNumber
                $entryIsRoot = $true
                if ($targetBlock.UsingStatements.Count) {
                    $entryLine = $targetBlock.UsingStatements[-1].Extent.EndLineNumber
                    $entryIsRoot = $false
                }

                if ($targetBlock.ParamBlock) {
                   $entryLine = $targetBlock.ParamBlock.Extent.EndLineNumber
                   $entryIsRoot = $false
                }

                $beginIndent = $parentBlockIndent + 4
                $parentIsRoot = -not $targetBlock.Parent
                if ($parentIsRoot) {
                    $beginIndent = 0
                }

                if ($parentIsRoot -and $entryIsRoot) {
                    $entryColumn = 1
                    $beginText = $beginText + [Environment]::NewLine
                } else {
                    $entryColumn = $fullScriptAsLines[$entryLine - 1].Length + 1
                    $beginText = [Environment]::NewLine + $beginText
                }

                $beginText = AddIndent $beginText -Amount $beginIndent

                $psEditor.GetEditorContext().CurrentFile.InsertText(
                    $beginText,
                    $entryLine,
                    $entryColumn)
            }
        }
    }
    end {
        $FunctionName = ValidateFunctionName
        $targetExtent = GetTargetExtent

        [int] $targetExtentIndent = [regex]::Match(($targetExtent.Text -replace '\r?\n'), '\S').Index

        $fullScript = $targetExtent.StartScriptPosition.GetFullScript()

        # Add braces to the selection so we can have a single AST to use for analysis.
        $alteredScript = $fullScript.
            Insert($targetExtent.EndOffset, '}').
            Insert($targetExtent.StartOffset, '{')

        $scriptAst = [Parser]::ParseInput(
            $alteredScript,
            $targetExtent.File,
            [ref]$null,
            [ref]$null)

        $targetAst = Find-Ast -Ast $scriptAst -First {
            $PSItem.Extent.StartOffset -eq $targetExtent.StartOffset
        }

        $localVariables = GetLocalVariables -Ast $targetAst

        $variableExpressions = Find-Ast -Ast $targetAst -Family {
            $PSItem -is [VariableExpressionAst] -and
            $PSItem.VariablePath.IsUnscopedVariable -and
            $PSItem.VariablePath.UserPath -notin $localVariables -and
            -not [SpecialVariables]::IsSpecialVariable($PSItem)
        }

        $parameters = GetInferredParameters $variableExpressions
        $destination = ValidateDestination
        if ($destination -eq 'ExternalFile') {
            [string] $targetFile = ValidateDestinationFile
        }

        $functionText = NewFunctionDefinition

        $invocation = [System.Text.StringBuilder]::new($FunctionName)
        foreach ($parameter in $parameters.Values) {
            $variableName = $parameter.Item2
            if ($parameter.Item4) {
                $variableName = '{' + [CodeGeneration]::EscapeVariableName($parameter.Item2) + '}'
            }

            $null = $invocation.AppendFormat(' -{0} ${1}', $parameter.Item1, $variableName)
        }

        $invocation = $invocation | AddIndent -Amount $targetExtentIndent

        $targetExtent | Set-ScriptExtent -Text $invocation

        switch ($destination) {
            Inline       { ExportFunctionInline }
            ExternalFile { ExportFunctionExternalFile }
            default      { ExportFunctionBegin }
        }
    }
}