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:
- Normalizing line endings (CRLF vs LF).
- Removing trailing whitespace.
- Converting tabs to spaces (or vice versa) consistently.
- Ensuring a single trailing newline at end-of-file.
- 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
}
jq .re-serializes JSON with consistent 2-space indentation and key ordering preserved — one of the simplest and most reliable JSON formatters available sincejqis commonly pre-installed or trivially installable.shfmt -wformats shell scripts in place according to consistent style conventions (indentation, spacing around operators).prettier --writecovers a broad swath of web-adjacent formats.black -qformats Python code,-qsuppressing its normal per-file output for cleaner script logs.- Each branch checks
command -v tool &>/dev/nullbefore calling it, so the script degrades gracefully (falling through to generic cleanup) rather than erroring out when a specialized formatter isn’t installed.
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
- Pre-commit hook: Run this as a Git pre-commit hook to guarantee every committed file has clean whitespace and consistent formatting.
- Onboarding legacy codebases: Batch-normalize an inherited project’s files that have accumulated years of inconsistent editor settings.
- Cross-platform collaboration: Normalize line endings automatically when files move between Windows and Unix contributors.
- CI formatting checks: Run in “check” mode (diff against formatted output without modifying) to fail a build if unformatted code is detected.
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
- Always back up before an in-place transformation, even a well-tested one — regex edge cases are inevitable across enough files.
- Run generic cleanup before specialized formatters, not after, to avoid the specialized tool re-introducing inconsistencies the generic pass would have caught.
- Check for tool availability (
command -v) before calling any specialized formatter, so the script works in reduced environments without failing outright. - Keep formatting rules configurable (tab width, whether to convert tabs at all) rather than hardcoded, since projects legitimately differ in convention.
Security Considerations
- Formatters that execute code as part of their process (some linter/formatter combinations do, especially in JavaScript tooling with plugin systems) should only be run against trusted source trees — never point a code-executing formatter at arbitrary untrusted uploads.
sed -iin place edits carry no inherent security risk, but always operate on a validated file path — never build the target path from unsanitized user input in a way that could escape the intended directory.
Optimization Tips
- Batch-format entire directories with
find:find . -name '*.py' -exec ./format.sh {} \;applies the same treatment across a whole tree in one pass. - For very large repositories, parallelize with
find ... | xargs -P4 -I{} ./format.sh {}to use multiple cores. - Cache which specialized formatters are installed once at script start rather than checking
command -vrepeatedly inside a large loop.
Troubleshooting
sed -ifails on macOS with “invalid command code”: macOS ships BSDsed, which requires an explicit backup suffix argument (even empty:sed -i '' ...) unlike GNUsed; detect the OS and adjust the invocation accordingly.jqreformatting breaks a JSON file with comments: Standard JSON doesn’t support comments;jqwill simply fail to parse such files — this is expected, since JSONC/JSON5 aren’t the same format.prettier/blackconflicts with generic whitespace pass: Rare, but if a specialized formatter’s opinion differs from the generic cleanup (e.g., trailing newline handling), let the specialized tool’s output be final, since it’s format-aware.
Common Mistakes
- Running a specialized formatter without checking it’s actually installed, causing the script to fail loudly in minimal environments (containers, fresh CI runners).
- Forgetting the macOS
sed -iquirk, leading to confusing failures for anyone testing the script on a Mac. - Applying tabs-to-spaces conversion universally, even to filetypes (like Makefiles) where tabs are semantically required — always exclude such formats explicitly.
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.
