Add Elm-style error diagnostics

Implement beautiful, informative error messages inspired by Elm:
- Rich diagnostic rendering with source code snippets
- Colored output with proper underlines showing error locations
- Categorized error titles (Type Mismatch, Unknown Name, etc.)
- Contextual hints and suggestions for common errors
- Support for type errors, runtime errors, and parse errors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 04:01:49 -05:00
parent 66132779cc
commit d37f0fb096
5 changed files with 703 additions and 2 deletions

426
src/diagnostics.rs Normal file
View File

@@ -0,0 +1,426 @@
//! Elm-style diagnostic messages for beautiful error reporting
use crate::ast::Span;
/// ANSI color codes for terminal output
pub mod colors {
pub const RESET: &str = "\x1b[0m";
pub const BOLD: &str = "\x1b[1m";
pub const DIM: &str = "\x1b[2m";
pub const RED: &str = "\x1b[31m";
pub const YELLOW: &str = "\x1b[33m";
pub const BLUE: &str = "\x1b[34m";
pub const CYAN: &str = "\x1b[36m";
pub const WHITE: &str = "\x1b[37m";
}
/// Severity level for diagnostics
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
Hint,
}
impl Severity {
fn color(&self) -> &'static str {
match self {
Severity::Error => colors::RED,
Severity::Warning => colors::YELLOW,
Severity::Hint => colors::CYAN,
}
}
fn label(&self) -> &'static str {
match self {
Severity::Error => "ERROR",
Severity::Warning => "WARNING",
Severity::Hint => "HINT",
}
}
}
/// A diagnostic message with source location and optional hints
#[derive(Debug, Clone)]
pub struct Diagnostic {
pub severity: Severity,
pub title: String,
pub message: String,
pub span: Span,
pub hints: Vec<String>,
}
impl Diagnostic {
pub fn error(title: impl Into<String>, message: impl Into<String>, span: Span) -> Self {
Self {
severity: Severity::Error,
title: title.into(),
message: message.into(),
span,
hints: Vec::new(),
}
}
pub fn warning(title: impl Into<String>, message: impl Into<String>, span: Span) -> Self {
Self {
severity: Severity::Warning,
title: title.into(),
message: message.into(),
span,
hints: Vec::new(),
}
}
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hints.push(hint.into());
self
}
pub fn with_hints(mut self, hints: Vec<String>) -> Self {
self.hints.extend(hints);
self
}
}
/// Converts byte offset to (line, column) - 1-indexed
pub fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
let mut line = 1;
let mut col = 1;
for (i, ch) in source.char_indices() {
if i >= offset {
break;
}
if ch == '\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
(line, col)
}
/// Extract a source line by line number (1-indexed)
pub fn get_source_line(source: &str, line_num: usize) -> Option<&str> {
source.lines().nth(line_num.saturating_sub(1))
}
/// Get context lines around a span
pub fn get_context_lines(source: &str, span: Span, context: usize) -> Vec<(usize, &str)> {
let (start_line, _) = offset_to_line_col(source, span.start);
let (end_line, _) = offset_to_line_col(source, span.end);
let first_line = start_line.saturating_sub(context);
let last_line = end_line + context;
source
.lines()
.enumerate()
.filter(|(i, _)| *i + 1 >= first_line && *i + 1 <= last_line)
.map(|(i, line)| (i + 1, line))
.collect()
}
/// Render a diagnostic to a string with colors
pub fn render_diagnostic(
diagnostic: &Diagnostic,
source: &str,
filename: Option<&str>,
) -> String {
let mut output = String::new();
let (line, col) = offset_to_line_col(source, diagnostic.span.start);
let (end_line, end_col) = offset_to_line_col(source, diagnostic.span.end);
let severity_color = diagnostic.severity.color();
let severity_label = diagnostic.severity.label();
// Header: -- ERROR ----------- filename.lux
let filename_str = filename.unwrap_or("<input>");
output.push_str(&format!(
"{}{}{} ── {} ──────────────────────────────────\n",
colors::BOLD,
severity_color,
severity_label,
filename_str
));
output.push_str(colors::RESET);
// Title
output.push_str(&format!(
"\n{}{}{}:{}{}\n\n",
colors::BOLD,
colors::WHITE,
line,
col,
colors::RESET
));
// Error title/category
output.push_str(&format!(
"{}{}{}{}\n\n",
colors::BOLD,
severity_color,
diagnostic.title,
colors::RESET
));
// Source code snippet with line numbers
let line_num_width = end_line.to_string().len().max(4);
// Show the problematic line(s)
for line_num in line..=end_line {
if let Some(source_line) = get_source_line(source, line_num) {
// Line number
output.push_str(&format!(
"{}{:>width$}{} ",
colors::DIM,
line_num,
colors::RESET,
width = line_num_width
));
// Source line
output.push_str(source_line);
output.push('\n');
// Underline the error span
let underline_start = if line_num == line { col - 1 } else { 0 };
let underline_end = if line_num == end_line {
end_col - 1
} else {
source_line.len()
};
let underline_len = underline_end.saturating_sub(underline_start).max(1);
output.push_str(&format!(
"{}{:>width$}{} ",
colors::DIM,
"",
colors::RESET,
width = line_num_width
));
output.push_str(&" ".repeat(underline_start));
output.push_str(&format!(
"{}{}{}",
severity_color,
"^".repeat(underline_len),
colors::RESET
));
output.push('\n');
}
}
// Error message
output.push_str(&format!("\n{}\n", diagnostic.message));
// Hints
if !diagnostic.hints.is_empty() {
output.push('\n');
for hint in &diagnostic.hints {
output.push_str(&format!(
"{}{}Hint:{} {}\n",
colors::BOLD,
colors::CYAN,
colors::RESET,
hint
));
}
}
output.push('\n');
output
}
/// Render a diagnostic without colors (for testing or non-TTY output)
pub fn render_diagnostic_plain(
diagnostic: &Diagnostic,
source: &str,
filename: Option<&str>,
) -> String {
let mut output = String::new();
let (line, col) = offset_to_line_col(source, diagnostic.span.start);
let (end_line, end_col) = offset_to_line_col(source, diagnostic.span.end);
let filename_str = filename.unwrap_or("<input>");
output.push_str(&format!(
"-- {} ── {} ──────────────────────────────────\n",
diagnostic.severity.label(),
filename_str
));
output.push_str(&format!("\n{}:{}\n\n", line, col));
output.push_str(&format!("{}\n\n", diagnostic.title));
let line_num_width = end_line.to_string().len().max(4);
for line_num in line..=end_line {
if let Some(source_line) = get_source_line(source, line_num) {
output.push_str(&format!(
"{:>width$} | ",
line_num,
width = line_num_width
));
output.push_str(source_line);
output.push('\n');
let underline_start = if line_num == line { col - 1 } else { 0 };
let underline_end = if line_num == end_line {
end_col - 1
} else {
source_line.len()
};
let underline_len = underline_end.saturating_sub(underline_start).max(1);
output.push_str(&format!(
"{:>width$} | ",
"",
width = line_num_width
));
output.push_str(&" ".repeat(underline_start));
output.push_str(&"^".repeat(underline_len));
output.push('\n');
}
}
output.push_str(&format!("\n{}\n", diagnostic.message));
if !diagnostic.hints.is_empty() {
output.push('\n');
for hint in &diagnostic.hints {
output.push_str(&format!("Hint: {}\n", hint));
}
}
output.push('\n');
output
}
/// Check if stdout is a TTY (for color support)
pub fn supports_color() -> bool {
// Simple check - in production you'd use atty crate
std::env::var("NO_COLOR").is_err() && std::env::var("TERM").map(|t| t != "dumb").unwrap_or(true)
}
/// Render diagnostic with automatic color detection
pub fn render(diagnostic: &Diagnostic, source: &str, filename: Option<&str>) -> String {
if supports_color() {
render_diagnostic(diagnostic, source, filename)
} else {
render_diagnostic_plain(diagnostic, source, filename)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_offset_to_line_col() {
let source = "line one\nline two\nline three";
assert_eq!(offset_to_line_col(source, 0), (1, 1));
assert_eq!(offset_to_line_col(source, 5), (1, 6));
assert_eq!(offset_to_line_col(source, 9), (2, 1));
assert_eq!(offset_to_line_col(source, 14), (2, 6));
}
#[test]
fn test_get_source_line() {
let source = "line one\nline two\nline three";
assert_eq!(get_source_line(source, 1), Some("line one"));
assert_eq!(get_source_line(source, 2), Some("line two"));
assert_eq!(get_source_line(source, 3), Some("line three"));
assert_eq!(get_source_line(source, 4), None);
}
#[test]
fn test_render_diagnostic_plain() {
let source = "fn test() {\n unknownVar\n}";
let diag = Diagnostic::error(
"Unknown Variable",
"The variable 'unknownVar' is not in scope.",
Span { start: 16, end: 26 },
)
.with_hint("Did you mean 'knownVar'?");
let output = render_diagnostic_plain(&diag, source, Some("test.lux"));
assert!(output.contains("ERROR"));
assert!(output.contains("test.lux"));
assert!(output.contains("Unknown Variable"));
assert!(output.contains("unknownVar"));
assert!(output.contains("Hint:"));
}
#[test]
fn test_diagnostic_builder() {
let diag = Diagnostic::error("Test", "Message", Span { start: 0, end: 5 })
.with_hint("Hint 1")
.with_hint("Hint 2")
.with_hints(vec!["Hint 3".to_string()]);
assert_eq!(diag.hints.len(), 3);
assert_eq!(diag.severity, Severity::Error);
}
#[test]
fn test_multiline_span() {
let source = "fn add(a: Int, b: Int): Int {\n a +\n b\n}";
let diag = Diagnostic::error(
"Multiline Error",
"This spans multiple lines.",
Span { start: 29, end: 43 },
);
let output = render_diagnostic_plain(&diag, source, None);
assert!(output.contains("Multiline Error"));
// Should show both lines
assert!(output.contains("a +"));
assert!(output.contains("b"));
}
#[test]
fn test_type_error_categorization() {
use super::Diagnostic;
// Test that errors are properly categorized
let source = "let x: Int = \"hello\"";
// Simulate a type mismatch diagnostic
let diag = Diagnostic {
severity: Severity::Error,
title: "Type Mismatch".to_string(),
message: "Type mismatch: expected Int, got String".to_string(),
span: Span { start: 13, end: 20 },
hints: vec!["Check that the types on both sides of the expression are compatible.".to_string()],
};
let output = render_diagnostic_plain(&diag, source, Some("test.lux"));
assert!(output.contains("Type Mismatch"));
assert!(output.contains("\"hello\""));
assert!(output.contains("Hint:"));
}
#[test]
fn test_empty_source() {
let source = "";
let diag = Diagnostic::error("Empty", "No source.", Span { start: 0, end: 0 });
let output = render_diagnostic_plain(&diag, source, None);
assert!(output.contains("Empty"));
}
#[test]
fn test_single_char_error() {
let source = "x + y";
let diag = Diagnostic::error("Operator", "Invalid op.", Span { start: 2, end: 3 });
let output = render_diagnostic_plain(&diag, source, None);
assert!(output.contains("+"));
assert!(output.contains("^"));
}
}

View File

@@ -3,6 +3,7 @@
#![allow(dead_code, unused_variables)] #![allow(dead_code, unused_variables)]
use crate::ast::*; use crate::ast::*;
use crate::diagnostics::{Diagnostic, Severity};
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt; use std::fmt;
@@ -327,6 +328,74 @@ impl fmt::Display for RuntimeError {
impl std::error::Error for RuntimeError {} impl std::error::Error for RuntimeError {}
impl RuntimeError {
/// Convert to a rich diagnostic for Elm-style error display
pub fn to_diagnostic(&self) -> Diagnostic {
let (title, hints) = categorize_runtime_error(&self.message);
Diagnostic {
severity: Severity::Error,
title,
message: self.message.clone(),
span: self.span.unwrap_or_default(),
hints,
}
}
}
/// Categorize runtime errors to provide better titles and hints
fn categorize_runtime_error(message: &str) -> (String, Vec<String>) {
let message_lower = message.to_lowercase();
if message_lower.contains("undefined") || message_lower.contains("not found") {
(
"Undefined Reference".to_string(),
vec!["Make sure the name is defined and in scope.".to_string()],
)
} else if message_lower.contains("division by zero") || message_lower.contains("divide by zero") {
(
"Division by Zero".to_string(),
vec![
"Check that the divisor is not zero before dividing.".to_string(),
"Consider using a guard or match to handle this case.".to_string(),
],
)
} else if message_lower.contains("type") && message_lower.contains("mismatch") {
(
"Type Mismatch".to_string(),
vec!["The value has a different type than expected.".to_string()],
)
} else if message_lower.contains("effect") && message_lower.contains("unhandled") {
(
"Unhandled Effect".to_string(),
vec![
"This effect must be handled before the program can continue.".to_string(),
"Wrap this code in a 'handle' expression.".to_string(),
],
)
} else if message_lower.contains("pattern") && message_lower.contains("match") {
(
"Non-exhaustive Pattern".to_string(),
vec!["Add more patterns to cover all possible cases.".to_string()],
)
} else if message_lower.contains("argument") {
(
"Wrong Arguments".to_string(),
vec!["Check the number and types of arguments provided.".to_string()],
)
} else if message_lower.contains("index") || message_lower.contains("bounds") {
(
"Index Out of Bounds".to_string(),
vec![
"The index is outside the valid range.".to_string(),
"Check the length of the collection before accessing.".to_string(),
],
)
} else {
("Runtime Error".to_string(), vec![])
}
}
/// Effect operation request /// Effect operation request
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct EffectRequest { pub struct EffectRequest {

View File

@@ -1,6 +1,7 @@
//! Lux - A functional programming language with first-class effects //! Lux - A functional programming language with first-class effects
mod ast; mod ast;
mod diagnostics;
mod interpreter; mod interpreter;
mod lexer; mod lexer;
mod modules; mod modules;
@@ -9,6 +10,7 @@ mod schema;
mod typechecker; mod typechecker;
mod types; mod types;
use diagnostics::render;
use interpreter::Interpreter; use interpreter::Interpreter;
use parser::Parser; use parser::Parser;
use std::io::{self, Write}; use std::io::{self, Write};
@@ -90,7 +92,8 @@ fn run_file(path: &str) {
let mut checker = TypeChecker::new(); let mut checker = TypeChecker::new();
if let Err(errors) = checker.check_program_with_modules(&program, &loader) { if let Err(errors) = checker.check_program_with_modules(&program, &loader) {
for error in errors { for error in errors {
eprintln!("Type error: {}", error); let diagnostic = error.to_diagnostic();
eprint!("{}", render(&diagnostic, &source, Some(path)));
} }
std::process::exit(1); std::process::exit(1);
} }
@@ -103,7 +106,8 @@ fn run_file(path: &str) {
} }
} }
Err(e) => { Err(e) => {
eprintln!("Runtime error: {}", e); let diagnostic = e.to_diagnostic();
eprint!("{}", render(&diagnostic, &source, Some(path)));
std::process::exit(1); std::process::exit(1);
} }
} }
@@ -850,4 +854,81 @@ c")"#;
assert!(result.is_err()); assert!(result.is_err());
assert!(result.unwrap_err().contains("pure but has effects")); assert!(result.unwrap_err().contains("pure but has effects"));
} }
// Diagnostic rendering tests
mod diagnostic_tests {
use crate::diagnostics::{render_diagnostic_plain, Diagnostic, Severity};
use crate::ast::Span;
use crate::typechecker::TypeError;
use crate::interpreter::RuntimeError;
#[test]
fn test_type_error_to_diagnostic() {
let error = TypeError {
message: "Type mismatch: expected Int, got String".to_string(),
span: Span { start: 10, end: 20 },
};
let diag = error.to_diagnostic();
assert_eq!(diag.severity, Severity::Error);
assert_eq!(diag.title, "Type Mismatch");
assert!(diag.hints.len() > 0);
}
#[test]
fn test_runtime_error_to_diagnostic() {
let error = RuntimeError {
message: "Division by zero".to_string(),
span: Some(Span { start: 5, end: 10 }),
};
let diag = error.to_diagnostic();
assert_eq!(diag.severity, Severity::Error);
assert_eq!(diag.title, "Division by Zero");
assert!(diag.hints.len() > 0);
}
#[test]
fn test_diagnostic_render_with_real_code() {
let source = "fn add(a: Int, b: Int): Int = a + b\nlet result = add(1, \"two\")";
let diag = Diagnostic {
severity: Severity::Error,
title: "Type Mismatch".to_string(),
message: "Expected Int but got String".to_string(),
span: Span { start: 56, end: 61 },
hints: vec!["The second argument should be an Int.".to_string()],
};
let output = render_diagnostic_plain(&diag, source, Some("example.lux"));
assert!(output.contains("ERROR"));
assert!(output.contains("example.lux"));
assert!(output.contains("Type Mismatch"));
assert!(output.contains("\"two\""));
assert!(output.contains("Hint:"));
}
#[test]
fn test_undefined_variable_categorization() {
let error = TypeError {
message: "Undefined variable: foobar".to_string(),
span: Span { start: 0, end: 6 },
};
let diag = error.to_diagnostic();
assert_eq!(diag.title, "Unknown Name");
assert!(diag.hints.iter().any(|h| h.contains("spelling")));
}
#[test]
fn test_purity_violation_categorization() {
let error = TypeError {
message: "Function 'foo' is declared as pure but has effects: {Console}".to_string(),
span: Span { start: 0, end: 10 },
};
let diag = error.to_diagnostic();
assert_eq!(diag.title, "Purity Violation");
}
}
} }

View File

@@ -1,6 +1,7 @@
//! Parser for the Lux language //! Parser for the Lux language
use crate::ast::*; use crate::ast::*;
use crate::diagnostics::{Diagnostic, Severity};
use crate::lexer::{LexError, Lexer, Token, TokenKind}; use crate::lexer::{LexError, Lexer, Token, TokenKind};
use std::fmt; use std::fmt;
@@ -21,6 +22,63 @@ impl fmt::Display for ParseError {
} }
} }
impl ParseError {
/// Convert to a rich diagnostic for Elm-style error display
pub fn to_diagnostic(&self) -> Diagnostic {
let (title, hints) = categorize_parse_error(&self.message);
Diagnostic {
severity: Severity::Error,
title,
message: self.message.clone(),
span: self.span,
hints,
}
}
}
/// Categorize parse errors to provide better titles and hints
fn categorize_parse_error(message: &str) -> (String, Vec<String>) {
let message_lower = message.to_lowercase();
if message_lower.contains("unexpected") && message_lower.contains("expected") {
(
"Unexpected Token".to_string(),
vec!["Check for missing or misplaced punctuation.".to_string()],
)
} else if message_lower.contains("expected") && message_lower.contains("expression") {
(
"Missing Expression".to_string(),
vec!["An expression was expected here.".to_string()],
)
} else if message_lower.contains("expected") && message_lower.contains(":") {
(
"Missing Type Annotation".to_string(),
vec!["A type annotation is required here.".to_string()],
)
} else if message_lower.contains("unclosed") || message_lower.contains("unterminated") {
(
"Unclosed Delimiter".to_string(),
vec![
"Check for matching opening and closing brackets.".to_string(),
"Make sure all strings are properly closed with quotes.".to_string(),
],
)
} else if message_lower.contains("invalid") {
(
"Invalid Syntax".to_string(),
vec!["Check the syntax of this construct.".to_string()],
)
} else if message_lower.contains("identifier") {
(
"Invalid Identifier".to_string(),
vec!["Identifiers must start with a letter and contain only letters, numbers, and underscores.".to_string()],
)
} else {
("Parse Error".to_string(), vec![])
}
}
impl From<LexError> for ParseError { impl From<LexError> for ParseError {
fn from(err: LexError) -> Self { fn from(err: LexError) -> Self {
ParseError { ParseError {

View File

@@ -7,6 +7,7 @@ use crate::ast::{
LetDecl, Literal, LiteralKind, MatchArm, Parameter, Pattern, Program, Span, Statement, LetDecl, Literal, LiteralKind, MatchArm, Parameter, Pattern, Program, Span, Statement,
TypeDecl, TypeExpr, UnaryOp, VariantFields, TypeDecl, TypeExpr, UnaryOp, VariantFields,
}; };
use crate::diagnostics::{Diagnostic, Severity};
use crate::modules::ModuleLoader; use crate::modules::ModuleLoader;
use crate::types::{ use crate::types::{
self, unify, EffectDef, EffectOpDef, EffectSet, HandlerDef, PropertySet, Type, TypeEnv, self, unify, EffectDef, EffectOpDef, EffectSet, HandlerDef, PropertySet, Type, TypeEnv,
@@ -30,6 +31,72 @@ impl std::fmt::Display for TypeError {
} }
} }
impl TypeError {
/// Convert to a rich diagnostic for Elm-style error display
pub fn to_diagnostic(&self) -> Diagnostic {
// Categorize the error and extract hints
let (title, hints) = categorize_type_error(&self.message);
Diagnostic {
severity: Severity::Error,
title,
message: self.message.clone(),
span: self.span,
hints,
}
}
}
/// Categorize a type error message to provide better titles and hints
fn categorize_type_error(message: &str) -> (String, Vec<String>) {
let message_lower = message.to_lowercase();
if message_lower.contains("type mismatch") {
(
"Type Mismatch".to_string(),
vec!["Check that the types on both sides of the expression are compatible.".to_string()],
)
} else if message_lower.contains("undefined variable") || message_lower.contains("not found") {
(
"Unknown Name".to_string(),
vec![
"Check the spelling of the name.".to_string(),
"Make sure the variable is defined before use.".to_string(),
],
)
} else if message_lower.contains("cannot unify") {
(
"Type Mismatch".to_string(),
vec!["The types are not compatible. Check your function arguments and return types.".to_string()],
)
} else if message_lower.contains("expected") && message_lower.contains("argument") {
(
"Wrong Number of Arguments".to_string(),
vec!["Check the function signature and provide the correct number of arguments.".to_string()],
)
} else if message_lower.contains("pure") && message_lower.contains("effect") {
(
"Purity Violation".to_string(),
vec![
"Functions marked 'is pure' cannot perform effects.".to_string(),
"Remove the 'is pure' annotation or handle the effects.".to_string(),
],
)
} else if message_lower.contains("effect") && message_lower.contains("unhandled") {
(
"Unhandled Effect".to_string(),
vec!["Use a 'handle' expression to provide an implementation for this effect.".to_string()],
)
} else if message_lower.contains("recursive") {
(
"Invalid Recursion".to_string(),
vec!["Check that recursive calls have proper base cases.".to_string()],
)
} else {
("Type Error".to_string(), vec![])
}
}
/// Type checker /// Type checker
pub struct TypeChecker { pub struct TypeChecker {
env: TypeEnv, env: TypeEnv,