feat(errors): panic(msg) + assert(cond, msg) with file:line — no raw crashes (#8)
RFC decision (the split the issue recommended): programmer bugs abort loud and located; recoverable failures become values. This ships the first half. panic(msg) prints `file:line: panic: <msg>` to stderr and aborts the process with exit code 1 — a clear, located error instead of a segfault or a silent wrong result. assert(cond, msg) is the guarded form: it aborts with `file:line: assertion failed: <msg>` only when cond is false, otherwise execution continues. The location is baked in at compile time (the call node carries its source line, g_src_name carries the file); the message is any string. Both lower in emit_call to an fprintf-to-stderr + exit(1) + unreachable tail (assert branches on the condition first). @fprintf and the format constant are declared on demand (g_uses_panic), so a program that never panics is unchanged — and the compiler's own source uses neither, so the C-free bootstrap fixpoint holds. - panic/assert registered as builtins across the vocabulary (ludic_syntax.h, the JetBrains lexer, the TextMate grammar) and documented (docs/language/builtins/) - examples/library/errors.ludic covers the success path (asserts hold, program runs to the end); a panic_case in the suite covers the failure path (non-zero exit + the located stderr message). x test is now 69 checks. Deferred: recoverable failures as `try`/`else` values (needs the tagged-union type system, #1) and a top-level `recover` for the dev game loop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ea2c6ab246
commit
c7c8e2779c
13 changed files with 5102 additions and 4778 deletions
3
changes/error-handling.md
Normal file
3
changes/error-handling.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
bump: minor
|
||||
type: feat
|
||||
Error handling for games — `panic(msg)` prints `file:line: panic: <msg>` to stderr and aborts cleanly (exit 1) instead of crashing, and `assert(cond, msg)` does the same, guarded, only when an invariant is false (`file:line: assertion failed: <msg>`). Non-experts get a clear, located message for a broken invariant or an impossible branch, never a raw segfault or a silent wrong result. The source location is baked in at compile time; the message is any string. Recoverable failures as `try`/`else` values are sequenced after the tagged-union type system (#1); this ships the "programmer bug = loud, located panic" half now.
|
||||
30
docs/language/builtins/fn-assert.md
Normal file
30
docs/language/builtins/fn-assert.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
id: fn-assert
|
||||
name: assert
|
||||
category: builtins
|
||||
kind: builtin
|
||||
tokens: assert
|
||||
sig: assert(cond: bool, msg: string)
|
||||
tip: Abort with a located message when an invariant is false.
|
||||
order: 11
|
||||
---
|
||||
|
||||
When <code>cond</code> is false, prints <code>file:line: assertion failed: <msg></code> to standard error and aborts (exit code 1); when it is true, execution continues. It is the guarded form of <a href="fn-panic"><code>panic</code></a> — for the programmer-bug cases the language should catch loudly rather than let corrupt the world: an index that must be in range, a value that must be non-negative, a state that must hold before a step. The location is baked in at compile time, so a failure points at the exact assertion. Assertions document and enforce the invariants a system relies on; keep them for "this must be true" checks, not for recoverable runtime conditions.
|
||||
|
||||
Parameters:
|
||||
- `cond` — the invariant that must hold (a bool)
|
||||
- `msg` — a message describing the invariant (a string)
|
||||
|
||||
```ludic
|
||||
program Demo {
|
||||
function withdraw(balance: int, amount: int) -> int {
|
||||
assert(amount >= 0, "amount must be non-negative")
|
||||
assert(amount <= balance, "cannot overdraw")
|
||||
return balance - amount
|
||||
}
|
||||
entry {
|
||||
print(withdraw(100, 30)) # 70
|
||||
print(withdraw(100, 250)) # aborts: Demo.ludic:4: assertion failed: cannot overdraw
|
||||
}
|
||||
}
|
||||
```
|
||||
31
docs/language/builtins/fn-panic.md
Normal file
31
docs/language/builtins/fn-panic.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
id: fn-panic
|
||||
name: panic
|
||||
category: builtins
|
||||
kind: builtin
|
||||
tokens: panic
|
||||
sig: panic(msg: string)
|
||||
tip: Abort with a located error message instead of crashing.
|
||||
order: 10
|
||||
---
|
||||
|
||||
Prints <code>file:line: panic: <msg></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.
|
||||
|
||||
Parameters:
|
||||
- `msg` — a message describing what went wrong (a string)
|
||||
|
||||
```ludic
|
||||
program Demo {
|
||||
function pick(options: int, n: int) -> int {
|
||||
if n < 0 { panic("choice index is negative") }
|
||||
if n >= options { panic("choice index out of range") }
|
||||
return n
|
||||
}
|
||||
entry {
|
||||
print(pick(3, 1)) # 1
|
||||
print(pick(3, 9)) # aborts: Demo.ludic:6: panic: choice index out of range
|
||||
}
|
||||
}
|
||||
```
|
||||
28
examples/library/errors.ludic
Normal file
28
examples/library/errors.ludic
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# errors.ludic — error handling (issue #8): assert(cond, msg) guards an invariant
|
||||
# and aborts with a located message when it is broken; panic(msg) aborts outright.
|
||||
# This is the SUCCESS path — every assertion here holds, so nothing aborts and the
|
||||
# program runs to the end, printing:
|
||||
# 5 10 0 7 1
|
||||
# The failure path (a panic exits non-zero with `file:line: panic: …` on stderr)
|
||||
# is exercised separately in the test runner.
|
||||
program Errors {
|
||||
function checked_div(a: int, b: int) -> int {
|
||||
assert(b != 0, "division by zero")
|
||||
return a / b
|
||||
}
|
||||
|
||||
function clampi(v: int, lo: int, hi: int) -> int {
|
||||
assert(lo <= hi, "lo must not exceed hi")
|
||||
if v < lo { return lo }
|
||||
if v > hi { return hi }
|
||||
return v
|
||||
}
|
||||
|
||||
entry {
|
||||
print(checked_div(10, 2)) # 5
|
||||
print(clampi(15, 0, 10)) # 10 (above hi)
|
||||
print(clampi(0 - 3, 0, 10)) # 0 (below lo)
|
||||
print(clampi(7, 0, 10)) # 7 (in range)
|
||||
print(1) # reached: every assertion held
|
||||
}
|
||||
}
|
||||
|
|
@ -448,6 +448,37 @@ function emit_call(e: Node) -> Val {
|
|||
emit_expect_fail(c, msg, a.code, b.code)
|
||||
return val("0", "void")
|
||||
}
|
||||
# --- error handling: panic / assert (issue #8) ------------------------------
|
||||
# panic(msg) prints `file:line: panic: <msg>` to stderr and aborts the process
|
||||
# cleanly (exit 1) — a located, human error instead of a raw crash. assert(cond,
|
||||
# msg) is the same, guarded: it aborts only when `cond` is false (a programmer
|
||||
# bug — an index out of range, an invariant broken). The location is baked in at
|
||||
# compile time; the message is any string.
|
||||
if (name == "panic") {
|
||||
g_uses_panic = true
|
||||
let m = emit_expr(e.kids[0])
|
||||
let prefix = emit_str_const(`{g_src_name}:{itoa(e.line)}: panic: `)
|
||||
let se = emit_bind("load ptr, ptr @__stderrp")
|
||||
emit(` call i32 (ptr, ptr, ...) @fprintf(ptr {se}, ptr @.fmt_panic, ptr {prefix}, ptr {m.code})\n`)
|
||||
emit(" call void @exit(i32 1)\n unreachable\n")
|
||||
g_term = true
|
||||
return val("0", "void")
|
||||
}
|
||||
if (name == "assert") {
|
||||
g_uses_panic = true
|
||||
let c = emit_expr(e.kids[0])
|
||||
let cond = emit_bind(`icmp ne i32 {c.code}, 0`)
|
||||
let lok = lbl("asok"); let lbad = lbl("asbad")
|
||||
emit(` br i1 {cond}, label %{lok}, label %{lbad}\n`)
|
||||
emit(`{lbad}:\n`)
|
||||
let m = emit_expr(e.kids[1])
|
||||
let prefix = emit_str_const(`{g_src_name}:{itoa(e.line)}: assertion failed: `)
|
||||
let se = emit_bind("load ptr, ptr @__stderrp")
|
||||
emit(` call i32 (ptr, ptr, ...) @fprintf(ptr {se}, ptr @.fmt_panic, ptr {prefix}, ptr {m.code})\n`)
|
||||
emit(" call void @exit(i32 1)\n unreachable\n")
|
||||
emit(`{lok}:\n`)
|
||||
return val("0", "void")
|
||||
}
|
||||
# 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.
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ var g_uses_unicodert: bool = false # Unicode.* was emitted -> emit the UTF-8 ru
|
|||
var g_uses_fsrt: bool = false # Fs.*/Path.*/Mime.* was emitted -> emit the filesystem runtime
|
||||
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_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
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ function emit_program() -> void {
|
|||
g_uses_strslice = false
|
||||
g_uses_loopback = false
|
||||
g_uses_expect = false
|
||||
g_uses_panic = false
|
||||
loc_name = new []pointer; loc_reg = new []pointer; loc_ty = new []pointer; loc_mut = new []int
|
||||
brk_lbl = new []pointer; cnt_lbl = new []pointer
|
||||
self_stk = new []pointer
|
||||
|
|
@ -184,6 +185,10 @@ function emit_program() -> void {
|
|||
if g_uses_unicodert { emit_unicode_prelude() } # @fn_uni_len/valid/decode/case/truncate/grapheme (UTF-8)
|
||||
if g_uses_fsrt { emit_fs_prelude() } # @fn_fs_*/fn_path_*/fn_mime_* (libc + string ops)
|
||||
if g_uses_datert { emit_datetime_prelude() } # @fn_days_from_civil / @fn_civil_from_days conversions
|
||||
if g_uses_panic { # panic/assert: located abort to stderr
|
||||
emith("declare i32 @fprintf(ptr, ptr, ...)\n")
|
||||
emith("@.fmt_panic = private unnamed_addr constant [6 x i8] c\"%s%s\\0A\\00\"\n")
|
||||
}
|
||||
}
|
||||
|
||||
# Flush the emitted IR. With a null path it goes to stdout (the pipe the shell
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -64,7 +64,7 @@ object LudicVocabulary {
|
|||
"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",
|
||||
"status", "print", "string", "quit", "panic", "assert",
|
||||
// compiler intrinsics: the floor the Ludic-written runtime stands on
|
||||
"bytes", "words", "resize", "free", "fill", "arg_count", "arg", "file_stderr", "file_stdout",
|
||||
"offset",
|
||||
|
|
|
|||
|
|
@ -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)\\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)\\b(?=\\s*\\()"
|
||||
},
|
||||
{
|
||||
"comment": "compiler intrinsics — these lower straight to libc or the OS",
|
||||
|
|
|
|||
|
|
@ -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)\\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)\\b(?=\\s*\\()"
|
||||
},
|
||||
{
|
||||
"comment": "compiler intrinsics — these lower straight to libc or the OS",
|
||||
|
|
|
|||
|
|
@ -141,6 +141,8 @@ static const LBuiltin LUDIC_BUILTINS[] = {
|
|||
{"print","print(x)","Print a value (int or string) and a newline to stdout."},
|
||||
{"string","string(x) -> string","Convert an int/bool/fixed to text (a string passes through)."},
|
||||
{"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."},
|
||||
{0,0,0}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,18 @@ function spec_case(path: pointer, exp: pointer) -> void {
|
|||
else { bad2(path, `rc={string(rc)} last=[{summary}]`) }
|
||||
}
|
||||
|
||||
# issue #8: the failure path — a panic must abort with a non-zero exit and a
|
||||
# `file:line: panic: <msg>` line on stderr (stdout is unaffected up to the panic).
|
||||
function panic_case() -> void {
|
||||
write_file("/tmp/x_panic.ludic", "program P { entry { print(7); panic(\"boom\") } }\n")
|
||||
if not shq("bin/ludicc /tmp/x_panic.ludic > /tmp/x_panic.ll 2>/dev/null") { bad("panic example did not compile"); return }
|
||||
if not shq(`{cc()} -O2 /tmp/x_panic.ll -o /tmp/x_panic 2>/dev/null`) { bad("panic example did not link"); return }
|
||||
let rc = sh("/tmp/x_panic > /tmp/x_panic.out 2>/tmp/x_panic.err")
|
||||
let msg = capture_line("cat /tmp/x_panic.err")
|
||||
if (rc != 0) and shq("grep -q 'panic: boom' /tmp/x_panic.err") { ok(`panic aborts non-zero with a located stderr message ({msg})`) }
|
||||
else { bad2("panic", `rc={string(rc)} err=[{msg}]`) }
|
||||
}
|
||||
|
||||
function cmd_test() -> int {
|
||||
PASS = 0
|
||||
FAIL = 0
|
||||
|
|
@ -129,6 +141,8 @@ function cmd_test() -> int {
|
|||
feat_case("library/render", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "render.ludic (Screen pixel/oval/camera/clip/blend_mode/measure_text + Camera set/follow/shake, verified by pixel readback)")
|
||||
feat_case("library/lighting", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "lighting.ludic (Light ambient/point radial falloff + occluder hard shadows — 2D light accumulation, verified by pixel readback)")
|
||||
spec_case("library/testing", "== 6 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)")
|
||||
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
|
||||
# their asserted values are macOS-specific; skip off Darwin (see is_darwin).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue