From c9da94d1a21b01b855daa17c98ab87038ea915c0 Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Tue, 7 Jul 2026 04:45:46 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(errors):=20structured=20runtime=20erro?= =?UTF-8?q?rs=20=E2=80=94=20catch=20binds=20{kind,=20message,=20line}=20(#?= =?UTF-8?q?406)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING: catch now binds a {kind, message, line} dict for BUILT-IN runtime errors instead of the flat "Error line N: ..." string. kind is drawn from a closed, SPEC-enumerated vocabulary (undefined_name, type_mismatch, value, index_range, parse, io, limit, sandbox, interrupt, assert, internal) — discriminating error classes no longer means string matching (the silent-tolerance bug class). User-thrown values bind untouched, exactly as before. Uncaught stderr output is unchanged, so the EM corpus survives byte-for-byte. Mechanics: runtime_error(line, fmt) is renamed rt_error(kind, line, fmt) — the rename forces every raise site through classification; all 173 sites across vm.c/builtins.c/ext_store.c/ext_db.c/ext_http.c are bucketed. The catch dict is built lazily in vm_take_error_value (heap dict, owned children — no allocation on the uncaught path). Builtin raise sites (line 0) now stamp the VM's live line, fixing the "Error line 0:" wart. Also: - assert failures are ordinary catchable errors (kind assert); caught asserts no longer leak an "ASSERT FAIL" line to stderr - sandbox_run's result dict gains "error": {kind, message, line} when ok is 0 (the graded ladder can discriminate denial vs type vs parse) - embed API: eigs_last_error_kind() / eigs_last_error_line(); throw stamps kind "user" for host introspection only - SPEC.md Error handling rewritten with executable examples; DIAGNOSTICS.md gains the closed kind table; COMPARISON/EMBEDDING synced; examples/catching_by_kind.eigs added - suite: EM26–EM30 assert the new binding; ~75 test string-matches migrated to .message/.kind (user-throw sites deliberately left) Consumer audit (all 10 + EigenOS): zero breaking catch sites. Parity note for the v0.28.0 bump: ouroboros frontend.eigs (+ the iLambdaAi vendored copy) must mirror the dict binding for interpreter-raised errors; Tidepool tidepool.eigs:1067 render-error print becomes a dict repr (cosmetic). Closes #406 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 16 ++ docs/COMPARISON.md | 12 +- docs/DIAGNOSTICS.md | 40 ++++- docs/EMBEDDING.md | 8 +- docs/SPEC.md | 69 +++++++-- examples/catching_by_kind.eigs | 33 +++++ src/builtins.c | 128 +++++++++------- src/eigenscript.c | 29 +++- src/eigenscript.h | 39 ++++- src/eigs_embed.c | 13 ++ src/eigs_embed.h | 10 ++ src/ext_db.c | 4 +- src/ext_http.c | 4 +- src/ext_store.c | 34 ++--- src/vm.c | 238 ++++++++++++++++-------------- tests/run_all_tests.sh | 45 ++++++ tests/test_bitwise.eigs | 14 +- tests/test_builtin_errors.eigs | 42 +++--- tests/test_db.eigs | 4 +- tests/test_dispatch.eigs | 10 +- tests/test_error_extra.eigs | 30 ++-- tests/test_error_propagation.eigs | 2 +- tests/test_http.eigs | 2 +- tests/test_import.eigs | 4 +- tests/test_import_errors.eigs | 8 +- tests/test_store_corruption.eigs | 12 +- tests/test_trycatch.eigs | 19 +-- 27 files changed, 586 insertions(+), 283 deletions(-) create mode 100644 examples/catching_by_kind.eigs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ac5cd6..2ace94f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to EigenScript are documented here. ## [Unreleased] ### Added +- **Structured runtime errors (#406, BREAKING)** — `catch` now binds a + `{kind, message, line}` dict for built-in runtime errors instead of + the flat `"Error line N: ..."` string: `kind` from a closed, + SPEC-enumerated vocabulary (`undefined_name`, `type_mismatch`, + `value`, `index_range`, `parse`, `io`, `limit`, `sandbox`, + `interrupt`, `assert`, `internal`), `message` without the line frame, + `line` 1-based. Discriminating error classes no longer means string + matching. User-thrown values bind untouched, as before; uncaught + stderr output is unchanged. Also: builtin raise sites now stamp the + live source line (previously `Error line 0:`), `assert` failures are + ordinary catchable errors (kind `assert`; caught asserts no longer + print to stderr), `sandbox_run`'s result carries `"error": {kind, + message, line}` when `ok` is 0, and the embed API gains + `eigs_last_error_kind()` / `eigs_last_error_line()`. The kind table + lives in docs/DIAGNOSTICS.md; SPEC.md Error handling rewritten with + executable examples (suite EM26–EM30). - **Parse errors print a source excerpt with a caret under the offending column (#407, increment two)** — for the column-carrying errors (`expected X, got Y`, one-statement-per-line), in the script runner, diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md index bca50d8..0358f61 100644 --- a/docs/COMPARISON.md +++ b/docs/COMPARISON.md @@ -226,17 +226,25 @@ except Exception as e: print(f"caught: {e}") ``` -EigenScript — errors are strings; `throw of` raises, `catch name:` -binds: +EigenScript — `throw of` raises, `catch name:` binds. A thrown value +binds unchanged; a built-in runtime error binds a `{kind, message, +line}` dict with `kind` from a closed vocabulary (no exception +hierarchy — discriminate on the kind string): ```eigenscript try: throw of "boom" catch e: print of f"caught: {e}" + +try: + x is [1] - 1 +catch e: + print of f"caught: {e.kind} at line {e.line}" ``` ```output caught: boom +caught: type_mismatch at line 7 ``` ## Pipes (vs Lisp threading / shell pipes) diff --git a/docs/DIAGNOSTICS.md b/docs/DIAGNOSTICS.md index 9993bc6..5130454 100644 --- a/docs/DIAGNOSTICS.md +++ b/docs/DIAGNOSTICS.md @@ -2,7 +2,7 @@ EigenScript reports errors with source line numbers and consistent formatting. This document defines the error contract. The behaviors -here are enforced by the suite (`run_all_tests.sh` EM1–EM23 and +here are enforced by the suite (`run_all_tests.sh` EM1–EM30 and `examples/errors/`). ## Error Categories @@ -12,9 +12,9 @@ here are enforced by the suite (`run_all_tests.sh` EM1–EM23 and | Syntax error | `Syntax error line N: ...` | 1 | Tokenizer problem. Accumulates all errors, aborts before execution. | | Parse error | `Parse error line N: ...` | 1 | Parser problem. Accumulates all errors, aborts before execution. | | Runtime error (uncaught) | `Error line N: ...` + stack trace | 1 | Type mismatch, undefined variable, index out of bounds, non-callable, uncaught `throw`. Prints to stderr and **halts** — no statement after the error runs. | -| Runtime error (caught) | bound to the `catch` variable | — | Recoverable with `try`/`catch`; execution continues in the handler. | +| Runtime error (caught) | `{kind, message, line}` dict bound to the `catch` variable | — | Recoverable with `try`/`catch`; execution continues in the handler. `kind` is from the closed set below. | | Warning | `Warning line N: ...` | 0 | Recoverable issue (division by zero yields `0`). Prints to stderr, continues execution. | -| Assertion | `ASSERT FAIL: ...` | 1 | Intentional termination via `assert`. | +| Assertion | `Error line N: ASSERT FAIL: ...` | 1 | `assert` failure — an ordinary runtime error with kind `assert` (catchable). | Parse errors prevent execution entirely — the program never runs if parsing fails. Warnings allow execution to continue; **uncaught runtime @@ -38,12 +38,42 @@ Caught errors print nothing; the handler decides. ## What `catch` binds -- A **runtime error** binds its message string - (`"Error line 2: cannot apply '-' to list and num"`). +- A **built-in runtime error** binds a `{kind, message, line}` dict + (#406): `kind` from the closed vocabulary below, `message` without + the `Error line N:` frame (`"cannot apply '-' to list and num"`), + `line` 1-based. Discriminate on `kind`, never on message text — + messages are wording, kinds are contract. - A **thrown value** binds unchanged: `throw of {"kind": "validation"}` gives the catch variable that dict — match on fields instead of substring-searching a message. Thrown strings bind as strings. +## Runtime error kinds (closed set) + +Every built-in runtime error carries exactly one kind. The set is +CLOSED by design — the same instinct as the closed trajectory +vocabulary. Extending it is a SPEC + DIAGNOSTICS change, not a +patch-release tweak. (`ErrKind` in `src/eigenscript.h`; the strings +below are the contract.) + +| kind | meaning | examples | +|------|---------|----------| +| `undefined_name` | no binding for a name | `undefined variable 'x'` | +| `type_mismatch` | operation/argument of the wrong type | `cannot apply '-' to list and num`, `bit_not expects a number` | +| `value` | right type, unacceptable value | `index must be an integer, got 1.5`, `chr of 0`, invalid channel | +| `index_range` | index/slice outside bounds | `index 10 out of range (list length 3)` | +| `parse` | runtime-surfaced parse/compile failure | `eval: parse error in code string`, `import: parse errors in 'm'` | +| `io` | the outside world failed | `import: cannot read 'm'`, `store_open: cannot create`, thread-create failure | +| `limit` | engine resource cap hit | `call stack overflow`, `store_put: record too large`, route table full | +| `sandbox` | sandbox policy denial or budget | `blocked in sandbox`, `sandbox memory budget exceeded` | +| `interrupt` | host-requested abort (`eigs_abort`) | `aborted` | +| `assert` | `assert` builtin failure | `ASSERT FAIL: ...` | +| `internal` | VM invariant broke — report it | `unknown opcode`, `SET_LOCAL slot out of range` | + +`throw` stamps kind `user` for host/embed introspection +(`eigs_last_error_kind`), but a catch never sees it — the thrown value +itself binds. `sandbox_run` reports failures with the same shape: its +result dict gains `"error": {kind, message, line}` when `ok` is 0. + ## Exit Codes | Situation | Exit Code | diff --git a/docs/EMBEDDING.md b/docs/EMBEDDING.md index cbd0e2a..691b478 100644 --- a/docs/EMBEDDING.md +++ b/docs/EMBEDDING.md @@ -133,10 +133,16 @@ if (!r) { const char *eigs_last_error_message(void); /* NULL when no error */ int eigs_has_error(void); void eigs_clear_error(void); +/* #406 structured errors: kind from the closed vocabulary in + * docs/DIAGNOSTICS.md ("user" for `throw`), NULL when no error; + * 1-based line, 0 when unknown. */ +const char *eigs_last_error_kind(void); +int eigs_last_error_line(void); ``` The returned message pointer is owned by the thread state — copy it if -you need to keep it past the next API call. +you need to keep it past the next API call. (The kind string itself is +static.) ## Aborting a running eval diff --git a/docs/SPEC.md b/docs/SPEC.md index cff8a44..f83b8a8 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -520,19 +520,21 @@ print of (a + b + c) 6 ``` -Out-of-range indexing is a runtime error (catchable with `try`): +Out-of-range indexing is a runtime error (catchable with `try`; the +caught value is a `{kind, message, line}` dict — see +[Error handling](#error-handling)): ```eigenscript xs is [1, 2] try: v is xs[10] catch e: - print of "caught:" - print of e + print of e.kind + print of e.message ``` ```output -caught: -Error line 3: index 10 out of range (list length 2) +index_range +index 10 out of range (list length 2) ``` ## Dictionaries @@ -802,8 +804,12 @@ expressions match too ## Error handling -`try:` / `catch name:` captures runtime errors; the caught message is -bound as a string. `throw of value` raises a user error. An *uncaught* +`try:` / `catch name:` captures runtime errors. A **built-in** runtime +error binds a small dict `{kind, message, line}`: `kind` is drawn from +a closed vocabulary (below), `message` is the error text without the +`Error line N:` frame, `line` is the 1-based source line. `throw of +value` raises a user error and the catch variable binds the thrown +value itself, unchanged — a thrown string stays a string. An *uncaught* runtime error stops the program with a nonzero exit; warnings (like division by zero) do not. @@ -816,19 +822,62 @@ catch e: try: x is undefined_name catch e: - print of e + print of e.kind + print of e.message + print of e.line print of "execution continues" ``` ```output caught: custom failure -Error line 7: undefined variable 'undefined_name' +undefined_name +undefined variable 'undefined_name' +7 execution continues ``` +The kind set is **closed** — the same design instinct as the closed +trajectory vocabulary. Every built-in runtime error carries exactly one +of: + +| kind | raised by | +|------|-----------| +| `undefined_name` | reading a name with no binding | +| `type_mismatch` | an operation or builtin argument of the wrong type | +| `value` | right type, unacceptable value (fractional index, `chr of 0`) | +| `index_range` | index or slice outside the target's bounds | +| `parse` | runtime-surfaced parse/compile failure (`eval`, `import`, `load_file`) | +| `io` | the outside world failed: files, stores, sockets, threads | +| `limit` | an engine resource cap: stack overflow, size caps | +| `sandbox` | sandbox policy denial or budget exhaustion | +| `interrupt` | host-requested abort | +| `assert` | `assert` builtin failure | +| `internal` | a VM invariant broke (report it) | + +Discriminate on `kind`, not on message text — messages are wording, +kinds are contract: + +```eigenscript +xs is [1, 2, 3] +define get(i) as: + try: + return xs[i] + catch e: + if e.kind == "index_range": + return null + throw of e + +print of (get of 1) +print of (get of 99) +``` +```output +2 +null +``` + `throw` preserves the thrown *value*: throw a dict (or list) and the catch variable binds it unchanged, so errors can carry data and be -matched on fields. Runtime errors and thrown strings bind as strings. +matched on fields. Thrown strings bind as strings. ```eigenscript define validate(age) as: diff --git a/examples/catching_by_kind.eigs b/examples/catching_by_kind.eigs new file mode 100644 index 0000000..43dff07 --- /dev/null +++ b/examples/catching_by_kind.eigs @@ -0,0 +1,33 @@ +# Structured runtime errors (#406): a built-in runtime error binds a +# {kind, message, line} dict to the catch variable. `kind` is drawn +# from a closed vocabulary (docs/DIAGNOSTICS.md) — discriminate on it +# instead of substring-matching message text. Thrown values still bind +# untouched. This example runs clean (exit 0): every error is caught. + +xs is [10, 20, 30] + +define get_or_null(i) as: + try: + return xs[i] + catch e: + if e.kind == "index_range": + return null + # anything else is unexpected here — re-raise it + throw of e + +print of (get_or_null of 1) +print of (get_or_null of 99) + +# The dict carries the bare message and the failing line too: +try: + print of ([1] - 1) +catch e: + print of e.kind + print of e.message + print of e.line + +# User throws are untouched — a thrown dict binds as that dict: +try: + throw of {"kind": "validation", "field": "age"} +catch e: + print of e.field diff --git a/src/builtins.c b/src/builtins.c index f278710..deed954 100644 --- a/src/builtins.c +++ b/src/builtins.c @@ -29,7 +29,6 @@ #endif /* Internal helpers defined in eigenscript.c. */ -void runtime_error(int line, const char *fmt, ...); Value* make_num_permanent(double n); const char* val_type_name(ValType t); int dict_has(Value *dict, const char *key); @@ -388,37 +387,37 @@ static int bit_pair(Value *arg, int64_t *a_out, int64_t *b_out) { Value* builtin_bit_and(Value *arg) { int64_t a, b; - if (!bit_pair(arg, &a, &b)) { runtime_error(0, "bit_and expects [number, number]"); return make_null(); } + if (!bit_pair(arg, &a, &b)) { rt_error(EK_TYPE, 0, "bit_and expects [number, number]"); return make_null(); } return make_num((double)(a & b)); } Value* builtin_bit_or(Value *arg) { int64_t a, b; - if (!bit_pair(arg, &a, &b)) { runtime_error(0, "bit_or expects [number, number]"); return make_null(); } + if (!bit_pair(arg, &a, &b)) { rt_error(EK_TYPE, 0, "bit_or expects [number, number]"); return make_null(); } return make_num((double)(a | b)); } Value* builtin_bit_xor(Value *arg) { int64_t a, b; - if (!bit_pair(arg, &a, &b)) { runtime_error(0, "bit_xor expects [number, number]"); return make_null(); } + if (!bit_pair(arg, &a, &b)) { rt_error(EK_TYPE, 0, "bit_xor expects [number, number]"); return make_null(); } return make_num((double)(a ^ b)); } Value* builtin_bit_not(Value *arg) { - if (!arg || arg->type != VAL_NUM) { runtime_error(0, "bit_not expects a number"); return make_null(); } + if (!arg || arg->type != VAL_NUM) { rt_error(EK_TYPE, 0, "bit_not expects a number"); return make_null(); } int64_t a = (int64_t)arg->data.num; return make_num((double)(~a)); } Value* builtin_bit_shift_left(Value *arg) { int64_t a, b; - if (!bit_pair(arg, &a, &b)) { runtime_error(0, "bit_shl expects [number, number]"); return make_null(); } + if (!bit_pair(arg, &a, &b)) { rt_error(EK_TYPE, 0, "bit_shl expects [number, number]"); return make_null(); } return make_num((double)(a << (b & 63))); } Value* builtin_bit_shift_right(Value *arg) { int64_t a, b; - if (!bit_pair(arg, &a, &b)) { runtime_error(0, "bit_shr expects [number, number]"); return make_null(); } + if (!bit_pair(arg, &a, &b)) { rt_error(EK_TYPE, 0, "bit_shr expects [number, number]"); return make_null(); } /* Arithmetic shift, like the infix >> on int64 (negative operands * keep their sign); mask a non-negative operand first for a * logical u32 shift. */ @@ -528,18 +527,18 @@ Value* builtin_report(Value *arg) { * or when working with values whose entropy changes are unusually small. */ Value* builtin_set_observer_thresholds(Value *arg) { if (!arg || arg->type != VAL_LIST || arg->data.list.count < 3) { - runtime_error(0, "set_observer_thresholds requires [dh_zero, dh_small, h_low]"); + rt_error(EK_TYPE, 0, "set_observer_thresholds requires [dh_zero, dh_small, h_low]"); return make_null(); } double dh_zero = arg->data.list.items[0]->data.num; double dh_small = arg->data.list.items[1]->data.num; double h_low = arg->data.list.items[2]->data.num; if (dh_zero <= 0 || dh_small <= 0 || h_low <= 0) { - runtime_error(0, "observer thresholds must be positive"); + rt_error(EK_VALUE, 0, "observer thresholds must be positive"); return make_null(); } if (dh_zero >= dh_small) { - runtime_error(0, "dh_zero must be less than dh_small"); + rt_error(EK_VALUE, 0, "dh_zero must be less than dh_small"); return make_null(); } fprintf(stderr, "Warning: observer thresholds changed — dh_zero=%.6f dh_small=%.6f h_low=%.6f\n", @@ -595,19 +594,13 @@ Value* builtin_assert(Value *arg) { Value *msg = arg->data.list.items[1]; if (!is_truthy(cond)) { char *msg_str = value_to_string(msg); - fprintf(stderr, "ASSERT FAIL: %s\n", msg_str); - snprintf(g_error_msg, sizeof(g_error_msg), "ASSERT FAIL: %s", msg_str); + rt_error(EK_ASSERT, 0, "ASSERT FAIL: %s", msg_str); free(msg_str); - g_has_error = 1; - eigs_clear_error_value(); } return make_null(); } if (!is_truthy(arg)) { - fprintf(stderr, "ASSERT FAIL\n"); - snprintf(g_error_msg, sizeof(g_error_msg), "ASSERT FAIL"); - g_has_error = 1; - eigs_clear_error_value(); + rt_error(EK_ASSERT, 0, "ASSERT FAIL"); } return make_null(); } @@ -615,9 +608,14 @@ Value* builtin_assert(Value *arg) { Value* builtin_throw(Value *arg) { /* g_error_msg always carries the stringified form (uncaught * printing, traces); g_error_value preserves the thrown value - * itself so `catch e` binds a dict/list/... unchanged. */ + * itself so `catch e` binds a dict/list/string/... unchanged — + * user throws do NOT get the #406 {kind, message, line} wrapping. + * Kind/line are still stamped for host/embed introspection. */ char *msg = value_to_string(arg); snprintf(g_error_msg, sizeof(g_error_msg), "%s", msg); + snprintf(g_error_raw, sizeof(g_error_raw), "%s", msg); + g_error_kind = (int)EK_USER; + g_error_line = vm_current_line(); g_has_error = 1; eigs_clear_error_value(); if (arg) { @@ -951,7 +949,7 @@ Value* eigs_json_parse_value(const char *s, int *pos) { Value* builtin_json_decode(Value *arg) { if (!arg || arg->type != VAL_STR) { - runtime_error(0, "json_decode requires a string, got %s", + rt_error(EK_TYPE, 0, "json_decode requires a string, got %s", arg ? val_type_name(arg->type) : "null"); return make_null(); } @@ -1406,7 +1404,7 @@ Value* builtin_str_replace(Value *arg) { if (new_len > old_len) { size_t grow = new_len - old_len; if (count != 0 && grow > (STR_REPLACE_MAX - str_len) / count) { - runtime_error(0, "str_replace result too large"); + rt_error(EK_LIMIT, 0, "str_replace result too large"); return make_null(); } result_len = str_len + count * grow; @@ -2508,7 +2506,7 @@ int resolve_eigenscript_file(const char *path, char *resolved, size_t resolved_c #if !EIGENSCRIPT_FREESTANDING Value* builtin_load_file(Value *arg) { if (!arg || arg->type != VAL_STR) { - runtime_error(0, "load_file requires a string path argument"); + rt_error(EK_TYPE, 0, "load_file requires a string path argument"); return make_null(); } char resolved[8192]; @@ -2546,7 +2544,7 @@ Value* builtin_load_file(Value *arg) { free_ast(ast); free_tokenlist(&tl); free(source); - runtime_error(0, "load_file: parse error in '%s'", arg->data.str); + rt_error(EK_PARSE, 0, "load_file: parse error in '%s'", arg->data.str); return make_null(); } /* Compile-stage diagnostics must fail the load too (#337) — same @@ -2562,7 +2560,7 @@ Value* builtin_load_file(Value *arg) { free_ast(ast); free_tokenlist(&tl); free(source); - runtime_error(0, "load_file: compile error in '%s'", arg->data.str); + rt_error(EK_PARSE, 0, "load_file: compile error in '%s'", arg->data.str); return make_null(); } g_parse_errors = saved_errors; @@ -2747,7 +2745,7 @@ Value* builtin_write_text(Value *arg) { * "fail loudly" at the boundary. Documented in docs/TRACE.md. */ static int replay_blocks(const char *fn) { if (__builtin_expect(g_replay_enabled, 0)) { - runtime_error(0, + rt_error(EK_IO, 0, "%s: not replayable under EIGS_REPLAY (subprocess/concurrency " "boundary; see docs/TRACE.md)", fn); return 1; @@ -3382,15 +3380,15 @@ Value* builtin_secure_equals(Value *arg) { * high-byte construction bug (#435). */ Value* builtin_chr(Value *arg) { if (!arg || arg->type != VAL_NUM) { - runtime_error(0, "chr requires a number"); + rt_error(EK_TYPE, 0, "chr requires a number"); return make_null(); } double num = arg->data.num; if (num != (double)(long long)num || num < 1 || num > 255) { if (num == 0) - runtime_error(0, "chr of 0: strings are NUL-terminated and cannot hold a NUL byte (use a buffer for binary with NULs)"); + rt_error(EK_VALUE, 0, "chr of 0: strings are NUL-terminated and cannot hold a NUL byte (use a buffer for binary with NULs)"); else - runtime_error(0, "chr requires an integer byte in 1..255 (got %g); for a codepoint use utf8_encode (lib/utf8.eigs)", num); + rt_error(EK_VALUE, 0, "chr requires an integer byte in 1..255 (got %g); for a codepoint use utf8_encode (lib/utf8.eigs)", num); return make_null(); } char buf[2] = { (char)(int)num, '\0' }; @@ -3416,11 +3414,11 @@ Value* builtin_hex(Value *arg) { num = arg->data.list.items[0]->data.num; width = (long long)arg->data.list.items[1]->data.num; } else { - runtime_error(0, "hex requires a number or [number, nibbles]"); + rt_error(EK_TYPE, 0, "hex requires a number or [number, nibbles]"); return make_null(); } if (num < 0 || num != (double)(long long)num || num > 9007199254740992.0) { - runtime_error(0, "hex requires a non-negative integer (got %g)", num); + rt_error(EK_VALUE, 0, "hex requires a non-negative integer (got %g)", num); return make_null(); } if (width < 0) width = 0; @@ -3499,7 +3497,7 @@ Value* builtin_eval(Value *arg) { * free it here so error paths don't leak (free_ast walks NULL * children safely — see parse_check for the same pattern). */ free_ast(ast); - runtime_error(0, "eval: parse error in code string"); + rt_error(EK_PARSE, 0, "eval: parse error in code string"); return make_null(); } /* Keep the zeroed counter through COMPILE too — compile-stage @@ -3512,7 +3510,7 @@ Value* builtin_eval(Value *arg) { chunk_free(ev_chunk); free_tokenlist(&tl); free_ast(ast); - runtime_error(0, "eval: compile error in code string"); + rt_error(EK_PARSE, 0, "eval: compile error in code string"); return make_null(); } g_parse_errors = saved_errors; @@ -3617,7 +3615,7 @@ Value* builtin_vm_run_bytecode(Value *arg) { * raises a catchable error so untrusted generated code can't touch the outside. */ Value* builtin_sandbox_blocked(Value *arg) { (void)arg; - runtime_error(0, "blocked in sandbox"); + rt_error(EK_SANDBOX, 0, "blocked in sandbox"); return make_null(); } @@ -3715,6 +3713,14 @@ Value* builtin_sandbox_run(Value *arg) { Value *zero = make_num(0); dict_set(out, "ok", zero); /* dict_set increfs; drop our ref */ val_decref(zero); + /* #406: surface the build failure structurally — a descriptor + * that doesn't verify is a bad argument value, same shape as a + * runtime failure's error field. */ + Value *ev = make_dict(3); + dict_set_owned(ev, "kind", make_str(err_kind_name(EK_VALUE))); + dict_set_owned(ev, "message", make_str("invalid chunk descriptor")); + dict_set_owned(ev, "line", make_num(0)); + dict_set_owned(out, "error", ev); return out; } @@ -3748,7 +3754,19 @@ Value* builtin_sandbox_run(Value *arg) { Value *result = vm_execute(chunk, sbox); int ok = g_has_error ? 0 : 1; - if (g_has_error) { g_has_error = 0; eigs_clear_error_value(); } + if (g_has_error) { + /* #406: surface the failure structurally on the result dict so the + * graded ladder can discriminate (sandbox denial vs type error vs + * parse) without re-running outside the sandbox. Same shape as a + * catch binding: {kind, message, line}. */ + Value *ev = make_dict(3); + dict_set_owned(ev, "kind", make_str(err_kind_name((ErrKind)g_error_kind))); + dict_set_owned(ev, "message", make_str(g_error_raw)); + dict_set_owned(ev, "line", make_num((double)g_error_line)); + dict_set_owned(out, "error", ev); + g_has_error = 0; + eigs_clear_error_value(); + } g_sandbox_loop_max = saved_max; g_loop_iterations = saved_iters; @@ -3775,7 +3793,7 @@ Value* builtin_sandbox_run(Value *arg) { * vm_run_bytecode) calls this to do the same. Returns the previous setting. */ Value* builtin_record_history(Value *arg) { if (!arg || arg->type != VAL_NUM) { - runtime_error(0, "record_history requires a number flag (nonzero=on, 0=off), got %s", + rt_error(EK_TYPE, 0, "record_history requires a number flag (nonzero=on, 0=off), got %s", arg ? val_type_name(arg->type) : "null"); return make_null(); } @@ -3906,7 +3924,7 @@ Value* builtin_range(Value *arg) { Much faster than a loop for large arrays (e.g., 64K memory). */ Value* builtin_fill(Value *arg) { if (!arg || arg->type != VAL_LIST || arg->data.list.count < 2) { - runtime_error(0, "fill requires [count, value]"); + rt_error(EK_TYPE, 0, "fill requires [count, value]"); return make_list(0); } int count = (int)arg->data.list.items[0]->data.num; @@ -4113,7 +4131,7 @@ Value* builtin_spawn(Value *arg) { for (int i = 0; i < fn_arg_count; i++) val_decref(fn_args[i]); free(fn_args); } - runtime_error(0, "spawn requires a function or [function, arg1, ...]"); + rt_error(EK_TYPE, 0, "spawn requires a function or [function, arg1, ...]"); return make_null(); } ThreadHandle *h = xmalloc(sizeof(ThreadHandle)); @@ -4165,7 +4183,7 @@ Value* builtin_spawn(Value *arg) { free(h->fn_args); } free(h); - runtime_error(0, "spawn: could not create thread: %s", strerror(pc_rc)); + rt_error(EK_IO, 0, "spawn: could not create thread: %s", strerror(pc_rc)); return make_null(); } Value *d = make_dict(8); @@ -4176,7 +4194,7 @@ Value* builtin_spawn(Value *arg) { Value* builtin_thread_join(Value *arg) { if (!arg || arg->type != VAL_DICT) { - runtime_error(0, "thread_join requires a thread handle"); + rt_error(EK_TYPE, 0, "thread_join requires a thread handle"); return make_null(); } Value *hv = dict_get(arg, "_handle_id"); @@ -4258,12 +4276,12 @@ Value* builtin_channel(Value *arg) { * old shared-mutable-container hazard for the copied types. */ Value* builtin_send(Value *arg) { if (!arg || arg->type != VAL_LIST || arg->data.list.count < 2) { - runtime_error(0, "send requires [channel, value]"); + rt_error(EK_TYPE, 0, "send requires [channel, value]"); return make_null(); } Channel *ch = get_channel(arg->data.list.items[0]); if (!ch) { - runtime_error(0, "send: invalid channel"); + rt_error(EK_VALUE, 0, "send: invalid channel"); return make_null(); } /* Deep-copy into a self-contained heap value (refcount 1) owned by the @@ -4288,7 +4306,7 @@ Value* builtin_recv(Value *arg) { if (replay_blocks("recv")) return make_null(); Channel *ch = get_channel(arg); if (!ch) { - runtime_error(0, "recv: invalid channel"); + rt_error(EK_VALUE, 0, "recv: invalid channel"); return make_null(); } pthread_mutex_lock(&ch->mutex); @@ -4310,7 +4328,7 @@ Value* builtin_try_recv(Value *arg) { if (replay_blocks("try_recv")) return make_null(); Channel *ch = get_channel(arg); if (!ch) { - runtime_error(0, "try_recv: invalid channel"); + rt_error(EK_VALUE, 0, "try_recv: invalid channel"); return make_null(); } pthread_mutex_lock(&ch->mutex); @@ -4332,17 +4350,17 @@ Value* builtin_try_recv(Value *arg) { Value* builtin_recv_timeout(Value *arg) { if (replay_blocks("recv_timeout")) return make_null(); if (!arg || arg->type != VAL_LIST || arg->data.list.count < 2) { - runtime_error(0, "recv_timeout requires [channel, ms]"); + rt_error(EK_TYPE, 0, "recv_timeout requires [channel, ms]"); return make_null(); } Channel *ch = get_channel(arg->data.list.items[0]); if (!ch) { - runtime_error(0, "recv_timeout: invalid channel"); + rt_error(EK_VALUE, 0, "recv_timeout: invalid channel"); return make_null(); } Value *ms_v = arg->data.list.items[1]; if (ms_v->type != VAL_NUM) { - runtime_error(0, "recv_timeout: ms must be a number"); + rt_error(EK_TYPE, 0, "recv_timeout: ms must be a number"); return make_null(); } double ms = ms_v->data.num; @@ -4386,7 +4404,7 @@ Value* builtin_recv_timeout(Value *arg) { Value* builtin_close_channel(Value *arg) { Channel *ch = get_channel(arg); if (!ch) { - runtime_error(0, "close_channel: invalid channel"); + rt_error(EK_VALUE, 0, "close_channel: invalid channel"); return make_null(); } pthread_mutex_lock(&ch->mutex); @@ -4490,7 +4508,7 @@ void handle_table_drain(EigsState *st) { using torus distance. Keys default to "px", "py", "active" if not provided. */ Value* builtin_nearest_in_range(Value *arg) { if (!arg || arg->type != VAL_LIST || arg->data.list.count < 6) { - runtime_error(0, "nearest_in_range requires [entities, x, y, range, world_w, world_h]"); + rt_error(EK_TYPE, 0, "nearest_in_range requires [entities, x, y, range, world_w, world_h]"); return make_null(); } Value *entities = arg->data.list.items[0]; @@ -4610,7 +4628,7 @@ Value* builtin_nearest_in_range(Value *arg) { pointer chases through 6-key dicts. */ Value* builtin_nearest_in_range_all(Value *arg) { if (!arg || arg->type != VAL_LIST || arg->data.list.count < 4) { - runtime_error(0, "nearest_in_range_all requires [entities, range, world_w, world_h]"); + rt_error(EK_TYPE, 0, "nearest_in_range_all requires [entities, range, world_w, world_h]"); return make_null(); } Value *entities = arg->data.list.items[0]; @@ -5108,7 +5126,7 @@ Value* builtin_sort(Value *arg) { for (int i = 0; i < arg->data.list.count; i++) { Value *v = arg->data.list.items[i]; if (!v || v->type != t || (t != VAL_NUM && t != VAL_STR)) { - runtime_error(0, "sort requires all numbers or all strings " + rt_error(EK_TYPE, 0, "sort requires all numbers or all strings " "(element %d is %s); use sort_by for records", i, v ? val_type_name(v->type) : "null"); return make_null(); @@ -5121,7 +5139,7 @@ Value* builtin_sort(Value *arg) { Value* builtin_dispatch(Value *arg) { if (!arg || arg->type != VAL_LIST || arg->data.list.count < 3) { - runtime_error(0, "dispatch requires [table, key, arg]"); + rt_error(EK_TYPE, 0, "dispatch requires [table, key, arg]"); return make_null(); } Value *table = arg->data.list.items[0]; @@ -5131,18 +5149,18 @@ Value* builtin_dispatch(Value *arg) { /* Key validation mirrors OP_DISPATCH (#353): a non-number key used to * reinterpret whatever union member the value carried as a double. */ if (!key_v || key_v->type != VAL_NUM) { - runtime_error(0, "dispatch: key must be a number"); + rt_error(EK_TYPE, 0, "dispatch: key must be a number"); return make_null(); } int key = (int)key_v->data.num; if ((double)key != key_v->data.num) { - runtime_error(0, "dispatch key must be an integer, got %g", + rt_error(EK_VALUE, 0, "dispatch key must be an integer, got %g", key_v->data.num); return make_null(); } if (!table || table->type != VAL_LIST) { - runtime_error(0, "dispatch: table must be a list"); + rt_error(EK_TYPE, 0, "dispatch: table must be a list"); return make_null(); } if (key < 0 || key >= table->data.list.count) { @@ -5176,7 +5194,7 @@ Value* builtin_dispatch(Value *arg) { return make_null(); } - runtime_error(0, "dispatch: slot %d is not a function", key); + rt_error(EK_TYPE, 0, "dispatch: slot %d is not a function", key); return make_null(); } diff --git a/src/eigenscript.c b/src/eigenscript.c index ac42185..92032b3 100644 --- a/src/eigenscript.c +++ b/src/eigenscript.c @@ -72,7 +72,27 @@ void eigs_clear_error_value(void) { * Safe to call when no VM is active (it no-ops). */ void vm_print_stack_trace(FILE *out); -void runtime_error(int line, const char *fmt, ...) { +/* #406: kind → the string `catch` sees in the bound dict's "kind" field. + * Table order tracks the ErrKind enum. */ +const char* err_kind_name(ErrKind k) { + switch (k) { + case EK_INTERNAL: return "internal"; + case EK_UNDEFINED_NAME: return "undefined_name"; + case EK_TYPE: return "type_mismatch"; + case EK_VALUE: return "value"; + case EK_INDEX: return "index_range"; + case EK_PARSE: return "parse"; + case EK_IO: return "io"; + case EK_LIMIT: return "limit"; + case EK_SANDBOX: return "sandbox"; + case EK_INTERRUPT: return "interrupt"; + case EK_ASSERT: return "assert"; + case EK_USER: return "user"; + } + return "internal"; +} + +void rt_error(ErrKind kind, int line, const char *fmt, ...) { char tmp[3900]; va_list args; va_start(args, fmt); @@ -83,7 +103,14 @@ void runtime_error(int line, const char *fmt, ...) { * their own, and a trailing \n would also leak into a caught error's text. */ size_t _tl = strlen(tmp); if (_tl > 0 && tmp[_tl - 1] == '\n') tmp[_tl - 1] = '\0'; + /* Builtins raise with line 0 (they don't carry source positions); + * stamp the VM's live line so both the printed frame and the caught + * dict's `line` point at the failing statement instead of 0. */ + if (line == 0) line = vm_current_line(); + snprintf(g_error_raw, sizeof(g_error_raw), "%s", tmp); snprintf(g_error_msg, sizeof(g_error_msg), "Error line %d: %s", line, tmp); + g_error_kind = (int)kind; + g_error_line = line; g_has_error = 1; eigs_clear_error_value(); /* a new error supersedes any thrown value */ if (g_try_depth == 0) { diff --git a/src/eigenscript.h b/src/eigenscript.h index a935d83..13d3582 100644 --- a/src/eigenscript.h +++ b/src/eigenscript.h @@ -546,6 +546,13 @@ struct EigsThread { char error_msg[4096]; char first_error_msg[256]; struct Value *error_value; /* thrown payload for structured catch */ + /* #406: structured runtime errors. Kind (ErrKind), 1-based line, and + * the bare message (no "Error line N:" frame) of the live error — + * catch binds {kind, message, line} from these when error_value is + * NULL (i.e. for built-in errors; thrown values bind untouched). */ + int error_kind; + int error_line; + char error_raw[3900]; /* Observer execution state — current observer and "unobserved {}" * scope depth (incremented on entry, decremented on exit; non-zero * suppresses assign-count bumps so observer interrogatives don't @@ -639,6 +646,9 @@ extern __thread EigsThread *eigs_current; #define g_error_msg (eigs_current->error_msg) #define g_first_error_msg (eigs_current->first_error_msg) #define g_error_value (eigs_current->error_value) +#define g_error_kind (eigs_current->error_kind) +#define g_error_line (eigs_current->error_line) +#define g_error_raw (eigs_current->error_raw) #define g_last_obs_slot_env (eigs_current->last_obs_slot_env) #define g_last_obs_slot_idx (eigs_current->last_obs_slot_idx) #define g_unobserved_depth (eigs_current->unobserved_depth) @@ -952,8 +962,32 @@ void eigenscript_set_args(int argc, char **argv); * when uncaught. Declared here so extension TUs (ext_store, etc.) can route * argument/operation failures through the same strict channel as the VM. */ const char* val_type_name(ValType t); -void runtime_error(int line, const char *fmt, ...) - __attribute__((format(printf, 2, 3))); +/* #406: the closed error-kind vocabulary. Every built-in runtime error + * carries exactly one of these; `catch` binds it as the dict's "kind" + * string (err_kind_name). The set is CLOSED by design — the same + * instinct as the closed trajectory vocabulary. Extend only with a SPEC + * + DIAGNOSTICS.md entry in the same PR. EK_USER marks `throw` (the + * thrown value itself binds; the kind shows up only in host/embed + * introspection). EK_INTERNAL is deliberately 0: a raise site that + * somehow never classified reads as the "runtime invariant broke" + * bucket, which is the loud interpretation. */ +typedef enum { + EK_INTERNAL = 0, /* VM invariant broke (unknown opcode, slot table) */ + EK_UNDEFINED_NAME, /* no binding for a name */ + EK_TYPE, /* operation/argument received the wrong type */ + EK_VALUE, /* right type, unacceptable value */ + EK_INDEX, /* index or slice out of range */ + EK_PARSE, /* runtime-surfaced parse/compile failure (eval, import, load_file) */ + EK_IO, /* the outside world failed: files, stores, sockets, threads */ + EK_LIMIT, /* engine resource cap: stack overflow, size caps, table full */ + EK_SANDBOX, /* sandbox policy denial or budget exhaustion */ + EK_INTERRUPT, /* host-requested abort (eigs_abort) */ + EK_ASSERT, /* assert builtin failure */ + EK_USER, /* `throw` — catch binds the thrown value, not a dict */ +} ErrKind; +const char* err_kind_name(ErrKind k); +void rt_error(ErrKind kind, int line, const char *fmt, ...) + __attribute__((format(printf, 3, 4))); char* read_file_util(const char *path, long *out_size); int resolve_eigenscript_file(const char *path, char *resolved, size_t resolved_cap); /* Same chain, but the "script-relative" and "../" steps anchor at @@ -973,6 +1007,7 @@ char* eigs_json_encode(Value *v); * above). The declarations below cover the not-yet-migrated globals. */ void eigs_clear_error_value(void); void vm_print_stack_trace(FILE *out); /* uncaught-error call stack (vm.c); no-ops without a VM */ +int vm_current_line(void); /* live source line (vm.c); 0 without a VM */ void eigs_record_first_error(int line, const char *msg); void eigs_record_first_error_at(int line, int col, const char *msg); /* #407: register the compilation unit's raw source so column-carrying parse diff --git a/src/eigs_embed.c b/src/eigs_embed.c index b6ca825..0c5ab18 100644 --- a/src/eigs_embed.c +++ b/src/eigs_embed.c @@ -145,10 +145,23 @@ int eigs_has_error(void) { return (eigs_current && g_has_error) ? 1 : 0; } +const char *eigs_last_error_kind(void) { + if (!eigs_current || !g_has_error) return NULL; + return err_kind_name((ErrKind)g_error_kind); +} + +int eigs_last_error_line(void) { + if (!eigs_current || !g_has_error) return 0; + return g_error_line; +} + void eigs_clear_error(void) { if (!eigs_current) return; g_has_error = 0; g_error_msg[0] = '\0'; + g_error_raw[0] = '\0'; + g_error_kind = 0; + g_error_line = 0; g_first_error_msg[0] = '\0'; g_first_error_line = 0; eigs_clear_error_value(); diff --git a/src/eigs_embed.h b/src/eigs_embed.h index 19b8001..8916259 100644 --- a/src/eigs_embed.h +++ b/src/eigs_embed.h @@ -85,6 +85,16 @@ const char *eigs_last_error_message(void); int eigs_has_error(void); void eigs_clear_error(void); +/* #406: kind of the pending error, from the closed vocabulary documented + * in docs/DIAGNOSTICS.md ("undefined_name", "type_mismatch", "value", + * "index_range", "parse", "io", "limit", "sandbox", "interrupt", + * "assert", "internal"; "user" for `throw`). NULL when no error is + * pending. Same lifetime rules as eigs_last_error_message (the kind + * string itself is static). eigs_last_error_line is 1-based, 0 when + * unknown. */ +const char *eigs_last_error_kind(void); +int eigs_last_error_line(void); + /* ---- Globals ------------------------------------------------------ */ /* Bind `val` into the global env under `name`. The store retains its own diff --git a/src/ext_db.c b/src/ext_db.c index ba9bb8b..b969636 100644 --- a/src/ext_db.c +++ b/src/ext_db.c @@ -29,7 +29,7 @@ static int db_build_query(Value *arg, const char **sql, int *nparams, * error confusingly at exec time — or worse, not at all) or coercing * non-scalar params to "" (the #316 class). */ if (*nparams > DB_MAX_PARAMS) { - runtime_error(0, "db: too many parameters (%d, max %d)", + rt_error(EK_LIMIT, 0, "db: too many parameters (%d, max %d)", *nparams, DB_MAX_PARAMS); return 0; } @@ -41,7 +41,7 @@ static int db_build_query(Value *arg, const char **sql, int *nparams, snprintf(numbuf[i], 64, "%g", v->data.num); params[i] = numbuf[i]; } else { - runtime_error(0, "db: parameter %d is not a string or number (got %s)", + rt_error(EK_TYPE, 0, "db: parameter %d is not a string or number (got %s)", i + 1, v ? val_type_name(v->type) : "null"); return 0; } diff --git a/src/ext_http.c b/src/ext_http.c index 6769be3..7f7acd6 100644 --- a/src/ext_http.c +++ b/src/ext_http.c @@ -138,11 +138,11 @@ Value* builtin_http_route(Value *arg) { /* #356: registration failures must raise — the return value is never * checked, so a silent make_null() means the route just never exists. */ if (!arg || arg->type != VAL_LIST || arg->data.list.count < 3) { - runtime_error(0, "http_route requires [method, path, handler...] (3+ elements)"); + rt_error(EK_TYPE, 0, "http_route requires [method, path, handler...] (3+ elements)"); return make_null(); } if (g_server.route_count >= MAX_ROUTES) { - runtime_error(0, "http_route: route table full (max %d routes)", MAX_ROUTES); + rt_error(EK_LIMIT, 0, "http_route: route table full (max %d routes)", MAX_ROUTES); return make_null(); } diff --git a/src/ext_store.c b/src/ext_store.c index 19e6d36..7516a2c 100644 --- a/src/ext_store.c +++ b/src/ext_store.c @@ -635,7 +635,7 @@ static void store_free(Store *store) { /* store_open(path) -> handle dict */ static Value* builtin_store_open(Value *arg) { if (!arg || arg->type != VAL_STR) { - runtime_error(0, "store_open requires a string path\n"); + rt_error(EK_TYPE, 0, "store_open requires a string path\n"); return make_null(); } const char *path = arg->data.str; @@ -649,13 +649,13 @@ static Value* builtin_store_open(Value *arg) { /* Existing file */ store->fp = fp; if (store_read_header(store) != 0) { - runtime_error(0, "store_open: invalid database file '%s'\n", path); + rt_error(EK_IO, 0, "store_open: invalid database file '%s'\n", path); fclose(fp); store_free(store); return make_null(); } if (store_load_catalog(store) != 0) { - runtime_error(0, "store_open: corrupt catalog in '%s'\n", path); + rt_error(EK_IO, 0, "store_open: corrupt catalog in '%s'\n", path); fclose(fp); store_free(store); return make_null(); @@ -664,7 +664,7 @@ static Value* builtin_store_open(Value *arg) { /* Create new file */ fp = xfopen_write(path, "w+b"); if (!fp) { - runtime_error(0, "store_open: cannot create '%s'\n", path); + rt_error(EK_IO, 0, "store_open: cannot create '%s'\n", path); store_free(store); return make_null(); } @@ -704,7 +704,7 @@ static Value* builtin_store_open(Value *arg) { static Value* builtin_store_close(Value *arg) { Store *store = get_store(arg); if (!store) { - runtime_error(0, "store_close requires a store handle\n"); + rt_error(EK_TYPE, 0, "store_close requires a store handle\n"); return make_null(); } /* Release handle BEFORE freeing memory to prevent use-after-free @@ -726,18 +726,18 @@ static Value* builtin_store_close(Value *arg) { /* store_put([handle, collection, record]) -> key string */ static Value* builtin_store_put(Value *arg) { if (!arg || arg->type != VAL_LIST || arg->data.list.count < 3) { - runtime_error(0, "store_put requires [handle, collection, record]\n"); + rt_error(EK_TYPE, 0, "store_put requires [handle, collection, record]\n"); return make_null(); } Store *store = get_store(arg->data.list.items[0]); Value *col_val = arg->data.list.items[1]; Value *record = arg->data.list.items[2]; if (!store || !col_val || col_val->type != VAL_STR) { - runtime_error(0, "store_put: invalid handle or collection\n"); + rt_error(EK_VALUE, 0, "store_put: invalid handle or collection\n"); return make_null(); } if (!record || record->type != VAL_DICT) { - runtime_error(0, "store_put: record must be a dict\n"); + rt_error(EK_TYPE, 0, "store_put: record must be a dict\n"); return make_null(); } const char *collection = col_val->data.str; @@ -793,14 +793,14 @@ static Value* builtin_store_put(Value *arg) { size_t key_len = strlen(key_buf); if (key_len > 0xFFFF || json_len > 0xFFFFFFFF) { - runtime_error(0, "store_put: record too large\n"); + rt_error(EK_LIMIT, 0, "store_put: record too large\n"); free(json); return make_null(); } size_t record_size = 2 + key_len + 4 + json_len; if (record_size > STORE_PAGE_DATA_SIZE) { - runtime_error(0, "store_put: record exceeds page size\n"); + rt_error(EK_LIMIT, 0, "store_put: record exceeds page size\n"); free(json); return make_null(); } @@ -873,7 +873,7 @@ static Value* builtin_store_put(Value *arg) { /* store_get([handle, collection, key]) -> record dict or null */ static Value* builtin_store_get(Value *arg) { if (!arg || arg->type != VAL_LIST || arg->data.list.count < 3) { - runtime_error(0, "store_get requires [handle, collection, key]\n"); + rt_error(EK_TYPE, 0, "store_get requires [handle, collection, key]\n"); return make_null(); } Store *store = get_store(arg->data.list.items[0]); @@ -929,7 +929,7 @@ static Value* builtin_store_get(Value *arg) { /* store_delete([handle, collection, key]) -> 1 or 0 */ static Value* builtin_store_delete(Value *arg) { if (!arg || arg->type != VAL_LIST || arg->data.list.count < 3) { - runtime_error(0, "store_delete requires [handle, collection, key]\n"); + rt_error(EK_TYPE, 0, "store_delete requires [handle, collection, key]\n"); return make_num(0); } Store *store = get_store(arg->data.list.items[0]); @@ -989,7 +989,7 @@ static Value* builtin_store_delete(Value *arg) { /* store_query([handle, collection]) -> list of all records */ static Value* builtin_store_query(Value *arg) { if (!arg || arg->type != VAL_LIST || arg->data.list.count < 2) { - runtime_error(0, "store_query requires [handle, collection]\n"); + rt_error(EK_TYPE, 0, "store_query requires [handle, collection]\n"); return make_list(0); } Store *store = get_store(arg->data.list.items[0]); @@ -1031,7 +1031,7 @@ static Value* builtin_store_query(Value *arg) { /* store_count([handle, collection]) -> number */ static Value* builtin_store_count(Value *arg) { if (!arg || arg->type != VAL_LIST || arg->data.list.count < 2) { - runtime_error(0, "store_count requires [handle, collection]\n"); + rt_error(EK_TYPE, 0, "store_count requires [handle, collection]\n"); return make_num(0); } Store *store = get_store(arg->data.list.items[0]); @@ -1066,7 +1066,7 @@ static Value* builtin_store_count(Value *arg) { /* store_update([handle, collection, key, record]) -> 1 or 0 */ static Value* builtin_store_update(Value *arg) { if (!arg || arg->type != VAL_LIST || arg->data.list.count < 4) { - runtime_error(0, "store_update requires [handle, collection, key, record]\n"); + rt_error(EK_TYPE, 0, "store_update requires [handle, collection, key, record]\n"); return make_num(0); } Value *handle = arg->data.list.items[0]; @@ -1116,7 +1116,7 @@ static Value* builtin_store_update(Value *arg) { static Value* builtin_store_collections(Value *arg) { Store *store = get_store(arg); if (!store) { - runtime_error(0, "store_collections requires a store handle\n"); + rt_error(EK_TYPE, 0, "store_collections requires a store handle\n"); return make_list(0); } Value *list = make_list(store->catalog->data.dict.count); @@ -1129,7 +1129,7 @@ static Value* builtin_store_collections(Value *arg) { /* store_drop([handle, collection]) -> 1 or 0 */ static Value* builtin_store_drop(Value *arg) { if (!arg || arg->type != VAL_LIST || arg->data.list.count < 2) { - runtime_error(0, "store_drop requires [handle, collection]\n"); + rt_error(EK_TYPE, 0, "store_drop requires [handle, collection]\n"); return make_num(0); } Store *store = get_store(arg->data.list.items[0]); diff --git a/src/vm.c b/src/vm.c index 3e50d27..4a009ef 100644 --- a/src/vm.c +++ b/src/vm.c @@ -93,7 +93,6 @@ extern void env_set_hashed(Env *env, const char *name, uint32_t h, Value *val); extern void env_set_local_hashed(Env *env, const char *name, uint32_t h, Value *val); extern Value* env_get_hashed(Env *env, const char *name, uint32_t h); extern Value* env_get(Env *env, const char *name); -extern void runtime_error(int line, const char *fmt, ...); extern double num_guard(double x); extern Value* promote_if_arena(Value *v); /* Observer helper — declared in eval.c, needs to be exposed for VM. @@ -413,7 +412,7 @@ static inline Value *slot_bridge_unwrap(EigsSlot s) { static inline void vm_push(Value *v) { if (g_vm.sp >= VM_STACK_MAX) { - runtime_error(0, "VM stack overflow"); + rt_error(EK_LIMIT, 0, "VM stack overflow"); return; } g_vm.stack[g_vm.sp++] = slot_bridge_wrap(v); @@ -466,7 +465,7 @@ static inline Value *vm_peek(int distance) { * immediates directly (post-arith, post-cmp, NEG/NOT/BNOT, DUP, etc.). */ static inline void vm_push_slot(EigsSlot s) { if (g_vm.sp >= VM_STACK_MAX) { - runtime_error(0, "VM stack overflow"); + rt_error(EK_LIMIT, 0, "VM stack overflow"); return; } g_vm.stack[g_vm.sp++] = s; @@ -574,7 +573,7 @@ int sandbox_charge(size_t bytes) { /* Overflow-safe: g_sandbox_bytes_used <= g_sandbox_byte_max is an invariant * (we never commit a charge that breaks it), so the subtraction can't wrap. */ if (bytes > g_sandbox_byte_max - g_sandbox_bytes_used) { - runtime_error(0, "sandbox memory budget exceeded (used %zu + %zu > %zu bytes)", + rt_error(EK_SANDBOX, 0, "sandbox memory budget exceeded (used %zu + %zu > %zu bytes)", g_sandbox_bytes_used, bytes, g_sandbox_byte_max); return 0; } @@ -613,7 +612,7 @@ void jit_helper_get_name(EigsChunk *chunk, int idx) { int slot_idx, depth; Env *target = env_resolve_chain(start, name, h, &slot_idx, &depth); if (!target) { - runtime_error(g_vm.current_line, "undefined variable '%s'", name); + rt_error(EK_UNDEFINED_NAME, g_vm.current_line, "undefined variable '%s'", name); vm_push_slot(slot_null()); return; } @@ -764,7 +763,7 @@ void jit_helper_local_idx_get(int slot, int idx) { if (i < target->data.buffer.count) { vm_push_slot(slot_from_num(target->data.buffer.data[i])); } else { - runtime_error(g_vm.current_line, + rt_error(EK_INDEX, g_vm.current_line, "buffer index %d out of range (length %d)", i, target->data.buffer.count); vm_push_slot(slot_null()); @@ -781,7 +780,7 @@ void jit_helper_local_idx_get(int slot, int idx) { vm_push(item); } } else { - runtime_error(g_vm.current_line, + rt_error(EK_INDEX, g_vm.current_line, "index %d out of range (list length %d)", i, target->data.list.count); vm_push_slot(slot_null()); @@ -794,7 +793,7 @@ void jit_helper_local_idx_get(int slot, int idx) { char buf[2] = { target->data.str[i], 0 }; vm_push(make_str(buf)); } else { - runtime_error(g_vm.current_line, + rt_error(EK_INDEX, g_vm.current_line, "string index %d out of range (length %d)", i, len); vm_push_slot(slot_null()); @@ -802,7 +801,7 @@ void jit_helper_local_idx_get(int slot, int idx) { return; } if (target->type != VAL_NULL) { - runtime_error(g_vm.current_line, + rt_error(EK_TYPE, g_vm.current_line, "cannot index %s", val_type_name(target->type)); } } @@ -840,7 +839,7 @@ void jit_helper_local_dot_get(EigsChunk *chunk, int slot, int name_idx) { } if (target && target->type != VAL_NULL) { const char *key = chunk->const_interns[name_idx]; - runtime_error(g_vm.current_line, + rt_error(EK_TYPE, g_vm.current_line, "cannot access field '%s' on %s", key, val_type_name(target->type)); } @@ -886,17 +885,17 @@ void jit_helper_local_idx_dot_get(EigsChunk *chunk, int slot, } } else if (dict && dict->type != VAL_NULL) { const char *key = chunk->const_interns[name_idx]; - runtime_error(g_vm.current_line, + rt_error(EK_TYPE, g_vm.current_line, "cannot access field '%s' on %s", key, val_type_name(dict->type)); } } else { - runtime_error(g_vm.current_line, + rt_error(EK_INDEX, g_vm.current_line, "index %d out of range (list length %d)", i, target->data.list.count); } } else if (target && target->type != VAL_NULL) { - runtime_error(g_vm.current_line, + rt_error(EK_TYPE, g_vm.current_line, "cannot index %s", val_type_name(target->type)); } vm_push_slot(slot_null()); @@ -945,7 +944,7 @@ void jit_helper_dot_set(EigsChunk *chunk, int name_idx) { if (target->type == VAL_DICT) dict_set_cached(target, key, h, val); else if (target->type != VAL_NULL) - runtime_error(g_vm.current_line, "cannot set field '%s' on %s", + rt_error(EK_TYPE, g_vm.current_line, "cannot set field '%s' on %s", key, val_type_name(target->type)); val_decref(target); val_incref(val); @@ -976,7 +975,7 @@ void jit_helper_dot_get(EigsChunk *chunk, int name_idx) { return; } } else if (target->type != VAL_NULL) { - runtime_error(g_vm.current_line, + rt_error(EK_TYPE, g_vm.current_line, "cannot access field '%s' on %s", key, val_type_name(target->type)); } @@ -1015,7 +1014,7 @@ void jit_helper_local_dot_set(EigsChunk *chunk, int slot, int name_idx) { } if (target && target->type != VAL_NULL) { const char *key = chunk->const_interns[name_idx]; - runtime_error(g_vm.current_line, "cannot set field '%s' on %s", + rt_error(EK_TYPE, g_vm.current_line, "cannot set field '%s' on %s", key, val_type_name(target->type)); } } @@ -1469,8 +1468,8 @@ void jit_helper_index_set(void) { if (_ok && vm_index_resolve(&i, target->data.buffer.count)) { target->data.buffer.data[i] = val_s.d; } else { - if (!_ok) runtime_error(g_vm.current_line, "index must be an integer, got %g", idx_s.d); - else runtime_error(g_vm.current_line, "buffer index %d out of range (length %d)", + if (!_ok) rt_error(EK_VALUE, g_vm.current_line, "index must be an integer, got %g", idx_s.d); + else rt_error(EK_INDEX, g_vm.current_line, "buffer index %d out of range (length %d)", i, target->data.buffer.count); } g_vm.sp -= 2; /* keep val on TOS */ @@ -1491,8 +1490,8 @@ void jit_helper_index_set(void) { target->data.list.items[i] = make_num(val_s.d); } } else { - if (!_ok) runtime_error(g_vm.current_line, "index must be an integer, got %g", idx_s.d); - else runtime_error(g_vm.current_line, "index %d out of range (list length %d)", + if (!_ok) rt_error(EK_VALUE, g_vm.current_line, "index must be an integer, got %g", idx_s.d); + else rt_error(EK_INDEX, g_vm.current_line, "index %d out of range (list length %d)", i, target->data.list.count); } g_vm.sp -= 2; @@ -1506,27 +1505,27 @@ void jit_helper_index_set(void) { if (target->type == VAL_LIST && idx->type == VAL_NUM) { int i; if (!vm_index_is_int(idx->data.num, &i)) { - runtime_error(g_vm.current_line, "index must be an integer, got %g", idx->data.num); + rt_error(EK_VALUE, g_vm.current_line, "index must be an integer, got %g", idx->data.num); } else if (vm_index_resolve(&i, target->data.list.count)) { val_incref(val); val_decref(target->data.list.items[i]); target->data.list.items[i] = val; } else { - runtime_error(g_vm.current_line, "index %d out of range (list length %d)", i, target->data.list.count); + rt_error(EK_INDEX, g_vm.current_line, "index %d out of range (list length %d)", i, target->data.list.count); } } else if (target->type == VAL_BUFFER && idx->type == VAL_NUM) { int i; if (!vm_index_is_int(idx->data.num, &i)) { - runtime_error(g_vm.current_line, "index must be an integer, got %g", idx->data.num); + rt_error(EK_VALUE, g_vm.current_line, "index must be an integer, got %g", idx->data.num); } else if (!vm_index_resolve(&i, target->data.buffer.count)) { - runtime_error(g_vm.current_line, "buffer index %d out of range (length %d)", i, target->data.buffer.count); + rt_error(EK_INDEX, g_vm.current_line, "buffer index %d out of range (length %d)", i, target->data.buffer.count); } else if (val->type == VAL_NUM) { target->data.buffer.data[i] = val->data.num; } } else if (target->type == VAL_DICT && idx->type == VAL_STR) { dict_set(target, idx->data.str, val); } else if (target->type != VAL_NULL) { - runtime_error(g_vm.current_line, "cannot index %s for assignment", val_type_name(target->type)); + rt_error(EK_TYPE, g_vm.current_line, "cannot index %s for assignment", val_type_name(target->type)); } val_decref(target); val_decref(idx); vm_push(val); @@ -1555,8 +1554,8 @@ void jit_helper_index_get(void) { vm_push(r); } } else { - if (!_ok) runtime_error(g_vm.current_line, "index must be an integer, got %g", idx_s.d); - else runtime_error(g_vm.current_line, + if (!_ok) rt_error(EK_VALUE, g_vm.current_line, "index must be an integer, got %g", idx_s.d); + else rt_error(EK_INDEX, g_vm.current_line, "index %d out of range (list length %d)", i, target->data.list.count); slot_decref(tgt_s); @@ -1570,8 +1569,8 @@ void jit_helper_index_get(void) { slot_decref(tgt_s); vm_push_slot(slot_from_num(v)); } else { - if (!_ok) runtime_error(g_vm.current_line, "index must be an integer, got %g", idx_s.d); - else runtime_error(g_vm.current_line, + if (!_ok) rt_error(EK_VALUE, g_vm.current_line, "index must be an integer, got %g", idx_s.d); + else rt_error(EK_INDEX, g_vm.current_line, "buffer index %d out of range (length %d)", i, target->data.buffer.count); slot_decref(tgt_s); @@ -1589,12 +1588,12 @@ void jit_helper_index_get(void) { if (target->type == VAL_LIST && idx->type == VAL_NUM) { int i; if (!vm_index_is_int(idx->data.num, &i)) { - runtime_error(g_vm.current_line, "index must be an integer, got %g", idx->data.num); + rt_error(EK_VALUE, g_vm.current_line, "index must be an integer, got %g", idx->data.num); } else if (vm_index_resolve(&i, target->data.list.count)) { result = target->data.list.items[i]; val_incref(result); } else { - runtime_error(g_vm.current_line, + rt_error(EK_INDEX, g_vm.current_line, "index %d out of range (list length %d)", i, target->data.list.count); } @@ -1604,27 +1603,27 @@ void jit_helper_index_get(void) { } else if (target->type == VAL_STR && idx->type == VAL_NUM) { int i; if (!vm_index_is_int(idx->data.num, &i)) { - runtime_error(g_vm.current_line, "index must be an integer, got %g", idx->data.num); + rt_error(EK_VALUE, g_vm.current_line, "index must be an integer, got %g", idx->data.num); } else if (vm_index_resolve(&i, (int)strlen(target->data.str))) { char buf[2] = { target->data.str[i], 0 }; result = make_str(buf); } else { - runtime_error(g_vm.current_line, + rt_error(EK_INDEX, g_vm.current_line, "string index %d out of range (length %d)", i, (int)strlen(target->data.str)); } } else if (target->type == VAL_BUFFER && idx->type == VAL_NUM) { int i; if (!vm_index_is_int(idx->data.num, &i)) - runtime_error(g_vm.current_line, "index must be an integer, got %g", idx->data.num); + rt_error(EK_VALUE, g_vm.current_line, "index must be an integer, got %g", idx->data.num); else if (vm_index_resolve(&i, target->data.buffer.count)) result = make_num(target->data.buffer.data[i]); else - runtime_error(g_vm.current_line, + rt_error(EK_INDEX, g_vm.current_line, "buffer index %d out of range (length %d)", i, target->data.buffer.count); } else if (target->type != VAL_NULL) { - runtime_error(g_vm.current_line, + rt_error(EK_TYPE, g_vm.current_line, "cannot index %s", val_type_name(target->type)); } val_decref(target); val_decref(idx); @@ -1994,16 +1993,29 @@ void eigs_jit_get_layout(EigsJitLayout *out) { * vm_run unwinds and returns with g_has_error still set so the C caller * (a re-entrant vm_run, or main) observes the failure. */ /* Catch-binding: if `throw` stashed a structured error value (dict, - * list, ...), the catch variable binds that value — ownership of the - * stash's ref transfers to the stack. Plain runtime errors (and string - * throws, which also fill g_error_msg) bind the message string. */ + * list, string, ...), the catch variable binds that value — ownership + * of the stash's ref transfers to the stack. Built-in runtime errors + * bind a {kind, message, line} dict (#406): kind from the closed + * ErrKind vocabulary, message without the "Error line N:" frame, line + * 1-based. Built lazily here so the uncaught path never allocates. */ static Value *vm_take_error_value(void) { if (g_error_value) { Value *v = g_error_value; g_error_value = NULL; return v; } - return make_str(g_error_msg); + Value *d = make_dict(3); + dict_set_owned(d, "kind", make_str(err_kind_name((ErrKind)g_error_kind))); + dict_set_owned(d, "message", make_str(g_error_raw)); + dict_set_owned(d, "line", make_num((double)g_error_line)); + return d; +} + +/* Live source line for error stamping — rt_error uses this when a raise + * site (a builtin) has no line of its own. 0 when no VM is running. */ +int vm_current_line(void) { + if (!eigs_current || !eigs_current->vm) return 0; + return g_vm.current_line; } /* Print the live call stack, innermost frame first. Called by @@ -2300,11 +2312,11 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { * concatenation use an f-string — f"{a}{b}" — or str of. */ const char *ta = val_type_name(a->type), *tb = val_type_name(b->type); if ((a->type == VAL_STR) != (b->type == VAL_STR)) - runtime_error(current_line, + rt_error(EK_TYPE, current_line, "cannot apply '+' to %s and %s (use an f-string or 'str of' to concatenate)", ta, tb); else - runtime_error(current_line, "cannot apply '+' to %s and %s", ta, tb); + rt_error(EK_TYPE, current_line, "cannot apply '+' to %s and %s", ta, tb); vm_push(make_null()); } val_decref(a); val_decref(b); @@ -2321,7 +2333,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { if (NUM_REUSE(b)) { b->data.num = r; vm_push(b); val_decref(a); DISPATCH(); } \ vm_push(make_num(r)); \ } else { \ - runtime_error(current_line, "cannot apply '%s' to %s and %s", \ + rt_error(EK_TYPE, current_line, "cannot apply '%s' to %s and %s", \ OPNAME, val_type_name(a->type), val_type_name(b->type)); \ vm_push(make_num(0)); \ } \ @@ -2345,7 +2357,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { vm_push(make_num(r)); } } else { - runtime_error(current_line, "cannot apply '/' to %s and %s", + rt_error(EK_TYPE, current_line, "cannot apply '/' to %s and %s", val_type_name(a->type), val_type_name(b->type)); vm_push(make_num(0)); } @@ -2362,7 +2374,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { vm_push(make_num(r)); } else { if (!(a->type == VAL_NUM && b->type == VAL_NUM)) - runtime_error(current_line, "cannot apply '%%' to %s and %s", + rt_error(EK_TYPE, current_line, "cannot apply '%%' to %s and %s", val_type_name(a->type), val_type_name(b->type)); vm_push(make_num(0)); } @@ -2402,7 +2414,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { { \ const char *_tna = slot_type_name(_as), *_tnb = slot_type_name(_bs); \ slot_decref(_as); slot_decref(_bs); \ - runtime_error(current_line, "cannot apply '%s' to %s and %s", \ + rt_error(EK_TYPE, current_line, "cannot apply '%s' to %s and %s", \ OPNAME, _tna, _tnb); \ } \ g_vm.stack[g_vm.sp - 2] = slot_from_num(0.0); \ @@ -2433,7 +2445,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { g_vm.stack[g_vm.sp - 1] = slot_from_num(-d); DISPATCH(); } - runtime_error(current_line, "cannot negate non-numeric"); + rt_error(EK_TYPE, current_line, "cannot negate non-numeric"); slot_decref(s); g_vm.stack[g_vm.sp - 1] = slot_from_num(0.0); DISPATCH(); @@ -2463,7 +2475,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { { const char *_tn = slot_type_name(s); slot_decref(s); - runtime_error(current_line, "cannot apply '~' to %s", _tn); + rt_error(EK_TYPE, current_line, "cannot apply '~' to %s", _tn); } g_vm.stack[g_vm.sp - 1] = slot_from_num(0.0); DISPATCH(); @@ -2561,7 +2573,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { { \ const char *_tna = slot_type_name(_as), *_tnb = slot_type_name(_bs); \ slot_decref(_as); slot_decref(_bs); \ - runtime_error(current_line, "cannot compare %s and %s with '%s'", \ + rt_error(EK_TYPE, current_line, "cannot compare %s and %s with '%s'", \ _tna, _tnb, OPNAME); \ } \ g_vm.stack[g_vm.sp - 2] = slot_from_num(0.0); \ @@ -2634,7 +2646,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { * a bad or missing local_names table. Loud and catchable. * NOTE: the GET side above deliberately stays a silent null — * that path is the missing-parameter-reads-null semantics. */ - runtime_error(current_line, + rt_error(EK_INTERNAL, current_line, "SET_LOCAL slot %d out of range (env has %d slots)", (int)slot, e->count); } @@ -2664,7 +2676,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { int slot_idx, depth; Env *target = env_resolve_chain(start, name, h, &slot_idx, &depth); if (!target) { - runtime_error(current_line, "undefined variable '%s'", name); + rt_error(EK_UNDEFINED_NAME, current_line, "undefined variable '%s'", name); vm_push_slot(slot_null()); DISPATCH(); } @@ -2825,7 +2837,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { * deref — the same check the JIT emits at native back-edges. */ if (__builtin_expect(*g_vm_abort_flag, 0)) { *g_vm_abort_flag = 0; - runtime_error(current_line, "aborted"); + rt_error(EK_INTERRUPT, current_line, "aborted"); DISPATCH(); } /* Hotness signal: this is the back-edge of every while/for body. @@ -3166,7 +3178,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { /* Save current frame and push new one */ frame->ip = ip; if (g_vm.frame_count >= VM_FRAMES_MAX) { - runtime_error(current_line, "call stack overflow"); + rt_error(EK_LIMIT, current_line, "call stack overflow"); env_decref(call_env); vm_push(make_null()); DISPATCH(); @@ -3255,7 +3267,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { } /* Not callable */ - runtime_error(current_line, "cannot call %s", val_type_name(fn_val->type)); + rt_error(EK_TYPE, current_line, "cannot call %s", val_type_name(fn_val->type)); for (int i = 0; i < argc; i++) val_decref(vm_pop()); val_decref(vm_pop()); vm_push(make_null()); @@ -3357,7 +3369,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { Value *k = STK_AS_VAL(base + i * 2); Value *v = STK_AS_VAL(base + i * 2 + 1); if (k->type != VAL_STR) { - runtime_error(current_line, "dict key must be a string, got %s", val_type_name(k->type)); + rt_error(EK_TYPE, current_line, "dict key must be a string, got %s", val_type_name(k->type)); continue; } dict_set(dict, k->data.str, v); @@ -3399,8 +3411,8 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { vm_push(r); } } else { - if (!_ok) runtime_error(current_line, "index must be an integer, got %g", idx_s.d); - else runtime_error(current_line, "index %d out of range (list length %d)", + if (!_ok) rt_error(EK_VALUE, current_line, "index must be an integer, got %g", idx_s.d); + else rt_error(EK_INDEX, current_line, "index %d out of range (list length %d)", i, target->data.list.count); slot_decref(tgt_s); vm_push_slot(slot_null()); @@ -3413,8 +3425,8 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { slot_decref(tgt_s); vm_push_slot(slot_from_num(v)); } else { - if (!_ok) runtime_error(current_line, "index must be an integer, got %g", idx_s.d); - else runtime_error(current_line, "buffer index %d out of range (length %d)", + if (!_ok) rt_error(EK_VALUE, current_line, "index must be an integer, got %g", idx_s.d); + else rt_error(EK_INDEX, current_line, "buffer index %d out of range (length %d)", i, target->data.buffer.count); slot_decref(tgt_s); vm_push_slot(slot_null()); @@ -3431,12 +3443,12 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { if (target->type == VAL_LIST && idx->type == VAL_NUM) { int i; if (!vm_index_is_int(idx->data.num, &i)) { - runtime_error(current_line, "index must be an integer, got %g", idx->data.num); + rt_error(EK_VALUE, current_line, "index must be an integer, got %g", idx->data.num); } else if (vm_index_resolve(&i, target->data.list.count)) { result = target->data.list.items[i]; val_incref(result); } else { - runtime_error(current_line, "index %d out of range (list length %d)", i, target->data.list.count); + rt_error(EK_INDEX, current_line, "index %d out of range (list length %d)", i, target->data.list.count); } } else if (target->type == VAL_DICT && idx->type == VAL_STR) { Value *v = dict_get(target, idx->data.str); @@ -3444,23 +3456,23 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { } else if (target->type == VAL_STR && idx->type == VAL_NUM) { int i; if (!vm_index_is_int(idx->data.num, &i)) { - runtime_error(current_line, "index must be an integer, got %g", idx->data.num); + rt_error(EK_VALUE, current_line, "index must be an integer, got %g", idx->data.num); } else if (vm_index_resolve(&i, (int)strlen(target->data.str))) { char buf[2] = { target->data.str[i], 0 }; result = make_str(buf); } else { - runtime_error(current_line, "string index %d out of range (length %d)", i, (int)strlen(target->data.str)); + rt_error(EK_INDEX, current_line, "string index %d out of range (length %d)", i, (int)strlen(target->data.str)); } } else if (target->type == VAL_BUFFER && idx->type == VAL_NUM) { int i; if (!vm_index_is_int(idx->data.num, &i)) - runtime_error(current_line, "index must be an integer, got %g", idx->data.num); + rt_error(EK_VALUE, current_line, "index must be an integer, got %g", idx->data.num); else if (vm_index_resolve(&i, target->data.buffer.count)) result = make_num(target->data.buffer.data[i]); else - runtime_error(current_line, "buffer index %d out of range (length %d)", i, target->data.buffer.count); + rt_error(EK_INDEX, current_line, "buffer index %d out of range (length %d)", i, target->data.buffer.count); } else if (target->type != VAL_NULL) { - runtime_error(current_line, "cannot index %s", val_type_name(target->type)); + rt_error(EK_TYPE, current_line, "cannot index %s", val_type_name(target->type)); } val_decref(target); val_decref(idx); vm_push(result); @@ -3480,8 +3492,8 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { if (_ok && vm_index_resolve(&i, target->data.buffer.count)) { target->data.buffer.data[i] = val_s.d; } else { - if (!_ok) runtime_error(current_line, "index must be an integer, got %g", idx_s.d); - else runtime_error(current_line, "buffer index %d out of range (length %d)", + if (!_ok) rt_error(EK_VALUE, current_line, "index must be an integer, got %g", idx_s.d); + else rt_error(EK_INDEX, current_line, "buffer index %d out of range (length %d)", i, target->data.buffer.count); } g_vm.sp -= 2; /* keep val on TOS */ @@ -3503,8 +3515,8 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { target->data.list.items[i] = make_num(val_s.d); } } else { - if (!_ok) runtime_error(current_line, "index must be an integer, got %g", idx_s.d); - else runtime_error(current_line, "index %d out of range (list length %d)", + if (!_ok) rt_error(EK_VALUE, current_line, "index must be an integer, got %g", idx_s.d); + else rt_error(EK_INDEX, current_line, "index %d out of range (list length %d)", i, target->data.list.count); } g_vm.sp -= 2; @@ -3518,27 +3530,27 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { if (target->type == VAL_LIST && idx->type == VAL_NUM) { int i; if (!vm_index_is_int(idx->data.num, &i)) { - runtime_error(current_line, "index must be an integer, got %g", idx->data.num); + rt_error(EK_VALUE, current_line, "index must be an integer, got %g", idx->data.num); } else if (vm_index_resolve(&i, target->data.list.count)) { val_incref(val); val_decref(target->data.list.items[i]); target->data.list.items[i] = val; } else { - runtime_error(current_line, "index %d out of range (list length %d)", i, target->data.list.count); + rt_error(EK_INDEX, current_line, "index %d out of range (list length %d)", i, target->data.list.count); } } else if (target->type == VAL_BUFFER && idx->type == VAL_NUM) { int i; if (!vm_index_is_int(idx->data.num, &i)) { - runtime_error(current_line, "index must be an integer, got %g", idx->data.num); + rt_error(EK_VALUE, current_line, "index must be an integer, got %g", idx->data.num); } else if (!vm_index_resolve(&i, target->data.buffer.count)) { - runtime_error(current_line, "buffer index %d out of range (length %d)", i, target->data.buffer.count); + rt_error(EK_INDEX, current_line, "buffer index %d out of range (length %d)", i, target->data.buffer.count); } else if (val->type == VAL_NUM) { target->data.buffer.data[i] = val->data.num; } } else if (target->type == VAL_DICT && idx->type == VAL_STR) { dict_set(target, idx->data.str, val); } else if (target->type != VAL_NULL) { - runtime_error(current_line, "cannot index %s for assignment", val_type_name(target->type)); + rt_error(EK_TYPE, current_line, "cannot index %s for assignment", val_type_name(target->type)); } val_decref(target); val_decref(idx); val_incref(val); @@ -3568,7 +3580,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { DISPATCH(); } } else if (target->type != VAL_NULL) { - runtime_error(current_line, "cannot access field '%s' on %s", + rt_error(EK_TYPE, current_line, "cannot access field '%s' on %s", key, val_type_name(target->type)); } val_decref(target); @@ -3602,7 +3614,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { if (target->type == VAL_DICT) dict_set_cached(target, key, h, val); else if (target->type != VAL_NULL) - runtime_error(current_line, "cannot set field '%s' on %s", + rt_error(EK_TYPE, current_line, "cannot set field '%s' on %s", key, val_type_name(target->type)); val_decref(target); val_incref(val); @@ -3638,7 +3650,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { } } else if (target && target->type != VAL_NULL) { const char *key = chunk->const_interns[name_idx]; - runtime_error(current_line, "cannot access field '%s' on %s", + rt_error(EK_TYPE, current_line, "cannot access field '%s' on %s", key, val_type_name(target->type)); vm_push_slot(slot_null()); } else { @@ -3666,7 +3678,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { dict_set_cached(target, key, h, val); } else if (target && target->type != VAL_NULL) { const char *key = chunk->const_interns[name_idx]; - runtime_error(current_line, "cannot set field '%s' on %s", + rt_error(EK_TYPE, current_line, "cannot set field '%s' on %s", key, val_type_name(target->type)); } DISPATCH(); @@ -3687,7 +3699,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { if (i < target->data.buffer.count) { vm_push_slot(slot_from_num(target->data.buffer.data[i])); } else { - runtime_error(current_line, "buffer index %d out of range (length %d)", + rt_error(EK_INDEX, current_line, "buffer index %d out of range (length %d)", i, target->data.buffer.count); vm_push_slot(slot_null()); } @@ -3704,7 +3716,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { vm_push(item); } } else { - runtime_error(current_line, "index %d out of range (list length %d)", + rt_error(EK_INDEX, current_line, "index %d out of range (list length %d)", i, target->data.list.count); vm_push_slot(slot_null()); } @@ -3716,14 +3728,14 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { char buf[2] = { target->data.str[i], 0 }; vm_push(make_str(buf)); } else { - runtime_error(current_line, "string index %d out of range (length %d)", + rt_error(EK_INDEX, current_line, "string index %d out of range (length %d)", i, len); vm_push_slot(slot_null()); } DISPATCH(); } if (target->type != VAL_NULL) { - runtime_error(current_line, "cannot index %s", val_type_name(target->type)); + rt_error(EK_TYPE, current_line, "cannot index %s", val_type_name(target->type)); } } vm_push_slot(slot_null()); @@ -3760,15 +3772,15 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { } } else if (dict && dict->type != VAL_NULL) { const char *key = chunk->const_interns[name_idx]; - runtime_error(current_line, "cannot access field '%s' on %s", + rt_error(EK_TYPE, current_line, "cannot access field '%s' on %s", key, val_type_name(dict->type)); } } else { - runtime_error(current_line, "index %d out of range (list length %d)", + rt_error(EK_INDEX, current_line, "index %d out of range (list length %d)", i, target->data.list.count); } } else if (target && target->type != VAL_NULL) { - runtime_error(current_line, "cannot index %s", val_type_name(target->type)); + rt_error(EK_TYPE, current_line, "cannot index %s", val_type_name(target->type)); } vm_push_slot(slot_null()); DISPATCH(); @@ -3802,15 +3814,15 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { dict_set_cached(dict, key, h, val); } else if (dict && dict->type != VAL_NULL) { const char *key = chunk->const_interns[name_idx]; - runtime_error(current_line, "cannot assign field '%s' on %s", + rt_error(EK_TYPE, current_line, "cannot assign field '%s' on %s", key, val_type_name(dict->type)); } } else { - runtime_error(current_line, "index %d out of range (list length %d)", + rt_error(EK_INDEX, current_line, "index %d out of range (list length %d)", i, target->data.list.count); } } else if (target && target->type != VAL_NULL) { - runtime_error(current_line, "cannot index %s for assignment", + rt_error(EK_TYPE, current_line, "cannot index %s for assignment", val_type_name(target->type)); } DISPATCH(); @@ -3821,7 +3833,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { CASE(ITER_SETUP): { Value *iterable = vm_pop(); if (iterable && iterable->type != VAL_LIST && iterable->type != VAL_BUFFER) { - runtime_error(current_line, "'for' requires a list or buffer, got %s", + rt_error(EK_TYPE, current_line, "'for' requires a list or buffer, got %s", val_type_name(iterable->type)); } Value *state = make_iter_state(iterable); @@ -4009,7 +4021,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { int oidx = -1, odepth = 0; Env *oe = env_resolve_chain(frame->env, name, h, &oidx, &odepth); if (!oe) { - runtime_error(current_line, "undefined variable '%s'", name); + rt_error(EK_UNDEFINED_NAME, current_line, "undefined variable '%s'", name); vm_push_slot(slot_null()); DISPATCH(); } @@ -4048,7 +4060,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { int oidx = -1, odepth = 0; Env *oe = env_resolve_chain(frame->env, name, h, &oidx, &odepth); if (!oe) { - runtime_error(current_line, "undefined variable '%s'", name); + rt_error(EK_UNDEFINED_NAME, current_line, "undefined variable '%s'", name); vm_push_slot(slot_null()); DISPATCH(); } @@ -4263,7 +4275,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { EigsSlot tgt_s = g_vm.stack[g_vm.sp - 1]; g_vm.sp -= 1; if (!slot_is_ptr(tgt_s) || slot_as_ptr(tgt_s)->type != VAL_LIST) { - runtime_error(current_line, + rt_error(EK_TYPE, current_line, "destructuring requires a list, got %s", slot_is_ptr(tgt_s) ? val_type_name(slot_as_ptr(tgt_s)->type) : "number"); slot_decref(tgt_s); @@ -4272,7 +4284,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { } Value *lst = slot_as_ptr(tgt_s); if (lst->data.list.count != n) { - runtime_error(current_line, + rt_error(EK_VALUE, current_line, "destructuring expected list of length %d, got %d", n, lst->data.list.count); slot_decref(tgt_s); @@ -4303,7 +4315,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { g_vm.sp -= 3; if (!slot_is_ptr(tgt_s)) { - runtime_error(g_vm.current_line, "cannot slice number"); + rt_error(EK_TYPE, g_vm.current_line, "cannot slice number"); slot_decref(start_s); slot_decref(end_s); slot_decref(tgt_s); vm_push_slot(slot_null()); DISPATCH(); @@ -4315,7 +4327,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { case VAL_STR: len = (int)strlen(target->data.str); break; case VAL_BUFFER: len = target->data.buffer.count; break; default: - runtime_error(g_vm.current_line, "cannot slice %s", + rt_error(EK_TYPE, g_vm.current_line, "cannot slice %s", val_type_name(target->type)); slot_decref(start_s); slot_decref(end_s); slot_decref(tgt_s); vm_push_slot(slot_null()); @@ -4332,7 +4344,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { do { \ if (slot_is_num(slot)) { \ if (!vm_index_is_int(slot.d, &(outvar))) { \ - runtime_error(g_vm.current_line, \ + rt_error(EK_VALUE, g_vm.current_line, \ "slice bound must be an integer, got %g", slot.d);\ bound_err = 1; \ } \ @@ -4342,13 +4354,13 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { (outvar) = (defval); \ } else if (bv->type == VAL_NUM) { \ if (!vm_index_is_int(bv->data.num, &(outvar))) { \ - runtime_error(g_vm.current_line, \ + rt_error(EK_VALUE, g_vm.current_line, \ "slice bound must be an integer, got %g", \ bv->data.num); \ bound_err = 1; \ } \ } else { \ - runtime_error(g_vm.current_line, \ + rt_error(EK_VALUE, g_vm.current_line, \ "slice bound must be an integer or null, got %s", \ val_type_name(bv->type)); \ bound_err = 1; \ @@ -4371,7 +4383,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { if (start < 0) start += len; if (end < 0) end += len; if (start < 0 || start > len || end < 0 || end > len || start > end) { - runtime_error(g_vm.current_line, + rt_error(EK_INDEX, g_vm.current_line, "slice %d:%d out of range (length %d)", orig_start, orig_end, len); slot_decref(start_s); slot_decref(end_s); slot_decref(tgt_s); @@ -4460,7 +4472,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { int oidx = -1, odepth = 0; Env *oe = env_resolve_chain(frame->env, name, h, &oidx, &odepth); if (!oe) { - runtime_error(current_line, "undefined variable '%s'", name); + rt_error(EK_UNDEFINED_NAME, current_line, "undefined variable '%s'", name); vm_push_slot(slot_null()); DISPATCH(); } @@ -4582,7 +4594,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { if (!source) { /* Raise with the real cause: there is no filesystem here, * and the registered provider (if any) had no entry. */ - runtime_error(current_line, + rt_error(EK_IO, current_line, "import: module '%s' not found — no filesystem in the " "freestanding profile (no source-provider entry)", name); vm_push(make_null()); @@ -4614,7 +4626,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { snprintf(request, sizeof(request), "%.1024s.eigs", name); if (!resolve_eigenscript_file_from(resolve_base, request, path_buf, sizeof(path_buf))) { - runtime_error(current_line, "import: module '%s' not found " + rt_error(EK_IO, current_line, "import: module '%s' not found " "(tried lib/%s.eigs and %s.eigs)", name, name, name); vm_push(make_null()); DISPATCH(); @@ -4640,7 +4652,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { long src_size = 0; source = read_file_util(path_buf, &src_size); if (!source) { - runtime_error(current_line, "import: cannot read '%s'", name); + rt_error(EK_IO, current_line, "import: cannot read '%s'", name); vm_push(make_null()); DISPATCH(); } @@ -4658,7 +4670,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { free_tokenlist(&tl); free(source); env_decref(mod_env); - runtime_error(current_line, "import: parse errors in '%s'", name); + rt_error(EK_PARSE, current_line, "import: parse errors in '%s'", name); vm_push(make_null()); DISPATCH(); } @@ -4700,7 +4712,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { free_tokenlist(&tl); free(source); env_decref(mod_env); - runtime_error(current_line, "import: compile errors in '%s'", name); + rt_error(EK_PARSE, current_line, "import: compile errors in '%s'", name); vm_push(make_null()); DISPATCH(); } @@ -4794,7 +4806,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { key_d = slot_as_ptr(key_s)->data.num; } else { slot_decref(arg_s); slot_decref(key_s); slot_decref(table_s); - runtime_error(current_line, "dispatch: key must be a number"); + rt_error(EK_TYPE, current_line, "dispatch: key must be a number"); vm_push_slot(slot_null()); DISPATCH(); } @@ -4802,7 +4814,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { if (!slot_is_ptr(table_s)) { slot_decref(arg_s); slot_decref(table_s); - runtime_error(current_line, "dispatch: table must be a list"); + rt_error(EK_TYPE, current_line, "dispatch: table must be a list"); vm_push_slot(slot_null()); DISPATCH(); } @@ -4811,7 +4823,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { int key; if (!vm_index_is_int(key_d, &key)) { slot_decref(arg_s); slot_decref(table_s); - runtime_error(current_line, + rt_error(EK_VALUE, current_line, "dispatch key must be an integer, got %g", key_d); vm_push_slot(slot_null()); DISPATCH(); @@ -4819,7 +4831,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { if (table->type != VAL_LIST) { slot_decref(arg_s); slot_decref(table_s); - runtime_error(current_line, "dispatch: table must be a list"); + rt_error(EK_TYPE, current_line, "dispatch: table must be a list"); vm_push_slot(slot_null()); DISPATCH(); } @@ -4908,7 +4920,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { frame->ip = ip; if (g_vm.frame_count >= VM_FRAMES_MAX) { - runtime_error(current_line, "call stack overflow"); + rt_error(EK_LIMIT, current_line, "call stack overflow"); env_decref(call_env); vm_push(make_null()); DISPATCH(); @@ -4977,7 +4989,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { /* Anything else in-range is a populated-with-non-function bug in * the program — raise like builtin_dispatch (#353). */ slot_decref(arg_s); slot_decref(table_s); - runtime_error(current_line, "dispatch: slot %d is not a function", key); + rt_error(EK_TYPE, current_line, "dispatch: slot %d is not a function", key); vm_push_slot(slot_null()); DISPATCH(); } @@ -4989,7 +5001,7 @@ static Value *vm_run(EigsChunk *chunk, Env *env) { #ifndef __GNUC__ default: - runtime_error(current_line, "unknown opcode %d", ip[-1]); + rt_error(EK_INTERNAL, current_line, "unknown opcode %d", ip[-1]); vm_push(make_null()); break; }} /* end switch / for */ diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index 033751a..c84eb10 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -718,6 +718,51 @@ check_exit "EM18 caught error exits 0" 'try: x is undefined_thing catch e: print of "ok"' "0" + +# #406: catch binds {kind, message, line} for built-in runtime errors. +# check() compares stdout exactly; these run the program and grep stdout. +check_stdout() { + TOTAL=$((TOTAL + 1)) + local test_name="$1" + local script="$2" + local expected_substr="$3" + local tmpfile + tmpfile=$(mktemp /tmp/eigs_test_XXXXXX.eigs) + printf '%s\n' "$script" > "$tmpfile" + local outfile + outfile=$(mktemp /tmp/eigs_out_XXXXXX.txt) + ./eigenscript "$tmpfile" >"$outfile" 2>/dev/null || true + if grep -qF "$expected_substr" "$outfile"; then + echo " PASS: $test_name" + PASS=$((PASS + 1)) + else + echo " FAIL: $test_name (output: '$(cat "$outfile")', expected to contain '$expected_substr')" + FAIL=$((FAIL + 1)) + fi + rm -f "$tmpfile" "$outfile" +} + +check_stdout "EM26 catch binds kind from the closed set" 'try: + x is undefined_thing +catch e: + print of e.kind' "undefined_name" +check_stdout "EM27 catch message carries no Error-line frame" 'try: + v is [1,2][9] +catch e: + print of e.message' "index 9 out of range (list length 2)" +check_stdout "EM28 catch line is the failing source line" 'x is 1 +try: + y is [1] - 1 +catch e: + print of e.line' "3" +check_stdout "EM29 thrown string binds untouched (no dict wrap)" 'try: + throw of "boom" +catch e: + print of (type of e)' "str" +check_stdout "EM30 assert failure is catchable with kind assert" 'try: + assert of [1 == 2, "nope"] +catch e: + print of e.kind' "assert" echo "" # [17] Transformer smoke test — only runs if model extension compiled in. diff --git a/tests/test_bitwise.eigs b/tests/test_bitwise.eigs index 8ce1d79..83363ab 100644 --- a/tests/test_bitwise.eigs +++ b/tests/test_bitwise.eigs @@ -56,28 +56,28 @@ try: x is bit_and of ["abc", 5] catch err: caught_and is err -assert_true of [contains of [caught_and, "bit_and"], "bit_and string arg raises"] +assert_true of [contains of [caught_and.message, "bit_and"], "bit_and string arg raises"] caught_or is "" try: x is bit_or of [null, 5] catch err: caught_or is err -assert_true of [contains of [caught_or, "bit_or"], "bit_or null arg raises"] +assert_true of [contains of [caught_or.message, "bit_or"], "bit_or null arg raises"] caught_shl is "" try: x is bit_shl of [[1,2], 4] catch err: caught_shl is err -assert_true of [contains of [caught_shl, "bit_shl"], "bit_shl list arg raises"] +assert_true of [contains of [caught_shl.message, "bit_shl"], "bit_shl list arg raises"] caught_not is "" try: x is bit_not of "abc" catch err: caught_not is err -assert_true of [contains of [caught_not, "bit_not"], "bit_not string arg raises"] +assert_true of [contains of [caught_not.message, "bit_not"], "bit_not string arg raises"] # Operators: caught_op_and is "" @@ -85,21 +85,21 @@ try: x is "a" & 1 catch err: caught_op_and is err -assert_true of [contains of [caught_op_and, "cannot apply"], "& string operand raises"] +assert_true of [contains of [caught_op_and.message, "cannot apply"], "& string operand raises"] caught_op_shl is "" try: x is null << 2 catch err: caught_op_shl is err -assert_true of [contains of [caught_op_shl, "cannot apply"], "<< null operand raises"] +assert_true of [contains of [caught_op_shl.message, "cannot apply"], "<< null operand raises"] caught_op_not is "" try: x is ~null catch err: caught_op_not is err -assert_true of [contains of [caught_op_not, "cannot apply"], "~ null operand raises"] +assert_true of [contains of [caught_op_not.message, "cannot apply"], "~ null operand raises"] # --- int64 alignment: the bit_* builtins ARE the infix operators --- # (they used to be a divergent int32 implementation: UB past 2^31 and diff --git a/tests/test_builtin_errors.eigs b/tests/test_builtin_errors.eigs index d74c35f..fcd1f6c 100644 --- a/tests/test_builtin_errors.eigs +++ b/tests/test_builtin_errors.eigs @@ -22,7 +22,7 @@ catch e: c1 is 1 m1 is e assert of [c1 == 1, "BE01 set_observer_thresholds wrong arity caught"] -assert of [(contains of [m1, "set_observer_thresholds requires"]) == 1, "BE01 message"] +assert of [(contains of [m1.message, "set_observer_thresholds requires"]) == 1, "BE01 message"] # ---- set_observer_thresholds: non-positive threshold ---- c2 is 0 @@ -33,7 +33,7 @@ catch e: c2 is 1 m2 is e assert of [c2 == 1, "BE02 non-positive threshold caught"] -assert of [(contains of [m2, "must be positive"]) == 1, "BE02 message"] +assert of [(contains of [m2.message, "must be positive"]) == 1, "BE02 message"] # ---- set_observer_thresholds: dh_zero >= dh_small ---- c3 is 0 @@ -44,7 +44,7 @@ catch e: c3 is 1 m3 is e assert of [c3 == 1, "BE03 dh_zero>=dh_small caught"] -assert of [(contains of [m3, "dh_zero must be less than dh_small"]) == 1, "BE03 message"] +assert of [(contains of [m3.message, "dh_zero must be less than dh_small"]) == 1, "BE03 message"] # ---- load_file: non-string path ---- c4 is 0 @@ -55,7 +55,7 @@ catch e: c4 is 1 m4 is e assert of [c4 == 1, "BE04 load_file non-string caught"] -assert of [(contains of [m4, "load_file requires a string"]) == 1, "BE04 message"] +assert of [(contains of [m4.message, "load_file requires a string"]) == 1, "BE04 message"] # ---- fill: wrong argument shape ---- c5 is 0 @@ -66,7 +66,7 @@ catch e: c5 is 1 m5 is e assert of [c5 == 1, "BE05 fill wrong shape caught"] -assert of [(contains of [m5, "fill requires"]) == 1, "BE05 message"] +assert of [(contains of [m5.message, "fill requires"]) == 1, "BE05 message"] # ---- send: not a channel ---- c6 is 0 @@ -77,7 +77,7 @@ catch e: c6 is 1 m6 is e assert of [c6 == 1, "BE06 send invalid channel caught"] -assert of [(contains of [m6, "invalid channel"]) == 1, "BE06 message"] +assert of [(contains of [m6.message, "invalid channel"]) == 1, "BE06 message"] # ---- try_recv: not a channel ---- c7 is 0 @@ -88,7 +88,7 @@ catch e: c7 is 1 m7 is e assert of [c7 == 1, "BE07 try_recv invalid channel caught"] -assert of [(contains of [m7, "invalid channel"]) == 1, "BE07 message"] +assert of [(contains of [m7.message, "invalid channel"]) == 1, "BE07 message"] # ---- recv_timeout: not a channel ---- c8 is 0 @@ -99,7 +99,7 @@ catch e: c8 is 1 m8 is e assert of [c8 == 1, "BE08 recv_timeout invalid channel caught"] -assert of [(contains of [m8, "invalid channel"]) == 1, "BE08 message"] +assert of [(contains of [m8.message, "invalid channel"]) == 1, "BE08 message"] # ---- dispatch: wrong arity ---- c9 is 0 @@ -110,7 +110,7 @@ catch e: c9 is 1 m9 is e assert of [c9 == 1, "BE09 dispatch wrong arity caught"] -assert of [(contains of [m9, "dispatch requires"]) == 1, "BE09 message"] +assert of [(contains of [m9.message, "dispatch requires"]) == 1, "BE09 message"] # ---- dispatch: table is not a list (variable arg to avoid list spread) ---- c10 is 0 @@ -122,7 +122,7 @@ catch e: c10 is 1 m10 is e assert of [c10 == 1, "BE10 dispatch table-not-list caught"] -assert of [(contains of [m10, "table must be a list"]) == 1, "BE10 message"] +assert of [(contains of [m10.message, "table must be a list"]) == 1, "BE10 message"] # ---- dispatch: slot is not a function ---- c11 is 0 @@ -135,7 +135,7 @@ catch e: c11 is 1 m11 is e assert of [c11 == 1, "BE11 dispatch slot-not-fn caught"] -assert of [(contains of [m11, "is not a function"]) == 1, "BE11 message"] +assert of [(contains of [m11.message, "is not a function"]) == 1, "BE11 message"] # ---- dispatch: key must be a number (builtin path; used to reinterpret ---- # ---- the value's union bits as a double — #353 parity fix) ---- @@ -148,7 +148,7 @@ catch e: c11b is 1 m11b is e assert of [c11b == 1, "BE11b dispatch non-number key caught"] -assert of [(contains of [m11b, "key must be a number"]) == 1, "BE11b message"] +assert of [(contains of [m11b.message, "key must be a number"]) == 1, "BE11b message"] # ---- dispatch: fractional key raises on the builtin path too (#353) ---- c11c is 0 @@ -160,7 +160,7 @@ catch e: c11c is 1 m11c is e assert of [c11c == 1, "BE11c dispatch fractional key caught"] -assert of [(contains of [m11c, "integer"]) == 1, "BE11c message"] +assert of [(contains of [m11c.message, "integer"]) == 1, "BE11c message"] # ---- sort: list of records raises instead of silently no-oping (#368) ---- c11d is 0 @@ -172,7 +172,7 @@ catch e: c11d is 1 m11d is e assert of [c11d == 1, "BE11d sort on records caught"] -assert of [(contains of [m11d, "use sort_by"]) == 1, "BE11d message"] +assert of [(contains of [m11d.message, "use sort_by"]) == 1, "BE11d message"] # ---- sort: mixed numbers and strings raises ---- c11e is 0 @@ -184,7 +184,7 @@ catch e: c11e is 1 m11e is e assert of [c11e == 1, "BE11e sort on mixed types caught"] -assert of [(contains of [m11e, "element 1 is"]) == 1, "BE11e message"] +assert of [(contains of [m11e.message, "element 1 is"]) == 1, "BE11e message"] # ---- sort: strings now actually sort (was a silent no-op, #368) ---- strs is ["cherry", "apple", "banana"] @@ -212,7 +212,7 @@ catch e: c_hex1 is 1 m_hex1 is e assert of [c_hex1 == 1, "hex negative raises"] -assert of [(contains of [m_hex1, "non-negative integer"]) == 1, "hex negative message"] +assert of [(contains of [m_hex1.message, "non-negative integer"]) == 1, "hex negative message"] c_hex2 is 0 try: hex of 1.5 @@ -237,8 +237,8 @@ catch e: c_chr1 is 1 m_chr1 is e assert of [c_chr1 == 1, "chr above 255 raises"] -assert of [(contains of [m_chr1, "1..255"]) == 1, "chr range message"] -assert of [(contains of [m_chr1, "utf8_encode"]) == 1, "chr message points at utf8_encode"] +assert of [(contains of [m_chr1.message, "1..255"]) == 1, "chr range message"] +assert of [(contains of [m_chr1.message, "utf8_encode"]) == 1, "chr message points at utf8_encode"] c_chr2 is 0 try: chr of (0 - 1) @@ -259,7 +259,7 @@ catch e: c_chr4 is 1 m_chr4 is e assert of [c_chr4 == 1, "chr of 0 raises (NUL unrepresentable)"] -assert of [(contains of [m_chr4, "NUL"]) == 1, "chr NUL message"] +assert of [(contains of [m_chr4.message, "NUL"]) == 1, "chr NUL message"] c_chr5 is 0 try: chr of "A" @@ -276,7 +276,7 @@ catch e: c12 is 1 m12 is e assert of [c12 == 1, "BE12 nearest_in_range wrong shape caught"] -assert of [(contains of [m12, "nearest_in_range requires"]) == 1, "BE12 message"] +assert of [(contains of [m12.message, "nearest_in_range requires"]) == 1, "BE12 message"] # ---- nearest_in_range_all: wrong argument shape ---- c13 is 0 @@ -287,6 +287,6 @@ catch e: c13 is 1 m13 is e assert of [c13 == 1, "BE13 nearest_in_range_all wrong shape caught"] -assert of [(contains of [m13, "nearest_in_range_all requires"]) == 1, "BE13 message"] +assert of [(contains of [m13.message, "nearest_in_range_all requires"]) == 1, "BE13 message"] print of "All builtin_errors tests passed" diff --git a/tests/test_db.eigs b/tests/test_db.eigs index 97258d6..3dab7f8 100644 --- a/tests/test_db.eigs +++ b/tests/test_db.eigs @@ -81,7 +81,7 @@ if (contains of [conn, "connected"]) == 1: c16a is 1 m16a is e assert of [c16a == 1, "DB16 17-param query raises"] - assert of [(contains of [m16a, "too many parameters"]) == 1, "DB16 message names the cap"] + assert of [(contains of [m16a.message, "too many parameters"]) == 1, "DB16 message names the cap"] qbad is ["SELECT $1::text", [1, 2]] c17a is 0 @@ -92,7 +92,7 @@ if (contains of [conn, "connected"]) == 1: c17a is 1 m17a is e assert of [c17a == 1, "DB17 list param raises"] - assert of [(contains of [m17a, "not a string or number"]) == 1, "DB17 message names the type rule"] + assert of [(contains of [m17a.message, "not a string or number"]) == 1, "DB17 message names the type rule"] db_execute of "DROP TABLE eigs_ci_smoke" else: diff --git a/tests/test_dispatch.eigs b/tests/test_dispatch.eigs index 02340fa..383a64c 100644 --- a/tests/test_dispatch.eigs +++ b/tests/test_dispatch.eigs @@ -60,7 +60,7 @@ try: x is dispatch of [t, 1.5, null] catch e: caught_frac is e -check of ["fractional key raises", contains of [caught_frac, "integer"]] +check of ["fractional key raises", contains of [caught_frac.message, "integer"]] # Out-of-range key still silently nulls (deliberate — sparse tables; both # implementations agree, see builtin_dispatch). @@ -76,7 +76,7 @@ try: x is dispatch of [42, 0, 7] catch e: caught_tbl is e -check of ["non-list table raises", contains of [caught_tbl, "table must be a list"]] +check of ["non-list table raises", contains of [caught_tbl.message, "table must be a list"]] # In-range slot holding a non-callable raises (was a silent null). nums is [42, 7] @@ -85,7 +85,7 @@ try: x is dispatch of [nums, 0, 99] catch e: caught_slot is e -check of ["non-callable slot raises", contains of [caught_slot, "is not a function"]] +check of ["non-callable slot raises", contains of [caught_slot.message, "is not a function"]] # A null slot (hole in the table) stays a silent null on both paths. holes is [null, f1] @@ -97,7 +97,7 @@ try: x is dispatch of [t, "k", null] catch e: caught_key is e -check of ["non-number key raises", contains of [caught_key, "key must be a number"]] +check of ["non-number key raises", contains of [caught_key.message, "key must be a number"]] # Literal (OP_DISPATCH) and variable (builtin) forms agree on the error. vargs is [42, 0, 7] @@ -106,7 +106,7 @@ try: x is dispatch of vargs catch e: caught_var is e -check of ["literal/variable forms agree", contains of [caught_var, "table must be a list"]] +check of ["literal/variable forms agree", contains of [caught_var.message, "table must be a list"]] # #459 facet 3: the parenthesized #355 form is ONE argument on the normal # call path (never OP_DISPATCH); builtin_dispatch given a single 3-element diff --git a/tests/test_error_extra.eigs b/tests/test_error_extra.eigs index 619f00a..7943475 100644 --- a/tests/test_error_extra.eigs +++ b/tests/test_error_extra.eigs @@ -14,7 +14,7 @@ catch e: caught1 is 1 msg1 is e assert of [caught1 == 1, "EE01 try/catch caught undefined variable"] -assert of [(contains of [msg1, "undefined variable"]) == 1, "EE01 message mentions 'undefined variable'"] +assert of [(contains of [msg1.message, "undefined variable"]) == 1, "EE01 message mentions 'undefined variable'"] # ---- try/catch around an index-out-of-range ---- caught2 is 0 @@ -26,7 +26,7 @@ catch e: caught2 is 1 msg2 is e assert of [caught2 == 1, "EE02 try/catch caught out-of-range index"] -assert of [(contains of [msg2, "out of range"]) == 1, "EE02 message mentions 'out of range'"] +assert of [(contains of [msg2.message, "out of range"]) == 1, "EE02 message mentions 'out of range'"] # ---- try/catch around indexing a number ---- caught3 is 0 @@ -38,7 +38,7 @@ catch e: caught3 is 1 msg3 is e assert of [caught3 == 1, "EE03 try/catch caught index-on-num"] -assert of [(contains of [msg3, "cannot index"]) == 1, "EE03 message mentions 'cannot index'"] +assert of [(contains of [msg3.message, "cannot index"]) == 1, "EE03 message mentions 'cannot index'"] # ---- try/catch around calling a non-function ---- caught4 is 0 @@ -142,7 +142,7 @@ catch e: caught13 is 1 msg13 is e assert of [caught13 == 1, "EE13 caught non-integer list index (read)"] -assert of [(contains of [msg13, "index must be an integer"]) == 1, "EE13 message mentions integer index"] +assert of [(contains of [msg13.message, "index must be an integer"]) == 1, "EE13 message mentions integer index"] # ---- list index out of range (assignment) ---- caught14 is 0 @@ -154,7 +154,7 @@ catch e: caught14 is 1 msg14 is e assert of [caught14 == 1, "EE14 caught out-of-range list index (assignment)"] -assert of [(contains of [msg14, "out of range"]) == 1, "EE14 message mentions 'out of range'"] +assert of [(contains of [msg14.message, "out of range"]) == 1, "EE14 message mentions 'out of range'"] # ---- non-integer index (assignment) ---- caught15 is 0 @@ -166,7 +166,7 @@ catch e: caught15 is 1 msg15 is e assert of [caught15 == 1, "EE15 caught non-integer list index (assignment)"] -assert of [(contains of [msg15, "index must be an integer"]) == 1, "EE15 message mentions integer index"] +assert of [(contains of [msg15.message, "index must be an integer"]) == 1, "EE15 message mentions integer index"] # ---- set a field on a non-dict ---- caught16 is 0 @@ -178,7 +178,7 @@ catch e: caught16 is 1 msg16 is e assert of [caught16 == 1, "EE16 caught field-set on a number"] -assert of [(contains of [msg16, "cannot set field"]) == 1, "EE16 message mentions 'cannot set field'"] +assert of [(contains of [msg16.message, "cannot set field"]) == 1, "EE16 message mentions 'cannot set field'"] # ---- index a non-indexable for assignment ---- caught17 is 0 @@ -190,7 +190,7 @@ catch e: caught17 is 1 msg17 is e assert of [caught17 == 1, "EE17 caught index-assignment on a number"] -assert of [(contains of [msg17, "cannot index"]) == 1, "EE17 message mentions 'cannot index'"] +assert of [(contains of [msg17.message, "cannot index"]) == 1, "EE17 message mentions 'cannot index'"] # ---- dict indexed by a non-string key (read) ---- caught18 is 0 @@ -202,7 +202,7 @@ catch e: caught18 is 1 msg18 is e assert of [caught18 == 1, "EE18 caught dict indexed by number (read)"] -assert of [(contains of [msg18, "cannot index"]) == 1, "EE18 message mentions 'cannot index'"] +assert of [(contains of [msg18.message, "cannot index"]) == 1, "EE18 message mentions 'cannot index'"] # ---- dict indexed by a non-string key (assignment) ---- caught19 is 0 @@ -214,7 +214,7 @@ catch e: caught19 is 1 msg19 is e assert of [caught19 == 1, "EE19 caught dict indexed by number (assignment)"] -assert of [(contains of [msg19, "cannot index"]) == 1, "EE19 message mentions 'cannot index'"] +assert of [(contains of [msg19.message, "cannot index"]) == 1, "EE19 message mentions 'cannot index'"] # ---- '+' on incompatible types ---- caught20 is 0 @@ -225,7 +225,7 @@ catch e: caught20 is 1 msg20 is e assert of [caught20 == 1, "EE20 caught '+' on dict and num"] -assert of [(contains of [msg20, "cannot apply '+'"]) == 1, "EE20 message mentions cannot apply '+'"] +assert of [(contains of [msg20.message, "cannot apply '+'"]) == 1, "EE20 message mentions cannot apply '+'"] # ---- '/' on incompatible types ---- caught21 is 0 @@ -236,7 +236,7 @@ catch e: caught21 is 1 msg21 is e assert of [caught21 == 1, "EE21 caught '/' on list and num"] -assert of [(contains of [msg21, "cannot apply '/'"]) == 1, "EE21 message mentions cannot apply '/'"] +assert of [(contains of [msg21.message, "cannot apply '/'"]) == 1, "EE21 message mentions cannot apply '/'"] # ---- '%' on incompatible types ---- caught22 is 0 @@ -247,7 +247,7 @@ catch e: caught22 is 1 msg22 is e assert of [caught22 == 1, "EE22 caught '%' on list and num"] -assert of [(contains of [msg22, "cannot apply"]) == 1, "EE22 message mentions cannot apply"] +assert of [(contains of [msg22.message, "cannot apply"]) == 1, "EE22 message mentions cannot apply"] # ---- buffer index out of range ---- caught23 is 0 @@ -259,7 +259,7 @@ catch e: caught23 is 1 msg23 is e assert of [caught23 == 1, "EE23 caught buffer index out of range"] -assert of [(contains of [msg23, "out of range"]) == 1, "EE23 message mentions 'out of range'"] +assert of [(contains of [msg23.message, "out of range"]) == 1, "EE23 message mentions 'out of range'"] # ---- buffer non-integer index ---- caught24 is 0 @@ -271,6 +271,6 @@ catch e: caught24 is 1 msg24 is e assert of [caught24 == 1, "EE24 caught non-integer buffer index"] -assert of [(contains of [msg24, "index must be an integer"]) == 1, "EE24 message mentions integer index"] +assert of [(contains of [msg24.message, "index must be an integer"]) == 1, "EE24 message mentions integer index"] print of "All error_extra tests passed" diff --git a/tests/test_error_propagation.eigs b/tests/test_error_propagation.eigs index b737a13..19feb59 100644 --- a/tests/test_error_propagation.eigs +++ b/tests/test_error_propagation.eigs @@ -80,7 +80,7 @@ try: bad_index of null catch e: caught_rt is e -assert of [contains of [caught_rt, "out of range"], "EP7 runtime error propagates through call"] +assert of [contains of [caught_rt.message, "out of range"], "EP7 runtime error propagates through call"] # --- Try/catch in loop — each iteration independent --- results is [] diff --git a/tests/test_http.eigs b/tests/test_http.eigs index beaa318..49fd7d8 100644 --- a/tests/test_http.eigs +++ b/tests/test_http.eigs @@ -21,7 +21,7 @@ catch e: c3 is 1 m3 is e assert of [c3 == 1, "HTTP03 http_route non-list arg raises"] -assert of [(contains of [m3, "http_route requires"]) == 1, "HTTP03 message"] +assert of [(contains of [m3.message, "http_route requires"]) == 1, "HTTP03 message"] c4 is 0 try: diff --git a/tests/test_import.eigs b/tests/test_import.eigs index b4c03eb..5e10c3b 100644 --- a/tests/test_import.eigs +++ b/tests/test_import.eigs @@ -64,7 +64,7 @@ try: import surely_does_not_exist_zz catch e: import_err is e -assert_true of [contains of [import_err, "not found"], "missing module raises"] -assert_true of [contains of [import_err, "surely_does_not_exist_zz.eigs"], "error names tried path"] +assert_true of [contains of [import_err.message, "not found"], "missing module raises"] +assert_true of [contains of [import_err.message, "surely_does_not_exist_zz.eigs"], "error names tried path"] test_summary of null diff --git a/tests/test_import_errors.eigs b/tests/test_import_errors.eigs index b779dc4..2917fb7 100644 --- a/tests/test_import_errors.eigs +++ b/tests/test_import_errors.eigs @@ -10,7 +10,7 @@ try: import _nonexistent_module_xyz_42 catch e: err1 is e -assert_true of [contains of [err1, "not found"], "import nonexistent triggers 'not found' error"] +assert_true of [contains of [err1.message, "not found"], "import nonexistent triggers 'not found' error"] # ---- Test 2: Import module with parse errors ---- # Write a file with invalid syntax into script_dir/../lib/ which is where @@ -24,7 +24,7 @@ catch e: err2 is e rm of bad_path assert_true of [(len of err2) > 0, "import parse-error triggers error"] -assert_true of [contains of [err2, "parse errors"], "import parse-error message contains 'parse errors'"] +assert_true of [contains of [err2.message, "parse errors"], "import parse-error message contains 'parse errors'"] # ---- Test 3: Import via eval — nonexistent ---- # Confirms the same not-found path works through eval indirection @@ -33,7 +33,7 @@ try: eval of "import _also_nonexistent_999" catch e: err3 is e -assert_true of [contains of [err3, "not found"], "eval import nonexistent caught"] +assert_true of [contains of [err3.message, "not found"], "eval import nonexistent caught"] # ---- Test 4: Import valid module works (sanity check) ---- import math @@ -60,7 +60,7 @@ catch e: err_lf is e rm of bad_lf_path assert_true of [(len of err_lf) > 0, "load_file parse-error raises (not a silent no-op)"] -assert_true of [contains of [err_lf, "parse error"], "load_file parse-error message contains 'parse error'"] +assert_true of [contains of [err_lf.message, "parse error"], "load_file parse-error message contains 'parse error'"] # ---- Test 7: a clean load_file is unaffected ---- ok_lf_path is "../lib/_test_ok_loadfile_tmp.eigs" diff --git a/tests/test_store_corruption.eigs b/tests/test_store_corruption.eigs index 2312426..4502f0c 100644 --- a/tests/test_store_corruption.eigs +++ b/tests/test_store_corruption.eigs @@ -38,7 +38,7 @@ catch e: cm is 1 mm is e assert of [cm == 1, "SC01 corrupt magic rejected"] -assert of [(contains of [mm, "invalid database file"]) == 1, "SC01 message"] +assert of [(contains of [mm.message, "invalid database file"]) == 1, "SC01 message"] # ---- corrupt version (version != 1) ---- hv is base_hdr of 1 @@ -52,7 +52,7 @@ catch e: cv is 1 mv is e assert of [cv == 1, "SC02 corrupt version rejected"] -assert of [(contains of [mv, "invalid database file"]) == 1, "SC02 message"] +assert of [(contains of [mv.message, "invalid database file"]) == 1, "SC02 message"] # ---- corrupt page_size (4097 != 4096) ---- hp is base_hdr of 1 @@ -66,7 +66,7 @@ catch e: cp is 1 mp is e assert of [cp == 1, "SC03 corrupt page_size rejected"] -assert of [(contains of [mp, "invalid database file"]) == 1, "SC03 message"] +assert of [(contains of [mp.message, "invalid database file"]) == 1, "SC03 message"] # ---- not page-aligned (header + 10 stray bytes) ---- ha is base_hdr of 1 @@ -83,7 +83,7 @@ catch e: ca is 1 ma is e assert of [ca == 1, "SC04 non-page-aligned file rejected"] -assert of [(contains of [ma, "invalid database file"]) == 1, "SC04 message"] +assert of [(contains of [ma.message, "invalid database file"]) == 1, "SC04 message"] # ---- page_count mismatch (header claims 5, file has 1 page) ---- hpc is base_hdr of 5 @@ -100,7 +100,7 @@ catch e: cpc is 1 mpc is e assert of [cpc == 1, "SC05 page_count mismatch rejected"] -assert of [(contains of [mpc, "invalid database file"]) == 1, "SC05 message"] +assert of [(contains of [mpc.message, "invalid database file"]) == 1, "SC05 message"] # ---- store_put: record larger than a page ---- db is store_open of "/tmp/eigs_corrupt_put.db" @@ -115,7 +115,7 @@ catch e: cput is 1 mput is e assert of [cput == 1, "SC06 oversized record rejected"] -assert of [(contains of [mput, "exceeds page size"]) == 1, "SC06 message"] +assert of [(contains of [mput.message, "exceeds page size"]) == 1, "SC06 message"] store_close of db print of "All store_corruption tests passed" diff --git a/tests/test_trycatch.eigs b/tests/test_trycatch.eigs index cbfdfb3..8eb55dd 100644 --- a/tests/test_trycatch.eigs +++ b/tests/test_trycatch.eigs @@ -8,7 +8,7 @@ try: x is items[10] catch err: caught1 is err -assert_true of [contains of [caught1, "index"], "catch index error"] +assert_true of [contains of [caught1.message, "index"], "catch index error"] # Catch undefined variable caught2 is "" @@ -16,7 +16,7 @@ try: x is nonexistent_var # lint: allow E003 -- deliberately undefined (tests the runtime error) catch err: caught2 is err -assert_true of [contains of [caught2, "undefined"], "catch undefined var"] +assert_true of [contains of [caught2.message, "undefined"], "catch undefined var"] # Catch type error caught3 is "" @@ -24,7 +24,7 @@ try: x is [1, 2] - 5 catch err: caught3 is err -assert_true of [contains of [caught3, "cannot apply"], "catch type error"] +assert_true of [contains of [caught3.message, "cannot apply"], "catch type error"] # No error — catch body should not run ran_catch is 0 @@ -74,21 +74,21 @@ try: x is nums[1.5] catch e: caught_frac is e -assert_true of [contains of [caught_frac, "integer"], "fractional list index raises"] +assert_true of [contains of [caught_frac.message, "integer"], "fractional list index raises"] caught_sfrac is "" try: x is "abc"[1.5] catch e: caught_sfrac is e -assert_true of [contains of [caught_sfrac, "integer"], "fractional string index raises"] +assert_true of [contains of [caught_sfrac.message, "integer"], "fractional string index raises"] caught_setfrac is "" try: nums[0.5] is 99 catch e: caught_setfrac is e -assert_true of [contains of [caught_setfrac, "integer"], "fractional index assignment raises"] +assert_true of [contains of [caught_setfrac.message, "integer"], "fractional index assignment raises"] # floor is the explicit escape hatch for division-derived indices assert_eq of [nums[floor of (5 / 2)], 30, "floor of (5/2) indexes element 2"] @@ -133,7 +133,8 @@ catch e2: assert_eq of [rethrown.kind, "inner", "rethrow preserves structure"] # A runtime error after a structured throw supersedes the stash: -# catch must bind the runtime error's message string, not the old dict. +# catch must bind the runtime error's {kind, message, line} dict, not +# the old thrown dict (#406). superseded is null try: try: @@ -142,7 +143,7 @@ try: x is undefined_variable_here # lint: allow E003 -- deliberately undefined (tests the runtime error) catch e3: superseded is e3 -assert_eq of [type of superseded, "str", "runtime error supersedes stale value"] -assert_true of [contains of [superseded, "undefined variable"], "superseding message intact"] +assert_eq of [superseded.kind, "undefined_name", "runtime error supersedes stale value"] +assert_true of [contains of [superseded.message, "undefined variable"], "superseding message intact"] test_summary of null From 48a091ae4048a0e2490541a91c276d2d772e410e Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Tue, 7 Jul 2026 04:47:29 -0500 Subject: [PATCH 2/2] docs: ROADMAP tick #406 (structured errors), link follow-up #469 Co-Authored-By: Claude Fable 5 --- ROADMAP.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c2b9b00..49c73b6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -63,8 +63,11 @@ observer/deterministic-replay niche instead of diluting it.** Fully executed: shipped in 0.27.0 with lint `W017` as the migration audit; the ouroboros `frontend.eigs` parity leg landed at the v0.27.0 pin bump (ouroboros PR #68) -- [ ] Structured runtime errors `{kind, message, line}` with a closed - kind set ([#406](https://github.com/InauguralSystems/EigenScript/issues/406)) +- [x] Structured runtime errors `{kind, message, line}` with a closed + kind set ([#406](https://github.com/InauguralSystems/EigenScript/issues/406)): + 11 kinds (no `arity` — calls pad by design), user throws bind + untouched, uncaught output byte-unchanged; kind-typo lint is the + follow-up ([#469](https://github.com/InauguralSystems/EigenScript/issues/469)) - [~] Column tracking + caret/span diagnostics — parse errors carry line:col (human + `--lint --json` E002 + LSP range) AND print a source excerpt + caret (increment 2); per-warning spans and