No implicit expansion: why sh2 treats strings literally
A developer wrote a template generation script:
dpkg-query -W -f '${Package}\n' bash
Worked fine. Then someone moved it into a bigger script where set -u was enabled. Suddenly it failed: Bash tried to expand ${Package} as a variable, found it unset, and aborted. The fix? More quotes. Or escaping. Or both. Or maybe a here-doc.
This is the core problem: Bash does things to your strings before passing them to commands. Spaces split. Asterisks glob. Dollar signs expand. Tildes transform. You can quote around most of it, but the rules are inconsistent and the failure modes are silent.
sh2 takes a different approach: strings are literal by default. You opt into expansion explicitly.
What counts as “implicit expansion”?
In Bash, text goes through multiple transformation phases before reaching a command:
| Phase | What happens | Example pitfall |
|---|---|---|
| Word splitting | Unquoted variables split on whitespace | rm $file deletes multiple files if file="a b" |
| Globbing | *, ?, [...] expand to matching files |
echo * lists the directory instead of printing * |
| Parameter expansion | $FOO, ${FOO} replace with variable value |
echo $undefined silently becomes empty |
| Tilde expansion | ~ becomes $HOME |
Works in some contexts, not others |
| Command substitution | $(...) runs a command |
Nested quoting becomes a nightmare |
All of these happen automatically unless you know the right quoting incantation.
sh2’s rule: strings are literal
In sh2:
"..."is a strict literal. Dollar signs, asterisks, braces—all stay as-is.run(...)passes each argument as-is. No splitting, no globbing.- If you want variables in strings, you explicitly use
&concatenation or$"..."interpolation.
This means you can read sh2 code and know what will happen.
12 examples: Bash vs sh2
1. Spaces in variables
Bash (dangerous):
file="my document.txt"
rm $file
# Bash runs: rm my document.txt (two arguments!)
sh2 (safe):
let file = "my document.txt"
run("rm", file)
# rm receives exactly one argument: "my document.txt"
Why it matters: No quoting required. Variables are values, not text to be re-parsed.
2. Literal asterisk
Bash:
echo "*" # Prints *, but only because it's quoted
echo * # Lists files in current directory
sh2:
run("echo", "*")
# Prints: *
Why it matters: What you see is what you get.
3. Literal question mark
Bash:
echo "pattern?" # Stays literal (quoted)
ls pattern? # Globs to pattern1, pattern2, etc.
sh2:
run("ls", "pattern?")
# Passes literal "pattern?" to ls (no glob)
Why it matters: No silent transformation into something else.
4. Tilde is just a character
Bash:
ls ~/Documents # Tilde expands to $HOME
ls "~/Documents" # Tilde stays literal (ls fails!)
sh2:
run("ls", "~/Documents")
# Passes literal "~/Documents" (THIS WILL FAIL)
# Correct sh2 pattern:
run("ls", env.HOME & "/Documents")
Why it matters: No hidden expansion. The correct pattern (env.HOME & "...") makes intent explicit.
5. Literal $FOO
Bash:
echo "$FOO" # Prints value of FOO (or empty if unset)
echo '$FOO' # Prints literal $FOO (single quotes)
sh2:
print("$FOO")
# Prints: $FOO
Why it matters: You don’t need to know single-vs-double quote rules.
6. Literal ${Package} for dpkg-query
Bash:
dpkg-query -W -f '${Package}\n' bash # Must use single quotes
dpkg-query -W -f "${Package}\n" bash # BUG: expands as variable
sh2:
run("dpkg-query", "-W", "-f", "${Package}\n", "bash")
# ${Package} passes literally to dpkg-query
Why it matters: Format strings with ${...} just work. No escaping needed.
7. Preventing option injection with --
Bash:
rm -- "$file" # Must remember to add --
rm "$file" # If file is "-rf", disaster
sh2:
run("rm", "--", file)
# You can add -- explicitly if desired, but...
run("rm", file)
# Even without --, sh2 quotes the argument correctly
# so "-rf" stays an argument, not a flag
Why it matters: Arguments are arguments. They don’t get reinterpreted as flags by the shell.
8. Safe concatenation with &
Bash:
path="$HOME/repos/$project/main.go" # Easy to mis-quote
sh2:
let path = env.HOME & "/repos/" & project & "/main.go"
Why it matters: Explicit joining. No expansion surprises.
9. Explicit interpolation with $"..." (v0.1.1+)
sh2:
let user = "alice"
print($"Welcome, {user}!")
# Output: Welcome, alice!
With expressions:
print($"Sum: {1 + 2}")
# Output: Sum: 3
Why it matters: You opt into interpolation. The $ prefix signals intent.
Note:
$"..."interpolation was added in v0.1.1. Both&concatenation and$"..."are valid approaches.
10. Escape hatch: sh("...")
When you genuinely need shell parsing and no structured primitive exists:
sh2:
# sh(...) because: complex pipeline with awk field extraction
sh("ls *.log | wc -l")
# Globs expand. Pipes work. You're in shell-land.
Prefer structured alternatives where available:
glob("*.log")for simple patterns,find0()for file discovery,run(...) | run(...)for pipelines.
Warning: Inside
sh(...), you lose sh2’s safety guarantees. Avoid with user input.
11. with cwd(...) requires a literal path
sh2:
# ✅ Works
with cwd("/tmp") {
run("ls")
}
# ❌ Compile error
let dir = "/tmp"
with cwd(dir) { ... }
# Error: cwd(...) requires a string literal path
Why it exists: If cwd(...) accepted variables, the compiler couldn’t verify path safety at compile time. Use run("sh", "-c", ...) with an explicit cd if you need dynamic paths.
12. Explicit control: allow_fail + status()
Bash:
rm "$file" || true # Ignore error
status=$? # Oops, captured `true`'s status
sh2:
run("rm", file, allow_fail=true)
if status() != 0 {
print("rm failed with " & status())
}
Why it matters: No shell magic. You say “allow failure”, then check the status.
How to think about strings in sh2
"..."is always literal. Dollar signs, braces, asterisks—all text.run(...)never splits or globs. Each argument is passed as-is.- Use
&to build strings."Hello " & nameis explicit concatenation. - Use
$"..."for interpolation (v0.1.1+). Braces mark holes:$"Hi {name}". - Use
env.HOMEinstead of~. Tilde doesn’t expand. sh("...")is the escape hatch. Shell rules apply inside. Use sparingly.- You can always check. Run
sh2c --emit-sh script.sh2to see the generated Bash.
Comparison table
| Bash behavior | Typical footgun | sh2 behavior | How to do it intentionally |
|---|---|---|---|
Word splitting ($var splits on spaces) |
rm $file deletes wrong files |
No splitting | Just use the variable: run("rm", file) |
Globbing (* matches files) |
echo * lists directory |
No globbing | Use glob("*") for safe globbing |
Parameter expansion ($FOO) |
Unset variables become empty | Literal $FOO |
Use & env.FOO or $"{env.FOO}" |
Brace expansion (${...}) |
Conflicts with format strings | Literal ${...} |
Just write it: "${Package}" |
Tilde expansion (~) |
Works sometimes, not others | Literal ~ |
Use env.HOME & "/path" |
Command substitution ($(...)) |
Quoting nightmare | Use capture(run(...)) |
Explicit and safe |
| Unquoted special chars | $5 becomes 5th argument |
Literal $5 |
Just write it: "Price: $5" |
Option injection (-rf) |
Interpreted as flags | Remains an argument | run(...) quotes correctly |
The bottom line
Bash’s expansion rules made sense for interactive typing. You want ls *.txt to glob.
But in scripts? Those same rules become landmines. You review code, think it’s correct, and then spaces or dollars or asterisks bite you at runtime.
sh2 flips the default:
- Literal by default, expansion by intent.
- You say what you mean, and it does what you said.
That’s the whole idea.
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