Compiler error reasoning: report it, and suggest a fix
The self-hosted PatLang compiler (self_hosting/patc1_main.patlang, driving patc1.exe — see the self-hosting story for what that actually means) had a real, embarrassing gap until recently: it had no parse-error handling at all. A syntax error in the input program didn't produce a diagnostic — it silently corrupted the compiled output or failed unpredictably deeper in the pipeline, with nothing to point at what actually went wrong. This page documents the fix: every parse error now reports its exact line, shows the offending source, states the problem in plain language, and suggests a concrete fix — the same "report on it, and suggest potential fixes" idea already proven for induction-engine gap diagnosis in the inductive-synthesis work, applied to the compiler's own errors instead of a failed rule search.
Deliberately scoped to PatLang's own errors only, never Rust's. PatLang's native-compiled path still shells out to rustc as its last step today, but the project's own direction is to remove that dependency entirely, not build more tooling around its error output. This reasoning never looks at a rustc diagnostic and never will — it operates purely on the self-hosted lexer/parser's own token stream and AST.
Before and after: the same broken program, two different outcomes
Take a program with an unterminated argument list — a missing closing parenthesis, with more code after it:
let x = 1
let y = 2
print(x + y
let z = 3
Before this fix, compiling this via patc1.exe produced no error message at all. The parser's own error placeholder (an ["Err", message] AST node) was generated internally, but nothing in the compiler driver ever checked for it — it just fell through into code generation, either silently vanishing from the output (PatLang's own "silent statement dropping" gotcha) or producing something a downstream Rust compile step would fail on with no connection back to the actual PatLang source line that caused it.
After the fix, the same input produces:
Parse error at line 5:
let z = 3
expected ) in argument list
Suggestion: A '(' opened earlier is missing its matching ')' -- check
argument lists and parenthesized expressions for a missing closing
paren, or a stray extra argument.
Note what the reported line actually is: not line 3, where the ( was opened, but line 5, where the parser finally gave up looking for the matching ) and hit let z = 3 instead. That's not a bug in the diagnostic — it's an honest reflection of how the parser actually failed: it kept consuming tokens as further arguments (there's no syntactic reason y couldn't be followed by more expressions) until it ran into something that couldn't possibly be an argument. The suggestion text still points the reader in the right direction (look for a missing closing paren nearby), and the source line shown is exactly where compilation gave up, which is the honest, useful thing to show even when it isn't the exact character the human made the typo at.
Worked example: running out of file mid-block
A different failure mode — a block that never gets its closing keyword, because the file simply ends:
let a = 1
if a > 0 then
print("yes")
let b = 2
Output:
Parse error at line 6:
(reached end of file -- nothing follows on this line)
expected else, elif, or end after if block
Suggestion: An 'if' block needs to close with 'end', or continue with
'elif <cond> then'/'else' -- check what follows the last statement in
this if-block.
Line 6 doesn't exist in the source shown above — it's one past the last real line, where the lexer's end-of-file token lives. Rather than showing a blank line with no context, the diagnostic says plainly that it reached end of file, which is a more honest and more actionable statement than pointing at nothing.
Worked example: an unexpected character, echoed faithfully
let a = 1
let b = @@@
Parse error at line 2:
let b = @@@
unexpected UNK '@'
Suggestion: This token wasn't expected here -- look for a missing
keyword, operator, or unbalanced bracket/parenthesis just before it.
This one looks unremarkable, but it's the case that broke the first version of this feature during development (see lessons below): the offending character literally being reported (@) is the same character used internally to separate the error message from its line number in the diagnostic-encoding scheme, and the first implementation didn't realize that could collide.
And when nothing's wrong
The same machinery correctly reports nothing at all for valid source:
let x = 1
let y = 2
print(x + y)
no error found
Unremarkable, but worth stating explicitly: a diagnostic system that's eager to find problems that aren't there is worse than useless. All four cases above — three broken, one valid — are real output from running the actual compiler against these exact files, not illustrative examples written after the fact.
How it works, briefly
Every one of the self-hosted parser's ~34 error sites (self_hosting/lib/parser.patlang) now attaches a line number, using position information the lexer already recorded on every token ([type, text, line]) but the parser had simply never carried forward into its own error nodes. A single parse_find_error function walks a parsed program looking for that error node — nested arbitrarily deep inside expressions, not just at the top level, since e.g. a malformed argument list produces an error buried inside a Call node, not a standalone statement. format_parse_error renders the final message: the source line, the raw parser message, and a suggestion from a small, deliberately specific lookup table (suggest_parse_fix) matched against the closed, known set of parser error messages — not a generic "syntax error" catch-all, the same "specific beats generic" choice the induction engine's own gap-diagnosis questions already made.
Two real bugs, found building this
Both are worth naming honestly rather than glossing over, since finding them is most of what "building this" actually consisted of.
The delimiter collision. The first working version encoded a message and its line number as a single string using a bare "@" as the separator, e.g. "expected ) in argument list@5", on the assumption that @ never appears inside an error message. That assumption broke immediately on the @@@ example above: the message text itself became "unexpected UNK '@'", and the parser's own extraction logic found the wrong @ — the one inside the message, not the one separating it from the line number — corrupting both fields (the reported line became 0, the message got truncated). The fix was to stop treating any single character as safe and use unambiguous multi-character markers instead ("@@ERR@@" + line + "@@" + message + "@@ENDERR@@"), which can't collide with arbitrary echoed token text no matter what the source file contains.
The bigger one: a false-positive bug that had been silently harmless until this exact feature exposed it. The error-detection mechanism works by rendering each AST node to text and searching for a recognizable error marker — and the renderer (ast_to_str) turned out to have no case at all for RuleDecl nodes, meaning every single rule pred(...). fact or rule declaration — used constantly throughout the whole logic-programming subsystem the induction engine depends on — fell through into the renderer's fallback case for "unrecognized node," which happened to be the exact same shape used for genuine errors. This bug had existed for a long time with zero effect, because nothing previously consumed that misleading rendered text in a way that could act on it. The moment the compiler driver started actually checking for errors and refusing to compile on one, this pre-existing gap surfaced as sixteen of twenty synthesis test suites suddenly "failing to compile" — every one of them built on background facts declared via rule. The fix was adding a proper rendering case for RuleDecl; the discovery is the more interesting part — a feature can be dormant and harmless for a long time, then turn into a real regression the instant something downstream finally starts trusting its output.
Both bugs were caught the same way everything else in this project gets caught: by running the actual, full existing test corpus (all twenty synthesis selftest suites, plus fantpop-patlang's own acceptance suite) immediately after the change, not just the small number of examples written to demonstrate the feature.
See also
The Journey of Building PatLang, Continued covers this as part of the broader engine-maintenance arc (Act X). Inductive Synthesis: from BDD scenarios to PatLang code is the earlier, larger system this reuses the "report and suggest a fix" idea from. Capabilities & Honest Limitations covers what this diagnostic reasoning does not yet do — it currently only covers parse/syntax errors, not runtime errors like an undefined function call or an out-of-range index.