shfmt, compiled to WebAssembly
shfmt is the formatter from the mvdan/sh project, written in Go and used by the VS Code shell-format extension, pre-commit hooks and countless dotfiles repositories. This page runs it as WebAssembly, so the script you paste is parsed and printed on your own machine. Deploy scripts tend to contain hostnames, tokens and internal URLs, so keeping them off third-party servers is not a small thing.
Scripts are read with Bash grammar, which is a superset of POSIX sh, so arrays, [[ ]] tests and $(( )) arithmetic all parse whatever the shebang says. zsh-only syntax such as parameter expansion flags is the exception and is reported as an error rather than silently mangled.
Paste, format, copy
A shebang line such as #!/usr/bin/env bash or #!/bin/sh is enough for detection. Without one, choose Shell from the format menu. Ctrl/Cmd+Enter formats, Ctrl/Cmd+Shift+M switches to minified output and back, and Ctrl/Cmd+Shift+C copies whatever is in the output pane.
Indent in the toolbar sets the indentation: 2 spaces by default, 4 spaces, or real tabs. shfmt itself defaults to tabs, so choose Tabs to match a project that runs shfmt without flags.
Error messages follow shfmt’s file:line:column pattern. The file name is a stand-in: it reads script.bash when the shebang mentions bash or zsh and script.sh otherwise, so script.sh:1:6 simply means line 1, column 6 of what you pasted. The editor underlines the same spot.
Options, flag by flag
Each option corresponds to a shfmt command-line flag, which makes it easy to copy the same settings into an .editorconfig or a CI job afterwards.
- Indent case branches (
-ci, on by default) indents thestart)andstop)patterns inside acaseblock. Turn it off for the flush style some projects use. - Operators start continuation lines (
-bn) moves&&,||and|to the beginning of the next line when a pipeline is split across lines, so each continued line starts with the operator that joins it. - Space after redirects (
-sr) writes> out.logand< in.txtinstead of>out.log. Descriptor duplications like2>&1stay compact. - Function brace on next line (
-fn) puts the opening brace ofdeploy()on its own line. - Simplify code (
-s) rewrites a few things that are safe to shorten:[[ "$env" == "prod" ]]drops the quotes that[[ ]]does not need, and$(( $count + 1 ))becomes$((count + 1)).
Minify (-mn) removes comments and indentation but keeps the shebang, one statement per line and heredoc bodies exactly as written, since their contents are data.
What shfmt leaves to you
shfmt respects some layout choices instead of imposing its own. A one-line if ...; then ...; fi stays on one line, and a long command split with backslashes keeps its breaks. It never adds or removes quotes outside the [[ ]] simplification, so an unquoted $file that will break on spaces stays unquoted. That kind of bug is a job for ShellCheck, which this page does not run.
Examples
Deploy function with simplify and next-line braces
The function brace moves to its own line and the [[ ]] test loses its unneeded quotes, while the one-line for loop stays on one line.
#!/usr/bin/env bash
set -euo pipefail
deploy(){ local env=${1:-staging}
if [[ "$env" == "prod" ]];then
echo "Deploying to production"
fi
for f in dist/*.js;do gzip -k "$f"&&echo "compressed $f">>build.log;done
case "$env" in prod) url=https://example.com;; *) url=https://$env.example.com;; esac
curl -fsS "$url/health"|| { echo "health check failed" >&2; exit 1; }
}
deploy "$@"
#!/usr/bin/env bash
set -euo pipefail
deploy()
{
local env=${1:-staging}
if [[ $env == "prod" ]]; then
echo "Deploying to production"
fi
for f in dist/*.js; do gzip -k "$f" && echo "compressed $f" >>build.log; done
case "$env" in prod) url=https://example.com ;; *) url=https://$env.example.com ;; esac
curl -fsS "$url/health" || {
echo "health check failed" >&2
exit 1
}
}
deploy "$@"
CI pipeline with operators at line starts
The && and | operators move to the front of their continuation lines and redirects get a space before the file name.
#!/bin/sh
docker build \
-t registry.example.com/shop/api:$CI_COMMIT_SHA . && \
docker push registry.example.com/shop/api:$CI_COMMIT_SHA |
tee push.log
echo "pushed" >>deploy.log 2>&1
#!/bin/sh
docker build \
-t registry.example.com/shop/api:$CI_COMMIT_SHA . \
&& docker push registry.example.com/shop/api:$CI_COMMIT_SHA \
| tee push.log
echo "pushed" >> deploy.log 2>&1
Release script minified
Comments and extra spaces are removed, while the shebang and the heredoc text survive untouched.
#!/usr/bin/env bash
# build the release notes
set -e # stop on first error
VERSION=$(git describe --tags)
echo "building $VERSION"
cat <<EOF > notes.txt
release $VERSION
EOF
#!/usr/bin/env bash
set -e
VERSION=$(git describe --tags)
echo "building $VERSION"
cat <<EOF >notes.txt
release $VERSION
EOFCommon errors and how to fix them
| Error | Cause | Fix |
|---|---|---|
script.sh:1:1: `if` statement must end with `fi` | An if block has no closing fi. The position points at the if that was never closed, not at the end of the file. | Add fi after the last command of the block, and check that elif and else branches belong to the right if. |
script.sh:1:6: reached EOF without closing quote `"` | A double-quoted string was opened and never closed, so the rest of the script was swallowed into it. | Close the quote. To include a literal double quote inside, escape it as ". |
script.sh:1:1: `case` statement must end with `esac` | A case block is missing its esac terminator. | Add esac after the last pattern, and end each pattern body with ;;. |
script.sh:1:1: `for` statement must end with `done` | A for, while or until loop is missing done, often because a one-line loop was cut short when copied. | Add done after the loop body. |
script.sh:1:5: a command can only contain words and redirects; encountered `(` | Usually an array assignment with spaces around =, as in x = (a b), which the shell reads as a command named x. | Remove the spaces: x=(a b). |
script.bash:2:18: parameter expansion flags are a zsh feature; tried parsing as bash | The script uses zsh-only syntax such as ${(f)var}. shfmt here parses everything as Bash. | Format zsh-specific files with a zsh-aware tool, or rewrite the expansion in portable Bash. |
Frequently asked questions
Is this the same as running shfmt locally?
Yes. It is shfmt compiled to WebAssembly, and each option maps to a command-line flag: -ci, -bn, -sr, -fn, -s and -mn. Indentation corresponds to -i.
Does it work for sh, dash and zsh scripts?
POSIX sh and dash scripts format fine, because Bash grammar covers them. zsh scripts work only if they avoid zsh-only syntax such as expansion flags and glob qualifiers.
Will formatting change what my script does?
No. shfmt only changes whitespace, line breaks and, with Simplify code, quotes inside [[ ]] and $ signs inside arithmetic, both of which are safe. Behaviour stays the same.
Does it find bugs like ShellCheck?
No. It reports syntax errors only. Unquoted variables, useless cat and other lint warnings need ShellCheck.
Why does a minified script still have line breaks?
shfmt keeps statements on separate lines and preserves heredoc bodies, because joining them could change meaning. Comments and indentation are what get removed.