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", resolved relative to the importing file, withpubdeclarations exposed on the imported value.
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
- Typed pipeline boundary enforcement: the type checker validates that the
upstream stdout type matches the downstream stdin type at every
|boundary. Mismatches are caught before execution. Function bodies are also checked against their declared stdin/stdout contracts. - File-descriptor streams (
&0/&1/&2):&0reads the function's stdin as a typed value (aString, or a parsedInt), enabling pure Runic processing without relying on executables likecat.&1/&2are stdout and stderr. yieldfor explicit output: functions push values withyield(to stdout by default, oryield &2 ...for stderr); a function'sreturn/body value is not auto-pushed, so a stage that consumes its input without yielding produces no output.- 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.rn, 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.bash scripts/run_ci.sh— preferred full pre-push verification. This wrapsrunic scripts/run_ci.rn, verifies that the Runic-driven path emits expected progress markers, and falls back to the direct shell stages if the Runic path regresses.zig build run -- scripts/run_ci.rn— direct Runic CI orchestration path. Use this when debugging the Runic implementation itself.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 through the CI wrapper and the Runic CI script, and 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 bash scripts/run_ci.sh before pushing changes. The wrapper prefers the Runic CI entrypoint, verifies that it actually produces expected output, and falls back to the direct shell stages if the Runic path regresses. For debugging the Runic implementation itself, you can still run zig build run -- scripts/run_ci.rn directly. The CI flow enforces formatter → linter → unit tests → CLI smoke tests 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; the shell wrapper provides both for the fallback path, and scripts/run_ci.rn attempts to export 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.
Contributions are welcome, but Runic is still changing quickly, so it helps to start from the current implementation and roadmap rather than older design notes.
- Read CONTRIBUTING.md for contributor workflow, testing expectations, and PR guidance.
- Read docs/plan.md for the current roadmap.
- Read todo.md for lower-level backlog items and known bugs.
In general, the most useful contributions are focused bug fixes, regression tests, roadmap-aligned improvements, and documentation updates that match the current behavior.
Runic modules are currently regular .rn files imported from other .rn
files:
- Place the implementation at
<script_dir>/<spec>.rnand import it withconst m = import "spec.rn"or another relative spec that resolves from the importing file. - Keep imported modules parameterless. A file that declares parameters in its
@(...)signature cannot currently be imported. - Export reusable bindings with
pubso they are visible on the imported value. - Be aware that importing a module executes its top-level body, captures its execution result, and exposes both execution-result fields and
pubdeclarations on the imported value. - Validate module behavior by running
zig build testand by invoking a small importing script viazig build run -- path/to/script.rn.
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 plain.rnmodules imported viaimport "...", and move reusable entry points behindpubdeclarations. - 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.