Named arguments: making shell scripts self-documenting
A deploy script had this function call:
backup_and_copy "$src" "$dst" true false 30
What does true false 30 mean? Is true verbose mode? Is 30 a timeout or a retry count? Did someone swap src and dst?
Three months later, someone added a new parameter for “dry run mode” in position 4. Half the call sites broke silently—30 was now being interpreted as a boolean.
This is the “flag soup” problem. Positional arguments work until you have more than two of them. Then they become a maintenance nightmare.
sh2 solves this with named arguments for builtins.
Parameters vs arguments: a quick primer
Parameters are in the function definition:
func greet(name, title) {
print("Hello, " & title & " " & name)
}
Arguments are at the call site:
# Positional arguments (matched by position)
greet("Alice", "Dr.")
# Named arguments (matched by name) — for builtins only
sudo("ls", user="root", n=true)
In sh2:
- User-defined functions accept positional arguments only.
- Builtins (
run,sudo,capture,confirm,sh) support named arguments for options.
Why named arguments matter
1. Self-documenting code
# What does "true" mean?
confirm("Proceed?", true)
# Clear intent
confirm("Proceed?", default=true)
2. Order doesn’t matter
# All equivalent
sudo("ls", user="root", n=true)
sudo("ls", n=true, user="root")
sudo(n=true, "ls", user="root")
3. Adding options doesn’t break calls
When a builtin gains a new option, existing calls keep working. You opt into new behavior by adding the named argument.
4. Compile-time validation
Typos and duplicates are caught before your script runs:
error: unknown sudo() option 'usr'; supported: user, n, k, prompt, E, env_keep, allow_fail
Before/after examples
1. Confirmation with default
Positional (ambiguous):
# What does "false" mean?
if confirm("Delete all?", false) { ... }
Named (clear):
if confirm("Delete all?", default=false) { ... }
default=falseis unmistakable: in non-interactive mode, decline.
2. sudo with options
Bash flag soup:
sudo -n -u root -E apt-get update
sh2 named:
sudo("apt-get", "update", n=true, user="root", E=true)
- Intent is obvious: non-interactive, as root, preserve environment.
- No need to remember
-nvs-uvs-Eorder.
3. Function with multiple optional parameters
Positional (confusing):
# retry(command, max_retries, timeout_sec, verbose)
retry("curl http://example.com", 5, 30, true)
# Is 5 the timeout? Is 30 the retries?
If sh2 supported named args for user functions (it doesn’t yet), you’d write:
retry("curl ...", max_retries=5, timeout=30, verbose=true)
For now, the workaround is to use clear variable names:
let max_retries = 5
let timeout = 30
let verbose = true
retry("curl http://example.com", max_retries, timeout, verbose)
4. The “swap bug”
Dangerous:
# copy(src, dst) — but which is which?
copy(dst, src) # Oops, silently backwards
Safer pattern:
let src = "/data/important"
let dst = "/backup/important"
copy(src, dst) # Variable names make intent clear
Named arguments would prevent this entirely. For user functions, explicit variable names are the current workaround.
5. Adding an option without breaking calls
Scenario: capture(...) gains a new trim option.
Old call (still works):
let out = capture(run("whoami"))
New call (opts in):
let out = capture(run("whoami"), trim=true) # hypothetical
Existing code doesn’t break because the new option is opt-in via its name.
6. Explicit allow_fail
Implicit (confusing):
run("might-fail", true) # What does "true" do?
Named (clear):
run("might-fail", allow_fail=true)
- Reviewers immediately see: “this command is allowed to fail.”
7. List options like env_keep
Bash:
sudo --preserve-env=PATH,HOME,HTTP_PROXY env
sh2:
sudo("env", env_keep=["PATH", "HOME", "HTTP_PROXY"])
- The list syntax
[...]makes it clear these are preserved variables. - No comma-separated string parsing at runtime.
8. Mixed positional and named ordering
sh2 allows mixing positional command arguments with named options in any order (as of v0.1.1):
All valid:
sudo("systemctl", "restart", "nginx", user="root", n=true)
sudo(user="root", "systemctl", "restart", "nginx", n=true)
sudo(n=true, "systemctl", user="root", "restart", "nginx")
- Positional arguments (the command words) are collected in order.
- Named arguments (the options) are collected by name.
- The compiler sorts it out.
Rules and gotchas
Which builtins support named arguments?
| Builtin | Supported named arguments |
|---|---|
run(...) |
allow_fail |
sudo(...) |
user, n, k, prompt, E, env_keep, allow_fail |
capture(...) |
allow_fail |
confirm(...) |
default |
sh(...) |
allow_fail |
User-defined functions: positional only
func greet(name, title) { ... }
greet("Alice", "Dr.") # ✅ Works
greet(name="Alice", ...) # ❌ Compile error
Named arguments at call sites are reserved for builtins.
Unknown names are errors
sudo("ls", usr="root")
# Error: unknown sudo() option 'usr'; supported: user, n, k, prompt, E, env_keep, allow_fail
Duplicates are errors
confirm("go?", default=true, default=false)
# Error: default specified more than once
Literal-only constraints
Some options require literal values (not variables):
| Builtin | Option | Required type |
|---|---|---|
sudo |
user |
string literal |
sudo |
prompt |
string literal |
sudo |
n, k, E |
boolean literal |
sudo |
env_keep |
list of string literals |
confirm |
default |
boolean literal |
let u = "root"
sudo("ls", user=u)
# Error: user must be a string literal
This ensures the generated shell command is predictable at compile time.
Context restrictions
allow_fail has context-specific rules:
# ✅ Statement form
sudo("ls", allow_fail=true)
# ❌ Expression form
let out = capture(sudo("ls", allow_fail=true))
# Error: allow_fail is only valid on statement-form sudo(...);
# use capture(sudo(...), allow_fail=true) to allow failure during capture
Compiler diagnostics
The compiler catches common mistakes:
1. Unknown option
sudo("ls", xyz=true)
error: unknown sudo() option 'xyz'; supported: user, n, k, prompt, E, env_keep, allow_fail
2. Duplicate option
sudo("ls", n=true, n=false)
error: n specified more than once
3. Wrong type
sudo("ls", user=123)
error: user must be a string literal
4. allow_fail in wrong context
let x = capture(sudo("ls", allow_fail=true))
error: allow_fail is only valid on statement-form sudo(...); use capture(sudo(...), allow_fail=true) to allow failure during capture
When to use named arguments
| Situation | Recommendation |
|---|---|
| Boolean options | Always use named: n=true, not true |
| Optional parameters | Always use named: default=false |
| Multiple options | Named prevents ordering confusion |
| Self-documenting code | Named arguments are documentation |
| Positional command args | Keep positional: "apt-get", "update" |
Rule of thumb: If a reader would need to check the docs to understand an argument, use a named argument.
Comparison table
| Pattern | Bash / positional style | Named-arg sh2 style | Why it’s easier to review |
|---|---|---|---|
| Non-interactive mode | sudo -n ls |
sudo("ls", n=true) |
n=true is self-explanatory |
| Run as user | sudo -u admin ls |
sudo("ls", user="admin") |
user="admin" reads like English |
| Default prompt answer | (custom read logic) | confirm("go?", default=false) |
Intent is in the code |
| Allow command failure | cmd || true |
run("cmd", allow_fail=true) |
Explicit and searchable |
| Preserve environment | sudo -E cmd |
sudo("cmd", E=true) |
No need to remember -E |
| Preserve specific vars | sudo --preserve-env=X,Y |
sudo("cmd", env_keep=["X","Y"]) |
List syntax is clearer |
| Multiple options | sudo -n -u root -E |
sudo(..., n=true, user="root", E=true) |
Order doesn’t matter |
| Capture with failure | out=$(cmd) || true |
capture(run(...), allow_fail=true) |
Explicit control flow |
The philosophy
Shell scripts are often write-once, debug-forever. The original author knows what -n -u root -E means—but the next person doesn’t.
Named arguments shift that knowledge from the author’s head into the code itself. When you write n=true, user="root", E=true, the meaning is there for everyone to see.
That’s the whole point: code that explains itself.
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