Sprout series · Part 2 of 5
Technical guide · 13 October 2026 · 8 min read
Error messages that teach: "Did you mean" inside a C interpreter
Build typo suggestions, stable error codes and a try that won't hide name typos, traced through Sprout's C source.
Should try be allowed to hide a misspelled variable name?
In Sprout, the language I built for first-time programmers, it isn’t. A misspelled name stops the program, inside a try or not, and when a close match exists the error names it. Getting there in C took an edit-distance search, error kinds I promised never to rename, and a hint that once made some lookups 12–55× slower.
The smallest case is two lines, saved as hello.sprout. Running it prints the file, the line, the problem and one guess.
make name = "Sam"
show nme
Sprout error in hello.sprout (line 2): I don't know what 'nme' is.
Did you mean 'name'?
Many languages answer that typo with an exception class and a traceback, assuming the reader knows what an exception is. Beginners see this screen long before they open the docs, so it got as much design work as the parser.
fail_full, the one door every error goes through
The lexer, parser and runtime all report problems through fail, fail_kind or fail_hard, one-line wrappers around fail_full. It prints the location, the message, and a call trace when inside a task:
static void fail_full(int line, const char *msg, const char *kind, int hard) {
...
if (!hard && g_quiet_fail && err_jmp) SJLONG(*err_jmp); /* soft error caught by an enclosing try: (don't print) */
fprintf(stderr, "\n Sprout error");
if (g_current_file) fprintf(stderr, " in %s", g_current_file);
if (line > 0) fprintf(stderr, " (line %d)", line);
fprintf(stderr, ": %s\n\n", msg);
...
No error can drift from that shape, and a format change improves every message at once. A shared arg_error helper for built-in type checks previews the bad value, as in add expected its 1st input to be a list, but got a number (5).
Levenshtein distance in two rows of stack memory
The guess comes from Levenshtein distance, the fewest single-character insertions, deletions and substitutions that turn one string into another:
static int edit_distance(const char *a, const char *b) {
int la = (int)strlen(a), lb = (int)strlen(b);
if (la > 64 || lb > 64) return 99;
int prev[66], cur[66];
for (int j = 0; j <= lb; j++) prev[j] = j;
for (int i = 1; i <= la; i++) {
cur[0] = i;
for (int j = 1; j <= lb; j++) {
int cost = (tolower((unsigned char)a[i-1]) == tolower((unsigned char)b[j-1])) ? 0 : 1;
int del = prev[j] + 1, ins = cur[j-1] + 1, sub = prev[j-1] + cost;
int m = del < ins ? del : ins; if (sub < m) m = sub;
cur[j] = m;
}
for (int j = 0; j <= lb; j++) prev[j] = cur[j];
}
return prev[lb];
}
The textbook version fills an la × lb table, but each row needs only the one above, so two stack arrays suffice and nothing is allocated. Past 64 characters it returns 99, “not close”, so the cost is bounded. Comparing lowercased characters puts Nme one edit from name instead of two, and since three-letter names get only one edit, that’s what lets show Nme suggest name.
Picking one suggestion, or none
An unknown variable goes to suggest_name, which ranks candidates and decides whether the best one is close enough. Variables come from the scope chain, so only the visible ones compete.
static const char *suggest_name(const char *name, Env *env, int include_vars) {
const char *best = NULL; int bestd = 1000;
if (include_vars)
for (Env *e = env; e; e = e->parent)
for (int i = 0; i < e->n; i++) { int d = edit_distance(name, e->vars[i].name); if (d < bestd) { bestd = d; best = e->vars[i].name; } }
...
int L = (int)strlen(name); int thr = L <= 3 ? 1 : 2;
...
return (best && bestd >= 1 && bestd <= thr) ? best : NULL;
}
Three characters or fewer allow one edit, longer names two. A flat 2 is far too loose for short names: sum and sin are two edits apart. bestd >= 1 drops an exact match, because echoing the user’s own word back teaches nothing. With no close match Sprout teaches instead of guessing: Variables are made with 'make', like: and then make nme = "Sam". The parser reuses the same distance for keywords, so shoe "hi" gets I didn't expect 'shoe' at the start of a line. Did you mean 'show'?
The map-key hint that made lookups 12–55× slower
Map keys got hints too, so order["cost"] on a map holding cost2 explains itself. But a truly absent key reads as nothing, a successful lookup, so the suggester runs on every miss. The first version ran edit_distance against every key, and per the changelog map["missing"] in a loop got 12–55× slower. An adversarial review caught it before release, and the fix put two cheap checks in front:
if (!m || !key || !*key || m->n > 512) return NULL; /* huge data-maps: optional lookups must stay O(1), skip typo search */
...
if (k[0] != key[0] || k[1] != key[1]) continue;
Maps over 512 entries skip the search. Smaller ones still walk their keys, but the full distance runs only for keys sharing the first two characters. I’d rather lose the hint than pay for it on every lookup.
Error kinds that never get renamed
Message text is for humans and should keep improving. Code needs something fixed, so every error carries a kind, and a caught error is always a map with at least message, kind and line. Catch show 10 / 0 on line 2 and you get {message: you tried to divide by zero., kind: math, line: 2}.
The docs promise the kinds are frozen since v0.1.0: new ones can be added, none renamed or removed. Tests can pin a kind, as Sprout’s expect error "index": blocks do. All but name are catchable.
kind |
raised by |
|---|---|
math |
divide or remainder by zero, sqrt of a negative |
type |
the wrong kind of value for an operator or [ ] |
index |
a position that doesn’t exist |
io |
a file that can’t be opened for writing |
fail |
your own fail |
error |
the default: a built-in called wrongly, a misspelled method, a use of a missing module file |
name |
an unknown variable or task, a bad module reference like mod.x |
Catchability still belongs to each error: calling a non-task with ( ) is a hard type error. The contract paid off in v0.1.21, when I rewrote the list-index message to name the length and range. A program branching on e["kind"] == "index" wouldn’t have noticed; one matching the old wording would have broken. fail {kind: "http", status: 404} gives your own errors the same shape, with missing standard keys filled in.
Why try can’t catch a name typo
A beginner who wraps code in try “to be safe” shouldn’t have to debug a program that silently does nothing. So soft errors, the ones correct code can hit (divide by zero, a bad index, a fail), are caught by try. Hard errors are mistakes in the program text, like an unknown name, a bad module reference or a syntax error, and they skip every enclosing try:
try:
show totl
caught e:
show "you will never see this: " + e["message"]
Sprout error in hard.sprout (line 2): I don't know what 'totl' is.
Did you mean 'title'?
title is a built-in (the Title Case function), so the typo is one edit from a real name. The program exits with status 1 and caught never runs. Underneath, C’s setjmp saves a bookmark and longjmp jumps back to it, abandoning every call in between. Sprout keeps two: err_jmp for the nearest boundary of any sort, g_top_jmp for the nearest system boundary (a test, a REPL line, a file run). A try: sets only err_jmp. A soft error inside a try jumps there before printing. Everything else prints, then takes this line:
sjmp_buf *target = g_top_jmp ? g_top_jmp : err_jmp; /* uncaught or hard: stop at the nearest SYSTEM boundary */
Lexer and parser errors use the soft fail(), but parse_file switches catching off for the whole parse, so a syntax error in a module loaded inside a try still escapes. System boundaries do contain hard errors: a typo fails one test or one REPL line, and the session carries on. A catch-all except Exception around a call that hits a renamed field does the opposite and treats a code bug as weather.
d.barkk(), case-only typos and private tasks
The gap that bothers me most is d.barkk(). A misspelled method gets a correct “Did you mean ‘bark’?” through the soft fail() with kind error, so try catches it when it should be a hard name error. The interpreter also raises key and value, two kinds the frozen table never lists; part 5 covers that gap and the CI check that would catch it.
Case is next. Lookup is case-sensitive but the distance isn’t, so Total next to an existing total scores 0, bestd >= 1 discards it, and nothing beats a 0. The user gets no hint and a fallback telling them to make Total; order["Cost"] gets none either, since the map-key pre-filter compares exactly. Replacing bestd >= 1 with strcmp(best, name) != 0 would still refuse the identical spelling and point Total at total, and that one line isn’t in the repo yet.
Tasks are the smallest gap. They come from a global table of every loaded file’s tasks, private ones included, and only the call path checks a task’s file, so Sprout can suggest a task you can’t call from here.
Mistyped map keys are the call I haven’t made. They raise a catchable key error, and keys often come from data, so a hard error might be wrong there. Would you want a typo in a key you loaded from a file to stop your program with no try able to catch it?