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:
426
src/diagnostics.rs
Normal file
426
src/diagnostics.rs
Normal 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("^"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user