Skip to main content

tardis_cli/parser/
error.rs

1//! Parser error types with span-based diagnostics.
2//!
3//! Errors carry the original input, byte-level position information, and
4//! optional typo-correction suggestions.
5
6use std::fmt;
7
8use crate::parser::token::ByteSpan;
9
10/// A parse error with optional span, expected/found context, and suggestion.
11#[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    /// Construct an error for unrecognized input.
30    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    /// Construct an error for unexpected token with position.
40    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    /// Construct an error for resolution failures (e.g., overflow).
53    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    /// Construct an error for input too long.
63    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    /// Attach a typo-correction suggestion.
73    pub(crate) fn with_suggestion(mut self, suggestion: String) -> Self {
74        self.suggestion = Some(suggestion);
75        self
76    }
77
78    /// Access the typo-correction suggestion, if any.
79    pub fn suggestion(&self) -> &Option<String> {
80        &self.suggestion
81    }
82
83    /// Format the error message for display to the user.
84    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}