Skip to content

lib: improve error reporting - #463

Open
jow- wants to merge 3 commits into
masterfrom
feat/improved-error-reporting
Open

jow- wants to merge 3 commits into
masterfrom
feat/improved-error-reporting

Conversation

@jow-

@jow- jow- commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator

Improve runtime and syntax error reporting in two ways:

Underline the failing statement

Error contexts previously marked only the single token/instruction which failed, leaving the reader to reconstruct the statement around it. The statement start/end offsets are now resolved from the offset info and underlined, keeping the caret at the failing position and dropping the now-redundant "Near here" label.

  • Stacktrace entries now carry the statement extent as a statement member (start/end line and byte), useful for editors and debug clients.
  • Syntax errors are reported against the innermost statement compilation was inside of.
  • Fixed two flaws in the span lookup along the way: the search was abandoned one level too early, and the byte/instruction deltas of a span-start entry were accumulated alike.

Name the offending expression in call failures

When a call fails because its left-hand side isn't a function, the message now includes the recovered source text of the invoked expression, falling back to the generic message whenever it cannot be recovered with confidence. Expressions which do not fit are cut short, marked with an ellipsis.

Example

Before:

Type error: left-hand side is not a function
In fn(), line 3, byte 24:
  called from function call ([C])
  called from anonymous function ([stdin]:7:30)

 `        print("Hello world\n");`
  Near here -------------------^

After:

Type error: `print` is not a function, got null instead
In fn(), line 3, byte 24:
  called from function call ([C])
  called from anonymous function ([stdin]:7:30)

 `        print("Hello world\n");`
         ~~~~~~~~~~~~~~~~~~~~~^~

jow- added 3 commits October 3, 2026 15:18
Error contexts so far marked the single token or instruction which
failed, which left the reader to reconstruct the statement around it:
for `return o.missing.deep;` the caret sat under the failing member
access and said nothing about which expression the VM had been
evaluating.

The offset info already records where each statement begins and ends,
but only the debugger consulted it. Resolve that extent for the failing
instruction now and underline it, keeping the caret at the failing
position and dropping the "Near here" label, which the underline makes
redundant. Where no statement can be resolved, the previous caret
rendering is kept.

Statement spans nest, but an inner span always closes before its
enclosing one, so the first closed span covering the instruction is the
innermost match and remembering a single parent suffices to walk out of
a non-matching one. The few instructions which fall through - loop
back-edges, function return tails and the like, none of which can be a
failing expression - are left to the caller to render as a plain
position marker.

While wiring this up, two flaws in the span lookup came to light.
Abandoning the search after the remembered parent was exhausted made
the walk come up empty one level too early, and the byte and instruction
deltas of a span-start entry were accumulated alike, although the byte
delta leads up to the statement start while the instruction delta counts
instructions emitted after it.

Since the extent of the failing statement is useful to editors and debug
clients as well, stacktrace entries now carry it as a `statement` member
holding the start and end line and byte of the statement, and syntax
errors are reported against the innermost statement compilation was
inside of, whose end marker the error prevented from being emitted.

The expected error output of the test suite is adjusted to the new
marker rendering.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
When a call fails because its left-hand side isn't a function, the
message "left-hand side is not a function" identifies nothing the user
can see. Recover the text of the invoked expression from the source and
name it instead, falling back to the generic message whenever the
expression cannot be recovered with confidence.

The instruction of a failing call refers to the closing parenthesis of
its argument list, so the source in front of it is scanned backwards
over that balanced pair, which marks the end of the invoked expression,
and then over the spine of the expression itself. Subscripts, nested
calls and literal values are taken as a whole so that their contents
cannot be mistaken for part of the surrounding expression, and
whitespace which does not surround a member access operator is dropped,
since it separates the expression from whatever precedes it.

The source is read one character at a time, and how far back the scan
may reach is limited, since only the arguments passed to the failing
call can be arbitrarily wide. What is recovered goes into a fixed size
buffer which is filled from its end towards its beginning, so no second
pass is needed to put the characters in order; expressions which do not
fit are cut short, and only their tails are shown, marked with an
ellipsis.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
Assigning to a forward-declared function fails after the right-hand
side has already emitted its closure instruction, so the interrupted
statement resolves to a non-empty extent and the error context now
underlines it instead of pointing at the failing byte with a
"Near here" marker.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant