use std::fmt;
use crate::parser::token::ByteSpan;
#[must_use]
#[derive(Debug)]
pub struct ParseError {
kind: ParseErrorKind,
span: Option<ByteSpan>,
input: String,
suggestion: Option<String>,
}
#[derive(Debug)]
enum ParseErrorKind {
UnexpectedToken { expected: String, found: String },
UnrecognizedInput,
ResolutionFailed(String),
InputTooLong { len: usize, max: usize },
}
impl ParseError {
pub(crate) fn unrecognized(input: &str) -> Self {
Self {
kind: ParseErrorKind::UnrecognizedInput,
span: None,
input: input.to_string(),
suggestion: None,
}
}
pub(crate) fn unexpected(input: &str, span: ByteSpan, expected: &str, found: &str) -> Self {
Self {
kind: ParseErrorKind::UnexpectedToken {
expected: expected.to_string(),
found: found.to_string(),
},
span: Some(span),
input: input.to_string(),
suggestion: None,
}
}
pub(crate) fn resolution(detail: String) -> Self {
Self {
kind: ParseErrorKind::ResolutionFailed(detail),
span: None,
input: String::new(),
suggestion: None,
}
}
pub(crate) fn input_too_long(len: usize, max: usize) -> Self {
Self {
kind: ParseErrorKind::InputTooLong { len, max },
span: None,
input: String::new(),
suggestion: None,
}
}
pub(crate) fn with_suggestion(mut self, suggestion: String) -> Self {
self.suggestion = Some(suggestion);
self
}
pub fn suggestion(&self) -> &Option<String> {
&self.suggestion
}
pub fn format_message(&self) -> String {
let mut msg = match &self.kind {
ParseErrorKind::UnexpectedToken { expected, found } => {
if let Some(span) = &self.span {
format!(
"expected {} at position {}, found '{}'",
expected, span.start, found,
)
} else {
format!("expected {}, found '{}'", expected, found)
}
}
ParseErrorKind::UnrecognizedInput => {
if self.input.is_empty() {
"could not parse as a date expression".to_string()
} else {
format!("could not parse '{}' as a date expression", self.input)
}
}
ParseErrorKind::ResolutionFailed(detail) => detail.clone(),
ParseErrorKind::InputTooLong { len, max } => {
format!("input too long ({len} bytes, max {max})")
}
};
if let Some(suggestion) = &self.suggestion {
msg.push_str(&format!("\n\nDid you mean '{suggestion}'?"));
}
msg
}
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.format_message())
}
}
impl std::error::Error for ParseError {}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
#[test]
fn unrecognized_error_message() {
let err = ParseError::unrecognized("xyz");
assert_eq!(
err.format_message(),
"could not parse 'xyz' as a date expression"
);
}
#[test]
fn unrecognized_empty_input_no_echo() {
let err = ParseError::unrecognized("");
assert_eq!(err.format_message(), "could not parse as a date expression");
}
#[test]
fn unrecognized_with_suggestion_echoes_input_and_suggests() {
let err = ParseError::unrecognized("tomorow").with_suggestion("tomorrow".to_string());
let msg = err.format_message();
assert!(msg.contains("could not parse 'tomorow'"));
assert!(msg.contains("Did you mean 'tomorrow'?"));
}
#[test]
fn suggestion_accessor_returns_value() {
let err = ParseError::unrecognized("tomorow").with_suggestion("tomorrow".to_string());
assert_eq!(err.suggestion(), &Some("tomorrow".to_string()));
}
#[test]
fn suggestion_accessor_returns_none() {
let err = ParseError::unrecognized("xyz");
assert_eq!(err.suggestion(), &None);
}
#[test]
fn unexpected_token_with_span() {
let err =
ParseError::unexpected("next 32", ByteSpan { start: 5, end: 7 }, "day name", "32");
assert_eq!(
err.format_message(),
"expected day name at position 5, found '32'"
);
}
#[test]
fn input_too_long_message() {
let err = ParseError::input_too_long(2048, 1024);
assert_eq!(
err.format_message(),
"input too long (2048 bytes, max 1024)"
);
}
#[test]
fn error_with_suggestion() {
let err = ParseError::unrecognized("thursdya").with_suggestion("thursday".to_string());
assert!(err.format_message().contains("Did you mean 'thursday'?"));
}
#[test]
fn suggestion_is_multiline_with_blank_separator() {
let err = ParseError::unrecognized("tomorow").with_suggestion("tomorrow".to_string());
let msg = err.format_message();
let lines: Vec<&str> = msg.lines().collect();
assert!(lines[0].contains("could not parse 'tomorow'"));
assert_eq!(
lines.len(),
3,
"Expected 3 lines: error, blank, suggestion. Got: {msg:?}"
);
assert!(lines[2].contains("Did you mean"));
}
#[test]
fn suggestion_is_plain_text_no_ansi() {
let err = ParseError::unrecognized("tomorow").with_suggestion("tomorrow".to_string());
let msg = err.format_message();
assert!(
!msg.contains("\x1b["),
"format_message() must not contain ANSI codes: {msg:?}"
);
assert!(msg.contains("Did you mean 'tomorrow'?"));
}
#[test]
fn error_without_suggestion_has_no_trailing_blank_lines() {
let err = ParseError::unrecognized("xyz");
let msg = err.format_message();
assert!(!msg.ends_with('\n'), "Message should not end with newline");
assert!(
!msg.contains("\n\n"),
"Message should not contain double newlines"
);
}
#[test]
fn display_impl_matches_format_message() {
let err = ParseError::unrecognized("@999999999999999999");
assert_eq!(format!("{err}"), err.format_message());
}
#[test]
fn resolution_failed_message() {
let err = ParseError::resolution("overflow: date out of bounds".to_string());
assert_eq!(err.format_message(), "overflow: date out of bounds");
}
}