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:

  1. Error message: What went wrong
  2. Source location: Line and column number
  3. Source context: The actual source line
  4. 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 TypeWhenExample
LexerTokenizationInvalid character, unterminated string
ParserAST constructionMissing semicolon, unexpected token
RuntimeExecutionUndefined 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