Errors as a System
One format, one path, one error per line — before storage brings a dozen more
Make every error the program can produce travel the same way: detected where it happens, carried up as a value with its code and position, printed once by the loop in one format, and followed by the next prompt.
- An error code as a small system: the first two digits say which layer noticed, the last three say what it noticed
- Detection and reporting are different places: the layer that finds an error is rarely the one that prints it
- Errors as values: an error travels up through return values, never by printing on the way or by ending the program
- One line, one error: the first layer to fail decides, and the layers after it do not run
- Recoverable and fatal: almost every error ends a line, only a few end the program
Why this stage exists
Count the places that can go wrong so far. The loop rejects unknown meta commands. The lexer rejects characters and unterminated strings. The parser rejects statements that do not fit the grammar and INSERTs whose counts do not match. Three layers, written at three different times, and it is likely that they report their problems in three different ways: one prints and carries on, one prints and returns a flag, one prints two lines.
The next stages add the pager, the file, the B+Tree, and statement execution. Each brings errors of its own — a page out of bounds, a file that cannot be opened, a duplicate key, a full node. If every one of them chooses its own way to fail, the program ends up with errors printed twice, errors printed in the middle of other output, errors that end the session, and errors that vanish. Now, with only three layers, is the cheapest moment to decide how an error travels. After this stage, adding a new error means adding a code and one line that detects it, and nothing else.
What changes
Until now each layer had its own debug command, tokenize or ast, and its tests talked to it directly. From this stage on, a line that is not a meta command is a statement, and it goes through every layer in turn. A statement the parser accepts has nowhere to go yet: what it prints is not tested until the stages that store rows, so printing nothing is fine. Here is one bad statement making the whole trip:
The lexer found the problem, but the loop printed it. Between them the error was a value — a code, a column, a message — returned instead of a token list. The parser was never called, because there was nothing for it to parse. And the loop went back to the prompt, as it does after every answer.
This has two consequences you can see from outside. First, a line produces at most one error. launch #; is wrong twice: # is not a character the lexer accepts, and launch is not a statement. Only the first layer to fail gets to say so, and that is the lexer, so the answer is 00202 and never also 00301. Second, nothing but the error is printed. No token list, no half an AST, no second line saying "Syntax error". A person reading the output sees one clear sentence; a test reading it sees one line to check.
Codes as a system
The code is SSCCC: two digits for the layer, three for the condition inside it.
SS | Layer | Codes so far |
|---|---|---|
01 | command line and loop | 00101 unknown option, 00102 option missing its argument, 00103 unknown meta command |
02 | lexer | 00201 unterminated string, 00202 unexpected character |
03 | parser | 00301 unknown statement, 00302 any other syntax error, 00303 INSERT count mismatch |
Two rules make this a system rather than a list. One condition has one code, so a code tells you exactly what happened without reading the message. And a code never changes meaning: a number is never reused, and the layers that come later get ranges of their own (06 for the pager, 07 for the B+Tree, and so on) instead of squeezing into these. The full table, including every code still to come, is in the contract.
The message is for a person and is yours to write. Make it say what was expected and what was found. If you have a suggestion, put it on a second line starting HINT:; it is optional, and nothing checks what it says.
The algorithm in words
The path of an error
Every error goes through the same four steps, whichever layer finds it. What matters is that each step happens in exactly one place.
- 1.Detect. The layer that sees the problem stops what it is doing and describes it: a code from the contract, a column when the problem has one, and a message for a person.
- 2.Carry. It hands that description back to whoever called it, as its result. It prints nothing and does not end the program; it cannot know whether it was called from a session, from
-c, or from a debug command. - 3.Pass on. Each layer above receives the error instead of a result, does none of its own work, and hands the same error further up. A layer may add to the message; it never changes the code.
- 4.Report. The loop, which is the only place that knows how the line arrived, prints the one error line — and, if you like, a
HINT:line after it — then goes back to the prompt. - 5.Invariant: between reading a line and printing its answer there is exactly one decision about what to print, and it is made at the top.
Deciding whether an error ends the program
Most errors are about one line. A few are about the program itself being unable to start.
- 1.A line that cannot be tokenized or parsed, an unknown meta command, anything wrong with one statement: print the error and read the next line. In
-cmode, where there is no next line, exit 1. - 2.A command line the program cannot understand: print the error and exit 1 before the session starts.
- 3.From the
persistencestage, a database file that cannot be used is the same: nothing can work without it. - 4.Invariant: the loop has no path from an error to the end of the session. The only ways out are
.exit, end of input, and the handful of errors that happen before the loop begins.
Positions
The lexer is the last layer that sees characters, so it is the one that has to keep their positions for everyone else.
- 1.Trim the line before anything reads it, so column 1 is the first character that is not a space.
- 2.Give every token the column where it starts. A lexer error uses the column of the character; a parser error uses the column of the token it could not accept.
- 3.Count the column in the line as typed: a statement at the prompt starts at column 1, and after
tokenizeorastit starts later.
Output contract for this stage
These are the exact lines the tests check. The full definition is on the output contract page, sections 2 and 3.
| Rule | What the tests check |
|---|---|
| an error is one line | [ERROR:SSCCC] <message>, the code from the contract |
| lexer and parser errors have a position | the line ends with at col N, N counted from 1 after trimming |
| a hint is optional | a HINT: <text> line may follow an error line, and only an error line |
| one error per line | the first layer to fail reports, and no other output comes from that line |
| an error ends the line, not the session | the next line is read and answered normally |
-c reports failure in its status | the error line alone, and exit 1; exit 0 when the command worked |
| everything on standard output | errors included, so the order of a session is kept |
Only two kinds of error end the program: a command line it cannot understand (00101, 00102), and — from the persistence stage — a database file it cannot use. Everything else is an answer to one line.
What you should see when done
Welcome to droid!>select # from users;[ERROR:00202] Unexpected character: # at col 8> select # from users;[ERROR:00202] Unexpected character: # at col 8>select 'abc[ERROR:00201] Unterminated string literal at col 8>launch #;[ERROR:00202] Unexpected character: # at col 8>launch users;[ERROR:00301] Unrecognized statement: launch at col 1>insert into users (id, name) values (1);[ERROR:00303] Column count and value count must match at col 39>ast delete from users;Statement: DELETETable: users>.exitexiting.. good bye!
Things to notice. The indented statement reported the same column as the plain one. launch #; reported only the lexer's error, and launch users;, which the lexer accepts, reported the parser's. No line printed anything besides its error. And the ast command at the end worked normally after five errors in a row.
From a script:
$droid -c "select # from users;"[ERROR:00202] Unexpected character: # at col 8$echo $?1$droid -c "ast delete from users;"Statement: DELETETable: users$echo $?0
Common bugs in this stage
- Errors printed where they are found. The lexer prints, then returns failure, then the loop prints again — or the parser prints "Syntax error" after the lexer already explained. Detect in one place, print in one place.
- Two lines for one error. A code line followed by a generic
Syntax errorline. The second line is either the optionalHINT:or nothing. - The parser runs after the lexer failed. It then reports its own error about the missing tokens. When a layer fails, the layers after it do not run.
- Partial output before the error. Tokens or AST lines printed as they were built, then an error. Build the whole answer first; print it only if nothing failed.
- The error ends the program. An error path that calls the exit routine works for
-cand kills every session. Only the loop decides what happens after an error, and it goes back to the prompt. -cexits 0 after an error. The status must say whether the command worked; a script has nothing else to go on.- The column is counted before trimming, or from 0. Trim first; the first character is column 1.
- The code comes from the message. A code chosen by matching the message text, or a message reused for two conditions with one code between them. One condition, one code, decided where the error is detected.
- Errors on standard error. Everything goes to standard output so a reader sees the session in the order it happened.
Language notes
The page above is the same for every language. These are the only language-specific hints.
- C: there are no exceptions, so an error travels in the return value. A common shape is a status return plus an error description the caller passes in, filled in by whoever detects the problem. Check every return at every call; the compiler will not remind you.
- C++: exceptions work, as long as exactly one
catchsits in the loop and nothing below it prints.std::expected(C++23) keeps the error in the return value instead, which makes the path visible in every signature. - Rust:
Result<T, E>with an error type that carries the code, the column and the message;?is the whole of "pass on". Print only in the loop, from theErrit receives. - Zig: error unions pass errors up with
try, but an error value carries no data. Keep the code, column and message in a small diagnostics value the caller provides, and let the error union say only that something failed.
Checklist
One line per thing the tests check. The tags name the test cases.
- A statement typed at the prompt reports a lexer error at its column, counted from the first charactererror-has-position
- Leading spaces do not move the columnerror-position-after-trim
- Six bad lines, from the lexer, the parser and the loop, print six error lines with their six codes and nothing elseerror-one-line-format
- A line wrong in two ways reports the first layer's error onlyfirst-error-wins
- After an error, the next command prints exactly its own outputerror-then-continue
-cwith a bad statement prints only the error line and exits 1cli-error-exit-code-cwith a command that works exits 0cli-success-exit-code