Wes Ellis./ a personal notebook
Technology. Stories. Side projects.
A few things worth writing down.
← Back to Script Library

SCRIPT LIBRARY · POWERSHELL

Check PowerShell Scripts for Syntax Errors Without Running Them

Use PowerShell's own parser to catch typos and broken brackets in a whole folder of scripts, before anything runs.

AT A GLANCETest-ScriptSyntax.ps1
What it does
Parses every .ps1, .psm1 and .psd1 file you point it at and reports each syntax error with the file, line and column. Nothing gets executed.
Requires
  • PowerShell 7+ or Windows PowerShell 5.1
  • No modules
Permissions
Read access to the files. That's it.
Runs on
Windows, macOS, Linux, CI runners
Tested
Run against clean and deliberately broken .ps1, .psm1 and .psd1 files in PowerShell 7.4

Part 2 of the thread PowerShell craft

You know the feeling. You tweak a script, hand it off or schedule it, and an hour later it falls over on line 212 because of a curly brace you deleted by accident. The script never even got to the part you changed.

PowerShell has a full parser built in, the same one it uses right before it runs anything, and you can call it directly. It reads the file, builds the syntax tree, and tells you what's broken. It doesn't run a single line, so it's safe to point at scripts that delete things, touch production, or need credentials you don't have handy.

This little wrapper runs that parser over a file or a whole folder and gives you back a clean list of problems. I run it before every commit now, and it's the first check in every pipeline I build.

Test-ScriptSyntax.ps1Download
<#
.SYNOPSIS
    Checks PowerShell scripts for syntax errors without running a single line of them.
.DESCRIPTION
    Uses PowerShell's own parser to read each .ps1, .psm1, or .psd1 file and report any
    parse errors with the file, line, and column. Nothing is executed. Handy before you
    commit, before you hand a script to someone else, or as a quick CI check.
.PARAMETER Path
    One or more files or folders. Folders are searched for PowerShell files.
.PARAMETER Recurse
    Search subfolders too.
.EXAMPLE
    .\Test-ScriptSyntax.ps1 -Path .\scripts -Recurse
.EXAMPLE
    Get-ChildItem *.ps1 | .\Test-ScriptSyntax.ps1
#>
[CmdletBinding()]
param(
    [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
    [Alias('FullName')]
    [string[]]$Path = '.',
    [switch]$Recurse
)

begin {
    $extensions = '.ps1', '.psm1', '.psd1'
    $checked = 0
    $failed  = 0
}

process {
    foreach ($item in $Path) {
        $files = if (Test-Path -LiteralPath $item -PathType Container) {
            Get-ChildItem -LiteralPath $item -File -Recurse:$Recurse | Where-Object { $extensions -contains $_.Extension }
        }
        else {
            Get-Item -LiteralPath $item
        }

        foreach ($file in $files) {
            $tokens = $null
            $errors = $null
            [void][System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$tokens, [ref]$errors)
            $checked++

            if ($errors.Count -eq 0) {
                Write-Verbose "OK  $($file.FullName)"
                continue
            }

            $failed++
            foreach ($err in $errors) {
                [pscustomobject]@{
                    File    = $file.Name
                    Line    = $err.Extent.StartLineNumber
                    Column  = $err.Extent.StartColumnNumber
                    Message = $err.Message
                    Path    = $file.FullName
                }
            }
        }
    }
}

end {
    $color = if ($failed) { 'Red' } else { 'Green' }
    Write-Host ("Checked {0} file(s): {1} with errors." -f $checked, $failed) -ForegroundColor $color
}

Parameters

ParameterTypeDefaultWhat it's for
-Pathstring[].Files or folders to check. Folders are searched for .ps1, .psm1 and .psd1 files. Also takes pipeline input, so you can pipe Get-ChildItem straight into it.
-Recurseswitch—Look in subfolders too.

Run it

Check everything in a repo, subfolders included.

.\Test-ScriptSyntax.ps1 -Path .\scripts -Recurse

Just the scripts you changed today.

Get-ChildItem *.ps1 | Where-Object LastWriteTime -gt (Get-Date).Date | .\Test-ScriptSyntax.ps1

Fail a CI job if anything's broken.

if (.\Test-ScriptSyntax.ps1 -Path . -Recurse) { exit 1 }

See every file it looked at, not only the broken ones.

.\Test-ScriptSyntax.ps1 -Path .\scripts -Recurse -Verbose

What you'll see

Example outputvalues are illustrative
File          Line Column Message
----          ---- ------ -------
Deploy.ps1      42     16 Missing closing '}' in statement block or type definition.
Settings.psd1    7      1 The hash literal was incomplete.

Checked 18 file(s): 2 with errors.

How it works

The heavy lifting is one line:

[System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$tokens, [ref]$errors)

ParseFile hands back the syntax tree, a list of tokens and a list of parse errors. We only care about the errors. If that list is empty the file is fine, and if it isn't, each error already knows where it lives, down to the line and column.

Everything else is plumbing:

  1. Figure out what to check. If you pass a folder, it grabs every PowerShell file in it (and below it, with -Recurse). If you pass a file, it checks just that file.
  2. Parse each one. No dot-sourcing, no Invoke-Expression, no running anything. That's the whole point.
  3. Return objects, one per error. You get File, Line, Column, Message and the full Path, so you can sort, filter or export them like anything else.
  4. Print a summary at the end so you know it actually looked at something. A clean run that checked zero files is a very different thing from a clean run that checked fifty.

Because clean files return nothing, "no output" means "no problems." That's what makes the CI example work: if (...) is only true when there's at least one error.

Take it further

  • Add it as a Git pre-commit hook. Have the hook call the script on staged .ps1 files and refuse the commit if anything comes back. You'll never push a broken brace again.
  • Pair it with PSScriptAnalyzer. Run this first because it's instant, then Invoke-ScriptAnalyzer for the style and best-practice checks.
  • Check scripts before a scheduled task runs them. A quick syntax check at the top of a runner script is a cheap way to fail loudly instead of halfway through.

Things that'll trip you up

  • It catches syntax, not logic. A misspelled cmdlet name or a wrong parameter still parses fine, because PowerShell can't know what'll be installed when the script runs. For that, add PSScriptAnalyzer on top.
  • One mistake can cause a pile of errors. A missing brace near the top can make the parser confused about everything after it. Fix the first error on the list and run it again before chasing the rest.
  • Version differences are real. Syntax that's new in PowerShell 7, like the ternary operator or ??, won't parse in 5.1. Run the check with the same version that'll run the script.
  • The summary line is Write-Host. That's on purpose. It shows up on screen but stays out of the pipeline, so the only thing the script returns is the errors themselves.