Transpilers/Core/Pipescript.psx.ps1

<#
.Synopsis
    The Core PipeScript Transpiler
.Description
    The Core PipeScript Transpiler.

    This will convert various portions in the PowerShell Abstract Syntax Tree from their PipeScript syntax into regular PowerShell.
    
    It will run other converters as directed by the source code.
.Link
    .>Pipescript.Function
.Link
    .>Pipescript.AttributedExpression
.EXAMPLE
    {
        function [explicit]ExplicitOutput {
            "whoops"
            return 1
        }
    } | .>PipeScript
.Example
    {
        [minify]{
            # Requires PSMinifier (this comment will be minified away)
            "blah"
                "de"
                    "blah"
        }
    } | .>PipeScript
.Example
    .\PipeScript.psx.ps1 -ScriptBlock {
        [bash]{param([string]$Message) $message}
    }
.Example
    .\PipeScript.psx.ps1 -ScriptBlock {
        [explicit]{1;2;3;echo 4}
    }
#>

<#
.EXAMPLE
    {
        function [ProxyCommand<'Get-Process'>]GetProcessProxy {}
    } | .>PipeScript
#>

param(
# A ScriptBlock that will be transpiled.
[Parameter(Mandatory,ParameterSetName='ScriptBlock',ValueFromPipeline,Position=0)]
[ScriptBlock]
$ScriptBlock,

# One or more transpilation expressions that apply to the script block.
[Parameter(ParameterSetName='ScriptBlock')]
[string[]]
$Transpiler
)

begin {
    # First off, we declare a few inner functions that we will use within only this script.
    $myCmd = $MyInvocation.MyCommand
}

process {
    # First, make a copy of our input parameters.
    $invocationParams = [Ordered]@{} + $PSBoundParameters

    # Then, check on which parameter set is being used
    switch ($PSCmdlet.ParameterSetName) {
        #region Transpile ScriptBlocks
        ScriptBlock {
            # Walk thru any provided transpilers
            $TranspilerAttributes = @()
            $RemainingTranspilers = @()
            $scriptText = "$scriptBlock"
            foreach ($transpilerExpression in $invocationParams['transpiler']) {
                # If the transpiled expression doesn't use variables (except for true or false),
                if ($transpiledExpression -notmatch '(?>\@|\$(?!(?>true|false)))') {
                    # we will make the expression into a psuedo-attribute.
                    $TranspilerAttributes += $transpilerExpression -replace 
                        '\<', '(' -replace '\>', ')' -replace # To do this, replace <> with ()
                        '^([^\[])', '[$1' -replace '([^\]])$', '$1]' -replace # then ensure the attribute begins and ends with brackets
                        '(?<=[^\)])\]$', '()]' # and then ensure that it has a set of empty parenthesis at the end.
                } else {
                    $RemainingTranspilers += $transpilerExpression # Keep track of any other transpilers (for future use).
                }
            }
            
            # If the ScriptBlock had attributes, we'll add them to a special list of things that will be transpiled first.
            if ($ScriptBlock.Ast.ParamBlock.Attributes) {
                # To determine what we transpile first,
                $moreToPreTranspile =
                    @(
                        # check each ScriptBlock attribute
                        foreach ($attrAst in $ScriptBlock.Ast.ParamBlock.Attributes) {
                            # and see if it is a real type.
                            $attrRealType = 
                                if ($attrAst.TypeName.GetReflectionType) {
                                    $attrAst.TypeName.GetReflectionType()
                                } elseif ($attrAst.TypeName.ToString) {
                                    $attrAst.TypeName.ToString() -as [type]
                                }
                            
                            # If it is not a real type,
                            if (-not $attrRealType) {
                                $attrAst # we will transpile it with the whole script block as input.
                            }
                        }
                    )
                
                # Now, we strip away any Attribute Based Composition.
                $replacements = [Ordered]@{}
                $myOffset     = 0
                # To do this effeciently, we collect all of the attributes left to pretranspile
                foreach ($moreTo in $moreToPreTranspile) {                                        
                    $TranspilerAttributes += $moreTo.Extent.ToString()
                    $start = $scriptText.IndexOf($moreTo.extent.text, $myOffset)
                    $end   = $start + $moreTo.Extent.Text.Length
                    $replacements["$start,$end"] = '' # and replace each of them with a blank.
                }

                # get the updated script,
                $UpdatedScript = Update-PipeScript -ScriptReplacement $replacements -ScriptBlock $ScriptBlock
                $scriptBlock = $UpdatedScript # and replace $ScriptBlock.
            }


            # Now we set the to the contents of the scriptblock.
            $scriptText = "$scriptBlock"

            # If there was no ScriptBlock, return
            if (-not $ScriptBlock) { return }

            $PreTranspile =
                if ($transpilerAttributes) {
                    [ScriptBlock]::Create(
                        ($TranspilerAttributes -join [Environment]::NewLine) + $(
                            if (-not $ScriptBlock.Ast.ParamBlock) {
                                "param()" + [Environment]::NewLine
                            }
                        ) + $ScriptBlock
                    )
                }

            # If there were any attributes that can be pre-transpiled, convert them.
            if ($PreTranspile.Ast.ParamBlock.Attributes) {
                # Get the list of transpilation attributes
                $attrList = @($PreTranspile.Ast.ParamBlock.Attributes)
                $currentInput = $ScriptBlock  # and set the current input to this script block.
                # Then walk backwards thru the attribute list
                for ($attrIndex = $attrList.Length - 1; $attrIndex -ge 0; $attrIndex--) {
                    # and invoke the transpiler by name
                    $currentOutput = $currentInput | Invoke-PipeScript -AttributeSyntaxTree $attrList[$attrIndex]
                    if ($currentOutput -and $currentOutput -ne $attrList[$attrIndex]) {
                        $currentInput  = $currentOutput
                    }                    
                }

                
                # If the current output is a [ScriptBlock]
                if ($currentOutput -is [scriptblock]) {
                    # reset the script block.
                    $ScriptBlock = $currentOutput
                } elseif ( # Otherwise, if we can convert the string to a ScriptBlock without error
                    $currentOutput -is [string] -and $(
                    $CurrentOutputAsScriptBlock = try { [scriptblock]::Create($currentOutput)} catch { $null }
                    $CurrentOutputAsScriptBlock
                )) {
                    # reset the script block.
                    $ScriptBlock = $CurrentOutputAsScriptBlock
                }
            }
            
            # Find all AST elements within the script block.
            $astList = @($scriptBlock.Ast.FindAll({$true}, $false))
            # Prepare to replace code by stringifying the -ScriptBlcok,
            $scriptText = "$scriptBlock"
            # creating an ordered dictionary of replacements,
            $replacements = [Ordered]@{}
            $AstReplacements = [Ordered]@{}
            # and a list of Update-PipeScript splats
            $updateSplats = @()
            
            # At various points within transpilation, we will be skipping processing until a known end pointer. For now, set this to null.
            $skipUntil  = 0
            # Keep track of the offset from a starting position as well, for the same reason.
            $myOffset   = 0

            # Walk over each item in the abstract syntax tree.
            :NextAstItem foreach ($item in $astList) {
                # If skipUntil was set,
                if ($skipUntil) {
                    # find the space between now and the last known offset.
                    try {
                        $newOffset = $scriptText.IndexOf($item.Extent.Text, $myOffset)
                        if ($newOffset -eq -1) { continue }
                        $myOffset  = $newOffset
                    } catch {
                        $ex =$_
                        $null = $null
                    }
                    if ($myOffset -lt $skipUntil) { # If this is before our skipUntil point
                        continue # ignore this AST element.
                    }
                    $skipUntil = $null # If we have reached our skipUntil point, let's stop skipping.
                }
                # otherwise, find if any pipescripts match this AST

                # We can cache transpilers by type
                if (-not $script:TranspilerAstTypes) {
                    $script:TranspilerAstTypes = @{}
                }

                $itemTypeName = $item.GetType().Fullname
                if (-not $script:TranspilerAstTypes[$itemTypeName]) {
                    $script:TranspilerAstTypes[$itemTypeName] = 
                        foreach ($couldPipe in Get-Transpiler -CouldPipe $item) {
                            if ($couldPipe.ExtensionCommand.CouldRun(@{} + $couldPipe.ExtensionParameter)) {
                                $couldPipe
                            }
                        }
                }

                # But we still need to determine if an extension is valid for a given type's context
                $pipescripts =  foreach ($potentialPipeScript in $script:TranspilerAstTypes[$itemTypeName]) {                    
                        if (
                        $potentialPipeScript.ExtensionCommand.CouldRun(@{} + $potentialPipeScript.ExtensionParameter) -and
                        $(
                            $eap = $ErrorActionPreference
                            $ErrorActionPreference = 'ignore'
                            $potentialPipeScript.ExtensionCommand.Validate($item, $true)
                            $ErrorActionPreference = $eap
                        )
                        ) {
                            $potentialPipeScript
                        }
                    }

                # If we found any matching pipescripts
                if ($pipescripts) {
                    # try to run each one.
                    :NextPipeScript foreach ($ps in $pipescripts) {
                        # If it had output
                        $pipeScriptOutput = Invoke-PipeScript -InputObject $item -CommandInfo $ps.ExtensionCommand
                        # Walk over each potential output
                        foreach ($pso in $pipeScriptOutput) {
                            # If the output was a dictionary, treat it as a series of replacements.
                            if ($pso -is [Collections.IDictionary]) {                                
                                $psoCopy = [Ordered]@{} + $pso
                                foreach ($kv in @($psoCopy.GetEnumerator())) {
                                    # If the key was an AST element
                                    if ($kv.Key -is [Management.Automation.Language.Ast]) {
                                        # replace the element with it's value
                                        $astReplacements[$kv.Key] = $kv.Value
                                        $psoCopy.Remove($kv.Key)
                                    } elseif ($kv.Key -match '^\d+,\d+$') {
                                        # otherwise, it the key was a pair of digits, replace that span.
                                        $Replacements["$($kv.Key)"] = $kv.Value
                                        $psoCopy.Remove($kv.Key)
                                    }
                                }
                                if ($psoCopy.Count) {
                                    $updateSplats += $psoCopy
                                }                            
                            }
                            # If we have ouput from any of the scripts (and we have not yet replaced anything)
                            elseif ($pso -and -not $AstReplacements[$item]) 
                            { 
                                # determine the end of this AST element
                                $start = $scriptText.IndexOf($item.Extent.Text, $myOffset) 
                                $end   = $start + $item.Extent.Text.Length
                                $skipUntil = $end # set SkipUntil
                                $AstReplacements[$item] = $pso # and store the replacement.

                                #region Special Properties
                                # Because PowerShell can attach properties to any object,
                                # we can use the presence of attached properties to change context around the replacement.

                                # .SkipUntil or .IgnoreUntil can specify a new index or AST end point
                                foreach ($toSkipAlias in 'SkipUntil', 'IgnoreUntil') {                                    
                                    foreach ($toSkipUntil in $pso.$toSkipAlias) {
                                        if ($toSkipUntil -is [int] -and $toSkipUntil -gt $end) {
                                            $skipUntil = $toSkipUntil
                                        } elseif ($toSkipUntil -is [Management.Automation.Language.Ast]) {
                                            $newSkipStart = $scriptText.IndexOf($toSkipUntil.Extent.Text, $myOffset)
                                            if ($newSkipStart -ne -1) {
                                                $end   = $newSkipStart + $toSkipUntil.Extent.Text.Length
                                                if ($end -gt $skipUntil) {
                                                    $skipUntil = $end
                                                }
                                                if ($toSkipUntil -ne $item) {
                                                    $AstReplacements[$toSkipUntil] = ''
                                                }
                                            }
                                        }
                                    }
                                }

                                #.ToRemove,.RemoveAST, or .RemoveElement will remove AST elements or ranges
                                foreach ($toRemoveAlias in 'ToRemove','RemoveAST','RemoveElement') {
                                    foreach ($toRemove in $pso.$toRemoveAlias) {
                                        if ($toRemove -is [Management.Automation.Language.Ast]) {
                                            $AstReplacements[$toRemove] = ''
                                        } elseif ($toRemove -match '^\d+,\d+$') {
                                            $Replacements[$toRemove] = ''
                                        }
                                    }
                                }

                                #.ToReplace,.ReplaceAST or .ReplaceElement will replace elements or ranges.
                                foreach ($toReplaceAlias in 'ToReplace','ReplaceAST','ReplaceElement') {
                                    foreach ($toReplace in $pso.$toReplaceAlias) {
                                        if ($toReplace -isnot [Collections.IDictionary]) {
                                            continue
                                        }
                                        foreach ($tr in $toReplace.GetEnumerator()) {
                                            if ($tr.Key -is [Management.Automaton.Language.Ast]) {
                                                $AstReplacements[$tr.Key] = $tr.Value
                                            } elseif ($tr.Key -match '^\d+,\d+$') {
                                                $textReplacements["$($tr.Key)"] = $tr.Value
                                            }
                                        }
                                    }
                                }
                                #endregion Special Properties
                            }
                        }
                        # If the transpiler had output, do not process any more transpilers.
                        if ($pipeScriptOutput) { break }
                    }                
                } 
            }
            
            $newScript =            
                if ($AstReplacements.Count) {
                    Update-PipeScript -ScriptBlock $ScriptBlock -ScriptReplacement $replacements -AstReplacement $AstReplacements
                } elseif ($updateSplats) {
                    foreach ($upSplat in $updateSplats) {
                        $newScript = Update-PipeScript -ScriptBlock $ScriptBlock @upSplat
                        if ($newScript -is [ScriptBlock]) {
                            $scriptBlock = $newScript
                        }
                    }
                    $scriptBlock
                } else {
                    $scriptBlock
                }
            
            $transpiledScriptBlock =
                [ScriptBlock]::Create($newScript)
            
            $transpiledScriptBlock
            # output the new script.
        }
        #endregion Transpile ScriptBlocks
    }
}