How to Create a Bash File Formatting Tool

How to Create a Bash File Formatting Tool

How to Create a Bash File Formatting Tool

Inconsistent formatting is a quiet source of friction: mixed line endings between Windows and Unix collaborators, tabs and spaces fighting inside the same file, trailing whitespace littering diffs, JSON files with no consistent indentation. None of these individually is a big deal, but they add up, and fixing them by hand every time is a waste of attention. A Bash formatting tool automates the cleanup so it stops being a decision anyone has to make manually.

This article builds a formatting utility covering whitespace normalization, line-ending conversion, indentation, and language-aware formatting hooks.

What “Formatting” Covers Here

For the purposes of this tool, formatting means:

  1. Normalizing line endings (CRLF vs LF).
  2. Removing trailing whitespace.
  3. Converting tabs to spaces (or vice versa) consistently.
  4. Ensuring a single trailing newline at end-of-file.
  5. Delegating to language-specific formatters (e.g., jq, shfmt, prettier) when available.

Step 1: Normalizing Line Endings

Files edited across Windows and Unix systems often end up with a mix of \r\n (CRLF) and \n (LF) line endings. Standardizing to LF:

#!/usr/bin/env bash
set -euo pipefail

FILE="$1"

sed -i 's/\r$//' "$FILE"

echo "Normalized line endings in $FILE"

sed -i 's/\r$//' strips a trailing carriage return (\r) from the end of every line, in place (-i), converting CRLF to plain LF. On macOS, sed -i requires an explicit (even empty) backup extension argument: sed -i '' 's/\r$//' "$FILE" — a common portability gotcha worth handling explicitly.

Step 2: Removing Trailing Whitespace

Trailing spaces and tabs at the end of lines are a frequent source of noisy diffs in version control:

sed -i 's/[ \t]*$//' "$FILE"

This regex matches zero or more spaces/tabs ([ \t]*) immediately before the end of line ($) and removes them.

Step 3: Ensuring a Single Trailing Newline

Many style guides (and linters) expect exactly one newline at the end of a file — not zero, not several:

ensure_trailing_newline() {
    local file="$1"
    # Remove all trailing blank lines, then add exactly one newline
    printf '%s\n' "$(cat "$file")" > "$file"
}

Here, $(cat "$file") captures the file’s content with command substitution, which itself strips all trailing newlines during capture; printf '%s\n' then adds exactly one back. This is a simple, reliable pattern for enforcing single-trailing-newline discipline.

Step 4: Tabs to Spaces Conversion

convert_tabs_to_spaces() {
    local file="$1"
    local width="${2:-4}"
    expand -t "$width" "$file" > "${file}.tmp" && mv "${file}.tmp" "$file"
}

expand -t "$width" converts tabs into the specified number of spaces (4 by default). The output is written to a temporary file first, then moved over the original — a safer pattern than trying to edit in place directly with a pipe, which can truncate the source file before it’s fully read in some shells.

Step 5: A Combined Whitespace-Formatting Script

#!/usr/bin/env bash
set -euo pipefail

usage() {
    echo "Usage: $0 <file> [-t (tabs to spaces)] [-w width] [-n (skip trailing newline fix)]"
    exit 1
}

FILE="${1:-}"
[[ -z "$FILE" ]] && usage
shift

TABS_TO_SPACES=false
WIDTH=4
SKIP_NEWLINE=false

while getopts ":tw:n" opt; do
    case "$opt" in
        t) TABS_TO_SPACES=true ;;
        w) WIDTH="$OPTARG" ;;
        n) SKIP_NEWLINE=true ;;
        *) usage ;;
    esac
done

[[ -f "$FILE" ]] || { echo "File not found: $FILE"; exit 1; }

cp "$FILE" "${FILE}.bak"

sed -i 's/\r$//' "$FILE"
sed -i 's/[ \t]*$//' "$FILE"

if [[ "$TABS_TO_SPACES" == true ]]; then
    expand -t "$WIDTH" "$FILE" > "${FILE}.tmp" && mv "${FILE}.tmp" "$FILE"
fi

if [[ "$SKIP_NEWLINE" == false ]]; then
    printf '%s\n' "$(cat "$FILE")" > "$FILE"
fi

echo "Formatted: $FILE (backup saved as ${FILE}.bak)"

A .bak backup is created before any modification — cheap insurance against a regex behaving unexpectedly on an edge case the script wasn’t tested against.

Step 6: Delegating to Language-Specific Formatters

Generic whitespace cleanup only goes so far. For structured formats, dedicated formatters produce far better results:

format_by_type() {
    local file="$1"
    local ext="${file##*.}"

    case "$ext" in
        json)
            jq . "$file" > "${file}.tmp" && mv "${file}.tmp" "$file"
            ;;
        sh|bash)
            command -v shfmt &>/dev/null && shfmt -w "$file"
            ;;
        js|ts|jsx|tsx|css|md)
            command -v prettier &>/dev/null && prettier --write "$file"
            ;;
        py)
            command -v black &>/dev/null && black -q "$file"
            ;;
        *)
            echo "No specialized formatter for .$ext; applying generic whitespace cleanup only."
            ;;
    esac
}

Step 7: Putting It All Together

#!/usr/bin/env bash
set -euo pipefail

FILE="$1"
[[ -f "$FILE" ]] || { echo "File not found: $FILE"; exit 1; }

cp "$FILE" "${FILE}.bak"

# Generic whitespace normalization first
sed -i 's/\r$//' "$FILE"
sed -i 's/[ \t]*$//' "$FILE"
printf '%s\n' "$(cat "$FILE")" > "$FILE"

# Then language-specific formatting
EXT="${FILE##*.}"
case "$EXT" in
    json) command -v jq &>/dev/null && { jq . "$FILE" > "${FILE}.tmp" && mv "${FILE}.tmp" "$FILE"; } ;;
    sh|bash) command -v shfmt &>/dev/null && shfmt -w "$FILE" ;;
    py) command -v black &>/dev/null && black -q "$FILE" ;;
    js|ts|css|md) command -v prettier &>/dev/null && prettier --write "$FILE" ;;
esac

echo "Formatted: $FILE"

Running generic whitespace cleanup first, then a language-aware formatter second, means the specialized tool always operates on already-normalized input — reducing the chance of odd interactions between the two passes.

Real-World Use Cases

Automation Example: Git Pre-Commit Hook

#!/usr/bin/env bash
# .git/hooks/pre-commit
set -euo pipefail

for file in $(git diff --cached --name-only --diff-filter=ACM); do
    [[ -f "$file" ]] || continue
    ./format.sh "$file"
    git add "$file"
done

This formats every staged file before commit and re-stages the formatted result, ensuring nothing unformatted slips into history. --diff-filter=ACM limits the loop to Added, Copied, and Modified files, skipping deletions.

Best Practices

Security Considerations

Optimization Tips

Troubleshooting

Common Mistakes

FAQs

Will this reformat code logic, or just whitespace? By itself, just whitespace and line endings. Logic-level formatting (reordering imports, wrapping long lines) is delegated entirely to the specialized tools (prettier, black, shfmt) when available.

Can this run in “check-only” mode without modifying files? Yes — diff the formatted output against the original without overwriting: diff <(jq . file.json) file.json, exiting non-zero if they differ, useful for CI gates.

What about Makefiles, which require literal tabs? Exclude them explicitly from any tabs-to-spaces step, since converting tabs to spaces in a Makefile breaks its syntax.

Summary

A formatting tool built around a two-stage approach — generic whitespace normalization first, specialized formatters second — cleans up the most common sources of inconsistency across nearly any file type, while still deferring to purpose-built tools where they exist. It’s a small script that pays for itself the first time it prevents a noisy, whitespace-only diff from cluttering a code review.

References

Exit mobile version