Add polished CLI output across all commands: colored help text, green/red pass/fail indicators (✓/✗), elapsed timing on compile/check/test/fmt, command shorthands (c/t/f/s/k), fuzzy "did you mean?" on typos, and smart port-in-use suggestions for serve. Respects NO_COLOR/TERM=dumb. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1055 lines
34 KiB
Rust
1055 lines
34 KiB
Rust
//! 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";
|
|
pub const BOLD: &str = "\x1b[1m";
|
|
pub const DIM: &str = "\x1b[2m";
|
|
pub const RED: &str = "\x1b[31m";
|
|
pub const GREEN: &str = "\x1b[32m";
|
|
pub const YELLOW: &str = "\x1b[33m";
|
|
pub const BLUE: &str = "\x1b[34m";
|
|
pub const MAGENTA: &str = "\x1b[35m";
|
|
pub const CYAN: &str = "\x1b[36m";
|
|
pub const WHITE: &str = "\x1b[37m";
|
|
pub const GRAY: &str = "\x1b[90m";
|
|
}
|
|
|
|
/// Apply color to text, respecting NO_COLOR / TERM=dumb
|
|
pub fn c(color: &str, text: &str) -> String {
|
|
if supports_color() {
|
|
format!("{}{}{}", color, text, colors::RESET)
|
|
} else {
|
|
text.to_string()
|
|
}
|
|
}
|
|
|
|
/// Apply bold + color to text
|
|
pub fn bc(color: &str, text: &str) -> String {
|
|
if supports_color() {
|
|
format!("{}{}{}{}", colors::BOLD, color, text, colors::RESET)
|
|
} else {
|
|
text.to_string()
|
|
}
|
|
}
|
|
|
|
/// 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 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(),
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
/// 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
|
|
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 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",
|
|
colors::BOLD,
|
|
severity_color,
|
|
severity_label,
|
|
code_str,
|
|
filename_str,
|
|
colors::RESET
|
|
));
|
|
|
|
// Location
|
|
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 and context
|
|
let context_lines = 2; // Show 2 lines before and after
|
|
let start_line = line.saturating_sub(context_lines).max(1);
|
|
let end_context_line = (end_line + context_lines).min(source.lines().count());
|
|
let line_num_width = end_context_line.to_string().len().max(4);
|
|
|
|
// Show context before, error lines, and context after
|
|
for line_num in start_line..=end_context_line {
|
|
if let Some(source_line) = get_source_line(source, line_num) {
|
|
let is_error_line = line_num >= line && line_num <= end_line;
|
|
|
|
// Line number (dimmed for context, normal for error lines)
|
|
if is_error_line {
|
|
output.push_str(&format!(
|
|
"{}{:>width$} │{} ",
|
|
colors::DIM,
|
|
line_num,
|
|
colors::RESET,
|
|
width = line_num_width
|
|
));
|
|
// Source line (normal)
|
|
output.push_str(source_line);
|
|
} else {
|
|
// Context lines are fully dimmed
|
|
output.push_str(&format!(
|
|
"{}{:>width$} │ {}{}",
|
|
colors::DIM,
|
|
line_num,
|
|
source_line,
|
|
colors::RESET,
|
|
width = line_num_width
|
|
));
|
|
}
|
|
output.push('\n');
|
|
|
|
// Underline the error span (only for error lines)
|
|
if is_error_line {
|
|
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));
|
|
|
|
// 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');
|
|
for hint in &diagnostic.hints {
|
|
output.push_str(&format!(
|
|
"{}{}Hint:{} {}\n",
|
|
colors::BOLD,
|
|
colors::CYAN,
|
|
colors::RESET,
|
|
hint
|
|
));
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
/// 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>");
|
|
let code_str = diagnostic
|
|
.code
|
|
.map(|c| format!("[{}]", c.code()))
|
|
.unwrap_or_default();
|
|
output.push_str(&format!(
|
|
"-- {}{} ── {} ──────────────────────────────────\n",
|
|
diagnostic.severity.label(),
|
|
code_str,
|
|
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));
|
|
|
|
// 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 {
|
|
output.push_str(&format!("Hint: {}\n", hint));
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
/// 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, ErrorCode};
|
|
|
|
// Test that errors are properly categorized
|
|
let source = "let x: Int = \"hello\"";
|
|
|
|
// 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]
|
|
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()));
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
}
|