Bug story: when “$FOO” expanded — why strict literals matter
A user ran this sh2 snippet:
print("$FOO")
They expected it to print the literal text $FOO.
Instead, with FOO=EXPANDED in the environment, it printed EXPANDED.
This wasn’t supposed to happen. sh2’s core promise is no implicit expansion: what you write is what you get. But the generated Bash code was allowing shell parameter expansion to slip through.
This is the story of a P0 correctness bug, the fix, and the rules for writing strings going forward.
The intended behavior
sh2’s design says:
String literals (
"...") are strict literals. They do not expand variables, globs, or shell metacharacters.
This means:
print("$FOO") // Should print: $FOO
print("${Package}") // Should print: ${Package}
print("Price: $5") // Should print: Price: $5
run("echo", "*") // Should pass literal * to echo
The user writes "$FOO", the script prints $FOO. Simple.
The minimal repros
Repro 1: print(“$FOO”)
File: repro_dollar_expansion_print.sh2
func main() {
print("$FOO")
}
Running with the bug:
FOO=EXPANDED sh2do repro_dollar_expansion_print.sh2
Expected output:
$FOO
Actual output (before fix):
EXPANDED
Repro 2: dpkg-query format string
File: string_braced_no_expand_run_dpkg_query.sh2
func main() {
run("dpkg-query", "-W", "-f", "${Package}\n", "bash", allow_fail=true)
print("status=" & status())
}
Running with the bug:
Package=BAD sh2do string_braced_no_expand_run_dpkg_query.sh2
Expected output:
bash
status=0
Actual output (before fix):
BAD
status=0
The format string ${Package} was being expanded as a shell variable instead of being passed literally to dpkg-query.
What went wrong
The root cause was in code generation. When sh2 compiled string literals to Bash, it was emitting them in a form that still allowed shell expansion.
For example, print("$FOO") was generating something like:
echo "$FOO"
The double quotes in Bash allow parameter expansion. $FOO gets replaced with the environment value.
The fix was to emit string literals in single quotes (or with proper escaping), so the output becomes:
echo '$FOO'
Single quotes in Bash prevent all expansion. The literal text passes through unchanged.
Why this matters: real strings containing $
The $ character appears in many legitimate contexts:
1. dpkg-query format strings
run("dpkg-query", "-W", "-f", "${Package} ${Version}\n", "bash")
dpkg uses ${...} as its own template syntax. If shell expansion happened, the query would break.
2. Template snippets
let template = "Hello ${name}, your balance is $${amount}"
Generating templates for other languages (Terraform, CloudFormation, etc.) often uses ${} syntax.
3. Regex patterns
run("grep", "-E", "\\$[0-9]+", "prices.txt")
Regex patterns frequently contain $ for end-of-line or literal dollar signs.
4. JSON with dollar signs
let json = "{\"price\": \"$5.00\", \"expr\": \"${item}\"}"
JSON payloads may contain dollar signs as data.
5. Console output
print("Price: $5 per unit")
print("Variable syntax: $VAR or ${VAR}")
User-facing messages should print exactly what you wrote.
In all these cases, the user expects literal output. Unexpected expansion is a correctness bug.
The fix
From the v0.1.1 release notes:
P0 Fix (Breaking Change / Correctness Fix): String literals (
"...") are now strict literals. They do not support implicit variable interpolation or Bash parameter expansion.
"$foo"and${bar}in string literals are preserved as literal text (e.g.print("$foo")prints$foo).- To use variables, use concatenation (
"Hello " & name) or explicit interpolation ($"Hello {name}").- This change ensures that strings like
"$5"or"*"are strictly safe and never trigger unintended Bash behavior.
The fix enforces the original design: strings are literal, expansion is explicit.
How to write strings now
Literal strings (no expansion)
These just work:
print("$FOO") // Prints: $FOO
print("${Package}") // Prints: ${Package}
print("Price: $5") // Prints: Price: $5
run("echo", "*") // Passes literal * to echo
Dynamic strings (with variables)
Use concatenation with &:
let name = "Alice"
print("Hello " & name) // Prints: Hello Alice
print("User: " & name & "!") // Prints: User: Alice!
Dynamic strings with interpolation (v0.1.1+)
Use explicit interpolation with $"...":
let name = "Alice"
print($"Hello {name}") // Prints: Hello Alice
print($"Sum: {1 + 2}") // Prints: Sum: 3
The $ prefix signals interpolation. Braces {...} mark expression holes.
Note:
$"..."interpolation was added in v0.1.1.
Mixing literals and variables
let user = "alice"
let msg = "User=" & user & ", HOME=$HOME"
print(msg)
// Prints: User=alice, HOME=$HOME
// (The literal $HOME is NOT expanded)
Shell parsing escape hatch
If you genuinely need shell expansion, use sh("..."):
# sh(...) because: intentionally demonstrating shell variable expansion
sh("echo $HOME")
// Prints the actual value of HOME (shell expansion inside sh)
Inside sh("..."), you’re in shell-land. Expansion happens. Use this when you want it.
Before and after comparison
Example 1: print(“$FOO”)
Before fix:
$ FOO=EXPANDED sh2do -e 'func main() { print("$FOO") }'
EXPANDED
After fix:
$ FOO=EXPANDED sh2do -e 'func main() { print("$FOO") }'
$FOO
Example 2: dpkg-query format string
Before fix:
$ Package=BAD sh2do string_braced_no_expand.sh2
BAD
status=0
After fix:
$ Package=BAD sh2do string_braced_no_expand.sh2
bash
status=0
Example 3: Asterisk literal
Before fix (potential):
run("echo", "*")
// Might glob to: file1.txt file2.txt ...
After fix:
run("echo", "*")
// Prints: *
Tests that lock this down
The following tests exist to prevent regression:
1. test_string_dollar_no_expand_print
// repro_dollar_expansion_print.sh2
func main() {
print("$FOO")
}
Assertion: With FOO=EXPANDED, output must be $FOO.
2. test_string_braced_no_expand_run_dpkg_query
// string_braced_no_expand_run_dpkg_query.sh2
func main() {
run("dpkg-query", "-W", "-f", "${Package}\n", "bash", allow_fail=true)
print("status=" & status())
}
Assertion: With Package=BAD, output must contain bash and NOT contain BAD.
3. test_string_braced_no_expand_run_printf
// string_braced_no_expand_run_printf.sh2
func main() {
run("printf", "%s\n", "${Package}")
}
Assertion: With Package=BAD, output must be ${Package}.
4. Hostile string guardrails
// guardrail_hostile_strings.sh2
let dollar_str = "$HOME and $USER"
run("echo", dollar_str)
Assertion: Output is literally $HOME and $USER.
The rules, summarized
| What you write | What happens |
|---|---|
"$FOO" |
Literal $FOO (no expansion) |
"${Package}" |
Literal ${Package} (no expansion) |
"*" |
Literal * (no glob) |
"~" |
Literal ~ (no tilde expansion) |
"Hello " & name |
Concatenation: Hello + value of name |
$"Hello {name}" |
Interpolation: Hello + value of name |
sh("echo $FOO") |
Shell expansion: actual value of FOO |
The principle:
- Strings are literal. What you write is what you get.
- Interpolation is explicit. Use
&or$"...". sh("...")is the escape hatch. You’re opting into shell parsing.
Closing
This bug was a violation of sh2’s core design. The fix restores the original intent: a string literal should be literal.
If you’re migrating older sh2 code that relied on implicit expansion, the fix is straightforward: use & concatenation or $"..." interpolation where you want dynamic values.
And if you’re writing new code: just write your strings. They’ll do what you expect.
Docs
The GitHub repo is here:
https://github.com/siu-mak/sh2lang
Further Documentation
docs/language.md— full language reference (syntax + semantics)docs/releases/v0.1.1.md— release notes including this fixtests/— fixtures and integration tests (acts as an executable spec)
👉 https://github.com/siu-mak/sh2lang