Compress-ScriptBlock.ps1

#requires -Version 3.0
function Compress-ScriptBlock
{
    <#
    .Synopsis
        Compresss a script block
    .Description
        Compresss a script block into a minified version of itself.
 
        Minified scripts remove documentation and minimize the spacing between statements.
 
        This makes scripts significantly smaller and less readable, and also makes them more embeddable.
 
        Additionally, Compress-ScriptBlock can -GZip the content to further compress the output.
        If -NoBlock is passed, the minified and compressed output will be returned in a single line.
 
        ScriptBlocks can be given a -Name, which will declare them as a variable.
        This will happen automatically when piping in a command.
        This can be avoided by passing -Anonymous.
    .Example
        $compressedSelf = Get-Command Compress-ScriptBlock | Compress-ScriptBlock
    .Example
        Get-Module PSMinifier | # Get the minifier module's
            Split-Path | # root path
            Join-Path -ChildPath Compress-ScriptBlock.ps1 | # join it with Compress-ScriptBlock.ps1
            Get-Command | # get the command
            Compress-ScriptBlock -Anonymous -GZip -DotSource | # Compress it, anonymized, and dot-sourced
            Set-Content -Path ( # And put it back into
                Get-Module PSMinifier |
                    Split-Path |
                    Join-Path -ChildPath Compress-ScriptBlock.min.gzip.ps1 # Compress-ScriptBlock.min.gzip.ps1
            )
    .Example
        Get-Module PSMinifier | # Get the minifier module's
            Split-Path | # root path
            Join-Path -ChildPath Compress-ScriptBlock.ps1 | # join it with Compress-ScriptBlock.ps1
            Get-Command | # get the command
            Compress-ScriptBlock -Anonymous | # Compress it, anonymized
            Set-Content -Path ( # And put it back into
                Get-Module PSMinifier |
                    Split-Path |
                    Join-Path -ChildPath Compress-ScriptBlock.min.ps1 # Compress-ScriptBlock.min.gzip.ps1
            )
    #>

    [OutputType([string])]
    param(
    # The ScriptBlock that will be compressed.
    [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName)]
    [ScriptBlock]
    $ScriptBlock,

    # If provided, will assign the script block to a named variable.
    [Parameter(ValueFromPipelineByPropertyName)]
    [string]
    $Name,

    # If set, will ignore any provided name.
    [Parameter(ValueFromPipelineByPropertyName)]
    [switch]
    $Anonymous,

    # If set, the minified content will be encoded as GZip, further reducing it's size.
    [Parameter(ValueFromPipelineByPropertyName)]
    [Alias('Zip', 'Compress')]
    [switch]
    $GZip,

    # If set, will dot source the compressed content.
    [Parameter(ValueFromPipelineByPropertyName)]
    [Alias('Dot', '.')]
    [switch]
    $DotSource,

    # If set, zipped minified content will be encoded without blocks, making it a very long single line.
    # This parameter is only valid with -GZip.
    [Parameter(ValueFromPipelineByPropertyName)]
    [Alias('NoBlocks')]
    [switch]
    $NoBlock
    )

    begin {
        # First, we declare a number of quick variables to access AST types.
        foreach ($_ in 'BinaryExpression','Expression','ScriptBlockExpression','ParenExpression','ArrayExpression',
            'SubExpression', 'Command','CommandExpression', 'IfStatement', 'LoopStatement',
            'FunctionDefinition','AssignmentStatement','Pipeline','Statement','TryStatement','CommandExpression') {
            $ExecutionContext.SessionState.PSVariable.Set($_, "Management.Automation.Language.${_}Ast" -as [Type])
        }

        # Next, we declare a bunch of ScriptBlocks that handle different scenarios for compressing the AST.
        # These will recursively call each other as needed.

        $CompressScriptBlockAst = { # The topmost compresses a given Script Block's AST.
            param($ast)

            if ($ast.Body) { $ast = $ast.Body } # If the AST had an inner body AST, use that instead

            $dps = $ast.DynamicParamBlock.Statements
            $bs = $ast.BeginBlock.Statements
            $ps = $ast.ProcessBlock.Statements
            $es = $ast.EndBlock.Statements

            @(if ($ast.ParamBlock) { # Walk thru the param block.
                $pb = $ast.ParamBlock
                foreach ($a in $pb.Attributes) {
                    $a # Declaration attributes are emitted unaltered
                }
                'param('
                    @(foreach ($p in $pb.Parameters) {
                        @(foreach ($a in $p.Attributes) {
                            $a # Parameter attributes are emitted unaltered
                        }
                        $p.Name) -join '' # then we emit the name and all attributes in one statement
                    }) -join ','
                ')'
            }
            # then, for dynamicParam, begin, process, and end, redeclare the blocks and minify the statements.
            if ($dps) {
                'dynamicParam{'
                @($dps | & $CompressStatement) -join ';'
                '}'
            }
            if ($bs) {
                'begin{'
                @($bs | & $CompressStatement) -join ';'
                '}'
            }
            if ($ps) {
                'process{'
                @($ps | & $CompressStatement) -join ';'
                '}'
            }
            if ($es) {
                if ($bs -or $ps) { 'end{'}
                @($es | & $CompressStatement) -join ';'
                if ($bs -or $ps) { '}'}
            }) -join ''
        }


        $CompressStatement = { # Compressing statements is the tricky part.
            param(
            [Parameter(ValueFromPipeline=$true)]
            [Management.Automation.Language.StatementAst]$s)
            process {
                if ($s -is $IfStatement) { # If it's an if statement
                    $nc = 0
                    @(foreach ($c in $s.Clauses) { # minify each clause
                        if( -not $nc){
                            'if'
                        } else {
                            'elseif'
                        }
                        '(' # by compressing the condition pipeline
                            @($c.Item1.PipelineElements | & $CompressPipeline) -join '|'
                        ')'
                        '{' # and compressing the inner statements
                        @($c.Item2.Statements | & $CompressStatement) -join ';'
                        $nc++
                        '}'
                    }
                    if ($s.ElseClause) {
                        'else{'
                            @($s.ElseClause.Statements | & $CompressStatement) -join ';'
                        '}'
                    }
                    ) -join ''
                }
                elseif ($s -is $LoopStatement) { # If it's a loop
                    $loopType = $s.GetType().Name.Replace('StatementAst','') # determine it's type
                    @(
                    if ($s.Label) { # add the loop label if it exists
                        ":$($s.Label) "
                    }
                    if ($loopType -eq 'foreach') { # and recreate each loop condition.
                        'foreach('
                        $s.Variable
                        ' in '
                        & $compressPart $s.Condition
                        ')'
                    } elseif ($loopType -eq 'for') {
                        'for('
                        $s.Initializer
                        ';'
                        $s.Condition
                        ';'
                        $s.Iterator
                        ')'
                    } elseif ($loopType -eq 'while') {
                        'while('
                        $s.Condition
                        ')'
                    } elseif ($loopType -eq 'dowhile') {
                        'do'
                    }

                    '{'
                    @($s.Body.Statements | & $CompressStatement) -join ';'
                    '}'
                    if ($loopType -eq 'dowhile') {
                        'while('
                        $s.Condition
                        ')'
                    }
                    ) -join ''
                }
                elseif ($s -is $AssignmentStatement) { # If it's an assignment,
                    $as = $s
                    @(
                    $as.Left.ToString().Trim()
                    $as.ErrorPosition.Text
                    if ($as.Right -is [Management.Automation.Language.StatementAst]) {
                        @($as.right | & $CompressStatement) -join ';' # compress the right side
                    }) -join ''
                }
                elseif ($s -is $Pipeline) { # If it's a pipeline
                    @($s.PipelineElements | & $CompressPipeline) -join '|' # minify the pipeline and join by |
                }
                elseif ($s -is $TryStatement) { # If it's a type/catch
                    @(
                    'try{'
                        @($s.Body.statements | & $CompressStatement) -join ';' # minify the try
                    '}'
                    foreach ($cc in $s.CatchClauses) { # then each of the catches
                        'catch'
                        if ($cc.CatchTypes) {
                            foreach ($ct in $cc.CatchTypes) {
                                if ($ct.FullName.StartsWith('System.')) {
                                    '['
                                    $ct.FullName.Substring('System.'.Length)
                                    ']'
                                } else {
                                    '['
                                    $ct.FullName
                                    ']'
                                }
                            }
                        }
                        '{'
                            @($cc.Body.statements | & $CompressStatement) -join ';'
                        '}'
                    }) -join ''
                    if ($s.Finally) { # then the finally (if it exists)
                        'finally{'
                            $($s.Finally.statements | & $CompressStatement) -join ';'
                        '}'
                    }
                }
                elseif ($s -is $CommandExpression) { # If it's a command expression
                    if ($s.Expression) {
                        $s.Expression | & $CompressExpression # minify the expression
                    } else {
                        $s.ToString()
                    }
                }
                elseif ($s -is $FunctionDefinition) { # If it's a function
                    $(if ($s.IsWorklow) { "workflow " }
                    elseif ($s.IsFilter) { "filter " }
                    else { "function " }) + $s.Name + "{$(& $CompressScriptBlockAst $s.Body)}" # redeclare it with a minified body.
                }
                else {
                    $s.ToString()
                }
            }
        }

                $CompressPipeline = { # If we're compressing a pipeline
            param(
            [Parameter(ValueFromPipeline=$true)]
            [Management.Automation.Language.CommandBaseAst]$p)

            process {
                if ($p -is $CommandExpression) {
                    & $CompressExpression $p.Expression # compress each expression
                } elseif ($p -is $Command) {
                    @(
                    if ($p.InvocationOperator -eq 'Ampersand') {
                        '&'
                    } elseif ($p.InvocationOperator -eq 'Dot') {
                        '.'
                    }
                    foreach ($e in $p.CommandElements) {
                        if ($e.ScriptBlock) {
                            "{$(& $CompressScriptBlockAst $e.ScriptBlock)}" # and compress any nested script blocks
                        } else { $e }
                    }) -join ' '
                } else {
                    $p.ToString()
                }
            }
        }

        $CompressExpression = { # If we're compressing an expression,
            param(
            [Parameter(ValueFromPipeline=$true,Position=0)]
            [Management.Automation.Language.ExpressionAst]$e)
            process {
                @(
                if ($e -is $BinaryExpression) # and it's a binary expression
                {
                    if ($e.Left -is $Expression) { # compress the left
                        & $CompressExpression $e.Left
                    } else {
                        $e.Left
                    }
                    $e.ErrorPosition
                    if ($e.Right -is $Expression) { # and the right.
                        & $CompressExpression $e.Right
                    } else {
                        $e.Right
                    }
                }
                elseif ($e -is $ScriptBlockExpression) # If it was a script expression
                {
                    '{'
                    & $CompressScriptBlockAst $e.ScriptBlock # minify the script.
                    '}'
                }
                elseif ($e -is $ParenExpression) { # If it was a paren expresssion, arrayexpression, or subexpression
                    '('
                    . $compressPart $e # we have to minify each part of the expression.
                    ')'
                }
                elseif ($e -is $ArrayExpression) {
                    '@('
                    . $compressPart $e
                    ')'
                }
                elseif ($e -is $SubExpression) {
                    '$('
                    . $compressPart $e
                    ')'
                }
                elseif ($e.Elements) {
                    @(foreach ($_ in $e.Elements) {
                        . $CompressPart $_
                    }) -join ','
                }
                else {
                    $e.ToString()
                }) -join ''

            }
        }


        $CompressPart = { # If we're minifying pars of an expression
            param([Parameter(ValueFromPipeline=$true,Position=0)]$p)
            process {
                if ($p.SubExpression) { @($p.Subexpression.Statements | & $CompressStatement) -join ';' } # join minified subexpression statements by ;,
                elseif ($p.Pipeline) { @($p.Pipeline.PipelineElements | & $CompressPipeline) -join '|' } # pipeline elements by |,
                elseif ($p -is $FunctionDefinition) { # redeclare any functions, minified
                    $(if ($p.IsWorklow) { "workflow " }
                    elseif ($p.IsFilter) { "filter " }
                    else { "function " }) + $p.Name + "{$(& $CompressScriptBlockAst $p.Body)}"
                }
                elseif ($p.ScriptBlock) { "{$(& $CompressScriptBlockAst $p.ScriptBlock)}" } # minify any script blocks
                elseif ($p -is $Pipeline) {
                    @($p.PipelineElements | & $CompressPipeline) -join '|'
                }
                else { $p } # any emit anything we don't know about.
            }
        }
    }

    process {
        # Now, call our minifier with this script block's AST
        $compressedScriptBlock = & $CompressScriptBlockAst $ScriptBlock.Ast

        $compressedScriptBlock = # After that, resassign $CompressedScriptBlock as needed
            if (-not $GZip) {
                $compressedScriptBlock
            }
            else { # If we're GZIPing,
                $data = [Text.Encoding]::Unicode.GetBytes($compressedScriptBlock) # compress the content
                $ms = [IO.MemoryStream]::new()
                $cs = [IO.Compression.GZipStream]::new($ms, [Io.Compression.CompressionMode]"Compress")
                $cs.Write($Data, 0, $Data.Length)
                $cs.Close()
                $cs.Dispose()

                if ($NoBlock) { # If we're using -NoBlocks, emit it as a single line
                    "`$([ScriptBlock]::Create(([IO.StreamReader]::new(([IO.Compression.GZipStream]::new([IO.MemoryStream]::new([Convert]::FromBase64String('$([Convert]::ToBase64String($ms.ToArray()))')),[IO.Compression.CompressionMode]'Decompress')),[Text.Encoding]::unicode)).ReadToEnd()))"
                }
                else
                {
                    # Otherwise, add _some_ whitespace.
                    "`$([ScriptBlock]::Create(([IO.StreamReader]::new((
    [IO.Compression.GZipStream]::new([IO.MemoryStream]::new(
        [Convert]::FromBase64String('
$([Convert]::ToBase64String($ms.ToArray(), 'InsertLineBreaks'))
        ')),
        [IO.Compression.CompressionMode]'Decompress')),
    [Text.Encoding]::unicode)).ReadToEnd()
))"

                }

                $ms.Close()
                $ms.Dispose()
            }

        if ($DotSource) { # If we're dot sourcing,

            $compressedScriptBlock = # reassign $compressedScriptBlock again
                if ($GZip) {
                    ". $compressedScriptBlock" # if we're GZipping, fine (since the return value of this will be a script block)
                } else {
                     ". {$compressedScriptBlock}" # otherwise, wrap it in {}s.
                }
        }
        if ($Name -and -not $Anonymous) { # If we've provided a -Name and don't want to be -Anonymous, we're assigning to a variable.
            if (-not $GZip -and -not $DotSource) {  # If it's not GZipped or dotted,
                $compressedScriptBlock = "{$compressedScriptBlock}" # we need to wrap it in {}s.
            }
            if ($Name -match '\W') { # If the name contained non-word characters,
                "`${$name} = $compressedScriptBlock" # we need to wrap it in {}s.
            } else {
                "`$$name = $compressedScriptBlock" # otherwise, it's just $name = $compressedScriptBlock
            }
        } else {
            $compressedScriptBlock
        }
    }
}