Error Handling & Reporting

Good error messages are crucial for a pleasant programming experience. This chapter improves Lux's error reporting so it is 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

BadGood
"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 should not 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

Next Steps

Errors now report clearly. Continue to 08-repl-file-runner.md to build a REPL and a file runner that make Lux usable day to day.