Why Compilers Don’t Autocorrect “Obvious” Parse Errors
chelseatroy.com
chelseatroy.com
1) if your language papers over a syntax error, then that error is effectively just an alternative syntax
2) alternative syntaxes make a language more complex
3) complex languages take more work to implement and more work to learn
When you compile a file successfully, make an edit, then compilation fails, are there any compilers/IDEs that compare the before-and-after of the file to create better error messages? The compiler would have a lot of extra information this way because files usually change gradually and not all at once.
I'm thinking of cases where you're refactoring some code but miss out a bracket, the compiler says the missing bracket could be anywhere down the bottom of the whole file but anyone watching intuitively knows what block of code the missing bracket likely falls into, usually localised around what you just edited.
> elsif say_goodbye nd we_like_this_person:
> If the compiler tried to automatically add a colon, I’d have two colons and the code is even wronger.
Couldn't a smarter compiler guess that because "we_like_this_person" and "say_goodbye" are defined variables and there's no variables similar looking to "nd", that "nd" should probably be the "and" keyword?
I'm surprised by how unhelpful error messages still are for most tools. I'm curious how much this is because it's a very hard problem rather than it's a neglected area that developers accept as normal. I heard Elm is meant to be good here (where strong static typing allows for certain kinds of hints): https://elm-lang.org/news/compiler-errors-for-humans
As a counterexample, suppose you are checking out a new version of a file, or a new branch with many changes across many files. Identifying this usage would require the compiler to be aware of the version control system, and still wouldn't correctly identify that the version sent from $COWORKER via email for some weird reason isn't a gradual change.
For me personally, debugging is difficult enough without needing to worry that the compiler is going to maintain state across multiple runs. If I see an error message that is different at all, I assume that means I'm triggering a different failure mode, and debug accordingly.
Edit: That said, the Rust compiler is tremendous with error messages, without relying on time-dependent state. If a variable is misspelled, it will look for similarly named variables that are in scope, and ask if you meant one of them. But this behavior is still consistent for a given file and compiler version.
Ooh, the idea makes me shudder.
I remember looking at a project's makefile which called a custom build script where the README said to run the buildscript twice-- once to generate some state, and a second time to compile stuff using that state.
Without any comments provided, the makefile called the custom buildscript three times in a row.
I can't even imagine the superstition and cargo culting that would arise from an IDE "helping out" by analyzing who has changed what, when, and in what order they changed it.
Please paste a new empty function named "momo" here before doing a release. Also make sure your blinds are closed before compiling.
One thing that winds me up is error messages that tell you that some string can't be parsed or a file can't be read but they don't show you the string or the file path.
This is especially irritating for the actual end user because they generally do not have the opportunity, or knowledge, to run the program in a debugger or to examine the source code.
Then simply rebase out the intermediate steps before pushing.
There was a PhD thesis I read in the 90s that included a version of this idea. I forget the specifics.
For a compiler, you want to stop at the first error. The parser can also emit an intermediate representation as it goes, so that what it is processing is not necessarily serializable to the original code. This makes it difficult to use as a data model for tools like IDEs.
For an IDE, you want to process the entire file, recovering from errors as you go. This is so that the IDE can keep things like function resolution working without turning the entire file red as the user is typing code, while ideally only updating the references that haven't changed. It also allows the IDE to offer different fixes and auto-complete functionality.
This makes it difficult to share parser logic between the two.
You do not actually want to stop at the first error in either case. You want to accumulate all the errors at a given phase of the compilation and halt. Sometimes that allows other phases to progress (for example, you do not want an error in one compilation unit to halt the compilation of any other units until linking in an incremental compiler).
You do actually want to reuse IRs in the IDE, otherwise it can be extremely difficult to get certain things correct (and some are next to impossible, like macro expansion/syntax extensions, decompilation of libraries, etc).
Unification of the reference compiler and IDE backend are extremely desirable, in my opinion. Very few languages take that tact (C#/.NET being a major exception) but not because it's a bad design - it's because it's hard. Writing a lexer and parser is easy if you don't care about edits and and updates. And there are very few parser generators that do make that possible (tree sitter being the major exception). And once you have a working lexer/parser it is difficult to replace it in your compiler, so few language devs ever take that approach.
It's essentially a massive engineering effort in something that is rather boring for low payoff in the early life of a language implementation, the RoI is only obvious much later when usage scales up. So it's unsurprising many languages do not do it early on, and like objects, most languages die young.
It’s difficult to reuse traditional compiler logic in an IDE, but there’s good examples that the reverse isn’t true. IDE validation of language semantics is a strictly more complex problem, but if you start by solving that, it’s not as hard to add a compiler backend. A compiler’s job is to either take correct code and translate to its compiled form or take incorrect code and report errors. There’s no reason an IDE-focused parser/compiler can’t do both.
IIRC, Microsoft talked publicly about how they built the C# compiler as IDE-first and found that it simplified things greatly. And I think there has been substantive discussions within the Rust community about bringing parts of rust-analyzer into the official compiler whereas the RLS approach of reusing compiler APIs wasn’t able to provide a reasonable IDE experience.
The XPath lexer and parser are designed to be overridden where needed to implement the XQuery lexer and parser.
The lexer itself has state as a stack-based lexer in order to tokenize the different structures (string literals, comments, embedded XML) correctly. A compiler could use the parse state as the context to drive the tokenizer without needing a state/stack-based lexer.
The lexer also treats keyword tokens as an identifier type as keywords can be used as identifiers. This is not necessary in a compiler as it knows when it is reading/expects a keyword.
My parser handles the different versions of XPath/XQuery, the different extensions, and vendor-specific extensions all in a unified lexer/parser. A compiler could ignore the bits it does not support and simplify some of the logic.
My QName parser is very complex due to providing error recovery and reporting for things like spaces, etc. -- Other parsers (e.g. Saxon) treat the QName as a single token.
I'm also generating a full AST with single nodes removed, e.g.:
XPath
InstanceofExpr
IntegerLiteral "5"
XmlNCName "instance"
XmlNCName "of"
SequenceType
AtomicOrUnionType
QName
XmlNCName "xs"
Token ":"
XmlNCName "string"
Token "?"
I'm traversing this AST to do things like variable and namespace resolution. For the modules, I'm using the IDE's mechanisms to search the project files. -- In a compiler, these would be collated and built as the file is parsed, which does not work with incremental/partial parsing.I'm getting to the stage where I can evaluate several static programs due to the need of implementing IDE features, and providing static analysis.
In addition, I suppose that there are people hard at work applying ML in tools to help understand incomplete code and mitigate the false positive problem of traditional static analysis. I can imagine probabilistic parsing being useful in this case, but not so much in compiling.
- The compiler code becomes more complicated, making correctness harder
- The compiler might become slower to run
- Introducing new languages features may become harder, again due to code complexity
> Introducing new languages features may become harder, again due to code complexity
It'll be written for IDEs anyway. Might as well reuse if possible, right?
Turbo Pascal at the time just stop at the first problem and the student could focus on addressing that one and only one issue at the time. Yes it was a game of whack-a-mole with syntax errors but at least it was a straight forward process to getting something to compile
A while back I looked at how several languages implement this and Pascal was actually one of the better ones. It is a very hard problem...
It also helps that Turbo Pascal was an extremely fast compiler for its time. So you could fix one error, re-rerun the compiler, and get another error quickly.
first time I ever "wrote" a program was hand copying one from PC Magazine. Knowing nothing about pascal syntax nor semantics, what you said describes that whole week of mine.
Yet it turned out that doing that introduces a lot of subtle security issues. Today many people came to the conclusion that the robustness principle was a mistake: https://www.ietf.org/archive/id/draft-iab-protocol-maintenan...
Maybe this is semantics, but a loose syntax is different than the language trying to automatically correct mistakes.
JavaScript has optional semi-colons and braces. The semicolons seem to fall into the into the autocorrecting category because you are supposed to use them. Optional braces are a language feature shared with C.
Ruby has optional parentheses on method calls, which is usually fine until you attempt to do `a(b(4))` as `a b 4`. It’s easy to get into a syntax error omitting writing code like that. But the fact it will give you a syntax error when it hits an unclear structure means this is a (mis-)feature, rather than an attempt as guessing what you meant.
(A preprocessor could be used to fix it if wanted, I suppose, but then it must be preprocessed and converted)
That said when you look at either in terms of how they are implemented it'll seem like a correction feature. I think the real difference between auto-correction and optional syntax is simply whether or not the language spec designed it to be optional.
The grammar for e.g. an if statement is simple: *if (* expression *)* statement *else* statement. One particular value of statement is a block statement, which is where the braces come from. Nothing more, nothing less.
Inversely, the grammar specifically says that most statements (of types empty, expression, do-while, continue, break, return, throw, and debugger) must end with a semicolon, and ASI is explicitly described as a few cases where you're allowed to add an extra token to the token stream when the grammar refuses to accept the stream as-is.
Here is an example to illustrate:
console.log('a')
(1 < 2) ? console.log('b') : console.log('c')
You might expect this to output 'a', then 'b'. However, it instead outputs 'a' and then throws an error like this: Uncaught TypeError: console.log(...) is not a function
...because a semicolon was not inserted at the end of the first line. console.log = function(value){console.error(value);return function(value2){console.error(value2)}}
Your example runs just fine because there was never actually a syntaxError anywhere in it to begin with, let alone a sytanxError that could be fixed with a ; by ASI. Similarly if I define console.log = 2 all of the above will throw typeError but that also has nothing to do with ASI.This is precisely what "but if you need them to be separated in a special way you can add semicolons to manually control separation behavior" was referring to.
Does this distinction matter in practice? Probably not. The more important different is probably just that JS has more unfortunate edge cases related to semicolons than Lua.
Compilers used to correct obvious parse errors a lot more than they do now. The goal wasn't to make the program pass compilation so that the user can ignore the error messages. That would be harmful, as noted above. The goal is to be able to continue processing the program and uncover more errors in it in a useful way.
There is a gamble there:
- if you make a good correction to the token stream, all is well: you can diagnose more errors later in a pertinent way.
- if the correction is wrong, then the compiler may emit a flurry of nonsense errors which caused by the correct, so that only the first diagnostic makes any sense.
There is a third risk:
- the correction may lead to looping. This risk exists in any correction that lengthens the token sequence. The compiler may have to quit when the error count reaches some defined maximum. The looping may otherwise be infinite, or possibly unpredictable in length (think Hailstone Sequence).
In the 1970's, Creative Computing magazine conducted a contest to see who could produce the most error messages using the least amount of code.
The reason old time compilers tried to correct as many errors in a single run is that the programmers didn't always have use of the computer; they had to produce the program using keypunch equipment onto punched cards, and then line up at a job submission window, where an operator would submit their card deck for execution. You wouldn't want to line up to fix one semicolon at a time.
It's like having dual-path redundancy in an airplane avionics system. If they disagree, then it is clear there's a fault in one of them - but it doesn't mean you can tell which one is erroneous. Without redundancy, there's no way to detect faulty operation.
Guessing which parse is correct, or which avionics subsystem is correct, is as bad as no redundancy at all.
Unfortunately, programming language syntax is not as redundant as we would wish. When the code is in the valid syntax, the compiler can parse it. When the code isn't in the valid syntax, the compiler doesn't even have a valid base to parse any information out of the code. The compiler writer may assume a common cause for certain parsing error and insert some "meaningful" error messages, but that is very different from the compiler knows anything. The "redundant" information is carried in the out-of-band channel (human vocabulary and common patterns) rather than in the syntax.
The compiler can (and does, for error messages and error recovery) guess at what was meant, but it cannot know what was meant.
This really deserves proper attribution: it's the opening sentence of Anna Karenina by Leo Tolstoy.
In a further tangent, I've long been fond of a mildly-related idiom (whose source I do not know) which instructs the listener
"Never wrestle with a pig. You both get dirty and the pig likes it."
PTU LIST('Hello, world!
into a valid program (in fact, the claim was that it would never fail to convert any string of text into a valid program).
PL/C made a lot of sense when short student programs were entered on punched cards (and hence trivial typos were tedious to correct) and batch turnaround times were measured in hours. This makes much less sense when (a) editors can give us clues about typos right away, e.g., by indenting in a surprising way, and (b) compile times for short modules are very short.
And there are still edge cases that could be ambiguous to humans, so you definitely want any compiler to refuse ambiguous programs. Computers are mathematical machines, they do everything without asking for your permission, so you better pray their behavior is well defined.
Look at what happens when language are ambiguous like javascript or HTML: it becomes hard to use, and js engines are monsters you don't want to understand how they work. I'm not a fan of C++ and its difficulty, but it's my favorite language because it's well defined.
Maybe compiler engineers may attempt to demonstrate how inserting semicolons in some place could create undesirable situations. Writing parsers is one of the toughest programming task, in my view.
Rules in languages don't exist for nothing. Even duck typing has a cost. It's like deciding that people can drive anywhere on the road, and let people decide how to avoid each other. Sure they can, and it would work 99% of the time, but 99% of the time is not good enough.
Javascript Error Steamroller
FuckItJS uses state-of-the-art technology to make sure your javascript code runs whether your compiler likes it or not.
Technology Through a process known as Eval-Rinse-Reload-And-Repeat, FuckItJS repeatedly compiles your code, detecting errors and slicing those lines out of the script. To survive such a violent process, FuckItJS reloads itself after each iteration, allowing the onerror handler to catch every single error in your terribly written code.Years later, when I was meeting with Tim Berners-Lee, he wanted to see the doc for a Web-related Scheme library I had with me, and he started speed-reading it in front of me. The doc had an irreverent criticism I'd thrown in, about the practice of overly-permissive parsers in Web browsers. In the days of dotcom gold rush, when anyone who would spell "HTML programmer" was getting truckloads of investment money dumped on them, I'd proposed a very prominent angry red browser error indicator for Web pages with invalid with HTML. I thought that having that could be a source of shame, like the creator of it didn't know Web, and all the people tossing around money blindly and not knowing who to invest in might take that as one indicator. :) (Sir Tim later gave a big talk endorsing Python for the Web, but he did reference one of my arguments for why I was adopting Scheme at the time.)
"Conservative in what you send, liberal in what you accept" seemed a good default model for protocol interoperation, especially in an environment of legacy systems and imperfectly-specified protocols. But Web was new, and HTML was often being handwritten, and having the Web browser silently accept invalid and often ambiguous HTML without giving any indication it was wrong even during development seemed to create an unnecessary mess.
I actually had to spend a chunk of last weekend dusting off some code to handle that mess, because another open source developer was still running into the mess: https://www.neilvandyke.org/racket/html-parsing/#%28part._.H...
The HTML5 spec has a long, detailed set of rules for consistently parsing bad HTML. They're very funny to read. That was the best anyone could do at that late date.
It was good the way autocorrect is good today, and I hated it, but you couldn’t switch it off because it was also used for macro expansion!
The manual entry for DWIM:
However, one could easily imagine a design where certain keywords automatically introduce a block after the current line, which would eliminate the need for the colon. It would prevent one-liners (e.g. “if x: y”) but that’s no big loss. The colon would continue to be used for e.g. lambda, dict and annotation syntax.
The computer does not know that. The computer is being too smart. And probably wrong.
The fact that some programming languages are overly pedantic is part of their design.
SyntaxError: Missing parentheses in call to 'print'. Did you mean print("a")?
Well yes... obviously... so please just print it.
Which leads us to the real issue at hand: if the compiler is going to do anything by itself, that means it is following well defined rules. Therefore, whatever automatic thing the compiled does is part of the language. And, sometimes, the design rules of said language plain and simply do not allow for that.
The compiler is smart enough to guess what variable I meant when I misspell a variable. How come nobody's ever given me a tool to close the loop and when the error is reported, confirm that I want my source code edited to correct and correct it in place?
Copilot, however, begs to differ.
"line 5 needs a terminating semicolon -- add it?"
Has this been attempted?
What they shouldn’t do is produce a binary based on their (smart or stupid) guesses about the programmer’s intention.
That allows you to compile, fix multiple typos, compile, instead of compile, fix one typo, compile, fix the next typo, compile, etc, _and_ prevents you from running a program that you didn’t write.
I am not aware of any compiler that doesn’t do this, as it would be extremely annoying to have a compiler give up at the first error.
The search term to use is parser error recovery. It doesn’t give obviously great hits, though. Sample hits:
- https://www.geeksforgeeks.org/what-is-error-recovery/
- https://cs.adelaide.edu.au/~charles/lt/Lectures/07-ErrorReco...
- https://en.wikipedia.org/wiki/Burke–Fisher_error_repair
- https://en.wikipedia.org/wiki/LR_parser#Syntax_error_recover...
A few years ago GCC wasn't as good at error recovery, so the "too many errors, bailing out" message was a common occurrence (code for Internal Compiler Error, but managed to print at least one diagnostic). Today it is much much rarer to encounter it and using the compiler is a much more pleasant experience.
Instead, compiler authors need to understand and prioritize good ergonomics. Diagnostics should be accurate, come with suggestions, have unique error codes you can look up, and follow patterns you can predict over time.
I think languages that use different ways to delineate different loops (do…od, if…fi, while…wend, repeat…until) make it easier to do error recovery of “about compilable” source than C-style ones that use {…} everywhere. In general, redundancy will improve the ability to do error recovery.
(1) The trick is to not think about code quality at all. Emit assembly for every individual statement, never inline functions, feel free to write a load from/store to memory for every variable read/write, etc. It will get you slow code, but also code that’s faster than an interpreter for the same language (your version 2 could post-process to eliminate superfluous loads and stores. Even only removing loads gives a speed up and a code size decrease).
Can you give an example of CPython, or GCC/clang autocorrecting a parse error?