Bash one-liners ironed out
What this article is
One-liners are seductive. They fit in a tweet, a README, or a quick Slack message. But the moment you need to understand, trust, or modify them, the magic evaporates:
- Quoting rules are invisible until they break.
- Word splitting happens to variables you didn’t think about.
$expansion fires when you didn’t want it.- Error handling is “hope nothing fails.”
sh2 is a small, structured shell language. You write structured snippets, compile them to Bash or POSIX sh, and run them. sh2do is the one-command wrapper: sh2do 'snippet' compiles and executes in one step.
The goal isn’t to replace Bash—it’s to give you a safer, more readable way to handle the hairy cases where Bash one-liners become write-only code.
This article walks through 8 real-world one-liner patterns, shows the Bash version and the sh2do equivalent, and honestly assesses when sh2 helps and when Bash remains the better tool.
1. Confirmation before a dangerous action
The Bash way
read -p "Delete /var/data? [y/N] " ans && [[ "$ans" =~ ^[Yy] ]] && rm -rf /var/data
Common failure modes:
- Forgetting to quote
$ans(empty input breaks the test). - Script runs in CI with no stdin—hangs forever or deletes anyway.
- No environment variable override for automation.
The sh2do way
sh2do 'if confirm("Delete /var/data?", default=false) { run("rm", "-rf", "/var/data") }'
Why it’s better:
confirm(...)handles yes/no parsing, default values, and CI overrides.- See Confirm Helper for details on
default=falseandSH2_YES.
2. Running a command as root with flags
The Bash way
sudo -n -u deploy systemctl restart nginx
Common failure modes:
- The
-nmust come before the command, but flag order is easy to get wrong. - Forgetting quotes around arguments that contain spaces.
- No way to safely add
--preserve-env=PATHwithout risking syntax errors.
The sh2do way
sh2do 'sudo("systemctl", "restart", "nginx", user="deploy", n=true)'
Why it’s better:
- Named options (
user=,n=) are validated at compile time. - The compiler enforces a stable flag order with mandatory
--separator. - See Sudo Builtin for the full list of options.
3. Error handling based on exit status
The Bash way
output=$(grep "pattern" file.txt) || { echo "grep failed"; code=$?; exit "$code"; }
Common failure modes:
$?gets clobbered: afterecho "grep failed"succeeds,$?becomes 0, socodeis 0.- You must save
$?before running any other command—easy to forget. - Easy to forget the braces, turning the one-liner into two separate commands.
- Word splitting on
$outputif you later use it unquoted.
The sh2do way
sh2do '
let out = capture(run("grep", "pattern", "file.txt"), allow_fail=true)
if status() != 0 {
print_err("grep failed with " & status())
}
'
Why it’s better:
allow_fail=trueprevents script abort, andstatus()is preserved.- See Error Handling for more patterns.
4. The quoting / word-splitting footgun
The Bash way
file="my document.txt"
cat $file # WRONG: splits into "my" and "document.txt"
Common failure modes:
- Forgetting quotes around
$filecauses word splitting. - Glob expansions fire if the variable contains
*or?. - Even experienced devs get this wrong in complex scripts.
The sh2do way
sh2do '
let file = "my document.txt"
run("cat", file)
'
Why it’s better:
- No implicit expansion.
run(...)passes arguments as-is. - See No Implicit Expansion for the rules.
5. The $ expansion / format-string scenario
The Bash way
out=$(dpkg-query -W -f '${Package}\n' bash)
Common failure modes:
- If you accidentally use double quotes:
"${Package}"becomes an empty variable expansion. - Mixing single and double quotes to get the right behavior is error-prone.
- The distinction between
${}as a Bash variable vs a dpkg-query format specifier is invisible.
The sh2do way
sh2do '
let out = capture(run("dpkg-query", "-W", "-f", "${Package}\n", "bash"))
print(out)
'
Why it’s better:
- sh2 string literals are strict literals.
"${Package}"is passed exactly as written. - See No Implicit Expansion and the Dollar Expansion Bug story.
6. Pipelines (where Bash remains better)
The Bash way
grep ERROR /var/log/app.log | awk '{print $3}' | sort | uniq -c | sort -rn | head -5
The sh2do way
For simple two-stage pipelines, sh2 works well:
sh2do 'run("echo", "hello world") | run("sed", "s/hello/hi/")'
For long text-processing chains, use sh(...):
# sh(...) because: complex pipeline
sh2do 'sh("grep ERROR /var/log/app.log | awk '\''{print $3}'\'' | sort | uniq -c | sort -rn | head -5")'
Honest assessment: sh2 pipelines are structured (run(...) | run(...)), but for complex grep|awk|sort|uniq chains:
- Bash is genuinely more concise for ad-hoc text munging.
- sh2’s
sh(...)is an escape hatch that passes raw shell code, losing the safety guarantees. - Use sh2 when the pipeline benefits from structured arguments; use Bash when the pipeline is pure text transformation.
7. File iteration (line-by-line processing)
The Bash way
while IFS= read -r line; do echo "Line: $line"; done < file.txt
Common failure modes:
- Forgetting
IFS=causes leading/trailing whitespace to be stripped. - Forgetting
-rcauses backslash escapes to be processed. - Complex quoting if the file path has spaces.
The sh2do way
sh2 provides lines(...) to split a string into a list by newlines:
sh2do '
let content = read_file("file.txt")
for line in lines(content) {
print("Line: " & line)
}
'
Why it’s better:
- No
IFS/-rincantations to remember. read_file(...)is explicit about what you’re doing.lines(...)handles trailing newlines correctly (doesn’t produce a final empty element).
Streaming iteration: For very large files where loading everything into memory is inefficient, pipe the file in and use stdin_lines() to stream line-by-line:
sh2do '
for line in stdin_lines() {
print("Line: " & line)
}
' < file.txt
8. The “many files” pattern: find -exec vs xargs
The Bash way
find . -name "*.log" -exec gzip {} \;
# or
find . -name "*.log" -print0 | xargs -0 gzip
Common failure modes:
find -execwith\;is slow (one gzip per file).xargswithout-0breaks on filenames with spaces/newlines.- The
{}placeholder syntax is arcane and easy to get wrong.
The sh2do way
sh2do '
let files = capture(run("find", ".", "-name", "*.log"))
for f in lines(files) {
run("gzip", f)
}
'
Why it’s better:
- Each argument is properly quoted—no word splitting on filenames with spaces.
- The loop is explicit and readable.
- You can add logic (e.g.,
if is_file(f) { ... }) trivially.
Trade-off: This is functionally equivalent to -exec {} \; (one process per file). For batch processing, you’d still want xargs. sh2 doesn’t have a xargs-style builtin (yet).
Where sh2do doesn’t help (yet)
sh2 is still young. Here are things it cannot do:
| Gap | Reality |
|---|---|
| Streaming line iteration | stdin_lines() provides a streaming while read equivalent. |
| Process substitution | No <(...) or >(...) syntax. Use sh(...) as an escape hatch. |
| Background jobs / & | Structured job control exists via spawn() and wait(), but not terse &. |
| Here-strings | No <<< syntax. Use sh(...) or temp files. |
| Arithmetic in conditions | Comparisons work, but $(( )) arithmetic expansion isn’t built-in. |
| Complex xargs patterns | No batching multiple arguments. You can loop, but lose parallelism. |
| Interactive REPL | sh2do is compile-then-run; there’s no interactive shell mode. |
For these cases, use Bash directly or use sh(...) to embed raw shell code.
Rules of thumb
When to use Bash
- Quick, interactive throwaway commands.
- Complex text pipelines where
|is the primary structure. - When you need features sh2 doesn’t have (process substitution).
- Sub-20-character commands where quoting is trivial.
When to use sh2do
- Any one-liner with user-controlled input (file paths, usernames, etc.).
- Commands where quoting would be error-prone (spaces, globs,
$). - Scripts that need to run in both interactive and CI contexts (use
confirm(default=...)). - When you want explicit error handling (
allow_fail=true+status()).
When to write a .sh2 file instead of a one-liner
- More than 3-4 statements.
- Reusable logic (functions, imports).
- You want version control and code review on your scripts.
- You’re building tooling for a team, not just yourself.
Comparison table
| Category | Bash one-liner | Common failure | sh2do version | Verdict |
|---|---|---|---|---|
| Confirmation | read -p "..." && [[ $ans =~ ... ]] |
Hangs in CI; no default | confirm("...", default=false) |
✅ sh2 is safer |
| Sudo + flags | sudo -n -u user cmd |
Flag order errors; no validation | sudo("cmd", user="...", n=true) |
✅ sh2 is clearer |
| Error status | cmd || echo $? |
$? gets clobbered |
allow_fail=true + status() |
✅ sh2 preserves status |
| Word splitting | cat $file |
Splits on spaces | run("cat", file) |
✅✅ sh2’s biggest win |
| $ expansion | '${Package}' vs "${Package}" |
Wrong quote type expands | "${Package}" is literal |
✅ sh2 is safer |
| Long pipelines | grep \| awk \| sort |
N/A—Bash is good at this | sh("grep \| awk \| sort") |
⚖️ Bash is better |
| File iteration | while IFS= read -r |
Forget IFS or -r | for line in lines(read_file(...)) |
✅ sh2 is cleaner |
| Many files | find -exec \; or xargs -0 |
Spaces break; slow | Loop with run("gzip", f) |
⚖️ Comparable |
Docs
The GitHub repo is here:
https://github.com/siu-mak/sh2lang
Further Documentation
docs/language.md— full language reference (syntax + semantics)docs/sh2do.md— sh2do CLI documentationtests/— fixtures and integration tests (acts as an executable spec)
👉 https://github.com/siu-mak/sh2lang