codespan_reporting/term/views.rs
1use alloc::string::{String, ToString};
2use alloc::vec;
3use alloc::vec::Vec;
4use core::ops::Range;
5
6use crate::diagnostic::{Diagnostic, LabelStyle};
7use crate::files::{Error, Files, Location};
8use crate::term::renderer::{Locus, MultiLabel, Renderer, SingleLabel};
9use crate::term::Config;
10
11/// Calculate the number of decimal digits in `n`.
12fn count_digits(n: usize) -> usize {
13 n.ilog10() as usize + 1
14}
15
16/// Output a richly formatted diagnostic, with source code previews.
17pub struct RichDiagnostic<'diagnostic, 'config, FileId> {
18 diagnostic: &'diagnostic Diagnostic<FileId>,
19 config: &'config Config,
20}
21
22impl<'diagnostic, 'config, FileId> RichDiagnostic<'diagnostic, 'config, FileId>
23where
24 FileId: Copy + PartialEq,
25{
26 #[must_use]
27 pub fn new(
28 diagnostic: &'diagnostic Diagnostic<FileId>,
29 config: &'config Config,
30 ) -> RichDiagnostic<'diagnostic, 'config, FileId> {
31 RichDiagnostic { diagnostic, config }
32 }
33
34 pub fn render<'files>(
35 &self,
36 files: &'files (impl Files<'files, FileId = FileId> + ?Sized),
37 renderer: &mut Renderer<'_, '_>,
38 ) -> Result<(), Error>
39 where
40 FileId: 'files,
41 {
42 use alloc::collections::BTreeMap;
43
44 struct LabeledFile<'diagnostic, FileId> {
45 file_id: FileId,
46 start: usize,
47 name: String,
48 location: Location,
49 num_multi_labels: usize,
50 lines: BTreeMap<usize, Line<'diagnostic>>,
51 max_label_style: LabelStyle,
52 }
53
54 impl<'diagnostic, FileId> LabeledFile<'diagnostic, FileId> {
55 fn get_or_insert_line(
56 &mut self,
57 line_index: usize,
58 line_range: Range<usize>,
59 line_number: usize,
60 ) -> &mut Line<'diagnostic> {
61 self.lines.entry(line_index).or_insert_with(|| Line {
62 range: line_range,
63 number: line_number,
64 single_labels: vec![],
65 multi_labels: vec![],
66 // This has to be false by default so we know if it must be rendered by another condition already.
67 must_render: false,
68 })
69 }
70 }
71
72 struct Line<'diagnostic> {
73 number: usize,
74 range: core::ops::Range<usize>,
75 // TODO: How do we reuse these allocations?
76 single_labels: Vec<SingleLabel<'diagnostic>>,
77 multi_labels: Vec<(usize, LabelStyle, MultiLabel<'diagnostic>)>,
78 must_render: bool,
79 }
80
81 // TODO: Make this data structure external, to allow for allocation reuse
82 let mut labeled_files = Vec::<LabeledFile<'_, _>>::new();
83 // Keep track of the outer padding to use when rendering the
84 // snippets of source code.
85 let mut outer_padding = 0;
86
87 // Group labels by file
88 for label in &self.diagnostic.labels {
89 let start_line_index = files.line_index(label.file_id, label.range.start)?;
90 let start_line_number = files.line_number(label.file_id, start_line_index)?;
91 let start_line_range = files.line_range(label.file_id, start_line_index)?;
92 let end_line_index = files.line_index(label.file_id, label.range.end)?;
93 let end_line_number = files.line_number(label.file_id, end_line_index)?;
94 let end_line_range = files.line_range(label.file_id, end_line_index)?;
95
96 outer_padding = core::cmp::max(outer_padding, count_digits(start_line_number));
97 outer_padding = core::cmp::max(outer_padding, count_digits(end_line_number));
98
99 // NOTE: This could be made more efficient by using an associative
100 // data structure like a hashmap or B-tree, but we use a vector to
101 // preserve the order that unique files appear in the list of labels.
102 let labeled_file = labeled_files
103 .iter_mut()
104 .find(|labeled_file| label.file_id == labeled_file.file_id);
105 let labeled_file = if let Some(labeled_file) = labeled_file {
106 // another diagnostic also referenced this file
107 if labeled_file.max_label_style > label.style
108 || (labeled_file.max_label_style == label.style
109 && labeled_file.start > label.range.start)
110 {
111 // this label has a higher style or has the same style but starts earlier
112 labeled_file.start = label.range.start;
113 labeled_file.location = files.location(label.file_id, label.range.start)?;
114 labeled_file.max_label_style = label.style;
115 }
116 labeled_file
117 } else {
118 // no other diagnostic referenced this file yet
119 labeled_files.push(LabeledFile {
120 file_id: label.file_id,
121 start: label.range.start,
122 name: files.name(label.file_id)?.to_string(),
123 location: files.location(label.file_id, label.range.start)?,
124 num_multi_labels: 0,
125 lines: BTreeMap::new(),
126 max_label_style: label.style,
127 });
128 // this unwrap should never fail because we just pushed an element
129 labeled_files
130 .last_mut()
131 .expect("just pushed an element that disappeared")
132 };
133
134 // insert context lines before label
135 // start from 1 because 0 would be the start of the label itself
136 for offset in 1..=self.config.before_label_lines {
137 let index = if let Some(index) = start_line_index.checked_sub(offset) {
138 index
139 } else {
140 // we are going from smallest to largest offset, so if
141 // the offset can not be subtracted from the start we
142 // reached the first line
143 break;
144 };
145
146 if let Ok(range) = files.line_range(label.file_id, index) {
147 let line =
148 labeled_file.get_or_insert_line(index, range, start_line_number - offset);
149 line.must_render = true;
150 } else {
151 break;
152 }
153 }
154
155 // insert context lines after label
156 // start from 1 because 0 would be the end of the label itself
157 for offset in 1..=self.config.after_label_lines {
158 let index = end_line_index
159 .checked_add(offset)
160 .expect("line index too big");
161
162 if let Ok(range) = files.line_range(label.file_id, index) {
163 let line =
164 labeled_file.get_or_insert_line(index, range, end_line_number + offset);
165 line.must_render = true;
166 } else {
167 break;
168 }
169 }
170
171 if start_line_index == end_line_index {
172 // Single line
173 //
174 // ```text
175 // 2 │ (+ test "")
176 // │ ^^ expected `Int` but found `String`
177 // ```
178 let label_start = label.range.start - start_line_range.start;
179 // Ensure that we print at least one caret, even when we
180 // have a zero-length source range.
181 let label_end =
182 usize::max(label.range.end - start_line_range.start, label_start + 1);
183
184 let line = labeled_file.get_or_insert_line(
185 start_line_index,
186 start_line_range,
187 start_line_number,
188 );
189
190 // Ensure that the single line labels are lexicographically
191 // sorted by the range of source code that they cover.
192 let index = match line.single_labels.binary_search_by(|(_, range, _)| {
193 // `Range<usize>` doesn't implement `Ord`, so convert to `(usize, usize)`
194 // to piggyback off its lexicographic comparison implementation.
195 (range.start, range.end).cmp(&(label_start, label_end))
196 }) {
197 // If the ranges are the same, order the labels in reverse
198 // to how they were originally specified in the diagnostic.
199 // This helps with printing in the renderer.
200 Ok(index) | Err(index) => index,
201 };
202
203 line.single_labels
204 .insert(index, (label.style, label_start..label_end, &label.message));
205
206 // If this line is not rendered, the SingleLabel is not visible.
207 line.must_render = true;
208 } else {
209 // Multiple lines
210 //
211 // ```text
212 // 4 │ fizz₁ num = case (mod num 5) (mod num 3) of
213 // │ ╭─────────────^
214 // 5 │ │ 0 0 => "FizzBuzz"
215 // 6 │ │ 0 _ => "Fizz"
216 // 7 │ │ _ 0 => "Buzz"
217 // 8 │ │ _ _ => num
218 // │ ╰──────────────^ `case` clauses have incompatible types
219 // ```
220
221 let label_index = labeled_file.num_multi_labels;
222 labeled_file.num_multi_labels += 1;
223
224 // First labeled line
225 let label_start = label.range.start - start_line_range.start;
226
227 let start_line = labeled_file.get_or_insert_line(
228 start_line_index,
229 start_line_range.clone(),
230 start_line_number,
231 );
232
233 start_line.multi_labels.push((
234 label_index,
235 label.style,
236 MultiLabel::Top(label_start),
237 ));
238
239 // The first line has to be rendered so the start of the label is visible.
240 start_line.must_render = true;
241
242 // Marked lines
243 //
244 // ```text
245 // 5 │ │ 0 0 => "FizzBuzz"
246 // 6 │ │ 0 _ => "Fizz"
247 // 7 │ │ _ 0 => "Buzz"
248 // ```
249 for line_index in (start_line_index + 1)..end_line_index {
250 let line_range = files.line_range(label.file_id, line_index)?;
251 let line_number = files.line_number(label.file_id, line_index)?;
252
253 outer_padding = core::cmp::max(outer_padding, count_digits(line_number));
254
255 let line = labeled_file.get_or_insert_line(line_index, line_range, line_number);
256
257 line.multi_labels
258 .push((label_index, label.style, MultiLabel::Left));
259
260 // The line should be rendered to match the configuration of how much context to show.
261 line.must_render |=
262 // Is this line part of the context after the start of the label?
263 line_index - start_line_index <= self.config.start_context_lines
264 ||
265 // Is this line part of the context before the end of the label?
266 end_line_index - line_index <= self.config.end_context_lines;
267 }
268
269 // Last labeled line
270 //
271 // ```text
272 // 8 │ │ _ _ => num
273 // │ ╰──────────────^ `case` clauses have incompatible types
274 // ```
275 let label_end = label.range.end - end_line_range.start;
276
277 let end_line = labeled_file.get_or_insert_line(
278 end_line_index,
279 end_line_range,
280 end_line_number,
281 );
282
283 end_line.multi_labels.push((
284 label_index,
285 label.style,
286 MultiLabel::Bottom(label_end, &label.message),
287 ));
288
289 // The last line has to be rendered so the end of the label is visible.
290 end_line.must_render = true;
291 }
292 }
293
294 // Header and message
295 //
296 // ```text
297 // error[E0001]: unexpected type in `+` application
298 // ```
299 renderer.render_header(
300 None,
301 self.diagnostic.severity,
302 self.diagnostic.code.as_deref(),
303 self.diagnostic.message.as_str(),
304 )?;
305
306 // Source snippets
307 //
308 // ```text
309 // ┌─ test:2:9
310 // │
311 // 2 │ (+ test "")
312 // │ ^^ expected `Int` but found `String`
313 // │
314 // ```
315 let mut labeled_files = labeled_files.into_iter().peekable();
316 while let Some(labeled_file) = labeled_files.next() {
317 let source = files.source(labeled_file.file_id)?;
318 let source = source.as_ref();
319
320 // Top left border and locus.
321 //
322 // ```text
323 // ┌─ test:2:9
324 // ```
325 if !labeled_file.lines.is_empty() {
326 renderer.render_snippet_start(
327 outer_padding,
328 &Locus {
329 name: labeled_file.name,
330 location: labeled_file.location,
331 },
332 )?;
333 renderer.render_snippet_empty(
334 outer_padding,
335 self.diagnostic.severity,
336 labeled_file.num_multi_labels,
337 &[],
338 )?;
339 }
340
341 let mut lines = labeled_file
342 .lines
343 .iter()
344 .filter(|(_, line)| line.must_render)
345 .peekable();
346
347 while let Some((line_index, line)) = lines.next() {
348 renderer.render_snippet_source(
349 outer_padding,
350 line.number,
351 &source[line.range.clone()],
352 self.diagnostic.severity,
353 &line.single_labels,
354 labeled_file.num_multi_labels,
355 &line.multi_labels,
356 )?;
357
358 // Check to see if we need to render any intermediate stuff
359 // before rendering the next line.
360 if let Some((next_line_index, next_line)) = lines.peek() {
361 match next_line_index.checked_sub(*line_index) {
362 // Consecutive lines
363 Some(1) => {}
364 // One line between the current line and the next line
365 Some(2) => {
366 // Write a source line
367 let file_id = labeled_file.file_id;
368
369 // This line was not intended to be rendered initially.
370 // To render the line right, we have to get back the original labels.
371 let labels = labeled_file
372 .lines
373 .get(&(line_index + 1))
374 .map_or(&[][..], |line| &line.multi_labels[..]);
375
376 renderer.render_snippet_source(
377 outer_padding,
378 files.line_number(file_id, line_index + 1)?,
379 &source[files.line_range(file_id, line_index + 1)?],
380 self.diagnostic.severity,
381 &[],
382 labeled_file.num_multi_labels,
383 labels,
384 )?;
385 }
386 // More than one line between the current line and the next line.
387 Some(_) | None => {
388 // Source break
389 //
390 // ```text
391 // ·
392 // ```
393 renderer.render_snippet_break(
394 outer_padding,
395 self.diagnostic.severity,
396 labeled_file.num_multi_labels,
397 &next_line.multi_labels,
398 )?;
399 }
400 }
401 }
402 }
403
404 // Check to see if we should render a trailing border after the
405 // final line of the snippet.
406 if labeled_files.peek().is_none() && self.diagnostic.notes.is_empty() {
407 // We don't render a border if we are at the final newline
408 // without trailing notes, because it would end up looking too
409 // spaced-out in combination with the final new line.
410 } else {
411 // Render the trailing snippet border.
412 renderer.render_snippet_empty(
413 outer_padding,
414 self.diagnostic.severity,
415 labeled_file.num_multi_labels,
416 &[],
417 )?;
418 }
419 }
420
421 // Additional notes
422 //
423 // ```text
424 // = expected type `Int`
425 // found type `String`
426 // ```
427 for note in &self.diagnostic.notes {
428 renderer.render_snippet_note(outer_padding, note)?;
429 }
430 renderer.render_empty()
431 }
432}
433
434/// Output a short diagnostic, with a line number, severity, and message.
435pub struct ShortDiagnostic<'diagnostic, FileId> {
436 diagnostic: &'diagnostic Diagnostic<FileId>,
437 show_notes: bool,
438}
439
440impl<'diagnostic, FileId> ShortDiagnostic<'diagnostic, FileId>
441where
442 FileId: Copy + PartialEq,
443{
444 #[must_use]
445 pub fn new(
446 diagnostic: &'diagnostic Diagnostic<FileId>,
447 show_notes: bool,
448 ) -> ShortDiagnostic<'diagnostic, FileId> {
449 ShortDiagnostic {
450 diagnostic,
451 show_notes,
452 }
453 }
454
455 pub fn render<'files>(
456 &self,
457 files: &'files (impl Files<'files, FileId = FileId> + ?Sized),
458 renderer: &mut Renderer<'_, '_>,
459 ) -> Result<(), Error>
460 where
461 FileId: 'files,
462 {
463 // Located headers
464 //
465 // ```text
466 // test:2:9: error[E0001]: unexpected type in `+` application
467 // ```
468 let mut primary_labels_encountered = 0;
469 let labels = self.diagnostic.labels.iter();
470 for label in labels.filter(|label| label.style == LabelStyle::Primary) {
471 primary_labels_encountered += 1;
472
473 renderer.render_header(
474 Some(&Locus {
475 name: files.name(label.file_id)?.to_string(),
476 location: files.location(label.file_id, label.range.start)?,
477 }),
478 self.diagnostic.severity,
479 self.diagnostic.code.as_deref(),
480 self.diagnostic.message.as_str(),
481 )?;
482 }
483
484 // Fallback to printing a non-located header if no primary labels were encountered
485 //
486 // ```text
487 // error[E0002]: Bad config found
488 // ```
489 if primary_labels_encountered == 0 {
490 renderer.render_header(
491 None,
492 self.diagnostic.severity,
493 self.diagnostic.code.as_deref(),
494 self.diagnostic.message.as_str(),
495 )?;
496 }
497
498 if self.show_notes {
499 // Additional notes
500 //
501 // ```text
502 // = expected type `Int`
503 // found type `String`
504 // ```
505 for note in &self.diagnostic.notes {
506 renderer.render_snippet_note(0, note)?;
507 }
508 }
509
510 Ok(())
511 }
512}