Skip to main content

alba_shell/
error.rs

1//! The shell's single error type for lexing and parsing, and its
2//! plain-text rendering (message, source excerpt, caret underline,
3//! optional help). Rendering is here rather than in the CLI because the
4//! executor turns a parse failure into beam output lines and must not
5//! depend on CLI rendering.
6
7use crate::token::Span;
8
9#[derive(Debug, Clone, PartialEq, thiserror::Error)]
10#[error("{message}")]
11pub struct ShellParseError {
12    pub message: String,
13    pub span: Span,
14    pub suggestion: Option<String>,
15}
16
17impl ShellParseError {
18    /// Renders the diagnostic against the source the span points into:
19    ///
20    /// ```text
21    /// error: background jobs (`&`) are not supported
22    ///   │ watch & build
23    ///   │       ^
24    ///   = help: the engine already parallelizes beams; ...
25    /// ```
26    ///
27    /// Multi-line sources (a `run` string with embedded newlines) excerpt
28    /// only the line containing the span start.
29    ///
30    /// The caret is positioned by counting characters, not bytes: spans
31    /// are byte offsets, and padding with one space per byte would push
32    /// the underline right by one column for every accented character
33    /// ahead of it on the line. One space per character is right for
34    /// everything a beam command realistically contains; a double-width
35    /// character (CJK, an emoji) would still shift it, which would need
36    /// display-width measurement rather than a count.
37    pub fn render(&self, source: &str) -> String {
38        let line_start = source[..self.span.start.min(source.len())]
39            .rfind('\n')
40            .map_or(0, |i| i + 1);
41        let line_end = source[line_start..]
42            .find('\n')
43            .map_or(source.len(), |i| line_start + i);
44        let line = &source[line_start..line_end];
45        let span_start = self.span.start.clamp(line_start, line_end);
46        let span_end = self.span.end.clamp(span_start, line_end);
47        let caret_offset = source[line_start..span_start].chars().count();
48        let caret_len = source[span_start..span_end].chars().count().max(1);
49
50        let mut out = format!("error: {}\n", self.message);
51        out.push_str(&format!("  \u{2502} {line}\n"));
52        out.push_str(&format!(
53            "  \u{2502} {}{}\n",
54            " ".repeat(caret_offset),
55            "^".repeat(caret_len)
56        ));
57        if let Some(help) = &self.suggestion {
58            out.push_str(&format!("  = help: {help}\n"));
59        }
60        out
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use crate::token::Span;
68
69    #[test]
70    fn renders_message_caret_and_suggestion() {
71        let error = ShellParseError {
72            message: "background jobs (`&`) are not supported".into(),
73            span: Span { start: 6, end: 7 },
74            suggestion: Some(
75                "the engine already parallelizes beams; run one command per `run` entry".into(),
76            ),
77        };
78        insta::assert_snapshot!(error.render("watch & build"));
79    }
80
81    #[test]
82    fn aligns_the_caret_past_non_ascii_characters() {
83        // `echo café &`: the `&` is at byte 11 but column 10, so a caret
84        // padded per byte would sit one column too far right.
85        let source = "echo café &";
86        let start = source.find('&').unwrap();
87        let error = ShellParseError {
88            message: "background jobs (`&`) are not supported".into(),
89            span: Span {
90                start,
91                end: start + 1,
92            },
93            suggestion: None,
94        };
95        insta::assert_snapshot!(error.render(source));
96    }
97
98    #[test]
99    fn renders_without_suggestion() {
100        let error = ShellParseError {
101            message: "unclosed single quote".into(),
102            span: Span { start: 5, end: 6 },
103            suggestion: None,
104        };
105        insta::assert_snapshot!(error.render("echo 'oops"));
106    }
107}