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.
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}