Improving Sola's errors for development
After quite some time of not working on Sola for various reasons, I decided to get back into it with a relatively simple change that will make development much nicer in the future: adding a feature for development to display the line of code in the compiler that threw the error.
For example, in the following program the following error gets thrown:
fn fib(x: i32) -> i32 {
let y: i32 = 1;
if x < 3 {
y
} else {
fib(x - 1) + fib(x - 2)
}
}
fn main() -> i32 {
fib(7)
}
Error:
╭─[test.sla:6:9]
│
6 │ fib(x - 1) + fib(x - 2)
│ ─────┬────
│ ╰────── function fib not found
│
───╯
Now, of course, I want to figure out where that error is coming from because this program should be valid according to my own "specification" of Sola (which at this point isn't written down in anything but a few test programs and by definition the compiler.).
To figure out where that error is coming from, I had to search for what I thought was a constant string in the error message in the compiler source code. And in this case, it was pretty easy to figure out that it came from the resolver. In general I would like to improve this significantly by being able to have the compiler just spit out in what file and what line the error originated.
A very simple change that I'll have enabled by default is that the different kinds of error
, namely parser
, resolver
and compiler
error are now displayed instead of just a simple "error". The commit can be found here.
Resolver Error:
╭─[test.sla:6:9]
│
6 │ fib(x - 1) + fib(x - 2)
│ ─────┬────
│ ╰────── function fib not found
│
───╯
Implementation of Rust locations
To make my life a little easier I made the following helper module. It contains a struct for remembering where in the Rust code the error originated, a display implementation for easy printing, and a nice little macro that you can call to create the CompilerLocation
struct.
pub struct CompilerLocation {
pub file: &'static str,
pub line: u32,
}
impl Display for CompilerLocation {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
write!(f, "{}:{}", self.file, self.line)
}
}
macro_rules! compiler_location {
() => {
CompilerLocation {
file: file!(),
line: line!(),
}
};
}
Now it's just a matter of adding the compiler location to each of the error types and then printing them as a note.
It is here that I realize that Rust's macro imports are weird and you need to have the module with the macro in it declared before the module in which you want to use the macro. e.g.:
#[macro_use]
pub mod helpers; // this contains the macro
// these use the macro
pub mod compile;
pub mod resolver;
But having that figured out, the actual implementation was extremely straightforward and just a bit tedious for the resolver and compiler, which are hand-implemented, but for the tokenizer and parser, it is not so simple. Those are libraries and don't have a way that I could figure out to extract where exactly the error is coming from. And especially in the case of the parser, I'm not sure if that's even technically possible because commonly, multiple combinators together are responsible for a single error.
Result
With those changes done, the error message now looks like:
Resolver Error:
╭─[test.sla:6:9]
│
6 │ fib(x - 1) + fib(x - 2)
│ ─────┬────
│ ╰────── function fib not found
│
│ Note: This error occurred in the compiler at: src/resolver.rs:691:36
───╯
And a single click on that path in my IDE takes me to exactly the line of code where the error is generated. In this case:
let function_id = resolver
.all_functions
.iter()
.find(|(_, function)| function.definition.name == ustr(call.name))
.map(|(id, _)| id)
.ok_or_else(|| ResolverError {
message: format!("function {} not found", call.name),
span: span.clone(),
compiler_location: compiler_location!(),
})?;
The commit can be found here.
Now that I've made this little change I hope I can get back into rhythm of occasionally doing improvements to Sola again.