Zachary Loeber

The personal website of Zachary Loeber.

Powershell: Make Pretty Scripts..With Scripts

2015-10-15 9 min read Microsoft Powershell Zachary Loeber

I released a new module for standardizing and beautifying your PowerShell code. Aside from code indentation it also can reduce line length, replace here-strings, and a whole lot more.

Introduction

Some people sit down after a long day’s work, turn on the TV, and proceed to do suduku or crossword puzzles to wind down. I sit down and marathon watch old star trek series with the wife and hack through coding algorithms or puzzles that I cannot get out of my head. This module is the result of ‘just one more’ puzzle after another on the quest to automatically reformat some of my old PowerShell projects in an automated manner. I’ve taken it far enough along that I think it may be worth publicizing a little bit to see if I’m on the right path or if I need to simply stop obsessing over AST, tokens, and code formatting functions.

So what I’ve done is create a PowerShell module and it is creatively called….

Format PowerShell Code Module

This is a set of functions to re-factor your script code in different ways with the aim of beautifying and standardizing your code.

  1. This module has multiple goals. Here are a few things one might use it for:
  2. Cleanse and format code copied from the web (fix characters)
  3. Refactor your old code to adhere to best practices in line length, alias usage, type definition usage, indentation and so on.
  4. Use as a pre-build tool to maintain consistency across your code base.
  5. Turn someone else’s insane semi-colon riddled one liner into a script that doesn’t hurt your eyes quite as much.

My selfish reasons for this project were primarily to fix up my old code though. I’ve got tens of thousands of lines of code I want to add features too and improve upon but everytime I open it up one of these old scripts I find myself tediously editing the code for style and other waste of time changes which should be automatic.

Limitations

What this module is not going to do is fix broken PowerShell! Much of exported cmdlets use AST which can only parse functioning code (with some interesting exceptions).

Stupid Cmdlet Names

Well I think they are kind of silly at least. To keep the cmdlets in this code distinct I’ve gone with the following rather non-standard naming standard:

Format-ScriptWhatTheFormattingDoes

It feels a bit wonky but we can always change it later I suppose….

Warnings

I really don’t think this should need to be stated but here it is anyway…

Do NOT just read in your source code and blindly pipe it to the cmdlets included in this module and then write the results out to the same file! I’ve tried to account for a large number of caveats and scenarios but I’m positive I’ve not thought of them all. Additionally I’ve written this code primarily for myself (hey, we are all selfish creatures). What seems to work fine for me may not work at all for your code.

Even though every function defaults to validating the script text after processing I’d go as far as to say you should unit test your code before and after any reformatting done by this module to ensure you get consistent results.

Consider yourself warned.

Logic

Each formatting function has its own special logic. Generally though we tend to perform the actual string manipulations (script formatting) working from the bottom up. Working in reverse lets us not have to refactor token/string locations after every change made. This is especially true of token driven updates like tabifying your script with Format-ScriptFormatCodeIndentation.

There are many interesting exceptions I’ve run into which required some elegant and not so elegant methods to work around. In these cases I try to note in comments where I think more elegant code or algorithms could have been used (which I simply was unable to figure out). A good example is NamedBlockAST or StatementBlockAST code expansion. As there can be embedded blocks beneath each block you find you cannot simply make a change without all the extent start and end locations for every AST element below it changing. So I recreate the AST search results on every iteration for every change made. It feels… awkward but I’ve no better solution yet.

NOTE: None of the functions in this module touch comments! I’ve no way to tell what you are intending with your comments so we do our very best to simply leave them alone. This doesn’t mean that I’ve tested every variant of comments existing in oddball places in your code so I’ll repeat that you should proceed with caution!

Usage

Each function included with this module can be used individually but many of these functions were built around one another for specific purposes. Simply piping all your code through all the cmdlets exported in this module is likely to make your code even more grotesque looking than it was beforehand. Here are a few example usages which you may find handy.

NOTE: Most functions which affect newlines in any manner (expanding code blocks, removing semicolons, et cetera) do nothing for your indentation. This was done on purpose to keep each function as basic as possible. This means you will almost always run your code through Format-ScriptFormatCodeIndentation at the very end of any transformations you are performing!

Example 1 – Condense and Remove ‘Here Strings’

Here-strings are pretty useful variable assignments which are essentially multi-lined strings. I’ve used them for embedding quick templates into my code among other things. They are also totally unwieldy when it comes to making your code look nice. This is because they have strict requirements as to where the terminating here string characters must be (the start of the next line in column 0). Here is an example function with a here string assignment embedded within:

function New-CPUReport ($Title,$Data) {
    $Report = @"

-----------------------------------------------------
- $($Title)
-----------------------------------------------------
Process ID      Process Name        CPU Usage

"@

$ReportDataTemplate = @'
<>            <>            <>

'@
    $Data | Foreach {
        $Report += $ReportDataTemplate -replace '<>',$_.ID -replace '<>',$_.Name -replace '<>',$_.CPU
    }
    
    return $Report
}

$Data = Get-Process | Sort-Object -Property CPU -Descending | select -First 5
New-CPUReport 'My Rocking Report!' $Data

The here-strings are embedded in a function and are thusly unable to be indented without breaking the script entirely. Here is what we would like to happen to fix this:

  1. Convert here strings into simple multiple part string assignments
  2. As these string assignments will likely be very long we would also like to automatically reduce the line length of the script by automatically inserting line breaks in appropriate positions.
  3. Automatically indent the resulting code.

To achieve these tasks with this module you would simply do the following:

import-module .\FormatPowershellCode.psm1
Get-Content .\tests\testcase-strings.ps1 -raw | 
    Format-ScriptReplaceHereStrings |
    Format-ScriptReduceLineLength |
    Format-ScriptFormatCodeIndentation | 
    clip

The resulting code would look a bit less unsightly (though not by much as it was a fast and dumb example to begin with):

function New-CPUReport ($Title,$Data) {
    $Report = "-----------------------------------------------------" +
    "`r`n" + "- $($Title)" + "`r`n" +
    "-----------------------------------------------------" + "`r`n" +
    "Process ID     Process Name        CPU Usage" + "`r`n"
    
    $ReportDataTemplate = '<>         <>            <>' + "`r`n"
    $ReportFooter = '-----------------------------------------------------' + "`r`n"
    $Data | Foreach {
        $Report += $ReportDataTemplate -replace '<>',$_.ID -replace '<>',$_.Name -replace '<>',$_.CPU
    }
    
    
    $Report += $ReportFooter                
    
    return $Report      
}

$Data = Get-Process | Sort-Object -Property CPU -Descending | select -First 5
New-CPUReport 'My Rocking Report!' $Data

Example 2 – De-obfuscation

A truly obfuscated bit of PowerShell code will require more than this module to de-obfuscate but this module may help a little bit in making it more readable. You may ‘de-obfuscate’ a crazy looking one-liner you came up with to just get a job done in the heat of the moment. Here is a one-liner I purposefully made look like crap. It is a function that gets the lines of a script that token kinds are found between:

function Format-ScriptGetKindLines {[CmdletBinding()]param([parameter(Position=0, ValueFromPipeline=$true, HelpMessage='Lines of code to process.')][string[]]$Code,[parameter(Position=1, HelpMessage='Type of AST kind to retrieve.')][string]$Kind = "*"); begin {$Codeblock = @();$ParseError = $null; $Tokens = $null; $FunctionName = $MyInvocation.MyCommand.Name; Write-Verbose "$($FunctionName): Begin."}; process{$Codeblock += $Code }; end { $ScriptText = $Codeblock | Out-String;  Write-Verbose "$($FunctionName): Attempting to parse AST."; $AST = [System.Management.Automation.Language.Parser]::ParseInput($ScriptText, [ref]$Tokens, [ref]$ParseError);  if($ParseError) { $ParseError | Write-Error; throw "$($FunctionName): Will not work properly with errors in the script, please modify based on the above errors and retry." }; $TokenKinds = @($Tokens | Where {$_.Kind -like $Kind}); Foreach ($Token in $TokenKinds) { New-Object psobject -Property @{ 'Start' = $Token.Extent.StartLineNumber; 'End' = $Token.Extent.EndLineNumber;}}; Write-Verbose "$($FunctionName): End." }}

In order to make this look more like a version which doesn’t instantly give you a migraine you’d need to perform several transformations. Here is the general logic of what we will do:

  1. Turn statement separators (semicolons) into newlines
  2. Expand function blocks (function{})
  3. Expand named blocks (begin/process/end)
  4. Expand parameter blocks (param())
  5. Expand statement blocks (if/then/else)
  6. Move starting curly braces to the end of the prior line (a personal preference)
  7. Auto-indent all blocks with 4 spaces

With this module you would accomplish this with the following:

import-module .\FormatPowershellCode.psm1
get-content .\tests\testcase-codeblockexpansion.ps1 -raw |
    Format-ScriptRemoveStatementSeparators |
    Format-ScriptExpandFunctionBlocks |
    Format-ScriptExpandNamedBlocks |
    Format-ScriptExpandParameterBlocks |
    Format-ScriptExpandStatementBlocks |
    Format-ScriptFormatCodeIndentation |
    Format-ScriptCondenseEnclosures |
    clip

Then you can go ahead and paste the output into your favorite editor to get something more palatable:

Format-ScriptGetKindLines {
    [CmdletBinding()]
    param (
    [parameter(Position=0, ValueFromPipeline=$true, HelpMessage='Lines of code to process.')]
    [String[]]$Code,
    [parameter(Position=1, HelpMessage='Type of AST kind to retrieve.')]
    [String]$Kind
    )
    
    Begin {
        $Codeblock = @()
        $ParseError = $null
        $Tokens = $null
        $FunctionName = $MyInvocation.MyCommand.Name
        Write-Verbose "$($FunctionName): Begin."
    }
    Process {
        $Codeblock += $Code
    }
    End {
        $ScriptText = $Codeblock | Out-String
        Write-Verbose "$($FunctionName): Attempting to parse AST."
        $AST = [System.Management.Automation.Language.Parser]::ParseInput($ScriptText, [ref]$Tokens, [ref]$ParseError)
        if($ParseError)  {
            $ParseError | Write-Error
            throw "$($FunctionName): Will not work properly with errors in the script, please modify based on the above errors and retry."
        }
        $TokenKinds = @($Tokens | Where {$_.Kind -like $Kind})
        Foreach ($Token in $TokenKinds)  {
            New-Object psobject -Property @{ 'Start' = $Token.Extent.StartLineNumber
                'End' = $Token.Extent.EndLineNumber
            }
        }
        Write-Verbose "$($FunctionName): End."
    }
}

NOTE: I’ve included a vanity function you can tack on the end of any transform to move the beginning curly brace to the end of the prior line called  Format-ScriptCondenseEnclosures. I prefer my code with less wasted lines but its just a personal preference so the default for all expansion transforms is to place the start of blocks ({) on their own line.

 

Included Functions

Thus far I’ve completed and done testing with the following exported module members:

[table id=1 /]

Conclusion

Well this was a long post but I’m releasing a fairly large module to to community so it was probably warranted. If you find some time to take this module and kick its tires I’d love input and community involvement. You can install the module like so:

iex (New-Object Net.WebClient).DownloadString("https://github.com/zloeber/FormatPowershellCode/raw/master/Install.ps1")

Otherwise with PowerShell 5 you can simply install from the gallery with the following:

Install-Module FormatPowershellCode

Or manually download/clone/fork the project at Github: https://github.com/zloeber/FormatPowershellCode

comments powered by Disqus