Dockerfile inspired build DSL for Rust
OxDock is a Dockerfile-inspired build DSL for Rust: scripted pipelines with hermetic workspaces, typed variables, and pipes instead of snowflake shell. Embed scripts at compile time with macros, or run the same scripts as standalone CLI pipelines. Native. No containers. No daemon. No VM.
One script runs on Linux, macOS, and Windows, with platform gating, async tasks, and piped workflows for custom pipelines. Only RUN touches the host shell.
Plain Rust functions become script functions with one attribute: #[oxdock_func] exports them into namespaced modules scripts call as DEMO::NAME(...). See Extending OxDock from Rust.
Jump to the command reference below for the full command list with runnable examples.
Add it to your Rust build with cargo add [email protected], or install the standalone runner with cargo install [email protected].
Run a script:
oxdock <PATH>Scripts run during rustc, and their artifacts ship inside the binary with zero heap allocation, no_std included:
use oxdock_macros::oxdock_embed;
oxdock_embed! {
// Embedded resources are mapped to `SiteAssets::get(resource)`
name: SiteAssets,
script: {
// Scripts run in an ephemeral snapshot workspace: every command
// sees an isolated temp dir, so the local checkout stays untouched
// unless the script opts in with WORKSPACE LOCAL. Finished assets
// are staged to out_dir below, where rustc scoops them up with
// include_bytes!.
ENV PROJECT=OxDock
MKDIR dist
// Provenance comes from the shell: only the matching gate runs,
// so this stays green on every OS in CI.
[unix] LET $os: STRING = RUN uname -srm
[windows] LET $os: STRING = RUN ver
LET $toolchain: STRING = RUN cargo --version
WRITE dist/os.txt "{{ $os }}"
WRITE dist/toolchain.txt "{{ $toolchain }}"
WRITE dist/manifest.txt "os toolchain"
},
// Generated assets land under target/, keeping the source tree clean
out_dir: "target/prebuilt",
}
fn main() {
// Verify we can read the resources we just created
let manifest = SiteAssets::get("dist/manifest.txt").expect("manifest must be embedded");
assert_eq!(manifest.data.as_ref(), b"os toolchain");
let toolchain = SiteAssets::get("dist/toolchain.txt").expect("toolchain must be embedded");
assert!(toolchain.data.starts_with(b"cargo "));
let os = SiteAssets::get("dist/os.txt").expect("os must be embedded");
assert!(!os.data.is_empty());
}For each artifact the macro emits a constant backed by include_bytes!, which bakes the file bytes into read-only binary data during compilation. At runtime get() scans a static table and returns a borrowed slice, so there are no file reads and no heap allocation. The support types only need alloc::borrow::Cow and core iterators, which is why it works in no_std.
Asset scripts resolve STD (via IMPORT [STD]) and SCRIPT functions only. There is no modules: prefix here, and that is structural, not missing: opaque modules defer membership to runtime, but asset scripts execute at compile time with no Engine to resolve against. Scripts needing host functions belong in build.rs through the Engine facade instead.
The oxdock! macro builds the same DSL into a Vec<Step> at compile time, so tests and tools can run scripts without a file. Pass the steps to a run_steps_* runner with a guarded root. The root types live in oxdock-fs, so add both crates: cargo add oxdock oxdock-fs. Only portable commands are used below, so the script behaves identically on every OS.
use oxdock::{oxdock, oxdock_parser, run_steps_with_context};
use oxdock_fs::{GuardedPath, PathResolver};
// A version stamping pipeline: variables, a function, a loop over a list,
// a conditional call, templates, and native assertions. The version comes
// from Cargo at compile time, never a literal.
let crate_version = env!("CARGO_PKG_VERSION");
let steps: Vec<oxdock_parser::Step> = oxdock! {
ENV PROJECT=OxDock
LET $version: STRING = #crate_version
MKDIR dist
FUNC STAMP($name: STRING) {
WRITE dist/{{ $name }}.txt {{ $name }} {{ env:PROJECT }} {{ $version }}
RETURN $name
}
FOR $name: STRING IN ["alpha", "beta"] {
STAMP($name)
}
FUNC PICK($flag: BOOL) {
IF $flag {
RETURN "alpha"
}
RETURN "beta"
}
LET $picked: STRING = PICK(true)
WRITE dist/picked.txt {{ $picked }}
LET $a: STRING = READ dist/alpha.txt
LET $b: STRING = READ dist/beta.txt
LET $p: STRING = READ dist/picked.txt
ASSERT_EQ $a "alpha OxDock 0.18.1-alpha"
ASSERT_EQ $b "beta OxDock 0.18.1-alpha"
ASSERT_EQ $p "alpha"
};
let temp = GuardedPath::tempdir().expect("tempdir");
let root = temp.as_guarded_path().clone();
run_steps_with_context(&root, &root, &steps).expect("run script");
let resolver = PathResolver::new(root.as_path(), root.as_path()).expect("resolver");
let out = root.join("dist/alpha.txt").expect("out path");
assert_eq!(
resolver.read_to_string(&out).expect("read out"),
"alpha OxDock 0.18.1-alpha"
);Use #var to inject Rust values into the script (any value that implements Display). DSL variables keep their $var form and are unaffected. Guards accept injected values too, so Rust flags can gate steps. The script below wires a pipe between steps, reads one line back into a variable, and expands every generated file.
use oxdock::{oxdock, oxdock_parser, run_steps_with_context};
use oxdock_fs::{GuardedPath, PathResolver};
let project = "OxDock";
let verbose = true;
let steps: Vec<oxdock_parser::Step> = oxdock! {
ENV PROJECT=#project
MKDIR dist
[bool:#verbose] WRITE dist/verbose.log "verbose on"
LET $log: PIPE
WITH_IO [stdout=$log] ECHO "built {{ env:PROJECT }}"
WITH_IO [stdin=$log] READ_LINE $line
WRITE dist/build.txt "{{ $line }}"
IMPORT [STD]
FOR $f: STRING IN GLOB("dist/*.txt") {
EXPAND $f
}
ASSERT_CONTAINS stdout "built OxDock"
LET $build: STRING = READ dist/build.txt
LET $verbose: STRING = READ dist/verbose.log
ASSERT_EQ $build "built OxDock"
ASSERT_EQ $verbose "verbose on"
};
let temp = GuardedPath::tempdir().expect("tempdir");
let root = temp.as_guarded_path().clone();
run_steps_with_context(&root, &root, &steps).expect("run script");
let resolver = PathResolver::new(root.as_path(), root.as_path()).expect("resolver");
let out = root.join("dist/build.txt").expect("out path");
assert_eq!(
resolver.read_to_string(&out).expect("read out"),
"built OxDock"
);The top level runners need the default cli feature. With --no-default-features, run the same steps through oxdock::oxdock_core::run_steps_* instead.
Scripts that call host functions declare their modules up front: the macro parses at compile time with only STD known, so modules: [DEMO], as the first line makes IMPORT [DEMO] and DEMO::... calls resolve (membership is checked at runtime). Scripts using only STD and SCRIPT functions omit it. See Extending OxDock from Rust for the complete example.
New types and functions take two attributes, a type registration, and a function module. Small
Copy scalars can ride inline on the stack with zero allocation
instead of heap boxing; both forms, with stateful functions, live under
Extending OxDock from Rust below.
use oxdock::{Engine, HostModule, OxDockFn, OxDockType, Value, oxdock_func, oxdock_type};
use std::fmt;
/// Word count summary: computed in Rust, carried as one script value.
#[oxdock_type(name = "STATS")]
#[derive(Debug, Clone, PartialEq)]
struct Stats {
words: usize,
chars: usize,
}
impl fmt::Display for Stats {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{} words, {} chars", self.words, self.chars)
}
}
/// Summarize a string: `STATS("hello brave world")` carries its counts.
#[oxdock_func(pure, name = "STATS")]
fn summarize(text: String) -> anyhow::Result<Value> {
let words = text.split_whitespace().count();
let chars = text.chars().count();
Ok(Value::mint_heap(
Stats::descriptor(),
Stats { words, chars },
))
}
/// Read the word count back out: `WORD_COUNT($s)` is an `INT`.
#[oxdock_func(pure, returns = "INT")]
fn word_count(summary: Value) -> anyhow::Result<Value> {
let Some(stats) = summary.read_heap::<Stats>(Stats::descriptor()) else {
anyhow::bail!("WORD_COUNT() expects a STATS value");
};
Ok(Value::int(stats.words as i64))
}
fn main() -> anyhow::Result<()> {
let mut engine = Engine::new();
engine.register_type::<Stats>();
engine.register_module(HostModule {
name: "DEMO".to_string(),
funcs: vec![Summarize::registration(), WordCount::registration()],
types: vec![],
});
let temp = oxdock_fs::GuardedPath::tempdir().unwrap();
let root = temp.as_guarded_path().clone();
let steps: Vec<oxdock::oxdock_parser::Step> = oxdock::oxdock! {
modules: [DEMO],
IMPORT [DEMO]
LET $s: STATS = STATS("hello brave world")
LET $n: INT = WORD_COUNT($s)
ASSERT_EQ $n 3
};
let run = engine.run_steps(&root, &steps)?;
assert_eq!(format!("{}", run.bindings["s"]), "3 words, 17 chars");
Ok(())
}The same script also runs standalone through the CLI. It builds artifacts and verifies them with native assertions. Every fenced oxdock example in this README is executed against the implementation by crates/oxdock-logic-tests/tests/docs_conformance.rs, so what you read here is guaranteed to match what the DSL actually does:
// Script-local variable: usable by templates and guards below.
ENV PROJECT=OxDock
// Creates the directory and any missing parents.
MKDIR dist
// Interpolate the variable into the file body via a template.
WRITE dist/hello.txt Built with {{ env:PROJECT }}
// Fail the script unless the artifact exists with exactly these bytes.
LET $body: STRING = READ dist/hello.txt
ASSERT_EQ $body "Built with OxDock"
// LS prints "<dir>:" then the entry names.
LS dist
ASSERT_CONTAINS stdout "dist:"
ASSERT_CONTAINS stdout "hello.txt"
Save the script above as ./build.oxfile and run it by path (install once, see above):
oxdock ./build.oxfileFour mechanisms keep script execution predictable: how values are stored, how bytes move between commands, how state stays isolated, and how the host stays sandboxed. Each one is shown running below.
Every script value is a fixed 128 bit word that lives on the stack: a static type descriptor pointer plus a 64 bit payload. Only payload contents may live on the heap, never the word itself.
Scalars that fit (INT, FLOAT, BOOL, handles) ride inline, copied into the payload byte for byte with zero allocation, so arithmetic and loop counters run at register speed and never touch the allocator. Strings and host types ride behind a thin pointer to a single owned box holding the concrete Rust type, while containers (LIST, MAP) ride behind a thin pointer to a shared reference counted buffer. The pointer stays thin because every payload type is sized, so there are no fat pointers and no trait objects anywhere in the path.
A naive tagged enum needs 32 bytes per value (a 24 byte payload plus tag and padding), so the word form holds four values per 64 byte cache line instead of two.
Type checks compare one descriptor address, and operations (clone, drop, equality, formatting) call the descriptor directly, with no registry lookup and no lock. Each type owns one compile time descriptor singleton, so identity is pointer equality that fails closed. Host types extend the same path: #[oxdock_type] derives a static descriptor for the payload struct, and inline selects the zero allocation form for small Copy scalars.
There is no garbage collector because values form trees, not graphs. Each exclusive heap word owns its box exactly once: cloning allocates a fresh box with a deep copy, dropping frees it. Each container word co-owns its buffer instead: cloning a LIST or MAP bumps a reference count in constant time with no allocation, dropping releases one count. A LIST owns its items and a MAP owns its entries.
Nothing is mutably borrowed from two places, so cycles cannot form and plain deterministic cleanup suffices. Pointer casts always round trip through the same concrete box or buffer type, and inline words never enter the pointer domain, which keeps provenance intact. The lifecycle is checked under Miri.
No pauses, no write barriers, no background collector. Python, JavaScript, and Lua permit aliasing and cycles and need tracing collectors to reclaim them; here there is nothing to trace.
| Design | Stack word | Scalar allocs | Cache density | Host extensibility |
|---|---|---|---|---|
| Tagged Rust enum | 32 bytes | 0 | 2 values per line | Closed |
| Boxed trait objects | 16 byte fat pointer | 1 per scalar | Medium | Open |
| 16 byte word (this design) | 16 bytes | 0 | 4 values per line | Open, static descriptors |
| NaN boxing (LuaJIT, V8) | 8 bytes | 0 | 8 values per line | Constrained |
Two trade offs come with the form. Cloning a string still deep copies it, since only containers share buffers. And 16 bytes is roomier than 8 byte NaN boxing, which buys clean 64 bit integer and float storage plus safe abstraction boundaries without pointer masking.
A command's standard streams can be rerouted through pipes declared with LET $p: PIPE, so producers and consumers connect without touching the terminal or temp files. Buffers stay in memory and spill to a guarded temp file past 8 MiB, and background single command tasks can promote a pipe to a zero copy OS kernel pair instead.
LET $log: PIPE
WITH_IO [stdout=$log] ECHO hello
WITH_IO [stdin=$log] READ_LINE $line
ASSERT_EQ $line "hello"
State mutations stay where the script puts them. Entering a braced block or a function call snapshots variables and settings, and exiting restores all of them, so nothing leaks outward. Background tasks fork the same way, so concurrent workers cannot observe each other's half finished mutations. Only pipes and filesystem effects cross these boundaries, by design.
# Calls snapshot caller state: the parameter shadows without clobbering.
LET $v: STRING = "outer"
FUNC SHADOW($v: STRING) {
LET $inner: STRING = "inner"
RETURN $v
}
LET $out: STRING = SHADOW("param")
ASSERT_EQ $out "param"
ASSERT_EQ $v "outer"
# Background tasks fork the same way: worker mutations never escape.
LET $w: STRING = "outer"
LET $t: HANDLE = ASYNC {
$w = "inner"
ECHO "task-ran"
}
AWAIT $t
ASSERT_EQ $w "outer"
ASSERT_CONTAINS stdout "task-ran"
Every path resolves inside a guarded workspace root, and escapes are rejected before any filesystem call. Scripts start with only builtin keys (Cargo feature/cfg entries, WORKSPACE_GIT_COMMIT) and opt into host variables explicitly.
WRITE ../escape.txt "nope"
oxdock_embed! ships artifacts inside the binary. oxdock_prepare! runs the same script but emits no runtime module. Use it when assets only need to exist during the build, for codegen or include! workflows.
use oxdock_macros::oxdock_prepare;
oxdock_prepare! {
name: PreparedAssets,
script: {
MKDIR gen
WRITE gen/out.txt generated
LET $o: STRING = READ gen/out.txt
ASSERT_EQ $o "generated"
},
out_dir: "target/prebuilt_prepare",
}
fn main() {}WITH_IO routes a step's stdout into a script pipe and back into another step's stdin. Run the producer under ASYNC so both ends stay live while bytes flow.
LET $msg: PIPE
WITH_IO [stdout=$msg] ASYNC ECHO piped-bytes
WITH_IO [stdin=$msg] WRITE piped.txt
READ piped.txt
ASSERT_CONTAINS stdout "piped-bytes"
Scripts start in the SNAPSHOT workspace, an ephemeral isolated temp dir that leaves the source tree untouched. It is the only ephemeral root. Pull inputs with COPY or COPY_GIT. Each root below is entered inside a scoped block, which reverts to the previous root on exit.
IMPORT [STD]
# SNAPSHOT is the starting root.
WRITE snap.txt from-snapshot
LET $s: STRING = READ snap.txt
ASSERT_EQ $s "from-snapshot"
# LOCAL is a separate directory: snapshot files are absent there.
[bool:true] {
WORKSPACE LOCAL
LET $t: STRING = PATH_TYPE("snap.txt")
ASSERT_EQ $t "absent"
WRITE local.txt from-local
}
# The block reverted to SNAPSHOT: the local file is absent here.
LET $u: STRING = PATH_TYPE("local.txt")
ASSERT_EQ $u "absent"
# CACHE persists outside the snapshot and reads back through its own root.
[bool:true] {
WORKSPACE CACHE
WRITE cached.txt from-cache
}
COPY --from-workspace CACHE cached.txt restored.txt
LET $w: STRING = READ restored.txt
ASSERT_EQ $w "from-cache"
# CACHE --local is a different directory from the OS cache.
[bool:true] {
WORKSPACE CACHE --local
LET $x: STRING = PATH_TYPE("cached.txt")
ASSERT_EQ $x "absent"
}
# SYSTEM keeps the current directory: relative paths keep working.
# Scripts using it are not hermetic.
[bool:true] {
WORKSPACE SYSTEM
WRITE sys.txt from-system
}
LET $y: STRING = READ sys.txt
ASSERT_EQ $y "from-system"
Scripts call host functions and custom types that Rust code registers
under a module. Registration is a type plus a module on one facade, and
scripts name the module: IMPORT [DEMO] lets the rest call
MAKE_TAG() bare, or qualify as DEMO::MAKE_TAG(). This example runs as
written: the shape first, the definitions it names right below it.
use oxdock::{Engine, HostModule, OxDockFn, OxDockType, oxdock_func, oxdock_type};
use std::fmt;
// The script below is the DSL itself, not a string: the `oxdock!` macro
// builds it into steps at compile time.
fn main() -> anyhow::Result<()> {
let mut engine = Engine::new();
engine.register_type::<Tag>();
engine.register_module(HostModule {
name: "DEMO".to_string(),
funcs: vec![MakeTag::registration(), ReadTag::registration()],
types: vec![],
});
let temp = oxdock_fs::GuardedPath::tempdir().unwrap();
let root_path = temp.as_guarded_path().clone();
let steps: Vec<oxdock::oxdock_parser::Step> = oxdock::oxdock! {
modules: [DEMO],
IMPORT [DEMO]
LET $t: TAG = MAKE_TAG()
LET $s: STRING = READ_TAG($t)
ASSERT_EQ $s "demo"
WRITE tag.txt "{{ $s }}::{{ $t }}"
};
let run = engine.run_steps(&root_path, &steps)?;
assert!(run.bindings.contains_key("s"));
let reader = oxdock_fs::PathResolver::new(root_path.root(), root_path.root()).unwrap();
let written = reader
.read_to_string(&root_path.join("tag.txt").unwrap())
.expect("script writes tag.txt");
assert_eq!(written.trim(), "demo::tag:demo");
Ok(())
}
// The type. `#[oxdock_type]` implements `OxDockType` on the payload struct
// itself. The default is the heap path: the word holds a thin pointer to
// an owned box. Heap payloads require `Clone + PartialEq + Display +
// Debug + Send + Sync`.
/// Opaque label type.
#[oxdock_type(name = "TAG")]
#[derive(Debug, Clone, PartialEq)]
struct Tag(String);
impl fmt::Display for Tag {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "tag:{}", self.0)
}
}
// The functions. `#[oxdock_func(pure)]` implements `OxDockFn` on a
// registration marker named after the function (`make_tag` becomes
// `MakeTag`). The DSL name defaults to the uppercased Rust name.
// `Value::mint_heap` with the payload type's own descriptor mints a word
// of the registered type, and `read_heap` with that descriptor reads it
// back. No name lookup runs anywhere on this path.
/// Mint one opaque label.
#[oxdock_func(pure)]
fn make_tag() -> anyhow::Result<oxdock::Value> {
Ok(oxdock::Value::mint_heap(Tag::descriptor(), Tag("demo".into())))
}
/// Read the payload back out through a descriptor-checked typed read.
#[oxdock_func(pure, returns = "STRING")]
fn read_tag(val: oxdock::Value) -> anyhow::Result<oxdock::Value> {
let Some(tag) = val.read_heap::<Tag>(Tag::descriptor()) else {
anyhow::bail!("READ_TAG() expects a TAG value");
};
Ok(oxdock::Value::string(tag.0.clone()))
}The shape is always the same. LET $t: TAG = MAKE_TAG() calls a host
function like any native one: arity, depth budget, and the declared TAG
coercion apply uniformly. READ_TAG($t) reclaims the payload through an
id checked read. {{ $t }} renders the custom value through its
Display, so interpolation, WRITE, and equality treat host values like
native ones. TYPES() lists every registered name and
TYPE_DESCRIBE("TAG") returns its summary and docs, so scripts introspect
host surface exactly like native surface. Host types stay opaque:
literal syntax, $var.key traversal, and FOR iteration remain LIST
and MAP only, so queryable containers expose host accessor functions
(MATRIX_GET($m, $row, $col)) instead of new syntax.
Without pure, the first parameter must be cx: &mut StepCtx<P>, which
exposes variables, environment, pipes, and IO. Override the DSL name and
the declared return type explicitly when the defaults do not fit:
use oxdock::{HostModule, OxDockFn, StepCtx, oxdock_func};
use oxdock::oxdock_core::ProcessManager;
/// Read an environment variable, defaulting to empty.
#[oxdock_func(name = "ENV_OR", returns = "STRING")]
fn env_or<P: ProcessManager>(
cx: &mut StepCtx<P>,
key: String,
) -> anyhow::Result<oxdock::Value> {
Ok(oxdock::Value::string(cx.get_env(&key).unwrap_or_default()))
}
fn main() {
let mut engine = oxdock::Engine::new();
engine.register_module(HostModule {
name: "DEMO".to_string(),
funcs: vec![EnvOr::registration()],
types: vec![],
});
}Parameters accept Value, String, i64, f64, and bool, in either
form. Stateful markers register exactly like pure ones. Pure functions run
on the compiled math path too; stateful ones stay on the AST path unless
they opt in with #[oxdock_func(rpn)] (as GLOB does).
#[oxdock_type(inline)] selects the zero allocation path for Copy
scalars that fit in 64 bits: the word holds the value bytes directly. This
is the same derivation the startup integer, float, boolean, and handle
types use:
use oxdock::{OxDockType, oxdock_type};
use std::fmt;
/// Entity handle.
#[oxdock_type(name = "ENTITY", inline)]
#[derive(Debug, Clone, Copy, PartialEq)]
struct EntityId(u64);
impl fmt::Display for EntityId {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "entity#{}", self.0)
}
}
fn main() {
let mut engine = oxdock::Engine::new();
engine.register_type::<EntityId>();
let val = oxdock::Value::mint_inline(EntityId::descriptor(), EntityId(7));
assert_eq!(val.inline_bits(), 7);
assert_eq!(format!("{val}"), "entity#7");
assert_eq!(val.clone(), val);
}Four limits decide what can ride inline:
- Fixed 64 bit slot. Anything larger panics at mint time (a runtime check, not a compile time one).
Copypayloads only. Inline clones copy bits and drops do nothing, which resource owning types cannot satisfy.- No variable length data.
STRING,LIST, andMAPcan never fit a fixed slot and stay behind the pointer. - Widening punishes everything. A bigger slot means fewer words per cache line and costlier moves for the scalars that dominate scripts.
One boundary to keep straight: words stored inside a LIST or MAP
live in that container's heap buffer, so inline describes the payload
encoding, not a promise that every word sits on the stack.
Opaque types can still answer queries: pair the payload with host
accessor functions and scripts read cells, lengths, and keys through
ordinary calls, with no new syntax. The example below mints a grid and
reads one cell through its accessor; from a script the same call spells
LET $c: INT = MATRIX_GET($m, 0, 1). Key paths and FOR iteration stay
unavailable by the boundary above.
use oxdock::{HostModule, OxDockFn, OxDockType, Value, oxdock_func, oxdock_type};
use std::fmt;
/// Integer grid with no literal syntax: scripts query it through functions.
#[oxdock_type(name = "MATRIX")]
#[derive(Debug, Clone, PartialEq)]
struct Matrix(Vec<Vec<i64>>);
impl fmt::Display for Matrix {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "matrix[{}x{}]", self.0.len(), self.0.first().map_or(0, Vec::len))
}
}
/// Mint a fixed 1x2 grid.
#[oxdock_func(pure)]
fn make_matrix() -> anyhow::Result<Value> {
Ok(Value::mint_heap(
Matrix::descriptor(),
Matrix(vec![vec![1, 2]]),
))
}
/// Read one cell by row and column.
#[oxdock_func(pure, returns = "INT")]
fn matrix_get(board: Value, row: i64, col: i64) -> anyhow::Result<Value> {
let Some(grid) = board.read_heap::<Matrix>(Matrix::descriptor()) else {
anyhow::bail!("MATRIX_GET() expects a MATRIX value");
};
let cell = usize::try_from(row)
.ok()
.and_then(|r| grid.0.get(r))
.and_then(|r| usize::try_from(col).ok().and_then(|c| r.get(c)))
.copied()
.ok_or_else(|| anyhow::anyhow!("index out of bounds"))?;
Ok(Value::int(cell))
}
fn main() -> anyhow::Result<()> {
let word = Value::mint_heap(Matrix::descriptor(), Matrix(vec![vec![1, 2]]));
let cell = matrix_get(word, 0, 1).unwrap();
assert_eq!(cell.as_i64(), Some(2));
let mut engine = oxdock::Engine::new();
engine.register_type::<Matrix>();
engine.register_module(HostModule {
name: "DEMO".to_string(),
funcs: vec![
MakeMatrix::registration(),
MatrixGet::registration(),
],
types: vec![],
});
let temp = oxdock_fs::GuardedPath::tempdir().unwrap();
let root = temp.as_guarded_path().clone();
let run = engine.run_script(
&root,
"IMPORT [DEMO]\nLET $m: MATRIX = MAKE_MATRIX()\nLET $c: INT = MATRIX_GET($m, 0, 1)\n",
)?;
assert_eq!(run.bindings["c"].as_i64(), Some(2));
Ok(())
}Two evaluators run scripts, and the distinction decides where a host
function may run. The AST evaluator walks the parsed tree one step at a
time. Every step knows its line number, so failures name it (step 1: INT() expects 1 argument(s), got 2). Scopes, pipes, declarations, and
every statement form live here. The RPN evaluator runs arithmetic and
comparison expressions compiled to a flat stack-machine program
(PushConst, LoadVar, Call, Add, ...). A stack program carries
values only: no statements, no scopes, no pipes, and no step numbers, so
a failing call inside math reports the bare error (INT() expects 1 argument(s), got 2).
The tradeoff is expressiveness against compactness, not speed. The tree can say anything the language can say, with errors that point at the script. The stack program can only compute values, which is exactly what math needs and nothing more. That is why not everything runs on RPN: statements, declarations, scoping, and IO orchestration have no stack encoding, and step-numbered errors require the tree.
For host functions the rule follows from that split. Pure functions take
only values and touch nothing, so they run on both paths with no flag.
Stateful functions default to AST-only. Opt in with
#[oxdock_func(rpn)] only for read-only queries that stay meaningful
inside math (GLOB, LOAD_TOML, LOAD_JSON do this): the function
still receives full step context, but its failures lose their step
numbers. Side-effecting stateful functions stay out, since stack-order
execution with step-less errors is the worst place for an effect to go
wrong.
Compared against embedding Rust in Python, there are three structural reasons the host boundary stays small, and none of them are API polish.
Embedding Rust in Python means linking an interpreter, managing the
GIL, and marshaling across two object models with different lifetimes.
OxDock functions are plain Rust functions returning Result<Value>:
the VM is just the calling convention, and values are fixed size words
with deterministic lifetimes, so there is nothing to pin, nothing
reference counted, and nothing kept alive across the boundary.
The Python path needs module registration plus type conversions
negotiated with dynamic types. #[oxdock_func] derives the
registration marker, the arity gate, and the String, i64, f64,
bool, and Value extraction from the signature, and doc comments
become introspectable metadata for free.
No maturin, no ABI tags, and no wheels built per interpreter. The host
crate depends on oxdock-core and calls Engine::register_module.
The one thing Python still wins is its C ABI as a stable interop target
for other languages. The OxDock boundary is Rust only, which is exactly
what keeps it cheap.
One language for the whole build: farm steps out to npm, bundlers, or code generators and pull their artifacts back under cargo's control. Pipe bytes between steps (buffered in memory to 8 MiB, then spilled to a temp file), fan work out with ASYNC, or skip embedding entirely and run the same scripts as standalone CLI processes.
OxDock comes in two variants, each of which is independent of the other, but share the same core:
- oxdock-macros: Provides a Rust build-time dependency which runs OxDock scripts during the compilation of a Rust program.
- oxdock-cli: Command-line interface for running OxDock scripts from the command line.
OxDock has a simple goal to provide a simple DSL that works the same across Mac, Linux, and Windows, including support for background processes, symlinks, and boolean conditionals (such as env and platform-based command filtering), which runs the same whether it's used as a preprocessing step in a build-time Rust macro, or as a CLI program, regardless of platform it is building on.
Every internal command is engineered to run the same way across platforms, except for the RUN command, which calls native programs.
OxDock adds no additional runtime dependencies if used as a macro preprocessor.
Prototype status: OxDock is still being prototyped. DSL syntax and Rust APIs may change without deprecation warnings until the first stable release.
Scripts are sequences of instructions, one per line. Instructions may be prefixed with guards ([...]) that decide whether they run, and grouped into scoped blocks ({ ... }). The authoritative grammar is crates/oxdock-parser/src/dsl.pest, which is also embedded in the parser crate as the LANGUAGE_SPEC constant for tooling.
- Commands are uppercase and case-sensitive:
WORKDIR, notworkdir. Lowercase or mixed-case spellings are parse errors with an uppercase hint. - One instruction per line; a semicolon (
;) splits multiple instructions on a single line. - Paths and arguments use forward slashes (
/) for portability (see Path Separators). - Scripts do not inherit your shell environment unless
INHERIT_ENVopts specific keys in (see Selective environment inheritance).
Every variable binding declares its type at the binding site. LET $name: TYPE = ... creates the binding, $name = ... mutates it, and bodies use the bare $name reference. The leading $ keeps mutation distinct from KEY=value command assignments. Repeating LET for the same name in the same scope is a redeclaration error. Loop variables are declared the same way: FOR $item: STRING IN .... Valid types: STRING, INT, FLOAT, BOOL, PIPE, LIST, MAP, HANDLE, DURATION, PATH.
LET $count: INT = 1
$count = 2
LET $msg: STRING = hello
FOR $item: STRING IN ["a", "b"] {
ECHO "{{ $item }}"
}
WRITE count.txt "{{ $count }}"
LET $c: STRING = READ count.txt
ASSERT_EQ $c "2"
The env:KEY expression reads the script environment into a plain value. A $var reference never reads the environment, even when the names match:
ENV FOO="bar"
LET $e: STRING = env:FOO
WRITE env.txt "{{ $e }}"
LET $v: STRING = READ env.txt
ASSERT_EQ $v "bar"
Parentheses mark the boundary between computing a value and running a pipeline step. Builtin functions (INSPECT, LOAD_TOML, LOAD_JSON, GLOB, INT, FLOAT, PATH_TYPE) evaluate to an in-memory value and never write to standard output. They compute or query (Value::Map, Value::String, Value::Int, Value::Float, Value::List) with zero stream side effects, so they appear only where values are expected: on the right-hand side of LET, inside IF conditions, or nested in other calls. Commands (READ, ECHO, RUN, WRITE, ASSERT_EQ) are line-starting statements with space-separated arguments. They drive the I/O pipeline, streaming bytes to stdout or mutating state, which makes their output available to pipes and LET capture. When a function evaluates, process stdout stays completely untouched. When a command runs, streaming bytes is the payload:
// Functions compute values; stdout stays untouched.
IMPORT [STD]
LET $t: STRING = PATH_TYPE("missing.txt")
ASSERT_EQ $t "absent"
LET $n: INT = INT("41") + 1
ASSERT_EQ $n 42
ASSERT_EQ stdout ""
// One line, two instructions: the semicolon splits them.
ECHO one; ECHO two
ASSERT_CONTAINS stdout "one"
ASSERT_CONTAINS stdout "two"
Shell form (RUN <command...>) joins its arguments and runs the string in the system shell. Exec form (RUN ["exe", "arg", ...]) spawns the executable directly with no shell. Exec form has no shell expansion, globbing, redirection, or pipes. Quoted {{ ... }} templates still interpolate per element, and guards and wrappers (ASYNC, TIMEOUT, WITH_IO) apply to both forms.
RUN ["cargo", "--version"]
ASSERT_CONTAINS stdout "cargo"
Three comment styles are supported: // line comments, nestable /* ... */ block comments, and # comments. A # comment occupies a whole line (optionally indented) and may also trail values inside multi-line (), [], and {} brackets; inside a command payload a # is ordinary text. Similarly, // ends a RUN argument list but survives inside quoted strings:
// Slash comment at end of line.
# Hash comment occupies the whole line.
/* block comments
/* nest */
like this */
ECHO visible-after-comments
ASSERT_CONTAINS stdout "visible-after-comments"
ECHO hash-mid-line # stays-in-payload
ASSERT_CONTAINS stdout "hash-mid-line # stays-in-payload"
RUN echo run-args-stop-at-slashes // removed-as-comment
ASSERT_CONTAINS stdout "run-args-stop-at-slashes"
RUN echo "quoted // kept"
ASSERT_CONTAINS stdout "quoted // kept"
Comment markers inside quoted strings are always preserved.
Commands that take flags accept the value either after a space or joined with =: --from-workspace LOCAL and --from-workspace=LOCAL mean the same thing. Boolean flags take no value. Quoted arguments are never treated as flags, even when they start with --.
WRITE flag-src.txt flag-content
COPY --from-workspace=LOCAL flag-src.txt flag-copy.txt
LET $body: STRING = READ flag-copy.txt
ASSERT_EQ $body "flag-content"
Arguments accept single- or double-quoted strings; the escape sequences \" and \' embed a quote, and any other backslash escape keeps the escaped character while dropping the backslash. Quoted fragments containing whitespace, ;, newlines, //, or /* retain their quotes when RUN reconstructs the command string:
// Single and double quotes behave identically.
ECHO 'single quotes'
ASSERT_CONTAINS stdout "single quotes"
ECHO "double quotes"
ASSERT_CONTAINS stdout "double quotes"
// \" embeds a quote; the backslash itself is consumed.
ECHO "escaped \" quote"
ASSERT_CONTAINS stdout 'escaped " quote'
// Quoted separators stay literal: no instruction splits here.
ECHO 'semi;colon'
ASSERT_CONTAINS stdout "semi;colon"
{{ env:KEY }} interpolates script environment values into arguments at execution time. Values come from the script environment (ENV, inherited keys): there is no fallback to host variables in command context, and unknown keys expand to an empty string. The unprefixed form {{ KEY }} resolves a DSL variable of that name instead, else expands to empty; it never reads the environment, so always use the env:-prefixed spelling for environment values:
ENV USER=OxDock
# The env:-prefixed form interpolates from the script environment.
ECHO "Hello {{ env:USER }}!"
ASSERT_CONTAINS stdout "Hello OxDock!"
# Unprefixed names resolve DSL variables instead: $WHO exists, so this expands.
LET $WHO: STRING = "Ada"
ECHO "Hi {{ WHO }}!"
ASSERT_CONTAINS stdout "Hi Ada!"
# With no such variable the bare name expands to empty.
ECHO "Hello {{ USER }}!"
ASSERT_CONTAINS stdout "Hello !"
# Unknown keys expand to empty with no host fallback.
ECHO "a{{ env:OXDOCK_DOC_NO_SUCH_KEY }}b"
ASSERT_CONTAINS stdout "ab"
A guard is a bracketed expression that gates the instruction or block that follows it. Inside the brackets:
env:KEYpasses when variableKEYexists and is non-empty;eq(env:KEY, value)andne(env:KEY, value)compare values.- Bare platform tags pass based on the host:
linux,macos(aliasmac),windows,unix. Tags are case-insensitive. - A comma-separated list means AND:
[env:A, linux]. - Disjunction is expressed as a call:
any(expr, expr, ...)with at least two branches, not an infix operator. - Conjunction is expressed as a call (
all(expr, expr, ...)) or implicitly via comma separation. - Any predicate may be negated with
not(...):[not(env:SKIP)]. - Parentheses group expressions:
[any(env:A, linux), mac].
Guards attach to the next instruction. Several guard lines in a row chain onto the same target, and a guard immediately followed by { opens a guarded block whose guard applies to every enclosed instruction.
Guard evaluation checks the script environment only: ENV entries, INHERIT_ENV opt-ins, and builtin keys. Host variables stay invisible unless inherited, so guards interact naturally with INHERIT_ENV and ENV.
// Copy the key from the host environment (the runner injects it).
INHERIT_ENV [DEPLOY_TARGET]
// Passes when the variable exists with any non-empty value.
[env:DEPLOY_TARGET] ECHO deploy-target-visible
ASSERT_CONTAINS stdout "deploy-target-visible"
// Equality against the inherited value.
[eq(env:DEPLOY_TARGET, staging)] ECHO deploying-to-staging
ASSERT_CONTAINS stdout "deploying-to-staging"
// Inequality: skipped below, because DEPLOY_TARGET IS staging.
[ne(env:DEPLOY_TARGET, staging)] ECHO deploying-elsewhere
// Exactly one block runs depending on the host OS; every command
// inside a guarded block inherits the block's guard.
[windows] {
WRITE os-report.txt windows
ECHO windows-detected
LET $rep: STRING = READ os-report.txt
ASSERT_EQ $rep "windows"
ASSERT_CONTAINS stdout "windows-detected"
}
[unix] {
WRITE os-report.txt unix-family
ECHO unix-detected
LET $rep: STRING = READ os-report.txt
ASSERT_EQ $rep "unix-family"
ASSERT_CONTAINS stdout "unix-detected"
}
// Bring the runner-injected value into the script environment.
INHERIT_ENV [OXDOCK_DOC_FEATURE_A]
// not(...) inverts the predicate: passes because the variable does NOT exist.
[not(env:OXDOCK_DOC_UNDEFINED_VAR)] ECHO negation-passes-for-undefined
ASSERT_CONTAINS stdout "negation-passes-for-undefined"
// any(...) passes when ANY branch holds; A exists, so this runs.
[any(env:OXDOCK_DOC_FEATURE_A, env:OXDOCK_DOC_FEATURE_B)] ECHO or-matched-a-branch
ASSERT_CONTAINS stdout "or-matched-a-branch"
// Comma composes with AND: (A or linux) AND A: true here on every OS.
[any(env:OXDOCK_DOC_FEATURE_A, linux), env:OXDOCK_DOC_FEATURE_A] ECHO composed-and-or-guard
ASSERT_CONTAINS stdout "composed-and-or-guard"
Bracket expressions may span lines. Chained guard lines apply conjunctively to the next instruction; here neither variable is defined, so the gated instruction is skipped:
IMPORT [STD]
// Brackets may span lines; chained lines AND together and gate
// the next command.
[
env:OXDOCK_DOC_CHAIN_ONE,
env:OXDOCK_DOC_CHAIN_TWO
]
// Neither variable exists, so this WRITE is skipped entirely.
WRITE chained.txt applied
// The artifact was never created.
LET $t: STRING = PATH_TYPE("chained.txt")
ASSERT_EQ $t "absent"
Braced blocks scope everything: LET variables, ENV values, WORKDIR, and WORKSPACE all revert when the block exits. Files created inside a block persist on disk, and pipes registered with WITH_IO stay open. Those are the only things that cross a scope boundary. (A bare { ... } needs an always-true guard: [bool:true]. Single commands, including single WITH_IO lines like READ_LINE, never open a scope.)
LET $a: STRING = "some_value"
ENV MODE="production"
MKDIR scoped_area
WORKDIR scoped_area
// Guarded block: LET, ENV, and WORKDIR below are scoped and revert
// when the block closes.
[bool:true] {
LET $a: STRING = "inner_value"
ENV MODE="staging"
WRITE inner.txt "{{ $a }}-{{ env:MODE }}"
}
// $a is back to "some_value", MODE is back to "production",
// and cwd is back at scoped_area. Files persist.
LET $in_body: STRING = READ inner.txt
ASSERT_EQ $in_body "inner_value-staging"
WRITE outer.txt "{{ $a }}-{{ env:MODE }}"
LET $out_body: STRING = READ outer.txt
ASSERT_EQ $out_body "some_value-production"
Pipes cross scope boundaries the same way files do: a handle bound inside stays usable after the block exits.
LET $p: PIPE
[bool:true] {
WITH_IO [stdout=$p] ECHO "piped-out"
}
WITH_IO [stdin=$p] READ_LINE $line
ASSERT_EQ $line "piped-out"
IF/ELSE branches, FOR loop bodies, TIMEOUT bodies, ASYNC bodies, and WITH_IO [..] { ... } blocks are all scopes under the same rule: only files and pipes leak out.
EXIT <code> stops the pipeline immediately with an EXIT requested with code <code> error. Steps after it never run, at any nesting depth. Files written before the EXIT persist, which the fence below asserts; state unwinding and task teardown follow the same scope rules as everywhere else.
WRITE before.txt "persisted"
LET $b: STRING = READ before.txt
ASSERT_EQ $b "persisted"
[bool:true] {
EXIT 3
WRITE unreachable.txt "never"
}
TIMEOUT <duration> <command> bounds a single step, TIMEOUT <duration> { ... } bounds a block, and TIMEOUT <duration> AWAIT $task bounds a task join. Durations accept ms, s, m, and h suffixes (a bare number means seconds, e.g. TIMEOUT 30 ...). A step that overruns its deadline is cancelled. A blocking foreground process is killed, and the pipeline fails with a TIMEOUT after <duration> error. SLEEP <duration> parks the step without spawning a shell, which makes it ideal for testing deadlines portably (a SLEEP inside an expired TIMEOUT is interrupted instead of running out the clock).
// Inline form bounds a single command.
TIMEOUT 30s WRITE heartbeat.txt alive
LET $beat: STRING = READ heartbeat.txt
ASSERT_EQ $beat "alive"
// Block form bounds multiple steps.
TIMEOUT 30s {
WRITE a.txt one
WRITE b.txt two
}
LET $a: STRING = READ a.txt
LET $b: STRING = READ b.txt
ASSERT_EQ $a "one"
ASSERT_EQ $b "two"
// AWAIT form bounds a task join.
LET $quick: HANDLE = ASYNC {
ECHO hi
}
TIMEOUT 30s AWAIT $quick
ASYNC wraps any command or block (including TIMEOUT, CANCEL, SLEEP, and nested ASYNC) in either nesting order with order-dependent deadline semantics: LET $task: HANDLE = ASYNC TIMEOUT 30s RUN "build" enforces the deadline inside the background thread (a later AWAIT $task surfaces the TIMEOUT error), while TIMEOUT 30s AWAIT $task preempts a hung task from the awaiting side:
// ASYNC wraps TIMEOUT: the deadline fires inside the background thread.
LET $bounded: HANDLE = ASYNC TIMEOUT 30s ECHO "bounded"
AWAIT $bounded
The one structural exception is WITH_IO, which must wrap ASYNC from the outside (LET $p: PIPE first, then WITH_IO [stdout=$p] ASYNC ...) so pipe endpoints are allocated synchronously on the main thread before the worker spawns. Placing WITH_IO directly inside ASYNC is rejected at parse time.
CANCEL $task synchronously stops a named background task spawned via LET $task: HANDLE = ASYNC .... It is blocking: when the statement returns, the task thread has been joined and its OS process reaped, so no residual filesystem or stream mutation can follow and the next step runs in a quiet workspace. Only named tasks can be cancelled; a later AWAIT $task fails with a cancellation error, and a second CANCEL $task fails as already cancelled.
// CANCEL form stops a named background task synchronously.
LET $worker: HANDLE = ASYNC SLEEP 30s
CANCEL $worker
See the changelog for what changed in each release.
| Command | Syntax |
|---|---|
WORKDIR |
WORKDIR <path> |
WORKSPACE |
WORKSPACE (SNAPSHOT|LOCAL|CACHE|SYSTEM) [--local] |
ENV |
ENV KEY=value |
INHERIT_ENV |
INHERIT_ENV [<key>, ...] |
ECHO |
ECHO <message> |
RUN |
RUN <command...> | RUN ["exe", "arg", ...] |
COPY |
COPY [--from-workspace SNAPSHOT|LOCAL|CACHE|SYSTEM] <from> <to> |
COPY_GIT |
COPY_GIT [--include-dirty] <rev> <src> <dst> |
SYMLINK |
SYMLINK [--from-workspace SNAPSHOT|LOCAL|CACHE|SYSTEM] <from> <to> |
MKDIR |
MKDIR <path> |
LS |
LS [<path>] |
CWD |
CWD |
READ |
READ [<path>] |
READ_LINE |
READ_LINE $var |
WRITE |
WRITE <path> [<contents>] |
APPEND |
APPEND <path> [<contents>] |
EXPAND |
EXPAND [<path>] [<KEY=val> ...] |
ASSERT_EQ |
ASSERT_EQ <actual> <expected> | ASSERT_EQ --hash <sha256> <actual> |
ASSERT_CONTAINS |
ASSERT_CONTAINS <haystack> <needle> |
HASH_SHA256 |
HASH_SHA256 <path> |
EXIT |
EXIT <code> |
SLEEP |
SLEEP <duration> |
LIST_APPEND |
LIST_APPEND $list <item> |
WITH_IO |
WITH_IO [<stream>[=$var], ...] <command> | WITH_IO [bindings] { <commands> } |
FOR |
FOR $item: TYPE IN <expr> { <commands> } | FOR $key: STRING, $value: TYPE IN <expr> { <commands> } |
IF |
IF <expr> { <commands> } [ELSE IF <expr> { <commands> } ...] [ELSE { <commands> }] |
LET |
LET $var: TYPE = <expr> | LET $p: PIPE | LET $var: TYPE = ASYNC { <commands> } | LET $var: TYPE = <command> | LET $var: TYPE = AWAIT $task | LET $var: TYPE = { <commands> } |
MUTATION |
$var = <expr> |
ASYNC |
ASYNC <command...> | ASYNC { <commands> } | LET $var: HANDLE = ASYNC { <commands> } |
AWAIT |
AWAIT $var | LET $out: STRING = AWAIT $var |
CANCEL |
CANCEL $var |
TIMEOUT |
TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> } | TIMEOUT <duration> AWAIT $var |
FUNC |
FUNC NAME([$param: TYPE, ...]) { <commands> } |
RETURN |
RETURN [<expr>] |
WHILE |
WHILE <bool-expr> { <commands> } |
BREAK |
BREAK |
CONTINUE |
CONTINUE |
IMPORT |
IMPORT [<module>, ...] | IMPORT <module> |
Reroute standard streams.
Syntax: WITH_IO [<stream>[=$var], ...] <command> | WITH_IO [bindings] { <commands> }
Reroutes the standard streams of the next command or, in block form, of every enclosed command.
Bindings map streams (stdin, stdout, stderr) to a PIPE-typed
variable (stdout=$p, stdin=$p), resolved from the variable
when the step runs. Both stdout and stderr pipes capture output
the same way. Declare the handle first with LET $p: PIPE.
Pipes hold bytes in memory and spill to a temp file above 8 MiB, so a producer can finish before the consumer starts.
If WITH_IO wraps an ASYNC block whose body is a single RUN, guarded or not, the pipe is a zero copy OS kernel pipe instead: pair it with a consumer that runs while the producer is alive, since output past the 64 KiB kernel buffer stalls until drained. That promotion never crosses a function boundary: pipes created, bound, or passed by variable inside FUNC bodies are always script pipes, even when the surrounding task would otherwise promote.
A second producer or consumer on a live handle is an explicit
error. A handle bound as output can later feed another
command's stdin, connecting commands without touching the
terminal. Binding stdout and stderr to the same live
handle fails deterministically. Merge streams in shell
via 2>&1 instead.
Nested blocks stack defaults; inline bindings override inherited ones for their command only; closing a block restores previous wiring.
Examples:
Example: with_io block
LET $log: PIPE
WITH_IO [stdout=$log] {
ECHO first
ECHO second
}
WITH_IO [stdin=$log] WRITE captured.txt
# The piped bytes landed in the file.
LET $body: STRING = READ captured.txt
ASSERT_CONTAINS $body "first"
ASSERT_CONTAINS $body "second"
Example: variable pipe binding
# Declare the pipe first: `LET $p: PIPE` mints a fresh
# backend without touching a stream. A plain string here
# would be a TypeMismatch.
LET $p: PIPE
WITH_IO [stdout=$p] ECHO hello
WITH_IO [stdin=$p] READ_LINE $line
ASSERT_EQ $line "hello"
Iterate over a list or map.
Syntax: FOR $item: TYPE IN <expr> { <commands> } | FOR $key: STRING, $value: TYPE IN <expr> { <commands> }
The loop variable receives each element (lists) or value (maps); with two variables, the first receives the key.
Loop variables are declared with explicit types and scoped per iteration;
they do not leak outward. The body may be a braced block
or a single-line { ... } command.
GLOB("...") patterns must be quoted (* is not a bare word, so
GLOB(*) is a parse error); GLOB returns a root-relative sorted list,
empty when nothing matches, and rejects .. escapes.
Examples:
Example: for loop
# Each element binds in turn; the loop body sees every one.
LET $items: LIST = ["a", "b"]
FOR $item: STRING IN $items {
ECHO $item
}
ASSERT_CONTAINS stdout "a"
ASSERT_CONTAINS stdout "b"
# Key and value bind together for maps.
LET $map: MAP = {"x": 1}
FOR $k: STRING, $v: INT IN $map {
ECHO "{{ $k }}={{ $v }}"
}
ASSERT_CONTAINS stdout "x=1"
Example: expand every match
# Single-line body; $x is a template path, WHO an override.
IMPORT [STD]
WRITE a.txt "hi \{{ env:WHO }}!"
FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
ASSERT_CONTAINS stdout "hi World!"
Conditional execution.
Syntax: IF <expr> { <commands> } [ELSE IF <expr> { <commands> } ...] [ELSE { <commands> }]
The condition is evaluated as a boolean expression.
Prefix ! negates (IF !false); && binds tighter than
||, and both short-circuit, so IF true || $missing
never evaluates the right side. Only Bool values are
accepted as conditions.
Examples:
Example: if else
IMPORT [STD]
# True branch runs; the false branch is skipped.
IF true {
WRITE yes.txt taken
} ELSE {
WRITE yes.txt skipped
}
# ELSE IF selects the first true branch.
IF false {
WRITE skipped.txt no
} ELSE IF true {
WRITE fallback.txt taken
}
# !false evaluates to true, so this branch runs.
IF !false {
WRITE negated.txt taken
}
LET $yes_body: STRING = READ yes.txt
LET $fallback_body: STRING = READ fallback.txt
LET $negated_body: STRING = READ negated.txt
ASSERT_EQ $yes_body "taken"
ASSERT_EQ $fallback_body "taken"
ASSERT_EQ $negated_body "taken"
LET $t: STRING = PATH_TYPE("skipped.txt")
ASSERT_EQ $t "absent"
Example: logical condition composition
IMPORT [STD]
LET $role: STRING = "admin"
LET $level: INT = 3
# || is true when either side holds; && needs both.
IF $role == "owner" || $level >= 5 {
WRITE unexpected.txt no
} ELSE {
WRITE fallback.txt or-false
}
LET $fb: STRING = READ fallback.txt
ASSERT_EQ $fb "or-false"
LET $t1: STRING = PATH_TYPE("unexpected.txt")
ASSERT_EQ $t1 "absent"
IF $role == "admin" || $level >= 5 {
WRITE chosen.txt or-true
}
LET $ch: STRING = READ chosen.txt
ASSERT_EQ $ch "or-true"
IF $role == "admin" && $level >= 5 {
WRITE unexpected-too.txt no
} ELSE {
WRITE and.txt and-false
}
LET $an: STRING = READ and.txt
ASSERT_EQ $an "and-false"
LET $t2: STRING = PATH_TYPE("unexpected-too.txt")
ASSERT_EQ $t2 "absent"
Bind script-local variables.
Syntax: LET $var: TYPE = <expr> | LET $p: PIPE | LET $var: TYPE = ASYNC { <commands> } | LET $var: TYPE = <command> | LET $var: TYPE = AWAIT $task | LET $var: TYPE = { <commands> }
Declares a script-local variable with an explicit type (STRING, INT,
FLOAT, BOOL, PIPE, LIST, MAP, HANDLE, DURATION, PATH). Duplicate LET
in the same scope frame is a redeclaration error; mutate with
$var = <expr>.
Variables are usable in templates ({{ $var }}), guards, and
expressions. With ASYNC, spawns a background task and stores its
handle (see ASYNC). The $ sigil on the name is mandatory.
No hoisting: a variable exists only after its LET runs, in
execution order. Reading $var before its LET (or after the
block that declared it exits) fails with
undefined variable $var. Scopes are a stack of frames and
resolution walks innermost outward, so nothing pre-declares
names. Function bodies read outer variables through the same
walk, but their own LETs never leak out (see FUNC).
The right-hand side is always an expression — literals, lists, maps,
arithmetic (+ - * / with *// binding tighter, unary -,
parentheses), comparisons (< <= > >= binding tighter than
== !=), logical && (tighter) and || with short-circuit,
! negation, env:KEY reads, INSPECT($var) snapshots,
GLOB("*.md"), INT(x) / FLOAT(x) conversions — never a
{{ ... }} template; interpolation happens in string values,
not here.
The one exception is pipes: LET $p: PIPE with no =
and no initializer mints a fresh anonymous backend,
lazily materialized at first binding, so two declarations
never share a channel.
Numbers are numeric literals: 42 binds INT, 3.14 binds
FLOAT. Int x Int stays INT (checked, integer division,
so 7 / 2 is 3); any Float operand promotes to FLOAT.
Division by zero, overflow, and non-finite results are errors.
Both numeric sides compare numerically (1 == 1.0 is true);
otherwise ==/!= compare rendered strings and ordering on
non-numerics is a Type Error. Constant subtrees fold at parse
time and dynamic arithmetic compiles to flat RPN with
identical semantics.
Float equality is exact with no epsilon. Floats store decimals
in binary, so a value is exact only when its reduced fraction
has a power-of-2 denominator: 0.5 (1/2), 0.25 (1/4), 0.75
(3/4) are exact, while 0.1 (1/10), 0.2 (1/5), 0.3 (3/10)
repeat forever in binary (like 1/3 in decimal) and truncate,
so 0.1 + 0.2 == 0.3 is false (the sum is
0.30000000000000004). Rule of thumb: endings .5, .25, .75,
.125, .625, .875 are exact; .1, .2, .3 and similar are
approximations. Bound approximations instead of comparing
them: IF $sum > 0.299999 && $sum < 0.300001.
Comparisons do not chain: a < b < c is a parse error, not
(a < b) < c. Chaining would compare a BOOL against a
number (a runtime Type Error in C-style parsing) or evaluate
the middle term twice (Python-style chaining), so the grammar
accepts exactly one comparison operator per level. Write the
conjunction explicitly: $a < $b && $b < $c. The same holds
for equality ($a == $b == $c is rejected).
Captured command output is a string, so convert before math:
LET $total: INT = $total + INT($size_str) (INT trims ASCII
whitespace; FLOAT accepts int strings and rejects
non-finite).
Bare words need no quotes: LET $d: STRING = 30s binds the same string
as quoted.
When the right-hand side is a synchronous command
(LET $out: STRING = ECHO hi), the command runs to completion and its
exact stdout bytes are captured into the variable as a string (no newline
stripping; commands with no stdout capture as ""; non-UTF8 stdout is
an error). Combining capture with an explicit
WITH_IO [stdout=$var] is a parse error.
Coming from Bash, the capture line looks familiar but behaves strictly:
Bash output=$(...) |
OxDock LET $out: STRING = ... |
|
|---|---|---|
| Trailing newlines | Stripped (all of them) | Preserved byte-exact |
| Variable type | Always an untyped string | Declared: STRING, INT, FLOAT, ... |
| Math on output | Implicit: $((var + 1)) |
Explicit: INT($out) + 1 |
| Failing command | Continues with empty output unless set -e |
Step fails immediately, binds nothing |
LET $out: TYPE = AWAIT $var binds the background task's
explicit RETURN value instead (tasks stream their stdout
live, so there is no output left to capture); a task that
succeeded without RETURN yields INT 0, like a process
exit status.
An inline block (LET $var: TYPE = { <commands> }) runs its
steps in a fresh scope and binds the nearest RETURN value,
like a zero-arg function body: fallthrough without RETURN
binds "", and BREAK/CONTINUE escaping the block are
errors. The block reads outer variables but its own LETs
never leak out. A {k: v} shape still parses as a map
literal; anything else in braces is a block.
The split is deliberate: synchronous commands capture
stdout because they run inline to completion on the same
thread; background tasks never capture stdout because
concurrent output has no well-defined value. Task results
travel only through RETURN (or INT 0 for void tasks).
LET $e: STRING = env:FOO reads the script environment into a plain
string.
Examples:
Example: let
LET $name: STRING = "world"
ECHO "hello, {{ $name }}"
ASSERT_CONTAINS stdout "hello, world"
LET $items: LIST = ["a", "b"]
ASSERT_CONTAINS $items "a"
ASSERT_CONTAINS $items "b"
LET $count: INT = 42
ASSERT_EQ $count 42
Example: no hoisting
# Reading before the LET runs is an error, not an empty value.
ECHO $too_early
LET $too_early: STRING = "too late"
Expected error: undefined variable
Example: glob binding
# The RHS is an expression: GLOB(...) runs and binds a list.
IMPORT [STD]
WRITE a.txt "x"
LET $files: LIST = GLOB("*.txt")
FOR $f: STRING IN $files { ECHO $f }
ASSERT_CONTAINS stdout "a.txt"
Example: scoped variable reverts
# LET inside a braced block reverts when the block exits.
LET $a: STRING = "outer"
[bool:true] {
LET $a: STRING = "inner"
WRITE inner.txt "{{ $a }}"
}
WRITE outer.txt "{{ $a }}"
LET $in_body: STRING = READ inner.txt
ASSERT_EQ $in_body "inner"
LET $out_body: STRING = READ outer.txt
ASSERT_EQ $out_body "outer"
Example: capture command output
# Capture keeps the trailing newline.
LET $out: STRING = ECHO hi
ASSERT_EQ $out "hi\n"
Example: inline block
LET $who: STRING = "ada"
# An inline block binds its RETURN value like a function body.
LET $res: STRING = {
LET $loud: STRING = "{{ $who }}!"
RETURN $loud
}
ASSERT_EQ $res "ada!"
# Any declared type works: the block value checks like any RHS.
LET $n: INT = {
RETURN 40 + 2
}
ASSERT_EQ $n 42
Example: arithmetic over captured output
# Captured output converts explicitly: INT() then arithmetic.
IMPORT [STD]
LET $size_str: STRING = ECHO 41
LET $total: INT = INT($size_str) + 1
ASSERT_EQ $total 42
# FLOAT() promotes instead of truncating.
LET $ratio: FLOAT = 1 + 2.5
ASSERT_EQ $ratio 3.5
# Int x Int stays INT: integer division truncates.
LET $half: INT = 7 / 2
ASSERT_EQ $half 3
Example: float equality is exact
# Binary fractions compare cleanly; decimal fractions may not:
# 0.1 + 0.2 is 0.30000000000000004, so == is false.
IMPORT [STD]
LET $exact: BOOL = 0.5 + 0.25 == 0.75
LET $decimal: BOOL = 0.1 + 0.2 == 0.3
IF $exact {
WRITE exact.txt yes
}
IF $decimal {
WRITE unexpected.txt no
}
LET $ok: STRING = READ exact.txt
ASSERT_EQ $ok "yes"
LET $t: STRING = PATH_TYPE("unexpected.txt")
ASSERT_EQ $t "absent"
Example: bound inexact decimals
# Never test inexact decimals for equality; bound them.
LET $sum: FLOAT = 0.1 + 0.2
IF $sum > 0.299999 && $sum < 0.300001 {
WRITE bounded.txt yes
}
LET $ok: STRING = READ bounded.txt
ASSERT_EQ $ok "yes"
Example: inspect a variable
# INSPECT($var) snapshots a variable into a MAP: declared
# type plus live details (pipe backend stats here), so
# scripts can branch on engine state.
IMPORT [STD]
LET $p: PIPE
WITH_IO [stdout=$p] ECHO hello
LET $info: MAP = INSPECT($p)
IF $info.is_os_pipe {
WRITE unexpected.txt "should be a script pipe"
}
ASSERT_EQ $info.type "PIPE"
LET $t: STRING = PATH_TYPE("unexpected.txt")
ASSERT_EQ $t "absent"
Mutate a declared variable.
Syntax: $var = <expr>
Reassigns an existing variable, converting the new value to
the type declared at LET time. The explicit annotation is
what authorizes string-to-number conversion here ($n = "42"
binds 42 for an INT); a non-numeric string is an error.
Expressions never convert: "100" + 1 is a Type Error, use
INT() / FLOAT() to cross that boundary explicitly.
The leading $ distinguishes mutation from KEY=value command
assignments. Assigning an undeclared variable or a mismatched type is
an error.
Mutation writes through to the scope where the variable was
declared, so it survives block exit: LET $x outside a block
followed by $x = ... inside still reads back the new value
afterwards, for every type. This is the counterpart to LET
shadowing, where LET $x inside the block declares a
separate inner variable that reverts on exit.
Examples:
Example: mutate
# Mutation writes through: the binding holds the new value.
LET $count: INT = 1
$count = 2
ASSERT_EQ $count 2
Example: convert before math
# Captured output is a string: `"100" + 1` is a Type Error.
# Convert explicitly, then mutate with arithmetic.
IMPORT [STD]
LET $raw: STRING = ECHO 100
LET $n: INT = INT($raw)
$n = $n + 1
# The declared type also converts plain strings on assignment.
$n = "42"
ASSERT_EQ $n 42
# Same crossing for decimals via FLOAT().
LET $frac_str: STRING = ECHO 2.5
LET $f: FLOAT = FLOAT($frac_str) + 0.25
ASSERT_EQ $f 2.75
Run steps in a background thread.
Syntax: ASYNC <command...> | ASYNC { <commands> } | LET $var: HANDLE = ASYNC { <commands> }
Runs a command or block of commands in a background thread with subshell isolation.
Mutations (ENV, WORKDIR) stay within the block. With LET, stores a
task handle for AWAIT. Task output streams live to the parent
stdout; a task publishes a value with an explicit RETURN,
which LET $out: TYPE = AWAIT $task binds.
Examples:
Example: async
# Inline and block forms both run in the background; AWAIT joins them.
ASYNC ECHO "warming-up"
LET $a: HANDLE = ASYNC ECHO "first"
LET $b: HANDLE = ASYNC {
ECHO "second"
}
AWAIT $a
AWAIT $b
ASSERT_CONTAINS stdout "first"
ASSERT_CONTAINS stdout "second"
Example: async task handle
LET $task: HANDLE = ASYNC {
ECHO "built"
}
AWAIT $task
ASSERT_CONTAINS stdout "built"
Join a background task.
Syntax: AWAIT $var | LET $out: STRING = AWAIT $var
Blocks until the named task completes. Propagates errors if the task failed.
Task output streams live during the run; joining binds nothing by
itself. LET $out: TYPE = AWAIT $var binds the task's explicit
RETURN value instead, or INT 0 when the task succeeded
without one (add RETURN <expr> to the task body to yield
a value).
Examples:
Example: await
LET $task: HANDLE = ASYNC ECHO "done"
AWAIT $task
ASSERT_CONTAINS stdout "done"
Example: await capture
LET $task: HANDLE = ASYNC {
ECHO "logged"
RETURN "returned"
}
# AWAIT binds the RETURN value, not the streamed output.
LET $out: STRING = AWAIT $task
ASSERT_EQ $out "returned"
Synchronously cancel a background task.
Syntax: CANCEL $var
Kills the named background task spawned via LET $var: HANDLE = ASYNC ....
Blocking: returns only after the task thread has been joined and its OS process reaped, so no residual filesystem or stream mutation follows. A later AWAIT $var reports cancellation. Only named tasks can be cancelled.
Examples:
Example: cancel
LET $task: HANDLE = ASYNC SLEEP 30s
CANCEL $task
Example: await after cancel reports cancellation
# A cancelled task stays cancelled: joining it reports.
LET $task: HANDLE = ASYNC SLEEP 30s
CANCEL $task
AWAIT $task
Expected error: was cancelled
Enforce an execution deadline.
Syntax: TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> } | TIMEOUT <duration> AWAIT $var
Aborts the wrapped step or block with a deadline error if it exceeds the duration (e.g. 500ms, 10s, 2m; a bare number means seconds).
A blocking foreground process is killed.
Examples:
Example: timeout
TIMEOUT 30s WRITE heartbeat.txt alive
LET $beat: STRING = READ heartbeat.txt
ASSERT_EQ $beat "alive"
Example: timeout block
TIMEOUT 30s {
WRITE a.txt one
WRITE b.txt two
}
LET $a: STRING = READ a.txt
LET $b: STRING = READ b.txt
ASSERT_EQ $a "one"
ASSERT_EQ $b "two"
Example: deadline aborts the step
# 50ms expires long before the sleep does: the step dies
# with a deadline error instead of running out the clock.
TIMEOUT 50ms SLEEP 30s
Expected error: TIMEOUT after
Example: timeout variable duration
# Durations resolve at runtime, so variables work too.
LET $budget: DURATION = "30s"
TIMEOUT $budget WRITE heartbeat.txt alive
LET $beat: STRING = READ heartbeat.txt
ASSERT_EQ $beat "alive"
Define a user function.
Syntax: FUNC NAME([$param: TYPE, ...]) { <commands> }
Defines a user function with UPPERCASE name and explicitly typed parameters.
Params bind by position, converting each argument to its declared parameter type before the body runs. Bodies run in a fresh variable scope; LETs inside do not leak. A nested FUNC definition is scoped to its block and reverts on exit. Names share one namespace with native and host-registered functions, which a FUNC may never shadow.
Functions resolve like variables: a name is visible from its
definition line, so recursion works but mutual recursion does
not (the second name does not exist while the first body
lowers). Calls name their module (STD::GLOB(...)) unless
imported; see IMPORT.
Invoke any function with one syntax: NAME(...) as a statement
(discarding the value) or LET $var: TYPE = NAME(...) to capture
the RETURN value (fallthrough without RETURN captures as "").
Examples:
Example: func def call
FUNC GREET($name: STRING) {
RETURN $name
}
LET $res: STRING = GREET("ada")
ASSERT_EQ $res "ada"
# Statement form: parens stay, the value drops.
GREET("bex")
Example: call with pipes
# A pipe handle travels into a function as a typed argument
# and is usable as a binding target in both directions.
# `LET $p: PIPE` mints the handle; `$p` passes it on.
FUNC DRAIN($q: PIPE) {
WITH_IO [stdin=$q] READ_LINE $line
RETURN $line
}
LET $p: PIPE
WITH_IO [stdout=$p] ECHO "payload"
LET $got: STRING = DRAIN($p)
ASSERT_EQ $got "payload"
Return a value from a function, task, or inline block.
Syntax: RETURN [<expr>]
Ends the nearest enclosing boundary with a value: a function
call, an ASYNC task (bound by LET $o = AWAIT $t), or an
inline LET block. Bare RETURN with no expression yields
"".
Falling off the end without RETURN yields "". RETURN with no enclosing boundary (including at top level) is an error; use EXIT or ECHO there.
Examples:
Example: return
FUNC PICK($flag: BOOL) {
IF $flag {
RETURN "yes"
}
RETURN "no"
}
LET $res: STRING = PICK(true)
ASSERT_EQ $res "yes"
# Fallthrough without RETURN yields its own value.
LET $no: STRING = PICK(false)
ASSERT_EQ $no "no"
Loop while a condition holds.
Syntax: WHILE <bool-expr> { <commands> }
Re-evaluates a Bool condition each iteration (same is_truthy rule as IF; non-Bool is a type error).
Each iteration runs in a fresh scope; mutate outer state with $var = ... so the next check observes it. BREAK exits the loop; CONTINUE skips to the next check.
Examples:
Example: while loop
# The condition re-evaluates every iteration: three passes, then stop.
LET $n: INT = 0
WHILE $n < 3 {
WRITE tick.txt "{{ $n }}"
$n = $n + 1
}
ASSERT_EQ $n 3
LET $tick: STRING = READ tick.txt
ASSERT_EQ $tick "2"
Exit the innermost loop.
Syntax: BREAK
Exits the innermost enclosing FOR or WHILE loop.
BREAK outside a loop, or across a FUNC or ASYNC boundary, is an error.
Examples:
Example: break
# BREAK leaves after the first pass: only "a" is written.
FOR $x: STRING IN ["a", "b"] {
WRITE picked.txt "{{ $x }}"
BREAK
}
LET $body: STRING = READ picked.txt
ASSERT_EQ $body "a"
Skip to the next loop iteration.
Syntax: CONTINUE
Skips the rest of the innermost enclosing FOR or WHILE body and starts the next iteration.
CONTINUE outside a loop, or across a FUNC or ASYNC boundary, is an error.
Examples:
Example: continue
# CONTINUE skips the write on "a": only "b" lands.
FOR $x: STRING IN ["a", "b"] {
IF $x == "a" {
CONTINUE
}
WRITE picked.txt "{{ $x }}"
}
LET $body: STRING = READ picked.txt
ASSERT_EQ $body "b"
Bring module functions into bare-call scope.
Syntax: IMPORT [<module>, ...] | IMPORT <module>
Every function call names its module (STD::GLOB(...),
MOCK::READ_CSV(...)) unless the module is imported:
IMPORT [STD] lets the rest of the scope call GLOB(...)
bare. Calls resolve at parse time against SCRIPT
definitions first, then imported modules; unknown modules,
unknown functions, and unimported bare calls are parse
errors, never runtime surprises.
IMPORT is a lowering directive, not a step: it applies from
its line to the enclosing block exit, then reverts, exactly
like LET scoping but with no runtime footprint. Guards do
not apply to it. Two imported modules exporting one name is
an ambiguity error: qualify the call instead.
EXPORT is reserved for future script-module support and
cannot be used yet.
Examples:
Example: import
# Calls name their module (STD::GLOB); IMPORT [STD] drops the prefix.
WRITE a.txt "hi \{{ env:WHO }}!"
IMPORT [STD]
FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
ASSERT_CONTAINS stdout "hi World!"
Change the working directory.
Syntax: WORKDIR <path>
Sets the current working directory.
Relative paths resolve against the current directory; / resets to
the workspace root. Paths cannot escape the workspace.
Arguments:
| Name | Type | Required | Description |
|---|---|---|---|
path |
PATH |
yes | Directory to change to |
Examples:
Example: change working directory
# Later relative paths resolve under the new directory.
WORKDIR project/src
WRITE generated.txt generated-under-workdir
LET $body: STRING = READ generated.txt
ASSERT_EQ $body "generated-under-workdir"
Example: workdir in a scoped block
# The block reverts to the starting directory on exit.
LET $outside: STRING = CWD
MKDIR project
[bool:true] {
WORKDIR project
WRITE inner.txt inner
}
LET $back: STRING = CWD
ASSERT_EQ $back $outside
LET $body: STRING = READ project/inner.txt
ASSERT_EQ $body "inner"
Switch workspace roots.
Syntax: WORKSPACE (SNAPSHOT|LOCAL|CACHE|SYSTEM) [--local]
Switches the workspace root. The selection reverts at scope
exit like WORKDIR.
SNAPSHOT: the materialized build snapshot (the default).LOCAL: the local workspace directory.CACHE: a persistent per-project directory shared across runs, never evicted. It lives under the OS user cache (OXDOCK_CACHE_DIRpins an exact directory);WORKSPACE CACHE --localkeeps it in<project>/.cache/workspaceinstead.SYSTEM: full filesystem access. Scripts using it are not hermetic.
Arguments:
| Name | Type | Required | Description |
|---|---|---|---|
target |
SNAPSHOT|LOCAL|CACHE|SYSTEM |
yes | Target root |
Flags:
| Flag | Type | Description |
|---|---|---|
--local |
BOOL |
Use the project-local cache directory instead of the OS user cache (CACHE only) |
Examples:
Example: switch roots
IMPORT [STD]
WORKSPACE LOCAL
LET $t: STRING = PATH_TYPE(".")
ASSERT_EQ $t "dir"
Example: workspace cache in a scoped block
[bool:true] {
WORKSPACE CACHE
WRITE cached.txt cached-content
}
COPY --from-workspace CACHE cached.txt restored.txt
LET $body: STRING = READ restored.txt
ASSERT_EQ $body "cached-content"
Set an environment variable.
Syntax: ENV KEY=value
Inserts or updates an env var.
The value uses the unified string-value rules shared by every command:
"..." or '...' quotes keep exact bytes (spaces, tabs), a lone $var
evaluates that variable, {{ ... }} placeholders interpolate, unquoted
words join with single spaces, and the first = splits key from value
(KEY=a=b stores a=b).
A $var inside larger text stays literal — write {{ $var }} to
interpolate there.
Arguments:
| Name | Type | Required | Description |
|---|---|---|---|
assignment |
STRING |
yes | KEY=value pair; the value resolves as STRING |
Examples:
Example: set env
ENV APP_MODE=production
LET $mode: STRING = env:APP_MODE
ASSERT_EQ $mode "production"
Example: quoted value with spaces
# Quotes keep the space: SET_FORTH stores `outer scope`.
ENV SET_FORTH="outer scope"
WRITE out.txt "{{ env:SET_FORTH }}"
LET $body: STRING = READ out.txt
ASSERT_EQ $body "outer scope"
Example: variable value
# A lone $var evaluates, like ECHO $var.
LET $who: STRING = "Alice"
ENV GREETING=$who
WRITE out.txt "{{ env:GREETING }}"
LET $body: STRING = READ out.txt
ASSERT_EQ $body "Alice"
Example: all value forms agree
# A bare variable, a quoted literal, and a template all
# store plain strings through the same value rules.
LET $x: STRING = "Ada"
ENV A=$x
ENV B="hello world"
ENV C="{{ $x }} concatenated"
WRITE check.txt "{{ env:A }}|{{ env:B }}|{{ env:C }}"
LET $body: STRING = READ check.txt
ASSERT_EQ $body "Ada|hello world|Ada concatenated"
Example: scoped env reverts
# ENV inside a braced block reverts when the block exits
ENV MODE=production
[bool:true] {
ENV MODE=staging
WRITE inner.txt "{{ env:MODE }}"
}
WRITE outer.txt "{{ env:MODE }}"
LET $inner_body: STRING = READ inner.txt
ASSERT_EQ $inner_body "staging"
LET $outer_body: STRING = READ outer.txt
ASSERT_EQ $outer_body "production"
Inherit env vars from host.
Syntax: INHERIT_ENV [<key>, ...]
Declares which host environment variables to inherit into the script.
Must appear before any other commands and at most once. Without this directive, the script starts with an empty environment.
Arguments:
| Name | Type | Required | Description |
|---|---|---|---|
keys |
STRING... |
no | Host variables to inherit |
Examples:
Example: inherit env
INHERIT_ENV [PATH, HOME]
LET $path: STRING = env:PATH
ASSERT_CONTAINS $path ":"
Print to stdout.
Syntax: ECHO <message>
Outputs message to stdout.
Arguments:
| Name | Type | Required | Description |
|---|---|---|---|
message |
STRING... |
yes | Text |
Output: Stdout
Examples:
Example: echo
ECHO build-complete
ASSERT_CONTAINS stdout "build-complete"
Example: variables
# {{ }} interpolates inside text; a lone $var evaluates on its own.
LET $x: STRING = "World"
ECHO "braced:{{ $x }}"
ECHO $x
ASSERT_EQ stdout "braced:World\nWorld\n"
Execute shell command or direct executable.
Syntax: RUN <command...> | RUN ["exe", "arg", ...]
Shell form (RUN <command...>) runs the joined command string in the
system shell ($SHELL -c / COMSPEC /C).
Exec form (RUN ["exe", "arg", ...]) spawns the executable directly
with no shell, so there is no shell expansion, globbing, redirection,
or pipes; use it for portable commands.
Guards and wrappers (ASYNC, TIMEOUT, WITH_IO, LET) apply to
both forms.
Arguments:
| Name | Type | Required | Description |
|---|---|---|---|
command |
STRING... |
yes | Command |
Examples:
Example: run
RUN echo hello
# Captured runs prove the output, not just the exit status.
LET $o: STRING = RUN echo hello
ASSERT_CONTAINS $o "hello"
Example: run exec form
# No shell: `>` stays a literal argument, so no file is created.
IMPORT [STD]
RUN ["cargo", "--version", ">", "x.txt"]
ASSERT_CONTAINS stdout "cargo"
LET $t: STRING = PATH_TYPE("x.txt")
ASSERT_EQ $t "absent"
Copy file into workspace.
Syntax: COPY [--from-workspace SNAPSHOT|LOCAL|CACHE|SYSTEM] <from> <to>
Copies from host (the source is never moved or modified). Docker destination semantics: a file copied onto a directory (an existing one, or a trailing-slash spell like out/) is duplicated inside it under its own basename; a directory source duplicates its contents into the destination; any other destination path is created holding the copied bytes.
Arguments:
| Name | Type | Required | Description |
|---|---|---|---|
from |
PATH |
yes | Source |
to |
PATH |
yes | Dest |
Flags:
| Flag | Type | Description |
|---|---|---|
--from-workspace |
STRING |
Copy from the given workspace root instead of the build context |
Examples:
Example: copy
# Copy to a new name, then read back.
WRITE src.txt content
COPY src.txt dst.txt
LET $body: STRING = READ dst.txt
ASSERT_EQ $body "content"
Example: copy from workspace
# Same name, different contents per root: only LOCAL has ws-content.
WRITE shared.txt from-snapshot
WORKSPACE LOCAL
WRITE shared.txt ws-content
WORKSPACE SNAPSHOT
COPY --from-workspace LOCAL shared.txt ws-copy.txt
LET $body: STRING = READ ws-copy.txt
ASSERT_EQ $body "ws-content"
Copy from git revision.
Syntax: COPY_GIT [--include-dirty] <rev> <src> <dst>
Checkout and copy.
Arguments:
| Name | Type | Required | Description |
|---|---|---|---|
rev |
STRING |
yes | Rev |
src |
PATH |
yes | Src |
dst |
PATH |
yes | Dst |
Flags:
| Flag | Type | Description |
|---|---|---|
--include-dirty |
BOOL |
Include dirty |
Examples:
Example: git copy missing source errors
COPY_GIT HEAD src.txt dst.txt
Expected error: COPY source missing
Create symlink.
Syntax: SYMLINK [--from-workspace SNAPSHOT|LOCAL|CACHE|SYSTEM] <from> <to>
Creates symlink. A directory destination (existing, or a trailing-slash spell) receives the link under the source basename.
Arguments:
| Name | Type | Required | Description |
|---|---|---|---|
from |
PATH |
yes | Target |
to |
PATH |
yes | Link |
Flags:
| Flag | Type | Description |
|---|---|---|
--from-workspace |
STRING |
Symlink from the given workspace root instead of the build context |
Examples:
Example: symlink
# A symlink reads like its target.
WRITE original.txt content
SYMLINK original.txt link.txt
LET $body: STRING = READ link.txt
ASSERT_EQ $body "content"
Example: symlink from workspace
# Same name, different contents per root: only LOCAL has ws-content.
WRITE shared.txt from-snapshot
WORKSPACE LOCAL
WRITE shared.txt ws-content
WORKSPACE SNAPSHOT
SYMLINK --from-workspace LOCAL shared.txt ws-link.txt
LET $body: STRING = READ ws-link.txt
ASSERT_EQ $body "ws-content"
Create directory.
Syntax: MKDIR <path>
Creates dir with parents.
Arguments:
| Name | Type | Required | Description |
|---|---|---|---|
path |
PATH |
yes | Dir path |
Examples:
Example: mkdir
IMPORT [STD]
MKDIR deeply/nested/tree
LET $t: STRING = PATH_TYPE("deeply/nested/tree")
ASSERT_EQ $t "dir"
List directory.
Syntax: LS [<path>]
Lists entries.
Arguments:
| Name | Type | Required | Description |
|---|---|---|---|
path |
PATH |
no | Dir |
Output: Stdout
Examples:
Example: ls
MKDIR inventory
WRITE inventory/a.txt a
LS inventory
ASSERT_CONTAINS stdout "a.txt"
Print working directory.
Syntax: CWD
Outputs cwd.
Output: Stdout
Examples:
Example: cwd
CWD
# CWD tracks WORKDIR: the listing names the new directory.
MKDIR sub
WORKDIR sub
LET $c: STRING = CWD
ASSERT_CONTAINS $c "sub"
Read file to stdout.
Syntax: READ [<path>]
Outputs file contents.
Arguments:
| Name | Type | Required | Description |
|---|---|---|---|
path |
PATH |
no | File |
Output: Stdout
Examples:
Example: read
WRITE note.txt "hello"
READ note.txt
LET $body: STRING = READ note.txt
ASSERT_EQ $body "hello"
Read one line from stdin into a variable.
Syntax: READ_LINE $var
Reads bytes until newline without waiting for EOF, leaving the pipe open.
Trailing newline is stripped (shell-read parity). On premature EOF assigns accumulated bytes and returns.
Arguments:
| Name | Type | Required | Description |
|---|---|---|---|
var |
STRING |
yes | Target variable ($name); the line binds as STRING |
Examples:
Example: read line
# The trailing newline is stripped: the variable holds exactly `first`.
LET $lines: PIPE
WITH_IO [stdout=$lines] ECHO "first"
WITH_IO [stdin=$lines] READ_LINE $reply
ASSERT_EQ $reply "first"
Write to file.
Syntax: WRITE <path> [<contents>]
Writes contents.
Arguments:
| Name | Type | Required | Description |
|---|---|---|---|
path |
PATH |
yes | File |
contents |
STRING... |
no | Content |
Examples:
Example: write
WRITE output.txt hello-world
LET $body: STRING = READ output.txt
ASSERT_EQ $body "hello-world"
Append to file.
Syntax: APPEND <path> [<contents>]
Appends contents.
Arguments:
| Name | Type | Required | Description |
|---|---|---|---|
path |
PATH |
yes | File |
contents |
STRING... |
no | Content |
Examples:
Example: append
WRITE log.txt line1
APPEND log.txt line2
# APPEND concatenates with no separator.
LET $all: STRING = READ log.txt
ASSERT_EQ $all "line1line2"
Expand a template file (or stdin) to stdout.
Syntax: EXPAND [<path>] [<KEY=val> ...]
A template is any text file — or piped stdin when no path is given —
containing {{ ... }} placeholders. EXPAND replaces each placeholder
and prints the result to stdout.
Placeholders: {{ NAME }} reads a KEY=val override passed on this
command; {{ env:NAME }} reads an override, falling back to the
environment; {{ $var }} reads a script variable (dotted paths allowed).
A missing key is an error, never a silent empty.
Substitution runs in a single pass. EXPAND is not recursive and does not
expand nested placeholders: a value that itself contains {{ ... }} is
inserted verbatim and never expanded again.
A bare $var argument is a template path; KEY=val arguments are
overrides whose values follow the unified string-value rules (same as
ENV: quotes keep exact bytes, a lone $var evaluates,
{{ ... }} interpolates).
NOTE: WRITE interpolates {{ ... }} while writing, so escape it
(\{{ ... }}) when writing a template file for a later EXPAND.
With no path, the template arrives on stdin through a pipe. When piping
from a shell, single-quote the template (echo '{{ $x }}'): double
quotes let the shell swallow $x, so oxdock receives an empty {{ }}
placeholder and errors.
Arguments:
| Name | Type | Required | Description |
|---|---|---|---|
path |
PATH |
no | Template file to expand; omit to expand stdin |
overrides |
STRING... |
no | Template overrides shadowing that key (unified string values) |
Output: Stdout
Examples:
Example: expand
# Placeholders read overrides first, then the environment.
ENV NAME="Alice"
WRITE template.md "Hello {{ env:NAME }}!"
EXPAND template.md
ASSERT_CONTAINS stdout "Hello Alice!"
Example: override with spaces
# WRITE would interpolate {{ }} right away, so escape it.
# The file must literally contain {{ env:NAME }} for EXPAND.
WRITE template.md "Hello \{{ env:NAME }}!"
EXPAND template.md NAME="Alice Smith"
ASSERT_CONTAINS stdout "Hello Alice Smith!"
Example: variable override
# Same escaping: keep the placeholder literal until EXPAND.
# A lone $who evaluates, like ECHO $who.
LET $who: STRING = "Bob"
WRITE template.md "Hi \{{ env:WHO }}!"
EXPAND template.md WHO=$who
ASSERT_CONTAINS stdout "Hi Bob!"
Example: override forms agree
# A bare variable and a template-with-tail expand identically.
LET $x: STRING = "Ada"
WRITE template.md "Hi \{{ env:NAME }} and \{{ env:NAME2 }}!"
EXPAND template.md NAME=$x NAME2="{{ $x }} concatenated"
ASSERT_CONTAINS stdout "Hi Ada and Ada concatenated!"
Example: expand stdin
# No path: the template arrives on stdin through a pipe.
LET $tpl: PIPE
WITH_IO [stdout=$tpl] ECHO "Hello \{{ env:NAME }}!"
WITH_IO [stdin=$tpl] EXPAND NAME=Alice
ASSERT_CONTAINS stdout "Hello Alice!"
Example: override does not leak
# KEY=val overrides shadow env for that EXPAND only.
# They never update the environment itself.
ENV NAME="Alice"
WRITE template.md "Hi \{{ env:NAME }}!"
EXPAND template.md NAME="Bob"
ASSERT_CONTAINS stdout "Hi Bob!"
EXPAND template.md
ASSERT_CONTAINS stdout "Hi Alice!"
Assert strict equality.
Syntax: ASSERT_EQ <actual> <expected> | ASSERT_EQ --hash <sha256> <actual>
Compares two evaluated values with typed equality (no coercion:
INT(42) never equals STRING("42")), aborting the pipeline
with a step-numbered error showing expected vs actual otherwise.
Both sides are values: $var, literals, templates, and calls
evaluate in memory and never touch disk. Read files explicitly
first (LET $text: STRING = READ "out.txt", then
ASSERT_EQ $text ...).
Bare stdout / stderr observe stream buffers; a $var
holding a PIPE observes its backend bytes. --hash compares
the SHA-256 of a string, pipe, or captured-stdout actual
instead of the raw bytes (stderr is unsupported).
Arguments:
| Name | Type | Required | Description |
|---|---|---|---|
actual |
<any> |
yes | Value, stdout, stderr, or a $var holding a PIPE |
expected |
<any>... |
no | Expected (required unless --hash) |
Flags:
| Flag | Type | Description |
|---|---|---|
--hash |
STRING |
SHA-256 |
Examples:
Example: assert eq
LET $status: INT = 200
ASSERT_EQ $status 200
Example: assert eq file
WRITE payload.bin stable-content
LET $body: STRING = READ payload.bin
ASSERT_EQ $body "stable-content"
Example: assert eq hash
# --hash compares the SHA-256 digest instead of raw bytes.
WRITE payload.bin stable-content
LET $body: STRING = READ payload.bin
ASSERT_EQ --hash 08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c $body
Assert containment.
Syntax: ASSERT_CONTAINS <haystack> <needle>
Checks containment and aborts the pipeline with a step-numbered error otherwise: substring for strings, element match for lists, key presence for maps, substring over stream and pipe buffers.
Like ASSERT_EQ, both sides are values read without implicit
I/O; read files explicitly first
(LET $text: STRING = READ "cfg.txt").
Bare stdout / stderr observe stream buffers; a $var
holding a PIPE observes its backend bytes.
Arguments:
| Name | Type | Required | Description |
|---|---|---|---|
haystack |
<any> |
yes | Value, stdout, stderr, or a $var holding a PIPE |
needle |
<any>... |
yes | Substring, element, or key |
Examples:
Example: assert contains
ECHO build-complete
ASSERT_CONTAINS stdout "build-complete"
Print SHA-256.
Syntax: HASH_SHA256 <path>
Computes digest.
Arguments:
| Name | Type | Required | Description |
|---|---|---|---|
path |
PATH |
yes | File |
Output: Stdout
Examples:
Example: hash
WRITE payload.txt hello
HASH_SHA256 payload.txt
LET $digest: STRING = HASH_SHA256 payload.txt
ASSERT_EQ $digest "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\n"
Exit pipeline.
Syntax: EXIT <code>
Stops the pipeline immediately with an EXIT requested with code <code>
error; steps after it never run, at any nesting depth.
Enclosing blocks still unwind their LET/ENV/WORKDIR/WORKSPACE state, anonymous background tasks are killed synchronously, and files written before the EXIT persist.
Arguments:
| Name | Type | Required | Description |
|---|---|---|---|
code |
INT |
yes | Code |
Examples:
Example: exit
EXIT 0
Expected error: EXIT requested with code 0
Pause execution for a duration.
Syntax: SLEEP <duration>
Parks the step for the duration (e.g. 500ms, 10s, 2m).
Cooperative: checks for cancellation so an enclosing TIMEOUT or task teardown interrupts the sleep. Cross-platform alternative to shell sleep for testing time boundaries.
Arguments:
| Name | Type | Required | Description |
|---|---|---|---|
duration |
DURATION |
yes | How long to sleep |
Examples:
Example: sleep
SLEEP 100ms
Example: sleep variable duration
# Durations resolve at runtime, so variables work too:
# quoted or bare, both bind the same string.
LET $pause: STRING = "100ms"
SLEEP $pause
LET $bare: STRING = 100ms
SLEEP $bare
Append an item to a LIST variable in place.
Syntax: LIST_APPEND $list <item>
Appends the item to the LIST variable in place.
When the binding holds the only reference the push runs in amortized constant time. Aliased buffers detach first, so other holders keep their contents.
Arguments:
| Name | Type | Required | Description |
|---|---|---|---|
list |
LIST |
yes | Target LIST variable ($name) |
item |
<any> |
yes | Item to append (any value) |
Examples:
Example: list append
# Appends accumulate in order.
LET $items: LIST = []
LIST_APPEND $items "first"
LIST_APPEND $items "second"
LET $want: LIST = ["first", "second"]
ASSERT_EQ $items $want
64-bit signed integer, e.g. an exit code.
64-bit float, e.g. a ratio.
Arbitrary text. Quotes keep exact bytes, lone $var evaluates, {{ ... }} interpolates.
Boolean true or false.
Ordered list of values. Shared heap: cloning bumps a refcount.
String-keyed map of values. Shared heap: cloning bumps a refcount.
Workspace path, resolved against cwd and guarded against escape.
Positive time span: 500ms, 10s, 2m, 1h; bare number means seconds.
Anonymous pipe handle. The backend materializes lazily on first binding (never eagerly at declaration), so the choice always has full usage context. Cloning shares the backend (explicit-sharing fan-out); equality is handle identity, never byte comparison.
Background ASYNC task handle for AWAIT/CANCEL.
Counting semaphore for admission control. Cloning shares the backend (explicit-sharing fan-out); equality is handle identity, never the count.
Opaque admission permit minted by SEMAPHORE_TRY_ACQUIRE. Cloning
shares the release obligation (first drops release nothing, the last
releases once); equality is handle identity.
Callable as MODULE::NAME(...) in expressions (or bare NAME(...) with the module imported via IMPORT). Introspectable from scripts with FUNCTIONS() and DESCRIBE(name).
Signature: STD::DESCRIBE($name: STRING) -> MAP
Contexts: AST only
Describe one function by qualified name.
Returns a MAP with name, module, kind, params, returns, and summary.
Bare names fail closed: DESCRIBE requires the qualified form (except
INSPECT, which is syntax rather than a registry entry). Errors on
unknown function.
Signature: STD::FLOAT($val) -> FLOAT
Contexts: AST, RPN
Convert a value to FLOAT.
Parses f64 (accepts int strings), bails on non-finite or non-numeric.
Signature: STD::FUNCTIONS() -> LIST
Contexts: AST only
List all visible function names.
Sorted LIST of qualified MODULE::NAME entries: DSL-defined plus native
plus host-registered names.
Signature: STD::GLOB($pattern: STRING) -> LIST
Contexts: AST, RPN
List workspace paths matching a glob pattern.
Sorted, root-relative LIST; empty on no match or .. escape.
Signature: STD::INT($val) -> INT
Contexts: AST, RPN
Convert a value to INT.
Trims ASCII whitespace and parses i64. Passes Int through; Float only when integral and finite.
Signature: STD::IS_TERMINAL($stream: STRING) -> BOOL
Contexts: AST only
Report whether a standard stream is a terminal.
IS_TERMINAL("stdin"), IS_TERMINAL("stdout"), or IS_TERMINAL("stderr")
answers for the step's stream as currently bound, so scripts can adapt
prompts, colors, and progress output. Anything diverted from the
terminal reports false without touching host handles: WITH_IO pipe
bindings (script backends and OS pairs), LET-capture sinks, staged
runner sinks, and any materialized stdin stream (only a directly
inherited fd falls back to the process check). A transparent root tee
still answers the session question via the process check. The name
matches exactly (no case folding): anything else bails. AST-only:
reads the step context like the other introspection functions.
Signature: STD::LOAD_JSON($path: STRING) -> MAP
Contexts: AST, RPN
Load and parse a JSON file.
Reads a workspace file and parses JSON into a DSL value.
Signature: STD::LOAD_TOML($path: STRING) -> MAP
Contexts: AST, RPN
Load and parse a TOML file.
Reads a workspace file and parses TOML into a DSL value.
Signature: STD::PATH_TYPE($path: STRING) -> STRING
Contexts: AST only
Describe a filesystem entry.
Reports file, dir, symlink (no-follow), or absent. AST-only by design; there is no RPN arm for filesystem IO.
Signature: STD::SEMAPHORE_AVAILABLE($sem) -> INT
Contexts: AST, RPN
Read free permits under the lock, with no mutation.
Observability only (audit lines, healthchecks: active = max - free).
Exact at read time and stale the instant the caller acts on it, so it
must never drive admission: that is SEMAPHORE_TRY_ACQUIRE's job.
LET $free: INT = SEMAPHORE_AVAILABLE($sem)
Signature: STD::SEMAPHORE_NEW($max: INT) -> SEMAPHORE
Contexts: AST only
Create a counting semaphore admitting at most max concurrent holders.
Non-positive maxima bail. The word names a shared backend: every clone
observes the same count, and admission runs through
SEMAPHORE_TRY_ACQUIRE, never through the SEMAPHORE_AVAILABLE
readout.
LET $sem: SEMAPHORE = SEMAPHORE_NEW(10)
Signature: STD::SEMAPHORE_TRY_ACQUIRE($sem) -> MAP
Contexts: AST only
Attempt one non-blocking acquire, always answering a MAP.
held is 1 with the permit under the permit key, or 0 with no
permit key: branch on $m.held == 1 (an INT compare; bare IF $m.held
is a Type Error). The DSL has no null, so the absent key is the miss
shape. Do not read $m.permit unless held == 1: missing-key access
bails strictly.
Never waits, so no wait can wedge.
LET $acq: MAP = SEMAPHORE_TRY_ACQUIRE($sem)
IF $acq.held == 0 {
ECHO "at cap, rejecting"
} ELSE {
LET $permit: PERMIT = $acq.permit
ASYNC { session work }
}
Signature: STD::TYPES() -> LIST
Contexts: AST only
List all known type names.
Sorted LIST of startup plus host-registered type descriptors. Reads the run's name directory, so it runs on the AST path like the other introspection functions.
Signature: STD::TYPE_DESCRIBE($name: STRING) -> MAP
Contexts: AST only
Describe one type by name.
Returns a MAP with name, summary, and docs. Errors on unknown type. Reads the run's name directory, so it runs on the AST path.
Full function and type references for the bundled host plugins live in their own READMEs:
- SSH plugin reference: ephemeral loopback SSH servers and session pumps.
- NET plugin reference: virtual-endpoint TCP listeners, pumps, and memory sessions.
Parse errors tell you what went wrong, where, and what was expected.
Every error names the line and column, echoes the source line, and points
a caret at the offending text. A syntax error is never reported as
unknown command: any line starting with a known command or keyword
always explains the real problem instead. Each example below runs the
real parser and pins its exact output.
A typo in the command name reports the line with a caret:
use indoc::indoc;
use oxdock::oxdock_parser;
let script = indoc! {"
FROBNICATE hi
"};
let err = oxdock_parser::parse_script(script, oxdock_parser::lower_command)
.expect_err("must fail");
let expected = indoc! {"
unknown command: FROBNICATE
--> line 1, col 1-13
1 | FROBNICATE hi
| ^^^^^^^^^^^^^
"};
assert_eq!(err.to_string(), expected.trim_end());A malformed line for a real command reports the expected syntax with an
example. Here the LET is missing its required type:
use indoc::indoc;
use oxdock::oxdock_parser;
let script = indoc! {"
LET $x = 1
"};
let err = oxdock_parser::parse_script(script, oxdock_parser::lower_command)
.expect_err("must fail");
let expected = indoc! {"
invalid syntax for command LET: LET assigns a variable, e.g. `LET $name: STRING = <expr>`, `LET $t: HANDLE = ASYNC ...`, `LET $out: STRING = <command>` (capture), `LET $out: STRING = AWAIT $t`, or `LET $var: TYPE = { ... }` (inline block); got `$x = 1`.
--> line 1, col 1-10
1 | LET $x = 1
| ^^^^^^^^^^
"};
assert_eq!(err.to_string(), expected.trim_end());A valid command with malformed WITH_IO bindings explains the binding
rules instead of claiming the command does not exist:
use indoc::indoc;
use oxdock::oxdock_parser;
let script = indoc! {"
WITH_IO [stdout=discard] ECHO hi
"};
let err = oxdock_parser::parse_script(script, oxdock_parser::lower_command)
.expect_err("must fail");
let expected = indoc! {"
invalid syntax for command WITH_IO: WITH_IO needs `WITH_IO [bindings] <command>` or `WITH_IO [bindings] { <commands> }`: invalid binding `stdout=discard`; bindings are `stdin`, `stdout`, `stderr`, or `<stream>=$var` with a PIPE-typed variable (e.g. `[stdout=$p]`, `[stdin=$p]`); got `[stdout=discard] ECHO hi`.
--> line 1, col 1-32
1 | WITH_IO [stdout=discard] ECHO hi
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
"};
assert_eq!(err.to_string(), expected.trim_end());Lowercase commands are caught by the grammar with a correction note:
use indoc::indoc;
use oxdock::oxdock_parser;
let script = indoc! {"
echo hi
"};
let err = oxdock_parser::parse_script(script, oxdock_parser::lower_command)
.expect_err("must fail");
let expected = indoc! {"
parse error (expected: script)
note: command must be uppercase: found `echo`, expected `ECHO`
--> line 1, col 1-1
1 | echo hi
| ^
"};
assert_eq!(err.to_string(), expected.trim_end());Reading an error, top to bottom: the first line states the kind and the
fix (expected syntax or a did you mean hint), the --> line gives the
exact position, and the caret marks the token to change. For block and
guard mistakes the caret covers the statement; for argument mistakes such
as a bad WITH_IO binding or a wrong FOR key type it points at the
specific token.
Scripts no longer inherit the caller's environment wholesale. Host variables stay private unless you opt in explicitly.
- Add
INHERIT_ENV [FOO, BAR, BAZ]at the very top of the script to copy those keys from the process environment before any other command runs. - The directive must be top-level: no guards, no surrounding blocks, and no repeats. Trying to nest or guard it triggers a parser error so scripts stay deterministic.
- Subsequent
ENVcommands can override inherited values, similar to how Docker'sENVoverrides--envflags. - Test harnesses and embedders can supply values programmatically; the environment-guards example injects
DEPLOY_TARGETthrough the docs-conformance runner rather than the real process environment.
Keeping inheritance selective avoids leaking secrets by default while still allowing ergonomics for well-known keys (proxy settings, artifact caches, etc.).
-
Cross-platform behavior: Paths in OxDock scripts are treated as filesystem paths and are resolved using Rust's
Path/PathBufAPIs. That means you can use either/-separated paths or./-prefixed relative paths in scripts and they will be interpreted correctly on Windows, macOS, and Linux. -
Path separator preference / requirement: For consistency and portability, OxDock scripts should use the forward slash (
/) as the path separator in script source. While the runtime resolves paths using platform APIs and will accept platform-specific absolute paths, using/in scripts (even on Windows) avoids needing to escape backslashes (\) and matches Docker-style examples. If you must reference a native Windows absolute path, prefer theC:/path/toform or escape backslashes carefully. -
Relative paths: A leading
./indicates a path relative to the current DSL working directory (the same semantics used by Docker). For example:COPY ./src ./outorSYMLINK ./dir ./dir-linkwill work on all platforms. -
Absolute paths: Use platform-appropriate absolute paths (e.g.,
/usr/binon Unix-like systems,C:\path\toon Windows). OxDock will use the host OS path semantics when resolving absolute paths. -
Symlinks and Windows: Creating symlinks on Windows may require elevated permissions on some older OS versions; without permission the
SYMLINKstep fails with an error instead of falling back. -
Globbing & shell expansion: OxDock does not implicitly perform shell globbing or shell-side expansion for file arguments. When you need shell semantics use
RUNwith the platform shell, or add explicit DSL commands that accept wildcards if you want portable behavior.
-
How workspaces are created: OxDock materializes a clean workspace as an isolated temporary directory. It does not implicitly populate that directory from Git; scripts can pull files in via
COPY(from the build context) orCOPY_GIT(from a specific revision). Treat this workspace as a scratchpad surface for experimentation: you can run scripts inside it, create or modify files, and prepare assets for publishing without affecting your main source tree or requiring--allow-dirtyworkflows. -
Typical usage pattern: the temporary workspace is intended for short lived build and test iterations. Run scripts against it, inspect outputs, and discard when done. Because it is separate from the original repo it is safe to run multiple concurrent experiments without changing the original repo.
-
Four workspace roots:
WORKSPACE SNAPSHOT(the default ephemeral temp location),WORKSPACE LOCAL(the local directory),WORKSPACE CACHE(a persistent per-project cache directory shared across runs), andWORKSPACE SYSTEM(full filesystem access, not hermetic).WORKSPACEselection reverts at scope exit likeWORKDIR. -
Persistent cache:
WORKSPACE CACHEstores artifacts under the OS per-user cache, namespaced by application identity<app>, with aworkspacegroup segment underneath. Concretely: macOS~/Library/Caches/com.oxdock.<app>/workspace, Linux$XDG_CACHE_HOME/<app>/workspace(or~/.cache/<app>/workspace), Windows%LOCALAPPDATA%\oxdock\<app>\cache\workspace. Identity resolves as explicit builder argument,OXDOCK_CACHE_APP, runtimeCARGO_PKG_NAME, running binary name, then"oxdock";OXDOCK_CACHE_DIRpins an exact directory instead (OS flavor only), and when no home directory is available the cache falls back to a temp dir (oxdock-cache-<app>).WORKSPACE CACHE --localkeeps the cache in<project>/.cache/workspaceinstead, unconditionally. The directory is created on first use, survives restarts, and is never evicted by default. -
Filesystem gating via
oxdock-fs: all filesystem operations in the runtime are routed through the crate internaloxdock-fsabstraction. That module centralizes path resolution, canonicalization and access checks so reads and writes can be validated against the allowed workspace root and build context.WORKSPACE SYSTEMintentionally bypasses these checks; scripts using it are not hermetic. -
What
oxdock-fsprotects you from: the guardrails are pragmatic. They prevent common mistakes such as accidentally writing outside the materialized workspace or reading files from arbitrary absolute paths. However, they are not a full sandbox. A determined process or script can still create destructive actions (for example, invoking nativeRUNcommands that modify external state). If you require strict isolation, run OxDock inside a container or VM. -
Performance: routing via
oxdock-fsadds negligible overhead for typical workloads. The module focuses on correctness and containment with minimal runtime cost so interactive iteration remains fast.
Every ```oxdock fence in this document is extracted with oxdock_parser::extract_fenced_blocks and executed by crates/oxdock-logic-tests/tests/docs_conformance.rs against the real parser and interpreter, so the documentation cannot drift from the implementation. Enforcement layers:
- Parse & execute: Every snippet must parse and run clean (or fail with its declared
expect_error:message) on Linux, macOS, and Windows CI. - Coverage gates: Every parser command must appear in at least one executable example, and key structural features (
any(,not(,{{ env:,[env:) must be demonstrated. - Compile-time parity: A build-time fixture runs this README's quick-start script through
oxdock_embed!, assertions included. - Real-binary check: The quick start is additionally executed through the actual
oxdockbinary exactly as documented (--script Oxfile). - Doctest execution: The Rust quick start is wired into
crates/oxdock-doc-testsand compiled and run bycargo test --docon every CI OS. - Reference integrity: Every relative Markdown link target and every repo path referenced from a
```bashfence must exist.
Snippets contain nothing but OxDock: copy any of them straight into an Oxfile or an oxdock_embed! macro. Runner-specific configuration lives in the fence info-string, which Markdown renders as inert metadata:
```oxdock plain snippet, must parse and run clean
```oxdock env:KEY=value inject an environment value (visible to INHERIT_ENV/guards)
```oxdock roots:unified run with workspace root == build context (COPY/COPY_GIT demos)
```oxdock expect_error:"message substring" snippet must fail with this text in its error
Everything else you see inside the fences, including the ASSERT_* commands, is part of the DSL itself and executes identically in your own pipelines.
If you change the DSL, update this reference in the same commit. CI will hold you to it.
Environment variables understood by the toolchain (workspace roots, caching fingerprints, IDE integrations) are specified in ENV_CONTRACTS.md.
OxDock scripts can emit GitHub Actions workflow commands using native DSL primitives.
Steps that only make sense on a runner live inside [env:GITHUB_ACTIONS] blocks:
guards consult the script environment, so each snippet first bridges the runner
variable in with INHERIT_ENV. Where GITHUB_ACTIONS is absent the whole block
skips and docs_conformance still passes; on a hosted runner it executes.
ECHO writes to stdout, which GitHub Actions intercepts for annotations:
INHERIT_ENV [GITHUB_ACTIONS]
[env:GITHUB_ACTIONS] {
ECHO "::notice::test notice message"
ECHO "::warning::test warning message"
ECHO "::error::test error message"
}
Group markers go through ECHO: no shell required:
INHERIT_ENV [GITHUB_ACTIONS]
[env:GITHUB_ACTIONS] {
ECHO "::group::unit tests"
ECHO "running tests"
ECHO "::endgroup::"
}
APPEND writes to append-only runner state files without truncating earlier entries:
INHERIT_ENV [GITHUB_ACTIONS]
[env:GITHUB_ACTIONS] {
APPEND dist/summary.md "### Build Report\n- Passed: 123\n- Failed: 0\n"
APPEND dist/outputs.txt "artifact_path=dist/app.tar\n"
APPEND dist/env.txt "NOTEBOOK_MODE=release\n"
}
On GitHub Actions, replace the paths with the runner-provided env vars ({{ env:GITHUB_STEP_SUMMARY }}, {{ env:GITHUB_OUTPUT }}, {{ env:GITHUB_ENV }}).
Testing is performed across Linux, Mac, and Windows environments, and UB (Undefined Behavior) testing is handled by Miri.
There is strong prioritization in keeping unit and integration tests compatible with Miri, because doing so also encourages clean separation of process and filesystem modeling from direct OS calls, avoiding scattered filesystem and process usage throughout the codebase.
The coverage (cargo-llvm-cov) GitHub Actions job installs cargo-llvm-cov and publishes lcov data to Coveralls. Once the repository is enabled on Coveralls, pushes and pull requests to main automatically update the badge above.
To reproduce the report locally (requires the nightly LLVM tools component):
cargo install cargo-llvm-cov
rustup component add llvm-tools-preview
cargo llvm-cov --workspace --all-features --lcov --output-path lcov.infoThe CI miri job monitors how many workspace unit tests can run under cargo miri. On pushes to main, the job publishes a badge description (badges/miri-coverage.json on the badges branch) that backs the Miri coverage badge above.
To keep the badge grounded in real coverage reporting, the workflow multiplies two signals:
- Runnable test ratio: how many workspace tests are runnable under Miri vs. the total (
cargo miri test -- --list). - LLVM line coverage baseline: the percent reported by
cargo llvm-cov --summary-only(the same value sent to Coveralls).
The badge therefore shows an approximate “effective Miri coverage” (baseline coverage × runnable ratio), which can never exceed the standard coverage percentage but gives a tangible sense of how much of the tested surface area is validated under the runner.
To test the calculation locally without waiting for CI:
cargo llvm-cov --workspace --all-features --summary-only > coverage-summary.txt
BASE_LINE_COVERAGE=$(awk '/^TOTAL/ {print $10}' coverage-summary.txt | tr -d '%' | head -n1) \
scripts/.github/miri-badge-report.shThe helper emits the same badge JSON (badges/miri-coverage.json) and summary text used by CI, making it easy to confirm the numbers before opening a PR.
If you run new tests under Miri locally, you can sanity-check parity with CI via:
cargo +nightly miri setup
cargo +nightly miri test --workspace --all-features --lib --tests- AST: The parsed tree of a script. Statements, declarations, and IO walk this tree one step at a time.
- Descriptor: One type's static singleton: its name, docs, and the vtable every value of that type carries.
- Fat pointer: A pointer that carries metadata alongside the address. Words avoid them: every payload type is sized, so payload pointers stay thin.
- Heap: Memory for data that outgrows the stack. Container contents and non-inline payloads live here, each owned by exactly one word.
- Inline: Payload bytes carried inside the word itself, with zero allocation. Available to
Copyscalars that fit in 64 bits. - NaN boxing: A technique that packs values into 64 bits by reusing NaN float patterns. Denser than 16 byte words at the cost of pointer masking and constrained host values.
- Payload: The 64 bit data half of a word: either inline bytes or a pointer to one owned box.
- Plugin: A host extension module (NET, SSH) loaded with IMPORT, bringing extra functions into script scope.
- Provenance: The recorded origin of a pointer, which Rust uses to judge whether a memory access is valid. Round tripping through the same box type preserves it.
- RPN: Reverse Polish Notation: arithmetic compiled to a flat stack program instead of tree walking.
- Vtable: The operations half of a descriptor: function pointers that clone, drop, compare, and render values of that type.
- Word: The fixed 128 bit unit of every script value: a descriptor pointer plus a payload.
OxDock is distributed under the terms of the Apache License (Version 2.0).