Add Levenshtein distance-based similarity matching for undefined variables, unknown types, unknown effects, and unknown traits. When a name is not found, the error now suggests similar names within edit distance 2. Changes: - Add levenshtein_distance() function to diagnostics module - Add find_similar_names() and format_did_you_mean() helpers - Update typechecker to suggest similar names for: - Undefined variables - Unknown types - Unknown effects - Unknown traits - Add 17 new tests for similarity matching Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
575 lines
17 KiB
Rust
575 lines
17 KiB
Rust
//! 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<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
|
|
}
|
|
}
|
|
|
|
/// 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<char> = a.chars().collect();
|
|
let b_chars: Vec<char> = 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<Item = &'a str>,
|
|
max_distance: usize,
|
|
) -> Vec<String> {
|
|
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<String> {
|
|
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("<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("^"));
|
|
}
|
|
|
|
#[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()));
|
|
}
|
|
}
|