Renderingen-US

Errors and testing

Error handling#

Faber separates three related ideas that many languages collapse into one shape:

ConstructMeaning
→ TNormal success return channel
T ∪ nihilAbsence in the success value domain
⇥ ERecoverable alternate-exit channel for errors

Normal return#

fn porta(int x)  int {
    if x ≺ 0 then return 0
    return x * 2
}

Failable functions#

Use when a function can leave by an error channel:

fn divide(int a, int b)  intstring {
    if b  0 {
        throw "division by zero"
    }
    return a / b
}

Throwing — iace#

iace sends a value on the error channel:

fn exigePositivum(int value)  voidstring {
    if value ≺ 0 {
        throw "negative value"
    }
}

Recovery — fac / cape#

Callers recover locally with a do block and a catch handler:

fn divide(int a, int b)  int {
    if b  0 then return 0
    return a / b
}

fn tutum(int a, int b)  int {
    do {
        return divide(a, b)
    }
    catch err {
        warn err
        return 0
    }
}

A direct failable call is not an ordinary expression. Place calls to → T ⇥ E functions inside an active do / catch boundary.

The alternate channel in async surfaces#

The same ⇥ E channel rides inside Faber's async types rather than as a separate mechanism. A fiet function returns promissum<T> — the infallible shorthand for promissum<T ⇥ numquam> — and promissum<T ⇥ E> preserves the delayed alternate alongside the eventual value. Awaiting a failable promise is itself a failable operation: the success value binds inside a do / catch boundary while the failure stays observable, exactly like a failable sync call.

Async and sync streams carry the channel too: fient → T ⇥ E makes every pull a promise that can yield, end, or fail (the first failure ends the stream, handled by itera ex); fiunt → T ⇥ E makes the stream call itself failable, recovered with do / catch.

See Functions — two-channel promises for the full treatment.

Inline conversion recovery#

can also specify an inline recovery value on conversions:

const string raw  "42"

const int n  raw ↦ int0

Effect-only failable#

For functions that error but do not return a success value, omit → T:

fn exigePositivum(int value)  voidstring {
    if value ≺ 0 {
        throw "negative value"
    }
}

Current status#

, return, , iace, and do / catch are live grammar and checker surfaces. Rust and Go lowering for full / iace / catch runtime behaviour is still a backend gap — these pass type-checking but do not yet emit failable runtime code to all targets.

Inline testing#

Faber has a first-class testing framework built into the language with three keywords: probandum declares a test suite, proba declares a single test case, and adfirma asserts a condition. Tests live alongside the code they test — either in the same .fab file or in colocated *.proba test-source files — run through faber test on the MIR stepper, and support the same compiler pipeline as production code: locale-aware, type-checked, and target-neutral.

The three keywords#

KeywordRoleApproximate equivalent
probandumDeclares a named test suitedescribe, #[cfg(test)] mod
probaDeclares a single test caseit, #[test]
adfirmaAsserts a condition at runtimeassert!, assert_eq!

probandum — test suite#

A probandum block groups related test cases. Suites can be nested to organise tests hierarchically:

test "unum plus unum" {
    assert 1 + 1  2
}

test "multiplicatio" {
    assert 3 * 4  12
}

test "comparatio" {
    const int x  10
    assert x  10
}

proba — test case#

A proba block contains the test logic. It can use any Faber code — variable bindings, function calls, control flow — and ends with one or more adfirma assertions. Tests can be tagged with an optional tag marker for selective execution:

proba "echo formats operands with one space" tag "coreutils" {
    adfirma echo_textus(["hello", "world"]) ≡ "hello world"
}

adfirma — assertion#

adfirma evaluates a boolean expression and reports failure if it is false. An optional message string provides context on failure:

main {
    const int x  10

    # Simple assertion
    assert x ≻ 0

    # With custom message
    assert x  10 secus "x decem esse debet"

    # Multiple assertions in sequence
    const string nomen  "Marcus"
    assert nomen  "Marcus"
    assert nomen  "" secus "nomen vacuum non sit"
}

Workflow#

Tests run through the faber test command, which executes proba cases on the MIR stepper — no Cargo or rustc is invoked for the package:

faber test                        # run all tests in the current package
faber test examples/coreutils/packages/echo  # run tests for a specific package
faber test . --filter smoke       # substring filter on case path or title
faber test . --include math       # load only *.proba sources matching a path pattern
faber test . --exclude 'nested/*' # skip *.proba sources matching a path pattern
faber test . --name my_case       # select by proba name
faber test . --suite suite/path   # select by probandum suite path
faber test . --tag slow           # select by tag modifier

Tests can live in the same .fab file as the code they test, or in colocated *.proba files (the preferred home for stdlib and public-contract suites). .proba files are test-only: discovered only by faber test, never importable from product modules, and excluded from Cista package snapshots. There is no separate test directory structure and no test module declaration. The compiler knows which blocks are test code and which are production code by the keywords used — probandum and proba are parsed but excluded from production builds.

Warnings can be promoted to errors with --deny-warnings or --deny <CODE> (repeatable), on faber test and faber build alike.

Real-world example#

The coreutils echo package demonstrates the testing framework in practice. Tests live in the same file as the implementation, covering option parsing, escape expansion, and edge cases:

probandum "echo formatting" tag "coreutils" {
    proba "empty operands format as empty text" {
        fixum lista<textus> words ← vacua
        adfirma echo_textus(words) ≡ ""
    }

    proba "single operand is unchanged" {
        adfirma echo_textus(["hello"]) ≡ "hello"
    }

    proba "-E is a leading no-op option" {
        adfirma echo_textus(["-E", "hello", "world"]) ≡ "hello world"
    }

    proba "-n suppresses the trailing newline flag" {
        adfirma echo_novam_lineam(["-n", "hello"]) ≡ falsum
    }

    proba "-e expands the declared escape subset" {
        adfirma echo_textus(["-e", "a\\nb"]) ≡ "a\nb"
        adfirma echo_textus(["-e", "a\\tb"]) ≡ "a\tb"
    }
}

Design notes#

Several design choices distinguish Faber's testing framework from conventional approaches:

  • No separate test binary. Tests are declarations in the same source file (or in *.proba test sources), not a separate compilation target. The compiler filters test blocks from production output.
  • Tags, not directories. Tests are organised by tag markers rather than directory structure. A test can belong to multiple organisational axes without being moved.
  • Full compiler pipeline. Tests are type-checked, analysed, and locale-aware — the same --locale flag applies to test output.
  • Stepper-executed. faber test runs proba cases on the MIR stepper; no generated test crate, Cargo, or Rust toolchain is required.
  • Target-neutral. The stepper analysis is independent of any codegen target.
  • Nested suites. probandum blocks can nest, mirroring the structure of the code they test.

References#

  1. radix/corpus/probandum/ — probandum exemplar files
  2. radix/corpus/proba/ — proba exemplar files
  3. radix/corpus/adfirma/ — adfirma exemplar files
  4. examples/coreutils/packages/echo/src/main.fab — real-world usage with tags