PSScriptMinifier.psm1
#Requires -Version 7 using namespace System.Management.Automation.Language class MinifyVisitor : AstVisitor2 { [System.Text.StringBuilder]$sb MinifyVisitor() { $this.sb = [System.Text.StringBuilder]::new() } static [string] GetMinified([ScriptBlockAst]$ast) { $visitor = [MinifyVisitor]::new() $ast.Visit($visitor) return $visitor.sb.ToString().TrimEnd() } hidden [string] GetPrevChar() { return $this.sb.ToString()?[-1] } hidden [void] Append([string]$text) { if ($this.sb.Length -gt 0) { if ($this.GetPrevChar() -match '[%*?]|\w' -and $text -match '^@|-?\w+') { $this.sb.Append(' ') | Out-Null } } $this.sb.Append($text) | Out-Null } hidden [void] RemoveTrailingSemi() { if($this.GetPrevChar() -eq ';'){$this.sb.Remove($this.sb.Length - 1, 1)} } hidden [void] AppendSemi() { if($this.GetPrevChar() -ne ';'){$this.Append(';')} } hidden [void] AppendSemiIf([bool]$cond) { if($cond){$this.AppendSemi()} } [AstVisitAction] VisitCommand([CommandAst]$node) { switch ($node.InvocationOperator) { 'Dot' { $this.Append('.') } 'Ampersand' { $this.Append('&') } } return [AstVisitAction]::Continue } [AstVisitAction] VisitStatementBlock([StatementBlockAst]$node) { $stmts = $node.Statements for ($i = 0; $i -lt $stmts.Count; $i++) { $stmts[$i].Visit($this) $this.AppendSemiIf($i -lt $stmts.Count - 1) } return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitFunctionDefinition([FunctionDefinitionAst]$node) { $kwd = if ($node.IsFilter) { 'filter ' } elseif ($node.IsWorkflow) { 'workflow ' } else { 'function ' } $this.Append($kwd) $this.Append($node.Name) $params = $node.Parameters $n = $params.Count if ($n) { $this.Append('(') for ($i = 0; $i -lt $n; $i++) { $params[$i].Visit($this) if ($i -lt $n - 1) { $this.Append(',') } } $this.RemoveTrailingSemi() $this.Append(')') } $this.Append('{') ($node.Body)?.Visit($this) $this.RemoveTrailingSemi() $this.Append('}') return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitScriptBlockExpression([ScriptBlockExpressionAst]$node) { $this.Append('{') ($node.ScriptBlock)?.Visit($this) $this.RemoveTrailingSemi() $this.Append('}') return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitScriptBlock([ScriptBlockAst]$node) { $node.UsingStatements | % { $_.Visit($this) } $node.Attributes | % { $_.Visit($this) } ($node.ParamBlock)?.Visit($this) ($node.DynamicParamBlock)?.Visit($this) ($node.BeginBlock)?.Visit($this) ($node.ProcessBlock)?.Visit($this) ($node.EndBlock)?.Visit($this) ($node.CleanBlock)?.Visit($this) return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitNamedBlock([NamedBlockAst]$node) { if (-not $node.Unnamed) { $this.Append($node.BlockKind.ToString().ToLower()) $this.Append('{') } $stmts = $node.Statements $n = $stmts.Count if ($n) { for ($i = 0; $i -lt $n; $i++) { $stmt = $stmts[$i] ($stmt)?.Visit($this) $this.AppendSemiIf($i -lt $n - 1) } } if (-not $node.Unnamed) { $this.RemoveTrailingSemi() $this.Append('}') } $node.Traps | % { ($_)?.Visit($this) } return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitCommandParameter([CommandParameterAst]$node) { $this.Append("-$($node.ParameterName)") ($node.Argument)?.Visit($this) return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitPipeline([PipelineAst]$node) { $elts = $node.PipelineElements $n = $elts.Count for ($i = 0; $i -lt $n; $i++) { $elt = $elts[$i] $elt.Visit($this) if ($i -lt $n - 1) { $this.Append('|') } } return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitAttributedExpression([AttributedExpressionAst] $node) { $attr = $node.Attribute.Extent.Text.Replace("`n", '') $this.Append($attr) $node.Child.Visit($this) return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitCommandExpression([CommandExpressionAst] $node) { ($node.Expression)?.Visit($this) foreach ($redir in $node.Redirections) { $redir.Visit($this) } return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitIfStatement([IfStatementAst]$node) { $clauses = $node.Clauses $n = $clauses.Count for ($i = 0; $i -lt $n; $i++) { if ($i -eq 0) { $this.Append('if(') } else { $this.Append('elseif(') } ($clauses[$i].Item1)?.Visit($this) $this.RemoveTrailingSemi() $this.Append(')') $this.Append('{') ($clauses[$i].Item2)?.Visit($this) $this.RemoveTrailingSemi() $this.Append('}') } if ($node.ElseClause) { $this.Append('else{') ($node.ElseClause)?.Visit($this) $this.RemoveTrailingSemi() $this.Append('}') } $this.AppendSemi() return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitAssignmentStatement([AssignmentStatementAst]$node) { ($node.Left)?.Visit($this) $opStart = $node.Left.Extent.EndOffset $opLen = $node.Right.Extent.StartOffset - $opStart $opRelOffset = $opStart - $node.Extent.StartOffset $this.Append($node.Extent.Text.Substring($opRelOffset, $opLen).Trim()) ($node.Right)?.Visit($this) $this.AppendSemi() return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitForStatement([ForStatementAst]$node) { if ($node.Label) { $this.sb.Append(':{0} ' -f $node.Label) } $this.Append('for(') if ($node.Initializer) { ($node.Initializer)?.Visit($this) $this.AppendSemi() } if ($node.Condition) { ($node.Condition)?.Visit($this) $this.AppendSemi() } ($node.Iterator)?.Visit($this) $this.RemoveTrailingSemi() $this.Append(')') $this.Append('{') ($node.Body)?.Visit($this) $this.RemoveTrailingSemi() $this.Append('};') return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitForEachStatement([ForEachStatementAst]$node) { if ($node.Label) { $this.sb.Append(':{0} ' -f $node.Label) } $this.Append('foreach(') ($node.Variable)?.Visit($this) $this.Append('in') ($node.Condition)?.Visit($this) if ($node.ThrottleLimit) { $this.Append('-ThrottleLimit') ($node.ThrottleLimit)?.Visit($this) } $this.RemoveTrailingSemi() $this.Append('){') ($node.Body)?.Visit($this) $this.RemoveTrailingSemi() $this.Append('};') return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitDoUntilStatement([DoUntilStatementAst]$node) { if ($node.Label) { $this.sb.Append(':{0} ' -f $node.Label) } $this.Append('do{') ($node.Body)?.Visit($this) $this.RemoveTrailingSemi() $this.Append('}until(') ($node.Condition)?.Visit($this) $this.RemoveTrailingSemi() $this.Append(');') return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitDoWhileStatement([DoWhileStatementAst]$node) { if ($node.Label) { $this.sb.Append(':{0} ' -f $node.Label) } $this.Append('do{') ($node.Body)?.Visit($this) $this.RemoveTrailingSemi() $this.Append('}while(') ($node.Condition)?.Visit($this) $this.RemoveTrailingSemi() $this.Append(');') return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitSubExpression([SubExpressionAst]$node) { $this.Append('$(') ($node.SubExpression)?.Visit($this) $this.RemoveTrailingSemi() $this.Append(')') return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitTryStatement([TryStatementAst] $node) { if ($node.Body) { $node.Body.Visit($this) } foreach ($catch in $node.Catches) { $catch.Visit($this) } if ($node.Finally) { $node.Finally.Visit($this) } $this.AppendSemi() return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitCatchClause([CatchClauseAst] $node) { $this.Append('catch ') if ($node.ErrorType) { $node.ErrorType.Visit($this) } if ($node.Variable) { $this.Append(" `$$($node.Variable)") } if ($node.Body) { $node.Body.Visit($this) } return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitThrowStatement([ThrowStatementAst]$node) { $this.Append('throw ') ($node.Pipeline)?.Visit($this) ($node.Exception)?.Visit($this) $this.AppendSemi() return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitSwitchStatement([SwitchStatementAst]$node) { $this.Append('switch(') $node.Condition.Visit($this) $this.RemoveTrailingSemi() $this.Append('){') $node.Clauses | % { $_.Item1.Visit($this) $this.Append('{') $_.Item2.Visit($this) $this.RemoveTrailingSemi() $this.Append('}') } if ($node.DefaultClause) { $this.Append('default {') ($node.DefaultClause)?.Visit($this) $this.RemoveTrailingSemi() $this.Append('}') } $this.Append('};') return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitUsingStatement([UsingStatementAst]$node) { $this.Append($node.Extent.Text) $this.AppendSemi() return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitParamBlock([ParamBlockAst]$node) { $node.Attributes | ForEach-Object { $_.Visit($this) } $this.Append('param(') $params = $node.Parameters $n = $params.Count for ($i = 0; $i -lt $n; $i++) { $params[$i].Visit($this) if ($i -lt $n - 1) { $this.Append(',') } } $this.Append(');') return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitParameter([ParameterAst]$node) { $node.Attributes | ForEach-Object { $_.Visit($this) } $this.Append($node.Name) if ($node.DefaultValue) { $this.Append('=') $node.DefaultValue.Visit($this) } return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitAttribute([AttributeAst]$node) { $this.Append('[') $allArgs = @() $allArgs += $node.PositionalArguments $allArgs += $node.NamedArguments $this.Append("$($node.TypeName)($($allArgs -join ','))") $this.Append(']') return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitTrap([TrapStatementAst]$node) { $this.Append('trap ') ($node.Filter)?.Visit($this) $node.Body.Visit($this) return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitParenExpression([ParenExpressionAst]$node) { $this.Append('(') ($node.Pipeline)?.Visit($this) $this.RemoveTrailingSemi() $this.Append(')') return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitMemberExpression([MemberExpressionAst] $node) { ($node.Expression)?.Visit($this) if ($node.NullConditional) {$this.Append('?')} $this.Append($(if($node.Static){'::'}else{'.'})) $this.Append($node.Member) return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitInvokeMemberExpression([InvokeMemberExpressionAst] $node) { ($node.Expression)?.Visit($this) if ($node.NullConditional) {$this.Append('?')} $this.Append($(if($node.Static){'::'}else{'.'})) $this.Append($node.Member) if ($node.GenericTypeArguments.Count -gt 0) { $this.Append('[') for ($i = 0; $i -lt $node.GenericTypeArguments.Count; $i++) { $node.GenericTypeArguments[$i].Visit($this) if ($i -lt $node.GenericTypeArguments.Count - 1) { $this.Append(',') } } $this.Append(']') } $this.Append('(') for ($i = 0; $i -lt $node.Arguments.Count; $i++) { $arg = $node.Arguments[$i] $arg.Visit($this) if ($i -lt $node.Arguments.Count - 1) { $this.Append(',') } } $this.Append(')') return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitWhileStatement([WhileStatementAst]$node) { if ($node.Label) { $this.Append(':{0} ' -f $node.Label) } $this.Append('while(') $node.Condition.Visit($this) $this.RemoveTrailingSemi() $this.Append('){') foreach ($s in $node.Body.Statements) { $s.Visit($this) $this.AppendSemi() } $this.Append('}') return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitHashtable([HashtableAst]$node) { $this.Append('@{') $n = $node.KeyValuePairs.Count for ($i = 0; $i -lt $n; $i++) { $key = $node.KeyValuePairs[$i].Item1 $value = $node.KeyValuePairs[$i].Item2 $key.Visit($this) $this.Append('=') $value.Visit($this) $this.AppendSemiIf($i -lt $n - 1) } $this.Append('}') return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitArrayExpression([ArrayExpressionAst]$node) { $this.Append('@(') $node.SubExpression.Statements | % { $_.Visit($this) } $this.Append(')') return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitArrayLiteral([ArrayLiteralAst]$node) { $n = $node.Elements.Count for ($i = 0; $i -lt $n; $i++) { $node.Elements[$i].Visit($this) if ($i -lt $n - 1) { $this.Append(',') } } return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitVariableExpression([VariableExpressionAst]$node) { $path = $node.VariablePath $this.Append("$(if($node.Splatted){'@'}else{'$'})$path") return [AstVisitAction]::Continue } [AstVisitAction] VisitBinaryExpression([BinaryExpressionAst]$node) { $opSpan = @($node.Left.Extent.EndOffset - $node.Extent.StartOffset) $opSpan += $node.Right.Extent.StartOffset - $node.Left.Extent.EndOffset $opToken = $node.Extent.Text.Substring($opSpan[0], $opSpan[-1]) $node.Left.Visit($this) $this.Append($opToken.Trim()) $node.Right.Visit($this) return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitUnaryExpression([UnaryExpressionAst]$node) { $nodeExt = $node.Extent $childExt = $node.Child.Extent $isPostfix = $node.TokenKind.ToString().StartsWith('Postfix') if ($isPostfix) { $opSpan = @($childExt.EndOffset - $nodeExt.StartOffset) $opSpan += ($nodeExt.Text.Length - $opSpan[0]) $opToken = $nodeExt.Text.Substring($opSpan[0], $opSpan[-1]) $node.Child.Visit($this) $this.Append($opToken.Trim()) } else { $opToken = $nodeExt.Text.Substring(0, $childExt.StartOffset - $nodeExt.StartOffset) $this.Append($opToken.Trim()) $node.Child.Visit($this) } return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitTypeConstraint([TypeConstraintAst]$node) { $this.Append("[$($node.TypeName)]") return [AstVisitAction]::Continue } [AstVisitAction] VisitTypeExpression([TypeExpressionAst]$node) { $this.Append("[$($node.TypeName)]") return [AstVisitAction]::Continue } [AstVisitAction] VisitIndexExpression([IndexExpressionAst]$node) { $this.sb.Append($node.Target.ToString().Trim()) if ($node.NullConditional) { $this.sb.Append('?') } $this.sb.Append('[' + $node.Index + ']') return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitTypeDefinition([TypeDefinitionAst]$node) { $bytes = [System.Text.Encoding]::Unicode.GetBytes($node.Extent.Text) $enc = [Convert]::ToBase64String($bytes) $b64DecodeExpr = "[System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String(`"$enc`"))" $this.sb.Append("iex ($b64DecodeExpr)") return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitFunctionMember([FunctionMemberAst]$node) { if ($node.IsHidden) { $this.Append('hidden ') } if ($node.IsStatic) { $this.Append('static ') } if ($node.IsConstructor) { $this.Append($node.Parent.Name) } else { if ($node.ReturnType) { $node.ReturnType.Visit($this) $this.sb.Append(' ') } $this.Append($node.Name) } $n = $node.Parameters.Count $this.Append('(') for ($i = 0; $i -lt $n; $i++) { $param = $node.Parameters[$i] $param.Visit($this) if ($i -lt $n - 1) { $this.Append(',') } } $this.Append('){') ($node.Body)?.Visit($this) $this.Append('};') return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitPropertyMember([PropertyMemberAst]$node) { $node.Attributes | % { $_.Visit($this) } if ($node.IsHidden) { $this.Append('hidden ') } if ($node.IsStatic) { $this.Append('static ') } ($node.PropertyType)?.Visit($this) $this.Append("`$$($node.Name)") if ($node.InitialValue) { $this.Append('=') $node.InitialValue.Visit($this) } $this.Append("`n") return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitReturnStatement([ReturnStatementAst]$node) { $this.Append('return') if ($node.Pipeline) { $this.sb.Append(' ') $node.Pipeline.Visit($this) } return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitContinueStatement([ContinueStatementAst]$node) { $this.Append('continue') if ($node.Label) { $this.Append(' ' + $node.Label) } $this.Append(';') return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitBreakStatement([BreakStatementAst]$node) { $this.Append('break') if ($node.Label) { $this.Append(' ' + $node.Label) } $this.Append(';') return [AstVisitAction]::SkipChildren } [AstVisitAction] VisitExitStatement([ExitStatementAst]$node) { $this.Append('exit ') ($node.Pipeline)?.Visit($this) return [AstVisitAction]::SkipChildren } [AstVisitAction] DefaultVisit([Ast]$node) { Write-Debug "[DEFAULT-VISIT][TYPE]: $($node.GetType().FullName)" Write-Debug "[DEFAULT-VISIT][VALUE]: $node" $this.Append($node.Extent.Text) return [AstVisitAction]::SkipChildren } } function Remove-ScriptTrivia { param([Parameter(Position = 0, ValueFromPipeline)][string]$text) Write-Debug @" [SCRIPT-DEFINITION] `````` $text `````` "@ $toks = $errs = $null [void][Parser]::ParseInput($text, [ref]$toks, [ref]$errs) if ($errs) { $errs | % { Write-Error ($_) } break } $cleanSb = [System.Text.StringBuilder]::new() $i = 0 $prevKind = $null $toks | ? { $_.Kind -in @([TokenKind]::Comment, [TokenKind]::LineContinuation) } | % { $subStr = if ($prevKind -eq 'LineContinuation') { $text.Substring($i, $_.Extent.StartOffset - $i).TrimStart() } else { $text.Substring($i, $_.Extent.StartOffset - $i) } $cleanSb.Append($subStr) | Out-Null $i = $_.Extent.EndOffset $prevKind = $_.Kind } $rest = if ($prevKind -eq 'LineContinuation') { $text.Substring($i, $text.Length - $i).TrimStart() } else { $text.Substring($i, $text.Length - $i) } $cleanSb.Append($rest) | Out-Null $lines = $cleanSb.ToString().Split([Environment]::NewLine, [StringSplitOptions]::RemoveEmptyEntries) | ? { $_ -notmatch '^\s+$' } $lines -join [Environment]::NewLine } function Invoke-ScriptMinifier { [Alias('Minify')] param ( [Parameter(ParameterSetName = 'FromFile', Position = 0)] [Alias('f')] [string]$File, [Parameter(ParameterSetName = 'FromInput')] [Alias('c')] [string]$Command ) $text = switch ($PSCmdlet.ParameterSetName) { 'FromFile' { Get-Content -Path $File -Raw } 'FromInput' { $Command } } $clean = Remove-ScriptTrivia $text $ast = [Parser]::ParseInput($clean, [ref]@(), [ref]@()) [MinifyVisitor]::GetMinified($ast) } |