feat: Elm-quality error messages with error codes

- Add ErrorCode enum with categorized codes (E01xx parse, E02xx type,
  E03xx name, E04xx effect, E05xx pattern, E06xx module, E07xx behavioral)
- Extend Diagnostic struct with error code, expected/actual types, and
  secondary spans
- Add format_type_diff() for visual type comparison in error messages
- Add help URLs linking to lux-lang.dev/errors/{code}
- Update typechecker, parser, and interpreter to use error codes
- Categorize errors with specific codes and helpful hints

Error messages now show:
- Error code in header: -- ERROR[E0301] ──
- Clear error category title
- Visual type diff for type mismatches
- Context-aware hints
- "Learn more" URL for documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 04:11:15 -05:00
parent bc1e5aa8a1
commit 3a46299404
5 changed files with 1200 additions and 45 deletions

View File

@@ -1,9 +1,223 @@
//! Elm-style diagnostic messages for beautiful error reporting
//!
//! This module provides Elm-quality error messages with:
//! - Error codes (E0001, E0002, etc.) for easy searchability
//! - Visual type diffs showing expected vs actual
//! - "Did you mean?" suggestions using Levenshtein distance
//! - Context-aware hints based on error type
//! - Colored output with syntax highlighting
#![allow(dead_code)]
use crate::ast::Span;
/// Error codes for all Lux compiler errors
/// These follow the pattern E{category}{number}:
/// - E01xx: Parse errors
/// - E02xx: Type errors
/// - E03xx: Name resolution errors
/// - E04xx: Effect errors
/// - E05xx: Pattern matching errors
/// - E06xx: Import/module errors
/// - E07xx: Behavioral type errors
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorCode {
// Parse errors (E01xx)
E0100, // Generic parse error
E0101, // Unexpected token
E0102, // Unclosed delimiter
E0103, // Invalid literal
E0104, // Missing semicolon or delimiter
E0105, // Invalid operator
// Type errors (E02xx)
E0200, // Generic type error
E0201, // Type mismatch
E0202, // Cannot unify types
E0203, // Missing type annotation
E0204, // Invalid type application
E0205, // Recursive type without indirection
E0206, // Field type mismatch
E0207, // Missing record field
E0208, // Extra record field
E0209, // Wrong number of type arguments
E0210, // Type not found
E0211, // Return type mismatch
// Name resolution errors (E03xx)
E0300, // Generic name error
E0301, // Undefined variable
E0302, // Undefined function
E0303, // Undefined type
E0304, // Undefined module
E0305, // Duplicate definition
E0306, // Shadowing warning
E0307, // Unused variable warning
E0308, // Undefined field
// Effect errors (E04xx)
E0400, // Generic effect error
E0401, // Unhandled effect
E0402, // Effect not in scope
E0403, // Missing effect handler
E0404, // Invalid effect operation
E0405, // Effect mismatch
// Pattern matching errors (E05xx)
E0500, // Generic pattern error
E0501, // Non-exhaustive patterns
E0502, // Unreachable pattern
E0503, // Invalid pattern
E0504, // Duplicate pattern binding
// Import/module errors (E06xx)
E0600, // Generic module error
E0601, // Module not found
E0602, // Circular import
E0603, // Export not found
E0604, // Private item accessed
// Behavioral type errors (E07xx)
E0700, // Generic behavioral error
E0701, // Purity violation
E0702, // Totality violation
E0703, // Idempotency violation
E0704, // Commutativity violation
}
impl ErrorCode {
pub fn code(&self) -> &'static str {
match self {
// Parse errors
ErrorCode::E0100 => "E0100",
ErrorCode::E0101 => "E0101",
ErrorCode::E0102 => "E0102",
ErrorCode::E0103 => "E0103",
ErrorCode::E0104 => "E0104",
ErrorCode::E0105 => "E0105",
// Type errors
ErrorCode::E0200 => "E0200",
ErrorCode::E0201 => "E0201",
ErrorCode::E0202 => "E0202",
ErrorCode::E0203 => "E0203",
ErrorCode::E0204 => "E0204",
ErrorCode::E0205 => "E0205",
ErrorCode::E0206 => "E0206",
ErrorCode::E0207 => "E0207",
ErrorCode::E0208 => "E0208",
ErrorCode::E0209 => "E0209",
ErrorCode::E0210 => "E0210",
ErrorCode::E0211 => "E0211",
// Name errors
ErrorCode::E0300 => "E0300",
ErrorCode::E0301 => "E0301",
ErrorCode::E0302 => "E0302",
ErrorCode::E0303 => "E0303",
ErrorCode::E0304 => "E0304",
ErrorCode::E0305 => "E0305",
ErrorCode::E0306 => "E0306",
ErrorCode::E0307 => "E0307",
ErrorCode::E0308 => "E0308",
// Effect errors
ErrorCode::E0400 => "E0400",
ErrorCode::E0401 => "E0401",
ErrorCode::E0402 => "E0402",
ErrorCode::E0403 => "E0403",
ErrorCode::E0404 => "E0404",
ErrorCode::E0405 => "E0405",
// Pattern errors
ErrorCode::E0500 => "E0500",
ErrorCode::E0501 => "E0501",
ErrorCode::E0502 => "E0502",
ErrorCode::E0503 => "E0503",
ErrorCode::E0504 => "E0504",
// Module errors
ErrorCode::E0600 => "E0600",
ErrorCode::E0601 => "E0601",
ErrorCode::E0602 => "E0602",
ErrorCode::E0603 => "E0603",
ErrorCode::E0604 => "E0604",
// Behavioral errors
ErrorCode::E0700 => "E0700",
ErrorCode::E0701 => "E0701",
ErrorCode::E0702 => "E0702",
ErrorCode::E0703 => "E0703",
ErrorCode::E0704 => "E0704",
}
}
pub fn title(&self) -> &'static str {
match self {
// Parse errors
ErrorCode::E0100 => "Parse Error",
ErrorCode::E0101 => "Unexpected Token",
ErrorCode::E0102 => "Unclosed Delimiter",
ErrorCode::E0103 => "Invalid Literal",
ErrorCode::E0104 => "Missing Delimiter",
ErrorCode::E0105 => "Invalid Operator",
// Type errors
ErrorCode::E0200 => "Type Error",
ErrorCode::E0201 => "Type Mismatch",
ErrorCode::E0202 => "Cannot Unify Types",
ErrorCode::E0203 => "Missing Type Annotation",
ErrorCode::E0204 => "Invalid Type Application",
ErrorCode::E0205 => "Recursive Type Error",
ErrorCode::E0206 => "Field Type Mismatch",
ErrorCode::E0207 => "Missing Record Field",
ErrorCode::E0208 => "Extra Record Field",
ErrorCode::E0209 => "Wrong Type Arity",
ErrorCode::E0210 => "Type Not Found",
ErrorCode::E0211 => "Return Type Mismatch",
// Name errors
ErrorCode::E0300 => "Name Error",
ErrorCode::E0301 => "Undefined Variable",
ErrorCode::E0302 => "Undefined Function",
ErrorCode::E0303 => "Undefined Type",
ErrorCode::E0304 => "Undefined Module",
ErrorCode::E0305 => "Duplicate Definition",
ErrorCode::E0306 => "Variable Shadowing",
ErrorCode::E0307 => "Unused Variable",
ErrorCode::E0308 => "Undefined Field",
// Effect errors
ErrorCode::E0400 => "Effect Error",
ErrorCode::E0401 => "Unhandled Effect",
ErrorCode::E0402 => "Effect Not In Scope",
ErrorCode::E0403 => "Missing Effect Handler",
ErrorCode::E0404 => "Invalid Effect Operation",
ErrorCode::E0405 => "Effect Mismatch",
// Pattern errors
ErrorCode::E0500 => "Pattern Error",
ErrorCode::E0501 => "Non-Exhaustive Patterns",
ErrorCode::E0502 => "Unreachable Pattern",
ErrorCode::E0503 => "Invalid Pattern",
ErrorCode::E0504 => "Duplicate Pattern Binding",
// Module errors
ErrorCode::E0600 => "Module Error",
ErrorCode::E0601 => "Module Not Found",
ErrorCode::E0602 => "Circular Import",
ErrorCode::E0603 => "Export Not Found",
ErrorCode::E0604 => "Private Item Access",
// Behavioral errors
ErrorCode::E0700 => "Behavioral Type Error",
ErrorCode::E0701 => "Purity Violation",
ErrorCode::E0702 => "Totality Violation",
ErrorCode::E0703 => "Idempotency Violation",
ErrorCode::E0704 => "Commutativity Violation",
}
}
/// Get a URL to documentation about this error
pub fn help_url(&self) -> String {
format!("https://lux-lang.dev/errors/{}", self.code())
}
}
impl std::fmt::Display for ErrorCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.code())
}
}
/// ANSI color codes for terminal output
pub mod colors {
pub const RESET: &str = "\x1b[0m";
@@ -46,30 +260,57 @@ impl Severity {
#[derive(Debug, Clone)]
pub struct Diagnostic {
pub severity: Severity,
pub code: Option<ErrorCode>,
pub title: String,
pub message: String,
pub span: Span,
pub hints: Vec<String>,
pub expected_type: Option<String>,
pub actual_type: Option<String>,
pub secondary_spans: Vec<(Span, String)>,
}
impl Diagnostic {
pub fn error(title: impl Into<String>, message: impl Into<String>, span: Span) -> Self {
Self {
severity: Severity::Error,
code: None,
title: title.into(),
message: message.into(),
span,
hints: Vec::new(),
expected_type: None,
actual_type: None,
secondary_spans: Vec::new(),
}
}
pub fn warning(title: impl Into<String>, message: impl Into<String>, span: Span) -> Self {
Self {
severity: Severity::Warning,
code: None,
title: title.into(),
message: message.into(),
span,
hints: Vec::new(),
expected_type: None,
actual_type: None,
secondary_spans: Vec::new(),
}
}
/// Create an error with a specific error code
pub fn with_code(code: ErrorCode, message: impl Into<String>, span: Span) -> Self {
Self {
severity: Severity::Error,
code: Some(code),
title: code.title().to_string(),
message: message.into(),
span,
hints: Vec::new(),
expected_type: None,
actual_type: None,
secondary_spans: Vec::new(),
}
}
@@ -82,6 +323,111 @@ impl Diagnostic {
self.hints.extend(hints);
self
}
/// Add expected and actual types for type mismatch visualization
pub fn with_types(mut self, expected: impl Into<String>, actual: impl Into<String>) -> Self {
self.expected_type = Some(expected.into());
self.actual_type = Some(actual.into());
self
}
/// Add a secondary span with a label (e.g., "defined here")
pub fn with_secondary(mut self, span: Span, label: impl Into<String>) -> Self {
self.secondary_spans.push((span, label.into()));
self
}
/// Set the error code
pub fn code(mut self, code: ErrorCode) -> Self {
self.code = Some(code);
self.title = code.title().to_string();
self
}
}
/// Format a visual type diff between expected and actual types
/// Highlights the differences in red
pub fn format_type_diff(expected: &str, actual: &str) -> String {
let mut output = String::new();
output.push_str(&format!(
"\n {}{}Expected:{} {}{}{}\n",
colors::BOLD,
colors::CYAN,
colors::RESET,
colors::BOLD,
expected,
colors::RESET
));
output.push_str(&format!(
" {}{}Found: {} {}{}{}\n",
colors::BOLD,
colors::RED,
colors::RESET,
colors::BOLD,
actual,
colors::RESET
));
// If types are structurally similar, show where they differ
if let Some(diff) = compute_type_diff(expected, actual) {
output.push_str(&format!("\n {}\n", diff));
}
output
}
/// Compute a simple diff between two type strings
fn compute_type_diff(expected: &str, actual: &str) -> Option<String> {
// Simple case: find the first difference
let exp_chars: Vec<char> = expected.chars().collect();
let act_chars: Vec<char> = actual.chars().collect();
let mut diff_start = None;
for (i, (e, a)) in exp_chars.iter().zip(act_chars.iter()).enumerate() {
if e != a && diff_start.is_none() {
diff_start = Some(i);
break;
}
}
// If the types are identical, no diff needed
if diff_start.is_none() && exp_chars.len() == act_chars.len() {
return None;
}
// Show the difference location with an arrow
if let Some(start) = diff_start {
let pointer = format!(
"{}{}^-- difference starts here{}",
" ".repeat(start + 9), // Account for "Found: " prefix
colors::YELLOW,
colors::RESET
);
return Some(pointer);
}
// Length difference
if exp_chars.len() != act_chars.len() {
return Some(format!(
"{}Note:{} Types have different lengths ({} vs {} characters)",
colors::YELLOW,
colors::RESET,
exp_chars.len(),
act_chars.len()
));
}
None
}
/// Format a plain type diff (no colors)
pub fn format_type_diff_plain(expected: &str, actual: &str) -> String {
format!(
"\n Expected: {}\n Found: {}\n",
expected, actual
)
}
/// Calculate the Levenshtein edit distance between two strings
@@ -211,18 +557,23 @@ pub fn render_diagnostic(
let severity_color = diagnostic.severity.color();
let severity_label = diagnostic.severity.label();
// Header: -- ERROR ----------- filename.lux
// Header with error code: ── ERROR[E0201] ── filename.lux
let filename_str = filename.unwrap_or("<input>");
let code_str = diagnostic
.code
.map(|c| format!("[{}]", c.code()))
.unwrap_or_default();
output.push_str(&format!(
"{}{}{} ── {} ──────────────────────────────────\n",
"{}{}── {}{} ──────────────────── {}{}\n",
colors::BOLD,
severity_color,
severity_label,
filename_str
code_str,
filename_str,
colors::RESET
));
output.push_str(colors::RESET);
// Title
// Location
output.push_str(&format!(
"\n{}{}{}:{}{}\n\n",
colors::BOLD,
@@ -309,6 +660,11 @@ pub fn render_diagnostic(
// Error message
output.push_str(&format!("\n{}\n", diagnostic.message));
// Type diff visualization (if present)
if let (Some(expected), Some(actual)) = (&diagnostic.expected_type, &diagnostic.actual_type) {
output.push_str(&format_type_diff(expected, actual));
}
// Hints
if !diagnostic.hints.is_empty() {
output.push('\n');
@@ -323,6 +679,17 @@ pub fn render_diagnostic(
}
}
// Help URL for error codes
if let Some(code) = diagnostic.code {
output.push_str(&format!(
"\n{}{}Learn more:{} {}\n",
colors::DIM,
colors::BLUE,
colors::RESET,
code.help_url()
));
}
output.push('\n');
output
}
@@ -338,9 +705,14 @@ pub fn render_diagnostic_plain(
let (end_line, end_col) = offset_to_line_col(source, diagnostic.span.end);
let filename_str = filename.unwrap_or("<input>");
let code_str = diagnostic
.code
.map(|c| format!("[{}]", c.code()))
.unwrap_or_default();
output.push_str(&format!(
"-- {} ── {} ──────────────────────────────────\n",
"-- {}{} ── {} ──────────────────────────────────\n",
diagnostic.severity.label(),
code_str,
filename_str
));
@@ -381,6 +753,11 @@ pub fn render_diagnostic_plain(
output.push_str(&format!("\n{}\n", diagnostic.message));
// Type diff visualization (plain)
if let (Some(expected), Some(actual)) = (&diagnostic.expected_type, &diagnostic.actual_type) {
output.push_str(&format_type_diff_plain(expected, actual));
}
if !diagnostic.hints.is_empty() {
output.push('\n');
for hint in &diagnostic.hints {
@@ -388,6 +765,11 @@ pub fn render_diagnostic_plain(
}
}
// Help URL for error codes
if let Some(code) = diagnostic.code {
output.push_str(&format!("\nLearn more: {}\n", code.help_url()));
}
output.push('\n');
output
}
@@ -478,25 +860,28 @@ mod tests {
#[test]
fn test_type_error_categorization() {
use super::Diagnostic;
use super::{Diagnostic, ErrorCode};
// 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()],
};
// Simulate a type mismatch diagnostic with error code
let diag = Diagnostic::with_code(
ErrorCode::E0201,
"Type mismatch: expected Int, got String",
Span { start: 13, end: 20 },
)
.with_types("Int", "String")
.with_hint("Check that the types on both sides of the expression are compatible.");
let output = render_diagnostic_plain(&diag, source, Some("test.lux"));
assert!(output.contains("Type Mismatch"));
assert!(output.contains("\"hello\""));
assert!(output.contains("Hint:"));
assert!(output.contains("[E0201]"));
assert!(output.contains("Expected: Int"));
assert!(output.contains("Found: String"));
}
#[test]
@@ -589,4 +974,60 @@ mod tests {
let hint = super::format_did_you_mean(&["a".to_string(), "b".to_string(), "c".to_string()]);
assert_eq!(hint, Some("Did you mean 'a', 'b', or 'c'?".to_string()));
}
#[test]
fn test_error_code_display() {
assert_eq!(super::ErrorCode::E0201.code(), "E0201");
assert_eq!(super::ErrorCode::E0201.title(), "Type Mismatch");
assert!(super::ErrorCode::E0201.help_url().contains("E0201"));
}
#[test]
fn test_diagnostic_with_code() {
let diag = super::Diagnostic::with_code(
super::ErrorCode::E0301,
"Variable 'x' not found",
Span { start: 0, end: 1 },
);
assert_eq!(diag.code, Some(super::ErrorCode::E0301));
assert_eq!(diag.title, "Undefined Variable");
}
#[test]
fn test_diagnostic_with_types() {
let diag = super::Diagnostic::error("Test", "Msg", Span { start: 0, end: 1 })
.with_types("Int", "String");
assert_eq!(diag.expected_type, Some("Int".to_string()));
assert_eq!(diag.actual_type, Some("String".to_string()));
}
#[test]
fn test_type_diff_plain() {
let diff = super::format_type_diff_plain("Int", "String");
assert!(diff.contains("Expected: Int"));
assert!(diff.contains("Found: String"));
}
#[test]
fn test_diagnostic_render_with_all_features() {
let source = "let x: Int = \"hello\"";
let diag = super::Diagnostic::with_code(
super::ErrorCode::E0201,
"This value should be an Int but is a String",
Span { start: 13, end: 20 },
)
.with_types("Int", "String")
.with_hint("Try using String.parseInt to convert the string to an integer");
let output = super::render_diagnostic_plain(&diag, source, Some("test.lux"));
// Check all features are present
assert!(output.contains("[E0201]"));
assert!(output.contains("Type Mismatch"));
assert!(output.contains("Expected: Int"));
assert!(output.contains("Found: String"));
assert!(output.contains("Hint:"));
assert!(output.contains("parseInt"));
assert!(output.contains("lux-lang.dev/errors/E0201"));
}
}