feat(errors): recoverable failures as values — try/else over ok/err results (#46)
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 17s
ci / build-and-test (push) Successful in 1m14s
commit-lint / conventional-commits (push) Successful in 3s
docs / build-and-deploy (push) Successful in 19s

A fallible function returns a `result` value, built with ok(payload) on success
or err(message) on failure. The caller recovers a value with `try EXPR else {
… }`: on ok the whole expression is the payload; on err the else block runs —
with the failure message bound to `error` — and its trailing expression supplies
the fallback. It is a plain branch on the result's tag: no exceptions, no hidden
control flow, nothing unwinds. is_ok(r) / is_err(r) classify without unwrapping.

Payloads are any i32-width scalar (int/fixed/bool/entity). The feature is
additive and only kicks in when ok/err/try are used, so untouched programs
compile byte-identically (verified) and the C-free bootstrap fixpoint holds.
Complements panic/assert from #8 (the unrecoverable half). The optional
top-level frame `recover` stays deferred (needs a frame-abort mechanism); the
full tagged-union/any generalization is tracked in #1.

Adds the `try` keyword and ok/err/is_ok/is_err builtins across the compiler,
the vocabulary header, JetBrains + TextMate/VSCode grammars, the docs inventory
and pages, examples/library/recover.ludic, and a regression case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-31 15:04:14 +03:00
parent 498593311f
commit 0d1b09e4f0
21 changed files with 16590 additions and 15566 deletions

3
changes/recover.md Normal file
View file

@ -0,0 +1,3 @@
bump: minor
type: feat
Recoverable failures as values (#46) — a fallible function returns a `result` (a new value type), built with `ok(payload)` on success or `err(message)` on failure, and the caller recovers a value with `try EXPR else { … }`: on `ok` the whole expression is the payload, on `err` the `else` block runs — with the failure message bound to `error` — and its trailing expression supplies the fallback. It is a plain branch on the result's tag: no exceptions, no hidden control flow, nothing unwinds the stack. `is_ok(r)` / `is_err(r)` classify a result without unwrapping it. Payloads are any i32-width scalar (int/fixed/bool/entity); the whole feature is additive and only kicks in when `ok`/`err`/`try` are used, so programs that don't touch it compile byte-identically. Complements `panic`/`assert` from #8, which handle the unrecoverable programmer-bug half. The optional top-level frame `recover` remains deferred (it needs a frame-abort/unwind mechanism); the full tagged-union/`any` generalization is tracked in #1.

View file

@ -0,0 +1,28 @@
---
id: fn-err
name: err
category: builtins
kind: builtin
tokens: err
sig: err(msg: string) -> result
tip: Wrap a failure message in a result — the sad half, recovered by try/else.
order: 13
---
Wraps a failure in a <code>result</code>, carrying a message that says what went wrong. It is the counterpart to <a href="fn-ok"><code>ok</code></a>: a fallible function (declared <code>-&gt; result</code>) returns <code>err("…")</code> on the sad path instead of crashing or returning a magic sentinel. The caller recovers with <a href="kw-try"><code>try</code></a>/<code>else</code> — the <code>else</code> block sees the message as <code>error</code> — or tests it with <a href="fn-is_err"><code>is_err</code></a>. Unlike <a href="fn-panic"><code>panic</code></a>, which aborts, <code>err</code> is a value: the program keeps running and chooses a fallback.
Parameters:
- `msg` — a message describing the failure (a string)
```ludic
program Config {
function volume(pct: int) -> result {
if pct < 0 or pct > 100 { return err("volume out of range") }
return ok(pct)
}
test "err drives the fallback" {
let v = try volume(150) else { 100 } # clamp to a safe default
expect_eq(v, 100)
}
}
```

View file

@ -0,0 +1,28 @@
---
id: fn-is_err
name: is_err
category: builtins
kind: builtin
tokens: is_err
sig: is_err(r: result) -> bool
tip: True when a result carries a failure.
order: 15
---
Tests a <code>result</code>'s tag: <code>true</code> when it was built with <a href="fn-err"><code>err</code></a>, <code>false</code> when it was built with <a href="fn-ok"><code>ok</code></a>. Use it to detect the failure case explicitly — for example to count or log failures — while <a href="kw-try"><code>try</code></a>/<code>else</code> handles recovering a value. It is the exact negation of <a href="fn-is_ok"><code>is_ok</code></a>.
Parameters:
- `r` — the result to test
```ludic
program Retry {
function step(n: int) -> result {
if n < 3 { return err("not ready") }
return ok(n)
}
test "is_err detects the failure case" {
expect(is_err(step(1)))
expect(not is_err(step(5)))
}
}
```

View file

@ -0,0 +1,28 @@
---
id: fn-is_ok
name: is_ok
category: builtins
kind: builtin
tokens: is_ok
sig: is_ok(r: result) -> bool
tip: True when a result carries a success payload.
order: 14
---
Tests a <code>result</code>'s tag: <code>true</code> when it was built with <a href="fn-ok"><code>ok</code></a>, <code>false</code> when it was built with <a href="fn-err"><code>err</code></a>. Use it when you want to branch on success without unwrapping the payload — the payload itself is recovered with <a href="kw-try"><code>try</code></a>/<code>else</code>. It is the exact negation of <a href="fn-is_err"><code>is_err</code></a>.
Parameters:
- `r` — the result to test
```ludic
program Check {
function find(x: int) -> result {
if x == 7 { return ok(x) }
return err("missing")
}
test "is_ok classifies without unwrapping" {
expect(is_ok(find(7)))
expect(not is_ok(find(3)))
}
}
```

View file

@ -0,0 +1,30 @@
---
id: fn-ok
name: ok
category: builtins
kind: builtin
tokens: ok
sig: ok(v) -> result
tip: Wrap a success payload in a result — the happy half of try/else.
order: 12
---
Wraps a success value in a <code>result</code> — the value type a fallible function returns. A <code>result</code> carries either a success payload (this) or a failure message (from <a href="fn-err"><code>err</code></a>), and a caller recovers it with <a href="kw-try"><code>try</code></a>/<code>else</code> or classifies it with <a href="fn-is_ok"><code>is_ok</code></a> / <a href="fn-is_err"><code>is_err</code></a>. A function that can fail declares <code>-&gt; result</code> and returns <code>ok(value)</code> on the happy path.
The payload is any <code>i32</code>-width scalar — <code>int</code>, <code>fixed</code>, <code>bool</code>, or <code>entity</code>.
Parameters:
- `v` — the success payload
```ludic
program Parse {
function to_digit(c: int) -> result {
if c >= 48 and c <= 57 { return ok(c - 48) } # '0'..'9'
return err("not a digit")
}
test "ok carries the parsed value" {
let d = try to_digit(55) else { 0 } # '7' -> 7
expect_eq(d, 7)
}
}
```

View file

@ -11,7 +11,7 @@ order: 10
Prints <code>file:line: panic: &lt;msg&gt;</code> to standard error and aborts the process with exit code 1. It turns "something went wrong" into a clear, located message a non-expert can act on — the opposite of a raw segfault or a silent wrong result. Use it for the unrecoverable case: a corrupt asset, a broken invariant, a branch that should be impossible. The source location is baked in at compile time, so the message always points at the exact call. For a guarded check that only aborts when a condition fails, use <a href="fn-assert"><code>assert</code></a>.
Recoverable failures — a missing save, a bad network packet — are a different story: those should be values a game can fall back from rather than a `panic`. That value-carrying `try`/`else` path is sequenced after the type-system work (tagged unions); `panic` covers the programmer-bug half today.
Recoverable failures — a missing save, a bad network packet — are a different story: those should be values a game can fall back from rather than a `panic`. That value-carrying path is <a href="kw-try"><code>try</code></a>/<code>else</code> over a <a href="fn-ok"><code>result</code></a>; `panic` covers the unrecoverable programmer-bug half.
Parameters:
- `msg` — a message describing what went wrong (a string)

View file

@ -0,0 +1,41 @@
---
id: kw-try
name: try
category: control
kind: keyword
tokens: try
sig: try EXPR else { … }
tip: Recover a fallible result as a value, with a fallback — no exceptions, no unwinding.
order: 12
---
A <code>try EXPR else { … }</code> expression recovers a fallible operation without exceptions or unwinding. <code>EXPR</code> evaluates to a <a href="fn-ok"><code>result</code></a> — a value carrying either a success payload (from <a href="fn-ok"><code>ok</code></a>) or a failure message (from <a href="fn-err"><code>err</code></a>). When it is <code>ok</code>, the whole <code>try</code> expression <em>is</em> that payload and the <code>else</code> block is skipped. When it is <code>err</code>, the <code>else</code> block runs and its trailing expression supplies the fallback value; inside the block the failure message is bound to <code>error</code>, so you can log or branch on it. It is a plain branch on the result's tag — the happy path stays one line, there is no hidden control flow, and nothing unwinds the stack.
The payload is an <code>i32</code>-width scalar (<code>int</code>, <code>fixed</code>, <code>bool</code>, or <code>entity</code>) — the common case of "a number or nothing". For richer payloads and full pattern-matching over tagged unions, see the type-system work (#1).
```ludic
program SaveLoad {
# a fallible op returns a result: ok(payload) or err(message)
function load_score(slot: int) -> result {
if slot == 1 { return ok(4200) }
return err("no save in that slot")
}
test "ok yields the payload, err falls back" {
let a = try load_score(1) else { 0 } # 4200
let b = try load_score(9) else { 0 } # 0 (fallback)
expect_eq(a, 4200)
expect_eq(b, 0)
}
test "the fallback can read the failure via `error`" {
var logged = 0
let score = try load_score(3) else {
if error == "no save in that slot" { logged = 1 }
0 # trailing expression = the fallback
}
expect_eq(score, 0)
expect_eq(logged, 1)
}
}
```

View file

@ -0,0 +1,37 @@
# recover.ludic — recoverable failures as values (issue #46). A fallible function
# returns a `result`: `ok(payload)` on success, `err(message)` on failure. The
# caller recovers a value with `try EXPR else { … }` — on `ok` the expression is
# the payload, on `err` the else block runs (with the message bound to `error`)
# and its trailing expression is the fallback. No exceptions, no unwinding — just
# a branch on the result's tag. `is_ok`/`is_err` classify without unwrapping.
#
# This is the SUCCESS-style showcase (it always runs to the end), printing:
# 2600 100 1 1 42 7
program Recover {
# loading a possibly-missing save: slot 1 has data, everything else is empty.
function load_score(slot: int) -> result {
if slot == 1 { return ok(2600) }
return err("no save in slot")
}
entry {
# ok path: the payload flows straight out of `try`
print(try load_score(1) else { 0 }) # 2600
# err path: the else block supplies a fallback (a fresh game)
print(try load_score(4) else { 100 }) # 100
# the else block can read the failure message via `error`, and log/branch
var handled = 0
let s = try load_score(9) else {
if error == "no save in slot" { handled = 1 }
100 # trailing expression = fallback
}
print(handled) # 1
print(s / 100) # 1 (fallback was 100)
# is_ok / is_err classify a result without unwrapping it
if is_ok(load_score(1)) { print(42) } # 42
if is_err(load_score(2)) { print(7) } # 7
}
}

View file

@ -479,6 +479,50 @@ function emit_call(e: Node) -> Val {
emit(`{lok}:\n`)
return val("0", "void")
}
# --- recoverable failures as values (issue #46) -----------------------------
# A `result` is a heap `%Result = { i32 ok, i32 value, ptr err }`. `ok(v)` wraps
# a success payload (any i32-width scalar: int/fixed/bool/entity), `err(msg)` a
# failure with a message. `is_ok`/`is_err` test the tag; `try E else { … }`
# (emit_try) unwraps the value or runs the fallback. Each is guarded by a
# find_fn check so a user function of the same name still wins.
if (name == "ok") and (find_fn("ok") == null) {
g_uses_result = true
let v = emit_expr(e.kids[0])
let p = emit_bind("call ptr @malloc(i64 16)")
let okp = emit_bind(`getelementptr inbounds %Result, ptr {p}, i32 0, i32 0`)
emit(` store i32 1, ptr {okp}\n`)
let vp = emit_bind(`getelementptr inbounds %Result, ptr {p}, i32 0, i32 1`)
emit(` store i32 {coerce_code(v, "int")}, ptr {vp}\n`)
let ep = emit_bind(`getelementptr inbounds %Result, ptr {p}, i32 0, i32 2`)
emit(` store ptr null, ptr {ep}\n`)
return val(p, "result")
}
if (name == "err") and (find_fn("err") == null) {
g_uses_result = true
let m = emit_expr(e.kids[0])
let p = emit_bind("call ptr @malloc(i64 16)")
let okp = emit_bind(`getelementptr inbounds %Result, ptr {p}, i32 0, i32 0`)
emit(` store i32 0, ptr {okp}\n`)
let vp = emit_bind(`getelementptr inbounds %Result, ptr {p}, i32 0, i32 1`)
emit(` store i32 0, ptr {vp}\n`)
let ep = emit_bind(`getelementptr inbounds %Result, ptr {p}, i32 0, i32 2`)
emit(` store ptr {m.code}, ptr {ep}\n`)
return val(p, "result")
}
if (name == "is_ok") and (find_fn("is_ok") == null) {
let r = emit_expr(e.kids[0])
let okp = emit_bind(`getelementptr inbounds %Result, ptr {r.code}, i32 0, i32 0`)
let okv = emit_bind(`load i32, ptr {okp}`)
let c = emit_bind(`icmp ne i32 {okv}, 0`)
return val(emit_bind(`zext i1 {c} to i32`), "bool")
}
if (name == "is_err") and (find_fn("is_err") == null) {
let r = emit_expr(e.kids[0])
let okp = emit_bind(`getelementptr inbounds %Result, ptr {r.code}, i32 0, i32 0`)
let okv = emit_bind(`load i32, ptr {okp}`)
let c = emit_bind(`icmp eq i32 {okv}, 0`)
return val(emit_bind(`zext i1 {c} to i32`), "bool")
}
# The EV2 reflection ABI (the world table), exposed to Ludic so a Ludic mod can
# introspect the world by name — the same functions a foreign mod binds. Emitted
# only for a modding program (ECS + events), so a plain game is unchanged.
@ -629,6 +673,7 @@ function emit_expr(e: Node) -> Val {
return emit_load_at(a, g_addr_ty)
}
if e.kind == S_EMIT { return emit_emit(e) } # emit as an expression -> cancelled flag
if e.kind == E_TRY { return emit_try(e) } # try E else { … } -> recovered value (issue #46)
if e.kind == E_CALL { return emit_call(e) }
if e.kind == E_BIN { return emit_bin(e) }
if e.kind == E_UN {

View file

@ -35,6 +35,7 @@ var g_uses_fsrt: bool = false # Fs.*/Path.*/Mime.* was emitted -> emit the file
var g_uses_datert: bool = false # Date.*/DateTime.* was emitted -> emit the civil<->epoch conversions
var g_uses_expect: bool = false # expect/expect_eq/expect_near was emitted -> emit the test-assert globals
var g_uses_panic: bool = false # panic/assert was emitted -> declare @fprintf + the panic format
var g_uses_result: bool = false # ok()/err()/try was emitted -> define the %Result value type (issue #46)
var g_tests: []Node # test "name" { ... } blocks collected by the parser
var g_src_name: pointer = "?" # base name of the source file, for panic/expect file:line messages
var g_uses_longstr: bool = false # string(long) / interpolating a long was emitted -> emit fn_long_str

View file

@ -140,6 +140,7 @@ function emit_program() -> void {
g_uses_loopback = false
g_uses_expect = false
g_uses_panic = false
g_uses_result = false
g_cov_lines = new []int
g_cov_active = true
loc_name = new []pointer; loc_reg = new []pointer; loc_ty = new []pointer; loc_mut = new []int
@ -198,6 +199,7 @@ function emit_program() -> void {
emith("declare i32 @fprintf(ptr, ptr, ...)\n")
emith("@.fmt_panic = private unnamed_addr constant [6 x i8] c\"%s%s\\0A\\00\"\n")
}
if g_uses_result { emith("%Result = type { i32, i32, ptr }\n") } # issue #46: ok/err/try value
emit_cov_runtime() # issue #45: --coverage tables + exit dump
}

View file

@ -1,5 +1,53 @@
# emit_expr.ludic — lower an expression to IR, returning its register and type.
# issue #46: `try E else { BODY }` as an expression. Evaluate E (a `result`); on
# `ok` the expression is the payload, on `err` BODY runs with the failure message
# bound to `error` and its trailing expression supplies the fallback. Pure branch
# on the tag — no unwinding, no hidden control flow.
function emit_try_body(b: Node, slot: pointer) -> void {
var i = 0
while i < len(b.kids) {
if g_term { return }
let st = b.kids[i]
if (i == len(b.kids) - 1) and st.kind == S_EXPR { # trailing expression = the fallback value
emit_cov_hit(st.line)
let v = emit_expr(st.a)
emit(` store i32 {coerce_code(v, "int")}, ptr {slot}\n`)
} else { emit_stmt(st) }
i = i + 1
}
}
function emit_try(e: Node) -> Val {
g_uses_result = true
let r = emit_expr(e.a)
if not (r.ty == "result") { perr(`try expects a result value (got {r.ty}) — line {itoa(e.line)}`) }
let slot = emit_alloca("i32")
emit(` store i32 0, ptr {slot}\n`) # fallback default (an else block with no trailing value)
let okp = emit_bind(`getelementptr inbounds %Result, ptr {r.code}, i32 0, i32 0`)
let okv = emit_bind(`load i32, ptr {okp}`)
let isok = emit_bind(`icmp ne i32 {okv}, 0`)
let lok = lbl("tryok"); let lel = lbl("tryelse"); let lend = lbl("tryend")
emit(` br i1 {isok}, label %{lok}, label %{lel}\n`)
emit(`{lok}:\n`); g_term = false
let vp = emit_bind(`getelementptr inbounds %Result, ptr {r.code}, i32 0, i32 1`)
let vv = emit_bind(`load i32, ptr {vp}`)
emit(` store i32 {vv}, ptr {slot}\n`)
emit(` br label %{lend}\n`)
emit(`{lel}:\n`); g_term = false
let ep = emit_bind(`getelementptr inbounds %Result, ptr {r.code}, i32 0, i32 2`)
let ev = emit_bind(`load ptr, ptr {ep}`)
let eslot = emit_alloca("ptr")
emit(` store ptr {ev}, ptr {eslot}\n`)
let save = nloc
loc_push("error", eslot, "string") # bind the failure message inside the else block
emit_try_body(e.b, slot)
nloc = save
if not g_term { emit(` br label %{lend}\n`) }
emit(`{lend}:\n`); g_term = false
return val(emit_bind(`load i32, ptr {slot}`), "int")
}
function emit_load_at(addr: pointer, ty: pointer) -> Val {
let r = emit_bind(`load {llty(ty)}, ptr {addr}`)
return val(r, ty)

View file

@ -31,6 +31,9 @@ const S_EMIT: int = 49 # emit E(field: v, ...) — fire event E (calls
const S_CANCEL: int = 50 # cancel — inside a listener, veto a `cancellable` event
const N_TEST: int = 51 # test "name" { ... } — a named test block (the testing framework)
# s=name a=body block line=source line of the test
const E_TRY: int = 52 # try EXPR else { ... } — recover a fallible result as a value (issue #46)
# a=the fallible (result-typed) expression b=else block (its
# trailing expression is the fallback) line=source line
# statements
const S_LET: int = 10
const S_ASSIGN: int = 11

View file

@ -150,6 +150,19 @@ function p_primary() -> Node {
if (t.text == "false") { let n = node(E_BOOL); n.ival = 0; pi = pi + 1; return n }
if (t.text == "null") { pi = pi + 1; return node(E_NULL) }
if (t.text == "new") { pi = pi + 1; let n = node(E_NEW); n.s = ptype(); if is_op("{") { n.a = record() }; return n }
# try EXPR else { ... } — evaluate a fallible (result-typed) expression; on
# `ok` the whole expression is its payload, on `err` the else block runs (with
# the message bound to `error`) and its trailing expression is the fallback.
if (t.text == "try") {
pi = pi + 1
let n = node(E_TRY); n.line = t.line
n.a = expr()
skipnl()
if not is_id("else") { perr("try needs an `else { ... }` fallback") }
pi = pi + 1; skipnl()
n.b = block()
return n
}
let n = node(E_ID); n.s = t.text; pi = pi + 1; return n
}
if is_op("(") { pi = pi + 1; skipnl(); let e = expr(); skipnl(); eat_op(")"); return e }

File diff suppressed because it is too large Load diff

View file

@ -48,7 +48,11 @@
"fn-read_char",
"fn-file_write",
"fn-file_stdout",
"fn-file_stderr"
"fn-file_stderr",
"fn-ok",
"fn-err",
"fn-is_ok",
"fn-is_err"
],
"collision": [
"collision-rects",
@ -72,7 +76,8 @@
"kw-match",
"kw-machine",
"kw-state",
"kw-become"
"kw-become",
"kw-try"
],
"ease": [
"ease-in",

View file

@ -50,7 +50,7 @@ object LudicVocabulary {
val STMT = setOf(
"let", "return", "if", "else", "while", "for", "in", "spawn", "despawn",
"enable", "disable", "match", "machine", "state", "become", "where",
"and", "or", "not", "break", "continue", "new", "emit", "cancel"
"and", "or", "not", "break", "continue", "new", "emit", "cancel", "try"
)
val PRIMITIVES = setOf("int", "long", "fixed", "bool", "entity", "string", "pointer", "byte", "words", "fixeds", "pointers", "Vector", "void")
val PHASES = setOf("Start", "Input", "FixedUpdate", "Update", "LateUpdate", "Render")
@ -65,6 +65,7 @@ object LudicVocabulary {
"ui_tick", "ui_render", "ui_clicked", "ui_set_text", "ui_set_int", "ui_focus",
"ui_focused", "ui_visible", "key", "reg", "set_reg", "self", "save", "load",
"status", "print", "string", "quit", "panic", "assert",
"ok", "err", "is_ok", "is_err",
// compiler intrinsics: the floor the Ludic-written runtime stands on
"bytes", "words", "resize", "free", "fill", "arg_count", "arg", "file_stderr", "file_stdout",
"offset",

View file

@ -172,7 +172,7 @@
},
"keyword": {
"patterns": [
{ "name": "keyword.control.ludic", "match": "\\b(if|else|while|for|in|match|machine|become|return|spawn|despawn|enable|disable|where|break|continue|new)\\b" },
{ "name": "keyword.control.ludic", "match": "\\b(if|else|while|for|in|match|machine|become|return|spawn|despawn|enable|disable|where|break|continue|new|try)\\b" },
{ "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\b" },
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|on)\\b" },
{ "name": "keyword.other.ludic", "match": "\\b(import|extern)\\b" },
@ -204,7 +204,7 @@
{
"comment": "the runtime surface — every name here resolves to rt_<name> in runtime/native",
"name": "support.function.builtin.ludic",
"match": "\\b(min|max|abs|clamp|seed|rng_range|rng_chance|fixed|floor|map_size|map_row|tile|clear|present|fill_rect|frame_rect|put_px|text|text_int|font_load|text_ttf|text_w|text_h|image_load|draw_image|draw_image_scaled|draw_9slice|png_load|sprites_load|draw_sprite|draw_sprite_scaled|ui_build|ui_open|ui_tick|ui_render|ui_clicked|ui_set_text|ui_set_int|ui_focus|ui_focused|ui_visible|key|reg|set_reg|self|save|load|status|print|string|quit|panic|assert)\\b(?=\\s*\\()"
"match": "\\b(min|max|abs|clamp|seed|rng_range|rng_chance|fixed|floor|map_size|map_row|tile|clear|present|fill_rect|frame_rect|put_px|text|text_int|font_load|text_ttf|text_w|text_h|image_load|draw_image|draw_image_scaled|draw_9slice|png_load|sprites_load|draw_sprite|draw_sprite_scaled|ui_build|ui_open|ui_tick|ui_render|ui_clicked|ui_set_text|ui_set_int|ui_focus|ui_focused|ui_visible|key|reg|set_reg|self|save|load|status|print|string|quit|panic|assert|ok|err|is_ok|is_err)\\b(?=\\s*\\()"
},
{
"comment": "compiler intrinsics — these lower straight to libc or the OS",

View file

@ -172,7 +172,7 @@
},
"keyword": {
"patterns": [
{ "name": "keyword.control.ludic", "match": "\\b(if|else|while|for|in|match|machine|become|return|spawn|despawn|enable|disable|where|break|continue|new)\\b" },
{ "name": "keyword.control.ludic", "match": "\\b(if|else|while|for|in|match|machine|become|return|spawn|despawn|enable|disable|where|break|continue|new|try)\\b" },
{ "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\b" },
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|on)\\b" },
{ "name": "keyword.other.ludic", "match": "\\b(import|extern)\\b" },
@ -204,7 +204,7 @@
{
"comment": "the runtime surface — every name here resolves to rt_<name> in runtime/native",
"name": "support.function.builtin.ludic",
"match": "\\b(min|max|abs|clamp|seed|rng_range|rng_chance|fixed|floor|map_size|map_row|tile|clear|present|fill_rect|frame_rect|put_px|text|text_int|font_load|text_ttf|text_w|text_h|image_load|draw_image|draw_image_scaled|draw_9slice|png_load|sprites_load|draw_sprite|draw_sprite_scaled|ui_build|ui_open|ui_tick|ui_render|ui_clicked|ui_set_text|ui_set_int|ui_focus|ui_focused|ui_visible|key|reg|set_reg|self|save|load|status|print|string|quit|panic|assert)\\b(?=\\s*\\()"
"match": "\\b(min|max|abs|clamp|seed|rng_range|rng_chance|fixed|floor|map_size|map_row|tile|clear|present|fill_rect|frame_rect|put_px|text|text_int|font_load|text_ttf|text_w|text_h|image_load|draw_image|draw_image_scaled|draw_9slice|png_load|sprites_load|draw_sprite|draw_sprite_scaled|ui_build|ui_open|ui_tick|ui_render|ui_clicked|ui_set_text|ui_set_int|ui_focus|ui_focused|ui_visible|key|reg|set_reg|self|save|load|status|print|string|quit|panic|assert|ok|err|is_ok|is_err)\\b(?=\\s*\\()"
},
{
"comment": "compiler intrinsics — these lower straight to libc or the OS",

View file

@ -68,7 +68,7 @@ static const char* LUDIC_KW_RESERVED[] = {
static const char* LUDIC_KW_STMT[] = {
"let","return","if","else","while","for","in","spawn","despawn",
"enable","disable","match","machine","state","become","where",
"and","or","not","break","continue","new","emit","cancel", 0
"and","or","not","break","continue","new","emit","cancel","try", 0
};
static const char* LUDIC_TYPES[] = {
"int","long","fixed","bool","entity","string","pointer","byte","words","fixeds","pointers","Vector","void", 0
@ -143,6 +143,10 @@ static const LBuiltin LUDIC_BUILTINS[] = {
{"quit","quit()","Stop the frame loop and exit."},
{"panic","panic(msg: string)","Print `file:line: panic: msg` to stderr and abort (exit 1) — a located error, not a crash."},
{"assert","assert(cond: bool, msg: string)","Abort with `file:line: assertion failed: msg` when cond is false — for programmer-bug invariants."},
{"ok","ok(v) -> result","Wrap a success payload (any i32-width scalar) in a result — the happy half of `try`/`else`."},
{"err","err(msg: string) -> result","Wrap a failure message in a result — the sad half, recovered by `try`/`else`."},
{"is_ok","is_ok(r: result) -> bool","True when a result carries a success payload."},
{"is_err","is_err(r: result) -> bool","True when a result carries a failure."},
{0,0,0}
};

View file

@ -203,6 +203,7 @@ function cmd_test() -> int {
spec_case("library/testing", "== 6 passed, 0 failed ==")
spec_case("library/coverage", "== 3 passed, 0 failed ==")
feat_case("library/errors", "", "5 10 0 7 1", "errors.ludic (assert guards an invariant, holds -> runs to the end; issue #8 success path)")
feat_case("library/recover", "", "2600 100 1 1 42 7", "recover.ludic (try/else recovers a result value + fallback, error binding, is_ok/is_err; issue #46)")
panic_case()
feat_case("library/logging", "", "0 5 2 1", "logging.ludic (Log levels, set_level/level threshold, structured fields)")
# Os known-folders/arch and Fs.list read the BSD utsname/dirent layout, so