Skip to main content

lumen_compiler/
diagnostics.rs

1//! Rich error diagnostics with source snippets, colors, and suggestions.
2
3use crate::compiler::constraints::ConstraintError;
4use crate::compiler::lexer::LexError;
5use crate::compiler::parser::ParseError;
6use crate::compiler::resolve::ResolveError;
7use crate::compiler::typecheck::TypeError;
8use crate::CompileError;
9
10/// Severity level for diagnostics
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Severity {
13    Error,
14    Warning,
15    Note,
16}
17
18/// A rendered diagnostic with source context
19#[derive(Debug, Clone)]
20pub struct Diagnostic {
21    pub severity: Severity,
22    pub code: Option<String>,
23    pub message: String,
24    pub file: Option<String>,
25    pub line: Option<usize>,
26    pub col: Option<usize>,
27    pub source_line: Option<String>,
28    pub underline: Option<String>,
29    pub suggestions: Vec<String>,
30}
31
32impl Diagnostic {
33    /// Render with ANSI colors for terminal (Elm-style)
34    pub fn render_ansi(&self) -> String {
35        let mut out = String::new();
36
37        // Build the error category title
38        let error_category = match self.severity {
39            Severity::Error => match self.code.as_deref() {
40                Some("E010") | Some("E011") | Some("E012") | Some("E013") | Some("E014")
41                | Some("E015") | Some("E016") => "PARSE ERROR",
42                Some("E040") => "TYPE MISMATCH",
43                Some("E041") => "UNDEFINED VARIABLE",
44                Some("E042") => "UNKNOWN FIELD",
45                Some("E043") => "INCOMPLETE MATCH",
46                Some("E020") => "UNDEFINED TYPE",
47                Some("E021") => "UNDEFINED CELL",
48                Some("E022") => "UNDEFINED TOOL",
49                Some("E023") => "DUPLICATE DEFINITION",
50                Some("E030") => "UNDECLARED EFFECT",
51                Some("E001") | Some("E002") | Some("E003") | Some("E004") | Some("E005")
52                | Some("E006") => "LEX ERROR",
53                Some("E050") => "CONSTRAINT ERROR",
54                _ => "ERROR",
55            },
56            Severity::Warning => "WARNING",
57            Severity::Note => "NOTE",
58        };
59
60        // Elm-style header with dashes and location
61        let location_str =
62            if let (Some(ref file), Some(line), Some(col)) = (&self.file, self.line, self.col) {
63                format!(" {}:{}:{} ", file, line, col)
64            } else if let (Some(ref file), Some(line)) = (&self.file, self.line) {
65                format!(" {}:{} ", file, line)
66            } else {
67                String::from(" ")
68            };
69
70        let title_width: usize = 80;
71        let category_width = error_category.len();
72        let location_width = location_str.len();
73        let dashes_width = title_width.saturating_sub(category_width + location_width + 6);
74
75        out.push_str(&cyan(&format!(
76            "── {} {}",
77            error_category,
78            "─".repeat(dashes_width)
79        )));
80        out.push_str(&cyan(&location_str));
81        out.push_str(&cyan("──\n"));
82        out.push('\n');
83
84        // Friendly explanation message
85        let explanation = self.generate_explanation();
86        out.push_str(&explanation);
87        out.push('\n');
88
89        // Source snippet with context (show 1-3 lines)
90        if let (Some(line_num), Some(ref line_text), Some(ref underline)) =
91            (self.line, &self.source_line, &self.underline)
92        {
93            // Show line number slightly dimmed
94            let line_str = format!("{}", line_num);
95            out.push_str(&format!("  {} │ {}\n", gray(&line_str), line_text));
96
97            // Point to the error with red carets
98            let spaces = " ".repeat(line_str.len());
99            out.push_str(&format!("  {} │ {}\n", spaces, red(underline)));
100        }
101
102        out.push('\n');
103
104        // Suggestions with friendly prefix
105        if !self.suggestions.is_empty() {
106            for suggestion in &self.suggestions {
107                // Check if it starts with a known prefix
108                if suggestion.starts_with("did you mean") {
109                    out.push_str(&format!("  {}\n", cyan(suggestion)));
110                } else if suggestion.starts_with("add")
111                    || suggestion.starts_with("ensure")
112                    || suggestion.starts_with("check")
113                {
114                    out.push_str(&format!("  {}: {}\n", bold("Hint"), suggestion));
115                } else if suggestion.contains("Try:") || suggestion.contains("use") {
116                    out.push_str(&format!("  {}: {}\n", bold("Try"), suggestion));
117                } else {
118                    out.push_str(&format!("  {}: {}\n", bold("Hint"), suggestion));
119                }
120            }
121            out.push('\n');
122        }
123
124        out
125    }
126
127    /// Generate a friendly, plain-language explanation of the error
128    fn generate_explanation(&self) -> String {
129        match self.code.as_deref() {
130            Some("E041") => {
131                // Extract variable name from message
132                let var_name = self
133                    .message
134                    .trim_start_matches("undefined variable '")
135                    .trim_end_matches('\'');
136                format!("I cannot find a variable named `{}`:", var_name)
137            }
138            Some("E040") => {
139                // Type mismatch
140                format!(
141                    "I found a type mismatch:\n\n  {}",
142                    self.message.trim_start_matches("type mismatch: ")
143                )
144            }
145            Some("E042") => {
146                // Unknown field
147                format!("I cannot find this field:\n\n  {}", self.message)
148            }
149            Some("E043") => {
150                // Incomplete match
151                format!(
152                    "This match expression is not complete:\n\n  {}",
153                    self.message
154                )
155            }
156            Some("E020") => {
157                let type_name = self
158                    .message
159                    .trim_start_matches("undefined type '")
160                    .trim_end_matches('\'');
161                format!("I cannot find a type named `{}`:", type_name)
162            }
163            Some("E021") => {
164                let cell_name = self
165                    .message
166                    .trim_start_matches("undefined cell '")
167                    .trim_end_matches('\'');
168                format!("I cannot find a cell named `{}`:", cell_name)
169            }
170            Some("E010") | Some("E011") | Some("E012") | Some("E013") | Some("E014")
171            | Some("E015") | Some("E016") => {
172                format!(
173                    "I found something unexpected while parsing:\n\n  {}",
174                    self.message
175                )
176            }
177            Some("E030") => {
178                format!(
179                    "This cell is performing an effect that it hasn't declared:\n\n  {}",
180                    self.message
181                )
182            }
183            _ => {
184                format!("I found an issue:\n\n  {}", self.message)
185            }
186        }
187    }
188
189    /// Render without colors (for LSP, tests)
190    pub fn render_plain(&self) -> String {
191        let mut out = String::new();
192
193        // Header
194        let severity_label = match self.severity {
195            Severity::Error => "error",
196            Severity::Warning => "warning",
197            Severity::Note => "note",
198        };
199
200        if let Some(ref code) = self.code {
201            out.push_str(&format!("{}[{}]: ", severity_label, code));
202        } else {
203            out.push_str(&format!("{}: ", severity_label));
204        }
205        out.push_str(&self.message);
206        out.push('\n');
207
208        // Location
209        if let (Some(ref file), Some(line), Some(col)) = (&self.file, self.line, self.col) {
210            out.push_str(&format!("  --> {}:{}:{}\n", file, line, col));
211        } else if let (Some(ref file), Some(line)) = (&self.file, self.line) {
212            out.push_str(&format!("  --> {}:{}\n", file, line));
213        }
214
215        // Source line with underline
216        if let (Some(line_num), Some(ref line_text), Some(ref underline)) =
217            (self.line, &self.source_line, &self.underline)
218        {
219            out.push_str("   |\n");
220            out.push_str(&format!("{:>3} | {}\n", line_num, line_text));
221            out.push_str(&format!("   | {}\n", underline));
222        }
223
224        // Suggestions
225        if !self.suggestions.is_empty() {
226            out.push_str("   |\n");
227            for suggestion in &self.suggestions {
228                out.push_str(&format!("   = help: {}\n", suggestion));
229            }
230        }
231
232        out
233    }
234}
235
236// ANSI color helpers
237fn red(s: &str) -> String {
238    format!("\x1b[31m{}\x1b[0m", s)
239}
240
241fn cyan(s: &str) -> String {
242    format!("\x1b[36m{}\x1b[0m", s)
243}
244
245fn bold(s: &str) -> String {
246    format!("\x1b[1m{}\x1b[0m", s)
247}
248
249fn gray(s: &str) -> String {
250    format!("\x1b[90m{}\x1b[0m", s)
251}
252
253// Source line extraction
254fn get_source_line(source: &str, line: usize) -> Option<String> {
255    source
256        .lines()
257        .nth(line.saturating_sub(1))
258        .map(|s| s.to_string())
259}
260
261fn make_underline(col: usize, len: usize) -> String {
262    format!(
263        "{}{}",
264        " ".repeat(col.saturating_sub(1)),
265        "^".repeat(len.max(1))
266    )
267}
268
269// Edit distance for suggestions
270fn edit_distance(a: &str, b: &str) -> usize {
271    let a_chars: Vec<char> = a.chars().collect();
272    let b_chars: Vec<char> = b.chars().collect();
273    let a_len = a_chars.len();
274    let b_len = b_chars.len();
275
276    if a_len == 0 {
277        return b_len;
278    }
279    if b_len == 0 {
280        return a_len;
281    }
282
283    let mut matrix = vec![vec![0; b_len + 1]; a_len + 1];
284
285    #[allow(clippy::needless_range_loop)]
286    for i in 0..=a_len {
287        matrix[i][0] = i;
288    }
289    #[allow(clippy::needless_range_loop)]
290    for j in 0..=b_len {
291        matrix[0][j] = j;
292    }
293
294    for i in 1..=a_len {
295        for j in 1..=b_len {
296            let cost = if a_chars[i - 1] == b_chars[j - 1] {
297                0
298            } else {
299                1
300            };
301            matrix[i][j] = (matrix[i - 1][j] + 1)
302                .min(matrix[i][j - 1] + 1)
303                .min(matrix[i - 1][j - 1] + cost);
304        }
305    }
306
307    matrix[a_len][b_len]
308}
309
310fn suggest_similar(name: &str, candidates: &[&str], max_distance: usize) -> Vec<String> {
311    let mut matches: Vec<(usize, String)> = candidates
312        .iter()
313        .filter_map(|c| {
314            let d = edit_distance(name, c);
315            if d <= max_distance {
316                Some((d, c.to_string()))
317            } else {
318                None
319            }
320        })
321        .collect();
322
323    matches.sort_by_key(|(d, _)| *d);
324    matches.into_iter().map(|(_, s)| s).take(3).collect()
325}
326
327// Lumen keywords for suggestions
328const KEYWORDS: &[&str] = &[
329    "record", "enum", "cell", "let", "if", "else", "for", "in", "match", "return", "halt", "end",
330    "use", "tool", "as", "grant", "expect", "schema", "role", "where", "and", "or", "not", "null",
331    "result", "ok", "err", "list", "map", "while", "loop", "break", "continue", "mut", "const",
332    "pub", "import", "from", "async", "await", "parallel", "fn", "trait", "impl", "type", "set",
333    "tuple", "emit", "yield", "mod", "self", "with", "try", "union", "step", "comptime", "macro",
334    "extern", "then", "when", "bool", "int", "float", "string", "bytes", "json",
335];
336
337// Builtin functions for suggestions
338const BUILTINS: &[&str] = &[
339    "print",
340    "len",
341    "length",
342    "append",
343    "range",
344    "to_string",
345    "str",
346    "to_int",
347    "int",
348    "to_float",
349    "float",
350    "type_of",
351    "keys",
352    "values",
353    "contains",
354    "join",
355    "split",
356    "trim",
357    "upper",
358    "lower",
359    "replace",
360    "abs",
361    "min",
362    "max",
363    "hash",
364    "not",
365    "count",
366    "matches",
367    "slice",
368    "sort",
369    "reverse",
370    "map",
371    "filter",
372    "reduce",
373    "parallel",
374    "race",
375    "vote",
376    "select",
377    "timeout",
378    "spawn",
379    "resume",
380];
381
382/// Convert a CompileError + source text into a list of Diagnostics
383pub fn format_compile_error(error: &CompileError, source: &str, filename: &str) -> Vec<Diagnostic> {
384    match error {
385        CompileError::Lex(e) => vec![format_lex_error(e, source, filename)],
386        CompileError::Parse(errors) => errors
387            .iter()
388            .map(|e| format_parse_error(e, source, filename))
389            .collect(),
390        CompileError::Resolve(errors) => errors
391            .iter()
392            .map(|e| format_resolve_error(e, source, filename))
393            .collect(),
394        CompileError::Type(errors) => errors
395            .iter()
396            .map(|e| format_type_error(e, source, filename))
397            .collect(),
398        CompileError::Constraint(errors) => errors
399            .iter()
400            .map(|e| format_constraint_error(e, source, filename))
401            .collect(),
402    }
403}
404
405fn format_lex_error(error: &LexError, source: &str, filename: &str) -> Diagnostic {
406    match error {
407        LexError::UnexpectedChar { ch, line, col } => {
408            let source_line = get_source_line(source, *line);
409            let underline = source_line.as_ref().map(|_| make_underline(*col, 1));
410
411            Diagnostic {
412                severity: Severity::Error,
413                code: Some("E001".to_string()),
414                message: format!("unexpected character '{}'", ch),
415                file: Some(filename.to_string()),
416                line: Some(*line),
417                col: Some(*col),
418                source_line,
419                underline,
420                suggestions: vec![],
421            }
422        }
423        LexError::UnterminatedString { line, col } => {
424            let source_line = get_source_line(source, *line);
425            let underline = source_line
426                .as_ref()
427                .map(|l| make_underline(*col, l.len() - col + 1));
428
429            Diagnostic {
430                severity: Severity::Error,
431                code: Some("E002".to_string()),
432                message: "unterminated string literal".to_string(),
433                file: Some(filename.to_string()),
434                line: Some(*line),
435                col: Some(*col),
436                source_line,
437                underline,
438                suggestions: vec!["add a closing quote".to_string()],
439            }
440        }
441        LexError::InconsistentIndent { line } => {
442            let source_line = get_source_line(source, *line);
443            let underline = source_line.as_ref().map(|l| {
444                let indent = l.chars().take_while(|c| c.is_whitespace()).count();
445                make_underline(1, indent.max(1))
446            });
447
448            Diagnostic {
449                severity: Severity::Error,
450                code: Some("E003".to_string()),
451                message: "inconsistent indentation".to_string(),
452                file: Some(filename.to_string()),
453                line: Some(*line),
454                col: Some(1),
455                source_line,
456                underline,
457                suggestions: vec![
458                    "ensure all indentation uses the same number of spaces".to_string()
459                ],
460            }
461        }
462        LexError::InvalidNumber { line, col } => {
463            let source_line = get_source_line(source, *line);
464            let underline = source_line.as_ref().map(|_| make_underline(*col, 1));
465
466            Diagnostic {
467                severity: Severity::Error,
468                code: Some("E004".to_string()),
469                message: "invalid number literal".to_string(),
470                file: Some(filename.to_string()),
471                line: Some(*line),
472                col: Some(*col),
473                source_line,
474                underline,
475                suggestions: vec![],
476            }
477        }
478        LexError::InvalidBytesLiteral { line, col } => {
479            let source_line = get_source_line(source, *line);
480            let underline = source_line.as_ref().map(|_| make_underline(*col, 1));
481
482            Diagnostic {
483                severity: Severity::Error,
484                code: Some("E005".to_string()),
485                message: "invalid bytes literal".to_string(),
486                file: Some(filename.to_string()),
487                line: Some(*line),
488                col: Some(*col),
489                source_line,
490                underline,
491                suggestions: vec!["bytes literals must be hex: b\"48656c6c6f\"".to_string()],
492            }
493        }
494        LexError::InvalidUnicodeEscape { line, col } => {
495            let source_line = get_source_line(source, *line);
496            let underline = source_line.as_ref().map(|_| make_underline(*col, 1));
497
498            Diagnostic {
499                severity: Severity::Error,
500                code: Some("E006".to_string()),
501                message: "invalid unicode escape sequence".to_string(),
502                file: Some(filename.to_string()),
503                line: Some(*line),
504                col: Some(*col),
505                source_line,
506                underline,
507                suggestions: vec!["use \\u{XXXX} format for unicode escapes".to_string()],
508            }
509        }
510    }
511}
512
513fn format_parse_error(error: &ParseError, source: &str, filename: &str) -> Diagnostic {
514    match error {
515        ParseError::Unexpected {
516            found,
517            expected,
518            line,
519            col,
520        } => {
521            let source_line = get_source_line(source, *line);
522            let underline = source_line.as_ref().map(|s| {
523                // Try to underline the whole token
524                let col_idx = col.saturating_sub(1);
525                if let Some(token_end) = s[col_idx..]
526                    .chars()
527                    .position(|c| c.is_whitespace() || c == '(' || c == ')' || c == '{' || c == '}')
528                {
529                    make_underline(*col, token_end.max(1))
530                } else {
531                    make_underline(*col, s[col_idx..].len().max(1))
532                }
533            });
534
535            let mut suggestions = vec![];
536            // Detect if this looks like a parameter parsing issue
537            // In cell parameter lists, if we see an identifier where we expected comma/close,
538            // it likely means a missing colon.
539            let looks_like_type_annotation = expected.trim() == ","
540                && (found
541                    .chars()
542                    .next()
543                    .map(|c| c.is_uppercase())
544                    .unwrap_or(false)
545                    || matches!(found.as_str(), "Int" | "String" | "Float" | "Bool" | "Any"));
546
547            let friendly_message = if expected.trim() == ":" && found != ":" {
548                suggestions.push(format!("Try: name: {}", found));
549                format!(
550                    "I was expecting a `:` after the parameter name, but found `{}`",
551                    found
552                )
553            } else if looks_like_type_annotation {
554                suggestions.push("Add a `:` before the type annotation".to_string());
555                format!("I was expecting `,` or `)` after the parameter name, but found a type `{}`.\n\n  Did you forget the `:` between the parameter name and type?", found)
556            } else if expected.contains("end") {
557                suggestions.push("Add 'end' to close this block".to_string());
558                format!("I was expecting 'end', but found `{}`", found)
559            } else if expected.trim() == "," {
560                format!("I was expecting `,` or `)`, but found `{}`", found)
561            } else {
562                format!("I was expecting {}, but found `{}`", expected, found)
563            };
564
565            Diagnostic {
566                severity: Severity::Error,
567                code: Some("E010".to_string()),
568                message: friendly_message,
569                file: Some(filename.to_string()),
570                line: Some(*line),
571                col: Some(*col),
572                source_line,
573                underline,
574                suggestions,
575            }
576        }
577        ParseError::UnexpectedEof => Diagnostic {
578            severity: Severity::Error,
579            code: Some("E011".to_string()),
580            message: "unexpected end of input".to_string(),
581            file: Some(filename.to_string()),
582            line: None,
583            col: None,
584            source_line: None,
585            underline: None,
586            suggestions: vec!["check for missing 'end' keywords".to_string()],
587        },
588        ParseError::UnclosedBracket {
589            bracket,
590            open_line,
591            open_col,
592            current_line,
593            current_col,
594        } => {
595            let source_line = get_source_line(source, *open_line);
596            let underline = source_line.as_ref().map(|_| make_underline(*open_col, 1));
597            Diagnostic {
598                severity: Severity::Error,
599                code: Some("E012".to_string()),
600                message: format!(
601                    "unclosed '{}' opened at line {}, col {}",
602                    bracket, open_line, open_col
603                ),
604                file: Some(filename.to_string()),
605                line: Some(*current_line),
606                col: Some(*current_col),
607                source_line,
608                underline,
609                suggestions: vec![format!(
610                    "add closing '{}'",
611                    match *bracket {
612                        '(' => ')',
613                        '[' => ']',
614                        '{' => '}',
615                        _ => *bracket,
616                    }
617                )],
618            }
619        }
620        ParseError::MissingEnd {
621            construct,
622            open_line,
623            open_col,
624            current_line,
625            current_col,
626        } => {
627            let source_line = get_source_line(source, *open_line);
628            let underline = source_line.as_ref().map(|_| make_underline(*open_col, 1));
629            Diagnostic {
630                severity: Severity::Error,
631                code: Some("E013".to_string()),
632                message: format!(
633                    "expected 'end' to close '{}' at line {}, col {}",
634                    construct, open_line, open_col
635                ),
636                file: Some(filename.to_string()),
637                line: Some(*current_line),
638                col: Some(*current_col),
639                source_line,
640                underline,
641                suggestions: vec!["add 'end' to close the block".to_string()],
642            }
643        }
644        ParseError::MissingType { line, col, .. } => {
645            let source_line = get_source_line(source, *line);
646            let underline = source_line.as_ref().map(|_| make_underline(*col, 1));
647            Diagnostic {
648                severity: Severity::Error,
649                code: Some("E014".to_string()),
650                message: "missing type annotation".to_string(),
651                file: Some(filename.to_string()),
652                line: Some(*line),
653                col: Some(*col),
654                source_line,
655                underline,
656                suggestions: vec![],
657            }
658        }
659        ParseError::IncompleteExpression { line, col, .. } => {
660            let source_line = get_source_line(source, *line);
661            let underline = source_line.as_ref().map(|_| make_underline(*col, 1));
662            Diagnostic {
663                severity: Severity::Error,
664                code: Some("E015".to_string()),
665                message: "incomplete expression".to_string(),
666                file: Some(filename.to_string()),
667                line: Some(*line),
668                col: Some(*col),
669                source_line,
670                underline,
671                suggestions: vec![],
672            }
673        }
674        ParseError::MalformedConstruct { line, col, .. } => {
675            let source_line = get_source_line(source, *line);
676            let underline = source_line.as_ref().map(|_| make_underline(*col, 1));
677            Diagnostic {
678                severity: Severity::Error,
679                code: Some("E016".to_string()),
680                message: "malformed construct".to_string(),
681                file: Some(filename.to_string()),
682                line: Some(*line),
683                col: Some(*col),
684                source_line,
685                underline,
686                suggestions: vec![],
687            }
688        }
689    }
690}
691
692fn format_resolve_error(error: &ResolveError, source: &str, filename: &str) -> Diagnostic {
693    match error {
694        ResolveError::UndefinedType {
695            name,
696            line,
697            suggestions: error_suggestions,
698        } => {
699            let source_line = get_source_line(source, *line);
700            let underline = source_line.as_ref().map(|l| {
701                if let Some(pos) = l.find(name) {
702                    make_underline(pos + 1, name.len())
703                } else {
704                    make_underline(1, 1)
705                }
706            });
707
708            let help = if !error_suggestions.is_empty() {
709                vec![format!("Did you mean `{}`?", error_suggestions[0])]
710            } else {
711                vec![]
712            };
713
714            Diagnostic {
715                severity: Severity::Error,
716                code: Some("E020".to_string()),
717                message: format!("undefined type '{}'", name),
718                file: Some(filename.to_string()),
719                line: Some(*line),
720                col: None,
721                source_line,
722                underline,
723                suggestions: help,
724            }
725        }
726        ResolveError::UndefinedCell {
727            name,
728            line,
729            suggestions: error_suggestions,
730        } => {
731            let source_line = get_source_line(source, *line);
732            let underline = source_line.as_ref().map(|l| {
733                if let Some(pos) = l.find(name) {
734                    make_underline(pos + 1, name.len())
735                } else {
736                    make_underline(1, 1)
737                }
738            });
739
740            let help = if !error_suggestions.is_empty() {
741                vec![format!("Did you mean `{}`?", error_suggestions[0])]
742            } else {
743                vec![]
744            };
745
746            Diagnostic {
747                severity: Severity::Error,
748                code: Some("E021".to_string()),
749                message: format!("undefined cell '{}'", name),
750                file: Some(filename.to_string()),
751                line: Some(*line),
752                col: None,
753                source_line,
754                underline,
755                suggestions: help,
756            }
757        }
758        ResolveError::UndefinedTool { name, line } => {
759            let source_line = get_source_line(source, *line);
760            let underline = source_line.as_ref().map(|l| {
761                if let Some(pos) = l.find(name) {
762                    make_underline(pos + 1, name.len())
763                } else {
764                    make_underline(1, 1)
765                }
766            });
767
768            Diagnostic {
769                severity: Severity::Error,
770                code: Some("E022".to_string()),
771                message: format!("undefined tool alias '{}'", name),
772                file: Some(filename.to_string()),
773                line: Some(*line),
774                col: None,
775                source_line,
776                underline,
777                suggestions: vec!["ensure the tool is declared with 'use tool'".to_string()],
778            }
779        }
780        ResolveError::Duplicate { name, line } => {
781            let source_line = get_source_line(source, *line);
782            let underline = source_line.as_ref().map(|l| {
783                if let Some(pos) = l.find(name) {
784                    make_underline(pos + 1, name.len())
785                } else {
786                    make_underline(1, 1)
787                }
788            });
789
790            Diagnostic {
791                severity: Severity::Error,
792                code: Some("E023".to_string()),
793                message: format!("duplicate definition '{}'", name),
794                file: Some(filename.to_string()),
795                line: Some(*line),
796                col: None,
797                source_line,
798                underline,
799                suggestions: vec![],
800            }
801        }
802        ResolveError::UndeclaredEffect {
803            cell,
804            effect,
805            line,
806            cause,
807        } => {
808            let source_line = get_source_line(source, *line);
809            let underline = source_line.as_ref().map(|_| make_underline(1, 1));
810
811            let mut suggestions = vec![format!(
812                "add '{}' to the effect row of cell '{}'",
813                effect, cell
814            )];
815            if !cause.is_empty() {
816                suggestions.push(format!("caused by: {}", cause));
817            }
818
819            Diagnostic {
820                severity: Severity::Error,
821                code: Some("E030".to_string()),
822                message: format!(
823                    "cell '{}' performs effect '{}' but it is not declared in its effect row",
824                    cell, effect
825                ),
826                file: Some(filename.to_string()),
827                line: Some(*line),
828                col: None,
829                source_line,
830                underline,
831                suggestions,
832            }
833        }
834        _ => {
835            // Fallback for other resolve errors
836            Diagnostic {
837                severity: Severity::Error,
838                code: Some("E099".to_string()),
839                message: error.to_string(),
840                file: Some(filename.to_string()),
841                line: None,
842                col: None,
843                source_line: None,
844                underline: None,
845                suggestions: vec![],
846            }
847        }
848    }
849}
850
851fn format_type_error(error: &TypeError, source: &str, filename: &str) -> Diagnostic {
852    match error {
853        TypeError::Mismatch {
854            expected,
855            actual,
856            line,
857        } => {
858            let source_line = get_source_line(source, *line);
859            let underline = source_line.as_ref().map(|_| make_underline(1, 1));
860
861            Diagnostic {
862                severity: Severity::Error,
863                code: Some("E040".to_string()),
864                message: format!("type mismatch: expected {}, got {}", expected, actual),
865                file: Some(filename.to_string()),
866                line: Some(*line),
867                col: None,
868                source_line,
869                underline,
870                suggestions: vec![],
871            }
872        }
873        TypeError::UndefinedVar { name, line } => {
874            let source_line = get_source_line(source, *line);
875            let underline = source_line.as_ref().map(|l| {
876                if let Some(pos) = l.find(name) {
877                    make_underline(pos + 1, name.len())
878                } else {
879                    make_underline(1, 1)
880                }
881            });
882
883            let mut candidates: Vec<&str> = KEYWORDS.to_vec();
884            candidates.extend(BUILTINS.iter().copied());
885            let suggestions = suggest_similar(name, &candidates, 2);
886            let help = if !suggestions.is_empty() {
887                vec![format!("Did you mean `{}`?", suggestions[0])]
888            } else {
889                vec![]
890            };
891
892            Diagnostic {
893                severity: Severity::Error,
894                code: Some("E041".to_string()),
895                message: format!("undefined variable '{}'", name),
896                file: Some(filename.to_string()),
897                line: Some(*line),
898                col: None,
899                source_line,
900                underline,
901                suggestions: help,
902            }
903        }
904        TypeError::UnknownField {
905            field,
906            ty,
907            line,
908            suggestions: error_suggestions,
909        } => {
910            let source_line = get_source_line(source, *line);
911            let underline = source_line.as_ref().map(|l| {
912                if let Some(pos) = l.find(field) {
913                    make_underline(pos + 1, field.len())
914                } else {
915                    make_underline(1, 1)
916                }
917            });
918
919            let help = if !error_suggestions.is_empty() {
920                vec![format!("Did you mean `{}`?", error_suggestions[0])]
921            } else {
922                vec![]
923            };
924
925            Diagnostic {
926                severity: Severity::Error,
927                code: Some("E042".to_string()),
928                message: format!("unknown field '{}' on type '{}'", field, ty),
929                file: Some(filename.to_string()),
930                line: Some(*line),
931                col: None,
932                source_line,
933                underline,
934                suggestions: help,
935            }
936        }
937        TypeError::IncompleteMatch {
938            enum_name,
939            missing,
940            line,
941        } => {
942            let source_line = get_source_line(source, *line);
943            let underline = source_line.as_ref().map(|_| make_underline(1, 1));
944
945            let missing_list = missing.join(", ");
946            let suggestions = vec![format!(
947                "add patterns for missing variants: {}",
948                missing_list
949            )];
950
951            Diagnostic {
952                severity: Severity::Error,
953                code: Some("E043".to_string()),
954                message: format!(
955                    "incomplete match on enum '{}': missing variants [{}]",
956                    enum_name, missing_list
957                ),
958                file: Some(filename.to_string()),
959                line: Some(*line),
960                col: None,
961                source_line,
962                underline,
963                suggestions,
964            }
965        }
966        _ => {
967            // Fallback for other type errors
968            let line = match error {
969                TypeError::NotCallable { line }
970                | TypeError::ArgCount { line, .. }
971                | TypeError::Mismatch { line, .. }
972                | TypeError::UndefinedVar { line, .. }
973                | TypeError::UnknownField { line, .. }
974                | TypeError::IncompleteMatch { line, .. } => Some(*line),
975                _ => None,
976            };
977
978            let source_line = line.and_then(|l| get_source_line(source, l));
979            let underline = source_line.as_ref().map(|_| make_underline(1, 1));
980
981            Diagnostic {
982                severity: Severity::Error,
983                code: Some("E049".to_string()),
984                message: error.to_string(),
985                file: Some(filename.to_string()),
986                line,
987                col: None,
988                source_line,
989                underline,
990                suggestions: vec![],
991            }
992        }
993    }
994}
995
996fn format_constraint_error(error: &ConstraintError, source: &str, filename: &str) -> Diagnostic {
997    match error {
998        ConstraintError::Invalid {
999            field,
1000            line,
1001            message,
1002        } => {
1003            let source_line = get_source_line(source, *line);
1004            let underline = source_line.as_ref().map(|_| make_underline(1, 1));
1005
1006            Diagnostic {
1007                severity: Severity::Error,
1008                code: Some("E050".to_string()),
1009                message: format!("invalid constraint on field '{}': {}", field, message),
1010                file: Some(filename.to_string()),
1011                line: Some(*line),
1012                col: None,
1013                source_line,
1014                underline,
1015                suggestions: vec![],
1016            }
1017        }
1018    }
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023    use super::*;
1024
1025    #[test]
1026    fn test_get_source_line() {
1027        let source = "line 1\nline 2\nline 3\n";
1028        assert_eq!(get_source_line(source, 1), Some("line 1".to_string()));
1029        assert_eq!(get_source_line(source, 2), Some("line 2".to_string()));
1030        assert_eq!(get_source_line(source, 3), Some("line 3".to_string()));
1031        assert_eq!(get_source_line(source, 4), None);
1032    }
1033
1034    #[test]
1035    fn test_make_underline() {
1036        assert_eq!(make_underline(1, 3), "^^^");
1037        assert_eq!(make_underline(5, 2), "    ^^");
1038        assert_eq!(make_underline(10, 1), "         ^");
1039    }
1040
1041    #[test]
1042    fn test_edit_distance() {
1043        assert_eq!(edit_distance("", ""), 0);
1044        assert_eq!(edit_distance("a", ""), 1);
1045        assert_eq!(edit_distance("", "a"), 1);
1046        assert_eq!(edit_distance("abc", "abc"), 0);
1047        assert_eq!(edit_distance("abc", "abd"), 1);
1048        assert_eq!(edit_distance("kitten", "sitting"), 3);
1049    }
1050
1051    #[test]
1052    fn test_suggest_similar() {
1053        let candidates = &["for", "from", "foo", "bar"];
1054        let suggestions = suggest_similar("fr", candidates, 2);
1055        assert!(suggestions.contains(&"for".to_string()));
1056        assert!(suggestions.len() <= 3);
1057
1058        let suggestions = suggest_similar("xyz", candidates, 1);
1059        assert!(suggestions.is_empty());
1060    }
1061
1062    #[test]
1063    fn test_format_parse_error() {
1064        let error = ParseError::Unexpected {
1065            found: "if".to_string(),
1066            expected: "end".to_string(),
1067            line: 5,
1068            col: 10,
1069        };
1070        let source = "line 1\nline 2\nline 3\nline 4\nline 5 with if\n";
1071        let diag = format_parse_error(&error, source, "test.lm.md");
1072
1073        assert_eq!(diag.severity, Severity::Error);
1074        assert_eq!(diag.code, Some("E010".to_string()));
1075        assert!(diag.message.contains("expecting") || diag.message.contains("found"));
1076        assert_eq!(diag.line, Some(5));
1077    }
1078
1079    #[test]
1080    fn test_format_type_error_undefined_var() {
1081        let error = TypeError::UndefinedVar {
1082            name: "fo".to_string(),
1083            line: 3,
1084        };
1085        let source = "line 1\nline 2\nlet x = fo\n";
1086        let diag = format_type_error(&error, source, "test.lm.md");
1087
1088        assert_eq!(diag.severity, Severity::Error);
1089        assert_eq!(diag.code, Some("E041".to_string()));
1090        assert!(diag.message.contains("undefined variable"));
1091        assert!(!diag.suggestions.is_empty());
1092        // Should suggest "for" since edit distance is 1
1093        assert!(diag
1094            .suggestions
1095            .iter()
1096            .any(|s| s.contains("for") || s.contains("to")));
1097    }
1098
1099    #[test]
1100    fn test_render_plain() {
1101        let diag = Diagnostic {
1102            severity: Severity::Error,
1103            code: Some("E041".to_string()),
1104            message: "undefined variable 'foo'".to_string(),
1105            file: Some("test.lm.md".to_string()),
1106            line: Some(10),
1107            col: Some(5),
1108            source_line: Some("  let x = foo".to_string()),
1109            underline: Some("         ^^^".to_string()),
1110            suggestions: vec!["did you mean 'for'?".to_string()],
1111        };
1112
1113        let output = diag.render_plain();
1114        assert!(output.contains("error[E041]"));
1115        assert!(output.contains("undefined variable"));
1116        assert!(output.contains("test.lm.md:10:5"));
1117        assert!(output.contains("let x = foo"));
1118        assert!(output.contains("^^^"));
1119        assert!(output.contains("did you mean 'for'?"));
1120    }
1121
1122    #[test]
1123    fn test_render_ansi() {
1124        let diag = Diagnostic {
1125            severity: Severity::Error,
1126            code: Some("E041".to_string()),
1127            message: "undefined variable 'foo'".to_string(),
1128            file: Some("test.lm.md".to_string()),
1129            line: Some(10),
1130            col: Some(5),
1131            source_line: Some("  let x = foo".to_string()),
1132            underline: Some("         ^^^".to_string()),
1133            suggestions: vec!["did you mean 'for'?".to_string()],
1134        };
1135
1136        let output = diag.render_ansi();
1137        // Check that ANSI codes are present
1138        assert!(output.contains("\x1b["));
1139        // The Elm-style format uses ERROR category headers but the code isn't in the main output
1140        assert!(output.contains("UNDEFINED VARIABLE") || output.contains("undefined variable"));
1141    }
1142}