Runic is an experiment in designing a modern scripting language heavily inspired by bash. Its goal is to keep the terseness and command-focused workflow of bash scripts while removing long-standing ergonomics issues around quoting, data safety, and program structure.
Runic is still in an experimental phase, and major breaking changes may occur at any time as the design and implementation evolve.
Runic scripts keep the familiar "just run commands" flow you get in bash, but with clearer data handling and explicit types:
#!/usr/bin/env runic
// receive script arguments
fn Void @(name: String) String
echo "you entered name: ${name}"
// capture stdout and status from a pipeline
const recent = ls "./src" | head "-n" "5"
if (recent) {
echo "first few entries:"
echo "${recent.stdout}"
}
// simple function that reuses existing tools
fn Void check_git() String {
const summary = git "status" "--short"
if (summary.stdout != "") {
echo "working tree changes:"
echo "${summary.stdout}"
}
}
check_git
Bash is ubiquitous, but it is notoriously difficult to reason about:
- implicit stringly typed variables
- confusing quoting rules and word splitting
- inconsistent error handling defaults
- fragile functions and control structures bolted on over decades
Runic is not a shell — it does not replace bash as an interactive environment. It is a scripting language that borrows bash's command-first style and layers in contemporary programming language ideas so scripts are easier to read, write, and verify.
- First-class command execution:
echo "hi"should run exactly as in bash. Command pipelines remain the core abstraction. - Predictable data semantics: typed variables, immutable defaults, and opt-in mutation to avoid accidental string munging.
- Structured flow control: clean function, loop, and conditional syntax without legacy quirks.
- Safer defaults: explicit error propagation, no silent glob expansion surprises, and clear return values.
- Interoperability: seamless spawning of existing binaries and compatibility layers for embedding legacy bash snippets when needed.
- Modern syntax surface: brace-delimited blocks with required keywords, eliminating the mix of
then,fi,do, anddone. Bindings useconst(immutable) andvar(mutable). - Data primitives: strings, numbers, booleans, arrays, and maps with predictable coercion rules and convenient literals. Type names must always begin with a capital letter (
String,Int,Float,Bool,Void) so they stand out from value identifiers. Array and record literals use Zig-style anonymous syntax:.{ "a", "b" }and.{ .key = value }. - Command vs. expression separation: explicit operators differentiate when you're invoking a program versus evaluating a language expression, reducing quoting headaches.
- Functions as pipeline stages: function declarations carry both a stdin type and a stdout type —
fn StdinType name(params) StdoutType { ... }— making data flow through pipelines explicit. Functions are called like commands:check_gitorgreet "world". - Error-aware pipelines: pipeline execution surfaces per-stage exit codes, allowing guarded chaining without
set -efootguns. - Module system: reusable libraries imported with
const m = import "spec", backed by.rn.module.jsonmanifests that describe typed exports.
A working parser, type checker, and IR-based runtime are in place. The following features run end to end:
- Command execution and pipelines (
|,&&,||) constandvarbindings with optional type annotationsif/else,for(range and collection),whilewith capture clauses- Functions with stdin/stdout types, closures, and recursion
- String interpolation (
${ }) and block capture - Process handle access (
.stdout,.stderr,.status.ok,.status.exit_code) - File redirection (
>,>>) and stream capture (1>var,2>var) - Builtin
cdwith subshell-local working-directory updates - Explicit environment access via
$NAME, with mutable updates scoped to the current subshell - Optional types via
?T,null,orelse,.?, and capturedifunwrapping - Script argument handling via the
@entry-point function - Error declarations (
error Foo = enum/union),try/catch, and exact-valuematch - Background process execution via trailing
&, with.waiton captured execution values - Module imports via
import "spec"
Runic aims to be familiar enough that a bash user can start using it immediately, yet principled enough to scale to large automation projects without the typical bash scripting pitfalls.
The runtime and CLI are implemented in Zig (tested with Zig 0.15.1). Zig's build system drives the project layout: src/ hosts the reusable language/runtime modules, while cmd/runic/ exposes the CLI entry point that imports those modules.
- Install Zig 0.15.1 (matching the
build.zigdefaults). Confirm withzig version. - Ensure
zigandzig fmtare on yourPATH; every script inscripts/expects the CLI to be invocable directly. - Optional environment variables
RUNIC_LANG=zigandRUNIC_REPO_ROOT=/path/to/runicare exported automatically byscripts/run_ci.sh, but you can set them manually when calling the stage scripts yourself.
- From the repository root, run
zig build. This produceszig-out/bin/runic. - Use
zig build run -- --help(orzig-out/bin/runic --helpafter building once) to confirm the CLI wiring. - When experimenting with scripts, prefer
zig build run -- path/to/script.rn -- <args>so Zig automatically rebuilds if any sources changed. - For optimized binaries, pass
-Doptimize=ReleaseSafe(orReleaseFast) tozig build. Cross-compilation follows standard Zig flags (-Dtarget=x86_64-linux-gnu). - Build the language server with
zig build runic-lsp. The resultingzig-out/bin/runic-lspbinary speaks LSP over stdio and is ready to be registered with editors. - Keep
ZIG_GLOBAL_CACHE_DIRuntouched so repeated builds share the same cache location (the CI scripts configure it for you).
zig build test— executes every Zigtestblock, covering lexer/parser/runtime modules along with module-loader fixtures. Run this command before sending any PR../scripts/run_ci.sh— enforces formatter → linter →zig build test→ CLI smoke suites (tests/cli_*.sh). Use this script for a full pre-push verification.bash tests/cli_smoke.sh— runs the positive end-to-end feature scripts undertests/features/through the CLI.bash tests/cli_diagnostics.sh— runs the negative CLI diagnostic fixtures undertests/diagnostics/against the builtzig-out/bin/runicbinary, with ANSI stripped before diffing.- Stage-specific reruns:
./scripts/stages/unit_tests.sh,./scripts/stages/cli_smoke.sh, etc., respect the sameRUNIC_REPO_ROOT/RUNIC_LANGenvironment variables. - Add new CLI regression tests by dropping shell scripts under
tests/cli_*.sh. They run insiderun_ci.shand should invokezig build run -- ...to interact with the current binary.
Use optimized binaries for performance work. Debug builds are useful for correctness, but their timings are too noisy to compare with bash or to judge runtime changes reliably.
- Build an optimized CLI:
zig build -Doptimize=ReleaseFast - Run the benchmark harness:
bash scripts/bench.sh - Increase repetitions when comparing close changes:
bash scripts/bench.sh 25
The harness compares Runic and bash on two representative workloads:
tests/benchmarks/command_heavy.*for repeated external-command spawningtests/benchmarks/evaluator_heavy.*for pure arithmetic/control-flow evaluationtests/benchmarks/mixed_loop_exec.*for loops that also spawn commands each iteration
Override the binary path with RUNIC_BIN=/path/to/runic bash scripts/bench.sh if you want to compare multiple builds.
zig fmt src cmd testsformats every Zig source file and is enforced in CI before code review.zig fmt --check src cmd testsruns the same formatter in check mode and doubles as our lint step (scripts treat any diff as a failure).
- Build with
zig build runic-lspto producezig-out/bin/runic-lsp. Editors should launch it with the default--stdiotransport. - Set
RUNIC_LSP_LOG=1when debugging conversations; logs go to stderr so protocol responses on stdout remain untouched. - A placeholder
--tcp <port>flag exists for upcoming transport introspection. Until then, prefer stdio and invokezig-out/bin/runic-lsp --stdiodirectly when iterating locally.
Run ./scripts/run_ci.sh before pushing changes. The script enforces the formatter → linter → unit tests → CLI smoke tests flow described in docs/plan.md and automatically selects the toolchain (currently Zig because build.zig is present):
- Formatter — runs
zig fmtacrosssrc/,cmd/, andtests/. - Linter — runs
zig fmt --checkon the same set of Zig sources so misformatted files fail the build. - Unit tests — executes
zig build test(without changing whateverZIG_GLOBAL_CACHE_DIRis already exported), which compiles the runtime modules alongside the CLI and runs alltestblocks. - CLI smoke tests — executes every shell script that matches
tests/cli_*.sh. Today that includes:tests/cli_smoke.shfor successful end-to-end script execution andtests/cli_diagnostics.shfor expected compiler/runtime diagnostics.
Each stage stops the pipeline on failure so contributors get immediate feedback. Extend the stage scripts under scripts/stages/ if a different toolchain or extra checks are required.
Each stage script expects RUNIC_REPO_ROOT to point at the repository root and RUNIC_LANG to describe the toolchain; scripts/run_ci.sh exports both before invoking a stage. You can rerun an individual phase by calling, for example, RUNIC_LANG=zig RUNIC_REPO_ROOT=$PWD ./scripts/stages/unit_tests.sh when iterating on a specific failure.
Runic modules are regular .rn files paired with JSON manifests so the loader can expose typed exports to consumers:
- Place the implementation at
<script_dir>/<spec>.rn(e.g.scripts/net/http.rn) and keep the spec lowercase with/separators. - Create
<script_dir>/<spec>.rn.module.jsonto declare the module's public surface. Each entry in theexportsarray is either a function (withparams,return_type, and optionalis_async) or a value with atypedescriptor. - Supported type descriptors include
primitive,array,map,optional, andpromise, matching the Zig-side parser insrc/runtime/module_loader.zig. - Import the module from scripts using
const http = import "net/http". Supplement search paths with--module-path <dir>when iterating on modules stored outside the importing script's directory. - Validate manifests by running
zig build test(the module loader has dedicated fixtures) and by invoking a small script viazig build run -- examples/<script>.rn.
See docs/module_authoring.md for the full manifest schema, type descriptor explanations, and a sample manifest.
Runic keeps the familiar pipeline mindset while removing bash-specific hazards. To migrate existing scripts:
- Port declarations/functions to typed Runic syntax (
const,var,fn) before touching command pipelines so behavior stays verifiable. - Replace
source-style helper files with modules plus manifests (described above) to surface typed APIs. - Handle failures with
try/catchand typed status objects rather thanset -eand$?. - Exercise the CLI via
zig build run -- path/to/script.rn --trace pipelineand add CLI smoke tests totests/as soon as a migration lands.
docs/migrating-from-bash.md includes a checklist that walks through the recommended workflow, highlights common traps, and links back to features.md entries that replace legacy bash behaviors.
The cmd/runic binary accepts either a script path or --eval and exposes switches for tracing, module lookup paths, and environment overrides. Script execution parses .rn files directly, honors import/module lookups, const/var bindings, and pipelines, and forwards diagnostics and command output through the IR runtime.
A typical invocation looks like:
# run a script with inline args
zig build run -- path/to/script.rn --trace parser --module-path ./lib -- --flag value
# evaluate inline source
zig build run -- --eval 'echo "${1 + 2}"'Key flags:
--help,-h— show the usage summary (also the default when no arguments are provided).--trace <topic>— enable structured tracing for the given runtime subsystem. Current topics arepipeline(per-stage spawn/exit),process(handle summaries, stage outcomes, and captured IO sizes), andasync(background task lifecycle). Repeat the flag to collect multiple targets (e.g.--trace pipeline --trace process).--module-path <dir>— prepend an additional directory to the module loader search roots. This mirrorsconst foo = import "custom/foo"scenarios fromfeatures.md.--env KEY=VALUE— seed or override environment bindings for the initial script context. Read them explicitly as$NAME, reassign them with$NAME = ..., and child processes inherit the current subshell-local values.
After the script path, runic forwards every argument verbatim. Insert -- between the script and its arguments when you need to pass values that look like CLI flags.
Tracing is the fastest way to inspect how Runic pipelines, background tasks, and process handles behave while the runtime is still under construction. Enable one or more topics via --trace:
pipeline— prints[trace pipeline]records when a pipeline starts, when each stage exits (including PID, status, duration, and capture sizes), and when the pipeline finishes.process— captures the resulting handle summary: PID, duration, failed stage (if any), and a per-stage status recap. This is useful when destructuring handles in scripts.async— follows background execution lifecycle. Every detached task logs its spawn, completion/error, and the resolved handle summary so you can see exactly when background work finishes.
Use these switches with either a script path or --eval to watch commands execute in real time.
Sample Runic scripts live under examples/. Run them with zig build run -- examples/<script>.rn.
examples/pipelines_and_handles.rn— command pipelines, process handle inspection (.stdout,.stderr,.status), and&&/||chaining. Use--trace pipeline --trace processto see per-stage detail.examples/data_and_flow.rn—const/varbindings with type annotations,.{ }array literals,forloops over ranges and arrays, arithmetic, and comparisons.examples/functions_and_closures.rn— function declarations with stdin/stdout types, single-expression bodies, closures over outer variables, and recursion.
Syntax highlighting for Runic lives under editor/neovim/. Run
./scripts/neovim_plugin.sh install to symlink the plugin into your local
nvim/site/pack tree (customize the destination via --pack-root, --slot, or
--target). Call ./scripts/neovim_plugin.sh open-example to launch Neovim
with the plugin preloaded and one of the sample programs from examples/*.rn.
See editor/neovim/README.md for additional commands, a lazy.nvim spec
snippet, and troubleshooting tips.