1use 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 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 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}