Skip to main content
Metaist

Formatting Bash Commands

A 90% solution for the most common cases.

While I was working on the latest release of ds, I thought it would be nice to format the command that is about to be run. This is particularly true in files like package.json that don't support multi-line strings. And even those formats that do (e.g., .toml), escaping the newline also strips leading whitespace of the following line which makes commands hard to format (or you need to escape the slash).

Here are three examples of actual commands:

Example 1: Command with lots of options:

rm -rf docs; \
mkdir -p docs; \
pdoc \
  --html \
  --output-dir docs \
  --config sort_identifiers=False \
  --config show_inherited_members=True \
  --force src/$(basename $(pwd)); \
mv docs/**/* docs/; \
touch docs/.nojekyll

Example 2: Lots of short commands in a row:

git commit -am "release: $1";\
git tag $1;\
git push;\
git push --tags;\
git checkout main;\
git merge --no-ff --no-edit prod;\
git push

Example 3: Chain of pnpm commands:

npm run lint:spell && npm run lint:tsc && npm run lint:format

So my initial though was to split on ;, remove line continuations, and wrap the command as we went.

def wrap_cmd(cmd: str, width: int = 79, indent: int = 2, split_on: str = ";"):
    """Wrap a command-line command."""
    command_continue = " \\\n"

    lines = []
    cmd = cmd.strip().replace("\\\n", "\n")
    commands = [c.strip().split(" ") for c in cmd.split(split_on)]
    last_idx = len(commands) - 1
    for idx, command in enumerate(commands):
        new_cmd = []
        line = ""
        for item in command:
            item = item.strip()
            if not item:
                continue
            check = f"{line} {item}" if line else item
            if len(check) <= width:
                line = check
                continue
            new_cmd.append(line)
            line = item

        if line:
            new_cmd.append(line)

        if new_cmd:
            lines.append(
                f"{command_continue}{' ' * indent}".join(new_cmd)
                + (split_on if idx != last_idx else "")
            )
    return command_continue.join(lines).replace("\n", f"\n{' ' * indent}")

The problem is that example 3 doesn't have any semicolons. So then I worked on a function where we could specify which tokens prefer breaks (this is some code I had in a scratch pad).

NO_CONTINUE = ";; && |& || ; & |".split()
PREFER_BREAK = "; &&".split()
CONTINUE_LINE = "\\\n"


def peek_end(haystack: str, *needles: str) -> str:
    for needle in needles:
        if haystack.endswith(needle):
            return needle
    return ""


width = 78
line = ""
result = []
cmd = cmd3.replace(CONTINUE_LINE, "").strip()
for item in cmd.split(" "):
    item = item.strip()
    if not item:
        continue
    check = f"{line} {item}" if line else item
    if len(check) <= width:
        line = check
        if peek_end(line, *PREFER_BREAK):
            result.extend([line, "\n"])
            line = ""
        continue
    # need new line
    result.append(line)
    line = item
    if peek_end(line, *NO_CONTINUE):
        result.append("\n")
    else:
        result.append(f" {CONTINUE_LINE}")
    if not peek_end(line, *PREFER_BREAK):  # needs indent
        line = f"  {item}"

if line:
    result.append(line)

print("".join(result))

But then I had problems with indentation. So here's the solution I'm currently using. It's far from perfect, but it basically works for most cases I have so far:

import re
from os import get_terminal_size

SHELL_BREAK = "; &&".split()
"""Prefer line breaks after these."""

SHELL_CONTINUE = "\\\n"
"""Line continuation."""

SHELL_TERMINATORS = ";; && |& || ; & |".split()
"""No line continuation needed after these."""

RE_SPLIT = re.compile(
    r"""(
    (?<!\\)             # not preceded by backslash
    (?:
        (?:'[^']*')     # single quoted
        |(?:\"[^\"]*\") # double quoted
        |[\s;&]+        # one or more space, semicolon or ampersand
    ))""",
    flags=re.VERBOSE,
)
"""Regex for splitting commands."""


DEFAULT_WIDTH = 80
"""Default width for wrapping commands."""

try:
    DEFAULT_WIDTH = min(100, max(80, get_terminal_size().columns - 2))
except OSError:
    DEFAULT_WIDTH = 80

def peek_end(haystack: str, *needles: str) -> str:
    """Return the first `needle` that ends `haystack`.

    >>> peek_end("abc", "a", "b", "c")
    'c'
    >>> peek_end("abc", "x")
    ''
    """
    for needle in needles:
        if haystack.endswith(needle):
            return needle
    return ""

def wrap_cmd(cmd: str, width: int = DEFAULT_WIDTH) -> str:
    """Return a nicely wrapped command."""
    result = []
    line = ""
    space = " " * 2
    for item in RE_SPLIT.split(cmd.replace(SHELL_CONTINUE, "").strip()):
        item = item.strip()
        if not item:
            continue

        check = f"{line} {item}" if line else item
        if item in [";", ";;"]:
            check = f"{line}{item}"

        if len(check) <= width - 4:
            line = check
            # Coverage incorrectly thinks this branch is not covered.
            # See: nedbatchelder.com/blog/202406/coverage_at_a_crossroads.html
            if peek_end(line, *SHELL_BREAK):  # pragma: no cover
                result.extend([line, "\n"])
                line = ""
            continue

        # How should we terminate this line?
        if peek_end(line, *SHELL_TERMINATORS):  # no continuation
            result.append(f"{line}\n")
        else:
            result.append(f"{line} {SHELL_CONTINUE}")

        # Indent next line?
        if space and not peek_end(line, *SHELL_TERMINATORS):
            line = f"{space}{item}"
        else:
            line = item  # next line

    if line:  # add last line
        result.append(line)

    return "".join(result).replace("\n", f"\n{space}").strip()