Diagnostics
This chapter is a collection point. Nearly everything it needs was built earlier — spans on tokens in Lab 1, spans on AST nodes in Lab 2, operand spans in Lab 4, the line table in Lab 9, frames in Lab 11, debug names in Lab 10, tracebacks in Lab 7. Lab 24 is rendering, not retrofitting, and that is the whole payoff for decisions that looked like overhead at the time.
What a Good Diagnostic Contains
policy.ember:12:17: error: attempt to multiply a nil value
10 │ function score(article)
11 │ local base = article.semantic_score
12 │ return base * article.boost
│ ^^^^^^^^^^^^^ this is nil
13 │ end
stack traceback:
in function 'score' policy.ember:12
in function 'rank' policy.ember:22
in <host>
help: `article` has no field `boost`. Available fields: id, semantic_score, age_hours, topic
Seven components, and each traces to a specific earlier decision:
| Component | Comes from | Lab |
|---|---|---|
policy.ember | the SourceMap's file name, supplied at render time | 1 |
12:17 | SourceFile::location, a binary search over line starts | 1 |
error: and the message | EmberError::message, one type across all layers | 1 |
| The source lines with context | the SourceMap owning the text | 1 |
The caret under article.boost | the operand's span, because Binary merged its children's | 2, 4 |
| The traceback | the frame stack, captured at error creation | 7, 11 |
The help: line | userdata field introspection, when the receiver is a host object | 20 |
The caret row is the one to notice. It points at the operand, not the operator and not the whole
expression, and that is possible only because Expr::Binary's span is
lhs.span().merge(rhs.span()) and the runtime error was constructed with rhs.span(). Two
decisions, four labs apart, and neither looked like a diagnostics decision at the time.
Rendering
#![allow(unused)] fn main() { pub struct Renderer<'a> { map: &'a SourceMap, color: bool, // honors --no-color and NO_COLOR context_lines: usize, // 2 above, 1 below max_width: usize, // window long lines } }
Four rules, each of which is a bug someone has shipped:
- Tabs pass through into the padding. The caret's indentation copies the prefix character by character, substituting spaces for everything except tabs. Convert tabs to spaces and the caret is misaligned for anyone whose tab width is not yours.
- Long lines are windowed. A 10,000-character minified line renders as 10,000 characters of noise. Window around the span and mark the elision.
- Multi-line spans get a summary, not a caret.
^^^ … (continues to line 18). A proper multi-line rendering is whereariadneearns its dependency; do the simple thing and note it. - Color is off in tests. Golden files containing ANSI codes are a maintenance trap.
--no-colorandNO_COLORboth honored, and the test harness sets one.
Error Recovery
Lab 2's parser stops at the first syntax error. That is correct and it is a poor experience: a file with five mistakes takes five runs.
Panic-mode recovery is the standard answer and it is about forty lines:
#![allow(unused)] fn main() { fn synchronize(&mut self) { // Skip tokens until something that plausibly starts a new statement. // The set is chosen so recovery lands at a STATEMENT boundary; recovering // mid-expression produces cascade errors that are worse than silence. loop { match self.peek() { TokenKind::Eof => return, TokenKind::End | TokenKind::Semi => { self.advance(); return; } TokenKind::Local | TokenKind::Function | TokenKind::If | TokenKind::While | TokenKind::For | TokenKind::Return => return, _ => { self.advance(); } } } } }
The measurement that decides whether it was worth it, from Lab 24: on a file with five deliberate errors, how many does each version report, and how many of the recovered ones are real rather than cascade noise? A recovery that reports twelve errors for five mistakes is worse than one that reports one.
Tip: Cap the reported errors at ~20 and say "…and N more". Beyond that nobody reads, and a pathological input can generate thousands.
Runtime Errors and the Traceback
The two rules from Lab 7, restated because they are the ones that break silently:
- Capture the traceback where the error is created, not where it is caught. By the catch site
the
FrameGuards have unwound and the frames are gone. - The frame's span is the caller's line.
FrameInfo.call_span— where the call was made — is what makes a traceback navigable. The callee's definition line tells you nothing you did not already know.
And truncation, copying Lua: first 10 frames, ..., last 11. A 200-frame traceback is unreadable
and, in a log pipeline, expensive.
The help: Line
The highest-value diagnostic feature per line of code, and it is available because of information the runtime already has:
#![allow(unused)] fn main() { // A field miss on USERDATA can list the fields, because the builder registered them. // A field miss on a TABLE cannot list keys — that would leak data and could be huge. // A miss on a GLOBAL can suggest a close match, because the globals table is enumerable. fn help_for_index_miss(&self, obj: Value, key: Value) -> Option<String> { match obj { Value::UserData(u) => Some(format!("available fields: {}", self.userdata_fields(u)?.join(", "))), _ => None, } } fn help_for_missing_global(&self, name: &str) -> Option<String> { // Levenshtein distance ≤ 2 against the globals table. `articl` → `article`. // And a special case worth having: names that exist in LUA but not in Ember. if let Some(reason) = LUA_ONLY_NAMES.get(name) { return Some(format!("`{name}` exists in Lua but not in Ember: {reason}")); } self.closest_global(name).map(|c| format!("did you mean `{c}`?")) } }
That LUA_ONLY_NAMES table is worth building. A script author who writes os.time() gets
attempt to index a nil value (global 'os'), which is correct and unhelpful. With the table they
get "os exists in Lua but not in Ember: excluded for determinism; ask your host to grant the
CLOCK capability" — which turns a dead end into an action.
Errors as an API
The rendered string is not the API. EmberError exposes:
#![allow(unused)] fn main() { pub struct EmberError { pub kind: ErrorKind, // ← the API. Match on this. pub message: String, // ← presentation. Do NOT match on this. pub span: Option<Span>, pub traceback: Vec<Frame>, } }
Document it explicitly: hosts match on kind; the message text is unstable and may change in any
release. The differential test makes
the same distinction for the same reason — the two backends legitimately produce different message
text for the same kind.
Things to Notice
- Lab 24 renders; it does not retrofit. Every input it needs was created earlier by a decision that looked like overhead.
- The caret's position is an argument for span discipline, and it is the single most visible payoff in the curriculum.
- Recovery that reports cascade noise is worse than no recovery. Measure the real-error rate.
kindis the API; the message is presentation. Two backends, two message texts, one contract.- The
LUA_ONLY_NAMEStable turns every documented divergence into a helpful error, which meansappendix/lua-differences.mdearns its keep twice.
Validation / Self-check
- Name the seven components of a good diagnostic and the lab each one's input came from.
- Why does the caret land under
article.boostrather than under*? Name both contributing decisions. - Why must tabs pass through the caret's padding rather than becoming spaces?
- Why is the traceback captured at error creation, and what is the symptom otherwise?
- What is the metric that decides whether error recovery was worth adding?
- What can a
help:line say about a userdata field miss that it cannot say about a table's? - Why is the rendered message not part of the API, and which test relies on that distinction?
Next: The CLI and the REPL.