//! Elm-style diagnostic messages for beautiful error reporting #![allow(dead_code)] 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 } } /// Calculate the Levenshtein edit distance between two strings pub fn levenshtein_distance(a: &str, b: &str) -> usize { let a_len = a.chars().count(); let b_len = b.chars().count(); if a_len == 0 { return b_len; } if b_len == 0 { return a_len; } let a_chars: Vec = a.chars().collect(); let b_chars: Vec = b.chars().collect(); let mut matrix = vec![vec![0usize; b_len + 1]; a_len + 1]; for i in 0..=a_len { matrix[i][0] = i; } for j in 0..=b_len { matrix[0][j] = j; } for i in 1..=a_len { for j in 1..=b_len { let cost = if a_chars[i - 1] == b_chars[j - 1] { 0 } else { 1 }; matrix[i][j] = std::cmp::min( std::cmp::min( matrix[i - 1][j] + 1, // deletion matrix[i][j - 1] + 1, // insertion ), matrix[i - 1][j - 1] + cost, // substitution ); } } matrix[a_len][b_len] } /// Find similar names from a list of candidates /// Returns names within the given edit distance, sorted by similarity pub fn find_similar_names<'a>( target: &str, candidates: impl IntoIterator, max_distance: usize, ) -> Vec { let mut matches: Vec<(String, usize)> = candidates .into_iter() .filter(|&c| c != target) // Don't suggest the same name .map(|c| (c.to_string(), levenshtein_distance(target, c))) .filter(|(_, dist)| *dist <= max_distance && *dist > 0) .collect(); // Sort by distance (closest first), then alphabetically matches.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0))); // Return just the names, limited to top 3 matches.into_iter().take(3).map(|(name, _)| name).collect() } /// Format "Did you mean?" hint from suggestions pub fn format_did_you_mean(suggestions: &[String]) -> Option { match suggestions.len() { 0 => None, 1 => Some(format!("Did you mean '{}'?", suggestions[0])), 2 => Some(format!("Did you mean '{}' or '{}'?", suggestions[0], suggestions[1])), _ => Some(format!( "Did you mean '{}', '{}', or '{}'?", suggestions[0], suggestions[1], suggestions[2] )), } } /// 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("^")); } #[test] fn test_levenshtein_distance_identical() { assert_eq!(super::levenshtein_distance("hello", "hello"), 0); } #[test] fn test_levenshtein_distance_one_char() { assert_eq!(super::levenshtein_distance("hello", "hallo"), 1); assert_eq!(super::levenshtein_distance("cat", "car"), 1); } #[test] fn test_levenshtein_distance_insertion() { assert_eq!(super::levenshtein_distance("cat", "cats"), 1); } #[test] fn test_levenshtein_distance_deletion() { assert_eq!(super::levenshtein_distance("cats", "cat"), 1); } #[test] fn test_levenshtein_distance_empty() { assert_eq!(super::levenshtein_distance("", "hello"), 5); assert_eq!(super::levenshtein_distance("hello", ""), 5); } #[test] fn test_find_similar_names_basic() { let candidates = vec!["println", "print", "printf", "sprint"]; let similar = super::find_similar_names("prnt", candidates.into_iter(), 2); assert!(similar.contains(&"print".to_string())); } #[test] fn test_find_similar_names_no_match() { let candidates = vec!["apple", "banana", "cherry"]; let similar = super::find_similar_names("xyz", candidates.into_iter(), 2); assert!(similar.is_empty()); } #[test] fn test_find_similar_names_excludes_exact() { let candidates = vec!["hello", "hallo", "world"]; let similar = super::find_similar_names("hello", candidates.into_iter(), 2); assert!(!similar.contains(&"hello".to_string())); assert!(similar.contains(&"hallo".to_string())); } #[test] fn test_format_did_you_mean_none() { assert_eq!(super::format_did_you_mean(&[]), None); } #[test] fn test_format_did_you_mean_one() { let hint = super::format_did_you_mean(&["print".to_string()]); assert_eq!(hint, Some("Did you mean 'print'?".to_string())); } #[test] fn test_format_did_you_mean_two() { let hint = super::format_did_you_mean(&["print".to_string(), "println".to_string()]); assert_eq!(hint, Some("Did you mean 'print' or 'println'?".to_string())); } #[test] fn test_format_did_you_mean_three() { 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())); } }