1use ariadne::{Color, Label as AriadneLabel, Report, ReportKind, Source};
7
8pub mod catalog;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub struct FileId(pub u32);
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct Span {
19 pub file: FileId,
20 pub start: usize,
22 pub end: usize,
24}
25
26impl Span {
27 #[must_use]
30 pub fn merge(a: Span, b: Span) -> Span {
31 Span {
32 file: a.file,
33 start: a.start.min(b.start),
34 end: a.end.max(b.end),
35 }
36 }
37
38 #[must_use]
40 pub fn dummy() -> Span {
41 Span {
42 file: FileId(0),
43 start: 0,
44 end: 0,
45 }
46 }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Severity {
54 Error,
55 Warning,
56 Info,
57 Hint,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct DiagnosticCode {
63 pub prefix: char,
65 pub number: u16,
67}
68
69impl std::fmt::Display for DiagnosticCode {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 write!(f, "{}{:04}", self.prefix, self.number)
72 }
73}
74
75#[derive(Debug, Clone)]
77pub struct Label {
78 pub span: Span,
79 pub message: String,
80}
81
82#[derive(Debug, Clone)]
84pub struct Diagnostic {
85 pub severity: Severity,
86 pub code: DiagnosticCode,
87 pub message: String,
88 pub span: Span,
89 pub labels: Vec<Label>,
90 pub notes: Vec<String>,
91}
92
93impl Diagnostic {
94 pub fn label(&mut self, span: Span, message: impl Into<String>) -> &mut Self {
96 self.labels.push(Label {
97 span,
98 message: message.into(),
99 });
100 self
101 }
102
103 pub fn note(&mut self, message: impl Into<String>) -> &mut Self {
105 self.notes.push(message.into());
106 self
107 }
108}
109
110#[derive(Debug, Default)]
114pub struct DiagnosticBag {
115 items: Vec<Diagnostic>,
116}
117
118impl DiagnosticBag {
119 #[must_use]
121 pub fn new() -> Self {
122 Self::default()
123 }
124
125 pub fn error(
127 &mut self,
128 code: DiagnosticCode,
129 message: impl Into<String>,
130 span: Span,
131 ) -> &mut Diagnostic {
132 self.push(Severity::Error, code, message, span)
133 }
134
135 pub fn warning(
137 &mut self,
138 code: DiagnosticCode,
139 message: impl Into<String>,
140 span: Span,
141 ) -> &mut Diagnostic {
142 self.push(Severity::Warning, code, message, span)
143 }
144
145 pub fn info(
147 &mut self,
148 code: DiagnosticCode,
149 message: impl Into<String>,
150 span: Span,
151 ) -> &mut Diagnostic {
152 self.push(Severity::Info, code, message, span)
153 }
154
155 pub fn hint(
157 &mut self,
158 code: DiagnosticCode,
159 message: impl Into<String>,
160 span: Span,
161 ) -> &mut Diagnostic {
162 self.push(Severity::Hint, code, message, span)
163 }
164
165 #[must_use]
167 pub fn has_errors(&self) -> bool {
168 self.items.iter().any(|d| d.severity == Severity::Error)
169 }
170
171 #[must_use]
173 pub fn error_count(&self) -> usize {
174 self.items
175 .iter()
176 .filter(|d| d.severity == Severity::Error)
177 .count()
178 }
179
180 #[must_use]
182 pub fn warning_count(&self) -> usize {
183 self.items
184 .iter()
185 .filter(|d| d.severity == Severity::Warning)
186 .count()
187 }
188
189 pub fn iter(&self) -> impl Iterator<Item = &Diagnostic> {
191 self.items.iter()
192 }
193
194 #[must_use]
196 pub fn len(&self) -> usize {
197 self.items.len()
198 }
199
200 #[must_use]
202 pub fn is_empty(&self) -> bool {
203 self.items.is_empty()
204 }
205
206 fn push(
207 &mut self,
208 severity: Severity,
209 code: DiagnosticCode,
210 message: impl Into<String>,
211 span: Span,
212 ) -> &mut Diagnostic {
213 self.items.push(Diagnostic {
214 severity,
215 code,
216 message: message.into(),
217 span,
218 labels: Vec::new(),
219 notes: Vec::new(),
220 });
221 self.items.last_mut().expect("just pushed")
222 }
223}
224
225#[must_use]
233pub fn levenshtein(a: &str, b: &str) -> usize {
234 let a: Vec<char> = a.chars().collect();
235 let b: Vec<char> = b.chars().collect();
236 if a.is_empty() {
237 return b.len();
238 }
239 if b.is_empty() {
240 return a.len();
241 }
242 let mut prev: Vec<usize> = (0..=b.len()).collect();
243 let mut curr: Vec<usize> = vec![0; b.len() + 1];
244 for (i, ca) in a.iter().enumerate() {
245 curr[0] = i + 1;
246 for (j, cb) in b.iter().enumerate() {
247 let cost = if ca == cb { 0 } else { 1 };
248 curr[j + 1] = (curr[j] + 1)
249 .min(prev[j + 1] + 1)
250 .min(prev[j] + cost);
251 }
252 std::mem::swap(&mut prev, &mut curr);
253 }
254 prev[b.len()]
255}
256
257#[must_use]
262pub fn suggest_similar<S, I>(name: &str, candidates: I, max_distance: usize) -> Option<String>
263where
264 S: AsRef<str>,
265 I: IntoIterator<Item = S>,
266{
267 candidates
268 .into_iter()
269 .map(|s| {
270 let d = levenshtein(name, s.as_ref());
271 (s, d)
272 })
273 .filter(|(_, d)| *d <= max_distance)
274 .min_by_key(|(_, d)| *d)
275 .map(|(s, _)| s.as_ref().to_string())
276}
277
278#[must_use]
286pub fn render(diagnostics: &[Diagnostic], filename: &str, source: &str) -> String {
287 let mut out = Vec::new();
288 let cache = (filename, Source::from(source));
289
290 for diag in diagnostics {
291 let kind = severity_to_kind(diag.severity);
292 let span_range = diag.span.start..diag.span.end;
293
294 let mut builder = Report::build(kind, filename, diag.span.start)
295 .with_message(format!("[{}] {}", diag.code, diag.message))
296 .with_label(
297 AriadneLabel::new((filename, span_range))
298 .with_message(&diag.message)
299 .with_color(severity_color(diag.severity)),
300 );
301
302 for label in &diag.labels {
303 builder = builder.with_label(
304 AriadneLabel::new((filename, label.span.start..label.span.end))
305 .with_message(&label.message)
306 .with_color(Color::Blue),
307 );
308 }
309
310 for note in &diag.notes {
311 builder = builder.with_note(note);
312 }
313
314 builder
315 .finish()
316 .write(cache.clone(), &mut out)
317 .expect("write to Vec is infallible");
318 }
319
320 String::from_utf8_lossy(&out).into_owned()
321}
322
323fn severity_to_kind(severity: Severity) -> ReportKind<'static> {
324 match severity {
325 Severity::Error => ReportKind::Error,
326 Severity::Warning => ReportKind::Warning,
327 Severity::Info | Severity::Hint => ReportKind::Advice,
328 }
329}
330
331fn severity_color(severity: Severity) -> Color {
332 match severity {
333 Severity::Error => Color::Red,
334 Severity::Warning => Color::Yellow,
335 Severity::Info => Color::Cyan,
336 Severity::Hint => Color::Green,
337 }
338}
339
340#[cfg(test)]
343mod tests {
344 use super::*;
345
346 fn make_span(start: usize, end: usize) -> Span {
347 Span {
348 file: FileId(1),
349 start,
350 end,
351 }
352 }
353
354 #[test]
357 fn span_merge_basic() {
358 let a = make_span(2, 5);
359 let b = make_span(3, 8);
360 let m = Span::merge(a, b);
361 assert_eq!(m.start, 2);
362 assert_eq!(m.end, 8);
363 assert_eq!(m.file, FileId(1));
364 }
365
366 #[test]
367 fn span_merge_disjoint() {
368 let a = make_span(0, 3);
369 let b = make_span(10, 15);
370 let m = Span::merge(a, b);
371 assert_eq!(m.start, 0);
372 assert_eq!(m.end, 15);
373 }
374
375 #[test]
376 fn span_merge_identical() {
377 let s = make_span(4, 9);
378 let m = Span::merge(s, s);
379 assert_eq!(m, s);
380 }
381
382 #[test]
383 fn span_dummy_is_zero() {
384 let d = Span::dummy();
385 assert_eq!(d.file, FileId(0));
386 assert_eq!(d.start, 0);
387 assert_eq!(d.end, 0);
388 }
389
390 #[test]
393 fn diagnostic_code_display() {
394 let c = DiagnosticCode {
395 prefix: 'E',
396 number: 42,
397 };
398 assert_eq!(c.to_string(), "E0042");
399 }
400
401 #[test]
404 fn bag_has_errors_false_when_empty() {
405 let bag = DiagnosticBag::new();
406 assert!(!bag.has_errors());
407 }
408
409 #[test]
410 fn bag_has_errors_false_for_warnings() {
411 let mut bag = DiagnosticBag::new();
412 let code = DiagnosticCode {
413 prefix: 'W',
414 number: 1,
415 };
416 bag.warning(code, "watch out", make_span(0, 1));
417 assert!(!bag.has_errors());
418 }
419
420 #[test]
421 fn bag_has_errors_true_for_error() {
422 let mut bag = DiagnosticBag::new();
423 let code = DiagnosticCode {
424 prefix: 'E',
425 number: 1,
426 };
427 bag.error(code, "oops", make_span(0, 1));
428 assert!(bag.has_errors());
429 }
430
431 #[test]
432 fn bag_iter_yields_all() {
433 let mut bag = DiagnosticBag::new();
434 let ec = DiagnosticCode {
435 prefix: 'E',
436 number: 1,
437 };
438 let wc = DiagnosticCode {
439 prefix: 'W',
440 number: 2,
441 };
442 bag.error(ec, "err", make_span(0, 1));
443 bag.warning(wc, "warn", make_span(1, 2));
444 let items: Vec<_> = bag.iter().collect();
445 assert_eq!(items.len(), 2);
446 }
447
448 #[test]
449 fn bag_labels_and_notes() {
450 let mut bag = DiagnosticBag::new();
451 let code = DiagnosticCode {
452 prefix: 'E',
453 number: 5,
454 };
455 bag.error(code, "main", make_span(0, 3))
456 .label(make_span(1, 2), "secondary")
457 .note("fix it");
458 let d = bag.iter().next().unwrap();
459 assert_eq!(d.labels.len(), 1);
460 assert_eq!(d.notes.len(), 1);
461 }
462
463 #[test]
466 fn render_error_contains_message() {
467 let source = "let x = ;";
468 let span = Span {
469 file: FileId(1),
470 start: 8,
471 end: 9,
472 };
473 let diag = Diagnostic {
474 severity: Severity::Error,
475 code: DiagnosticCode {
476 prefix: 'E',
477 number: 1,
478 },
479 message: "unexpected token".into(),
480 span,
481 labels: vec![],
482 notes: vec![],
483 };
484 let out = render(&[diag], "test.bock", source);
485 assert!(out.contains("unexpected token"), "output: {out}");
486 }
487
488 #[test]
489 fn render_empty_produces_empty_string() {
490 let out = render(&[], "test.bock", "let x = 1;");
491 assert!(out.is_empty());
492 }
493
494 #[test]
497 fn levenshtein_equal_strings_is_zero() {
498 assert_eq!(levenshtein("foo", "foo"), 0);
499 }
500
501 #[test]
502 fn levenshtein_empty_strings() {
503 assert_eq!(levenshtein("", ""), 0);
504 assert_eq!(levenshtein("abc", ""), 3);
505 assert_eq!(levenshtein("", "abc"), 3);
506 }
507
508 #[test]
509 fn levenshtein_single_substitution() {
510 assert_eq!(levenshtein("cat", "bat"), 1);
511 }
512
513 #[test]
514 fn levenshtein_insert_and_delete() {
515 assert_eq!(levenshtein("kitten", "sitting"), 3);
516 }
517
518 #[test]
519 fn suggest_similar_finds_close_match() {
520 let names = vec!["println", "print", "printf"];
521 assert_eq!(suggest_similar("printn", names, 2), Some("println".into()));
522 }
523
524 #[test]
525 fn suggest_similar_rejects_far_matches() {
526 let names = vec!["elephant", "giraffe"];
527 assert_eq!(suggest_similar("cat", names, 2), None);
528 }
529
530 #[test]
531 fn render_with_note() {
532 let source = "foo bar";
533 let span = Span {
534 file: FileId(1),
535 start: 0,
536 end: 3,
537 };
538 let mut diag = Diagnostic {
539 severity: Severity::Warning,
540 code: DiagnosticCode {
541 prefix: 'W',
542 number: 99,
543 },
544 message: "test warning".into(),
545 span,
546 labels: vec![],
547 notes: vec![],
548 };
549 diag.note("consider renaming");
550 let out = render(&[diag], "src.bock", source);
551 assert!(out.contains("consider renaming"), "output: {out}");
552 }
553}