annotate_snippets/renderer/no_graphics.rs
1use alloc::borrow::{Cow, ToOwned};
2use alloc::string::{String, ToString};
3use alloc::vec::Vec;
4use core::cmp::Reverse;
5use core::fmt::{self, Write};
6
7use super::graphics::{Hyperlink, MessageOrTitle, TitleStyle, str_width};
8use super::preprocess::{Preprocessed, PreprocessedElement, PreprocessedGroup};
9use super::{ElementStyle, Stylesheet, normalize_whitespace};
10use crate::renderer::graphics::ANONYMIZED_LINE_NUM;
11use crate::{AnnotationKind, Id, Renderer, Report};
12
13/// Render all errors from a `Report` as text only.
14///
15/// We will render output as follows:
16///
17/// ```text
18/// error EXXXX: main message
19/// at $DIR/file.ext:LL:CC: span label
20/// on line LL, column KK: span label
21/// note: note message
22/// at $DIR/file.ext:LL:CC
23/// help: suggestion message
24/// on line LL, add `suggestion`
25/// ```
26pub(crate) fn render_no_graphics(
27 renderer: &Renderer,
28 groups: Report<'_>,
29) -> Result<String, fmt::Error> {
30 let mut output = String::new();
31 let Preprocessed {
32 max_line_num: _,
33 report_primary_path,
34 groups,
35 } = Preprocessed::preprocess(groups);
36
37 for PreprocessedGroup {
38 group,
39 elements,
40 primary_path,
41 max_depth: _,
42 } in groups
43 {
44 if let Some(title) = &group.title {
45 if !output.is_empty() {
46 writeln!(output)?;
47 }
48 let title_style = if title.allows_styling {
49 TitleStyle::Secondary
50 } else {
51 TitleStyle::Primary
52 };
53 render_title(
54 title,
55 &mut output,
56 title_style,
57 &renderer.stylesheet,
58 renderer.hyperlink,
59 )?;
60 }
61
62 let mut last_suggestion_path = None;
63 let mut suggestion_count = 0;
64
65 let mut message_iter = elements.into_iter().peekable();
66 while let Some(section) = message_iter.next() {
67 match section {
68 PreprocessedElement::Message(message) => {
69 last_suggestion_path = None;
70 if !output.is_empty() {
71 writeln!(output)?;
72 }
73 render_title(
74 message,
75 &mut output,
76 TitleStyle::Message,
77 &renderer.stylesheet,
78 renderer.hyperlink,
79 )?;
80 }
81 PreprocessedElement::Cause((snippet, sm, _)) => {
82 last_suggestion_path = None;
83
84 let mut annotations = snippet
85 .markers
86 .iter()
87 .filter(|ann| !matches!(ann.kind, AnnotationKind::Visible))
88 .collect::<Vec<_>>();
89 annotations.sort_by_key(|a| (Reverse(a.kind.is_primary()), a.span.start));
90 if annotations.is_empty() {
91 if !output.is_empty() {
92 writeln!(output)?;
93 }
94 // We have a diagnostic with no span labels, but we should show *some*
95 // position. We use the snippet start to provide that minimal context.
96 write!(output, " ")?;
97 render_path(
98 &mut output,
99 snippet.path.as_deref(),
100 Some(snippet.line_start),
101 None,
102 renderer.anonymized_origin_line_numbers,
103 )?;
104 } else {
105 for (i, annotation) in annotations.iter().enumerate() {
106 let label = annotation.label.as_ref().filter(|s| !s.is_empty());
107 if i > 0 && label.is_none() {
108 continue;
109 }
110 let (lo, hi) =
111 sm.span_to_locations(annotation.span.start..annotation.span.end);
112
113 if !output.is_empty() {
114 writeln!(output)?;
115 }
116 // Indent subsequent labels under the first label's file path.
117 // Without a path, keep all labels at the same indentation.
118 let padding = if i > 0 && snippet.path.is_some() {
119 " "
120 } else {
121 " "
122 };
123 write!(output, "{padding}")?;
124
125 if i == 0
126 && let Some(path) = &snippet.path
127 {
128 // `at $DIR/file.txt:LL:CC: label`
129 // ^^^^^^^^^^^^^^^^^^^^^^
130 render_path(
131 &mut output,
132 Some(path),
133 Some(lo.line),
134 Some(lo.char + 1),
135 renderer.anonymized_origin_line_numbers,
136 )?;
137 if lo.line != hi.line {
138 // This is a multiline highlight, so we mention both the start and the
139 // end. `LL:CC to MM:DD`
140 // ^^^^^^^^^
141 write!(output, " to {}:{}", hi.line, hi.char + 1)?;
142 }
143 } else {
144 let col = if let Some(line) = sm.get_line(lo.line)
145 && line.chars().take(lo.char).all(|c| c.is_whitespace())
146 {
147 // Everything before the span is whitespace, mentioning the column
148 // doesn't add information.
149 None
150 } else {
151 Some(lo.char + 1)
152 };
153 render_path(
154 &mut output,
155 None,
156 Some(lo.line),
157 col,
158 renderer.anonymized_origin_line_numbers,
159 )?;
160 if lo.line != hi.line {
161 // This is a multiline highlight, so we mention both the start and the
162 // end. `line LL, column CC to line MM, column DD`
163 // ^^^^^^^^^^^^^^^^^^^^^^
164 write!(output, " to line {}", hi.line)?;
165 if let Some(line) = sm.get_line(hi.line)
166 && let Some(post) = line.get(hi.char..)
167 && post.chars().all(|c| c.is_whitespace())
168 {
169 // Everything after the span is whitespace, mentioning the
170 // column doesn't add information.
171 } else {
172 write!(output, ", column {}", hi.char + 1)?;
173 }
174 }
175 }
176
177 if let Some(label) = label {
178 // If the span has a label, we render it to the right of the position
179 // information.
180 // `at $DIR/file.txt:LL:CC: this is the label`
181 // ^^^^^^^^^^^^^^^^^^^
182 write!(output, ": {label}")?;
183 }
184 }
185 }
186 }
187 PreprocessedElement::Suggestion((
188 suggestion,
189 sm,
190 spliced_lines,
191 _display_suggestion,
192 )) => {
193 if spliced_lines.patches.is_empty() {
194 // We have a suggestion with no patches, we don't render anything.
195 continue;
196 }
197
198 let matches_previous_suggestion =
199 last_suggestion_path == Some(suggestion.path.as_ref());
200
201 suggestion_count = if matches_previous_suggestion {
202 suggestion_count + 1
203 } else {
204 1
205 };
206
207 let matches_next_suggestion = matches!(
208 message_iter.peek(),
209 Some(PreprocessedElement::Suggestion((next, ..)))
210 if next.path.as_ref() == suggestion.path.as_ref()
211 );
212
213 // We only include the file path when it is different from
214 // the previous suggestion's path, or the primary path
215 //
216 // `at $DIR/file.txt:LL:CC: label`
217 // ^^^^^^^^^^^^^^^^^
218 let previous_path =
219 last_suggestion_path.unwrap_or(primary_path.or(report_primary_path));
220 let path = if suggestion.path.as_ref() != previous_path
221 && let Some(path) = suggestion.path.as_ref()
222 {
223 Some(path.as_ref())
224 } else {
225 None
226 };
227
228 let padding = if matches_previous_suggestion || matches_next_suggestion {
229 if !output.is_empty() {
230 writeln!(output)?;
231 }
232 write!(output, " option {suggestion_count}")?;
233 " "
234 } else {
235 " "
236 };
237
238 for patch in spliced_lines.patches.iter() {
239 let (lo, hi) = sm.span_to_locations(patch.span.start..patch.span.end);
240
241 let has_replacement = !patch.replacement.trim().is_empty();
242 let is_addition = patch.is_addition(&sm);
243 if !output.is_empty() {
244 writeln!(output)?;
245 }
246 write!(output, "{padding}")?;
247
248 if has_replacement && !is_addition {
249 render_path(
250 &mut output,
251 path,
252 Some(lo.line),
253 Some(lo.char + 1),
254 renderer.anonymized_origin_line_numbers,
255 )?;
256 if lo.line != hi.line {
257 let line = if renderer.anonymized_origin_line_numbers {
258 ANONYMIZED_LINE_NUM.to_owned()
259 } else {
260 hi.line.to_string()
261 };
262 write!(output, " to line {line}, column {}", hi.char + 1)?;
263 } else {
264 write!(output, " to column {}", hi.char + 1)?;
265 }
266 } else {
267 let col = if lo.line == hi.line
268 && let Some(line) = sm.get_line(lo.line)
269 && let Some(pre) = line.get(..lo.byte)
270 && pre.chars().all(|c| c.is_whitespace())
271 && let Some(post) = line.get(hi.byte..)
272 && (patch.replacement.lines().count() > 1
273 || post.chars().all(|c| c.is_whitespace()))
274 {
275 // We are changing the whole text in the line, no need to mention the
276 // column.
277 None
278 } else {
279 Some(lo.char + 1)
280 };
281 render_path(
282 &mut output,
283 path,
284 Some(lo.line),
285 col,
286 renderer.anonymized_origin_line_numbers,
287 )?;
288 }
289
290 if has_replacement {
291 let action = if is_addition { "add" } else { "replace with" };
292 write!(output, " {action}: ")?;
293
294 let st = ElementStyle::Addition
295 .color_spec(&crate::Level::NOTE, &renderer.stylesheet);
296
297 write!(
298 output,
299 "{st}{}{st:#}",
300 normalize_whitespace(patch.replacement.trim_end_matches('\n'))
301 )?;
302 }
303 }
304 last_suggestion_path = Some(suggestion.path.as_ref());
305 }
306 PreprocessedElement::Origin(origin) => {
307 last_suggestion_path = None;
308 if !output.is_empty() {
309 writeln!(output)?;
310 }
311 write!(output, " ")?;
312 render_path(
313 &mut output,
314 Some(&origin.path),
315 origin.line,
316 origin.char_column,
317 renderer.anonymized_origin_line_numbers,
318 )?;
319 }
320 PreprocessedElement::Padding(_) => {
321 last_suggestion_path = None;
322 }
323 }
324 }
325 }
326
327 Ok(output)
328}
329
330/// Render the main message line for a diagnostic
331///
332/// This will print out:
333///
334/// ```text
335/// LEVEL CODE: message
336/// ```
337/// like the following:
338/// ```text
339/// error E0308: mismatched types
340/// ```
341fn render_title(
342 title: &dyn MessageOrTitle,
343 buffer: &mut String,
344 title_style: TitleStyle,
345 stylesheet: &Stylesheet,
346 hyperlink: bool,
347) -> Result<(), fmt::Error> {
348 let (label_style, title_element_style) = match title_style {
349 TitleStyle::Primary => (
350 ElementStyle::Level(title.level().level),
351 ElementStyle::MainHeaderMsg,
352 ),
353 TitleStyle::Secondary => (
354 ElementStyle::Level(title.level().level),
355 ElementStyle::HeaderMsg,
356 ),
357 TitleStyle::Message => (ElementStyle::MainHeaderMsg, ElementStyle::NoStyle),
358 };
359 let label_style = label_style.color_spec(title.level(), stylesheet);
360 let title_element_style = title_element_style.color_spec(title.level(), stylesheet);
361
362 let mut label_width = 0;
363 let level_is_visible = title.level().name != Some(None);
364 if level_is_visible || title.id().is_some() {
365 if title_style == TitleStyle::Message {
366 write!(buffer, " ")?;
367 label_width += 1;
368 }
369
370 if level_is_visible {
371 // error EXXXX: message
372 // ^^^^^
373 write!(buffer, "{}{}{0:#}", label_style, title.level().as_str(),)?;
374 label_width += str_width(title.level().as_str());
375 }
376
377 if let Some(Id { id: Some(id), url }) = &title.id() {
378 let url = url
379 .as_deref()
380 .filter(|_| hyperlink)
381 .map(Hyperlink::with_url)
382 .unwrap_or_default();
383
384 if level_is_visible {
385 // error EXXXX: message
386 // ^
387 write!(buffer, " ")?;
388 label_width += 1;
389 }
390 // error EXXXX: message
391 // ^^^^^
392 write!(buffer, "{label_style}{url}{id}{url:#}{label_style:#}")?;
393 label_width += str_width(id);
394 }
395 // error EXXXX: message
396 // ^
397 write!(buffer, "{title_element_style}: {title_element_style:#}")?;
398 label_width += 2;
399 }
400 let padding = " ".repeat(label_width);
401
402 // error EXXXX: message
403 // ^^^^^^^
404 let (title_str, st) = if title.allows_styling() {
405 (
406 Cow::Borrowed(title.text()),
407 ElementStyle::NoStyle.color_spec(title.level(), stylesheet),
408 )
409 } else {
410 (normalize_whitespace(title.text()), title_element_style)
411 };
412 for (i, text) in title_str.split('\n').enumerate() {
413 if i != 0 {
414 write!(buffer, "\n{padding}")?;
415 }
416 write!(buffer, "{st}{text}{st:#}")?;
417 }
418 Ok(())
419}
420
421/// Print out a file position optimized for the data available.
422///
423/// When the `path` is available, we print `at PATH:LL:CC`, which is detected by terminals and
424/// editors as a path to a specific place in a file. Otherwise, we print`on line LL, column CC`,
425/// for the elements that are available.
426fn render_path(
427 output: &mut String,
428 path: Option<&str>,
429 line: Option<usize>,
430 col: Option<usize>,
431 anonymized_origin_line_numbers: bool,
432) -> Result<(), fmt::Error> {
433 let line = line.map(|l| {
434 if anonymized_origin_line_numbers {
435 ANONYMIZED_LINE_NUM.to_owned()
436 } else {
437 l.to_string()
438 }
439 });
440
441 match (path, line, col) {
442 (Some(path), Some(line), Some(col)) => {
443 // `at $DIR/file.txt:LL:CC`
444 write!(output, "at {path}:{line}:{col}")?;
445 }
446 (Some(path), Some(line), None) => {
447 // `at $DIR/file.txt:LL`
448 write!(output, "at {path}:{line}")?;
449 }
450 (Some(path), None, None) => {
451 // `at $DIR/file.txt`
452 write!(output, "at {path}")?;
453 }
454 (None, Some(line), Some(col)) => {
455 // We are not printing the path, so instead we show
456 // ` on line LL, column CC`.
457 write!(output, "on line {line}, column {col}")?;
458 }
459 (None, Some(line), None) => {
460 // ` on line LL`
461 write!(output, "on line {line}")?;
462 }
463 (None, None, Some(col)) => {
464 // ` on column CC`
465 write!(output, "on column {col}")?;
466 }
467 (Some(path), None, Some(col)) => {
468 // This condition should never have been called.
469 write!(output, "at {path}, column {col}")?;
470 }
471 (None, None, None) => {}
472 }
473 Ok(())
474}