1use crate::source::{SourceError, Sources};
2use crate::{LineIndex, Span};
3
4#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
5pub enum Severity {
6 Error,
7 Warning,
8}
9
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct Diagnostic {
12 pub severity: Severity,
13 pub span: Span,
14 pub message: String,
15 pub help: Vec<String>,
17 pub notes: Vec<(Span, String)>,
18}
19
20use crate::rendering::{RenderError, RenderLimits, RenderedText, Writer};
21
22pub struct DiagnosticView<'a> {
24 pub severity: Severity,
25 pub span: Span,
26 pub message: &'a str,
27 pub help: &'a [String],
28 pub notes: &'a [(Span, String)],
29}
30
31impl<'a> From<&'a Diagnostic> for DiagnosticView<'a> {
32 fn from(value: &'a Diagnostic) -> Self {
33 Self {
34 severity: value.severity,
35 span: value.span,
36 message: &value.message,
37 help: &value.help,
38 notes: &value.notes,
39 }
40 }
41}
42
43pub fn render(diagnostic: &Diagnostic, sources: &Sources) -> String {
44 render_checked(diagnostic, sources).map_or_else(
45 |error| crate::rendering::failure_text(&diagnostic.message, &error),
46 |rendered| rendered.text,
47 )
48}
49
50pub fn render_checked(
51 diagnostic: &Diagnostic,
52 sources: &Sources,
53) -> Result<RenderedText, RenderError> {
54 render_with_limits(diagnostic, sources, RenderLimits::default())
55}
56
57pub fn render_with_limits(
58 diagnostic: &Diagnostic,
59 sources: &Sources,
60 limits: RenderLimits,
61) -> Result<RenderedText, RenderError> {
62 Writer::render(limits, |out| {
63 write_diagnostic(out, &diagnostic.into(), sources)
64 })
65}
66
67pub fn render_collection(
68 diagnostics: &[Diagnostic],
69 sources: &Sources,
70) -> Result<RenderedText, RenderError> {
71 render_collection_with_limits(diagnostics, sources, RenderLimits::collection())
72}
73
74pub fn render_collection_with_limits(
75 diagnostics: &[Diagnostic],
76 sources: &Sources,
77 limits: RenderLimits,
78) -> Result<RenderedText, RenderError> {
79 render_views(
80 diagnostics.iter().map(|diagnostic| Ok(diagnostic.into())),
81 sources,
82 limits,
83 )
84}
85
86pub fn render_views<'a>(
87 diagnostics: impl IntoIterator<Item = Result<DiagnosticView<'a>, RenderError>>,
88 sources: &Sources,
89 limits: RenderLimits,
90) -> Result<RenderedText, RenderError> {
91 Writer::render(limits, |out| {
92 for diagnostic in diagnostics {
93 out.step()?;
94 let diagnostic = diagnostic?;
95 let rendered = Writer::render(out.child_limits(), |child| {
96 write_diagnostic(child, &diagnostic, sources)
97 })?;
98 out.append(rendered)?;
99 }
100 Ok(())
101 })
102}
103
104fn write_diagnostic(
105 out: &mut Writer,
106 diagnostic: &DiagnosticView<'_>,
107 sources: &Sources,
108) -> Result<(), RenderError> {
109 validate_span(sources, diagnostic.span)?;
110 let severity = match diagnostic.severity {
111 Severity::Error => "error",
112 Severity::Warning => "warning",
113 };
114 let mut max_line = 1;
115 let validation_allowance = out.remaining_steps() / 2;
116 let mut validation_truncated = false;
117 for (visited, span) in std::iter::once(diagnostic.span)
118 .chain(diagnostic.notes.iter().map(|(span, _)| *span))
119 .enumerate()
120 {
121 if visited >= validation_allowance {
122 validation_truncated = true;
123 break;
124 }
125 out.step()?;
126 validate_span(sources, span)?;
127 if let Some(source) = sources.get(span.file) {
128 let index = source.line_index();
129 let (line, _) = index.line_col(span.end)?;
130 max_line = max_line.max(line.saturating_add(1).min(index.line_count()));
131 }
132 }
133 out.format(format_args!("{severity}: {}\n", diagnostic.message))?;
134 if validation_truncated {
135 return Err(RenderError::Truncated);
136 }
137 let width = max_line.to_string().len();
138 write_context(out, sources, diagnostic.span, width)?;
139 for help in diagnostic.help {
140 out.format(format_args!("{:width$} |\nhelp: {help}\n", ""))?;
141 }
142 for (span, message) in diagnostic.notes {
143 out.format(format_args!("{:width$} |\nnote: {message}\n", ""))?;
144 write_context(out, sources, *span, width)?;
145 }
146 Ok(())
147}
148
149pub(crate) fn validate_span(sources: &Sources, span: Span) -> Result<(), SourceError> {
150 Span::new(span.file, span.start, span.end)?;
151 if span.file.reserved_path().is_some() {
152 return Ok(());
153 }
154 sources
155 .get(span.file)
156 .ok_or(SourceError::UnknownFile { file: span.file })?
157 .span_text(span)?;
158 Ok(())
159}
160
161fn write_context(
162 out: &mut Writer,
163 sources: &Sources,
164 span: Span,
165 width: usize,
166) -> Result<(), RenderError> {
167 if let Some(path) = span.file.reserved_path() {
168 return out.format(format_args!("{:width$}--> {path}\n", ""));
169 }
170 let source = sources
171 .get(span.file)
172 .ok_or(SourceError::UnknownFile { file: span.file })?;
173 let index = source.line_index();
174 let (line, col) = index.line_col(span.start)?;
175 out.format(format_args!(
176 "{:width$}--> {}:{line}:{col}\n{:width$} |\n",
177 "", source.path, ""
178 ))?;
179 write_source_block(out, index, span, width)
180}
181
182pub(crate) fn write_source_block(
183 out: &mut Writer,
184 index: &LineIndex,
185 span: Span,
186 width: usize,
187) -> Result<(), RenderError> {
188 span.text(index.source(), span.file)?;
189 let (start, start_col) = index.line_col(span.start)?;
190 let (end, end_col) = index.line_col(span.end)?;
191 if start > 1 {
192 write_line(out, index, start - 1, width)?;
193 }
194 for line in start..=end {
195 out.step()?;
196 let text = index.line_text(line)?;
197 write_line(out, index, line, width)?;
198 let indent = if line == start {
199 display_column(out, text, start_col.saturating_sub(1))?
200 } else {
201 0
202 };
203 let last = if line == end {
204 end_col.saturating_sub(1)
205 } else {
206 u32::MAX
207 };
208 let carets = display_column(out, text, last)?
209 .saturating_sub(indent)
210 .max(1);
211 out.format(format_args!("{:width$} | ", ""))?;
212 out.repeat(' ', indent)?;
213 out.repeat('^', carets)?;
214 out.push("\n")?;
215 }
216 if end < index.line_count() {
217 write_line(out, index, end + 1, width)?;
218 }
219 Ok(())
220}
221
222fn write_line(
223 out: &mut Writer,
224 index: &LineIndex,
225 line: u32,
226 width: usize,
227) -> Result<(), RenderError> {
228 use unicode_width::UnicodeWidthStr;
229 out.format(format_args!("{line:>width$} | "))?;
230 let text = index.line_text(line)?;
231 let mut column = 0usize;
232 for (i, segment) in text.split('\t').enumerate() {
233 out.step()?;
234 if i != 0 {
235 let spaces = 4 - column % 4;
236 out.repeat(' ', spaces)?;
237 column = column.checked_add(spaces).ok_or(RenderError::Formatting)?;
238 }
239 out.push(segment)?;
241 column = column
242 .checked_add(segment.width())
243 .ok_or(RenderError::Formatting)?;
244 }
245 out.push("\n")
246}
247
248fn display_column(out: &mut Writer, line: &str, offset: u32) -> Result<usize, RenderError> {
249 use unicode_width::UnicodeWidthStr;
250 let mut end = (offset as usize).min(line.len());
251 while !line.is_char_boundary(end) {
252 end = end.saturating_sub(1);
253 }
254 let text = line.get(..end).ok_or(RenderError::Formatting)?;
255 let mut column = 0usize;
256 for (i, segment) in text.split('\t').enumerate() {
257 out.step()?;
258 if i != 0 {
259 column = column
260 .checked_add(4 - column % 4)
261 .ok_or(RenderError::Formatting)?;
262 }
263 column = column
265 .checked_add(segment.width())
266 .ok_or(RenderError::Formatting)?;
267 }
268 Ok(column)
269}
270
271pub fn render_list(
273 diagnostics: &[Diagnostic],
274 sources: &Sources,
275) -> Result<Vec<String>, RenderError> {
276 let mut result = Vec::new();
277 let mut remaining = RenderLimits::collection().bytes;
278 let mut steps = RenderLimits::collection().steps;
279 for diagnostic in diagnostics.iter().take(RenderLimits::collection().steps) {
280 if remaining < crate::rendering::TRUNCATED.len() * 2 || steps == 0 {
281 break;
282 }
283 let rendered = render_with_limits(
284 diagnostic,
285 sources,
286 RenderLimits {
287 bytes: remaining
288 .min(RenderLimits::default().bytes)
289 .saturating_sub(crate::rendering::TRUNCATED.len()),
290 steps,
291 ..RenderLimits::default()
292 },
293 )?;
294 steps = steps.saturating_sub(rendered.steps());
295 remaining = remaining.saturating_sub(rendered.text.len());
296 result.try_reserve(1).map_err(|_| RenderError::Allocation)?;
297 result.push(rendered.text);
298 }
299 if result.len() < diagnostics.len() {
300 result.try_reserve(1).map_err(|_| RenderError::Allocation)?;
301 result.push(crate::rendering::TRUNCATED.into());
302 }
303 Ok(result)
304}
305
306#[cfg(test)]
307mod tests {
308 use super::{Diagnostic, Severity, render};
309 use crate::source::Sources;
310 use crate::{FileId, Span};
311
312 const F: FileId = FileId(0);
313
314 fn sources(text: &str) -> Sources {
315 let (sources, _) = Sources::single("script.subm", text).unwrap();
316 sources
317 }
318
319 #[test]
320 fn construct_and_read_fields() {
321 let d = Diagnostic {
322 severity: Severity::Error,
323 span: Span::new(F, 10, 20).unwrap(),
324 message: "type mismatch".to_string(),
325 help: vec![],
326 notes: vec![(Span::new(F, 5, 8).unwrap(), "defined here".to_string())],
327 };
328 assert_eq!(d.severity, Severity::Error);
329 assert_eq!(d.span, Span::new(F, 10, 20).unwrap());
330 assert_eq!(d.message, "type mismatch");
331 assert_eq!(d.notes.len(), 1);
332 assert_eq!(d.notes[0].0, Span::new(F, 5, 8).unwrap());
333 assert_eq!(d.notes[0].1, "defined here");
334 }
335
336 #[test]
337 fn clone_is_equal() {
338 let d = Diagnostic {
339 severity: Severity::Warning,
340 span: Span::new(F, 0, 3).unwrap(),
341 message: "unused variable".to_string(),
342 help: vec![],
343 notes: vec![],
344 };
345 assert_eq!(d.clone(), d);
346 }
347
348 #[test]
349 fn render_single_line_error() {
350 let source = "let x = 1;\nlet y: number = \"hello\";\nlet z = 2;\n";
351 let diag = Diagnostic {
352 severity: Severity::Error,
353 span: Span::new(F, 27, 34).unwrap(),
354 message: "type mismatch: expected `number`, found `string`".to_string(),
355 help: vec![],
356 notes: vec![],
357 };
358 insta::assert_snapshot!(render(&diag, &sources(source)));
359 }
360
361 #[test]
362 fn carets_count_characters_not_bytes() {
363 let source = "let s = \"é—\"; bad;\n";
365 let start = source.find("bad").unwrap() as u32;
366 let diag = Diagnostic {
367 severity: Severity::Error,
368 span: Span::new(F, start, start + 3).unwrap(),
369 message: "unresolved identifier `bad`".to_string(),
370 help: vec![],
371 notes: vec![],
372 };
373 let rendered = render(&diag, &sources(source));
374 let caret_line = rendered.lines().find(|l| l.contains('^')).unwrap();
375 let source_line = rendered.lines().find(|l| l.contains("bad;")).unwrap();
376 let caret_col = caret_line.chars().position(|c| c == '^').unwrap();
377 let bad_col = source_line[..source_line.find("bad").unwrap()]
378 .chars()
379 .count();
380 assert_eq!(caret_col, bad_col, "{rendered}");
381 assert_eq!(caret_line.matches('^').count(), 3, "{rendered}");
382 }
383
384 #[test]
385 fn carets_follow_terminal_columns() {
386 for (source, target, indent, width) in [
387 ("let s = \"日本😀\"; bad;", "bad", 18, 3),
388 ("\tlet s = \"e\u{301}\";\tbad;", "bad", 20, 3),
389 ("x\t日本😀;", "日本😀", 4, 6),
390 ("x\tbad;", "\tbad", 1, 6),
391 ] {
392 let start = source.find(target).unwrap();
393 let diag = Diagnostic {
394 severity: Severity::Error,
395 span: Span::new(F, start as u32, (start + target.len()) as u32).unwrap(),
396 message: "bad value".into(),
397 help: vec![],
398 notes: vec![],
399 };
400 let rendered = render(&diag, &sources(source));
401 assert!(!rendered.contains('\t'), "{rendered}");
402 let caret = rendered.lines().find(|line| line.contains('^')).unwrap();
403 assert_eq!(caret.find('^').unwrap(), 4 + indent, "{rendered}");
404 assert_eq!(caret.matches('^').count(), width, "{rendered}");
405 assert!(rendered.contains(&format!("script.subm:1:{}", start + 1)));
406 }
407 }
408
409 #[test]
410 fn multiline_carets_use_terminal_widths() {
411 let source = "a\t日\n\t😀e\u{301}\n終z";
412 let end = source.find('終').unwrap() + '終'.len_utf8();
413 let diag = Diagnostic {
414 severity: Severity::Error,
415 span: Span::new(F, 1, end as u32).unwrap(),
416 message: "multiline".into(),
417 help: vec![],
418 notes: vec![],
419 };
420 let rendered = render(&diag, &sources(source));
421 let carets: Vec<_> = rendered.lines().filter(|line| line.contains('^')).collect();
422 assert_eq!(
423 carets,
424 [" | ^^^^^", " | ^^^^^^^", " | ^^"],
425 "{rendered}"
426 );
427 assert!(!rendered.contains('\t'));
428 }
429
430 #[test]
431 fn render_multi_note_error_with_gutter_alignment() {
432 let source = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\n";
434 let diag = Diagnostic {
435 severity: Severity::Error,
436 span: Span::new(F, 2, 3).unwrap(), message: "missing return in all code paths".to_string(),
438 help: vec![],
439 notes: vec![
440 (
441 Span::new(F, 0, 1).unwrap(),
442 "function declared here".to_string(),
443 ),
444 (
445 Span::new(F, 20, 21).unwrap(),
446 "this branch returns, but else does not".to_string(),
447 ),
448 ],
449 };
450 insta::assert_snapshot!(render(&diag, &sources(source)));
451 }
452
453 #[test]
454 fn render_zero_length_span_at_eof() {
455 let source = "hello";
456 let diag = Diagnostic {
457 severity: Severity::Error,
458 span: Span::new(F, 5, 5).unwrap(),
459 message: "expected `;`".to_string(),
460 help: vec![],
461 notes: vec![],
462 };
463 insta::assert_snapshot!(render(&diag, &sources(source)));
464 }
465
466 #[test]
467 fn render_warning_severity() {
468 let source = "let unused = 42;\n";
469 let diag = Diagnostic {
470 severity: Severity::Warning,
471 span: Span::new(F, 4, 10).unwrap(),
472 message: "unused variable `unused`".to_string(),
473 help: vec![],
474 notes: vec![],
475 };
476 insta::assert_snapshot!(render(&diag, &sources(source)));
477 }
478
479 #[test]
480 fn render_clamps_at_sof() {
481 let source = "first\nsecond\nthird\n";
483 let diag = Diagnostic {
484 severity: Severity::Error,
485 span: Span::new(F, 0, 5).unwrap(),
486 message: "boom".to_string(),
487 help: vec![],
488 notes: vec![],
489 };
490 insta::assert_snapshot!(render(&diag, &sources(source)));
491 }
492
493 #[test]
494 fn render_clamps_at_eof() {
495 let source = "first\nsecond\nthird";
498 let diag = Diagnostic {
499 severity: Severity::Error,
500 span: Span::new(F, 13, 18).unwrap(),
501 message: "boom".to_string(),
502 help: vec![],
503 notes: vec![],
504 };
505 insta::assert_snapshot!(render(&diag, &sources(source)));
506 }
507
508 #[test]
509 fn render_multiline_span_carets() {
510 let source = "line1\nline2 start\nmiddle\nend stop here\nline5\n";
514 let start = source.find("start").unwrap() as u32;
515 let end = source.find("end").unwrap() as u32 + 3;
516 let diag = Diagnostic {
517 severity: Severity::Error,
518 span: Span::new(F, start, end).unwrap(),
519 message: "multi-line span".to_string(),
520 help: vec![],
521 notes: vec![],
522 };
523 insta::assert_snapshot!(render(&diag, &sources(source)));
524 }
525
526 #[test]
527 fn render_with_help() {
528 let source = "let x = foo.bar;\n";
529 let diag = Diagnostic {
530 severity: Severity::Error,
531 span: Span::new(F, 12, 15).unwrap(),
532 message: "field `bar` does not exist on `Foo`".to_string(),
533 help: vec!["interface Foo {\n baz: number;\n}".to_string()],
534 notes: vec![],
535 };
536 insta::assert_snapshot!(render(&diag, &sources(source)));
537 }
538
539 #[test]
540 fn render_with_help_and_notes() {
541 let source = "a\nb\nc\n";
542 let diag = Diagnostic {
543 severity: Severity::Error,
544 span: Span::new(F, 2, 3).unwrap(),
545 message: "bad".to_string(),
546 help: vec!["try this instead".to_string()],
547 notes: vec![(Span::new(F, 0, 1).unwrap(), "declared here".to_string())],
548 };
549 insta::assert_snapshot!(render(&diag, &sources(source)));
550 }
551
552 #[test]
553 fn render_multiple_helps() {
554 let source = "x\n";
555 let diag = Diagnostic {
556 severity: Severity::Error,
557 span: Span::new(F, 0, 1).unwrap(),
558 message: "bad".to_string(),
559 help: vec!["first hint".to_string(), "second hint".to_string()],
560 notes: vec![],
561 };
562 insta::assert_snapshot!(render(&diag, &sources(source)));
563 }
564}