1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Severity {
13 Error,
14 Warning,
15 Note,
16}
17
18#[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 pub fn render_ansi(&self) -> String {
35 let mut out = String::new();
36
37 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 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 let explanation = self.generate_explanation();
86 out.push_str(&explanation);
87 out.push('\n');
88
89 if let (Some(line_num), Some(ref line_text), Some(ref underline)) =
91 (self.line, &self.source_line, &self.underline)
92 {
93 let line_str = format!("{}", line_num);
95 out.push_str(&format!(" {} │ {}\n", gray(&line_str), line_text));
96
97 let spaces = " ".repeat(line_str.len());
99 out.push_str(&format!(" {} │ {}\n", spaces, red(underline)));
100 }
101
102 out.push('\n');
103
104 if !self.suggestions.is_empty() {
106 for suggestion in &self.suggestions {
107 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 fn generate_explanation(&self) -> String {
129 match self.code.as_deref() {
130 Some("E041") => {
131 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 format!(
141 "I found a type mismatch:\n\n {}",
142 self.message.trim_start_matches("type mismatch: ")
143 )
144 }
145 Some("E042") => {
146 format!("I cannot find this field:\n\n {}", self.message)
148 }
149 Some("E043") => {
150 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 pub fn render_plain(&self) -> String {
191 let mut out = String::new();
192
193 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 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 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 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
236fn 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
253fn 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
269fn 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
327const 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
337const 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
382pub 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 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 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 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 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 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 assert!(output.contains("\x1b["));
1139 assert!(output.contains("UNDEFINED VARIABLE") || output.contains("undefined variable"));
1141 }
1142}