1use std::io::IsTerminal;
7
8use ariadne::{Color, Config, Label as AriadneLabel, Report, ReportKind, Source};
9
10pub mod catalog;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub struct FileId(pub u32);
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub struct Span {
21 pub file: FileId,
22 pub start: usize,
24 pub end: usize,
26}
27
28impl Span {
29 #[must_use]
32 pub fn merge(a: Span, b: Span) -> Span {
33 Span {
34 file: a.file,
35 start: a.start.min(b.start),
36 end: a.end.max(b.end),
37 }
38 }
39
40 #[must_use]
42 pub fn dummy() -> Span {
43 Span {
44 file: FileId(0),
45 start: 0,
46 end: 0,
47 }
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum Severity {
56 Error,
57 Warning,
58 Info,
59 Hint,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub struct DiagnosticCode {
65 pub prefix: char,
67 pub number: u16,
69}
70
71impl std::fmt::Display for DiagnosticCode {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 write!(f, "{}{:04}", self.prefix, self.number)
74 }
75}
76
77#[derive(Debug, Clone)]
79pub struct Label {
80 pub span: Span,
81 pub message: String,
82}
83
84#[derive(Debug, Clone)]
86pub struct Diagnostic {
87 pub severity: Severity,
88 pub code: DiagnosticCode,
89 pub message: String,
90 pub span: Span,
91 pub labels: Vec<Label>,
92 pub notes: Vec<String>,
93}
94
95impl Diagnostic {
96 pub fn label(&mut self, span: Span, message: impl Into<String>) -> &mut Self {
98 self.labels.push(Label {
99 span,
100 message: message.into(),
101 });
102 self
103 }
104
105 pub fn note(&mut self, message: impl Into<String>) -> &mut Self {
107 self.notes.push(message.into());
108 self
109 }
110}
111
112#[derive(Debug, Default)]
116pub struct DiagnosticBag {
117 items: Vec<Diagnostic>,
118}
119
120impl DiagnosticBag {
121 #[must_use]
123 pub fn new() -> Self {
124 Self::default()
125 }
126
127 pub fn error(
129 &mut self,
130 code: DiagnosticCode,
131 message: impl Into<String>,
132 span: Span,
133 ) -> &mut Diagnostic {
134 self.push(Severity::Error, code, message, span)
135 }
136
137 pub fn warning(
139 &mut self,
140 code: DiagnosticCode,
141 message: impl Into<String>,
142 span: Span,
143 ) -> &mut Diagnostic {
144 self.push(Severity::Warning, code, message, span)
145 }
146
147 pub fn info(
149 &mut self,
150 code: DiagnosticCode,
151 message: impl Into<String>,
152 span: Span,
153 ) -> &mut Diagnostic {
154 self.push(Severity::Info, code, message, span)
155 }
156
157 pub fn hint(
159 &mut self,
160 code: DiagnosticCode,
161 message: impl Into<String>,
162 span: Span,
163 ) -> &mut Diagnostic {
164 self.push(Severity::Hint, code, message, span)
165 }
166
167 #[must_use]
169 pub fn has_errors(&self) -> bool {
170 self.items.iter().any(|d| d.severity == Severity::Error)
171 }
172
173 #[must_use]
175 pub fn error_count(&self) -> usize {
176 self.items
177 .iter()
178 .filter(|d| d.severity == Severity::Error)
179 .count()
180 }
181
182 #[must_use]
184 pub fn warning_count(&self) -> usize {
185 self.items
186 .iter()
187 .filter(|d| d.severity == Severity::Warning)
188 .count()
189 }
190
191 pub fn iter(&self) -> impl Iterator<Item = &Diagnostic> {
193 self.items.iter()
194 }
195
196 pub fn absorb(&mut self, other: &DiagnosticBag) {
202 self.items.extend(other.items.iter().cloned());
203 }
204
205 #[must_use]
207 pub fn len(&self) -> usize {
208 self.items.len()
209 }
210
211 #[must_use]
213 pub fn is_empty(&self) -> bool {
214 self.items.is_empty()
215 }
216
217 fn push(
218 &mut self,
219 severity: Severity,
220 code: DiagnosticCode,
221 message: impl Into<String>,
222 span: Span,
223 ) -> &mut Diagnostic {
224 self.items.push(Diagnostic {
225 severity,
226 code,
227 message: message.into(),
228 span,
229 labels: Vec::new(),
230 notes: Vec::new(),
231 });
232 self.items.last_mut().expect("just pushed")
233 }
234}
235
236#[must_use]
244pub fn levenshtein(a: &str, b: &str) -> usize {
245 let a: Vec<char> = a.chars().collect();
246 let b: Vec<char> = b.chars().collect();
247 if a.is_empty() {
248 return b.len();
249 }
250 if b.is_empty() {
251 return a.len();
252 }
253 let mut prev: Vec<usize> = (0..=b.len()).collect();
254 let mut curr: Vec<usize> = vec![0; b.len() + 1];
255 for (i, ca) in a.iter().enumerate() {
256 curr[0] = i + 1;
257 for (j, cb) in b.iter().enumerate() {
258 let cost = if ca == cb { 0 } else { 1 };
259 curr[j + 1] = (curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost);
260 }
261 std::mem::swap(&mut prev, &mut curr);
262 }
263 prev[b.len()]
264}
265
266#[must_use]
271pub fn suggest_similar<S, I>(name: &str, candidates: I, max_distance: usize) -> Option<String>
272where
273 S: AsRef<str>,
274 I: IntoIterator<Item = S>,
275{
276 candidates
277 .into_iter()
278 .map(|s| {
279 let d = levenshtein(name, s.as_ref());
280 (s, d)
281 })
282 .filter(|(_, d)| *d <= max_distance)
283 .min_by_key(|(_, d)| *d)
284 .map(|(s, _)| s.as_ref().to_string())
285}
286
287#[must_use]
299pub fn should_colorize(no_color_present: bool, stream_is_terminal: bool) -> bool {
300 !no_color_present && stream_is_terminal
301}
302
303#[must_use]
306pub fn color_enabled_for_diagnostics() -> bool {
307 should_colorize(
308 std::env::var_os("NO_COLOR").is_some(),
309 std::io::stderr().is_terminal(),
310 )
311}
312
313#[must_use]
323pub fn render(diagnostics: &[Diagnostic], filename: &str, source: &str) -> String {
324 render_with_color(
325 diagnostics,
326 filename,
327 source,
328 color_enabled_for_diagnostics(),
329 )
330}
331
332fn byte_to_char_offset(source: &str, byte_offset: usize) -> usize {
343 if byte_offset >= source.len() {
344 return source.chars().count();
345 }
346 source
350 .char_indices()
351 .take_while(|(i, _)| *i < byte_offset)
352 .count()
353}
354
355#[must_use]
359pub fn render_with_color(
360 diagnostics: &[Diagnostic],
361 filename: &str,
362 source: &str,
363 color: bool,
364) -> String {
365 let mut out = Vec::new();
366 let cache = (filename, Source::from(source));
367
368 for diag in diagnostics {
369 let kind = severity_to_kind(diag.severity);
370 let start_char = byte_to_char_offset(source, diag.span.start);
374 let end_char = byte_to_char_offset(source, diag.span.end);
375 let span_range = start_char..end_char;
376
377 let mut builder = Report::build(kind, filename, start_char)
378 .with_config(Config::default().with_color(color))
379 .with_message(format!("[{}] {}", diag.code, diag.message))
380 .with_label(
381 AriadneLabel::new((filename, span_range))
382 .with_message(&diag.message)
383 .with_color(severity_color(diag.severity)),
384 );
385
386 for label in &diag.labels {
387 let l_start = byte_to_char_offset(source, label.span.start);
388 let l_end = byte_to_char_offset(source, label.span.end);
389 builder = builder.with_label(
390 AriadneLabel::new((filename, l_start..l_end))
391 .with_message(&label.message)
392 .with_color(Color::Blue),
393 );
394 }
395
396 for note in &diag.notes {
397 builder = builder.with_note(note);
398 }
399
400 builder
401 .finish()
402 .write(cache.clone(), &mut out)
403 .expect("write to Vec is infallible");
404 }
405
406 String::from_utf8_lossy(&out).into_owned()
407}
408
409fn severity_to_kind(severity: Severity) -> ReportKind<'static> {
410 match severity {
411 Severity::Error => ReportKind::Error,
412 Severity::Warning => ReportKind::Warning,
413 Severity::Info | Severity::Hint => ReportKind::Advice,
414 }
415}
416
417fn severity_color(severity: Severity) -> Color {
418 match severity {
419 Severity::Error => Color::Red,
420 Severity::Warning => Color::Yellow,
421 Severity::Info => Color::Cyan,
422 Severity::Hint => Color::Green,
423 }
424}
425
426#[cfg(test)]
429mod tests {
430 use super::*;
431
432 fn make_span(start: usize, end: usize) -> Span {
433 Span {
434 file: FileId(1),
435 start,
436 end,
437 }
438 }
439
440 #[test]
443 fn span_merge_basic() {
444 let a = make_span(2, 5);
445 let b = make_span(3, 8);
446 let m = Span::merge(a, b);
447 assert_eq!(m.start, 2);
448 assert_eq!(m.end, 8);
449 assert_eq!(m.file, FileId(1));
450 }
451
452 #[test]
453 fn span_merge_disjoint() {
454 let a = make_span(0, 3);
455 let b = make_span(10, 15);
456 let m = Span::merge(a, b);
457 assert_eq!(m.start, 0);
458 assert_eq!(m.end, 15);
459 }
460
461 #[test]
462 fn span_merge_identical() {
463 let s = make_span(4, 9);
464 let m = Span::merge(s, s);
465 assert_eq!(m, s);
466 }
467
468 #[test]
469 fn span_dummy_is_zero() {
470 let d = Span::dummy();
471 assert_eq!(d.file, FileId(0));
472 assert_eq!(d.start, 0);
473 assert_eq!(d.end, 0);
474 }
475
476 #[test]
479 fn diagnostic_code_display() {
480 let c = DiagnosticCode {
481 prefix: 'E',
482 number: 42,
483 };
484 assert_eq!(c.to_string(), "E0042");
485 }
486
487 #[test]
490 fn bag_has_errors_false_when_empty() {
491 let bag = DiagnosticBag::new();
492 assert!(!bag.has_errors());
493 }
494
495 #[test]
496 fn bag_has_errors_false_for_warnings() {
497 let mut bag = DiagnosticBag::new();
498 let code = DiagnosticCode {
499 prefix: 'W',
500 number: 1,
501 };
502 bag.warning(code, "watch out", make_span(0, 1));
503 assert!(!bag.has_errors());
504 }
505
506 #[test]
507 fn bag_has_errors_true_for_error() {
508 let mut bag = DiagnosticBag::new();
509 let code = DiagnosticCode {
510 prefix: 'E',
511 number: 1,
512 };
513 bag.error(code, "oops", make_span(0, 1));
514 assert!(bag.has_errors());
515 }
516
517 #[test]
518 fn bag_iter_yields_all() {
519 let mut bag = DiagnosticBag::new();
520 let ec = DiagnosticCode {
521 prefix: 'E',
522 number: 1,
523 };
524 let wc = DiagnosticCode {
525 prefix: 'W',
526 number: 2,
527 };
528 bag.error(ec, "err", make_span(0, 1));
529 bag.warning(wc, "warn", make_span(1, 2));
530 let items: Vec<_> = bag.iter().collect();
531 assert_eq!(items.len(), 2);
532 }
533
534 #[test]
535 fn bag_labels_and_notes() {
536 let mut bag = DiagnosticBag::new();
537 let code = DiagnosticCode {
538 prefix: 'E',
539 number: 5,
540 };
541 bag.error(code, "main", make_span(0, 3))
542 .label(make_span(1, 2), "secondary")
543 .note("fix it");
544 let d = bag.iter().next().unwrap();
545 assert_eq!(d.labels.len(), 1);
546 assert_eq!(d.notes.len(), 1);
547 }
548
549 #[test]
552 fn render_error_contains_message() {
553 let source = "let x = ;";
554 let span = Span {
555 file: FileId(1),
556 start: 8,
557 end: 9,
558 };
559 let diag = Diagnostic {
560 severity: Severity::Error,
561 code: DiagnosticCode {
562 prefix: 'E',
563 number: 1,
564 },
565 message: "unexpected token".into(),
566 span,
567 labels: vec![],
568 notes: vec![],
569 };
570 let out = render(&[diag], "test.bock", source);
571 assert!(out.contains("unexpected token"), "output: {out}");
572 }
573
574 #[test]
575 fn render_empty_produces_empty_string() {
576 let out = render(&[], "test.bock", "let x = 1;");
577 assert!(out.is_empty());
578 }
579
580 #[test]
583 fn levenshtein_equal_strings_is_zero() {
584 assert_eq!(levenshtein("foo", "foo"), 0);
585 }
586
587 #[test]
588 fn levenshtein_empty_strings() {
589 assert_eq!(levenshtein("", ""), 0);
590 assert_eq!(levenshtein("abc", ""), 3);
591 assert_eq!(levenshtein("", "abc"), 3);
592 }
593
594 #[test]
595 fn levenshtein_single_substitution() {
596 assert_eq!(levenshtein("cat", "bat"), 1);
597 }
598
599 #[test]
600 fn levenshtein_insert_and_delete() {
601 assert_eq!(levenshtein("kitten", "sitting"), 3);
602 }
603
604 #[test]
605 fn suggest_similar_finds_close_match() {
606 let names = vec!["println", "print", "printf"];
607 assert_eq!(suggest_similar("printn", names, 2), Some("println".into()));
608 }
609
610 #[test]
611 fn suggest_similar_rejects_far_matches() {
612 let names = vec!["elephant", "giraffe"];
613 assert_eq!(suggest_similar("cat", names, 2), None);
614 }
615
616 #[test]
619 fn should_colorize_only_on_tty_without_no_color() {
620 assert!(should_colorize(false, true));
622 assert!(!should_colorize(true, true));
625 assert!(!should_colorize(false, false));
627 assert!(!should_colorize(true, false));
629 }
630
631 fn ansi_test_diag() -> Diagnostic {
632 Diagnostic {
633 severity: Severity::Error,
634 code: DiagnosticCode {
635 prefix: 'E',
636 number: 1,
637 },
638 message: "unexpected token".into(),
639 span: Span {
640 file: FileId(1),
641 start: 8,
642 end: 9,
643 },
644 labels: vec![],
645 notes: vec![],
646 }
647 }
648
649 #[test]
650 fn render_without_color_has_no_ansi_escapes() {
651 let out = render_with_color(&[ansi_test_diag()], "test.bock", "let x = ;", false);
652 assert!(
653 !out.contains('\u{1b}'),
654 "expected no ANSI escapes, got: {out:?}"
655 );
656 assert!(out.contains("unexpected token"), "output: {out}");
657 assert!(out.contains("E0001"), "output: {out}");
658 }
659
660 #[test]
661 fn render_with_color_emits_ansi_escapes() {
662 let out = render_with_color(&[ansi_test_diag()], "test.bock", "let x = ;", true);
663 assert!(
664 out.contains('\u{1b}'),
665 "expected ANSI escapes when color is forced on, got: {out:?}"
666 );
667 }
668
669 #[test]
670 fn render_with_note_without_color_keeps_note_text() {
671 let mut diag = ansi_test_diag();
672 diag.note("declare the variable first");
673 let out = render_with_color(&[diag], "test.bock", "let x = ;", false);
674 assert!(out.contains("declare the variable first"), "output: {out}");
675 assert!(!out.contains('\u{1b}'), "output: {out:?}");
676 }
677
678 #[test]
681 fn byte_to_char_offset_ascii_is_identity() {
682 let s = "abcdef";
683 assert_eq!(byte_to_char_offset(s, 0), 0);
684 assert_eq!(byte_to_char_offset(s, 3), 3);
685 assert_eq!(byte_to_char_offset(s, 6), 6);
686 }
687
688 #[test]
689 fn byte_to_char_offset_multibyte() {
690 let s = "fée x";
692 assert_eq!(byte_to_char_offset(s, 0), 0); assert_eq!(byte_to_char_offset(s, 1), 1); assert_eq!(byte_to_char_offset(s, 3), 2); assert_eq!(byte_to_char_offset(s, 5), 4); assert_eq!(byte_to_char_offset(s, 6), 5); assert_eq!(byte_to_char_offset(s, 999), 5);
699 }
700
701 #[test]
702 fn render_multibyte_span_underlines_correct_char() {
703 let source = "fée x";
708 let span = Span {
709 file: FileId(1),
710 start: 5,
711 end: 6,
712 };
713 let diag = Diagnostic {
714 severity: Severity::Error,
715 code: DiagnosticCode {
716 prefix: 'E',
717 number: 1,
718 },
719 message: "bad token".into(),
720 span,
721 labels: vec![],
722 notes: vec![],
723 };
724 let out = render_with_color(&[diag], "m.bock", source, false);
725 assert!(out.contains("m.bock:1:5"), "expected column 5, got:\n{out}");
728 assert!(
729 !out.contains("m.bock:1:6"),
730 "should not drift to col 6:\n{out}"
731 );
732 assert!(
736 out.contains('┬') || out.contains('─'),
737 "expected an underline under the span:\n{out}"
738 );
739 assert!(out.contains("fée x"), "source line should render:\n{out}");
741 }
742
743 #[test]
744 fn render_with_note() {
745 let source = "foo bar";
746 let span = Span {
747 file: FileId(1),
748 start: 0,
749 end: 3,
750 };
751 let mut diag = Diagnostic {
752 severity: Severity::Warning,
753 code: DiagnosticCode {
754 prefix: 'W',
755 number: 99,
756 },
757 message: "test warning".into(),
758 span,
759 labels: vec![],
760 notes: vec![],
761 };
762 diag.note("consider renaming");
763 let out = render(&[diag], "src.bock", source);
764 assert!(out.contains("consider renaming"), "output: {out}");
765 }
766}