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) {
      $prev = $this.GetPrevChar()
      if ($prev -match '\w' -and $text -match '-?\w+') {
        $this.sb.Append(' ') | Out-Null
      }
    }
    $this.sb.Append($text) | Out-Null
  }

  [AstVisitAction] VisitCommand([CommandAst]$node) {
    switch ($node.InvocationOperator) {
      'Dot' { $this.Append('.') }
      'Ampersand' { $this.Append('&') }
    }
    return [AstVisitAction]::Continue
  }

  [AstVisitAction] VisitStatementBlock([StatementBlockAst]$node) {
    $stmts = $node.Statements
    $n = $stmts.Count
    for ($i = 0; $i -lt $n; $i++) {
      $stmts[$i]?.Visit($this)
      if ($i -lt $n - 1 -and $this.GetPrevChar() -ne ';') { $this.Append(';') }
    }
    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.Append(')')
    }
    $this.Append('{')
    ($node.Body)?.Visit($this)
    $this.Append('}')
    return [AstVisitAction]::SkipChildren
  }

  [AstVisitAction] VisitScriptBlockExpression([ScriptBlockExpressionAst]$node) {
    $this.Append('{')
    ($node.ScriptBlock)?.Visit($this)
    $this.Append('}')
    return [AstVisitAction]::SkipChildren
  }

  [AstVisitAction] VisitScriptBlock([ScriptBlockAst]$node) {
    $node.UsingStatements | ForEach-Object { $_.Visit($this) }
    $node.Attributes | ForEach-Object { $_.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)
        if ($i -lt $n - 1 -and $this.GetPrevChar() -ne ';') { $this.Append(';') }
      }
    }
    if (-not $node.Unnamed) { $this.Append('}') }
    
    $node.Traps | ForEach-Object { ($_)?.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.Append(')')
      $this.Append('{')
      ($clauses[$i].Item2)?.Visit($this)
      $this.Append('}')
    }
    if ($node.ElseClause) {
      $this.Append('else{')
      ($node.ElseClause)?.Visit($this)
      $this.Append('}')
    }
    if ($this.GetPrevChar() -ne ';') { $this.Append(';') }
    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)
    if ($this.GetPrevChar() -ne ';') { $this.Append(';') }  
    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)
      if ($this.GetPrevChar() -ne ';') { $this.Append(';') }
    }
    if ($node.Condition) {
      ($node.Condition)?.Visit($this) 
      if ($this.GetPrevChar() -ne ';') { $this.Append(';') }
    }
    ($node.Iterator)?.Visit($this)
    $this.Append(')')
    $this.Append('{')
    ($node.Body)?.Visit($this)
    $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.Append('){')
    ($node.Body)?.Visit($this)
    $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.Append('}until(')
    ($node.Condition)?.Visit($this)
    $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.Append('}while(')
    ($node.Condition)?.Visit($this)
    $this.Append(');')
    return [AstVisitAction]::SkipChildren
  }

  [AstVisitAction] VisitSubExpression([SubExpressionAst]$node) {
    $this.Append('$(')
    ($node.SubExpression)?.Visit($this)
    $this.Append(')')
    return [AstVisitAction]::SkipChildren
  }

  [AstVisitAction] VisitTryStatement([TryStatementAst]$node) {
    $this.Append('try ')
    ($node.Body)?.Visit($this)
    $node.Catches | ForEach-Object { $_.Visit($this) }
    ($node.Finally)?.Visit($this)
    if ($this.GetPrevChar() -ne ';') { $this.Append(';') }
    return [AstVisitAction]::SkipChildren
  }

  [AstVisitAction] VisitCatchClause([CatchClauseAst]$node) {
    $this.Append('catch ')
    ($node.ErrorType)?.Visit($this)
    if ($node.Variable) { $this.Append(" `$$($node.Variable)") }
    $node.Body.Visit($this)
    return [AstVisitAction]::SkipChildren
  }

  [AstVisitAction] VisitThrowStatement([ThrowStatementAst]$node) {
    $this.Append('throw ')
    ($node.Pipeline)?.Visit($this)
    ($node.Exception)?.Visit($this)
    if ($this.GetPrevChar() -ne ';') { $this.Append(';') }
    return [AstVisitAction]::SkipChildren
  }

  [AstVisitAction] VisitSwitchStatement([SwitchStatementAst]$node) {
    $this.Append('switch(')
    $node.Condition.Visit($this)
    $this.Append('){')
    $node.Clauses | ForEach-Object {
      $_.Item1.Visit($this)
      $this.Append('{')
      $_.Item2.Visit($this)
      $this.Append('}')
    }
    if ($node.DefaultClause) {
      $this.Append('default {')
      ($node.DefaultClause)?.Visit($this)
      $this.Append('}')
    }
    $this.Append('};')
    return [AstVisitAction]::SkipChildren
  }

  [AstVisitAction] VisitUsingStatement([UsingStatementAst]$node) {
    $this.Append($node.Extent.Text)
    if ($this.GetPrevChar() -ne ';') { $this.Append(';') }
    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.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)
      if ($i -lt $n - 1 -and $this.GetPrevChar() -ne ';') { $this.Append(';') }
    }
    $this.Append('}')    
    return [AstVisitAction]::SkipChildren
  }

  [AstVisitAction] VisitArrayExpression([ArrayExpressionAst]$node) {
    $this.Append('@(')
    $node.SubExpression.Statements | ForEach-Object { $_.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("`$$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 | ForEach-Object { $_.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]`n```````n$text`n``````"
  $toks = $errs = $null
  [void][Parser]::ParseInput($text, [ref]$toks, [ref]$errs)
  if ($errs) {
    $errs | ForEach-Object { Write-Error ($_) }
    break
  }
  $cleanSb = [System.Text.StringBuilder]::new()
  $i = 0
  $prevKind = $null
  $toks | 
  Where-Object { $_.Kind -in @([TokenKind]::Comment, [TokenKind]::LineContinuation) } |
  ForEach-Object {
    $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) | 
  Where-Object { $_ -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)
}