LLuce

Traps and errors

Three shapes, and one rule that decides between them.

A failure is an error if and only if a correct program, given correct input, can still meet it — because the world decided, not the program. Everything else is a trap. And if the answer is simply "there is nothing there", with no reason worth carrying, it is neither: it is a T?. Traps are bugs. Errors are news.

Traps are bugs, errors are news is the working guide to applying it.

Traps#

A trap is deterministic, carries a stable code, and ends the program without publishing anything. Every check that raises one is part of the language and cannot be disabled: there is no build mode in which Int arithmetic wraps or an index goes unchecked.

A debug build reports code, message, file:line:column and the call trace. --release keeps the code, the message and the function names and drops the lines. Both engines report identically. A trace keeps the innermost 64 frames and counts the rest.

The codes#

CodeMessageRaised when
integer_overflowinteger overflowa checked Int operation leaves 64 bits
divide_by_zerodivision by zero/ or % with a zero Int divisor
conversion_rangeconversion out of rangeInt(f) outside the Int range
assertion_failedassertion failedassert(false)
explicit_trapexplicit traptrap(message)
missing_returnfunction ended without returning a valuea value-returning function fell off its end
step_budget_exhaustedevaluation step budget exhaustedthe host's step budget ran out
call_depth_exceededcall depth exceededthe call-depth budget ran out
string_boundsstring index out of boundsa String index or slice past the end
string_boundarystring slice splits a UTF-8 sequencea String slice cutting a character
host_unavailablehost service unavailablean effect the host does not implement
argument_boundsprogram argument out of rangearg(i) with i at or past arg_count()
index_boundsindex out of boundsa list or array index past the end
key_missingkey not found in mapindexing a map with an absent key
empty_collectionpop from an empty listpop() on an empty list
use_after_freeobject used after freean alias outliving its owner (S9)
null_objectnull object referenceusing an unfilled object slot (S41)
bad_codepointinvalid character codechr outside Unicode, or append_ascii outside 0..127
not_ownedobject is owned by a containergive through an alias of a container-owned object (S23)

Call depth is a policy limit, not a native-stack accident, on both engines: the interpreter runs on an explicit frame stack, and compiled code carries its remaining depth as a hidden argument and refuses the call that would exhaust it. Runaway recursion is a trap with a message and a call stack, never a segmentation fault.

main.luc
func main():
    var xs = [1, 2, 3]
    print("before")
    print(str(xs[7]))
Output — the program traps
before
loom: trap: index out of bounds [index_bounds]
    at main (main.luc:4:5)

Errors#

An error carries a stable code and a message. There are exactly two codes.

CodeRaised by
io_failedthe host's file services
user_errorerror(message)

Not not_found and not permission_denied — a host service answers yes, no, or out of memory, and cannot tell those two apart, so inventing the codes would be inventing the distinction.

There are no typed error sets and no error payload beyond the message.

Declaring, raising, propagating, handling#

-> T! on a function says it may raise. -> ! says it returns nothing or an error. T! is not a type: fallibility is an attribute of the function.

error(message) raises. It never returns, so it may stand where a value belongs.

try CALL propagates, releasing what this frame owns (S4). It requires the enclosing function to declare !.

catch handles, discarding the reason. EXPR catch FALLBACK supplies a value; CALL catch: opens a handler block guarding exactly one call, attached to a call written as a statement or to a plain assignment.

A fallible call whose outcome is neither tried nor caught is luce.sema.fallible.

The report#

An uncaught error out of main() -> ! ends the run. The host prints the words and the one place it was raised — one line, not a stack.

main.luc
func check(n: Int) -> Int!:
    if n < 0:
        error(f"negative: {n}")
    return n

func main() -> !:
    print(str(try check(1)))
    print(str(try check(-5)))
Output — the error reaches the top
1
loom: error: negative: -5 [user_error]
    raised in check (main.luc:3:9)

A debug build names the position; --release keeps the function name and drops the line. An error records that position once, where it was raised, and never assembles a trace — which is what keeps the success path of a try free of anything to save and restore.

No errdefer#

There is none, and there will not be. Cleanup is scope ownership, which already knows that return moves what it hands back and try moves nothing. The one bit errdefer encodes is already a parameter of the unwinder.

Absence#

Neither a trap nor an error. T? is the shape when the only fact is that there is nothing there.

In the language: parse_int and parse_float answer Int? and Float?.

In the standard library: math.mean, math.vmin, math.vmax, math.variance and math.stddev answer Float?, because an empty array has no mean.

The seven traps that remain in std.math are domains the caller was handed and could have checked: ln of a non-positive number, pow and ipow outside theirs, a shape mismatch in dot or axpy, and random_int with an empty range.