alpm_parsers/error/render.rs
1//! Rendering logic for [`ParseStack`] errors.
2
3use std::fmt;
4
5use colored::Colorize;
6use unicode_width::UnicodeWidthChar;
7
8use super::parse_stack::ParseStack;
9use crate::error::layer::LayerRef;
10
11/// Writes a potentially multi-line footer message for a parser layer.
12fn write_footer_message(out: &mut String, guide: &str, message: &str) {
13 let mut lines = message.lines();
14 let Some(first) = lines.next() else {
15 return;
16 };
17
18 out.push_str(&format!("{}→ {}\n", guide, first.dimmed()));
19 for line in lines {
20 out.push_str(&format!("{} {}\n", guide, line.dimmed()));
21 }
22}
23
24/// Returns the start and end byte offsets of the line containing `at`.
25///
26/// This function helps us navigate the given document.
27/// We usually start somewhere in the middle of the input, without any knowledge of what's around
28/// the current span/pointer.
29fn line_bounds(src: &str, at: usize) -> (usize, usize) {
30 let at = src.floor_char_boundary(at);
31 let line_start = src[..at].rfind('\n').map_or(0, |i| i + 1);
32 let line_end = src[at..].find('\n').map_or(src.len(), |i| at + i);
33 (line_start, line_end)
34}
35
36/// Returns the indentation and the underline for the span between `span_start` and `at`.
37///
38/// The two returned strings are meant to be concatenated and are followed by the caret that
39/// points at the character, which caused the error:
40///
41/// ```text
42/// 1 | foo-bar
43/// | ~~~~~~^
44/// ^^^ indentation
45/// ^^^^^^ underline
46/// ```
47///
48/// There are two scenarios that we need to handle to achieve a correct underline and to place the
49/// caret pointer at the correct position:
50///
51/// - Multi-width UTF-8 graphemes. To handle this, we measure the display width of each character on
52/// the error line, and insert the correct amount of spaces/`~`.
53///
54/// - Tabs cannot be handled 100% correctly, as their rendered width depends on the terminal's tab
55/// stops. We work around the issue as follows:
56/// - When encountering a tab while building the indentation, we simply place a `\t` at the same
57/// position as in the string above.
58/// - When encountering a tab while building the underline, we insert a literal `\t` there as
59/// well. This visually breaks the underline, but as there's no way to determine its display
60/// width, we accept the compromise of a broken indicator line in favor of a correct offset for
61/// the pointer.
62fn underline(src: &str, line_start: usize, span_start: usize, at: usize) -> (String, String) {
63 // Clamped all chars down to the closest valid UTF-8 character boundary, in case the parsing
64 // error points into the middle of a multi-byte character.
65 let line_start = src.floor_char_boundary(line_start);
66 let span_start = src.floor_char_boundary(span_start).max(line_start);
67 let at = src.floor_char_boundary(at).max(span_start);
68
69 // Build the indentation string.
70 let mut indent = String::new();
71 for char in src[line_start..span_start].chars() {
72 match char {
73 '\t' => indent.push('\t'),
74 _ => indent.push_str(&" ".repeat(char.width().unwrap_or(0))),
75 }
76 }
77
78 // Build the underline.
79 let mut span = String::new();
80 for char in src[span_start..at].chars() {
81 match char {
82 '\t' => span.push('\t'),
83 _ => span.push_str(&"~".repeat(char.width().unwrap_or(0))),
84 }
85 }
86
87 (indent, span)
88}
89
90/// Formats the given expected literals into a single "expected" message:
91///
92/// ```text
93/// expected `]`
94/// expected one of: `]`, `}`
95/// ```
96///
97/// Returns `None` if the provided literal list is empty.
98fn expected_message(literals: &[String]) -> Option<String> {
99 match literals {
100 [] => None,
101 [literal] => Some(format!("expected {literal}")),
102 literals => Some(format!("expected one of: {}", literals.join(", "))),
103 }
104}
105
106/// The maximum snippet length before showing a `…` indicator.
107const SNIPPET_SIZE: usize = 20;
108
109/// Returns a preview of the input starting at `from`, truncated to [`SNIPPET_SIZE`] chars or the
110/// next newline.
111fn snippet_at(src: &str, from: usize) -> String {
112 let from = src.floor_char_boundary(from);
113 let (_, line_end) = line_bounds(src, from);
114 let raw = &src[from..line_end];
115 let shown: String = raw.chars().take(SNIPPET_SIZE).collect();
116
117 // If there's trailing content on that line, also show a `…` indicator.
118 if raw.chars().count() > SNIPPET_SIZE {
119 format!("{shown}…")
120 } else {
121 shown
122 }
123}
124
125impl fmt::Display for ParseStack<'_> {
126 /// Displays this parse error.
127 ///
128 /// The rendered output is structured into three visual sections:
129 ///
130 /// 1. A headline with the innermost available label context.
131 /// 2. A source snippet with an underline spanning from beginning of the innermost named layer
132 /// up to the exact failing character. Any expected literals are shown next to the caret.
133 /// 3. A footer that provides error context from outermost to innermost layers.
134 ///
135 /// Named layers are rendered with their parser name and a mini source preview:
136 ///
137 /// ```text
138 /// error: invalid package release
139 /// |
140 /// 1 | foo-1:1.0.0-bar-any
141 /// | ^ expected positive decimal integer
142 /// |
143 /// = while parsing:
144 /// installed package name: (foo-1:1.0.0-bar-any)
145 /// └ alpm-package-version: (1:1.0.0-bar-any)
146 /// │ → an alpm-package-version (full or full with epoch) followed by a `-` and an alpm-architecture
147 /// └ alpm-pkgrel: (bar-any)
148 /// → invalid package release
149 /// → A freeform description over here
150 /// → expected positive decimal integer
151 /// ```
152 ///
153 /// Pending context that has not yet unwound past a named layer is rendered as an anonymous
154 /// outermost layer:
155 ///
156 /// ```text
157 /// = while parsing:
158 /// → alpm-package file name
159 /// → a package name, followed by an alpm-package-version...
160 /// └ installed package name: (foo-1:1.0.0_any)
161 /// ```
162 ///
163 /// Color output is controlled globally via [`colored::control`] (for example via
164 /// [`colored::control::set_override`]).
165 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166 let src = self.source;
167 let at = src.floor_char_boundary(self.at);
168
169 // We locate the failing line using the byte offsets (as that's what winnow provides).
170 // To support Unicode however, we consider the width of the unicode characters, so that
171 // the positioning of the underline characters stays correct.
172 let (line_start, line_end) = line_bounds(src, at);
173 let line_number = src[..at].bytes().filter(|&b| b == b'\n').count() + 1;
174 let line = &src[line_start..line_end];
175
176 // Get the neighboring lines around the line with the failure.
177 // Empty neighbors are dropped, as they provide no additional context.
178 //
179 // Everything before `line_start` ends with a newline, so the last line of that slice is
180 // the previous line. Everything from `line_end` starts with the current line's newline,
181 // so the second line of that slice is the next line.
182 let prev = src[..line_start]
183 .lines()
184 .next_back()
185 .map(|text| (line_number - 1, text));
186 let next = src[line_end..]
187 .lines()
188 .nth(1)
189 .map(|text| (line_number + 1, text));
190
191 // Calculate the width of the largest line number.
192 // We have to make sure that the padding is equal across all codeblock lines.
193 let (max_number_width, number_padding, line_number) = if prev.is_none() && next.is_none() {
194 // Special case where we're handling a single-line input, in which case we just scrap
195 // the number altogether.
196 (0, "".to_string(), None)
197 } else {
198 let max_number = next.map_or(line_number, |(n, _)| n);
199 let max_number_width = max_number.to_string().len();
200 (
201 max_number_width,
202 " ".repeat(max_number_width),
203 Some(line_number),
204 )
205 };
206
207 // Mini helper closure to write indent lines witih the given width.
208 // ```
209 // |
210 // 9 |
211 // 10 |
212 // |
213 // ```
214 let codeblock_line = |number: Option<usize>, line: &str| {
215 let number = number
216 .map(|n| format!("{n:>max_number_width$}"))
217 .unwrap_or_else(|| number_padding.to_string());
218 format!("{} {} {}\n", number.blue(), "|".blue(), line)
219 };
220
221 let mut out = String::new();
222
223 // Header.
224 // E.g. `error: invalid input`
225 out += &format!(
226 "{} {}\n",
227 "error:".red().bold(),
228 format!("invalid {}", self.headline()).bold(),
229 );
230
231 // Source code block with error context.
232 // - Spacer line
233 // - optional previous line
234 // - failing line
235 // - span underline with optional "expected" messages
236 // - optional next line
237 // - optional spacer line
238
239 // Add an empty line with some padding.
240 out += &codeblock_line(None, "");
241
242 // Print the previous line
243 if let Some((line_number, text)) = prev
244 && !text.is_empty()
245 {
246 out += &codeblock_line(Some(line_number), text);
247 }
248
249 // Print the line with the actual error.
250 out += &codeblock_line(line_number, line);
251
252 // Build the underline from the innermost named layer's start to the failing byte.
253 // If no named layer exists, this falls back to the failure position.
254 let (indent, span) = underline(src, line_start, self.innermost().start(), at);
255
256 // Print the span underline, with the first line of the "expected" message next to the
257 // caret. The "expected" message lists all expected literals of the innermost layer.
258 let mut span_line = format!("{}{}{}", indent, span.red(), "^".red().bold());
259 let expected = expected_message(&self.innermost_expected()).unwrap_or_default();
260 let mut expected_lines = expected.lines();
261 if let Some(first) = expected_lines.next() {
262 span_line.push_str(&format!(" {}", first.red()));
263 }
264 out += &codeblock_line(None, &span_line);
265
266 // Print all follow-up lines of the "expected" message.
267 // The text is indented past the caret, so it aligns with the first "expected" line.
268 // The underline only consists of single width characters, hence the char count is the
269 // width we have to pad. The `+1` is the caret itself.
270 let continuation = format!("{indent}{}", " ".repeat(span.chars().count() + 1));
271 for line in expected_lines {
272 out += &codeblock_line(None, &format!("{continuation} {}", line.red()));
273 }
274
275 // Print the next line after the error
276 if let Some((line_number, text)) = next
277 && !text.is_empty()
278 {
279 out += &codeblock_line(Some(line_number), text);
280 }
281
282 // The footer section should only be drawn if it provides new information.
283 //
284 // Two or more layers are a safe draw, as there will always be new information.
285 //
286 // To draw in case of one layer, there must be either
287 // - Staged content
288 // - Or at least a label context on that layer
289 //
290 // Descriptions are only ever rendered in the footer. Hence, the footer must always be
291 // drawn if any layer, including the anonymous one for pending context, has a description.
292 //
293 // In all other cases, the footer provides no additional information.
294 let has_descriptions = self
295 .layer_stack()
296 .iter()
297 .any(|layer| layer.descriptions().is_some());
298 let has_labeled_layer = self
299 .layers
300 .first()
301 .is_some_and(|layer| LayerRef::Named(layer).label_message().is_some());
302
303 let should_draw_footer_section =
304 // 2+ layers
305 self.layers.len() > 1
306 // 1 layer + pending context
307 || (!self.layers.is_empty() && !self.pending.is_empty())
308 // 1 layer with a label
309 || has_labeled_layer
310 // Any layer with a description
311 || has_descriptions
312 // There's an external error
313 || self.external.is_some();
314
315 // Only show the line below as visual buffer, if there's some content.
316 if should_draw_footer_section {
317 out += &codeblock_line(None, "");
318 }
319
320 // The footer section.
321 //
322 // Displays the layer stack, from outermost to innermost.
323 if should_draw_footer_section {
324 out += &format!(
325 "{} {} {}\n",
326 number_padding,
327 "=".blue(),
328 "while parsing:".dimmed(),
329 );
330
331 let mut layers_iter = self.layer_stack().into_iter().enumerate().peekable();
332 while let Some((depth, layer)) = layers_iter.next() {
333 if let Some(name) = layer.name() {
334 // The prefix for the layer's name
335 let branch = if depth == 0 {
336 String::new()
337 } else {
338 format!("{}└ ", " ".repeat((depth - 1) * 2))
339 };
340
341 let snippet = snippet_at(src, layer.start());
342 out += &format!(
343 "{} {}{}: {}\n",
344 number_padding,
345 branch,
346 name.bold(),
347 format!("({snippet})").dimmed(),
348 );
349 }
350
351 // Determine the indentation amount for the current layer.
352 let nested_padding = number_padding.len() + 3 + 2 * depth;
353
354 // In case we are not on the last/innermost layer, add a UTF-8 border as a
355 // visual guide.
356 let guide = if layers_iter.peek().is_none() {
357 " ".repeat(nested_padding)
358 } else {
359 format!("{}│ ", " ".repeat(nested_padding))
360 };
361
362 // Print the label of the layer.
363 let label = layer.label_message();
364 if let Some(label) = label {
365 write_footer_message(&mut out, &guide, &format!("invalid {label}"));
366 }
367
368 // Print the free-form descriptions.
369 if let Some(descriptions) = layer.descriptions() {
370 write_footer_message(&mut out, &guide, &descriptions);
371 }
372
373 // Print information about all expected literals at this position.
374 if let Some(message) = expected_message(&layer.expected_literals()) {
375 write_footer_message(&mut out, &guide, &message);
376 }
377 }
378 }
379
380 // This is some extra handling in case an error came from an external error,
381 // such as a `try_map`.
382 if let Some(external) = &self.external {
383 out += &format!(
384 "{} {} {}\n",
385 number_padding,
386 "=".blue(),
387 format!("an error occurred: {external}").dimmed(),
388 );
389 }
390 f.write_str(&out)
391 }
392}