tardis_cli/parser/
error.rs1use std::fmt;
7
8use crate::parser::token::ByteSpan;
9
10#[must_use]
12#[derive(Debug)]
13pub struct ParseError {
14 kind: ParseErrorKind,
15 span: Option<ByteSpan>,
16 input: String,
17 suggestion: Option<String>,
18}
19
20#[derive(Debug)]
21enum ParseErrorKind {
22 UnexpectedToken { expected: String, found: String },
23 UnrecognizedInput,
24 ResolutionFailed(String),
25 InputTooLong { len: usize, max: usize },
26}
27
28impl ParseError {
29 pub(crate) fn unrecognized(input: &str) -> Self {
31 Self {
32 kind: ParseErrorKind::UnrecognizedInput,
33 span: None,
34 input: input.to_string(),
35 suggestion: None,
36 }
37 }
38
39 pub(crate) fn unexpected(input: &str, span: ByteSpan, expected: &str, found: &str) -> Self {
41 Self {
42 kind: ParseErrorKind::UnexpectedToken {
43 expected: expected.to_string(),
44 found: found.to_string(),
45 },
46 span: Some(span),
47 input: input.to_string(),
48 suggestion: None,
49 }
50 }
51
52 pub(crate) fn resolution(detail: String) -> Self {
54 Self {
55 kind: ParseErrorKind::ResolutionFailed(detail),
56 span: None,
57 input: String::new(),
58 suggestion: None,
59 }
60 }
61
62 pub(crate) fn input_too_long(len: usize, max: usize) -> Self {
64 Self {
65 kind: ParseErrorKind::InputTooLong { len, max },
66 span: None,
67 input: String::new(),
68 suggestion: None,
69 }
70 }
71
72 pub(crate) fn with_suggestion(mut self, suggestion: String) -> Self {
74 self.suggestion = Some(suggestion);
75 self
76 }
77
78 pub fn suggestion(&self) -> &Option<String> {
80 &self.suggestion
81 }
82
83 pub fn format_message(&self) -> String {
85 let mut msg = match &self.kind {
86 ParseErrorKind::UnexpectedToken { expected, found } => {
87 if let Some(span) = &self.span {
88 format!(
89 "expected {} at position {}, found '{}'",
90 expected, span.start, found,
91 )
92 } else {
93 format!("expected {}, found '{}'", expected, found)
94 }
95 }
96 ParseErrorKind::UnrecognizedInput => {
97 if self.input.is_empty() {
98 "could not parse as a date expression".to_string()
99 } else {
100 format!("could not parse '{}' as a date expression", self.input)
101 }
102 }
103 ParseErrorKind::ResolutionFailed(detail) => detail.clone(),
104 ParseErrorKind::InputTooLong { len, max } => {
105 format!("input too long ({len} bytes, max {max})")
106 }
107 };
108
109 if let Some(suggestion) = &self.suggestion {
110 msg.push_str(&format!("\n\nDid you mean '{suggestion}'?"));
111 }
112
113 msg
114 }
115}
116
117impl fmt::Display for ParseError {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 write!(f, "{}", self.format_message())
120 }
121}
122
123impl std::error::Error for ParseError {}
124
125#[cfg(test)]
126mod tests {
127 #![allow(clippy::unwrap_used, clippy::expect_used)]
128 use super::*;
129
130 #[test]
131 fn unrecognized_error_message() {
132 let err = ParseError::unrecognized("xyz");
133 assert_eq!(
134 err.format_message(),
135 "could not parse 'xyz' as a date expression"
136 );
137 }
138
139 #[test]
140 fn unrecognized_empty_input_no_echo() {
141 let err = ParseError::unrecognized("");
142 assert_eq!(err.format_message(), "could not parse as a date expression");
143 }
144
145 #[test]
146 fn unrecognized_with_suggestion_echoes_input_and_suggests() {
147 let err = ParseError::unrecognized("tomorow").with_suggestion("tomorrow".to_string());
148 let msg = err.format_message();
149 assert!(msg.contains("could not parse 'tomorow'"));
150 assert!(msg.contains("Did you mean 'tomorrow'?"));
151 }
152
153 #[test]
154 fn suggestion_accessor_returns_value() {
155 let err = ParseError::unrecognized("tomorow").with_suggestion("tomorrow".to_string());
156 assert_eq!(err.suggestion(), &Some("tomorrow".to_string()));
157 }
158
159 #[test]
160 fn suggestion_accessor_returns_none() {
161 let err = ParseError::unrecognized("xyz");
162 assert_eq!(err.suggestion(), &None);
163 }
164
165 #[test]
166 fn unexpected_token_with_span() {
167 let err =
168 ParseError::unexpected("next 32", ByteSpan { start: 5, end: 7 }, "day name", "32");
169 assert_eq!(
170 err.format_message(),
171 "expected day name at position 5, found '32'"
172 );
173 }
174
175 #[test]
176 fn input_too_long_message() {
177 let err = ParseError::input_too_long(2048, 1024);
178 assert_eq!(
179 err.format_message(),
180 "input too long (2048 bytes, max 1024)"
181 );
182 }
183
184 #[test]
185 fn error_with_suggestion() {
186 let err = ParseError::unrecognized("thursdya").with_suggestion("thursday".to_string());
187 assert!(err.format_message().contains("Did you mean 'thursday'?"));
188 }
189
190 #[test]
191 fn suggestion_is_multiline_with_blank_separator() {
192 let err = ParseError::unrecognized("tomorow").with_suggestion("tomorrow".to_string());
193 let msg = err.format_message();
194 let lines: Vec<&str> = msg.lines().collect();
195 assert!(lines[0].contains("could not parse 'tomorow'"));
196 assert_eq!(
197 lines.len(),
198 3,
199 "Expected 3 lines: error, blank, suggestion. Got: {msg:?}"
200 );
201 assert!(lines[2].contains("Did you mean"));
202 }
203
204 #[test]
205 fn suggestion_is_plain_text_no_ansi() {
206 let err = ParseError::unrecognized("tomorow").with_suggestion("tomorrow".to_string());
207 let msg = err.format_message();
208 assert!(
209 !msg.contains("\x1b["),
210 "format_message() must not contain ANSI codes: {msg:?}"
211 );
212 assert!(msg.contains("Did you mean 'tomorrow'?"));
213 }
214
215 #[test]
216 fn error_without_suggestion_has_no_trailing_blank_lines() {
217 let err = ParseError::unrecognized("xyz");
218 let msg = err.format_message();
219 assert!(!msg.ends_with('\n'), "Message should not end with newline");
220 assert!(
221 !msg.contains("\n\n"),
222 "Message should not contain double newlines"
223 );
224 }
225
226 #[test]
227 fn display_impl_matches_format_message() {
228 let err = ParseError::unrecognized("@999999999999999999");
229 assert_eq!(format!("{err}"), err.format_message());
230 }
231
232 #[test]
233 fn resolution_failed_message() {
234 let err = ParseError::resolution("overflow: date out of bounds".to_string());
235 assert_eq!(err.format_message(), "overflow: date out of bounds");
236 }
237}