sh2 in plain English: the mental model
Have you ever written a Bash script that worked perfectly—until someone ran it with a filename that had a space? Or spent an hour debugging why ${Package} got mangled by dpkg’s format string?
# Looks fine. Explodes with spaces.
file="my report.txt"
grep pattern $file # Bash turns this into: grep pattern my report.txt
sh2 is designed so you can predict what will happen without memorizing Bash’s quoting rules. This article gives you the mental model.
The three rules
- Arguments are arguments. If you write
run("cmd", a, b), the command receives exactly two arguments—no matter whataandbcontain. - Strings are strict literals. What you type is what you get.
"*"stays"*"."$FOO"stays"$FOO". - Interpolation is explicit. You opt in with
$"..."or&concatenation. There’s no magic.
The rest of this article shows each rule in action.
Rule 1: Arguments are arguments
Example 1: Spaces don’t split
let file = "my report.txt"
run("grep", "pattern", file)
# grep receives exactly 2 args: "pattern" and "my report.txt"
In Bash, you’d need "$file" and hope you didn’t forget. In sh2, run(...) always passes each argument as a single value.
Example 2: Wildcards don’t glob
run("echo", "*")
# Prints: *
There’s no secret expansion. The asterisk goes to echo as-is.
Example 3: Tilde is just a character
run("ls", "~/Documents")
# ls receives the literal string "~/Documents"
# (This will fail because the path doesn't exist!)
If you want the home directory, use env.HOME:
run("ls", env.HOME & "/Documents")
Rule 2: Strings are strict literals
Example 4: Dollar signs stay literal
print("Price: $5")
# Output: Price: $5
Example 5: Braced variables stay literal
run("echo", "Current shell is ${SHELL}")
# echo receives the literal string "Current shell is ${SHELL}"
This is a lifesaver when you’re passing format strings to tools or generating templates. No escaping required.
Example 6: Both $FOO and ${FOO} are safe
let msg = "Hello $USER and ${HOME}"
print(msg)
# Output: Hello $USER and ${HOME}
sh2 never expands $... inside regular "..." strings.
Rule 3: Interpolation is explicit
When you want variables inside strings, you ask for it.
Option A: Use & (concatenation)
let name = "Alice"
print("Hello " & name & "!")
# Output: Hello Alice!
Option B: Use $"..." (explicit interpolation) — v0.1.1+
let user = "admin"
print($"Welcome, {user}!")
# Output: Welcome, admin!
The $"..." syntax signals intent. Braces mark where variables go. No ambiguity.
Example 7: Expressions in $"..."
print($"Sum: {1 + 2}")
# Output: Sum: 3
print($"Current dir: {pwd()}")
# Output: Current dir: /path/to/here
Example 8: Literal braces
print($"Set notation: \{a, b\}")
# Output: Set notation: {a, b}
Use \{ and \} to escape braces when you don’t want interpolation.
Note: String literals inside interpolation holes are not yet supported (e.g.,
$"X: { "val" }"). Use a variable as a workaround:let v = "val"; print($"X: {v}").
Named arguments: readable options
Bash flags are positional and cryptic. sh2 uses named arguments for clarity.
Example 9: confirm(default=false)
if confirm("Delete everything?", default=false) {
run("rm", "-rf", "data/")
}
- If the script runs in CI (non-interactive), it proceeds with
false(no deletion). - You can override interactively.
- Environment variables
SH2_YES=1orSH2_NO=1force the answer.
Example 10: sudo(...) with named options (v0.1.1)
sudo("apt-get", "update", n=true, user="root")
Instead of remembering -n vs -u, you write n=true and user="root". The compiler generates sudo -n -u root -- apt-get update with the -- separator automatically.
Example 11: env_keep=[...]
sudo("env", env_keep=["PATH", "HOME"])
# Generates: sudo --preserve-env=PATH,HOME -- env
Named arguments scale: add options without reordering positional flags.
Capturing output and handling failure
Example 12: capture(...) with allow_fail=true — v0.1.1+
let out = capture(run("ls", "missing/"), allow_fail=true)
if status() != 0 {
print("ls failed with code " & status())
} else {
print(out)
}
- The script doesn’t abort when
lsfails. status()holds the exit code.outcontains whateverlswrote before failing.
Before / After: A real footgun
Bash (common bugs)
pattern="foo bar"
file="my data.txt"
msg='Price: $5'
grep $pattern "$file" # Bug 1: $pattern splits
echo "$msg" # Bug 2: $5 expands to empty
rm *.bak # Bug 3: glob might match nothing
What can go wrong:
$patternbecomes two arguments (fooandbar) unless quoted.- Bash tries to expand
$5(empty). rmbehavior depends on shell options if no files match.
sh2 (these bugs can’t happen)
let pattern = "foo bar"
let file = "my data.txt"
let msg = "Price: $5"
run("grep", pattern, file) # ✅ 2 args, no splitting
print(msg) # ✅ $5 is literal
run("rm", "*.bak", allow_fail=true) # ✅ "*.bak" is literal; passed to rm
What disappeared:
- No quoting gymnastics.
- No escaping
$in strings. - No globbing surprises (unless you use
sh()).
The escape hatch: sh("...")
Sometimes you genuinely need shell features: pipes, process substitution, globs.
Example 13: Structured alternatives vs escape hatch
# Preferred: use glob() for simple patterns
for f in glob("*.log") {
print(f)
}
# If you need a count via pipeline:
let count = capture(
run("find", ".", "-name", "*.log", "-print")
| run("wc", "-l"),
allow_fail=true
)
if status() == 0 {
print($"Found {trim(count)} log files")
}
Inside sh(...), you’re back in shell-land. Globs expand. Variables expand if you write $FOO. Use sh() only when no structured primitive exists.
Use structured primitives when:
- You need glob expansion (
*.log) →glob() - You need file discovery →
find0() - You can express it with
run()and structured pipes
Rules you can remember
-
If you see
run(...), you’re safe from word splitting. Each argument is exactly one argument. -
If you see
"...", nothing expands. Dollar signs, braces, asterisks, tildes—all literal. -
If you want variables inside strings, use
$"..."or&. You choose when to interpolate. -
Use
env.HOMEinstead of~. Tilde is just a character. -
Named arguments replace flags.
n=true, user="root"instead of-n -u root. -
allow_fail=truemakes failures checkable. Combine withstatus()to handle errors explicitly. -
If you see
sh("..."), you’re back in shell-land. All Bash rules apply inside that string. -
When in doubt, check what the compiler generates. Run
sh2c --emit-sh your_script.sh2to see the output.
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