Last active
March 20, 2026 07:15
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .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