Overview

edit

Meziantou.Framework.Language.Shell parses sh, bash, zsh, PowerShell and cmd scripts into a tree that keeps every character, comments, blank lines and line continuations included. Use it to analyze the commands of a script, check a CI step before running it, or rename a command without touching the rest.

It is a parser: it does not run anything, and does not expand variables.

dotnet package add Meziantou.Framework.Language.Shell
PageCovers
DialectsThe shells, and what each one supports
Reading scriptsCommands, words, expressions and comments
Editing scriptsReplacing nodes, quoting, building commands, and rewriting

Parse a script

ShellSyntaxTree.ParseText takes the script and its dialect. Invalid scripts give diagnostics, not exceptions:

const string Script = """
    # deploy the app
    set -euo pipefail

    for target in web api; do
      if [[ -d "src/$target" ]]; then
        dotnet publish "src/$target" -c Release | tee "logs/$target.log"
      fi
    done
    """;

var tree = ShellSyntaxTree.ParseText(Script, ShellDialect.Bash);

// Nothing is lost: the tree reproduces the input byte for byte.
Assert.Equal(Script, tree.GetRoot().ToFullString());
Assert.Empty(tree.GetDiagnostics());

// Invalid input produces diagnostics instead of exceptions.
var invalid = ShellSyntaxTree.ParseText("echo 'unterminated", ShellDialect.Bash);
var diagnostic = Assert.Single(invalid.GetDiagnostics());
Assert.Equal("SHELL0003 at (0,5)-(0,6): Unterminated single-quoted string.", $"{diagnostic.Id} at {diagnostic.Location}: {diagnostic.Message}");

To read a single command, such as the value of a run: field, use ParseCommand. It returns the statement rather than a tree, and reports content after the first statement:

var command = (ShellCommandSyntax)ShellSyntaxTree.ParseCommand("git commit -m 'wip'", ShellDialect.Bash);

Assert.Equal("git", command.NameValue);
Assert.Equal("wip", command.Arguments[2].Value);

Limit the nesting

ShellParseOptions.MaxRecursionDepth, 128 by default, bounds how deeply the parser goes into nested constructs, so deeply nested input cannot overflow the stack. What nests deeper is reported as SHELL0100 and kept as skipped text:

var script = "echo $(echo $(echo nested))";
var options = new ShellParseOptions(ShellDialect.Bash) { MaxRecursionDepth = 64 };
var tree = ShellSyntaxTree.ParseText(script, options);

Assert.Empty(tree.GetDiagnostics());

The limit bounds nesting, not length. A chain of operators or members, such as $x.a.b.c…, is accepted at any length; building, walking and editing its tree do not recurse either.

Diagnostics

A syntax error is reported, and parsing goes on at the next statement or at the keyword that closes the construct it is in, so one mistake does not hide the rest of the script. The identifiers are shared by every dialect; the message says what was found.

IdReported for
SHELL0001A missing command, as in a \| at the end of a line, or if true; then fi outside zsh
SHELL0002An unexpected token, such as fi without if, a command right after } on the same line, or ;; outside case
SHELL0003An unterminated string
SHELL0004A redirection without a target
SHELL0005 to SHELL0007, SHELL0010An unterminated ${, $( or backquote, $(( or ((, [[
SHELL0008, SHELL0009A missing ) or }
SHELL0011A here-document without its closing delimiter (a warning: shells read it to the end of the input)
SHELL0012, SHELL0013A missing keyword or character, a missing name
SHELL0014A function body that is not a compound command, outside zsh
SHELL0015An invalid [[ ]] expression
SHELL0020 to SHELL0027PowerShell: block comments, member names, here-strings, missing expressions, using placement, reserved keywords, missing parameter arguments, invalid assignment targets
SHELL0040, SHELL0041cmd: an incomplete if condition, a missing comparison operator
SHELL0100, SHELL0101Nesting deeper than MaxRecursionDepth; content after the statement ParseCommand read