Skip to main content

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}