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 ∪ noneAbsence 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) → int ⇥ string {
    if b ≡ 0 {
        throw "division by zero"
    }
    return a / b
}

Throwing — throw#

throw sends a value on the error channel:

fn exigePositivum(int value) → void ⇥ string {
    if value ≺ 0 {
        throw "negative value"
    }
}

Guards — require / reject#

Guard statements pair a condition with a throw in one line. require (English reader spelling require) throws when the condition fails — it demands that the condition hold:

fn divide(int a, int b) → int ⇥ string {
    require b ≠ 0 throw "division by zero"
    return a / b
}

reject (English reader spelling reject) is its boolean opposite: it throws when the condition holds. Read it as an early rejection — the happy path is whatever the condition rules out:

fn exigePositivum(int value) → int ⇥ string {
    reject value ≺ 0 throw "negative value"
    return value
}

Both compile to the same shape as an if around throw — `reject cond throw err is exactly if cond { throw err } — and, like throw` itself, they require the enclosing function to declare a ⇥ channel.

Recovery — do / catch#

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 async function returns promise<T> — the infallible shorthand for promise<T ⇥ numquam> — and promise<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: async_generator → T ⇥ E makes every pull a promise that can yield, end, or fail (the first failure ends the stream, handled by for from); generator → T ⇥ E makes the stream call itself failable, recovered with do / catch.

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

Inline conversion recovery#

⊥ specifies an inline default value on ↦ conversions (⇥ only ever names an error type):

main {
    const string raw ← "42"
    const _ n ← raw ↦ int ⊥ 0
}

Effect-only failable#

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

fn exigePositivum(int value) → void ⇥ string {
    if value ≺ 0 {
        throw "negative value"
    }
}

Current status#

→, return, ⇥, throw, and do / catch are live grammar and checker surfaces. Rust and Go lowering for full ⇥ / throw / 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: describe declares a test suite, test declares a single test case, and assert 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
describeDeclares a named test suitedescribe, #[cfg(test)] mod
testDeclares a single test caseit, #[test]
assertAsserts a condition at runtimeassert!, assert_eq!

describe — test suite#

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

describe "arithmetica" {
    test "unum plus unum" {
        assert 1 + 1 ≡ 2
    }

    test "multiplicatio" {
        assert 3 * 4 ≡ 12
    }

    describe "implicata" {
        test "comparatio" {
            const _ x ← 10
            assert x ≥ 10
        }
    }
}

test — test case#

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

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

assert — assertion#

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

main {
    const _ x ← 10

    # Simple assertion
    assert x ≻ 0

    # With custom message
    assert x ≡ 10 panic "x decem esse debet"

    # Multiple assertions in sequence
    const _ name ← "Marcus"
    assert name ≡ "Marcus"
    assert name ≠ "" panic "nomen vacuum non sit"
}

Workflow#

Tests run through the faber test command, which executes test 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 test name
faber test . --suite suite/path   # select by describe 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 — describe and test 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:

describe "echo formatting" tag "coreutils" {
    test "empty operands format as empty text" {
        const list<string> words ← empty
        assert echo_textus(words) ≡ ""
    }

    test "single operand is unchanged" {
        assert echo_textus(["hello"]) ≡ "hello"
    }

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

    test "-n suppresses the trailing newline flag" {
        assert echo_novam_lineam(["-n", "hello"]) ≡ false
    }

    test "-e expands the declared escape subset" {
        assert echo_textus(["-e", "a\\nb"]) ≡ "a\nb"
        assert 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 test 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. describe blocks can nest, mirroring the structure of the code they test.

References#

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