diff --git a/CHANGELOG.md b/CHANGELOG.md index e36ffc3..5765630 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to EigenScript are documented here. ## [Unreleased] ### Added +- **Runtime-error carets + token-precise LSP ranges (#407 residual).** + Uncaught runtime errors now print the same one-line source excerpt + + `^` caret as parse errors, with the column attributed to the failing + token (the `[` of an out-of-range subscript, the operator of a type + mismatch, the undefined identifier, the `of` of a bad call). Mechanism: + a per-byte `cols[]` table beside the chunk's `lines[]` (stamped by + `compile_node`'s save/set/restore position cursor — zero dispatch-loop + cost, no OP_LINE/tape change), a refcounted per-unit source blob shared + by nested function chunks (so REPL lines, eval'd strings, and imported + modules all excerpt their own source), and uncaught-error printing + deferred from raise time to the dispatch loop's `CHECK_ERROR`, which + knows the failing instruction's offset. A `lines[off] == error_line` + guard suppresses the caret whenever the position can't be attributed + confidently (e.g. JIT-advanced ip), so a caret never points at the + wrong line. The `Error line N:` message and ` at fn (line N)` trace + lines are byte-unchanged. LSP diagnostics are now token-precise: + E002 (parse) and E003 (undefined name) squiggle exactly the offending + token instead of the whole line (0..1000 span remains only as the + no-position fallback). `examples/errors/*` pin runtime carets via a + new `# expect-caret:` directive; `tests/test_lsp.py` pins the ranges. - **The observer pair: value-channel raw-step signals (#422) + trajectory snapshots across call boundaries (#421).** - *#422*: relative normalization (`Δv/(1+|v|)`) erased exactly two diff --git a/docs/DIAGNOSTICS.md b/docs/DIAGNOSTICS.md index f9c2a09..3c7db6d 100644 --- a/docs/DIAGNOSTICS.md +++ b/docs/DIAGNOSTICS.md @@ -23,17 +23,27 @@ callers, Makefiles, and CI. ## Stack traces -An uncaught runtime error (or uncaught `throw`) prints the call stack -to stderr after the error line — every frame between the failure and -the top level, innermost first: +An uncaught runtime error (or uncaught `throw`) prints, to stderr: the +error line, a one-line source excerpt with a `^` caret under the +offending column (#407 residual — same format as parse errors), then +the call stack — every frame between the failure and the top level, +innermost first: ``` Error line 6: index 99 out of range (list length 2) + 6 | v is items[99] + | ^ at inner (line 6) at middle (line 8) at (line 9) ``` +The excerpt block is additive: the `Error line N: ...` message and the +` at fn (line N)` trace lines are byte-unchanged, so tools that match +on them keep working. The caret is suppressed (message + trace only) +when the failing position can't be attributed confidently — e.g. inside +JIT-compiled loops or when the unit's source isn't available. + Caught errors print nothing; the handler decides. ## What `catch` binds @@ -120,6 +130,8 @@ print of "reached" $ eigenscript type_err.eigs Error line 2: cannot apply '-' to list and num + 2 | y is x - 5 + | ^ at (line 2) $ echo $? 1 @@ -402,7 +414,10 @@ in the message (see "Runtime Error Types"). The LSP server (`make lsp` → `src/eigenlsp`) surfaces the first syntax or parse error of each open document as a -`textDocument/publishDiagnostics` squiggle at the failing line -(`tests/test_lsp.py` pins the protocol behavior). For batch static -checks, `eigenscript --lint file.eigs` runs the linter and -`eigenscript --fmt` the formatter. +`textDocument/publishDiagnostics` squiggle (`tests/test_lsp.py` pins the +protocol behavior). Ranges are token-precise (#407 residual): parse +errors (E002) and undefined-name errors (E003) squiggle exactly the +offending token (`start`/`end` from the token's column and length); +diagnostics without a tracked position fall back to a whole-line range. +For batch static checks, `eigenscript --lint file.eigs` runs the linter +and `eigenscript --fmt` the formatter. diff --git a/docs/SPEC.md b/docs/SPEC.md index 1806665..dd3a176 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -902,13 +902,16 @@ validation -5 ``` -An *uncaught* error prints the error followed by a stack trace — -every frame between the failure and the top level, innermost first — -then exits with code 1: +An *uncaught* error prints the error, a one-line source excerpt with a +`^` caret under the offending column, and a stack trace — every frame +between the failure and the top level, innermost first — then exits +with code 1: ```eigenscript skip # uncaught: stderr shows # Error line 6: index 99 out of range (list length 2) +# 6 | v is items[99] +# | ^ # at inner (line 6) # at middle (line 8) # at (line 9) diff --git a/examples/errors/calling_a_number.eigs b/examples/errors/calling_a_number.eigs index 47028d6..3ba589d 100644 --- a/examples/errors/calling_a_number.eigs +++ b/examples/errors/calling_a_number.eigs @@ -1,5 +1,6 @@ # ERROR DEMO — applying `of` to a non-function. # Only fn and builtin values are callable. -# expect-error: Error line 5: cannot call num +# expect-error: Error line 6: cannot call num +# expect-caret: | ^ x is 5 y is x of 10 diff --git a/examples/errors/index_out_of_range.eigs b/examples/errors/index_out_of_range.eigs index e2202c4..77d34d7 100644 --- a/examples/errors/index_out_of_range.eigs +++ b/examples/errors/index_out_of_range.eigs @@ -1,6 +1,7 @@ # ERROR DEMO — indexing past the end of a list. # Indexing is strictly bounds-checked (negative indices count from the # end, but only within -len..len-1). -# expect-error: Error line 6: index 10 out of range (list length 3) +# expect-error: Error line 7: index 10 out of range (list length 3) +# expect-caret: | ^ items is [1, 2, 3] v is items[10] diff --git a/examples/errors/string_plus_number.eigs b/examples/errors/string_plus_number.eigs index dc691bb..8bac7cc 100644 --- a/examples/errors/string_plus_number.eigs +++ b/examples/errors/string_plus_number.eigs @@ -2,4 +2,5 @@ # `+` requires both operands to be the same kind; convert explicitly # with `str of n` or `num of s`. # expect-error: cannot apply '+' to str and num +# expect-caret: | ^ msg is "count: " + 3 diff --git a/examples/errors/type_mismatch.eigs b/examples/errors/type_mismatch.eigs index 30e93dc..225bd43 100644 --- a/examples/errors/type_mismatch.eigs +++ b/examples/errors/type_mismatch.eigs @@ -1,4 +1,5 @@ # ERROR DEMO — arithmetic on incompatible types. # There is no implicit coercion: list - num has no meaning. -# expect-error: Error line 4: cannot apply '-' to list and num +# expect-error: Error line 5: cannot apply '-' to list and num +# expect-caret: | ^ x is [1, 2] - 5 diff --git a/examples/errors/undefined_variable.eigs b/examples/errors/undefined_variable.eigs index e68ed13..3d45e79 100644 --- a/examples/errors/undefined_variable.eigs +++ b/examples/errors/undefined_variable.eigs @@ -1,4 +1,5 @@ # ERROR DEMO — reading a name that was never assigned. # Variables must be assigned before use; there is no implicit null. -# expect-error: Error line 4: undefined variable 'total' +# expect-error: Error line 5: undefined variable 'total' +# expect-caret: | ^ print of total diff --git a/fuzz/fuzz_eigenscript.c b/fuzz/fuzz_eigenscript.c index 015edce..c6ffabd 100644 --- a/fuzz/fuzz_eigenscript.c +++ b/fuzz/fuzz_eigenscript.c @@ -63,7 +63,7 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { /* Execute in a fresh child env so global bindings from one * input don't shadow builtins on the next. */ Env *eval_env = env_new(g_global_env); - EigsChunk *chunk = compile_ast(ast, eval_env); + EigsChunk *chunk = compile_ast(ast, eval_env, source); if (chunk) { Value *result = vm_execute(chunk, eval_env); if (result) val_decref(result); diff --git a/fuzz/fuzz_stdin.c b/fuzz/fuzz_stdin.c index a9607b0..066cd02 100644 --- a/fuzz/fuzz_stdin.c +++ b/fuzz/fuzz_stdin.c @@ -61,7 +61,7 @@ int main(void) { if (g_parse_errors == 0) { ASTNode *ast = parse(&tl); if (g_parse_errors == 0 && ast) { - EigsChunk *chunk = compile_ast(ast, global); + EigsChunk *chunk = compile_ast(ast, global, buf); if (chunk) { Value *result = vm_execute(chunk, global); if (result) val_decref(result); diff --git a/src/builtins.c b/src/builtins.c index 91568d5..859a26d 100644 --- a/src/builtins.c +++ b/src/builtins.c @@ -641,8 +641,14 @@ Value* builtin_throw(Value *arg) { g_error_value = arg; } if (g_try_depth == 0) { - fprintf(stderr, "%s\n", g_error_msg); - vm_print_stack_trace(stderr); + /* #407 residual: defer to CHECK_ERROR for the caret excerpt when + * the VM is live — mirrors rt_error. */ + if (eigs_current && eigs_current->vm && g_vm.frame_count > 0) { + g_error_print_pending = 1; + } else { + fprintf(stderr, "%s\n", g_error_msg); + vm_print_stack_trace(stderr); + } } free(msg); return make_null(); @@ -2729,7 +2735,7 @@ Value* builtin_load_file(Value *arg) { Env *target = g_load_env ? g_load_env : g_global_env; int saved_boundary = g_compile_module_boundary; g_compile_module_boundary = 1; /* #373 */ - EigsChunk *lf_chunk = compile_ast(ast, target); + EigsChunk *lf_chunk = compile_ast(ast, target, source); g_compile_module_boundary = saved_boundary; if (g_parse_errors > 0) { g_parse_errors = saved_errors; @@ -3683,7 +3689,7 @@ Value* builtin_eval(Value *arg) { * diagnostics ('break' outside a loop #337, un-encodable jumps) must * fail the eval instead of executing a placeholder chunk. */ Env *target = g_builtin_call_env ? g_builtin_call_env : g_global_env; - EigsChunk *ev_chunk = compile_ast(ast, target); + EigsChunk *ev_chunk = compile_ast(ast, target, src); if (g_parse_errors > 0) { g_parse_errors = saved_errors; chunk_free(ev_chunk); diff --git a/src/chunk.c b/src/chunk.c index 9fc9b77..caf7771 100644 --- a/src/chunk.c +++ b/src/chunk.c @@ -11,6 +11,35 @@ /* ---- Chunk lifecycle ---- */ +/* #407: caret-excerpt source blob — see EigsSrcBuf in vm.h. */ +EigsSrcBuf *srcbuf_new(const char *text) { + if (!text) return NULL; + EigsSrcBuf *sb = xcalloc(1, sizeof(EigsSrcBuf)); + sb->text = strdup(text); + sb->refcount = 1; + return sb; +} + +void srcbuf_incref(EigsSrcBuf *sb) { + if (!sb) return; + if (__builtin_expect(g_vm_multithreaded, 0)) + __atomic_add_fetch(&sb->refcount, 1, __ATOMIC_RELAXED); + else + sb->refcount++; +} + +void srcbuf_decref(EigsSrcBuf *sb) { + if (!sb) return; + int rc; + if (__builtin_expect(g_vm_multithreaded, 0)) + rc = __atomic_sub_fetch(&sb->refcount, 1, __ATOMIC_ACQ_REL); + else + rc = --sb->refcount; + if (rc > 0) return; + free(sb->text); + free(sb); +} + EigsChunk *chunk_new(const char *name) { EigsChunk *c = xcalloc(1, sizeof(EigsChunk)); c->code_cap = 256; @@ -22,6 +51,7 @@ EigsChunk *chunk_new(const char *name) { c->env_ic = xcalloc(c->const_cap, sizeof(EnvIC)); c->lines_cap = 256; c->lines = xcalloc(c->lines_cap, sizeof(int)); + c->cols = xcalloc(c->lines_cap, sizeof(int)); c->fn_cap = 8; c->functions = xcalloc(c->fn_cap, sizeof(EigsChunk *)); c->name = name ? strdup(name) : strdup(""); @@ -62,6 +92,8 @@ void chunk_decref(EigsChunk *chunk) { * it and drops its owned parent ref). */ env_decref(chunk->env_cache); free(chunk->lines); + free(chunk->cols); + srcbuf_decref(chunk->src); for (int i = 0; i < chunk->fn_count; i++) chunk_decref(chunk->functions[i]); /* release creator refs */ free(chunk->functions); @@ -91,9 +123,11 @@ void chunk_emit(EigsChunk *chunk, uint8_t byte, int line) { if (chunk->lines_len >= chunk->lines_cap) { chunk->lines_cap *= 2; chunk->lines = realloc(chunk->lines, chunk->lines_cap * sizeof(int)); + chunk->cols = realloc(chunk->cols, chunk->lines_cap * sizeof(int)); } chunk->code[chunk->code_len] = byte; chunk->lines[chunk->lines_len] = line; + chunk->cols[chunk->lines_len] = chunk->cur_col; chunk->code_len++; chunk->lines_len++; } diff --git a/src/compiler.c b/src/compiler.c index 92dde37..11d4d16 100644 --- a/src/compiler.c +++ b/src/compiler.c @@ -1572,7 +1572,15 @@ static void compile_node(Compiler *c, ASTNode *node) { return; } g_parse_depth++; + /* #407: position cursor for the per-byte cols[] table. Set to this + * node's column for everything it emits, restore on exit so a parent + * resuming after a child recursion stamps its own ops with its own + * column (post-order emission would otherwise leave the last child's + * column on the parent's opcode). */ + int saved_col = c->chunk->cur_col; + if (node) c->chunk->cur_col = node->col; compile_node_inner(c, node); + c->chunk->cur_col = saved_col; g_parse_depth--; } @@ -2000,6 +2008,8 @@ static void compile_node_inner(Compiler *c, ASTNode *node) { EigsChunk *fn_chunk = chunk_new(node->data.func.name); fn_chunk->param_count = node->data.func.param_count; fn_chunk->first_default = node->data.func.first_default; + fn_chunk->src = c->chunk->src; /* #407: share the unit's blob */ + srcbuf_incref(fn_chunk->src); Compiler fn_compiler; memset(&fn_compiler, 0, sizeof(fn_compiler)); @@ -2090,6 +2100,8 @@ static void compile_node_inner(Compiler *c, ASTNode *node) { EigsChunk *fn_chunk = chunk_new(""); fn_chunk->param_count = node->data.lambda.param_count; fn_chunk->first_default = node->data.lambda.param_count; /* lambdas don't support defaults */ + fn_chunk->src = c->chunk->src; /* #407: share the unit's blob */ + srcbuf_incref(fn_chunk->src); Compiler fn_compiler; memset(&fn_compiler, 0, sizeof(fn_compiler)); @@ -2646,8 +2658,12 @@ static void compile_block(Compiler *c, ASTNode **stmts, int count) { /* ---- Public API ---- */ -EigsChunk *compile_ast(ASTNode *ast, Env *env) { +EigsChunk *compile_ast(ASTNode *ast, Env *env, const char *src) { EigsChunk *chunk = chunk_new(""); + /* #407: retain the unit's source for runtime-error caret excerpts. + * Owned copy — callers free their buffers while closures can keep + * chunks alive indefinitely. Nested fn chunks share the blob. */ + chunk->src = srcbuf_new(src); Compiler compiler; memset(&compiler, 0, sizeof(compiler)); diff --git a/src/eigenlsp.c b/src/eigenlsp.c index 8783a9e..3f7f16a 100644 --- a/src/eigenlsp.c +++ b/src/eigenlsp.c @@ -740,14 +740,18 @@ static void send_diagnostics(Document *doc) { snprintf(full, sizeof(full), "syntax error: %s", g_first_error_msg[0] ? g_first_error_msg : "invalid syntax"); + /* #407 residual: token-precise range when the parser recorded the + * offending token's length; the old 0..1000 whole-line span only + * remains as the unknown-length fallback. */ + int err_end = g_first_error_len > 0 ? err_col + g_first_error_len : 1000; strbuf_append(&sb, "{\"range\":{\"start\":{\"line\":"); strbuf_append_fmt(&sb, "%d", err_line); strbuf_append(&sb, ",\"character\":"); strbuf_append_fmt(&sb, "%d", err_col); strbuf_append(&sb, "},\"end\":{\"line\":"); strbuf_append_fmt(&sb, "%d", err_line); - strbuf_append(&sb, ",\"character\":1000}},\"severity\":1,\"code\":\"E002\"," - "\"source\":\"eigenscript\",\"message\":"); + strbuf_append_fmt(&sb, ",\"character\":%d}},\"severity\":1,\"code\":\"E002\"," + "\"source\":\"eigenscript\",\"message\":", err_end); json_escape_to(&sb, full); strbuf_append_char(&sb, '}'); } else if (doc->ast) { @@ -767,10 +771,16 @@ static void send_diagnostics(Document *doc) { if (ln < 0) ln = 0; int sev = strcmp(diags[i].severity, "error") == 0 ? 1 : 2; if (emitted++ > 0) strbuf_append_char(&sb, ','); + /* #407 residual: E-class diags carry the offending token's + * col/len (E003 = the undefined identifier) — publish a + * token-precise squiggle. len == 0 keeps the whole-line span + * (W-rules that haven't been migrated to positions yet). */ + int dcol = diags[i].len > 0 ? diags[i].col : 0; + int dend = diags[i].len > 0 ? diags[i].col + diags[i].len : 1000; strbuf_append_fmt(&sb, - "{\"range\":{\"start\":{\"line\":%d,\"character\":0}," - "\"end\":{\"line\":%d,\"character\":1000}},\"severity\":%d,\"code\":\"%s\"," - "\"source\":\"eigenscript\",\"message\":", ln, ln, sev, diags[i].code); + "{\"range\":{\"start\":{\"line\":%d,\"character\":%d}," + "\"end\":{\"line\":%d,\"character\":%d}},\"severity\":%d,\"code\":\"%s\"," + "\"source\":\"eigenscript\",\"message\":", ln, dcol, ln, dend, sev, diags[i].code); json_escape_to(&sb, diags[i].message); strbuf_append_char(&sb, '}'); } diff --git a/src/eigenscript.c b/src/eigenscript.c index 983e98f..c065952 100644 --- a/src/eigenscript.c +++ b/src/eigenscript.c @@ -47,15 +47,16 @@ * for consumers that can't see the parser's stderr (the LSP, which turns * it into a publishDiagnostics squiggle). Reset at the top of tokenize(). * g_first_error_line is 1-based and 0 when no error has been recorded. */ -void eigs_record_first_error_at(int line, int col, const char *msg) { +void eigs_record_first_error_at(int line, int col, int len, const char *msg) { if (g_first_error_line) return; /* keep only the first */ g_first_error_line = line; g_first_error_col = col; + g_first_error_len = len; snprintf(g_first_error_msg, sizeof(g_first_error_msg), "%s", msg ? msg : "syntax error"); } void eigs_record_first_error(int line, const char *msg) { - eigs_record_first_error_at(line, 0, msg); + eigs_record_first_error_at(line, 0, 0, msg); } /* Structured error payload: set by `throw` so catch can bind the thrown @@ -116,8 +117,18 @@ void rt_error(ErrKind kind, int line, const char *fmt, ...) { g_has_error = 1; eigs_clear_error_value(); /* a new error supersedes any thrown value */ if (g_try_depth == 0) { - fprintf(stderr, "%s\n", g_error_msg); - vm_print_stack_trace(stderr); + /* #407 residual: when the VM is mid-dispatch, defer the uncaught + * print to CHECK_ERROR, which knows the failing instruction's + * bytecode offset and can add a source excerpt + column caret. + * Same print decision, same content prefix — the excerpt is + * inserted between the message and the stack trace. Outside + * dispatch (embed API, teardown) print immediately as before. */ + if (eigs_current && eigs_current->vm && g_vm.frame_count > 0) { + g_error_print_pending = 1; + } else { + fprintf(stderr, "%s\n", g_error_msg); + vm_print_stack_trace(stderr); + } } } diff --git a/src/eigenscript.h b/src/eigenscript.h index 4af9d52..863cf5b 100644 --- a/src/eigenscript.h +++ b/src/eigenscript.h @@ -572,6 +572,13 @@ struct EigsThread { int try_depth; int first_error_line; int first_error_col; /* 0-based column of the first error, or 0 */ + int first_error_len; /* source length of the offending token + * (0 = unknown → whole-line LSP range) */ + /* #407 residual: uncaught-error printing raised during VM dispatch is + * deferred to the dispatch loop's CHECK_ERROR, which knows the failing + * instruction's bytecode offset (→ column) — rt_error/builtin_throw + * set this instead of printing when the VM is live. */ + int error_print_pending; char error_msg[4096]; char first_error_msg[256]; struct Value *error_value; /* thrown payload for structured catch */ @@ -677,6 +684,8 @@ extern __thread EigsThread *eigs_current; #define g_try_depth (eigs_current->try_depth) #define g_first_error_line (eigs_current->first_error_line) #define g_first_error_col (eigs_current->first_error_col) +#define g_first_error_len (eigs_current->first_error_len) +#define g_error_print_pending (eigs_current->error_print_pending) #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) @@ -1045,7 +1054,11 @@ 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); +void eigs_record_first_error_at(int line, int col, int len, const char *msg); +/* #407: one-line source excerpt + `^` caret under `col` (0-based), the + * shared format for parse-time and runtime diagnostics. No-op when src is + * NULL or the position is out of range. */ +void eigs_print_caret_src(FILE *out, const char *src, int line, int col); /* #407: register the compilation unit's raw source so column-carrying parse * errors print a one-line excerpt + caret. NULL = no excerpt (unchanged * output). Set before parse, clear after — the parser never reads it outside @@ -1124,8 +1137,11 @@ char* format_source_string(const char *source); /* malloc'd; caller frees */ /* Structured lint diagnostic (for non-CLI consumers like the LSP). */ typedef struct { int line; /* 1-based source line */ + int col; /* 0-based column of the offending token (0 = unknown) */ + int len; /* token length; 0 = unknown → whole-line range */ char code[8]; /* stable code, e.g. "W001" */ - char severity[12]; /* "warning" / "error" */ + char severity[16]; /* "warning" / "error" — sized to LintWarning.level + * so the copy in lint_collect can't truncate */ char message[256]; } LintDiag; /* Run all lint checks on an already-parsed AST; fill out[] (up to max), diff --git a/src/eigs_embed.c b/src/eigs_embed.c index 0c5ab18..9e9128e 100644 --- a/src/eigs_embed.c +++ b/src/eigs_embed.c @@ -93,7 +93,7 @@ EigsValue *eigs_eval_string(const char *src) { /* REPL-style compilation: top-level names land in the global env * (not module-export slots), so the host can read them back through * eigs_get_global and successive eigs_eval_string calls accumulate. */ - EigsChunk *chunk = compile_ast(ast, global); + EigsChunk *chunk = compile_ast(ast, global, src); Value *result = vm_execute(chunk, global); chunk_free(chunk); diff --git a/src/ext_http.c b/src/ext_http.c index 7f7acd6..3505288 100644 --- a/src/ext_http.c +++ b/src/ext_http.c @@ -1038,7 +1038,7 @@ static void handle_request(int fd) { TokenList auth_tl = tokenize(auth_src); ASTNode *auth_ast = parse(&auth_tl); Env *auth_env = env_new(g_global_env); - EigsChunk *auth_chunk = compile_ast(auth_ast, auth_env); + EigsChunk *auth_chunk = compile_ast(auth_ast, auth_env, auth_src); Value *auth_result = vm_execute(auth_chunk, auth_env); chunk_free(auth_chunk); env_decref(auth_env); @@ -1058,7 +1058,7 @@ static void handle_request(int fd) { TokenList tl = tokenize(r->payload); ASTNode *ast = parse(&tl); Env *req_env = env_new(g_global_env); - EigsChunk *req_chunk = compile_ast(ast, req_env); + EigsChunk *req_chunk = compile_ast(ast, req_env, r->payload); Value *result = vm_execute(req_chunk, req_env); chunk_free(req_chunk); char *result_str = value_to_string(result); diff --git a/src/lexer.c b/src/lexer.c index 5534494..c69723f 100644 --- a/src/lexer.c +++ b/src/lexer.c @@ -218,6 +218,8 @@ TokenList tokenize(const char *source) { * g_tokenize_depth, so only reset at the outermost pass. */ if (g_tokenize_depth == 0) { g_first_error_line = 0; + g_first_error_col = 0; + g_first_error_len = 0; g_first_error_msg[0] = '\0'; } diff --git a/src/lint.c b/src/lint.c index 3562ba2..9fab1b0 100644 --- a/src/lint.c +++ b/src/lint.c @@ -10,6 +10,8 @@ typedef struct { int line; + int col; /* 0-based column of the offending token (0 = unknown) */ + int len; /* token length (0 = unknown -> whole-line LSP range) */ char level[16]; /* "warning" or "error" */ char code[8]; /* stable diagnostic code, e.g. "W001" */ char message[256]; @@ -49,11 +51,14 @@ static void json_escape(const char *s, char *out, size_t outsz) { out[o] = '\0'; } -static void lint_vdiag(LintContext *ctx, int line, const char *level, +static void lint_vdiag(LintContext *ctx, int line, int col, int len, + const char *level, const char *code, const char *fmt, va_list ap) { if (ctx->warning_count >= MAX_LINT_WARNINGS) return; LintWarning *w = &ctx->warnings[ctx->warning_count++]; w->line = line; + w->col = col; + w->len = len; snprintf(w->level, sizeof(w->level), "%s", level); snprintf(w->code, sizeof(w->code), "%s", code); vsnprintf(w->message, sizeof(w->message), fmt, ap); @@ -63,16 +68,18 @@ static void lint_warn(LintContext *ctx, int line, const char *code, const char *fmt, ...) { va_list ap; va_start(ap, fmt); - lint_vdiag(ctx, line, "warning", code, fmt, ap); + lint_vdiag(ctx, line, 0, 0, "warning", code, fmt, ap); va_end(ap); } -/* Error-severity diagnostic: fails --lint even at --lint-level error. */ -static void lint_error(LintContext *ctx, int line, const char *code, - const char *fmt, ...) { +/* Error-severity diagnostic (fails --lint even at --lint-level error) + * carrying a token position (#407 residual): the LSP publishes + * col..col+len as the squiggle range; pass col=0,len=0 when unknown. */ +static void lint_error_at(LintContext *ctx, int line, int col, int len, + const char *code, const char *fmt, ...) { va_list ap; va_start(ap, fmt); - lint_vdiag(ctx, line, "error", code, fmt, ap); + lint_vdiag(ctx, line, col, len, "error", code, fmt, ap); va_end(ap); } @@ -1639,12 +1646,13 @@ static void e003_walk(ASTNode *n, E003 *e, LintContext *ctx, int mode) { e->dynamic = 1; } else if (!env_get(e->scope, n->data.ident.name)) { const char *near = e003_suggest(e->scope, n->data.ident.name); + int nlen = (int)strlen(n->data.ident.name); if (near) - lint_error(ctx, n->line, "E003", + lint_error_at(ctx, n->line, n->col, nlen, "E003", "undefined name '%s' — no binding on any path (did you mean '%s'?)", n->data.ident.name, near); else - lint_error(ctx, n->line, "E003", + lint_error_at(ctx, n->line, n->col, nlen, "E003", "undefined name '%s' — no binding on any path", n->data.ident.name); } @@ -1977,6 +1985,8 @@ int lint_collect(ASTNode *ast, const char *path, const char *source, int n = ctx.warning_count < max ? ctx.warning_count : max; for (int i = 0; i < n; i++) { out[i].line = ctx.warnings[i].line; + out[i].col = ctx.warnings[i].col; + out[i].len = ctx.warnings[i].len; snprintf(out[i].code, sizeof(out[i].code), "%s", ctx.warnings[i].code); snprintf(out[i].severity, sizeof(out[i].severity), "%s", ctx.warnings[i].level); snprintf(out[i].message, sizeof(out[i].message), "%s", ctx.warnings[i].message); diff --git a/src/main.c b/src/main.c index 3b23d4d..5f03c4a 100644 --- a/src/main.c +++ b/src/main.c @@ -289,7 +289,7 @@ int main(int argc, char **argv) { return 1; } g_compile_module_slots = 1; - EigsChunk *script_chunk = compile_ast(ast, global); + EigsChunk *script_chunk = compile_ast(ast, global, source); g_compile_module_slots = 0; if (g_parse_errors > 0) { /* compile-stage error, e.g. un-encodable jump */ fprintf(stderr, "%d compile error(s) — aborting\n", g_parse_errors); diff --git a/src/parser.c b/src/parser.c index bf8a790..3b330b8 100644 --- a/src/parser.c +++ b/src/parser.c @@ -55,8 +55,10 @@ void parser_set_caret_source(const char *src) { g_parse_caret_src = src; } -static void p_print_caret(int line, int col) { - const char *s = g_parse_caret_src; +/* Shared with the runtime-error printer (#407 residual): identical excerpt + * + caret format for parse-time and runtime diagnostics. */ +void eigs_print_caret_src(FILE *out, const char *src, int line, int col) { + const char *s = src; if (!s || line < 1 || col < 0) return; for (int l = 1; l < line; l++) { s = strchr(s, '\n'); @@ -67,14 +69,18 @@ static void p_print_caret(int line, int col) { size_t len = e ? (size_t)(e - s) : strlen(s); if (len > 200) len = 200; /* pathological lines stay sane */ if ((size_t)col > len) return; - fprintf(stderr, " %4d | %.*s\n", line, (int)len, s); + fprintf(out, " %4d | %.*s\n", line, (int)len, s); /* pad buffer, not fputc: the freestanding mini-libc has fprintf but no * fputc (the symbol gate rejects it). col <= len <= 200 by the guards. */ char pad[201]; for (int i = 0; i < col; i++) pad[i] = (s[i] == '\t') ? '\t' : ' '; pad[col] = '\0'; - fprintf(stderr, " | %s^\n", pad); + fprintf(out, " | %s^\n", pad); +} + +static void p_print_caret(int line, int col) { + eigs_print_caret_src(stderr, g_parse_caret_src, line, col); } static void p_end_statement(Parser *p) { @@ -90,7 +96,7 @@ static void p_end_statement(Parser *p) { char m[160]; snprintf(m, sizeof(m), "unexpected %s after statement", tok_type_name(tok->type)); - eigs_record_first_error_at(tok->line, tok->col, m); + eigs_record_first_error_at(tok->line, tok->col, tok->len, m); } p_print_caret(tok->line, tok->col); g_parse_errors++; @@ -110,7 +116,8 @@ static void p_expect(Parser *p, TokType type) { char m[160]; snprintf(m, sizeof(m), "expected %s, got %s", tok_type_name(type), tok_type_name(p_cur(p)->type)); - eigs_record_first_error_at(p_cur(p)->line, p_cur(p)->col, m); + eigs_record_first_error_at(p_cur(p)->line, p_cur(p)->col, + p_cur(p)->len, m); } p_print_caret(p_cur(p)->line, p_cur(p)->col); g_parse_errors++; @@ -614,6 +621,7 @@ static ASTNode** parse_block(Parser *p, int *count) { * The bare [expr] form remains AST_INDEX. */ static ASTNode* parse_subscript_suffix(Parser *p, ASTNode *target) { int line = p_cur(p)->line; + int col = p_cur(p)->col; /* the '[' — runtime caret anchor (#407) */ p_advance(p); /* skip '[' */ ASTNode *start = NULL, *end = NULL; @@ -634,13 +642,13 @@ static ASTNode* parse_subscript_suffix(Parser *p, ASTNode *target) { p_expect(p, TOK_RBRACKET); if (is_slice) { - ASTNode *n = make_node(AST_SLICE, line); + ASTNode *n = make_node_col(AST_SLICE, line, col); n->data.slice.target = target; n->data.slice.start = start; n->data.slice.end = end; return n; } - ASTNode *n = make_node(AST_INDEX, line); + ASTNode *n = make_node_col(AST_INDEX, line, col); n->data.index.target = target; n->data.index.index = start; return n; @@ -659,14 +667,14 @@ static ASTNode* parse_postfix_chain(Parser *p, ASTNode *n) { p_advance(p); Token *key_tok = p_cur(p); p_expect(p, TOK_IDENT); - ASTNode *dot = make_node(AST_DOT, p_cur(p)->line); + ASTNode *dot = make_node_col(AST_DOT, p_cur(p)->line, key_tok->col); dot->data.dot.target = n; dot->data.dot.key = xstrdup(key_tok->str_val); set_name_hash(dot, dot->data.dot.key); n = dot; } else { n = parse_subscript_suffix(p, n); - } + } } return n; } @@ -842,7 +850,7 @@ static ASTNode* parse_primary(Parser *p) { p_advance(p); Token *key_tok = p_cur(p); p_expect(p, TOK_IDENT); - ASTNode *dot = make_node(AST_DOT, key_tok->line); + ASTNode *dot = make_node_col(AST_DOT, key_tok->line, key_tok->col); dot->data.dot.target = expr; dot->data.dot.key = xstrdup(key_tok->str_val); set_name_hash(dot, dot->data.dot.key); @@ -950,7 +958,7 @@ static ASTNode* parse_primary(Parser *p) { p_advance(p); Token *key_tok = p_cur(p); p_expect(p, TOK_IDENT); - ASTNode *dot = make_node(AST_DOT, p_cur(p)->line); + ASTNode *dot = make_node_col(AST_DOT, p_cur(p)->line, key_tok->col); dot->data.dot.target = n; dot->data.dot.key = xstrdup(key_tok->str_val); set_name_hash(dot, dot->data.dot.key); @@ -1017,14 +1025,14 @@ static ASTNode* parse_relation(Parser *p) { ASTNode *left = parse_primary(p); if (p_cur(p)->type == TOK_OF) { - p_advance(p); + Token *op_tok = p_advance(p); /* RHS is a single unary-or-tighter expression. This preserves * `f of -x` (unary minus) and right-associative `f of g of x` * (unary falls through to relation), but stops `of` from * absorbing trailing infix arithmetic: `len of xs - 1` now * parses as `(len of xs) - 1`, not `len of (xs - 1)`. */ ASTNode *right = parse_unary(p); - ASTNode *n = make_node(AST_RELATION, p_cur(p)->line); + ASTNode *n = make_node_col(AST_RELATION, op_tok->line, op_tok->col); n->data.relation.left = left; n->data.relation.right = right; return n; @@ -1053,25 +1061,25 @@ static ASTNode* parse_unary(Parser *p) { static ASTNode* parse_unary_body(Parser *p) { if (p_cur(p)->type == TOK_MINUS) { - p_advance(p); + Token *op_tok = p_advance(p); ASTNode *operand = parse_unary(p); - ASTNode *n = make_node(AST_UNARY, p_cur(p)->line); + ASTNode *n = make_node_col(AST_UNARY, op_tok->line, op_tok->col); snprintf(n->data.unary.op, sizeof(n->data.unary.op), "-"); n->data.unary.operand = operand; return n; } if (p_cur(p)->type == TOK_NOT) { - p_advance(p); + Token *op_tok = p_advance(p); ASTNode *operand = parse_unary(p); - ASTNode *n = make_node(AST_UNARY, p_cur(p)->line); + ASTNode *n = make_node_col(AST_UNARY, op_tok->line, op_tok->col); snprintf(n->data.unary.op, sizeof(n->data.unary.op), "not"); n->data.unary.operand = operand; return n; } if (p_cur(p)->type == TOK_TILDE) { - p_advance(p); + Token *op_tok = p_advance(p); ASTNode *operand = parse_unary(p); - ASTNode *n = make_node(AST_UNARY, p_cur(p)->line); + ASTNode *n = make_node_col(AST_UNARY, op_tok->line, op_tok->col); snprintf(n->data.unary.op, sizeof(n->data.unary.op), "~"); n->data.unary.operand = operand; return n; @@ -1087,9 +1095,9 @@ static ASTNode* parse_multiply(Parser *p) { if (p_cur(p)->type == TOK_STAR) op[0] = '*'; else if (p_cur(p)->type == TOK_SLASH) op[0] = '/'; else op[0] = '%'; - p_advance(p); + Token *op_tok = p_advance(p); ASTNode *right = parse_unary(p); - ASTNode *n = make_node(AST_BINOP, p_cur(p)->line); + ASTNode *n = make_node_col(AST_BINOP, op_tok->line, op_tok->col); snprintf(n->data.binop.op, sizeof(n->data.binop.op), "%s", op); n->data.binop.left = left; n->data.binop.right = right; @@ -1104,9 +1112,9 @@ static ASTNode* parse_addition(Parser *p) { if (chain_too_deep(p)) break; char op[4] = {0}; op[0] = (p_cur(p)->type == TOK_PLUS) ? '+' : '-'; - p_advance(p); + Token *op_tok = p_advance(p); ASTNode *right = parse_multiply(p); - ASTNode *n = make_node(AST_BINOP, p_cur(p)->line); + ASTNode *n = make_node_col(AST_BINOP, op_tok->line, op_tok->col); snprintf(n->data.binop.op, sizeof(n->data.binop.op), "%s", op); n->data.binop.left = left; n->data.binop.right = right; @@ -1122,9 +1130,9 @@ static ASTNode* parse_shift(Parser *p) { char op[4] = {0}; if (p_cur(p)->type == TOK_SHL) { op[0] = '<'; op[1] = '<'; } else { op[0] = '>'; op[1] = '>'; } - p_advance(p); + Token *op_tok = p_advance(p); ASTNode *right = parse_addition(p); - ASTNode *n = make_node(AST_BINOP, p_cur(p)->line); + ASTNode *n = make_node_col(AST_BINOP, op_tok->line, op_tok->col); snprintf(n->data.binop.op, sizeof(n->data.binop.op), "%s", op); n->data.binop.left = left; n->data.binop.right = right; @@ -1137,9 +1145,9 @@ static ASTNode* parse_bitand(Parser *p) { ASTNode *left = parse_shift(p); while (p_cur(p)->type == TOK_AMP) { if (chain_too_deep(p)) break; - p_advance(p); + Token *op_tok = p_advance(p); ASTNode *right = parse_shift(p); - ASTNode *n = make_node(AST_BINOP, p_cur(p)->line); + ASTNode *n = make_node_col(AST_BINOP, op_tok->line, op_tok->col); snprintf(n->data.binop.op, sizeof(n->data.binop.op), "&"); n->data.binop.left = left; n->data.binop.right = right; @@ -1152,9 +1160,9 @@ static ASTNode* parse_bitxor(Parser *p) { ASTNode *left = parse_bitand(p); while (p_cur(p)->type == TOK_CARET) { if (chain_too_deep(p)) break; - p_advance(p); + Token *op_tok = p_advance(p); ASTNode *right = parse_bitand(p); - ASTNode *n = make_node(AST_BINOP, p_cur(p)->line); + ASTNode *n = make_node_col(AST_BINOP, op_tok->line, op_tok->col); snprintf(n->data.binop.op, sizeof(n->data.binop.op), "^"); n->data.binop.left = left; n->data.binop.right = right; @@ -1167,9 +1175,9 @@ static ASTNode* parse_bitor(Parser *p) { ASTNode *left = parse_bitxor(p); while (p_cur(p)->type == TOK_BITOR) { if (chain_too_deep(p)) break; - p_advance(p); + Token *op_tok = p_advance(p); ASTNode *right = parse_bitxor(p); - ASTNode *n = make_node(AST_BINOP, p_cur(p)->line); + ASTNode *n = make_node_col(AST_BINOP, op_tok->line, op_tok->col); snprintf(n->data.binop.op, sizeof(n->data.binop.op), "|"); n->data.binop.left = left; n->data.binop.right = right; @@ -1192,9 +1200,9 @@ static ASTNode* parse_comparison(Parser *p) { case TOK_NE: snprintf(op, sizeof(op), "!="); break; default: break; } - p_advance(p); + Token *op_tok = p_advance(p); ASTNode *right = parse_bitor(p); - ASTNode *n = make_node(AST_BINOP, p_cur(p)->line); + ASTNode *n = make_node_col(AST_BINOP, op_tok->line, op_tok->col); snprintf(n->data.binop.op, sizeof(n->data.binop.op), "%s", op); n->data.binop.left = left; n->data.binop.right = right; @@ -1207,9 +1215,9 @@ static ASTNode* parse_and(Parser *p) { ASTNode *left = parse_comparison(p); while (p_cur(p)->type == TOK_AND) { if (chain_too_deep(p)) break; - p_advance(p); + Token *op_tok = p_advance(p); ASTNode *right = parse_comparison(p); - ASTNode *n = make_node(AST_BINOP, p_cur(p)->line); + ASTNode *n = make_node_col(AST_BINOP, op_tok->line, op_tok->col); snprintf(n->data.binop.op, sizeof(n->data.binop.op), "and"); n->data.binop.left = left; n->data.binop.right = right; @@ -1222,9 +1230,9 @@ static ASTNode* parse_or(Parser *p) { ASTNode *left = parse_and(p); while (p_cur(p)->type == TOK_OR) { if (chain_too_deep(p)) break; - p_advance(p); + Token *op_tok = p_advance(p); ASTNode *right = parse_and(p); - ASTNode *n = make_node(AST_BINOP, p_cur(p)->line); + ASTNode *n = make_node_col(AST_BINOP, op_tok->line, op_tok->col); snprintf(n->data.binop.op, sizeof(n->data.binop.op), "or"); n->data.binop.left = left; n->data.binop.right = right; @@ -1237,10 +1245,10 @@ static ASTNode* parse_pipe(Parser *p) { ASTNode *left = parse_or(p); while (p_cur(p)->type == TOK_PIPE) { if (chain_too_deep(p)) break; - p_advance(p); + Token *op_tok = p_advance(p); ASTNode *fn = parse_or(p); /* a |> b desugars to b of a */ - ASTNode *n = make_node(AST_RELATION, p_cur(p)->line); + ASTNode *n = make_node_col(AST_RELATION, op_tok->line, op_tok->col); n->data.relation.left = fn; n->data.relation.right = left; left = n; @@ -1706,17 +1714,17 @@ static ASTNode* parse_statement_inner(Parser *p) { ASTNode *rhs = parse_expression(p); if (compound) { /* Desugar obj.f += expr → obj.f is obj.f + expr */ - ASTNode *read = make_node(AST_DOT, t->line); + ASTNode *read = make_node_col(AST_DOT, t->line, t->col); read->data.dot.target = clone_ast(target->data.dot.target); read->data.dot.key = xstrdup(target->data.dot.key); read->name_hash = target->name_hash; - ASTNode *binop = make_node(AST_BINOP, t->line); + ASTNode *binop = make_node_col(AST_BINOP, t->line, t->col); memcpy(binop->data.binop.op, cop, 4); binop->data.binop.left = read; binop->data.binop.right = rhs; rhs = binop; } - ASTNode *n = make_node(AST_DOT_ASSIGN, t->line); + ASTNode *n = make_node_col(AST_DOT_ASSIGN, t->line, t->col); n->data.dot_assign.target = target->data.dot.target; n->data.dot_assign.key = xstrdup(target->data.dot.key); n->name_hash = target->name_hash; @@ -1733,7 +1741,7 @@ static ASTNode* parse_statement_inner(Parser *p) { if (compound) compound_to_op(p_cur(p)->type, cop); p_advance(p); /* skip IS or compound op */ ASTNode *rhs = parse_expression(p); - ASTNode *n = make_node(AST_INDEX_ASSIGN, t->line); + ASTNode *n = make_node_col(AST_INDEX_ASSIGN, t->line, t->col); n->data.index_assign.target = target->data.index.target; n->data.index_assign.index = target->data.index.index; n->data.index_assign.expr = rhs; @@ -1763,7 +1771,7 @@ static ASTNode* parse_statement_inner(Parser *p) { ASTNode *ident = make_node_col(AST_IDENT, name_tok->line, name_tok->col); ident->data.ident.name = xstrdup(name_tok->str_val); set_name_hash(ident, ident->data.ident.name); - ASTNode *binop = make_node(AST_BINOP, t->line); + ASTNode *binop = make_node_col(AST_BINOP, t->line, t->col); memcpy(binop->data.binop.op, cop, 4); binop->data.binop.left = ident; binop->data.binop.right = expr; diff --git a/src/repl.c b/src/repl.c index 9151b65..8d4ff3a 100644 --- a/src/repl.c +++ b/src/repl.c @@ -67,7 +67,7 @@ static int repl_eval_buffer(Env *env, strbuf *input) { g_returning = 0; g_return_val = NULL; g_has_error = 0; /* don't carry a prior line's error into this one */ - EigsChunk *repl_chunk = compile_ast(ast, env); + EigsChunk *repl_chunk = compile_ast(ast, env, input->data); if (g_parse_errors > 0) { /* e.g. an un-encodable jump/loop offset */ fprintf(stderr, "%d compile error(s) — line not run\n", g_parse_errors); chunk_free(repl_chunk); diff --git a/src/vm.c b/src/vm.c index cee45e6..cce6289 100644 --- a/src/vm.c +++ b/src/vm.c @@ -2065,6 +2065,29 @@ void vm_print_stack_trace(FILE *out) { } } +/* #407 residual: print a deferred uncaught-error report (message, source + * excerpt + column caret, stack trace). rt_error/builtin_throw set + * g_error_print_pending instead of printing while the VM is mid-dispatch; + * the dispatch loop's CHECK_ERROR calls this with the live ip, which + * points one opcode past the failing instruction — so ip-1 lands inside + * the failing instruction's bytes and cols[]/lines[] carry its position. + * The lines[off] == g_error_line guard suppresses the caret whenever the + * offset disagrees with the reported line (JIT-advanced ip, builtin line + * restamps), so a caret only ever prints on the correct source line. */ +static void vm_error_flush_pending(EigsChunk *chunk, const uint8_t *ip) { + if (!g_error_print_pending) return; + g_error_print_pending = 0; + fprintf(stderr, "%s\n", g_error_msg); + if (chunk && ip && chunk->cols && chunk->src && chunk->src->text) { + long off = ip - chunk->code - 1; + if (off >= 0 && off < chunk->lines_len && + chunk->lines[off] == g_error_line) + eigs_print_caret_src(stderr, chunk->src->text, + g_error_line, chunk->cols[off]); + } + vm_print_stack_trace(stderr); +} + static int vm_handler_in_range(int base) { for (int i = g_vm.frame_count - 1; i >= base; i--) if (g_vm.frames[i].try_count > 0) return 1; @@ -2231,6 +2254,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { }; #define CHECK_ERROR() do { \ while (__builtin_expect(g_has_error, 0)) { \ + vm_error_flush_pending(chunk, ip); /* #407: uncaught print + caret */ \ if (frame->try_count > 0 && !g_exit_requested) { \ g_has_error = 0; \ g_try_depth--; \ @@ -4849,7 +4873,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { int saved_boundary = g_compile_module_boundary; g_compile_module_boundary = 1; /* #373 */ - EigsChunk *mod_chunk = compile_ast(ast, mod_env); + EigsChunk *mod_chunk = compile_ast(ast, mod_env, source); g_compile_module_boundary = saved_boundary; if (g_parse_errors > 0) { g_parse_errors = saved_errors; @@ -5163,6 +5187,7 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { return make_null(); vm_error_halt: + vm_error_flush_pending(chunk, ip); /* #407: belt — normally flushed by CHECK_ERROR */ /* Uncaught runtime error: unwind every frame this vm_run owns * ([base_frame, frame_count)) the same way OP_RETURN does — drain the * frame's stack window, free its env if owned, restore the per-frame diff --git a/src/vm.h b/src/vm.h index 0719da3..d6b549b 100644 --- a/src/vm.h +++ b/src/vm.h @@ -200,6 +200,23 @@ typedef struct { } EnvIC; /* ---- Bytecode Chunk ---- */ + +/* #407: shared source blob for runtime-error caret excerpts. One blob per + * compile unit (script, REPL line, eval'd string, imported module); the + * root chunk and every nested function chunk hold a ref, so the excerpt + * text outlives the caller's source buffer (REPL lines and eval strings + * are freed while closures keep their chunks alive). Not a GC edge — + * plain owned data like chunk->name. Refcount policy mirrors chunk + * refcounts: atomic when g_vm_multithreaded. */ +typedef struct EigsSrcBuf { + int refcount; + char *text; +} EigsSrcBuf; + +EigsSrcBuf *srcbuf_new(const char *text); +void srcbuf_incref(EigsSrcBuf *sb); +void srcbuf_decref(EigsSrcBuf *sb); + typedef struct EigsChunk { /* Lifetime: 1 creator ref (compile_ast caller, or the parent chunk's * functions[] slot for nested chunks) + 1 per live VAL_FN pointing at @@ -229,6 +246,17 @@ typedef struct EigsChunk { int *lines; /* line number per bytecode offset */ int lines_len; int lines_cap; + int *cols; /* #407: 0-based column per bytecode offset + * (the emitting AST node's col; 0 when + * unknown). Same length/cap as lines[]. + * Compile-time only data, read on the + * cold error path — never in dispatch. */ + int cur_col; /* compile-time cursor: column stamped into + * cols[] by chunk_emit; maintained by + * compile_node's save/set/restore. */ + EigsSrcBuf *src; /* #407: source text for caret excerpts; + * shared across the unit's chunks. May be + * NULL (no caret printed then). */ struct EigsChunk **functions; /* nested function chunks */ int fn_count; @@ -501,7 +529,7 @@ const char *op_name(uint8_t op); int chunk_verify(EigsChunk *chunk); /* Compiler */ -EigsChunk *compile_ast(ASTNode *ast, Env *env); +EigsChunk *compile_ast(ASTNode *ast, Env *env, const char *src); /* Sandbox loop-iteration cap (0 = default 100M). Set by builtin_sandbox_run. */ extern int g_sandbox_loop_max; diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index bab44b1..590ea10 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -654,6 +654,13 @@ check_stderr "EM3 unknown char shows character" 'x is @' "unexpected character" check_stderr "EM24 parse error prints source excerpt" 'if x > 0 print of x' "1 | if x > 0" check_stderr "EM25 caret lands on the error column" 'x is 2 x is 3' "| \^" +# #407 residual: uncaught RUNTIME errors print the same excerpt + caret, +# with the column attributed to the failing token (the '[' of the failing +# subscript here) via the per-byte cols[] table + deferred CHECK_ERROR print. +check_stderr "EM26 runtime error prints source excerpt" 'items is [1,2,3] +print of items[10]' "2 | print of items\[10\]" +check_stderr "EM27 runtime caret lands on the failing column" 'items is [1,2,3] +print of items[10]' "| \^" check_stderr "EM4 type error on bad subtraction" 'x is [1,2] - 5' "Error line 1: cannot apply" check_stderr "EM5 index out of bounds" 'items is [1,2,3] print of items[10]' "Error line 2: index 10 out of range" diff --git a/tests/test_error_examples.sh b/tests/test_error_examples.sh index 811f8a9..45b17ef 100755 --- a/tests/test_error_examples.sh +++ b/tests/test_error_examples.sh @@ -21,6 +21,10 @@ for f in "$ROOT"/examples/errors/*.eigs; do FAIL=$((FAIL + 1)) continue fi + # #407 residual: runtime-error cases also pin their caret position with + # # expect-caret: + # so a column-attribution regression moves the caret and fails here. + caret=$(grep -m1 "^# expect-caret:" "$f" | sed 's/^# expect-caret: //') out=$("$EIGS" "$f" &1) rc=$? if [ "$rc" = "0" ]; then @@ -31,6 +35,11 @@ for f in "$ROOT"/examples/errors/*.eigs; do echo " expected substring: $expected" printf '%s\n' "$out" | head -3 | sed 's/^/ got: /' FAIL=$((FAIL + 1)) + elif [ -n "$caret" ] && ! printf '%s' "$out" | grep -qF "$caret"; then + echo " FAIL: $name (caret not at the declared column)" + echo " expected caret line: $caret" + printf '%s\n' "$out" | head -4 | sed 's/^/ got: /' + FAIL=$((FAIL + 1)) else echo " PASS: $name" PASS=$((PASS + 1)) diff --git a/tests/test_lsp.py b/tests/test_lsp.py index 845bd4d..8b1548d 100755 --- a/tests/test_lsp.py +++ b/tests/test_lsp.py @@ -459,6 +459,22 @@ def main(): bool(d) and any(x.get("code") == "E003" for x in d)) check("E003 diagnostic severity is error (1)", bool(d) and any(x.get("code") == "E003" and x.get("severity") == 1 for x in d)) + # #407 residual: the squiggle is token-precise — 'totl' sits at line 2 + # (0-based), chars 9..13 — not the old whole-line 0..1000 span. + e3 = next((x for x in (d or []) if x.get("code") == "E003"), None) + check("E003 range is token-precise (start char 9)", + bool(e3) and e3["range"]["start"]["character"] == 9) + check("E003 range is token-precise (end char 13)", + bool(e3) and e3["range"]["end"]["character"] == 13) + + # --- E002 parse-error range is token-precise (#407 residual) --- + r = converse([INIT, did_open("x is 2 x is 3\n"), SHUTDOWN, EXIT]) + d = diagnostics(r) + e2 = next((x for x in (d or []) if x.get("code") == "E002"), None) + check("E002 range starts at the offending token (char 7)", + bool(e2) and e2["range"]["start"]["character"] == 7) + check("E002 range ends after the token (char 8)", + bool(e2) and e2["range"]["end"]["character"] == 8) # --- '# lint: allow-file' silences the code in the LSP too --- r = converse([INIT, did_open("# lint: allow-file E003\nx is 1\nif x > 5:\n y is totl + 1\n print of y\n"),