1use crate::errors::QalaError;
16use crate::span::{LineIndex, Span};
17use crate::typechecker::QalaWarning;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Severity {
23 Error,
26 Warning,
29}
30
31#[derive(Debug, Clone)]
40pub struct Diagnostic {
41 pub severity: Severity,
43 pub category: Option<String>,
47 pub message: String,
50 pub primary_span: Span,
53 pub notes: Vec<String>,
56 pub hints: Vec<String>,
60}
61
62#[derive(Debug, Clone, serde::Serialize)]
72pub struct MonacoDiagnostic {
73 pub line: u32,
76 pub column: u32,
79 pub end_line: u32,
81 pub end_column: u32,
84 pub severity: u8,
87 pub message: String,
89 pub category: Option<String>,
91}
92
93impl Diagnostic {
94 pub fn render(&self, src: &str) -> String {
125 let line_index = LineIndex::new(src);
126 let (line, column) = line_index.location(src, self.primary_span.start as usize);
127 let line_text = src.lines().nth(line - 1).unwrap_or("");
128
129 let header = match self.severity {
130 Severity::Error => "error",
131 Severity::Warning => "warning",
132 };
133
134 let mut out = String::new();
135
136 out.push_str(&format!("{header}: {}\n", self.message));
138
139 out.push_str(&format!(" --> {line}:{column}\n"));
142
143 out.push_str(" |\n");
145
146 out.push_str(&format!("{line:<3}| {line_text}\n"));
152
153 let avail = line_text.len().saturating_sub(column.saturating_sub(1));
158 let span_bytes = self.primary_span.len as usize;
159 let underline_width = std::cmp::min(span_bytes, avail).max(1);
160 let pad = " ".repeat(column.saturating_sub(1));
161 let underline = "^".repeat(underline_width);
162 out.push_str(&format!(" | {pad}{underline}\n"));
163
164 out.push_str(" |\n");
166
167 if span_bytes > avail {
169 out.push_str(" = note: spans multiple lines\n");
170 }
171
172 for note in &self.notes {
174 out.push_str(&format!(" = note: {note}\n"));
175 }
176 for hint in &self.hints {
177 out.push_str(&format!(" = hint: {hint}\n"));
178 }
179
180 out
181 }
182
183 pub fn to_monaco(&self, src: &str) -> MonacoDiagnostic {
191 let line_index = LineIndex::new(src);
192 let (line, column) = line_index.location(src, self.primary_span.start as usize);
193 let (end_line, end_column) = line_index.location(src, self.primary_span.end());
194 MonacoDiagnostic {
195 line: line as u32,
196 column: column as u32,
197 end_line: end_line as u32,
198 end_column: end_column as u32,
199 severity: match self.severity {
200 Severity::Error => 1,
201 Severity::Warning => 0,
202 },
203 message: self.message.clone(),
204 category: self.category.clone(),
205 }
206 }
207}
208
209impl From<QalaError> for Diagnostic {
210 fn from(err: QalaError) -> Self {
217 let message = err.message();
218 let primary_span = err.span();
219 let mut notes: Vec<String> = Vec::new();
220 let mut hints: Vec<String> = Vec::new();
221 match &err {
228 QalaError::EffectViolation {
229 caller_effect,
230 callee_effect,
231 ..
232 } => {
233 notes.push(format!(
234 "pure functions cannot call functions with {callee_effect} effects"
235 ));
236 hints.push(format!(
237 "remove the `is {caller_effect}` annotation, or refactor to remove the {callee_effect} call"
238 ));
239 }
240 QalaError::NonExhaustiveMatch { missing, .. } if !missing.is_empty() => {
241 notes.push(format!("add an arm for: {}", missing.join(", ")));
242 }
243 QalaError::InterfaceNotSatisfied {
244 missing,
245 mismatched,
246 ..
247 } => {
248 for name in missing {
250 notes.push(format!("missing method `{name}`"));
251 }
252 for (method, expected, found) in mismatched {
253 notes.push(format!(
254 "method `{method}` has signature {found}, expected {expected}"
255 ));
256 }
257 }
258 QalaError::RecursiveStructByValue { path, .. } => {
259 notes.push(format!("cycle path: {}", path.join(" -> ")));
260 }
261 QalaError::RedundantQuestionOperator { .. } => {
262 hints.push(
263 "change the function's return type to a compatible Result/Option, or handle the error explicitly"
264 .to_string(),
265 );
266 }
267 _ => {}
271 }
272 Diagnostic {
273 severity: Severity::Error,
274 category: None,
275 message,
276 primary_span,
277 notes,
278 hints,
279 }
280 }
281}
282
283impl From<&QalaWarning> for Diagnostic {
284 fn from(w: &QalaWarning) -> Self {
291 let notes = w.note.as_ref().map_or_else(Vec::new, |n| vec![n.clone()]);
292 Diagnostic {
293 severity: Severity::Warning,
294 category: Some(w.category.clone()),
295 message: w.message.clone(),
296 primary_span: w.span,
297 notes,
298 hints: Vec::new(),
299 }
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use crate::errors::QalaError;
307 use crate::lexer;
308 use crate::parser;
309 use crate::span::Span;
310 use crate::typechecker;
311 use crate::typechecker::QalaWarning;
312
313 fn sp() -> Span {
316 Span::new(0, 1)
317 }
318
319 fn diag_for(src: &str) -> Diagnostic {
325 let tokens = lexer::Lexer::tokenize(src).expect("lex");
326 let ast = parser::Parser::parse(&tokens).expect("parse");
327 let (_, errors, _) = typechecker::check_program(&ast, src);
328 assert!(!errors.is_empty(), "expected an error for: {src}");
329 Diagnostic::from(errors[0].clone())
330 }
331
332 fn warn_diag_for(src: &str) -> Diagnostic {
335 let tokens = lexer::Lexer::tokenize(src).expect("lex");
336 let ast = parser::Parser::parse(&tokens).expect("parse");
337 let (_, _errors, warnings) = typechecker::check_program(&ast, src);
338 assert!(!warnings.is_empty(), "expected a warning for: {src}");
339 Diagnostic::from(&warnings[0])
340 }
341
342 #[test]
343 fn diagnostic_builds_and_round_trips_through_debug_clone() {
344 let d = Diagnostic {
345 severity: Severity::Error,
346 category: None,
347 message: "x".to_string(),
348 primary_span: Span::new(0, 1),
349 notes: Vec::new(),
350 hints: Vec::new(),
351 };
352 let copy = d.clone();
353 let dbg = format!("{copy:?}");
355 assert!(!dbg.is_empty());
356 assert_eq!(copy.message, "x");
358 assert!(matches!(copy.severity, Severity::Error));
359 assert_eq!(copy.primary_span, Span::new(0, 1));
360 assert!(copy.notes.is_empty());
361 assert!(copy.hints.is_empty());
362 assert!(copy.category.is_none());
363 }
364
365 #[test]
366 fn from_qala_error_int_overflow_carries_message_and_span() {
367 let err = QalaError::IntOverflow {
368 span: Span::new(5, 4),
369 };
370 let d: Diagnostic = err.into();
371 assert!(matches!(d.severity, Severity::Error));
372 assert!(d.category.is_none());
373 assert_eq!(d.message, "integer literal is too large for i64");
374 assert_eq!(d.primary_span, Span::new(5, 4));
375 assert!(d.notes.is_empty());
376 assert!(d.hints.is_empty());
377 }
378
379 #[test]
380 fn from_qala_error_effect_violation_adds_locked_note_and_hint() {
381 let err = QalaError::EffectViolation {
382 span: sp(),
383 caller: "compute".to_string(),
384 caller_effect: "pure".to_string(),
385 callee: "println".to_string(),
386 callee_effect: "io".to_string(),
387 };
388 let d: Diagnostic = err.into();
389 assert_eq!(
390 d.notes,
391 vec!["pure functions cannot call functions with io effects".to_string()]
392 );
393 assert_eq!(
394 d.hints,
395 vec!["remove the `is pure` annotation, or refactor to remove the io call".to_string()]
396 );
397 }
398
399 #[test]
400 fn from_qala_error_non_exhaustive_match_adds_note() {
401 let err = QalaError::NonExhaustiveMatch {
402 span: sp(),
403 enum_name: "Shape".to_string(),
404 missing: vec!["Bar".to_string(), "Foo".to_string()],
405 };
406 let d: Diagnostic = err.into();
407 assert_eq!(d.notes, vec!["add an arm for: Bar, Foo".to_string()]);
408 assert!(d.hints.is_empty());
409 }
410
411 #[test]
412 fn from_qala_error_interface_not_satisfied_emits_per_method_notes() {
413 let err = QalaError::InterfaceNotSatisfied {
414 span: sp(),
415 ty: "Point".to_string(),
416 interface: "Printable".to_string(),
417 missing: vec!["a".to_string(), "b".to_string()],
418 mismatched: vec![(
419 "c".to_string(),
420 "fn(Self) -> str".to_string(),
421 "fn(Self) -> i64".to_string(),
422 )],
423 };
424 let d: Diagnostic = err.into();
425 assert_eq!(
426 d.notes,
427 vec![
428 "missing method `a`".to_string(),
429 "missing method `b`".to_string(),
430 "method `c` has signature fn(Self) -> i64, expected fn(Self) -> str".to_string(),
431 ]
432 );
433 assert!(d.hints.is_empty());
434 }
435
436 #[test]
437 fn from_qala_error_recursive_struct_adds_cycle_path_note() {
438 let err = QalaError::RecursiveStructByValue {
439 span: sp(),
440 path: vec!["A".to_string(), "B".to_string(), "A".to_string()],
441 };
442 let d: Diagnostic = err.into();
443 assert_eq!(d.notes, vec!["cycle path: A -> B -> A".to_string()]);
444 assert!(d.hints.is_empty());
445 }
446
447 #[test]
448 fn from_qala_error_redundant_question_operator_adds_hint() {
449 let err = QalaError::RedundantQuestionOperator {
450 span: sp(),
451 message: "?".to_string(),
452 };
453 let d: Diagnostic = err.into();
454 assert_eq!(
455 d.hints,
456 vec![
457 "change the function's return type to a compatible Result/Option, or handle the error explicitly"
458 .to_string()
459 ]
460 );
461 assert!(d.notes.is_empty());
462 }
463
464 #[test]
465 fn from_qala_warning_without_note_yields_empty_notes() {
466 let w = QalaWarning {
467 category: "unused_var".to_string(),
468 message: "unused variable `x`".to_string(),
469 span: Span::new(10, 1),
470 note: None,
471 };
472 let d: Diagnostic = (&w).into();
473 assert!(matches!(d.severity, Severity::Warning));
474 assert_eq!(d.category, Some("unused_var".to_string()));
475 assert_eq!(d.message, "unused variable `x`");
476 assert_eq!(d.primary_span, Span::new(10, 1));
477 assert!(d.notes.is_empty());
478 assert!(d.hints.is_empty());
479 }
480
481 #[test]
482 fn from_qala_warning_with_note_pushes_one_note_entry() {
483 let w = QalaWarning {
484 category: "shadowed_var".to_string(),
485 message: "shadowed `x`".to_string(),
486 span: Span::new(20, 1),
487 note: Some("the prior binding is at line 3:5".to_string()),
488 };
489 let d: Diagnostic = (&w).into();
490 assert_eq!(
491 d.notes,
492 vec!["the prior binding is at line 3:5".to_string()]
493 );
494 assert!(matches!(d.severity, Severity::Warning));
495 }
496
497 #[test]
498 fn to_monaco_translates_a_single_line_span_to_1_based_columns() {
499 let d = Diagnostic {
503 severity: Severity::Error,
504 category: None,
505 message: "m".to_string(),
506 primary_span: Span::new(0, 5),
507 notes: Vec::new(),
508 hints: Vec::new(),
509 };
510 let m = d.to_monaco("hello");
511 assert_eq!(m.line, 1);
512 assert_eq!(m.column, 1);
513 assert_eq!(m.end_line, 1);
514 assert_eq!(m.end_column, 6);
515 assert_eq!(m.severity, 1);
516 assert_eq!(m.message, "m");
517 assert!(m.category.is_none());
518 }
519
520 #[test]
521 fn to_monaco_translates_a_multi_line_span_to_two_line_numbers() {
522 let d = Diagnostic {
526 severity: Severity::Error,
527 category: None,
528 message: "m".to_string(),
529 primary_span: Span::new(0, 5),
530 notes: Vec::new(),
531 hints: Vec::new(),
532 };
533 let m = d.to_monaco("a\nb\nc");
534 assert_eq!(m.line, 1);
535 assert_eq!(m.column, 1);
536 assert_eq!(m.end_line, 3);
537 assert_eq!(m.end_column, 2);
538 }
539
540 #[test]
541 fn to_monaco_severity_maps_error_to_1_and_warning_to_0() {
542 let err_diag = Diagnostic {
543 severity: Severity::Error,
544 category: None,
545 message: String::new(),
546 primary_span: Span::new(0, 0),
547 notes: Vec::new(),
548 hints: Vec::new(),
549 };
550 assert_eq!(err_diag.to_monaco("").severity, 1);
551 let warn_diag = Diagnostic {
552 severity: Severity::Warning,
553 category: Some("unused_var".to_string()),
554 message: String::new(),
555 primary_span: Span::new(0, 0),
556 notes: Vec::new(),
557 hints: Vec::new(),
558 };
559 assert_eq!(warn_diag.to_monaco("").severity, 0);
560 }
561
562 #[test]
563 fn monaco_diagnostic_implements_serialize() {
564 fn assert_serialize<T: serde::Serialize>(_: &T) {}
570 let m = MonacoDiagnostic {
571 line: 1,
572 column: 1,
573 end_line: 1,
574 end_column: 1,
575 severity: 0,
576 message: String::new(),
577 category: None,
578 };
579 assert_serialize(&m);
580 }
581
582 #[test]
583 fn from_qala_error_is_deterministic_across_two_calls() {
584 let err = QalaError::InterfaceNotSatisfied {
590 span: sp(),
591 ty: "Point".to_string(),
592 interface: "Printable".to_string(),
593 missing: vec!["a".to_string(), "b".to_string()],
594 mismatched: vec![(
595 "c".to_string(),
596 "fn(Self) -> str".to_string(),
597 "fn(Self) -> i64".to_string(),
598 )],
599 };
600 let a: Diagnostic = err.clone().into();
601 let b: Diagnostic = err.into();
602 assert_eq!(a.notes, b.notes);
603 assert_eq!(a.hints, b.hints);
604 assert_eq!(a.message, b.message);
605 assert_eq!(a.primary_span, b.primary_span);
606 }
607
608 #[test]
611 fn render_header_says_error_for_an_error_diagnostic() {
612 let d = Diagnostic {
613 severity: Severity::Error,
614 category: None,
615 message: "oops".to_string(),
616 primary_span: Span::new(0, 1),
617 notes: Vec::new(),
618 hints: Vec::new(),
619 };
620 let out = d.render("x");
621 assert!(out.starts_with("error: oops\n"), "wrong header: {out:?}");
622 }
623
624 #[test]
625 fn render_arrow_line_uses_1_based_line_and_column() {
626 let d = Diagnostic {
633 severity: Severity::Error,
634 category: None,
635 message: "m".to_string(),
636 primary_span: Span::new(6, 1),
637 notes: Vec::new(),
638 hints: Vec::new(),
639 };
640 let out = d.render("abcde\nfghij");
641 assert!(out.contains(" --> 2:1\n"), "{out:?}");
642 }
643
644 #[test]
645 fn render_source_line_includes_line_number_and_text() {
646 let d = Diagnostic {
649 severity: Severity::Error,
650 category: None,
651 message: "m".to_string(),
652 primary_span: Span::new(4, 3),
653 notes: Vec::new(),
654 hints: Vec::new(),
655 };
656 let out = d.render("abc\ndef\nghi");
657 assert!(out.contains("2 | def\n"), "{out:?}");
658 }
659
660 #[test]
661 fn render_underline_has_one_caret_per_byte_at_column_1() {
662 let d = Diagnostic {
664 severity: Severity::Error,
665 category: None,
666 message: "m".to_string(),
667 primary_span: Span::new(0, 5),
668 notes: Vec::new(),
669 hints: Vec::new(),
670 };
671 let out = d.render("hello");
672 assert!(out.contains(" | ^^^^^\n"), "{out:?}");
673 }
674
675 #[test]
676 fn render_underline_pads_for_non_first_column_spans() {
677 let d = Diagnostic {
679 severity: Severity::Error,
680 category: None,
681 message: "m".to_string(),
682 primary_span: Span::new(2, 3),
683 notes: Vec::new(),
684 hints: Vec::new(),
685 };
686 let out = d.render("hello");
687 assert!(out.contains(" | ^^^\n"), "{out:?}");
688 }
689
690 #[test]
691 fn render_multi_line_span_clips_underline_and_appends_note() {
692 let d = Diagnostic {
697 severity: Severity::Error,
698 category: None,
699 message: "m".to_string(),
700 primary_span: Span::new(0, 20),
701 notes: Vec::new(),
702 hints: Vec::new(),
703 };
704 let out = d.render("line one\nline two");
705 assert!(out.contains(" | ^^^^^^^^\n"), "{out:?}");
706 assert!(out.contains(" = note: spans multiple lines\n"), "{out:?}");
707 }
708
709 #[test]
710 fn render_emits_bottom_separator_before_notes_and_hints() {
711 let d = Diagnostic {
715 severity: Severity::Error,
716 category: None,
717 message: "m".to_string(),
718 primary_span: Span::new(0, 1),
719 notes: vec!["a fact".to_string()],
720 hints: vec!["a suggestion".to_string()],
721 };
722 let out = d.render("x");
723 let bottom = out.find(" |\n = note: a fact\n");
724 assert!(
725 bottom.is_some(),
726 "bottom separator and note must be adjacent: {out:?}"
727 );
728 let note_pos = out.find(" = note: a fact\n").unwrap();
729 let hint_pos = out.find(" = hint: a suggestion\n").unwrap();
730 assert!(note_pos < hint_pos, "note must come before hint: {out:?}");
731 }
732
733 #[test]
734 fn render_emits_one_line_per_note_in_order() {
735 let d = Diagnostic {
736 severity: Severity::Error,
737 category: None,
738 message: "m".to_string(),
739 primary_span: Span::new(0, 1),
740 notes: vec!["first".to_string(), "second".to_string()],
741 hints: Vec::new(),
742 };
743 let out = d.render("x");
744 let first = out.find(" = note: first\n").unwrap();
745 let second = out.find(" = note: second\n").unwrap();
746 assert!(first < second, "notes must render in vec order: {out:?}");
747 }
748
749 #[test]
750 fn render_emits_one_line_per_hint_in_order() {
751 let d = Diagnostic {
752 severity: Severity::Error,
753 category: None,
754 message: "m".to_string(),
755 primary_span: Span::new(0, 1),
756 notes: Vec::new(),
757 hints: vec!["try this".to_string(), "or that".to_string()],
758 };
759 let out = d.render("x");
760 let first = out.find(" = hint: try this\n").unwrap();
761 let second = out.find(" = hint: or that\n").unwrap();
762 assert!(first < second, "hints must render in vec order: {out:?}");
763 }
764
765 #[test]
766 fn render_warning_uses_warning_header() {
767 let d = Diagnostic {
768 severity: Severity::Warning,
769 category: Some("unused_var".to_string()),
770 message: "unused variable `x`".to_string(),
771 primary_span: Span::new(0, 1),
772 notes: Vec::new(),
773 hints: Vec::new(),
774 };
775 let out = d.render("let x = 1");
776 assert!(
777 out.starts_with("warning: unused variable `x`\n"),
778 "wrong warning header: {out:?}"
779 );
780 }
781
782 #[test]
783 fn render_is_byte_identical_across_two_calls() {
784 let src = "fn f() -> i64 { return \"x\" }";
788 let d = diag_for(src);
789 let a = d.render(src);
790 let b = d.render(src);
791 assert_eq!(a, b, "render output drifted between calls");
792 }
793
794 #[test]
795 fn six_bundled_examples_render_no_error_output() {
796 for name in [
801 "hello",
802 "fibonacci",
803 "effects",
804 "pattern-matching",
805 "pipeline",
806 "defer-demo",
807 ] {
808 let path = format!(
809 "{}/../../playground/public/examples/{}.qala",
810 env!("CARGO_MANIFEST_DIR"),
811 name
812 );
813 let src = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
814 let tokens = lexer::Lexer::tokenize(&src).expect("lex");
815 let ast = parser::Parser::parse(&tokens).expect("parse");
816 let (_, errors, _warnings) = typechecker::check_program(&ast, &src);
817 assert!(
818 errors.is_empty(),
819 "{name}.qala: unexpected errors: {errors:?}"
820 );
821 let rendered: String = errors
822 .iter()
823 .map(|e| Diagnostic::from(e.clone()).render(&src))
824 .collect::<Vec<_>>()
825 .join("");
826 assert!(
827 rendered.is_empty(),
828 "{name}.qala: errors-rendered-to-empty failed: {rendered:?}"
829 );
830 }
831 }
832
833 #[test]
834 fn warnings_never_block_typed_ast_construction() {
835 let src = "fn main() is io {\nlet x = 1\nprintln(\"hi\")\n}";
839 let tokens = lexer::Lexer::tokenize(src).expect("lex");
840 let ast = parser::Parser::parse(&tokens).expect("parse");
841 let (typed, errors, warnings) = typechecker::check_program(&ast, src);
842 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
843 assert!(!warnings.is_empty(), "expected at least one warning");
844 assert!(
845 !typed.is_empty(),
846 "warnings must not block typed-AST construction"
847 );
848 }
849
850 #[test]
851 fn render_with_no_notes_or_hints_ends_at_the_bottom_separator() {
852 let d = Diagnostic {
857 severity: Severity::Error,
858 category: None,
859 message: "boom".to_string(),
860 primary_span: Span::new(0, 1),
861 notes: Vec::new(),
862 hints: Vec::new(),
863 };
864 let out = d.render("x");
865 assert!(out.ends_with(" |\n"), "unexpected tail: {out:?}");
867 assert!(!out.contains("= note:"), "no notes expected: {out:?}");
868 assert!(!out.contains("= hint:"), "no hints expected: {out:?}");
869 }
870
871 #[test]
872 fn renderer_is_byte_deterministic_against_snapshot() {
873 let cases: [String; 4] = [
888 diag_for("fn f() -> i64 { return \"x\" }").render("fn f() -> i64 { return \"x\" }"),
889 {
890 let src = "enum Shape { Circle(i64), Rect(i64), Triangle(i64) }\nfn f(s: Shape) -> i64 { match s { Circle(r) => r, Rect(w) => w } }";
891 diag_for(src).render(src)
892 },
893 diag_for("fn f() is pure { println(\"hi\") }")
894 .render("fn f() is pure { println(\"hi\") }"),
895 {
896 let src = "fn main() is io {\n let x = 1\n println(\"hi\")\n}";
897 warn_diag_for(src).render(src)
898 },
899 ];
900 let combined = cases.join("\n===\n");
901
902 let snapshot_path = format!(
903 "{}/tests/snapshots/diagnostics_basic.txt",
904 env!("CARGO_MANIFEST_DIR")
905 );
906 let snapshot = std::fs::read_to_string(&snapshot_path)
912 .unwrap_or_else(|e| panic!("read {snapshot_path}: {e}"))
913 .replace("\r\n", "\n");
914
915 assert_eq!(
916 combined, snapshot,
917 "renderer output drifted from snapshot at {snapshot_path}"
918 );
919 }
920}