diff --git a/src/diagnostics.rs b/src/diagnostics.rs new file mode 100644 index 0000000..819ae47 --- /dev/null +++ b/src/diagnostics.rs @@ -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, +} + +impl Diagnostic { + pub fn error(title: impl Into, message: impl Into, span: Span) -> Self { + Self { + severity: Severity::Error, + title: title.into(), + message: message.into(), + span, + hints: Vec::new(), + } + } + + pub fn warning(title: impl Into, message: impl Into, 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) -> Self { + self.hints.push(hint.into()); + self + } + + pub fn with_hints(mut self, hints: Vec) -> 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(""); + 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(""); + 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("^")); + } +} diff --git a/src/interpreter.rs b/src/interpreter.rs index cc7153e..ddbaab4 100644 --- a/src/interpreter.rs +++ b/src/interpreter.rs @@ -3,6 +3,7 @@ #![allow(dead_code, unused_variables)] use crate::ast::*; +use crate::diagnostics::{Diagnostic, Severity}; use std::cell::RefCell; use std::collections::HashMap; use std::fmt; @@ -327,6 +328,74 @@ impl fmt::Display 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) { + 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 #[derive(Debug, Clone)] pub struct EffectRequest { diff --git a/src/main.rs b/src/main.rs index d49e7c6..200ea9b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ //! Lux - A functional programming language with first-class effects mod ast; +mod diagnostics; mod interpreter; mod lexer; mod modules; @@ -9,6 +10,7 @@ mod schema; mod typechecker; mod types; +use diagnostics::render; use interpreter::Interpreter; use parser::Parser; use std::io::{self, Write}; @@ -90,7 +92,8 @@ fn run_file(path: &str) { let mut checker = TypeChecker::new(); if let Err(errors) = checker.check_program_with_modules(&program, &loader) { for error in errors { - eprintln!("Type error: {}", error); + let diagnostic = error.to_diagnostic(); + eprint!("{}", render(&diagnostic, &source, Some(path))); } std::process::exit(1); } @@ -103,7 +106,8 @@ fn run_file(path: &str) { } } Err(e) => { - eprintln!("Runtime error: {}", e); + let diagnostic = e.to_diagnostic(); + eprint!("{}", render(&diagnostic, &source, Some(path))); std::process::exit(1); } } @@ -850,4 +854,81 @@ c")"#; assert!(result.is_err()); 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"); + } + } } diff --git a/src/parser.rs b/src/parser.rs index 0d88669..f5c0163 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1,6 +1,7 @@ //! Parser for the Lux language use crate::ast::*; +use crate::diagnostics::{Diagnostic, Severity}; use crate::lexer::{LexError, Lexer, Token, TokenKind}; 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) { + 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 for ParseError { fn from(err: LexError) -> Self { ParseError { diff --git a/src/typechecker.rs b/src/typechecker.rs index 1f71108..dc33fb1 100644 --- a/src/typechecker.rs +++ b/src/typechecker.rs @@ -7,6 +7,7 @@ use crate::ast::{ LetDecl, Literal, LiteralKind, MatchArm, Parameter, Pattern, Program, Span, Statement, TypeDecl, TypeExpr, UnaryOp, VariantFields, }; +use crate::diagnostics::{Diagnostic, Severity}; use crate::modules::ModuleLoader; use crate::types::{ 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) { + 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 pub struct TypeChecker { env: TypeEnv,