sh2lang v0.1.1

Incremental release adding the sudo(...) builtin, confirm(...) helper, and semicolon statement separators.

Added

sudo(...) builtin

Structured wrapper for sudo command execution with type-safe options:

# Basic usage
sudo("apt-get", "update")

# With user option
sudo("systemctl", "restart", "nginx", user="root")

# With environment preservation
sudo("env", env_keep=["PATH", "HOME"])

# Mixed argument ordering supported
sudo(user="admin", "ls", n=true)

Supported options:

  • user (string literal) — run as specified user (-u)
  • n (boolean) — non-interactive mode
  • k (boolean) — invalidate cached credentials
  • prompt (string literal) — custom password prompt (-p)
  • E (boolean) — preserve environment (-E)
  • env_keep (list of string literals) — preserve specific variables (--preserve-env=...)
  • allow_fail (boolean, statement-form only) — non-aborting execution

Behavior:

  • Generates stable flag ordering with mandatory -- separator before command
  • Validates option types at compile time (literals only for user, prompt, env_keep)
  • Rejects duplicate options with clear diagnostics
  • Mixed positional/named argument ordering allowed in all contexts

Diagnostics:

  • Unknown options: "unknown sudo() option 'X'; supported: user, n, k, prompt, E, env_keep, allow_fail"
  • Duplicate options: "user specified more than once"
  • Type errors (per-option literal requirements):
    • user, prompt: "user must be a string literal"
    • n, k, E, allow_fail: "n must be a boolean literal"
    • env_keep: "env_keep must be a list of string literals"
  • Expression-form allow_fail: "allow_fail is only valid on statement-form sudo(...); use capture(sudo(...), allow_fail=true) to allow failure during capture"

Examples:

Basic sudo:

func main() {
    sudo("echo", "hello")
}

With user and options:

func deploy() {
    sudo("systemctl", "restart", "app", user="deploy", E=true)
}

Statement-form with allow_fail:

func cleanup() {
    sudo("rm", "-rf", "/tmp/cache", allow_fail=true)
    if status() != 0 {
        print_err("cleanup failed")
    }
}

confirm(...) helper

Interactive yes/no prompts with optional defaults:

# Basic confirmation
if confirm("Proceed?") {
    run("deploy.sh")
}

# With default value
if confirm("Delete files?", default=false) {
    run("rm", "-rf", "data/")
}

Behavior:

  • Returns boolean result
  • Supports default=true or default=false parameter
  • Non-interactive mode: uses default if provided, otherwise fails
  • Environment override: SH2_YES=1 or SH2_NO=1

Semicolon statement separators

Optional semicolon separators/terminators are now supported in statement blocks:

func main() {
    print("a"); print("b")
}

Semicolons are not allowed inside expressions.

sh2do File Mode

sh2do now supports running .sh2 files directly, in addition to inline snippets.

  • Run a file: sh2do script.sh2
  • Emit mode: sh2do script.sh2 --emit (compiles to script.sh and runs it)
  • Output selection: sh2do script.sh2 -o output.sh (compiles to output.sh and runs it)
  • Runtime selection: --target <bash|posix> and --shell <bash|sh>
  • Safety: Invokes the shell as <shell> -- <script> <args...> to prevent script paths from being interpreted as flags.

No Implicit Expansion

String literals and variables are never automatically expanded (globbed or split) by the shell.

  • run("echo", "*") prints * literal.
  • run("echo", my_var) passes my_var as a single argument, even if it contains spaces.
  • Tilde (~) is treated as a literal character in paths unless explicitly handled.

CWD Semantic Restriction

with cwd(...) accepts an expression syntactically, but the compiler enforces a string-literal-only rule.

  • Valid: with cwd("/tmp") { ... }
  • Invalid: with cwd(my_var) { ... } -> Compile Error:

    cwd(…) requires a string literal path. Computed expressions are not allowed. help: if you need a computed cwd, use run(“sh”, “-c”, …) (and cd inside the shell snippet).

Tilde Hint: If a literal cwd starts with ~ (e.g. with cwd("~/foo")) and the directory change fails at runtime (any non-zero exit), a hint is now printed to stderr:

hint: ‘~’ is not expanded; use env.HOME & “/path” or an absolute path.

Parser Hints

  • Added error hint for missing whitespace around & operator (e.g., let x="a"&"b" or env.HOME&"/path"):

    The & operator requires whitespace: env.HOME & “/x”

Comparisons & Breaking Changes

  • No Implicit Expansion: sh2lang treats all string literals verbatim. ~/foo is a literal path starting with ~. Users must use env.HOME & "/foo" or absolute paths.

Expression Interpolation

  • Expression Interpolation (Limited): $"..." string literals now support evaluating expressions inside {...} holes.
    • Supported: $"Sum: {1 + 2}", $"Cwd: {pwd()}", $"User: {name}".
    • Limitation: String literals inside holes are not yet supported (e.g., $"X: { "value" }" will not compile) due to lexer tokenization constraints. Use variables as a workaround: let v = "value"; print($"X: {v}").
    • Escape literal braces with \{ and \} (e.g., $"Set: \{a, b\}" outputs Set: {a, b}).
    • A future release will address this limitation with lexer redesign to support full expression interpolation.

Pipe Blocks

Support for arbitrary statement blocks in pipelines:

  • pipe { ... } | { ... }
  • run(...) | { ... }
  • pipe { ... } | run(...) Mixed run/block stages are fully supported, with each stage running in an isolated subshell context.

Pipeline Sudo

Pipelines now accept sudo(...) stages:

  • run("cmd") | sudo("cmd", n=true)
  • pipe { ... } | sudo(...) sudo stages participate in the pipeline with correct pipefail and error handling, using the same options as standalone sudo(...).

Predicates

  • Added starts_with(text, prefix) builtin predicate.

Argument Access

  • Added argv() as an alias for args() (returns all arguments as a list).
  • Fixed arg(n) to avoid generating runtime calls to argv command in shell output.

Capture Improvements

  • Fixed capture(..., allow_fail=true) to correctly return captured stdout and update status() without aborting the script on failure.
  • Added support for nested allow_fail option directly on run calls within capture (e.g. capture(run(..., allow_fail=true))), which is hoisted to the capture behavior.
  • Fixed bash codegen so capture(run(...), allow_fail=true) preserves status() (non-zero exit codes no longer clobbered).
  • Clarified that capture(..., allow_fail=true) is only valid in let assignments.
  • Implemented Named Argument Policy (Hardening): name=value arguments are now strictly limited to builtins (run, sudo, sh, capture, confirm). General function calls are restricted to positional arguments, with clear diagnostics for violations.
  • Implemented Strict Command Word Model for $(...): Command substitution now strictly interprets its content as command words. Generic function call shorthand $(func()) is preserved via parser-level flattening to func, but named options are rejected within $(...).
  • Sudo Hardening: Refactored sudo(...) lowering to unconditional inject the -- separator before command arguments in all contexts (including command substitution), preventing flag injection attacks. Flattened sudo arguments in the parser to ensure consistent behavior without double-quoting.
  • Fixed Bash codegen for arg(expr) with dynamic indexes to properly quote arguments passed to __sh2_arg_by_index using a dedicated helper to ensure safe and deterministic forms.
  • Hardened arg(expr) validation: non-integer indices (e.g., strings, nested calls) now produce a compile-time error.

Security & Correctness

  • 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.
  • contains() Type Safety Fix: contains(haystack, needle) now uses robust static type dispatch instead of brittle runtime probing.
    • List Membership: Triggered for list literals, list expressions (e.g. split), and tracked list variables (Bash-only).
    • Substring Search: Default behavior for strings and untracked variables (Portable).
    • Improvements: Removed declare -p probing that caused false negatives. Added support for contains(split(...), ...) via temporary variable materialization.
  • contains_line() Implementation (P0): contains_line(file, needle) now correctly reads file contents with exact-line matching semantics.
    • Behavior: Uses grep -Fqx -e <needle> <file> for exact-line matching within the file.
    • POSIX Portability: Uses -e flag (POSIX-compliant) instead of -- for robust handling of needles starting with -.
    • Use case: Ideal for registry trust checks, configuration validation, or any line-oriented file operations.
  • contains() String Substring POSIX Fix (P1): Fixed contains(string, string) to work correctly in POSIX targets and with needles starting with -.
    • Bug: Previously used non-POSIX grep -Fq -- which failed on POSIX sh and misinterpreted needles like "-b" as flags.
    • Fix: Changed to POSIX-compliant grep -Fq -e matching contains_line() implementation.
    • Impact: contains("a-b-c", "-b") now works correctly in both Bash and POSIX targets.
    • Special characters: All special chars ($, [, ], *, \) continue to be treated literally (fixed-string search).
  • Boolean Encoding Standardization: Boolean variables are now consistently stored as "true" and "false" strings (previously “1”/”0”).
    • Effect: Implicit print/string conversion for booleans is now supported (e.g. print(true) outputs true).
    • Back-compat: Generated conditions still use standard shell logic ([ "$v" = "true" ]). Users relying on internal “1”/”0” representation (undocumented) will be affected.

Changed

  • Parser now allows mixed positional and named arguments for sudo(...) in all syntactic forms (statement, expression, command substitution)
  • Diagnostic spans for option errors now highlight the specific option name rather than the entire expression

Fixed

  • Corrected sudo(...) lowering to use precise option-name spans for error messages
  • Removed legacy “positional arguments cannot follow named options” restriction in command substitution path

Documentation

  • Updated docs/language.md with sudo(...) and confirm(...) sections
  • Updated artifacts/grammar/sh2.ebnf to reflect implemented syntax
  • Updated README.md examples
  • Updated editors/vscode extension for new builtins

Testing

  • Added tests/syntax_sudo.rs with 22 test cases
  • Added tests/codegen_sudo.rs snapshot tests
  • Added tests/syntax_confirm.rs with interactive and non-interactive fixtures
  • All tests pass on both bash and POSIX targets

Full changelog: v0.1.0…v0.1.1