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