Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Relux Semantic Model

Modules

  • Every .relux file is a module
  • A module can contain any combination of: imports, functions, effects, tests
  • There is no distinction between “library” and “test” modules
  • Module path is its filesystem path relative to the project root (e.g. lib/matchers resolves to lib/matchers.relux)
  • The project root is defined by the location of Relux.toml

Imports

  • Imports resolve from the project root, never relative to the importing file
  • Selective imports bring specific names into scope: import lib/m { foo, bar, StartDb }
  • Wildcard imports bring all names into scope: import lib/m
  • as aliases rename an imported name locally: foo as f, StartDb as Db
  • Aliases must preserve the casing kind: lowercase names get lowercase aliases, CamelCase names get CamelCase aliases
  • Each module is loaded once regardless of how many files import it
  • Circular imports are a parse error

Variables

  • All variable values are strings, no other types exist
  • Uninitialized variables (let x) default to empty string ""
  • Variables are scoped to their enclosing block (test, shell, fn, effect)
  • Inner blocks can shadow outer variables with a new let declaration
  • Binding uses :=: declaration with a value (let x := expr), reassignment (x := expr), and overlay entries ({ KEY := expr }) all use it; bare = is no longer a binding operator (it serves as the literal-match arm inside a multimatch block)
  • Reassignment (x := expr) mutates an existing variable from an outer scope
  • Environment variables from the host process are available as pre-set variables in all scopes (read-only — let creates a shadow, not a modification of the process environment)
  • Hierarchical .env files, when present, layer over the host process environment and take precedence over it; their values feed interpolation, marker evaluation, and the shell under test
  • Regex capture groups ($1, $2, …) are set after a <? match or a ? pure match and remain in scope until overwritten by the next regex match. Inside a pure fn body a ? pure match binds $1..$n into the function’s own per-call frame, discarded when the function returns (they do not leak to the caller, just as a called fn’s captures do not leak out)

Injected Environment

  • The runtime injects eight __RELUX_* variables into every shell, layered above the .env stack so no .env or host value can shadow them
  • Run-level, shared by every test in the run:
    • __RELUX_RUN_ID — identifier of the current run; appears in the relux/out/<run>/ directory name alongside a timestamp
    • __RELUX_RUN_ARTIFACTS — absolute path to relux/out/<run>/artifacts/; shared across the run (visible to every test concurrently) and not collected into any test’s artifacts[]
    • __RELUX_SHELL_PROMPT — the configured shell prompt; mirrors [shell].prompt
    • __RELUX_SUITE_ROOT — absolute path to the directory containing Relux.toml
    • __RELUX — absolute path to the relux binary; unset if the runtime cannot resolve its own executable path
  • Per-test:
    • __RELUX_TEST_ID — stable mnemonic id hashed from the test’s file path and name; stable across runs and reruns
    • __RELUX_TEST_ARTIFACTS — absolute path to this test’s own artifacts directory; the only one the runtime scans, so test- and effect-produced output belongs here — files appear in the test’s artifacts[] and are bundled with its log
    • __RELUX_TEST_ROOT — absolute path to the directory containing the test file; unset when that path has no parent

Functions

  • Function and shell names must start with a lowercase letter or underscore (snake_case) — this is enforced at the syntactic level
  • Functions are reusable sequences of statements
  • A function executes in the caller’s shell context — it has no shell of its own
  • Functions can only be called inside shell blocks (since shell operators require an active shell)
  • The return value is the last expression’s value in the body
  • If the caller doesn’t capture the return value, it is discarded
  • Side effects persist in the caller’s shell: a function that sets ~30s or !? error changes the shell’s timeout/fail-pattern for subsequent statements
  • Functions can call other functions
  • Functions can use imports from their own module

Pure Functions

  • Declared with pure fn instead of fn
  • Cannot contain shell operators (>, =>, <?, <=, !?, !=, timeouts)
  • Cannot call impure built-in functions (e.g., match_prompt(), ctrl_c(), sleep(), log(), annotate())
  • Cannot call regular fn functions — only other pure functions and pure built-in functions
  • Can only contain: let declarations, variable reassignment, expressions, and pure-match statements (<expr> = <pattern> / <expr> ? <pattern>)
  • A ? pure match inside the body binds $1..$n into the call’s own capture frame, so a pure fn can extract a value and return it; a non-matching pure match fails the test through the runtime site that called the function (a marker condition is the exception — a pure-eval failure there is falsy, not a test failure)
  • Can be called from condition markers, overlay expressions, and regular shell blocks
  • “Pure” means shell-independent, not side-effect-free — a pure BIF may still read the filesystem (which()) or the clock (timestamp()), and need not be deterministic (uuid(), rand()). sleep(), log(), and annotate() require a shell context and are impure

Shells

  • A shell is a spawned PTY process (default: /bin/sh)
  • stdout and stderr are merged into a single output stream
  • Send operators (>, =>) write to the shell’s stdin
  • Match operators (<?, <=) assert against the shell’s accumulated output
  • Match operations block until a match is found or the timeout expires
  • A timeout expiry is a test failure
  • Any match operator can include an inline timeout override (<~dur or <@dur):
    • Applies only to that single operation (one-shot)
    • Does not affect the shell’s scoped timeout
    • Duration uses compact humantime format (no spaces): 2s, 500ms, 1m30s
  • Timeouts come in two kinds:
    • Tolerance (~) — scaled by --timeout-multiplier. Used for operations that may be slower under load
    • Assertion (@) — never scaled. Used to assert the system responds within a hard deadline
  • Each shell has one active fail pattern slot — if shell output matches the fail pattern, the test fails immediately
    • Fail patterns are checked inline during match operations (under the same lock as consume) and at statement boundaries
    • Setting a fail pattern immediately rescans the buffer for the pattern
    • An empty fail pattern operator (!? or != with no payload) clears the active fail pattern
  • A match operator with no payload (<? or <= with nothing after it) resets the output buffer cursor, consuming all current output
  • Each shell has one active timeout value, initially set to a framework default
  • Multiple shell <name> blocks with the same name in a test/effect refer to the same shell (switching the active shell, like lux’s [shell name])
  • A multi-pattern match block (<{ <line>+ }) waits for several patterns at once:
    • Each inner line is ? <pattern> (regex) or = <pattern> (literal); patterns are mixed freely
    • The block is atomic with respect to the cursor: every byte that arrives is offered to every still-unmatched pattern, the cursor sits still until block exit
    • A pattern transitions to matched the first time it succeeds against the slice [block_entry, current_buffer_end]; once matched, it no longer participates in subsequent scans
    • The block completes when every pattern has matched at least once
    • At block exit the cursor advances once, to the maximum of the per-pattern match-end offsets (overlapping matches are permitted; duplicate inner patterns are independent slots that may land on the same bytes)
    • Capture groups in inner regex patterns do not bind - $n is not written by a multimatch block
    • If the block timeout fires before all patterns have matched, the test fails; the failure record lists every pattern with its matched/unmatched status
    • Fail patterns remain active during the block; a fail-pattern hit aborts the block exactly as it would abort a single <? / <=
    • The inline-timeout prefix shape <~Ns{ ... } / <@Ns{ ... } carries the standard tolerance/assertion semantics, applied to the whole block

Pure Matching

  • A pure match asserts that a computed value satisfies a pattern; it reads nothing from a shell (contrast the <? / <= operators, which scan the output buffer)
  • Two statement forms, valid in shell blocks, fn bodies, pure fn bodies, and test / effect preambles (alongside let, before the first shell block):
    • <expr> = <pattern> — asserts <expr> equals <pattern> exactly (byte-for-byte literal equality; a superstring or partial overlap fails). This is the same exact-equality semantics as the marker = operator
    • <expr> ? <pattern> — asserts <expr> matches the regex <pattern> (unanchored, like <?)
  • <pattern> is an interpolated string (${var} resolved before comparison); <expr> is any pure expression
  • A pure match is an assertion: a no-match is an immediate test failure (Relux has no error handling), the same as a <? that never matches. There is no negated form and no timeout — the value is already in hand, so the match either succeeds or fails at once
  • A successful ? binds the numeric capture groups $0, $1, …, $n in the current shell, exactly like <?; they persist until the next regex match overwrites them. The = form binds no captures
  • A ? pattern that fails to compile (only reachable when interpolation produces malformed regex) is a runtime failure, not a no-match
  • Inside a pure fn body, a ? pure match binds numeric captures into the function’s own per-call capture frame (each call starts empty), so a pure fn can match $1 out of a value and return it. A no-match inside a pure fn fails the test through the runtime site that called it (let, overlay, shell-block call); inside a marker condition, a pure-eval failure instead makes the condition falsy rather than failing the test
  • In a test or effect preamble, a ? pure match binds $n into a single capture frame hoisted across the whole preamble: later preamble lets, later preamble pure matches, and a start overlay’s expressions all read it. A non-matching preamble pure match fails the test (test-level) or fails effect instantiation (effect-level). $n reads the ambient frame uniformly, rendering "" when no regex has run, so a $n with no preceding match is not an error
  • Shells do not inherit the preamble capture frame. A shell block owns its own frame, populated only by its own <? matches, starting empty; $n inside a shell reads the shell’s frame, never the preamble’s. Carry a preamble capture into a shell by binding it to a let. Markers run before the preamble, so a marker condition sees an empty frame
  • Statement-only: a pure match cannot be a let right-hand side, an overlay value, or a cleanup value. x = e asserts; binding still requires x := e (and let x = e remains an error)

Effects

  • Effect names must start with an uppercase letter (CamelCase) — this is enforced at the syntactic level, disambiguating effects from functions in imports
  • An effect is a reusable setup procedure that produces running shells and computed values
  • An effect has three explicit interface components:
    • expect — declares required environment variables the effect reads; the resolver validates these are satisfiable
    • expose — declares which shells and variables the effect makes available to callers; the expose keyword requires a shell or var discriminator (expose shell db, expose var port); internal shells not listed in expose are terminated after setup
    • start — declares dependency effects with optional env remapping via overlay
  • None of these declarations are mandatory: an effect may have no expect, no start, and no expose
  • start Effect runs the dependency for side effects only — its shells are not accessible
  • start Effect as Alias runs the dependency and makes its exposed shells/variables available via dot-access (shell Alias.shell_name, ${Alias.var_name})
  • Effect aliases (the name after as) must be CamelCase, matching effect naming conventions
  • start Effect as Alias { KEY := expr } provides an overlay that remaps the caller’s environment into the dependency’s environment
    • The shorthand form KEY (without := expr) is equivalent to KEY := KEY
  • An overlay value may also be a qualified reference to a sibling start’s exposed variable: start Api { DB_PORT := Db.port }. This induces an implicit dependency — Api is instantiated only after Db is ready, and reads Db’s exposed value at that point; independent siblings still start in parallel, only the data-dependent edge is serialized
    • The referenced start must be aliased (start Effect as Alias); an unaliased start cannot be referenced. The referenced effect must expose var the referenced variable — referencing a non-exposed or internal binding is a compile error
    • Only variables are referenceable this way: overlay values are strings, so Alias.var can name a sibling’s expose var, never its expose shell — an exposed shell is not a string value
    • Forward references are legal: a start may reference a sibling declared later in the same start-list. A reference cycle among siblings (A { X := B.out } and B { Y := A.out } in the same start-list) is a compile error
    • The rule applies uniformly to test-level and effect-body start-lists, and the reference works nested inside interpolation too: URL := "${Db.host}:${Db.port}"
    • This is the preferred way to route a value an effect produces to a sibling effect that needs it. The older pattern — hoist the value to a test-level let and feed it into both effects’ overlays — still compiles and runs unchanged
  • Effects inherit the full parent environment — overlay entries override specific keys
  • Effect instance identity is determined by (effect-name, evaluated overlay restricted to expect-declared vars):
    • Same identity tuple = same instance (deduplicated, reused)
    • Different identity tuple = separate instances
    • The registry is per-test: identity and reuse apply only within a single test’s dependency graph. Two tests that start the same effect with identical overlays get two independent instances, with independent setups and independent cleanups
  • When a test or effect starts the same effect multiple times with the same evaluated overlay, only one instance is created
  • A dependent’s identity includes any value it sources from a sibling via the Alias.var overlay form above, exactly like any other overlay value: two dependents wired to different producers (e.g. two Api instances reading different Db.port values) get distinct instances
  • Exposed shells are accessed via dot notation: shell Alias.shell_name { ... }
  • Exposed variables are accessed via dot notation in interpolation: ${Alias.var_name}
  • A qualified reference ${Alias.var} in a shell body or cleanup body — at effect level and at test level alike — is validated at compile time: Alias must be one of the enclosing effect’s or test’s own start dependency aliases, and that dependency must expose var the referenced variable, or it is a compile error naming the offending qualifier and variable. (Previously this went unvalidated and resolved to the empty string at runtime.)
  • Exposed variables are only accessible in shell contexts (runtime); test-level and effect-level let bindings cannot reference them (purity violation — let is evaluated at resolve time, before effects are started)
  • Exposed variables are read-only from the caller’s perspective
  • For composed effects, expose can re-export a dependency’s shell or variable: expose shell Dep.shell as public_name, expose var Dep.port as db_port
  • An effect’s setup preamble may contain pure-match statements alongside its lets; a ? match binds $n into a preamble capture frame that later setup lets and start overlays read (the effect’s own shell blocks do not inherit it). A non-matching setup pure match fails effect instantiation, failing every test that depends on the effect
  • Effects run before the test body; the dependency graph is resolved and executed in topological order
  • Circular effect dependencies are a parse error
  • If an effect fails (a match times out during setup), all tests depending on it are failed
  • Each effect has an optional cleanup block that runs when the effect is torn down

Condition Markers

  • Condition markers are placed immediately before test, effect, fn, or pure fn declarations
  • Condition markers evaluate before any shells are spawned
    • Test-level markers are checked before execute_effects
    • Effect-level markers are checked before the effect’s shells are created
    • Function-level markers are checked during resolution; a skipped function causes all tests that call it to be skipped
  • A bare marker (kind only, no modifier) is unconditional:
    • # skip always skips, # flaky always marks flaky, # run is a no-op
  • A conditional marker requires a modifier (if/unless) and an expression
  • Expressions are quoted strings with ${VAR} interpolation or bare numbers:
    • "${CI}" — environment variable reference
    • "literal" — literal string
    • "${HOST}:${PORT}" — compound interpolation
    • 42 — bare number (compared as string)
  • Bare variable identifiers (e.g. CI) are valid in markers
  • Marker expression evaluation uses ENV-only lookup (Arc<LayeredEnv> — the layered host-plus-.env environment) — no frame variables or test-scope variables exist at marker-evaluation time (this scoping is specific to marker conditions; pure evaluation elsewhere, e.g. a ? pure match inside a pure fn, does have a per-call capture frame)
  • Truthiness: empty string or unset variable is false, any non-empty string is true
  • = operator: evaluates both sides, returns the LHS value if it equals RHS exactly, empty string otherwise (unlike the shell literal-match operators <=/!=, which scan accumulated output for a substring; a marker compares against a complete value)
    • An empty RHS matches only an empty or unset LHS
    • For a substring or pattern check, use ? (regex) instead: expr ? value
  • ? operator: evaluates LHS, compiles the regex pattern (with ${var} interpolation), returns the match if found, empty string otherwise
  • Modifier semantics:
    • if acts when the result is truthy
    • unless acts when the result is falsy
  • Kind semantics:
    • skip: skips the test/effect when the condition is met
    • run: skips the test/effect when the condition is NOT met (inverse of skip)
    • flaky: marks the test as flaky — with [flaky].max_retries > 0 in Relux.toml, a failing flaky test is retried from scratch with exponentially increasing tolerance timeouts (base × m^(retry-1)). With max_retries = 0 (default), the marker is documentary only
  • Multiple markers stack with AND semantics: all conditions must pass or the test is skipped
  • When an effect is skipped, all tests depending on it are also skipped
  • When a function is skipped, all tests that call it are also skipped
  • # flaky propagates the same way skip does: a test is marked flaky if it, or any function or effect it reaches, has a # flaky marker whose condition applies

Tests

  • A test is the top-level unit of execution
  • Tests are independent — no test depends on another test’s execution or side effects
  • Condition markers (# skip/run/flaky ...) are placed immediately before the test declaration
  • Test structure (in order):
    1. Doc string (optional """...""")
    2. Preamble: let declarations (test-scoped variables) and pure-match statements (<expr> = <pattern> / <expr> ? <pattern>), interleaved; a ? match binds $n into a preamble capture frame the later preamble items and overlays read
    3. start declarations (effect dependencies)
    4. shell blocks (test body) — each owns its own capture frame; the preamble frame is not inherited
    5. cleanup block (optional)
  • Effects are instantiated and their shells are available before the test body runs
  • A test succeeds if all match operations in all shell blocks pass
  • A test fails if any match operation times out or any fail pattern matches
  • A test is cancelled (a distinct outcome from failure) when execution is stopped before the test could finish: the test’s own ~T timeout fired, the suite-wide timeout fired, fail-fast cut sibling tests short, or the process received SIGINT. Cancelled outcomes exit nonzero in CI, exactly like failures, but they preserve the distinction that the test was not misbehaving

Cleanup

  • Cleanup blocks exist in both effects and tests
  • Cleanup runs in a freshly spawned implicit shell, not in any existing shell
  • Existing shells are terminated automatically by the runtime (cleanup is not for graceful shutdown)
  • Cleanup is for external side effects: temp files, docker containers, log collection
  • Any statement valid in a shell block is valid in a cleanup block
  • Cleanup always executes, regardless of whether the test/effect passed or failed
  • Cleanup failures are logged as warnings but do not change the test result
  • Cleanup order: test cleanup runs first, then effect cleanups

Execution Model

  • The runtime discovers all .relux files, parses them, resolves imports and effect dependencies
  • Tests are the entry points — only modules with test blocks are executed
  • For each test:
    1. Resolve the effect dependency graph
    2. Run effects in topological order (reusing deduplicated instances)
    3. Execute the test body (shell blocks in declaration order)
    4. Run test cleanup
    5. Tear down effect instances (cleanup + shell termination)
  • All shells within a test share the same test-scoped variables
  • Only one shell is “active” at a time — statements execute sequentially, switching shells as blocks are entered

Configuration

Relux.toml

Every Relux project requires a Relux.toml file at the project root. The relux binary discovers this file by searching the current directory and all parent directories.

Scaffold a new project

relux init

Creates Relux.toml and the conventional directory structure in the current directory.

Minimal example

An empty Relux.toml is valid — all fields have defaults:

# empty Relux.toml is valid — all fields have defaults

The name defaults to the directory containing Relux.toml. Override it explicitly if needed:

name = "my-test-suite"

Full example

name = "my-test-suite"

[shell]
command = "/bin/sh"
prompt = "relux> "

[timeout]
match = "5s"
test = "5m"
suite = "10m"

[run]
jobs = 1

[flaky]
max_retries = 0
timeout_multiplier = 1.5

[available_ports]
range_start = 20000 # example override; the default window is OS-derived (see below)
range_end = 29999   # example override; the default window is OS-derived (see below)

Root-level fields

FieldTypeDefaultDescription
namestringdirectory containing Relux.tomlSuite name

[shell] section

FieldTypeDefaultDescription
commandstring/bin/shShell executable spawned for each shell
promptstringrelux> PS1 prompt set on shell init

[timeout] section

All durations use humantime format (e.g. 5s, 1m30s, 2h).

FieldTypeDefaultDescription
matchduration5sPer-match timeout
testduration5mMax wall-clock time per test
suiteduration10mMax wall-clock time for the entire test run

[run] section

FieldTypeDefaultDescription
jobsinteger1Number of parallel test workers

[flaky] section

FieldTypeDefaultDescription
max_retriesinteger0Max retries for # flaky-marked tests (0 = no retries)
timeout_multiplierfloat1.5Exponential timeout multiplier base for flaky retries (must be > 1.0)

[available_ports] section

Bounds for the window available_port() allocates from. Both bounds are inclusive and independently optional — a missing bound keeps its kernel-derived default.

KeyTypeDefaultDescription
range_startintegerOS privileged boundary (usually 1024)First allocatable port (inclusive)
range_endintegerone below the OS ephemeral intervalLast allocatable port (inclusive)

Bounds must be at least 1024, and range_start must not exceed range_end. A window that comes out empty against the detected defaults (e.g. range_start above the ephemeral boundary) is rejected at startup. An explicit range should stay outside the OS ephemeral interval — relux does not second-guess an override. See the built-in functions chapter for why the window avoids the ephemeral range.

Project structure

project-root/
├── Relux.toml
└── relux/
    ├── tests/       # test files (*.relux)
    ├── lib/         # reusable functions and effects
    ├── out/         # run output (auto-generated)
    │   ├── run-2025-03-05-…/
    │   └── latest -> run-2025-03-05-…
    └── .gitignore   # ignores out/
  • relux/tests/ — test files are discovered recursively when relux run is invoked without --file flags.
  • relux/lib/ — library files are always loaded alongside tests to make functions and effects available. May be empty or absent.
  • relux/out/ — run output directory. Each run creates a timestamped subdirectory. A latest symlink points to the most recent run.

CLI reference

relux init

Scaffolds a new project in the current directory. Errors if Relux.toml already exists.

relux new --test <module_path>

Creates a test module file from a template. The module path uses / separators and each segment must be lowercase alphanumeric with underscores ([a-z_][a-z0-9_]*). The .relux extension is optional.

relux new --test foo/bar/baz       # creates relux/tests/foo/bar/baz.relux
relux new --test foo/bar/baz.relux # same

relux new --effect <module_path>

Creates an effect module file from a template in relux/lib/. Same path rules as --test.

relux new --effect network/tcp_server       # creates relux/lib/network/tcp_server.relux

relux new --lib <module_path>

Creates a library module file (pure and impure functions) from a template in relux/lib/. Same path rules as --test.

relux new --lib utils/helpers               # creates relux/lib/utils/helpers.relux

relux run [flags]

Runs tests. Discovers Relux.toml by walking upward from the current directory.

Use -f/--file to specify files or directories. Directories are searched recursively for *.relux files. If no --file flags are given, tests are discovered from relux/tests/.

Use -t/--test to filter by test name within a single file. Requires exactly one --file.

Library files from relux/lib/ are always loaded regardless of which files are specified.

Exits with code 1 if any test fails.

FlagDescription
-f, --file <path>Test file or directory to run (repeatable; default: relux/tests/)
-t, --test <name>Run only tests with this name (repeatable; requires exactly one --file)
--manifest <path>Path to Relux.toml (default: auto-discover by walking upward)
-j, --jobsNumber of parallel test workers (default: 1)
--tapGenerate TAP artifact file in the run directory
--junitGenerate JUnit XML artifact file in the run directory
-m, --timeout-multiplierScale tolerance (~) timeout values (default: 1.0). Assertion (@) timeouts are never scaled
--progress <mode>Display mode: auto (TUI if TTY), plain (results only), tui (force TUI)
--strategy <mode>all (default) or fail-fast
--rerunRe-run only non-passing tests from the latest run
--flaky-retriesMax retries for # flaky-marked tests
--flaky-multiplierExponential timeout multiplier base for flaky retries (default: 1.5, must be > 1.0)
--test-timeoutOverride per-test timeout (humantime string)
--suite-timeoutOverride suite timeout (humantime string)

relux check [paths...] [flags]

Validates test files without executing them. Runs the parser and resolver, reports diagnostics, and exits with code 1 if any diagnostics are found. Same path discovery as run.

FlagDescription
--manifest <path>Path to Relux.toml (default: auto-discover by walking upward)

relux history [flags]

Analyze run history from relux/out/.

FlagDescription
--manifest <path>Path to Relux.toml (default: auto-discover by walking upward)
--flakyShow tests that have been both passing and failing
--failuresShow tests that have failed
--first-failShow the first failure for each test
--durationsShow test duration statistics
--tests <path>...Filter to specific test files or directories
--last <N>Limit analysis to the N most recent runs
--top <N>Show only the top N results
--format <format>Output format: human (default) or toml

relux completions [flags]

Installs shell completions for bash, zsh, or fish. Relux uses dynamic completions — the shell calls back into the relux binary at tab-press time, enabling context-aware completions like .relux file discovery and timeout presets.

Without --install, shows a dry-run of what would be written. With --install, writes the completion script.

FlagDescription
--shell <shell>Shell to generate completions for: bash, zsh, fish (default: autodetect from $SHELL)
--installWrite the completion script to the target location
--path <path>Override the install path (required for zsh, optional for bash/fish)

Default install paths:

  • bash: ~/.local/share/bash-completion/completions/relux
  • fish: ~/.config/fish/completions/relux.fish
  • zsh: no default — specify with --path

relux dump tokens <file>

Dumps lexer tokens for the given file.

relux dump ast <file>

Dumps the parsed AST for the given file.

relux dump ir <files...>

Dumps the resolved IR (execution plans) for the given files.

Relux Syntax Reference

General

  • Line-oriented, newline-terminated statements (no ;)
  • Comments: // to end of line
  • All values are strings
  • Every expression produces a string value
  • Blocks use { }

Naming Conventions

Naming conventions are enforced at the syntactic level (parse error on violation):

  • Effect names and effect aliases must start with an uppercase letter (CamelCase): StartDb, Effect1, start Db as MyDb
  • Function names and shell names must be snake_case: start_server, _helper, my_shell
  • Variable names and parameters are permissive (any alphanumeric + underscore, starting with letter or _): port, DB_HOST, _private
  • Import aliases must preserve the casing kind of the original name: foo as bar (both lowercase), StartDb as Db (both uppercase)
  • Overlay keys accept either casing (environment variables are conventionally UPPER_SNAKE_CASE)

Imports

import <path> { <name>, <name> as <alias>, }
import <path>
  • <path> resolves from project root (e.g. lib/module1)
  • Selective: import lib/m { foo, bar as b, StartDb as Db } — trailing commas allowed
  • Wildcard: import lib/m — imports all names

Functions

fn <name>(<param>, <param>) {
    <body>
}
  • Return value: last expression in body
  • Execute in the caller’s shell context
  • Shell operators (>, =>, <?, <=, etc.) are valid inside body

Pure Functions

pure fn <name>(<param>, <param>) {
    <body>
}
  • Return value: last expression in body
  • Cannot contain shell operators (>, =>, <?, <=, !?, !=, timeouts)
  • Cannot call impure built-in functions or regular fn functions
  • Only let, variable reassignment, and expressions (including pure BIF calls) are allowed
  • Can be called from condition markers, overlay expressions, and regular shell blocks

Effects

effect <EffectName> {
    expect <VAR>, <VAR>, <VAR>
    start <EffectName>
    start <EffectName> as <Alias>
    start <EffectName> as <Alias> { KEY := expr, KEY }
    let <name> := <expr>
    <expr> = <pattern>                       // setup pure match (asserts)
    <expr> ? <pattern>                        // setup pure match (binds $n)
    expose shell <shell_name>
    expose shell <Alias>.<shell_name> as <public_name>
    expose var <var_name>
    expose var <Alias>.<var_name> as <public_name>
    shell <name> { <body> }
    shell <Alias>.<shell_name> { <body> }
    cleanup { <body> }
}
  • expect declares required environment variables (comma-separated)
  • start declares dependencies (one per line)
  • start Effect runs the dependency for side effects only — its shells are not accessible
  • start Effect as Alias runs the dependency and makes its exposed shells/variables available via dot-access
  • Effect aliases must be CamelCase
  • start Effect as Alias { KEY := expr } provides an overlay; shorthand KEY is equivalent to KEY := KEY
  • An overlay value may reference a sibling start’s exposed variable: KEY := Alias.var (Alias must be a sibling’s alias in the same start-list and must expose var that name — never expose shell, since overlay values are strings). This induces an implicit ordering dependency; see Effects and Effect Identity below
  • expose shell declares which shells are part of the effect’s public interface
  • expose var declares which variables are part of the effect’s public interface; these are let-bound values computed during setup
  • expose shell Alias.shell as name re-exports a dependency’s shell under a new name
  • expose var Alias.var as name re-exports a dependency’s variable under a new name
  • shell Alias.shell_name { ... } — qualified shell block for operating on a dependency’s exposed shell
  • Internal shells not listed in expose are terminated after setup
  • cleanup block: only >, =>, let, variable reassignment allowed (no match operators)

Tests

test "<name>" ~<duration> {
test "<name>" @<duration> {
test "<name>" {
    """
    <doc string>
    """
    let <name>
    <expr> = <pattern>                  // preamble pure match (asserts)
    <expr> ? <pattern>                  // preamble pure match (binds $n)
    start <EffectName>
    start <EffectName> as <Alias>
    start <EffectName> as <Alias> { KEY := expr, KEY }
    shell <name> { <body> }
    shell <Alias>.<shell_name> { <body> }
    cleanup { <body> }
}
  • Test-level start overlay rules (including the sibling-reference form KEY := Alias.var) are identical to an effect body’s start — see Effects above
  • A qualified reference ${Alias.var} in a test’s shell or cleanup body is validated the same way: Alias must be one of the test’s own start dependency aliases, and that dependency must expose var the referenced variable

Condition Markers

# kind                                  // unconditional
# kind modifier expr                    // truthiness check
# kind modifier expr = expr             // exact-equality comparison
# kind modifier expr ? regex            // regex match (unanchored)

Where:

  • kind: skip | run | flaky
  • modifier: if | unless
  • expr: quoted string with interpolation ("${VAR}", "literal", "${A}:${B}") or bare number (42)
  • regex: regex pattern with ${var} interpolation, to end of line
  • = tests exact equality (LHS equals RHS); for a substring or pattern check use ? (regex). Unlike the shell literal-match operators <=/!=, which scan a streaming buffer for a substring, a marker compares against a complete value.

Examples:

# skip
# skip unless "${CI}"
# run if "${OS}" = "linux"
# run if "${COUNT}" = 0
# skip unless "${ARCH}" ? ^(x86_64|aarch64)$
# flaky if "${CI}" = "true"
# run if "${HOST}:${PORT}" = "localhost:8080"
# skip unless "${VER}" ? ^${MAJOR}\..*$
# skip unless "${PATH}" ? bin           // substring / pattern match via regex
  • A bare marker (kind only, no modifier) is unconditional
  • One marker per line
  • Multiple markers stack with AND semantics (all must pass or test is skipped)
  • Placed immediately before test, effect, fn, or pure fn declarations (not inside the body)
  • When a function is skipped, all tests that call it are also skipped
  • A # flaky marker on a function or effect propagates too: the test is marked flaky when a function or effect it reaches is flaky
  • Comments between markers and the declaration are allowed
MarkerModifierConditionMeaning
# skip(none)(unconditional)always skip
# skipiftruthyskip when condition is true
# skipunlessfalsyskip when condition is false
# run(none)(unconditional)no-op (always run)
# runiffalsyskip when condition is false
# rununlesstruthyskip when condition is true
# flaky(none)(unconditional)always mark as flaky
# flakyiftruthymark as flaky when condition is true
# flakyunlessfalsymark as flaky when condition is false

Truthiness

  • Empty string or unset variable = false
  • Any non-empty string = true
  • = returns the LHS value if it equals RHS exactly, empty string otherwise (use ? for a substring or pattern check)
  • ? returns the regex match if matched, empty string otherwise

Shell Blocks

shell <name> {
    <statements>
}
shell <Alias>.<shell_name> {
    <statements>
}
  • Unqualified form (shell name) creates or switches to a local shell; name must be snake_case
  • Qualified form (shell Alias.shell_name) operates on a dependency’s exposed shell; qualifier is a CamelCase effect alias, name is snake_case
  • Valid inside effect and test blocks

Variables

let <name>                   # declare, defaults to ""
let <name> := "<value>"      # declare with value
let <name> := <expression>   # declare from expression
<name> := <expression>       # reassign existing variable
  • Binding uses := — declaration (let x := e), reassignment (x := e), and overlay entries ({ KEY := e }) all use it. Bare = is no longer a binding operator; it is the exact-equality pure-match assertion at statement level (x = e asserts x equals e) and the literal-match arm inside a multimatch block (= <literal>, see below). let x = e is an error — use let x := e.
  • Quoted values required for let assignments
  • Interpolation inside strings: "${name}", "${1}", "${2}", etc.
  • Bare variable reference: name, $1, $2
  • Escape $ with $$
  • Scoped to enclosing block; inner blocks can shadow outer variables
  • Environment variables are readable (the layered base environment — host process plus any .env files — is available everywhere)

Operators

All operators are followed by a space, then payload to end of line.

Send

OperatorPayloadValue
> text to EOLsent string
=> text to EOLsent string
  • > sends with trailing newline
  • => sends without trailing newline (raw send)
  • Variable interpolation applies in payload

Match

OperatorPayloadValue
<? regex to EOLfull match ($0)
<= literal to EOLmatched text
  • <? matches regex against shell output; sets $1, $2, etc. for capture groups
  • <= matches literal with variable substitution
  • Both block until match or timeout

Pure Match

Statement forms that assert a computed value against a pattern (see Pure Matching). Distinct from <? / <=, which scan a shell’s output buffer; a pure match compares a complete value and reads nothing from the PTY.

StatementMeaning
<expr> = <pattern>assert <expr> equals <pattern> exactly (literal equality)
<expr> ? <pattern>assert <expr> matches the regex <pattern>; binds $0..$n
  • <expr> is any pure expression (identifier, quoted/interpolated string, function call, Alias.var, $n, number).
  • <pattern> is an interpolated string to end of line (same shape as the <= / <? payload).
  • = is exact equality (not a substring test); use ? for a substring or pattern check.
  • A no-match fails the test immediately — a pure match is an assertion and cannot time out. There is no negated form.
  • Valid in shell blocks, fn bodies, pure fn bodies, and test / effect preambles (alongside let). Inside a pure fn, a ? match binds captures into the function’s own per-call frame, so $1 can be returned to extract a value.
  • In a preamble, a ? match binds $n into a preamble capture frame that later preamble lets and start overlays read; a shell block does not inherit it (a shell owns its own frame). $n reads the ambient frame, "" when unset. See Pure Matching.
  • Statement-only: cannot be a let right-hand side, an overlay value, or a cleanup value.
os = linux                 // passes only if os is exactly "linux"
"${HOST}:${PORT}" ? ^db\.local:\d+$
greeting ? (hello) (world) // on a hit: $1="hello", $2="world"

Multi-Pattern Match

<{
    ? <regex>
    = <literal>
}
  • Inner lines are ? <regex> (regex) or = <literal> (literal), one per line
  • The block waits for every pattern to match at least once, in any order
  • The cursor advances once at block exit, to max(end) across all per-pattern matches
  • Capture groups in inner regex patterns do not bind
  • The empty form <{ } is a parse error
  • Comments are permitted between inner lines

Buffer Reset

<?
<=
  • A match operator with no payload consumes all current output and resets the cursor
  • Useful to skip past output you don’t care about

Inline Timeout Override

Any match operator can be prefixed with ~<duration> (tolerance) or @<duration> (assertion) to set a one-shot timeout:

<~2s? regex pattern       # regex match with 2s tolerance timeout
<~500ms= literal text     # literal match with 500ms tolerance timeout
<@2s? regex pattern       # regex match with 2s assertion timeout
<@500ms= literal text     # literal match with 500ms assertion timeout
<~10s{ ? a  ? b }         # multi-pattern block with 10s tolerance timeout
<@500ms{ = a  = b }       # multi-pattern block with 500ms assertion timeout
  • Duration uses compact humantime format (no spaces): 2s, 500ms, 1m30s
  • Applies only to that single operation — does not affect the scoped timeout
  • Works with both match operators (?, =) and the multi-pattern form <{ ... }
  • Tolerance (~) timeouts are scaled by --timeout-multiplier; assertion (@) timeouts are never scaled

Fail Pattern

OperatorPayload
!? regex to EOL
!= literal to EOL
  • One active fail pattern at a time (single slot)
  • Setting a new one replaces the previous (regex or literal)
  • An empty !? or != (no payload) clears the active fail pattern

Timeout

~<duration>
@<duration>
  • Compact humantime format (no spaces): ~10s, @2s, ~500ms, ~2m30s
  • ~ sets a tolerance timeout — scaled by --timeout-multiplier
  • @ sets an assertion timeout — never scaled (asserts the system responds within a hard deadline)
  • Sets timeout for subsequent match operations in the current shell
  • Overrides previous timeout
  • Scoped to the current function call — reverts when the function returns

Expressions

Every expression produces a string value:

ExpressionValue
"<text>"string literal
namevariable value
Alias.vara dependency’s exposed variable (dot-access)
$1, $2regex capture group
<fn>(<args>)function return value
> <text> / => <text>sent string
<? <regex>full match ($0)
<= <literal>matched text
<~dur? <regex>full match with timeout override
<~dur= <literal>matched text with timeout override
let x := <expr>assigned value

Last expression in a function body is the return value.

Effect Identity

(effect-name, evaluated overlay restricted to expect-declared vars) determines instance identity:

  • Same tuple = same instance (deduplicated)
  • Different tuple = different instance
  • The registry is per-test — the tuple deduplicates within one test’s dependency graph, never across tests
  • Overlay expressions are evaluated at setup time; identity is based on evaluated values, not AST form
  • A KEY := Alias.var sibling reference is an overlay value like any other: it is evaluated (the sibling is Ready by then) before identity is derived, so two dependents sourcing different sibling values get distinct identities

Cleanup Blocks

cleanup {
    <statements>
}
  • Runs in a fresh implicit shell
  • Any statement valid in a shell block is valid in a cleanup block
  • Always executes, regardless of pass/fail

Pure Matching

A pure match asserts that a value your test has already computed satisfies a pattern. Unlike the shell match operators (<? / <=), which scan a shell’s streaming output buffer for a match, a pure match compares against a single complete value — a variable, a function result, an interpolated string. Nothing is read from a PTY.

There are two statement forms:

<expr> = <pattern>      // exact-equality assertion
<expr> ? <pattern>      // regex assertion (binds captures $0..$n)
  • <expr> is any pure expression: a bare identifier (os), a quoted/interpolated string ("${HOST}:${PORT}"), a function call (which("docker")), a dot-accessed exposed variable (Db.port), a capture ($1), or a bare number (42).
  • <pattern> is an interpolated string to end of line — the same right-hand side shape as <= (for =) and <? (for ?). ${var} interpolation applies before the comparison.

These are the same = / ? semantics as condition markers: = is exact equality and ? is an unanchored regex. A pure match reuses the shared matcher; the only difference is that a marker gates a whole declaration before shells spawn, while a pure-match statement asserts inline during execution.

The = form: exact equality

<expr> = <pattern> passes only when the value of <expr> is byte-for-byte equal to the interpolated pattern. It is not a substring test:

let os := "linux"
os = linux                 // passes: "linux" == "linux"
os = lin                   // FAILS: not equal (substring is not enough)
os = ubuntu-linux          // FAILS: not equal

This differs from the shell literal operator <=, which scans the output buffer and succeeds on any substring hit. = compares a complete value, so it fails on a superstring or a partial overlap. For a substring or pattern check, use ? instead.

An empty pattern matches only an empty (or unset) value.

The ? form: regex and captures

<expr> ? <pattern> compiles <pattern> as a regular expression (Rust’s regex crate) and passes when the regex matches anywhere in the value — it is unanchored, exactly like <?. Add ^ / $ yourself when you need a full-value match.

A successful ? binds the numeric capture groups $0..$n in the current shell, read as $0, $1, ${2}, and so on — the same binding <? performs:

let greeting := "hello world"
greeting ? (hello) (world)
> echo "full=${0} first=${1} second=${2}"

After the match, $0 is hello world, $1 is hello, $2 is world. As with <?, the captures live until the next regex match overwrites them — bind anything you need to keep with let. The = form never binds captures.

Assertion semantics: a no-match fails the test

A pure match is an assertion. Relux has no error handling, so a no-match is a hard failure that stops the test immediately — the same way a <? that never matches fails. There is no negated form.

let status := "500"
status ? ^2\d\d$           // no match -> the test fails right here

Because the value is already in hand, a pure match cannot time out (no buffer, nothing to wait for) — it either matches or fails at once. A ? pattern that fails to compile (only possible when interpolation produces malformed regex) is a runtime failure, not a no-match.

Where pure matches are allowed

Pure-match statements are valid inside:

  • shell blocks (in tests and effects)
  • test and effect preambles — alongside let, before the first shell block (and, in an effect, before its start and expose items)
  • regular fn bodies
  • pure fn bodies

Inside a pure fn, a regex pure match binds numeric captures ($1, $2, …) into the function’s own capture frame, so a pure fn can run s ? ^id=(\d+)$ and then return $1 to extract a value. Each pure fn call starts with an empty capture frame. A no-match inside a pure fn fails the test through whichever runtime site called the function — a test- or effect-level let, an overlay value, or a shell-block call; a malformed interpolated pattern surfaces as a runtime error. The one exception is a marker condition: a no-match there makes the condition falsy rather than failing the test (a marker is evaluated to decide skip/run/flaky, not to assert). A malformed pattern is not excepted — it is a hard error in every pure context, marker conditions included, so a broken regex never masquerades as an unmet condition.

This is the extraction idiom: a pure fn asserts the value’s shape with ? and returns a capture, and a caller binds the result with let.

pure fn extract_id(s) {
    s ? ^id=(\d+)$
    $1
}

test "extract an id" {
    let payload := "id=42"
    let id := extract_id(payload)   // id is "42"
    shell s {
        > echo "id=${id}"
        <? ^id=42$
    }
}

The captures extract_id binds are local to its call: once it returns, $1 is gone (just as a called fn’s captures do not leak into the caller’s shell frame). Keep the extracted value by returning it and binding it with let, as above.

Preamble captures and the shell boundary

A ? pure match in a test or effect preamble binds its numeric captures into a single capture frame that is hoisted across the whole preamble. Later preamble lets and later preamble pure matches read it, and a start’s overlay expressions read it too:

test "destructure a dsn in the preamble" {
    let url := "postgres://user:pw@db.internal:5432/shop"
    url ? ^postgres://[^@]+@([^:/]+):(\d+)/(\w+)$
    let host := "${1}"                 // reads the preamble frame
    let port := "${2}"
    start Conn as C { HOST := host, DB := "${3}" }  // overlay reads $3
    shell C.c { // ... }
}

$n reads the ambient capture frame uniformly, rendering "" when no regex has run — so a $n in a let or overlay with no preceding regex match is not an error, just an empty string.

Shells do not inherit the preamble frame. A shell block owns its own capture frame, populated only by that shell’s own <? matches; it starts empty. $n inside a shell reads the shell’s frame, never the preamble’s. To carry a preamble capture into a shell, bind it to a let in the preamble and read the let:

test "captures do not leak into shells" {
    let subject := "token=abc"
    subject ? ^token=(\S+)$             // preamble frame: $1 = "abc"
    let kept := "${1}"                  // carry it across the boundary
    shell s {
        > echo "shell=[${1}] kept=[${kept}]"
        <? ^shell=\[\] kept=\[abc\]$    // shell $1 is empty; kept survives
    }
}

Markers are evaluated before the preamble runs, so a marker condition sees an empty capture frame.

A pure match is statement-only. It cannot appear as the right-hand side of a let, an overlay value, or a cleanup value. The statement evaluates to the matched text – $0 (the whole match) for ?, and the whole value for = – the same value the shell <? operator returns. Its intended use, though, is the assertion (and, for ?, the capture side effect) rather than a returned result; the value surfaces only when a pure match is the last statement of a fn / pure fn body.

:= binds, = asserts

Mind the one-character difference:

StatementMeaning
x := ebind or reassign the variable x to the value of e
x = eassert that the value of x equals the pattern e
let x := edeclare x with the value of e
let x = eerror — binding requires :=, never bare =

Reassignment always uses :=. Bare = is now the exact-equality pure-match assertion, so x = e no longer binds anything. let x = e remains an error: use let x := e.

There is no == operator. All values are strings, so == is not a comparison. Writing x == y — a = immediately followed by another =, with no space between them — is a parse error: there is no == operator. Write x = y for “x equals y exactly”; write x := y to bind. A pattern may legitimately begin with =, so if you really want to assert that x equals the literal text = y, put a space between the two: x = = y is valid and matches the pattern = y.

What lands in the structured log

Every pure match emits a three-event trio into events.json: pure-match-start (carries value, pattern, is_regex) followed by either pure-match-done (matched whole-match substring, plus captures) on a hit, or pure-match-failed on a miss. A malformed regex emits nothing (no orphan pure-match-start).

When a pure match fails the test, the outcome carries a FailureRecord of type pure-match with span, event_seq, match_context, value, pattern, is_regex, call_stack, and vars_in_scope. It deliberately has no buffer_tail — a pure match has no buffer.

match_context (a MatchContext) names exactly where the assertion ran: a shell block, a fn / pure fn body, a test preamble, or an effect preamble — replacing the plain shell name a "pure-match" failure used to carry, since a pure match can run outside any shell. event_seq and vars_in_scope are populated for every "pure-match" failure, including one raised from a test/effect preamble, an overlay expression, or a pure fn body — there is no preamble carve-out that leaves them at 0 / empty.

The console reporter renders “pure match in <context> did not match” (e.g. “pure match in shell default did not match” or “pure match in fn extract_id did not match”) together with the value and the failing = pattern / ? pattern. The HTML viewer instead surfaces the context as a structured context row on the failing pure-match-failed event (e.g. fn 'extract_id'), alongside the same value and pattern.

In the HTML viewer, a pure match appears as a single folded, outcome-colored row labeled pure-match, matching how impure PTY matches render.

Built-in Functions

Relux provides built-in functions (BIFs) that are always available without imports. BIFs are divided into two categories based on their purity — whether they require a shell context to operate.

Purity

  • Pure BIFs do not interact with any shell. They can be called from pure functions, condition markers, overlay expressions, and regular shell blocks.
  • Impure BIFs require a shell context (they send input or match output). They can only be called inside shell blocks and regular (non-pure) functions.

“Pure” here means shell-independent, not side-effect-free — a pure BIF may still touch the outside world (which reads the filesystem, timestamp reads the system clock) and need not be deterministic (uuid, rand). What it may not do is require a shell. sleep, log, and annotate do require one and are impure, despite doing nothing shell-specific.

Shell-independent also does not mean infallible. A pure fn body may contain a pure match (<expr> = <pattern> / <expr> ? <pattern>), and a non-matching pure match is an assertion failure: a pure fn that runs one can fail the test through whichever runtime site called it (a let, an overlay value, or a shell-block call). The one exception is a marker condition, where a pure-eval failure makes the condition falsy rather than failing the test.

Pure BIFs

String

FunctionSignatureReturnsDescription
trimtrim(s)stringRemove leading and trailing whitespace from s.
upperupper(s)stringConvert s to uppercase.
lowerlower(s)stringConvert s to lowercase.
replacereplace(s, from, to)stringReplace all occurrences of from with to in s.
splitsplit(s, sep, index)stringSplit s by sep and return the part at index (0-based). Returns "" if the index is out of bounds. Errors if index is not a valid integer.
lenlen(s)stringReturn the byte length of s as a decimal string.
defaultdefault(a, b)stringReturn a if it is non-empty, otherwise return b.

Generators

FunctionSignatureReturnsDescription
uuiduuid()stringGenerate a random UUID v4 (e.g. "550e8400-e29b-41d4-a716-446655440000").
randrand(n)stringGenerate a random alphanumeric string of length n. Errors if n is not a valid integer.
randrand(n, mode)stringGenerate a random string of length n using the given charset mode. Modes: alpha, num, alphanum, hex, oct, bin. Errors if mode is unknown or n is not a valid integer.

System

FunctionSignatureReturnsDescription
available_portavailable_port()stringAllocate a free TCP port on 127.0.0.1 from the window between the OS privileged and ephemeral port intervals. Probed at allocation and never handed out twice while the allocating test is running; freed after the test’s cleanup completes.
whichwhich(name)stringSearch PATH for an executable named name. Returns the absolute path to the first match, or "" if not found. Checks that the file has an executable permission bit set. If name contains a path separator, checks that path directly instead of searching PATH.

A naive “bind port 0, read the number, close” probe returns a port from the OS ephemeral range — the same range the kernel uses for the source ports of outbound connections. Between picking the port and your service binding it, any client connection (a database bootstrap, a migration, a health check) can be assigned that number, and a closed client keeps the port in TIME_WAIT for 60 seconds. The result is a rare, unreproducible Address already in use at service start.

available_port therefore allocates strictly outside the ephemeral range. At first use relux reads the OS ephemeral interval (/proc/sys/net/ipv4/ip_local_port_range on Linux, net.inet.ip.portrange on macOS) and the privileged boundary, and allocates from the window between them, starting at a random offset. Every candidate is verified by binding it (ports in TIME_WAIT fail this probe and are skipped). A handed-out port is owned by the running test — across all its effects and functions — and is returned to the pool only after the test’s cleanup has completed, so within one relux run, no two concurrent tests can ever receive the same port.

The window can be narrowed with the manifest’s [available_ports] section (see the configuration chapter). On exhaustion of the window the call returns -1, like other BIF failures.

One residual caveat: this bookkeeping is per relux process, not a machine-wide reservation, so relux cannot stop an unrelated process on the machine — including another concurrent relux run invocation — from binding the chosen port before your service does. The allocation window makes that unlikely (the kernel never assigns those ports on its own), but it is not a guarantee.

Hashing

FunctionSignatureReturnsDescription
mnemonicmnemonic(s)stringDerive a stable, human-readable id from s, formatted adjective-noun-NNNN (e.g. "brave-otter-0042"). Deterministic across runs and across relux versions. About 2^29 distinct values – a readable label, not collision-proof and not for security.
sha1sha1(s)stringSHA-1 digest of s as 40-character lowercase hexadecimal.

Time

FunctionSignatureReturnsDescription
timestamptimestamp(fmt)stringCurrent UTC time formatted with a GNU date-style strftime string. Fractional seconds accept any width (%<N>f, %.<N>f); an unknown specifier is emitted verbatim.

timestamp always renders the current instant in UTC – there is no local-timezone mode. It deviates from chrono’s strftime in two ways: fractional-second specifiers accept any width, not just chrono’s fixed 3/6/9 (%1f..%9f, %.1f..%.9f, and widths above 9 right-pad with zeros), and an unknown specifier is emitted verbatim instead of being blanked out. Like uuid and rand, timestamp is non-deterministic across calls – it reads the system clock – but it is still a pure BIF because it never touches a shell.

timestamp("%Y-%m-%dT%H:%M:%SZ")  -> 2026-07-28T15:30:45Z
timestamp("%Y%m%d-%H%M%S")       -> 20260728-153045
timestamp("%s")                  -> 1753716645
timestamp("%H%M%S-%6f")          -> 153045-123456

Impure BIFs

Shell matching

FunctionSignatureReturnsDescription
match_promptmatch_prompt()stringMatch the shell prompt configured in Relux.toml. Advances the output cursor past the prompt.
match_okmatch_ok()stringMatch the shell prompt, send echo $?, match 0, and match the prompt again. Verifies the previous command exited with status 0.
match_not_okmatch_not_ok()stringMatch the shell prompt, verify the previous command exited with a non-zero status, and match the prompt again. The inverse of match_ok().
match_not_okmatch_not_ok(code)stringMatch the shell prompt, verify the previous command exited with a specific non-zero status code, and match the prompt again. Like match_exit_code(code) but also asserts the code is non-zero.
match_exit_codematch_exit_code(code)stringSend echo $?, match code, and match the prompt. Verifies the previous command exited with the given status. code is passed as a bare literal (e.g. match_exit_code(1)).

Control characters

FunctionSignatureReturnsDescription
ctrl_cctrl_c()""Send ETX (0x03) — interrupt the current process.
ctrl_dctrl_d()""Send EOT (0x04) — signal end of input.
ctrl_zctrl_z()""Send SUB (0x1A) — suspend the current process.
ctrl_lctrl_l()""Send FF (0x0C) — clear the terminal screen.
ctrl_backslashctrl_backslash()""Send FS (0x1C) — send SIGQUIT to the current process.

Timing

FunctionSignatureReturnsDescription
sleepsleep(duration)""Pause execution for duration. Accepts humantime format: 500ms, 2s, 1m30s, etc. Errors if the duration is invalid.

Logging

FunctionSignatureReturnsDescription
loglog(message)stringEmit message to the event log and HTML report. Returns message.
annotateannotate(text)stringEmit text as a progress annotation. Renders inline on the live progress line (between the surrounding fn-call ( and )) and is recorded as an event in the structured log. Returns text.

CI Integration

Relux can produce TAP and JUnit output for integration with CI systems:

relux run --tap --junit

This writes results.tap and junit.xml into the run directory at relux/out/run-<timestamp>-<id>/. A relux/out/latest symlink always points to the most recent run. The run directory also contains index.html (summary report), logs/ (per-test event logs), and artifacts/.

Key point: Always archive the entire run directory, not just the XML/TAP files. The JUnit XML references log files via relative paths, and CI systems that support attachments (Jenkins, GitLab) can link directly to per-test event.html logs when the directory structure is preserved.

Each per-test directory under logs/ contains two artifacts:

  • events.json — canonical structured payload (spans, events, buffer events, outcome record, embedded source files), consumable by external tooling. See events.json Schema for the on-disk shape, the tagged-enum variants for spans/events/buffer events/outcome, and the schema-versioning policy. Surfaced to machine consumers via the TAP log_json: YAML field, a second JUnit [[ATTACHMENT|...]] marker, and a <property name="events_json" ...> element on each test case.
  • event.html — self-contained Svelte SPA viewer. The structured log, highlight.js core, Relux language definition, and the viewer bundle are each gzipped, base64-encoded, and inlined into <script type="application/octet-stream"> payload tags. A small bootstrap script decompresses them in-browser via DecompressionStream, sets window.RELUX_DATA, and runs the three JS payloads in order (hljs core → Relux grammar → viewer). Opens directly via file://; no server required. Requires Chrome 80+ / Firefox 113+ / Safari 16.4+; older browsers see a one-line message and nothing else. This is the recommended human entry point and the link target used by the run-summary index.html, JUnit [[ATTACHMENT|...]] markers, and TAP log: fields.

Skipped-test logs

Tests skipped by a marker — either # skip if X evaluating true or # run if X evaluating false, on the test itself or on any effect/function it depends on — produce a per-test log alongside passed and failed tests. The skipped-test log contains only the MARKERS section: the synthetic markers span tree with one marker-eval child per evaluated marker (including any flaky markers that ran before the skip-causing one). Opening event.html focuses the marker that triggered the skip and expands its ancestors so the tree is unfolded. For a skip propagated from a fn or effect, the focused marker is the originating one on that fn/effect, not on the test.

Tests skipped for other reasons (e.g., “skipped because an earlier test caused fail-fast and this test was never started”) do not produce a log: there are no marker evaluations to show; the actionable information lives on the test that caused the cancellation.

Cancelled outcome

A test that was started but did not run to completion produces a Cancelled outcome — distinct from Fail. Sources:

  • Test timeout (~T on the test): the per-test watchdog fired. Carried as reason: { type: "test-timeout", duration_ms }.
  • Suite timeout: the suite-wide watchdog fired. Other live tests are cancelled with reason: { type: "suite-timeout", duration_ms }.
  • Fail-fast: a sibling test failed with --strategy fail-fast. Live tests are cancelled with reason: { type: "fail-fast", trigger_test }.
  • SIGINT: the CLI process received SIGINT. Live tests are cancelled with reason: { type: "sigint" }.

Cancelled outcomes:

  • Exit nonzero from relux run (same as failures).
  • Render as not ok in TAP, with a diagnostic block carrying cancellation: <reason-tag>.
  • Render as <error type="cancelled" message="cancelled: <reason-tag>"/> in JUnit XML (distinct from <failure> and <skipped>).
  • Render as a cancelled row in the HTML run index and a CANCELLED pill in the per-test viewer.
  • A cancelled event in events.json marks the exact point where the VM observed the cancel, on the span execution was inside at that moment.

Flaky-retry semantics: a test marked # flaky is retried on Fail and on Cancelled { reason: TestTimeout } (the test’s own clock running out — exactly what scaled-timeout retries target). Other cancellation reasons (suite-timeout, fail-fast, SIGINT) are not retried.

Artifacts

Anything a test writes under $__RELUX_TEST_ARTIFACTS is enumerated in events.json under artifacts and surfaced in the viewer through an artifacts modal (AppBar chip, hotkey A). Each entry is a relative link that opens in a new browser tab; this works whether event.html is opened directly via file:// or served over HTTP. The chip is rendered as disabled when the test wrote no artifacts.

The viewer bundle is committed at crates/relux-runtime/vendor/relux-viewer.js.gz; regenerate it (and the TypeScript schema bindings) with just build-viewer.


GitLab CI

GitLab natively consumes JUnit XML via artifacts:reports:junit. Archive the full run directory so that [[ATTACHMENT|...]] markers in <system-out> resolve to the event logs.

test:
  stage: test
  script:
    - relux run --junit
  artifacts:
    when: always
    paths:
      - relux/out/latest/
    reports:
      junit: relux/out/latest/junit.xml

Setting when: always ensures artifacts are uploaded even when tests fail.


GitHub Actions

GitHub Actions does not have built-in JUnit support. Use actions/upload-artifact to preserve the run directory, and a third-party action to surface test results in the PR.

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - name: Run tests
        run: relux run --junit

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: relux-results
          path: relux/out/latest/

      - name: Publish test report
        if: always()
        uses: mikepenz/action-junit-report@v5
        with:
          report_paths: relux/out/latest/junit.xml

Other JUnit report actions (e.g., dorny/test-reporter) work the same way – point them at relux/out/latest/junit.xml.


Jenkins

Use the JUnit post-build step to parse results. Install the JUnit Attachments Plugin to make per-test event logs clickable – it reads the [[ATTACHMENT|...]] markers embedded in <system-out>.

pipeline {
    agent any
    stages {
        stage('Test') {
            steps {
                sh 'relux run --junit'
            }
            post {
                always {
                    junit testResults: 'relux/out/latest/junit.xml',
                          allowEmptyResults: true
                    archiveArtifacts artifacts: 'relux/out/latest/**',
                                     allowEmptyArchive: true
                }
            }
        }
    }
}

With the JUnit Attachments Plugin installed, each test case in the Jenkins UI will link to its event.html log automatically.


Azure DevOps

Use the PublishTestResults task to ingest JUnit XML.

steps:
  - script: relux run --junit
    displayName: Run tests

  - task: PublishTestResults@2
    condition: always()
    inputs:
      testResultsFormat: JUnit
      testResultsFiles: relux/out/latest/junit.xml
      mergeTestResults: true
      testRunTitle: Relux

  - task: PublishBuildArtifacts@1
    condition: always()
    inputs:
      pathToPublish: relux/out/latest
      artifactName: relux-results

Gitea Actions

Gitea Actions uses the same workflow syntax as GitHub Actions. Gitea does not render JUnit reports natively, but you can archive results and use compatible actions from the Gitea marketplace.

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - name: Run tests
        run: relux run --junit --tap

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: relux-results
          path: relux/out/latest/

The uploaded artifact preserves the full run directory including index.html, which serves as a self-contained test report you can browse locally.


TAP Consumers

The --tap flag produces TAP version 14 output in results.tap. This is useful with any TAP consumer (e.g., tap-diff, tap-dot, Jenkins TAP Plugin):

# Stream TAP to a formatter
cat relux/out/latest/results.tap | tap-diff

# Or use the file directly with CI plugins that accept TAP input

TAP output includes log file paths in YAML diagnostics blocks (log: field), but most CI systems do not parse TAP diagnostics for attachments. Use --junit when you need CI-native log attachment support.

Test Log Viewer

The test log viewer is the per-test HTML report that ships with every Relux run as event.html inside the run directory’s logs/<test>/ folder. It is a single self-contained SPA: open it directly via file://, no server required. See CI Integration for how the file is packaged and shipped.

This article catalogs the viewer’s surface. Each region is listed with the data it shows and the keys that act on it.

Layout

The viewer has four persistent regions, top to bottom:

  1. App bar — test identity, outcome, modal launchers, run timing.
  2. Timeline bar — proportional bar of the test’s time range with click-to-jump slices.
  3. Events list (left pane) — the structured event log as a foldable tree.
  4. Detail panel (right pane, 2x2 grid) — source / shell / variables / call stack views of the current selection.

Three modal overlays — env, shells, artifacts — open on top of the layout.

Selection is the spine of the UI: nearly every pane reads from selectedSpanId or selectedEventSeq. Clicking a row in the events list, clicking a timeline slice, or clicking a frame in the call stack all change the selection; every other pane re-derives from there.

App bar

The strip across the top of the viewer.

Contents, left to right:

  • Breadcrumb<directory>/<file> followed by the test name.
  • Outcome pillpass, fail, cancelled, skip, or invalid. cancelled indicates the test was stopped before completion (test-timeout, suite-timeout, fail-fast, or SIGINT); the in-stream cancelled event tells you which one.
  • Modal launcher chipsenv, shells (N), artifacts (N). The artifacts chip is disabled when the test produced no files.
  • Timing summary — total duration, event count, span count.
KeyAction
EToggle the env modal
SToggle the shells modal
AToggle the artifacts modal (no-op when empty)

Timeline bar

A proportional time track spanning the test’s duration.

The selected event or span is rendered as a pulsing accent slice over the bar. Hovering anywhere on the track reveals one or more preview cards for the spans active at that timestamp: a short stack of cards anchored to the slice on the track, each summarizing one candidate span. Clicking the track selects:

  • the only span there, if there is one;
  • otherwise, it pins the preview cards so you can click the one you want.

Clicking outside a pinned card stack dismisses the pin. Clicking a preview card selects its span and reveals the matching row in the events list.

No keyboard shortcuts; the timeline bar is mouse-driven.

Events list

The left pane. Renders the structured event log as a foldable, indented tree.

Row types:

  • Span entry — a span’s opening row. Indented by depth, foldable.
  • Event — a non-span event (send, match, var-let, interpolation, …). Some related events are folded into a single row (e.g. a match-start / match-done pair, a sleep-start / sleep-done pair).
  • Log bar — emitted by the log BIF; rendered as a horizontal bar carrying the log level and message.
  • BIF row — a transparent impure BIF call (e.g. match_prompt) shown as a single row instead of a foldable span.
  • Gap — a synthetic row marking a duration with no events.
  • Per-pattern match — inside a <{ ... } block, each multi-match-pattern-done event renders as its own selectable row, labelled match in the kind column (the same label used by a folded single-match match-start / match-done pair).
  • Pure match — pure string matches (= / ?) fold into a single row, outcome-colored (green on success, red on no-match), and labeled pure-match (vs match for PTY matches) so you can distinguish an instant string check from a buffer wait.

The footer below the list carries chips for filtering and bulk fold control:

  • Filter — opens a popup with one checkbox per event type. The chip is highlighted when any types are hidden.
  • Error path preset — hides everything except error, fail-pattern-triggered, match-timeout. Disabled when the test passed.
  • Send / match only preset — hides everything except send, match, match-timeout.
  • Collapse all / Expand all — fold or unfold every span at once.
KeyAction
Up / DownMove selection to the previous / next row
RightExpand the selected span
LeftCollapse the selected span
Enter / SpaceIf an event is selected, deselect it; if a span is selected, toggle its fold
FToggle the filter popup
TToggle the error-path preset (no-op when the test passed)
MToggle the send / match preset
CCollapse all spans
XExpand all spans

Detail panel

The right pane. A 2x2 grid of panes, all driven by the current selection:

+---------------------+---------------------+
|       source        |        shell        |
+---------------------+---------------------+
| variables in scope  |     call stack      |
+---------------------+---------------------+

Source

Renders the .relux file the selection points to, with the relevant byte range outlined by a pulsing accent frame. The header hint shows <file>:<line>; the view auto-scrolls to vertically center the anchor line and horizontally to keep the framed range on screen. Function calls, BIF rows, and imported items resolve to the file that actually defines them, not the file that called them.

When the selection has no source location, the pane shows no location and a placeholder.

Shell

The output buffer of the shell that owns the selected event, snapshotted at the moment of selection.

Renders three regions concatenated, top to bottom:

  • Consumed — bytes already matched and advanced past. Dimmed.
  • Matched — bytes consumed by the most recent match up to and including the selected event. Accent color, pulsing.
  • Tail — bytes still in the buffer after the cursor. Default ink.

The header hint surfaces the shell’s state at the moment of selection: timestamp, matched ✓ if the selected event was a successful match, the active timeout, and the count of fail patterns armed in scope.

Inside a multi-match span, selecting a per-pattern match row splits the Tail region into two halves around the matched bytes: tail-before (bytes that appear before the match in the undrained buffer), the matched highlight (the pattern’s own match), and tail-after (bytes that appear after). The Consumed region remains the cursor’s position at block entry — because the multimatch block advances the cursor once, at block exit, individual per-pattern matches inside the block do not move the consumed boundary.

When the selection has no shell context (e.g. a pure-function span), the pane shows this event has no shell context.

This pane embeds the searchable buffer (see below) — type into the search field to find substrings inside the buffer.

Variables in scope

A two-section key/value table:

  • Captures ($name) — regex captures live in this scope, rendered with the accent color.
  • let variables — variables declared at this scope or any enclosing scope.

The footer carries a chip that links to the env modal — environment variables are not shown in this pane; they live in the env modal because they are global to the test.

When the selection has no scope context, the pane shows a placeholder.

Call stack

The stack of lexical scopes that contain the selected event, deepest frame at the top. Each frame shows its kind (e.g. TEST, FN-CALL, EFFECT-SETUP), name, optional alias, source location, and any bound arguments.

The topmost frame is fixed (it is the selection’s own frame). Clicking a lower frame “promotes” it: the viewer selects that frame’s inner neighbor, effectively walking out one level. Use this to navigate from a deeply nested BIF call back up to the test body.

The pane’s footer lists also-live shells — shells that are running but are not the one owning the selected event. Useful for multi-shell tests where work is happening in parallel.

Searchable buffer

Used in two places: the shell pane of the detail panel, and inside every card in the shells modal. A single-line search bar above a buffer view.

The query is matched against the rendered (escape-expanded) buffer text using substring search with smart case: case-insensitive unless the query contains an uppercase letter. The bar shows <current> / <total> matches; the current hit is rendered with a stronger accent than the rest.

KeyAction
EnterCycle to the next hit
Shift+EnterCycle to the previous hit
EscClear the query; blur the field if already empty
Cmd+S / Ctrl+SFocus / cycle search inputs (see Global hotkeys)

Env modal

Snapshot of the environment seeded when the test started — the host process environment, any .env file values layered over it, and Relux’s own run internals.

The body lists every variable, grouped by its true origin, in precedence order top to bottom:

  1. relux internals — values Relux injects for the run, including the per-test __RELUX_TEST_ID / __RELUX_TEST_ROOT / __RELUX_TEST_ARTIFACTS. Relux sets the whole reserved __RELUX* namespace on this layer, so any copy inherited from the host environment is shadowed and this group shows Relux’s own values.
  2. .env files — one section per source file, headed by the file’s path relative to the suite root, written as .../<relative path>. Sections are ordered deepest-first, so the higher-precedence file (the one nearer the test) sits nearer the top. Hover a header to see the absolute path.
  3. host environment — values inherited from the process that launched relux.

A section only appears when it has at least one variable, so a run with no .env files shows just the relux and host groups. A filter row at the top accepts a query and a scope toggle: filter by name, value, or name · matches (either side). The counter shows <filtered> / <total>.

The header carries a copy all action that copies the full environment as KEY=VALUE lines, one per row.

KeyAction
EToggle the modal
EscClose the modal
Cmd+S / Ctrl+SFocus the filter input

Shells modal

One card per shell spawned during the test, sorted by spawn time.

Each card has:

  • Header — shell name, command, state dot (running, awaiting input, ended, error), and a ★ this event badge when the card corresponds to the shell owning the currently selected event.
  • Stats line — spawn timestamp, buffer size, events seen up to the selection, and termination timestamp (when ended before the selected event).
  • Buffer column — a searchable buffer view for that shell’s output at the moment of the selected event.

The modal subtitle reflects the current selection (@ event #N · kind · t = ... · in <test>) so you always know what timestamp the buffers are snapshotted at.

KeyAction
SToggle the modal
EscClose the modal
Cmd+S / Ctrl+SCycle through the cards’ search inputs

Artifacts modal

The list of files the test wrote under its artifacts/ directory.

Each row is path · size · mime. The path is a link that opens the artifact in a new tab (resolved relative to the report directory). A filter row at the top narrows the list; the header copy all action copies every path as a newline-separated list.

The modal launcher chip in the app bar is disabled when the test produced no artifacts; the A hotkey is a no-op in that case.

KeyAction
AToggle the modal (no-op when the test has no artifacts)
EscClose the modal
Cmd+S / Ctrl+SFocus the filter input

Global hotkeys

A consolidated reference. Keys without a modifier are ignored while a text input or contenteditable element has focus; the search-input cycle is the one exception (it deliberately runs before the input-focus guard so it can move from one input to the next).

KeyScopeAction
EglobalToggle env modal
SglobalToggle shells modal
AglobalToggle artifacts modal
TglobalToggle error-path preset in the events list
MglobalToggle send / match preset in the events list
FglobalToggle the events-list filter popup
CglobalCollapse all spans
XglobalExpand all spans
EscglobalClose the open modal
Cmd+S / Ctrl+SglobalFocus / cycle search inputs in the current scope (modal if one is open, otherwise the main view)
Up / Downevents listMove selection by one row
Right / Leftevents listExpand / collapse the selected span
Enter / Spaceevents listToggle the current row
Entersearchable bufferCycle to the next hit
Shift+Entersearchable bufferCycle to the previous hit
Escsearchable bufferClear the query; blur if already empty

Browser support

The bootstrap script in event.html uses DecompressionStream to unpack the inlined gzip payloads. Supported floors:

  • Chrome / Edge 80+
  • Firefox 113+
  • Safari 16.4+

Older browsers (Safari 15, ancient Chromium forks) cannot decode the payloads and see a one-line fallback message in place of the viewer. The underlying events.json is unaffected — open it directly or feed it to your own tooling.

events.json Schema

Every test run writes a per-test events.json next to its event.html under relux/out/run-<timestamp>-<id>/logs/<test>/. The file is the canonical structured artifact: the viewer that ships in event.html is one consumer, and downstream tooling (dashboards, CI integrations, custom reporters) can read the same file.

This article describes the on-disk shape. The TypeScript declarations shipped under viewer/src/types/ are generated from the Rust source via ts-rs and stay in sync; they are the machine-readable equivalent of what this page describes in prose.

Conventions

  • Tagged enums carry their discriminator either as "kind" (Span, Event, BufferEvent, TestOutcome, SpanKind, EventKind, BufferEventKind) or as "type" (CancelReasonRecord, TimeoutValue, FailureRecord, MatchContext). The discriminator is always a kebab-case string. The remaining variant-specific fields are flattened alongside it.
  • Timestamps (ts, start_ts, end_ts, spawn_ts, terminate_ts) are fractional milliseconds since test start, encoded as JSON numbers.
  • Durations exposed to JSON are JSON numbers in milliseconds (elapsed, duration) unless they live inside a TimeoutValue, where they are pre-formatted humantime strings.
  • IDs (SpanId, EventSeq) are 64-bit unsigned integers. JSON object keys (e.g. spans) are stringified per JSON rules; the generated TS types reflect this.
  • String values are arbitrary UTF-8. Shell output is captured through a UTF-8 stream sanitizer.

Top-level shape

{
  "schema_version": 3,
  "info":   { ... TestInfo ... },
  "outcome": { "kind": "pass" | "fail" | "cancelled" | "skip", ... },
  "env":    { "bootstrap": [{ "key": "KEY", "value": "value", "source": { "kind": "base" } }, ...] },
  "shells": { "<shell-marker>": { ... ShellRecord ... }, ... },
  "spans":  { "<span-id>":     { ... Span ... },         ... },
  "events":        [ { ... Event ... },        ... ],
  "buffer_events": [ { ... BufferEvent ... },  ... ],
  "sources":   { "<relative-path>": "<file contents>", ... },
  "artifacts": [ { "path": "...", "size": 123, "mime": "..." }, ... ]
}

Field notes:

  • schema_version (u32) — current version is 3. Bumped on any change to the on-disk shape. Consumers should verify this matches the version they expect and fail loudly otherwise. The viewer rejects mismatched artifacts with a banner.
  • info{ name, path, duration_ms }. path is the source-relative path of the test file; duration_ms is the total wall-clock from test start to outcome.
  • env.bootstrap — list of env entries captured at test startup (the seed for the test’s environment chain, including this test’s own __RELUX_TEST_* internals). Each entry is { key, value, source }, where source is a tagged EnvSourceRecord: { "kind": "base" } (process env), { "kind": "dot-env", "path": "<file>" } (a .env layer), { "kind": "relux-internal" } (__RELUX_* run internals), or { "kind": "effect-overlay", "mnemonic": "<id>" }. The tag is the provenance of the layer that supplied the winning value. Because effect envs are not dumped here, bootstrap entries in practice carry only base, dot-env, or relux-internal; effect-overlay exists on the shared EnvSourceRecord type but does not appear in this list.
  • shells — every PTY spawned during the test, keyed by stable identity marker (see ShellRecord).
  • spans — every span opened during the test, keyed by SpanId (see Span). Forms a tree via parent.
  • events — execution events in seq order (see Event).
  • buffer_events — parallel timeline of PTY-buffer transitions in seq order (see BufferEvent).
  • sources.relux file contents referenced by any Span.location or Event.source. Keyed by relative path; only files actually referenced are embedded.
  • artifacts — files written under the test’s artifacts directory (see ArtifactEntry).

Outcome

outcome is a tagged enum on kind:

kindExtra fields
"pass"none
"fail"one of the FailureRecord variants, flattened
"cancelled"CancellationRecord, flattened
"skip"SkipRecord, flattened

The variant tag carried by FailureRecord lives on type (not kind) to avoid colliding with the outer outcome tag — so a fail-outcome payload looks like { "kind": "fail", "type": "match-timeout", ... }.

Failure

FailureRecord is a tagged enum on type. All variants carry a pre-computed call_stack (the active span stack at the failure site) and vars_in_scope. Most variants also carry a buffer_tail (the last bytes of the PTY buffer when the failure landed) — the exceptions are "runtime" and "pure-match", neither of which observes a buffer.

typeSource of failure
"match-timeout"match exceeded its effective timeout
"fail-pattern-matched"an installed fail pattern matched a recv line
"shell-exited"the PTY shell died unexpectedly (carries exit_code: i32 | null)
"runtime"any other runtime error (carries message; span/event_seq optional)
"multi-match"a <{ ... } block timed out before all patterns matched (carries patterns, matched indices, effective)
"pure-match"a pure-match statement (<expr> = <pattern> / <expr> ? <pattern>) did not match. Carries match_context (a MatchContext), value, pattern, is_regex; no buffer_tail (a pure match has no buffer)

Each variant also carries the span and event_seq that pinpoint the event-stream location of the failure.

Cancellation

CancellationRecord:

{
  "reason": { ... CancelReasonRecord ... },
  "span": <span-id> | null,
  "event_seq": <seq> | null,
  "shell": "<name>" | null,
  "call_stack": [ { ... StackFrame ... }, ... ]
}

CancelReasonRecord is a tagged enum on type:

typeExtra fields
"test-timeout"duration_ms
"suite-timeout"duration_ms
"fail-fast"trigger_test
"sigint"none

Skip

SkipRecord is a pointer into the in-stream marker evaluations:

{
  "span": <marker-eval span-id>,
  "event_seq": <bool-check event seq>,
  "marker_kind": "skip" | "run" | "flaky",
  "evaluation": { ... MarkerEvalDetail ... },
  "location": { "file", "line", "start", "end" } | null
}

location is the marker source (denormalised from the marker-eval span), so consumers can read the verbatim marker line without hopping through spans. Synthetic markers carry null.

The viewer focuses these at open time and expands ancestors so the markers tree is unfolded.

Shells

Keyed by stable identity marker. Each entry:

{
  "marker": "<same as the map key>",
  "name": "<spawn-time bare name>",
  "spawn_ts": <ms>,
  "terminate_ts": <ms> | null,
  "command": "<the spawning shell command>"
}

The display layer renders qualified forms like Db.inner from events (ShellSwitch, EffectExposeShell); the record itself holds the bare name observed at spawn time.

Spans

A span represents one bracketed region of execution. Spans nest via parent. Each span:

{
  "id": <span-id>,
  "parent": <span-id> | null,
  "start_ts": <ms>,
  "end_ts": <ms> | null,
  "location": { ... SourceLocation ... } | null,
  "kind": "<one of the kinds below>",
  ...   // kind-specific fields, flattened
}

SpanKind is tagged on kind:

kindPurpose
"test"Root span for the test body. name.
"effect-setup"An effect being acquired. effect, overlay, alias, dep_sources, marker, is_reuse. The bootstrap span has is_reuse: false; dedup’d reuse spans have is_reuse: true and zero duration. dep_sources is an array of [overlay_key, source_alias] pairs recording which overlay values were sourced from a sibling start’s exposed variable (implicit dependencies); empty when the start has no implicit deps.
"effect-cleanup"An effect being released. effect, alias, setup_span, marker, is_deferred. Parented under the test, not the long-closed setup; setup_span back-references its pair.
"shell-block"A shell <name> block. shell.
"cleanup-block"A cleanup block. No payload.
"fn-call"A function call (user or BIF). name, args, result, callee_kind ("user" | "bif"), is_pure.
"markers"Synthetic root grouping per-test marker evaluations.
"marker-eval"One marker evaluation under markers. marker_kind, modifier ("if" | "unless"), decision ("pass" | "mark").
"multi-match"A <{ ... } block. shell.

Events

An event is a point-in-time observation made by the VM. Events are emitted in monotonic seq order. Each event carries the span it landed on, the shell it acted on (when applicable), and a source location resolving against sources. The common envelope:

{
  "seq": <u64>,
  "ts": <ms>,
  "span": <span-id>,
  "shell": "<display name>" | null,
  "shell_marker": "<shell map key>" | null,
  "source": { ... SourceLocation ... } | null,
  "kind": "<one of the kinds below>",
  ...   // kind-specific fields, flattened
}

shell and shell_marker are present iff a shell was in scope at the emit site; shell_marker is the stable identity, shell is the display name at that moment.

EventKind is tagged on kind. The variants, grouped by concern:

Shell lifecycle

kindExtra fields
"shell-spawn"name, command
"shell-ready"name
"shell-switch"name
"shell-terminate"name
"effect-expose-shell"name, target, qualifier
"effect-expose-var"name, target, qualifier, value

I/O

kindExtra fields
"send"data
"recv"data

Matching (buffer_seq cross-references a buffer_events entry)

kindExtra fields
"match-start"pattern, is_regex, effective (a TimeoutValue)
"match-done"matched, elapsed (ms), captures: { [name]: string } | null, buffer_seq
"timeout"pattern, buffer_seq: u64 | null, effective. buffer_seq is null when no buffer event corresponds (the failure record’s buffer_tail is canonical in that case).
kindExtra fields
"multi-match-start"effective (a TimeoutValue), patterns: MultiMatchPattern[]
"multi-match-pattern-done"index (into patterns), elapsed (ms), buffer_seq (-> the per-pattern Matched buffer event)
"multi-match-done"advance_to (EventSeq -> the per-pattern Matched whose match ends farthest)
"multi-match-timeout"unmatched: number[] (pattern indices that did not match)

The per-pattern payload type:

{
  "pattern": "<string>",
  "is_regex": <bool>
}

Event sequences:

  • Success: multi-match-start + N x multi-match-pattern-done (emitted in match-completion order, not source order) + multi-match-done.
  • Timeout: multi-match-start + 0..N x multi-match-pattern-done + multi-match-timeout.
  • Fail-pattern abort: multi-match-start + 0..N x multi-match-pattern-done, then the standard fail-pattern-triggered propagation. No multi-match-done or multi-match-timeout follow.

The per-pattern Matched buffer events have the same before + matched + after shape as single-match. Inside a multi-match span, individual Matched events do not advance the reconstructed cursor; the block-end cursor advance is applied once at multi-match-done by dropping len(before) + len(matched) bytes from the front of the buffer of the Matched event referenced by advance_to.

Fail patterns

kindExtra fields
"fail-pattern-set"pattern, is_regex
"fail-pattern-cleared"none
"fail-pattern-triggered"pattern, is_regex, matched_line, buffer_seq: u64 | null (null because fail-pattern hits observe without advancing the cursor)

Control flow

kindExtra fields
"sleep-start"duration (ms)
"sleep-done"none
"timeout-set"timeout, previous (both TimeoutValue)

Values

kindExtra fields
"var-let"name, value
"var-assign"name, value, previous
"var-read"name, value ("" when undefined)
"string-eval"result
"interpolation"template, result, bindings: Array<[name, value]>
"pure-match-start"value, pattern, is_regex. Emitted before a pure string-match attempt runs.
"pure-match-done"matched (whole-match substring), captures: { [name]: string }.
"pure-match-failed"none. No match; the preceding pure-match-start in the same span carries value/pattern.
"bool-check"evaluation: MarkerEvalDetail. Emitted as the last event inside a marker-eval span.

MarkerEvalDetail is tagged on shape: "unconditional", "bare" + { value, met }, or "pure-match" + { value, pattern, is_regex, met }. There is no separate "eq"/"regex" shape - MatchKind was removed in favor of the is_regex: bool flag, and marker = and ? conditions both evaluate through the same pure matcher, so they share one payload shape.

Diagnostics

kindExtra fields
"annotate"text
"log"message
"warning"message
"error"message

Cancellation

kindExtra fields
"cancelled"reason: CancelReasonRecord. Emitted on the span the VM was inside when it observed cancellation.

Buffer events

A parallel timeline tracking transitions of each shell’s PTY output buffer. Buffer events always carry a shell. The common envelope:

{
  "seq": <u64>,
  "ts": <ms>,
  "shell": "<display name>",
  "shell_marker": "<shell map key>",
  "kind": "<one of the kinds below>",
  ...   // kind-specific fields, flattened
}

BufferEventKind is tagged on kind:

kindExtra fieldsMeaning
"grew"dataNew bytes appended to the buffer.
"matched"before, matched, afterA match consumed the cursor up through matched; before is what preceded, after is what now remains.
"reset"consumedThe buffer was reset (e.g. cleared between shell blocks); consumed is what got dropped.

Stack frames

StackFrame (used in FailureRecord.call_stack and CancellationRecord.call_stack):

{
  "span": <span-id>,
  "kind": "<span kind discriminator>",
  "name": "<fn or effect name>" | null,
  "args": [["name", "value"], ...],
  "alias": "<user-supplied alias>" | null,
  "location": { ... SourceLocation } | null
}

kind mirrors the span’s SpanKind discriminator (e.g. "fn-call", "shell-block"), with one refinement: a pure fn call (a SpanKind::FnCall with is_pure: true) renders as "pure-fn-call" rather than "fn-call", so a failure report distinguishes the pure call chain from an impure fn call. A "pure-match" failure reached through one or more pure fn bodies therefore carries "pure-fn-call" frames in its call_stack, outermost-first. alias is the user-supplied start FX as Alias binding when present; only effect-setup / effect-cleanup frames carry one today.

TimeoutValue

// Either:
{ "type": "tolerance",
  "duration": "5s",            // humantime-formatted
  "multiplier": "1.0",
  "total_duration": "5s",
  "source": { ... SourceLocation } | null }
// or:
{ "type": "assertion",
  "duration": "30s",
  "source": { ... SourceLocation } | null }

tolerance is the soft kind that scales with --timeout-multiplier; assertion is the hard kind that does not. All three duration fields are humantime strings — consumers should display them verbatim rather than re-parsing.

MatchContext

Names exactly where a "pure-match" failure’s assertion ran. Tagged on type:

// One of:
{ "type": "fn",               "name": "<fn or pure fn name>" }
{ "type": "test-preamble",    "name": "<test name>" }
{ "type": "effect-preamble",  "name": "<effect name>" }
{ "type": "shell",            "name": "<shell name>" }
  • "fn" — the pure match ran inside a fn or pure fn body; name is the function name. Reached through one or more "pure-fn-call" / "fn-call" frames in call_stack.
  • "test-preamble" — the pure match ran in a test’s preamble (before its first shell block); name is the test name.
  • "effect-preamble" — the pure match ran in an effect’s preamble (its lets and overlay expressions, before start/expose); name is the effect name.
  • "shell" — the pure match ran inside a shell block; name is the shell name.

MatchContext replaced a plain shell: String field the "pure-match" failure record used to carry: a pure match can run outside any shell (a preamble, a fn or pure fn body), so a bare shell name could not say where the failure actually happened. Pre-VM pure-match failures — a test/effect preamble, an overlay expression, or a pure fn body — carry a real event_seq and a populated vars_in_scope, the same as VM-observed failures; neither field is ever 0 / empty for a "pure-match" failure.

SourceLocation

{
  "file": "<relative path; matches a key in `sources`>",
  "line": <1-based line number>,
  "start": <byte offset into the source>,
  "end":   <byte offset into the source>
}

start/end resolve against sources[file].

Artifacts

{
  "path": "<forward-slash relative path>",
  "size": <bytes>,
  "mime": "<mime/type>" | null
}

path never starts with / and never contains . / .. segments. The list is sorted with files preceding subdirectory contents at each level (cmp_artifact_paths). mime is derived from the extension via mime_guess; the browser does its own sniffing on click.

Versioning

schema_version is currently 3. Version 3 added the dep_sources array to effect-setup spans (implicit effect dependencies). Version 2 changed env.bootstrap from [name, value] tuples to { key, value, source } objects. The version bumps on any change to the on-disk shape – fields added, removed, or renamed, new tagged-enum variants, or a narrowed meaning for an existing field – because the viewer is bundled per artifact and reads the schema without tolerating version skew.

To regenerate the TypeScript bindings after editing the Rust types, run just build-viewer — it runs the ts-rs export tests and then rebuilds the viewer bundle.

Editor Support

Relux ships syntax highlighting and language support plugins for VS Code (and Cursor / VSCodium / code-server) and IntelliJ-family IDEs.

VS Code, Cursor, code-server, VSCodium

The extension is published to two registries and works wherever you install it from:

Install from the command line:

code --install-extension spawnlink-eu.relux

Or search for Relux in the Extensions sidebar of your editor.

Features

  • Syntax highlighting for keywords, operators, strings, regex patterns, timeouts, comments.
  • Bracket matching, auto-closing, folding.
  • String interpolation highlighting (${var}, $1).

Source

editors/vscode/ in shizzard/relux. Contributions welcome - see editors/vscode/CONTRIBUTING.md.

IntelliJ IDEA, RustRover, CLion, PyCharm, GoLand, WebStorm

The IntelliJ plugin is published to the JetBrains Marketplace.

Install via Settings -> Plugins -> Marketplace -> search “Relux”.

Source

editors/intellij/ in shizzard/relux.