Skip to content

Instantly share code, notes, and snippets.

@scriptingstudio
Last active March 20, 2026 07:15
Show Gist options
  • Select an option

  • Save scriptingstudio/575418c290d1360865057b5c0897a4b1 to your computer and use it in GitHub Desktop.

Select an option

Save scriptingstudio/575418c290d1360865057b5c0897a4b1 to your computer and use it in GitHub Desktop.
Simple PowerShell script compressor. The script works like "ConvertTo-Json -Compress". Two editions: System.Management.Automation.PSParser and System.Management.Automation.Language.Parser
<#
.SYNOPSIS
Removes comments and extra white space from an input PS script.
.DESCRIPTION
The filters omit white space and indented formatting in the output. Filters are comments, newlines, spaces, statement separator (;).
.PARAMETER Path
Specifies the path to the PS file to compress.
.PARAMETER ScriptBlock
Specifies the PowerShell scriptblock to compress.
.PARAMETER NoTest
This parameter allows to skip a file path test. Useful for internal cases to omit multiple checks.
.PARAMETER Basic
Removes comments only. Applies when advanced compression is impossible.
INPUTS
System.String
System.Management.Automation.ScriptBlock
.OUTPUTS
System.String
.NOTES
PSParser doesn't support here-string.
.LINK
http://www.leeholmes.com/blog/2007/11/07/syntax-highlighting-in-powershell/
https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.pstoken?view=powershellsdk-7.4.0
https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.language.parser.parseinput?view=powershellsdk-1.1.0
#>
function Compress-ScriptBlock {
[CmdletBinding(DefaultParameterSetName = 'File')]
[OutputType([string])]
[Alias('cmsb')]
param (
[Parameter(Position = 0, Mandatory, ParameterSetName = 'File')]
[ValidateNotNullOrEmpty()]
[alias('FilePath','FileName')][String] $Path,
[Parameter(Position = 0, Mandatory, ParameterSetName = 'ScriptBlock')]
[ValidateNotNullOrEmpty()]
[ScriptBlock] $ScriptBlock,
[Parameter(ParameterSetName = 'File')]
[Switch] $NoTest,
[Switch] $Basic # remove comments only
)
if ($Path) {
if (-not $NoTest) {if (-not (Test-Path $Path)) {return}}
$scriptBlockString = [IO.File]::ReadAllText((Resolve-Path $Path).Path)
$scriptBlock = [ScriptBlock]::Create($scriptBlockString)
} else {
# Convert the scriptblock to a string so that it can be referenced to extract script lines
$scriptBlockString = $scriptBlock.ToString()
}
if ([String]::IsNullOrEmpty($scriptBlockString)) {return}
# Tokenize the scriptblock and return all tokens except for comments/exclusions
$psperrors = $null
$tokens = @([System.Management.Automation.PSParser]::Tokenize($scriptBlock, [Ref]$psperrors)).where({$_.Type -ne 'Comment' -or $_.Content -match '^#Requires'})
if ($null -eq $tokens) {return}
#if ($psperrors.count) {}
# Runtime options
$currentColumn = 1 # script line cursor
$newLine = $false # sequential newlines semaphore
$prevToken = $null # token cursor to look back
$tindex = 0 # token index to lookup ahead
$rxoper = [regex]::new('[,+=|/\$\-]',[System.Text.RegularExpressions.RegexOptions]'Compiled')
$rxnewl = [regex]::new('^NewLine|^LineContinuation',[System.Text.RegularExpressions.RegexOptions]'Compiled,IgnoreCase')
$rxgroup = [regex]::new('Group[se]',[System.Text.RegularExpressions.RegexOptions]'Compiled,IgnoreCase')
$rxtype = [regex]::new('GroupStart|NewLine|LineContin|Statement',[System.Text.RegularExpressions.RegexOptions]'Compiled,IgnoreCase')
# The show begins here
$strBuilder = foreach ($currentToken in $tokens) {
if ($currentToken.Type -eq 5) { # work around for here-string
$tokenBody = $scriptBlockString.Substring($currentToken.Start,$currentToken.Length)
if ($tokenBody -match "^@['`"]") { # here-string
$tokenBody
$tindex++
$newLine = $false
$currentColumn = $currentToken.EndColumn
continue
}
}
if ($rxnewl.IsMatch($currentToken.Type)) {
$currentColumn = 1
# Handle newlines. Sequential newlines are ignored in order to save space
if (-not $newLine) {
if ($Basic) {[Environment]::NewLine}
else {
# TODO: ignore newline on condition
if ($rxtype.IsMatch($prevToken.Type)) {}
elseif ($tokens[$tindex+1].Type -eq 13) {} #13='GroupEnd'
else {[Environment]::NewLine}
}
$newLine = $true
}
} else {
$newLine = $false
# Handle any indenting
if ($currentColumn -lt $currentToken.StartColumn) {
# Handle spaces in between tokens on the same line
# TODO: ignore extra spaces on condition; insert StatementSeparator
if ($currentColumn -ne 1) {
if ($Basic) {' '}
else {
if ($currentToken.Type -eq 11 -and $rxoper.IsMatch($currentToken.Content)) {} #11='Operator'
elseif ($prevToken.Type -eq 11 -and $rxoper.IsMatch($prevToken.Content)) {}
elseif ($rxgroup.IsMatch($currentToken.Type) -or $prevToken.Type -eq 16) {} #16='StatementSeparator'
elseif ($currentToken.Type -eq 14 -and $currentToken.Content -ne 'in') {} #14='keyword'
#?elseif ($prevToken.Type -eq 13 -and $currentToken.Type -ne 'keyword') {';'} #13='GroupEnd'
else {' '}
}
}
}
$tokenBody = $scriptBlockString.Substring($currentToken.Start,$currentToken.Length)
# Handle multi-line strings 5='String'
if ($currentToken.Type -eq 5 -and $currentToken.EndLine -gt $currentToken.StartLine) {
$tokenBody.replace([Environment]::NewLine,'')
}
else { # Write out a regular token
$tokenBody
}
# Update current position in the column
$currentColumn = $currentToken.EndColumn
} # token type
$prevToken = $currentToken
$tindex++
} # token iterator
[string]::Concat($strBuilder) # -replace '(\r\n){2,}' .replace("`r`n`r`n","")
} # END Compress-ScriptBlock
function Compress-ScriptBlock {
[CmdletBinding(DefaultParameterSetName = 'File')]
[OutputType([string])]
[Alias('cmsb')]
param (
[Parameter(Position = 0, Mandatory, ParameterSetName = 'File')]
[ValidateNotNullOrEmpty()]
[alias('FilePath','FileName')][String] $Path,
[Parameter(Position = 0, Mandatory, ParameterSetName = 'ScriptBlock')]
[ValidateNotNullOrEmpty()]
[ScriptBlock] $ScriptBlock,
[Parameter(ParameterSetName = 'File')]
[Switch] $NoTest,
[Switch] $Basic # remove comments only
)
if ($Path) {
if (-not $NoTest) {if (-not (Test-Path $Path)) {return}}
$scriptBlockString = [IO.File]::ReadAllText((Resolve-Path $Path).Path)
$scriptBlock = [ScriptBlock]::Create($scriptBlockString)
} else {
# Convert the scriptblock to a string so that it can be referenced to extract script lines
$scriptBlockString = $scriptBlock.ToString()
}
if ([String]::IsNullOrEmpty($scriptBlockString)) {return}
# Tokenize the scriptblock and return all tokens except for comments/exclusions
$psperrors = $rawtokens = $null
$null = [System.Management.Automation.Language.Parser]::ParseInput($scriptBlockString, [ref]$rawtokens, [ref]$psperrors)
if ($null -eq $rawtokens) {return}
#if ($psperrors.count) {}
# Runtime options
$currentColumn = 1 # script line cursor
$newLine = $false # sequential newlines semaphore
$prevToken = $null # token cursor to look back
$tindex = 0 # token index to lookup ahead
$rxoper = [regex]::new('[,+=|/\$\-]',[System.Text.RegularExpressions.RegexOptions]'Compiled')
$rxnewl = [regex]::new('^NewLine|^LineContinuation',[System.Text.RegularExpressions.RegexOptions]'Compiled,IgnoreCase')
$rxgroup = [regex]::new('Paren$|Curly$',[System.Text.RegularExpressions.RegexOptions]'Compiled,IgnoreCase')
$rxtype = [regex]::new('LParen|LCurly|AtParen|AtCurly|Dollar|NewLine|LineContin|Semi',[System.Text.RegularExpressions.RegexOptions]'Compiled,IgnoreCase')
$rxgrpend = [regex]::new('RParen$|RCurly$',[System.Text.RegularExpressions.RegexOptions]'Compiled,IgnoreCase')
$rxoperator = [regex]::new('operator$|^binary',[System.Text.RegularExpressions.RegexOptions]'Compiled,IgnoreCase')
$keyword = [regex]::new('keyword',[System.Text.RegularExpressions.RegexOptions]'Compiled,IgnoreCase')
$stringtoken = [regex]::new('^String.',[System.Text.RegularExpressions.RegexOptions]'Compiled,IgnoreCase')
# The show begins here
$tokens = $rawtokens.where({$_.Kind -ne 'Comment' -or $_.Text -match '^#Requires'})
$strBuilder = foreach ($currentToken in $tokens) {
#if ($currentToken.HasError) {}
$currentCursor = $currentToken.Extent
if ($rxnewl.IsMatch($currentToken.Kind)) {
$currentColumn = 1
# Handle newlines. Sequential newlines are ignored in order to save space
if (-not $newLine) {
if ($Basic) {[Environment]::NewLine}
else {
# TODO: ignore newline on condition
if ($rxtype.IsMatch($prevToken.Kind)) {}
elseif ($rxgrpend.IsMatch($tokens[$tindex+1].Kind)) {}
else {[Environment]::NewLine}
}
$newLine = $true
}
}
else {
$newLine = $false
# Handle any indenting
if ($currentColumn -lt $currentCursor.StartColumnNumber) {
# Handle spaces in between tokens on the same line
# TODO: ignore extra spaces on condition; insert StatementSeparator
if ($currentColumn -ne 1) {
if ($Basic) {' '}
else {
if ($rxoperator.IsMatch($currentToken.TokenFlags) -and $rxoper.IsMatch($currentToken.Text)) {}
elseif ($rxoperator.IsMatch($prevToken.TokenFlags) -and $rxoper.IsMatch($prevToken.Text)) {}
elseif ($rxgroup.IsMatch($currentToken.Kind) -or $prevToken.Kind -eq 'Semi') {}
elseif ($keyword.IsMatch($currentToken.TokenFlags) -and $currentToken.Text -ne 'in') {}
##?elseif ($rxgrpend.IsMatch($prevToken.Kind) -and -not $keyword.IsMatch($currentToken.TokenFlags)) {';'}
else {' '}
}
}
}
$currentTokenLength = $currentCursor.EndOffset - $currentCursor.StartOffset
if ($currentTokenLength -eq 0) {break} # why it happens?
$tokenBody = $scriptBlockString.Substring($currentCursor.StartOffset,$currentTokenLength)
# Handle multi-line strings
if ($stringtoken.IsMatch($currentToken.Kind) -and $currentCursor.EndColumnNumber -gt $currentCursor.StartColumnNumber) {
$tokenBody.replace([Environment]::NewLine,'')
}
else { # Write out a regular token
$tokenBody
}
# Update current position in the column
$currentColumn = $currentCursor.EndColumnNumber
} # token type
$prevToken = $currentToken
$tindex++
} # token iterator
[string]::Concat($strBuilder) # -replace '(\r\n){2,}' .replace("`r`n`r`n","")
} # END Compress-ScriptBlock
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment