Skip to content

Repository files navigation

OxDock logo

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.

Documentation

Jump to the command reference below for the full command list with runnable examples.

Quick start

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.

Run scripts inline

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.

Extend the language

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.oxfile

Runtime architecture

Four 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.

Values: words, not objects

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.

Transport: pipes

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"

Scope isolation

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"

Sandboxing

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"

Prepare during the build

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() {}

Stream bytes between steps

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 SNAPSHOT

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"

Extending OxDock from Rust

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.

Complete example: define a type, define functions, run a script

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.

Reference: stateful functions

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).

Reference: inline types

#[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:

  1. Fixed 64 bit slot. Anything larger panics at mint time (a runtime check, not a compile time one).
  2. Copy payloads only. Inline clones copy bits and drops do nothing, which resource owning types cannot satisfy.
  3. No variable length data. STRING, LIST, and MAP can never fit a fixed slot and stay behind the pointer.
  4. 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.

Reference: queryable containers

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(())
}

Reference: evaluation paths and the rpn flag

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.

Why embedding Rust here stays simple

Compared against embedding Rust in Python, there are three structural reasons the host boundary stays small, and none of them are API polish.

No foreign runtime to host

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.

No binding layer

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 build system dance

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.

Variants

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.

Goals

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.

DSL Reference

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.

Lexical structure

  • Commands are uppercase and case-sensitive: WORKDIR, not workdir. 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_ENV opts specific keys in (see Selective environment inheritance).

Declared variable types

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"

Functions and commands

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 ""

Statements and semicolons

// One line, two instructions: the semicolon splits them.
ECHO one; ECHO two
ASSERT_CONTAINS stdout "one"
ASSERT_CONTAINS stdout "two"

RUN shell and exec forms

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"

Comments

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.

Command flags

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"

Quoting and escaping

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"

Templates

{{ 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"

Guards and scoped blocks

A guard is a bracketed expression that gates the instruction or block that follows it. Inside the brackets:

  • env:KEY passes when variable KEY exists and is non-empty; eq(env:KEY, value) and ne(env:KEY, value) compare values.
  • Bare platform tags pass based on the host: linux, macos (alias mac), 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.

Environment guards

// 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

Platform guards

// 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"
}

Negation, disjunction, and composition

// 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"

Multi-line guards

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"

Scoped blocks

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 in nested blocks

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"
}

Deadlines with TIMEOUT

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.

Cancelling tasks with CANCEL

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

Command Reference

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>

WITH_IO

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"

FOR

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!"

IF

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"

LET

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"

MUTATION

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

ASYNC

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"

AWAIT

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"

CANCEL

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

TIMEOUT

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"

FUNC

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

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"

WHILE

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"

BREAK

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"

CONTINUE

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"

IMPORT

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!"

WORKDIR

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"

WORKSPACE

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_DIR pins an exact directory); WORKSPACE CACHE --local keeps it in <project>/.cache/workspace instead.
  • 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"

ENV

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

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 ":"

ECHO

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"

RUN

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

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_GIT

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

SYMLINK

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"

MKDIR

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"

LS

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"

CWD

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

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_LINE

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

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

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

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_EQ

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_CONTAINS

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"

HASH_SHA256

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

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

SLEEP

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

LIST_APPEND

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

Value types

Value type: INT

64-bit signed integer, e.g. an exit code.

Value type: FLOAT

64-bit float, e.g. a ratio.

Value type: STRING

Arbitrary text. Quotes keep exact bytes, lone $var evaluates, {{ ... }} interpolates.

Value type: BOOL

Boolean true or false.

Value type: LIST

Ordered list of values. Shared heap: cloning bumps a refcount.

Value type: MAP

String-keyed map of values. Shared heap: cloning bumps a refcount.

Value type: PATH

Workspace path, resolved against cwd and guarded against escape.

Value type: DURATION

Positive time span: 500ms, 10s, 2m, 1h; bare number means seconds.

Value type: PIPE

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.

Value type: HANDLE

Background ASYNC task handle for AWAIT/CANCEL.

Value type: SEMAPHORE

Counting semaphore for admission control. Cloning shares the backend (explicit-sharing fan-out); equality is handle identity, never the count.

Value type: PERMIT

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.

Functions

Callable as MODULE::NAME(...) in expressions (or bare NAME(...) with the module imported via IMPORT). Introspectable from scripts with FUNCTIONS() and DESCRIBE(name).

STD::DESCRIBE

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.

STD::FLOAT

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.

STD::FUNCTIONS

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.

STD::GLOB

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.

STD::INT

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.

STD::IS_TERMINAL

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.

STD::LOAD_JSON

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.

STD::LOAD_TOML

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.

STD::PATH_TYPE

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.

STD::SEMAPHORE_AVAILABLE

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)

STD::SEMAPHORE_NEW

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)

STD::SEMAPHORE_TRY_ACQUIRE

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 }
}

STD::TYPES

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.

STD::TYPE_DESCRIBE

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.

Plugin references

Full function and type references for the bundled host plugins live in their own READMEs:

When a script fails to parse

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.

Selective environment inheritance

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 ENV commands can override inherited values, similar to how Docker's ENV overrides --env flags.
  • Test harnesses and embedders can supply values programmatically; the environment-guards example injects DEPLOY_TARGET through 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.).

Path Separators

  • Cross-platform behavior: Paths in OxDock scripts are treated as filesystem paths and are resolved using Rust's Path/PathBuf APIs. 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 the C:/path/to form 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 ./out or SYMLINK ./dir ./dir-link will work on all platforms.

  • Absolute paths: Use platform-appropriate absolute paths (e.g., /usr/bin on Unix-like systems, C:\path\to on 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 SYMLINK step 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 RUN with the platform shell, or add explicit DSL commands that accept wildcards if you want portable behavior.

Workspaces & Filesystem

  • 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) or COPY_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-dirty workflows.

  • 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), and WORKSPACE SYSTEM (full filesystem access, not hermetic). WORKSPACE selection reverts at scope exit like WORKDIR.

  • Persistent cache: WORKSPACE CACHE stores artifacts under the OS per-user cache, namespaced by application identity <app>, with a workspace group 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, runtime CARGO_PKG_NAME, running binary name, then "oxdock"; OXDOCK_CACHE_DIR pins 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 --local keeps the cache in <project>/.cache/workspace instead, 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 internal oxdock-fs abstraction. 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 SYSTEM intentionally bypasses these checks; scripts using it are not hermetic.

  • What oxdock-fs protects 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 native RUN commands that modify external state). If you require strict isolation, run OxDock inside a container or VM.

  • Performance: routing via oxdock-fs adds negligible overhead for typical workloads. The module focuses on correctness and containment with minimal runtime cost so interactive iteration remains fast.

How these examples are tested

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 oxdock binary exactly as documented (--script Oxfile).
  • Doctest execution: The Rust quick start is wired into crates/oxdock-doc-tests and compiled and run by cargo test --doc on every CI OS.
  • Reference integrity: Every relative Markdown link target and every repo path referenced from a ```bash fence 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 variable contracts

Environment variables understood by the toolchain (workspace roots, caching fingerprints, IDE integrations) are specified in ENV_CONTRACTS.md.

GitHub Actions Integration

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.

Log annotations

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"
}

Collapsible log groups

Group markers go through ECHO: no shell required:

INHERIT_ENV [GITHUB_ACTIONS]

[env:GITHUB_ACTIONS] {
    ECHO "::group::unit tests"
    ECHO "running tests"
    ECHO "::endgroup::"
}

Job summary, step outputs, and environment variables

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 & Coverage

Testing

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.

Coverage reporting

LLVM line coverage (cargo-llvm-cov)

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.info

Miri coverage

The 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:

  1. Runnable test ratio: how many workspace tests are runnable under Miri vs. the total (cargo miri test -- --list).
  2. 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.sh

The 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

Glossary

  • 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 Copy scalars 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.

License

OxDock is distributed under the terms of the Apache License (Version 2.0).

About

An experimental DSL used for building embeddable artifacts and orchestrating pipelines.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages