Relux Semantic Model
Modules
- Every
.reluxfile 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/matchersresolves tolib/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 asaliases 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
letdeclaration - 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 —
letcreates a shadow, not a modification of the process environment) - Hierarchical
.envfiles, 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 apure fnbody a?pure match binds$1..$ninto the function’s own per-call frame, discarded when the function returns (they do not leak to the caller, just as a calledfn’s captures do not leak out)
Injected Environment
- The runtime injects eight
__RELUX_*variables into every shell, layered above the.envstack so no.envor host value can shadow them - Run-level, shared by every test in the run:
__RELUX_RUN_ID— identifier of the current run; appears in therelux/out/<run>/directory name alongside a timestamp__RELUX_RUN_ARTIFACTS— absolute path torelux/out/<run>/artifacts/; shared across the run (visible to every test concurrently) and not collected into any test’sartifacts[]__RELUX_SHELL_PROMPT— the configured shell prompt; mirrors[shell].prompt__RELUX_SUITE_ROOT— absolute path to the directory containingRelux.toml__RELUX— absolute path to thereluxbinary; 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’sartifacts[]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
shellblocks (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
~30sor!? errorchanges 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 fninstead offn - Cannot contain shell operators (
>,=>,<?,<=,!?,!=, timeouts) - Cannot call impure built-in functions (e.g.,
match_prompt(),ctrl_c(),sleep(),log(),annotate()) - Cannot call regular
fnfunctions — only other pure functions and pure built-in functions - Can only contain:
letdeclarations, variable reassignment, expressions, and pure-match statements (<expr> = <pattern>/<expr> ? <pattern>) - A
?pure match inside the body binds$1..$ninto the call’s own capture frame, so apure fncan 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(), andannotate()require a shell context and are impure
Shells
- A shell is a spawned PTY process (default:
/bin/sh) stdoutandstderrare merged into a single output stream- Send operators (
>,=>) write to the shell’sstdin - 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 (
<~duror<@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
- Tolerance (
- 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 -
$nis 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
- Each inner line is
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
shellblocks,fnbodies,pure fnbodies, and test / effect preambles (alongsidelet, before the firstshellblock):<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, …,$nin 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 fnbody, a?pure match binds numeric captures into the function’s own per-call capture frame (each call starts empty), so apure fncan match$1out of a value and return it. A no-match inside apure fnfails 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$ninto a single capture frame hoisted across the whole preamble: later preamblelets, later preamble pure matches, and astartoverlay’s expressions all read it. A non-matching preamble pure match fails the test (test-level) or fails effect instantiation (effect-level).$nreads the ambient frame uniformly, rendering""when no regex has run, so a$nwith no preceding match is not an error - Shells do not inherit the preamble capture frame. A
shellblock owns its own frame, populated only by its own<?matches, starting empty;$ninside a shell reads the shell’s frame, never the preamble’s. Carry a preamble capture into a shell by binding it to alet. Markers run before the preamble, so a marker condition sees an empty frame - Statement-only: a pure match cannot be a
letright-hand side, an overlay value, or a cleanup value.x = easserts; binding still requiresx := e(andlet x = eremains 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 satisfiableexpose— declares which shells and variables the effect makes available to callers; theexposekeyword requires ashellorvardiscriminator (expose shell db,expose var port); internal shells not listed inexposeare terminated after setupstart— declares dependency effects with optional env remapping via overlay
- None of these declarations are mandatory: an effect may have no
expect, nostart, and noexpose start Effectruns the dependency for side effects only — its shells are not accessiblestart Effect as Aliasruns 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 toKEY := KEY
- The shorthand form
- 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 —Apiis instantiated only afterDbis ready, and readsDb’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 mustexpose varthe 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.varcan name a sibling’sexpose var, never itsexpose 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 }andB { 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
letand feed it into both effects’ overlays — still compiles and runs unchanged
- The referenced start must be aliased (
- 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.varoverlay form above, exactly like any other overlay value: two dependents wired to different producers (e.g. twoApiinstances reading differentDb.portvalues) 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 ashellbody orcleanupbody — at effect level and at test level alike — is validated at compile time:Aliasmust be one of the enclosing effect’s or test’s ownstartdependency aliases, and that dependency mustexpose varthe 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
letbindings cannot reference them (purity violation —letis evaluated at resolve time, before effects are started) - Exposed variables are read-only from the caller’s perspective
- For composed effects,
exposecan 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$ninto a preamble capture frame that later setuplets andstartoverlays read (the effect’s ownshellblocks 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
cleanupblock that runs when the effect is torn down
Condition Markers
- Condition markers are placed immediately before
test,effect,fn, orpure fndeclarations - 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
- Test-level markers are checked before
- A bare marker (kind only, no modifier) is unconditional:
# skipalways skips,# flakyalways marks flaky,# runis 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 interpolation42— 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-.envenvironment) — 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 apure 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:
ifacts when the result is truthyunlessacts when the result is falsy
- Kind semantics:
skip: skips the test/effect when the condition is metrun: skips the test/effect when the condition is NOT met (inverse ofskip)flaky: marks the test as flaky — with[flaky].max_retries > 0inRelux.toml, a failing flaky test is retried from scratch with exponentially increasing tolerance timeouts (base × m^(retry-1)). Withmax_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
# flakypropagates the same way skip does: a test is marked flaky if it, or any function or effect it reaches, has a# flakymarker 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 thetestdeclaration - Test structure (in order):
- Doc string (optional
"""...""") - Preamble:
letdeclarations (test-scoped variables) and pure-match statements (<expr> = <pattern>/<expr> ? <pattern>), interleaved; a?match binds$ninto a preamble capture frame the later preamble items and overlays read startdeclarations (effect dependencies)shellblocks (test body) — each owns its own capture frame; the preamble frame is not inheritedcleanupblock (optional)
- Doc string (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
~Ttimeout 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
.reluxfiles, parses them, resolves imports and effect dependencies - Tests are the entry points — only modules with
testblocks are executed - For each test:
- Resolve the effect dependency graph
- Run effects in topological order (reusing deduplicated instances)
- Execute the test body (shell blocks in declaration order)
- Run test cleanup
- 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
| Field | Type | Default | Description |
|---|---|---|---|
name | string | directory containing Relux.toml | Suite name |
[shell] section
| Field | Type | Default | Description |
|---|---|---|---|
command | string | /bin/sh | Shell executable spawned for each shell |
prompt | string | relux> | PS1 prompt set on shell init |
[timeout] section
All durations use humantime format (e.g. 5s, 1m30s, 2h).
| Field | Type | Default | Description |
|---|---|---|---|
match | duration | 5s | Per-match timeout |
test | duration | 5m | Max wall-clock time per test |
suite | duration | 10m | Max wall-clock time for the entire test run |
[run] section
| Field | Type | Default | Description |
|---|---|---|---|
jobs | integer | 1 | Number of parallel test workers |
[flaky] section
| Field | Type | Default | Description |
|---|---|---|---|
max_retries | integer | 0 | Max retries for # flaky-marked tests (0 = no retries) |
timeout_multiplier | float | 1.5 | Exponential 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.
| Key | Type | Default | Description |
|---|---|---|---|
range_start | integer | OS privileged boundary (usually 1024) | First allocatable port (inclusive) |
range_end | integer | one below the OS ephemeral interval | Last 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 whenrelux runis invoked without--fileflags.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. Alatestsymlink 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.
| Flag | Description |
|---|---|
-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, --jobs | Number of parallel test workers (default: 1) |
--tap | Generate TAP artifact file in the run directory |
--junit | Generate JUnit XML artifact file in the run directory |
-m, --timeout-multiplier | Scale 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 |
--rerun | Re-run only non-passing tests from the latest run |
--flaky-retries | Max retries for # flaky-marked tests |
--flaky-multiplier | Exponential timeout multiplier base for flaky retries (default: 1.5, must be > 1.0) |
--test-timeout | Override per-test timeout (humantime string) |
--suite-timeout | Override 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.
| Flag | Description |
|---|---|
--manifest <path> | Path to Relux.toml (default: auto-discover by walking upward) |
relux history [flags]
Analyze run history from relux/out/.
| Flag | Description |
|---|---|
--manifest <path> | Path to Relux.toml (default: auto-discover by walking upward) |
--flaky | Show tests that have been both passing and failing |
--failures | Show tests that have failed |
--first-fail | Show the first failure for each test |
--durations | Show 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.
| Flag | Description |
|---|---|
--shell <shell> | Shell to generate completions for: bash, zsh, fish (default: autodetect from $SHELL) |
--install | Write 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
fnfunctions - 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> }
}
expectdeclares required environment variables (comma-separated)startdeclares dependencies (one per line)start Effectruns the dependency for side effects only — its shells are not accessiblestart Effect as Aliasruns 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; shorthandKEYis equivalent toKEY := KEY- An overlay value may reference a sibling start’s exposed variable:
KEY := Alias.var(Aliasmust be a sibling’s alias in the same start-list and mustexpose varthat name — neverexpose shell, since overlay values are strings). This induces an implicit ordering dependency; see Effects and Effect Identity below expose shelldeclares which shells are part of the effect’s public interfaceexpose vardeclares which variables are part of the effect’s public interface; these arelet-bound values computed during setupexpose shell Alias.shell as namere-exports a dependency’s shell under a new nameexpose var Alias.var as namere-exports a dependency’s variable under a new nameshell Alias.shell_name { ... }— qualified shell block for operating on a dependency’s exposed shell- Internal shells not listed in
exposeare terminated after setup cleanupblock: 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
startoverlay rules (including the sibling-reference formKEY := Alias.var) are identical to an effect body’sstart— see Effects above - A qualified reference
${Alias.var}in a test’sshellorcleanupbody is validated the same way:Aliasmust be one of the test’s ownstartdependency aliases, and that dependency mustexpose varthe 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|flakymodifier:if|unlessexpr: 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, orpure fndeclarations (not inside the body) - When a function is skipped, all tests that call it are also skipped
- A
# flakymarker 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
| Marker | Modifier | Condition | Meaning |
|---|---|---|---|
# skip | (none) | (unconditional) | always skip |
# skip | if | truthy | skip when condition is true |
# skip | unless | falsy | skip when condition is false |
# run | (none) | (unconditional) | no-op (always run) |
# run | if | falsy | skip when condition is false |
# run | unless | truthy | skip when condition is true |
# flaky | (none) | (unconditional) | always mark as flaky |
# flaky | if | truthy | mark as flaky when condition is true |
# flaky | unless | falsy | mark 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
effectandtestblocks
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 = eassertsxequalse) and the literal-match arm inside a multimatch block (= <literal>, see below).let x = eis an error — uselet x := e. - Quoted values required for
letassignments - 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
.envfiles — is available everywhere)
Operators
All operators are followed by a space, then payload to end of line.
Send
| Operator | Payload | Value |
|---|---|---|
> | text to EOL | sent string |
=> | text to EOL | sent string |
>sends with trailing newline=>sends without trailing newline (raw send)- Variable interpolation applies in payload
Match
| Operator | Payload | Value |
|---|---|---|
<? | regex to EOL | full match ($0) |
<= | literal to EOL | matched 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.
| Statement | Meaning |
|---|---|
<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
shellblocks,fnbodies,pure fnbodies, and test / effect preambles (alongsidelet). Inside apure fn, a?match binds captures into the function’s own per-call frame, so$1can be returned to extract a value. - In a preamble, a
?match binds$ninto a preamble capture frame that later preamblelets andstartoverlays read; ashellblock does not inherit it (a shell owns its own frame).$nreads the ambient frame,""when unset. See Pure Matching. - Statement-only: cannot be a
letright-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
| Operator | Payload |
|---|---|
!? | 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:
| Expression | Value |
|---|---|
"<text>" | string literal |
name | variable value |
Alias.var | a dependency’s exposed variable (dot-access) |
$1, $2 | regex 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.varsibling reference is an overlay value like any other: it is evaluated (the sibling isReadyby 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:
shellblocks (in tests and effects)- test and effect preambles — alongside
let, before the firstshellblock (and, in an effect, before itsstartandexposeitems) - regular
fnbodies pure fnbodies
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:
| Statement | Meaning |
|---|---|
x := e | bind or reassign the variable x to the value of e |
x = e | assert that the value of x equals the pattern e |
let x := e | declare x with the value of e |
let x = e | error — 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. Writingx == y— a=immediately followed by another=, with no space between them — is a parse error: there is no==operator. Writex = yfor “x equals y exactly”; writex := yto bind. A pattern may legitimately begin with=, so if you really want to assert thatxequals the literal text= y, put a space between the two:x = = yis 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
| Function | Signature | Returns | Description |
|---|---|---|---|
trim | trim(s) | string | Remove leading and trailing whitespace from s. |
upper | upper(s) | string | Convert s to uppercase. |
lower | lower(s) | string | Convert s to lowercase. |
replace | replace(s, from, to) | string | Replace all occurrences of from with to in s. |
split | split(s, sep, index) | string | Split 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. |
len | len(s) | string | Return the byte length of s as a decimal string. |
default | default(a, b) | string | Return a if it is non-empty, otherwise return b. |
Generators
| Function | Signature | Returns | Description |
|---|---|---|---|
uuid | uuid() | string | Generate a random UUID v4 (e.g. "550e8400-e29b-41d4-a716-446655440000"). |
rand | rand(n) | string | Generate a random alphanumeric string of length n. Errors if n is not a valid integer. |
rand | rand(n, mode) | string | Generate 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
| Function | Signature | Returns | Description |
|---|---|---|---|
available_port | available_port() | string | Allocate 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. |
which | which(name) | string | Search 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
| Function | Signature | Returns | Description |
|---|---|---|---|
mnemonic | mnemonic(s) | string | Derive 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. |
sha1 | sha1(s) | string | SHA-1 digest of s as 40-character lowercase hexadecimal. |
Time
| Function | Signature | Returns | Description |
|---|---|---|---|
timestamp | timestamp(fmt) | string | Current 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
| Function | Signature | Returns | Description |
|---|---|---|---|
match_prompt | match_prompt() | string | Match the shell prompt configured in Relux.toml. Advances the output cursor past the prompt. |
match_ok | match_ok() | string | Match the shell prompt, send echo $?, match 0, and match the prompt again. Verifies the previous command exited with status 0. |
match_not_ok | match_not_ok() | string | Match 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_ok | match_not_ok(code) | string | Match 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_code | match_exit_code(code) | string | Send 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
| Function | Signature | Returns | Description |
|---|---|---|---|
ctrl_c | ctrl_c() | "" | Send ETX (0x03) — interrupt the current process. |
ctrl_d | ctrl_d() | "" | Send EOT (0x04) — signal end of input. |
ctrl_z | ctrl_z() | "" | Send SUB (0x1A) — suspend the current process. |
ctrl_l | ctrl_l() | "" | Send FF (0x0C) — clear the terminal screen. |
ctrl_backslash | ctrl_backslash() | "" | Send FS (0x1C) — send SIGQUIT to the current process. |
Timing
| Function | Signature | Returns | Description |
|---|---|---|---|
sleep | sleep(duration) | "" | Pause execution for duration. Accepts humantime format: 500ms, 2s, 1m30s, etc. Errors if the duration is invalid. |
Logging
| Function | Signature | Returns | Description |
|---|---|---|---|
log | log(message) | string | Emit message to the event log and HTML report. Returns message. |
annotate | annotate(text) | string | Emit 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. Seeevents.jsonSchema 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 TAPlog_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 viaDecompressionStream, setswindow.RELUX_DATA, and runs the three JS payloads in order (hljs core → Relux grammar → viewer). Opens directly viafile://; 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-summaryindex.html, JUnit[[ATTACHMENT|...]]markers, and TAPlog: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 (
~Ton the test): the per-test watchdog fired. Carried asreason: { 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 withreason: { 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 okin TAP, with a diagnostic block carryingcancellation: <reason-tag>. - Render as
<error type="cancelled" message="cancelled: <reason-tag>"/>in JUnit XML (distinct from<failure>and<skipped>). - Render as a
cancelledrow in the HTML run index and aCANCELLEDpill in the per-test viewer. - A
cancelledevent inevents.jsonmarks 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:
- App bar — test identity, outcome, modal launchers, run timing.
- Timeline bar — proportional bar of the test’s time range with click-to-jump slices.
- Events list (left pane) — the structured event log as a foldable tree.
- 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 pill —
pass,fail,cancelled,skip, orinvalid.cancelledindicates the test was stopped before completion (test-timeout, suite-timeout, fail-fast, or SIGINT); the in-streamcancelledevent tells you which one. - Modal launcher chips —
env,shells (N),artifacts (N). The artifacts chip is disabled when the test produced no files. - Timing summary — total duration, event count, span count.
| Key | Action |
|---|---|
E | Toggle the env modal |
S | Toggle the shells modal |
A | Toggle 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-donepair, asleep-start/sleep-donepair). - Log bar — emitted by the
logBIF; 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, eachmulti-match-pattern-doneevent renders as its own selectable row, labelledmatchin the kind column (the same label used by a folded single-matchmatch-start/match-donepair). - Pure match — pure string matches (
=/?) fold into a single row, outcome-colored (green on success, red on no-match), and labeledpure-match(vsmatchfor 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.
| Key | Action |
|---|---|
Up / Down | Move selection to the previous / next row |
Right | Expand the selected span |
Left | Collapse the selected span |
Enter / Space | If an event is selected, deselect it; if a span is selected, toggle its fold |
F | Toggle the filter popup |
T | Toggle the error-path preset (no-op when the test passed) |
M | Toggle the send / match preset |
C | Collapse all spans |
X | Expand 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. letvariables — 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.
| Key | Action |
|---|---|
Enter | Cycle to the next hit |
Shift+Enter | Cycle to the previous hit |
Esc | Clear the query; blur the field if already empty |
Cmd+S / Ctrl+S | Focus / 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:
- 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. .envfiles — 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.- 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.
| Key | Action |
|---|---|
E | Toggle the modal |
Esc | Close the modal |
Cmd+S / Ctrl+S | Focus 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 eventbadge 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.
| Key | Action |
|---|---|
S | Toggle the modal |
Esc | Close the modal |
Cmd+S / Ctrl+S | Cycle 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.
| Key | Action |
|---|---|
A | Toggle the modal (no-op when the test has no artifacts) |
Esc | Close the modal |
Cmd+S / Ctrl+S | Focus 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).
| Key | Scope | Action |
|---|---|---|
E | global | Toggle env modal |
S | global | Toggle shells modal |
A | global | Toggle artifacts modal |
T | global | Toggle error-path preset in the events list |
M | global | Toggle send / match preset in the events list |
F | global | Toggle the events-list filter popup |
C | global | Collapse all spans |
X | global | Expand all spans |
Esc | global | Close the open modal |
Cmd+S / Ctrl+S | global | Focus / cycle search inputs in the current scope (modal if one is open, otherwise the main view) |
Up / Down | events list | Move selection by one row |
Right / Left | events list | Expand / collapse the selected span |
Enter / Space | events list | Toggle the current row |
Enter | searchable buffer | Cycle to the next hit |
Shift+Enter | searchable buffer | Cycle to the previous hit |
Esc | searchable buffer | Clear 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 aTimeoutValue, 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 is3. 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 }.pathis the source-relative path of the test file;duration_msis 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 }, wheresourceis a taggedEnvSourceRecord:{ "kind": "base" }(process env),{ "kind": "dot-env", "path": "<file>" }(a.envlayer),{ "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 onlybase,dot-env, orrelux-internal;effect-overlayexists on the sharedEnvSourceRecordtype but does not appear in this list.shells— every PTY spawned during the test, keyed by stable identity marker (seeShellRecord).spans— every span opened during the test, keyed bySpanId(seeSpan). Forms a tree viaparent.events— execution events inseqorder (seeEvent).buffer_events— parallel timeline of PTY-buffer transitions inseqorder (seeBufferEvent).sources—.reluxfile contents referenced by anySpan.locationorEvent.source. Keyed by relative path; only files actually referenced are embedded.artifacts— files written under the test’s artifacts directory (seeArtifactEntry).
Outcome
outcome is a tagged enum on kind:
kind | Extra 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.
type | Source 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:
type | Extra 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:
kind | Purpose |
|---|---|
"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
kind | Extra 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
kind | Extra fields |
|---|---|
"send" | data |
"recv" | data |
Matching (buffer_seq cross-references a buffer_events entry)
kind | Extra 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). |
kind | Extra 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 xmulti-match-pattern-done(emitted in match-completion order, not source order) +multi-match-done. - Timeout:
multi-match-start+ 0..N xmulti-match-pattern-done+multi-match-timeout. - Fail-pattern abort:
multi-match-start+ 0..N xmulti-match-pattern-done, then the standardfail-pattern-triggeredpropagation. Nomulti-match-doneormulti-match-timeoutfollow.
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
kind | Extra 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
kind | Extra fields |
|---|---|
"sleep-start" | duration (ms) |
"sleep-done" | none |
"timeout-set" | timeout, previous (both TimeoutValue) |
Values
kind | Extra 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
kind | Extra fields |
|---|---|
"annotate" | text |
"log" | message |
"warning" | message |
"error" | message |
Cancellation
kind | Extra 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:
kind | Extra fields | Meaning |
|---|---|---|
"grew" | data | New bytes appended to the buffer. |
"matched" | before, matched, after | A match consumed the cursor up through matched; before is what preceded, after is what now remains. |
"reset" | consumed | The 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 afnorpure fnbody;nameis the function name. Reached through one or more"pure-fn-call"/"fn-call"frames incall_stack."test-preamble"— the pure match ran in a test’s preamble (before its firstshellblock);nameis the test name."effect-preamble"— the pure match ran in an effect’s preamble (itslets and overlay expressions, beforestart/expose);nameis the effect name."shell"— the pure match ran inside ashellblock;nameis 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:
- Visual Studio Marketplace - VS Code, Cursor.
- Open VSX - VSCodium, code-server, Gitpod, air-gapped derivatives.
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.