Error Handling & Reporting
Good error messages are crucial for a pleasant programming experience. In this chapter, we'll improve Lux's error reporting to be helpful and informative.
Types of Errors
Lux has three categories of errors:
1. Lexer Errors
Occur during tokenization:
- Unexpected characters
- Unterminated strings
- Invalid escape sequences
- Invalid numbers
2. Parser Errors
Occur during AST construction:
- Syntax errors (missing semicolons, braces, etc.)
- Invalid token sequences
- Unexpected end of input
3. Runtime Errors
Occur during execution:
- Type errors (adding string to number)
- Undefined variables
- Division by zero
- Out of bounds access
- Wrong number of function arguments
Error Information to Track
Good errors need:
- Error message: What went wrong
- Source location: Line and column number
- Source context: The actual source line
- Error type: Category of error
Designing a Unified Error Type
Create src/error.rs:
#[derive(Debug, Clone)]
pub enum ErrorType {
Lexer,
Parser,
Runtime,
}
#[derive(Debug, Clone)]
pub struct LuxError {
pub error_type: ErrorType,
pub message: String,
pub line: usize,
pub column: usize,
pub source_line: Option<String>,
}
impl LuxError {
pub fn new(
error_type: ErrorType,
message: String,
line: usize,
column: usize,
) -> Self {
LuxError {
error_type,
message,
line,
column,
source_line: None,
}
}
pub fn with_source(mut self, source: &str) -> Self {
// Extract the source line
let lines: Vec<&str> = source.lines().collect();
if self.line > 0 && self.line <= lines.len() {
self.source_line = Some(lines[self.line - 1].to_string());
}
self
}
pub fn display(&self) -> String {
let type_name = match self.error_type {
ErrorType::Lexer => "Lexer Error",
ErrorType::Parser => "Parser Error",
ErrorType::Runtime => "Runtime Error",
};
let mut output = format!(
"{} at line {}, column {}: {}\n",
type_name, self.line, self.column, self.message
);
if let Some(line) = &self.source_line {
output.push_str(&format!("\n {} | {}\n", self.line, line));
// Add caret pointing to error position
let padding = format!(" {} | ", self.line).len();
let spaces = " ".repeat(padding + self.column - 1);
let caret = "^".repeat(1.max(self.message.len().min(line.len() - self.column + 1)));
output.push_str(&format!(" {}{}~~ here\n", spaces, caret));
}
output
}
}
Pretty Error Messages
Example: Before (Poor)
Error: Undefined variable 'x'
Not helpful! Where is the error? What line?
Example: After (Good)
Runtime Error at line 5, column 11: Undefined variable 'x'
5 | print x + y;
^~~ here
Much better! Shows location and context.
Implementing Better Errors
Update Lexer Errors
Modify src/lexer.rs:
use crate::error::{LuxError, ErrorType};
impl Lexer {
pub fn tokenize(&mut self) -> Result<Vec<TokenInfo>, LuxError> {
// ... existing code ...
Some(c) => {
return Err(LuxError::new(
ErrorType::Lexer,
format!("Unexpected character '{}'", c),
self.line,
self.column,
));
}
}
fn read_string(&mut self) -> Result<Token, LuxError> {
// ... existing code ...
if self.ch.is_none() {
return Err(LuxError::new(
ErrorType::Lexer,
"Unterminated string literal".to_string(),
start_line,
1,
));
}
// ... handle escape sequences ...
Some(c) => {
return Err(LuxError::new(
ErrorType::Lexer,
format!("Invalid escape sequence '\\{}'", c),
self.line,
self.column,
));
}
}
}
Update Parser Errors
Modify src/parser.rs:
use crate::error::{LuxError, ErrorType};
impl Parser {
pub fn parse(&mut self) -> Result<Vec<Stmt>, LuxError> {
// ... existing code ...
}
fn error(&self, message: &str) -> LuxError {
let token_info = self.peek();
LuxError::new(
ErrorType::Parser,
message.to_string(),
token_info.line,
token_info.column,
)
}
fn consume(&mut self, token: Token, message: &str) -> Result<(), LuxError> {
if self.check(&token) {
self.advance();
Ok(())
} else {
Err(self.error(message))
}
}
}
Update Runtime Errors
Modify src/interpreter.rs:
use crate::error::{LuxError, ErrorType};
impl Interpreter {
pub fn interpret(&mut self, statements: Vec<Stmt>) -> Result<(), LuxError> {
for stmt in statements {
self.execute_statement(stmt).map_err(|msg| {
LuxError::new(ErrorType::Runtime, msg, 0, 0)
})?;
}
Ok(())
}
// Add line tracking to statements (would require extending AST with positions)
fn runtime_error(&self, message: String, line: usize, column: usize) -> LuxError {
LuxError::new(ErrorType::Runtime, message, line, column)
}
}
Error Recovery in Parser
The parser can recover from some errors to report multiple issues:
impl Parser {
pub fn parse(&mut self) -> Result<Vec<Stmt>, Vec<LuxError>> {
let mut statements = Vec::new();
let mut errors = Vec::new();
while !self.is_at_end() {
match self.parse_statement() {
Ok(stmt) => statements.push(stmt),
Err(e) => {
errors.push(e);
self.synchronize(); // Skip to next statement
}
}
}
if errors.is_empty() {
Ok(statements)
} else {
Err(errors)
}
}
/// Skip tokens until we reach a statement boundary
fn synchronize(&mut self) {
self.advance();
while !self.is_at_end() {
// After semicolon, likely at statement boundary
if matches!(self.previous().token, Token::Semicolon) {
return;
}
// These tokens start statements
match self.peek().token {
Token::Let
| Token::Fn
| Token::If
| Token::While
| Token::For
| Token::Return
| Token::Print => return,
_ => {}
}
self.advance();
}
}
fn previous(&self) -> &TokenInfo {
&self.tokens[self.current - 1]
}
}
Stack Traces for Runtime Errors
Track the call stack to show where errors occur:
pub struct Interpreter {
environment: Rc<RefCell<Environment>>,
call_stack: Vec<CallFrame>,
}
#[derive(Debug, Clone)]
struct CallFrame {
function_name: String,
line: usize,
}
impl Interpreter {
fn call_function(&mut self, func: Value, args: Vec<Value>) -> Result<Value, String> {
match func {
Value::Function { params, body, closure, name } => {
// Push call frame
self.call_stack.push(CallFrame {
function_name: name.clone(),
line: 0, // Would need line tracking in AST
});
// ... execute function ...
// Pop call frame
self.call_stack.pop();
Ok(result)
}
_ => Err(format!("Cannot call {}", func.type_name())),
}
}
fn format_stack_trace(&self) -> String {
let mut trace = String::from("Stack trace:\n");
for (i, frame) in self.call_stack.iter().enumerate() {
trace.push_str(&format!(
" {} at {}:line {}\n",
i, frame.function_name, frame.line
));
}
trace
}
fn runtime_error_with_trace(&self, message: String) -> String {
format!("{}\n\n{}", message, self.format_stack_trace())
}
}
Helpful Error Messages
Type Mismatch
// Bad
Runtime Error: Type error
// Good
Runtime Error at line 10, column 15: Cannot add string and bool
10 | let result = "hello" + true;
^~~ here
Note: The '+' operator requires both operands to be numbers or strings
Undefined Variable
// Bad
Error: Variable not found
// Good
Runtime Error at line 8, column 11: Undefined variable 'count'
8 | print count + 1;
^~~ here
Note: Did you forget to declare 'count' with 'let'?
Function Arity
// Bad
Error: Wrong arguments
// Good
Runtime Error at line 15, column 20: Function 'add' expects 2 arguments, got 3
15 | let sum = add(1, 2, 3);
^~~ here
Note: Function 'add' is defined at line 5 with parameters (a, b)
Update main.rs for Better Error Display
mod ast;
mod lexer;
mod parser;
mod environment;
mod interpreter;
mod error;
use lexer::Lexer;
use parser::Parser;
use interpreter::Interpreter;
use std::fs;
fn run_file(path: &str) {
let source = match fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to read file '{}': {}", path, e);
std::process::exit(1);
}
};
if let Err(e) = run(&source) {
eprintln!("{}", e.with_source(&source).display());
std::process::exit(1);
}
}
fn run(source: &str) -> Result<(), error::LuxError> {
// Lexing
let mut lexer = Lexer::new(source.to_string());
let tokens = lexer.tokenize()?;
// Parsing
let mut parser = Parser::new(tokens);
let statements = parser.parse()?;
// Interpreting
let mut interpreter = Interpreter::new();
interpreter.interpret(statements)?;
Ok(())
}
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() > 1 {
run_file(&args[1]);
} else {
println!("Usage: lux <file.lux>");
}
}
Testing Error Messages
Create tests/error_tests.lux:
// Test 1: Undefined variable
print x; // Should error with: Undefined variable 'x'
// Test 2: Type error
let result = 5 + "hello"; // Should error: Cannot add number and string
// Test 3: Division by zero
let bad = 10 / 0; // Should error: Division by zero
// Test 4: Wrong number of arguments
fn add(a, b) {
return a + b;
}
print add(1); // Should error: Function expects 2 arguments, got 1
// Test 5: Index out of bounds
let list = [1, 2, 3];
print list[10]; // Should error: List index 10 out of bounds
// Test 6: Unterminated string
let bad_str = "hello; // Should error: Unterminated string
// Test 7: Invalid syntax
fn incomplete { // Should error: Expected '(' after function name
Error Messages Best Practices
1. Be Specific
❌ "Syntax error"
✅ "Expected ';' after variable declaration"
2. Show Context
Always include the source line and a caret pointing to the error.
3. Suggest Fixes
Error: Undefined variable 'cout'
Did you mean 'count'?
4. Use Color (Terminal)
// Using ANSI color codes
pub fn display_colored(&self) -> String {
let red = "\x1b[31m";
let yellow = "\x1b[33m";
let reset = "\x1b[0m";
format!(
"{}{}:{} {}{}\n",
red, type_name, self.line, self.message, reset
)
}
5. Don't Cascade Errors
One error shouldn't cause dozens of follow-up errors. Use synchronization.
Common Error Patterns
Pattern 1: Validation Helper
fn validate_number(value: &Value, operation: &str) -> Result<f64, String> {
match value {
Value::Number(n) => Ok(*n),
_ => Err(format!(
"Cannot perform {} on {}, expected number",
operation,
value.type_name()
)),
}
}
// Usage
let n = validate_number(&value, "arithmetic operation")?;
Pattern 2: Context Wrapper
impl Interpreter {
fn eval_with_context(&mut self, expr: Expr, context: &str) -> Result<Value, String> {
self.evaluate_expression(expr)
.map_err(|e| format!("{} in {}", e, context))
}
}
Pattern 3: Error Recovery
fn parse_list(&mut self) -> Result<Expr, LuxError> {
self.consume(Token::LeftBracket, "Expected '['")?;
let elements = match self.parse_elements() {
Ok(elems) => elems,
Err(e) => {
// Try to recover: skip to closing bracket
while !self.check(&Token::RightBracket) && !self.is_at_end() {
self.advance();
}
return Err(e);
}
};
self.consume(Token::RightBracket, "Expected ']'")?;
Ok(Expr::List(elements))
}
Error Summary
| Error Type | When | Example |
|---|---|---|
| Lexer | Tokenization | Invalid character, unterminated string |
| Parser | AST construction | Missing semicolon, unexpected token |
| Runtime | Execution | Undefined variable, type mismatch, out of bounds |
What's Next?
Our error handling is solid! Now let's build the REPL and file runner to make Lux usable.
→ Continue to Chapter 8: REPL & File Runner