pub mod error;
pub mod lexer;
pub use error::{Result, TempsError};
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
pub enum TimeExpression {
Now,
Relative(RelativeTime),
Absolute(AbsoluteTime),
Day(DayReference),
Time(Time),
Date(StandardDate),
DayTime(DayTime),
LaterToday,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
pub struct RelativeTime {
pub amount: i64,
pub unit: TimeUnit,
pub direction: Direction,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
pub struct AbsoluteTime {
pub year: u16,
pub month: u8,
pub day: u8,
pub hour: Option<u8>,
pub minute: Option<u8>,
pub second: Option<u8>,
pub nanosecond: Option<u32>,
pub timezone: Option<Timezone>,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
pub enum Timezone {
Utc,
Offset {
total_minutes: i16,
},
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
pub enum DayReference {
Today,
Yesterday,
Tomorrow,
DayBeforeYesterday,
DayAfterTomorrow,
Weekday {
day: Weekday,
modifier: Option<WeekdayModifier>,
},
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
pub struct Time {
pub hour: u8,
pub minute: u8,
pub second: u8,
pub meridiem: Option<Meridiem>,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
pub struct StandardDate {
pub day: u8,
pub month: u8,
pub year: u16,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
pub struct DayTime {
pub day: DayReference,
pub time: Time,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
pub enum TimeUnit {
Second,
Minute,
Hour,
Day,
Week,
Month,
Year,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
pub enum Direction {
Past,
Future,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
pub enum Weekday {
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
pub enum WeekdayModifier {
Last,
Next,
This,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
pub enum Meridiem {
AM,
PM,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
pub enum Language {
English,
German,
}
pub trait TimeParser {
type DateTime;
fn now(&self) -> Self::DateTime;
fn parse_expression(&self, expr: TimeExpression) -> Result<Self::DateTime>;
}
pub trait LanguageParser {
fn parse(&self, input: &str) -> Result<TimeExpression>;
}
pub mod constants {
pub const SECONDS_PER_HOUR: i32 = 3600;
pub const SECONDS_PER_MINUTE: i32 = 60;
pub const MINUTES_PER_HOUR: i32 = 60;
pub const HOURS_PER_DAY: i32 = 24;
pub const DAYS_PER_WEEK: i32 = 7;
pub const MONTHS_PER_YEAR: i32 = 12;
}
pub mod errors {
pub const ERR_MONTH_POSITIVE: &str = "Month amount must be a positive number";
pub const ERR_YEAR_POSITIVE: &str = "Year amount must be a positive number";
pub const ERR_DATE_CALC_INVALID: &str = "Date calculation resulted in invalid date";
pub const ERR_YEAR_OVERFLOW: &str = "Year calculation overflow";
pub const ERR_AMOUNT_OUT_OF_RANGE: &str = "Relative amount is too large to represent as a date";
pub const ERR_INVALID_DATE: &str = "Invalid date";
pub const ERR_INVALID_TIME: &str = "Invalid time";
pub const ERR_AMBIGUOUS_TIME: &str = "Ambiguous or invalid local time";
pub const ERR_MIDNIGHT_FAILED: &str = "Failed to create midnight time";
pub const ERR_DATE_CALC_ERROR: &str = "Date calculation error";
pub const ERR_TIMEZONE_CONVERSION: &str = "Timezone conversion error";
pub const ERR_RELATIVE_AMOUNT_NON_NEGATIVE: &str = "Relative amount must be non-negative";
#[must_use]
pub fn format_invalid_date(year: u16, month: u8, day: u8) -> String {
format!("Invalid date: {year}-{month}-{day}")
}
#[must_use]
pub fn format_invalid_time(hour: u8, minute: u8, second: u8) -> String {
format!("Invalid time: {hour}:{minute}:{second}")
}
#[must_use]
pub fn format_invalid_timezone_offset(total_minutes: i16) -> String {
let sign = if total_minutes < 0 { '-' } else { '+' };
let magnitude = total_minutes.unsigned_abs();
format!(
"Invalid timezone offset: {sign}{:02}:{:02}",
magnitude / 60,
magnitude % 60
)
}
}
pub mod time_utils {
use crate::{Meridiem, Timezone, WeekdayModifier, constants::SECONDS_PER_MINUTE};
#[must_use]
pub fn convert_12_to_24_hour(hour: u8, meridiem: Option<&Meridiem>) -> u8 {
match meridiem {
Some(Meridiem::AM) => {
if hour == 12 {
0
} else {
hour
}
}
Some(Meridiem::PM) => {
if hour >= 12 {
hour
} else {
hour + 12
}
}
None => hour,
}
}
#[must_use]
pub fn calculate_timezone_offset_seconds(total_minutes: i16) -> i32 {
i32::from(total_minutes).saturating_mul(SECONDS_PER_MINUTE)
}
#[must_use]
pub fn is_valid_calendar_date(year: u16, month: u8, day: u8) -> bool {
let days_in_month = match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap_year(year) => 29,
2 => 28,
_ => return false,
};
(1..=days_in_month).contains(&day)
}
#[must_use]
pub fn is_valid_24_hour_time(hour: u8, minute: u8, second: u8) -> bool {
hour <= 23 && minute <= 59 && second <= 59
}
#[must_use]
pub fn is_valid_time(hour: u8, minute: u8, second: u8, meridiem: Option<Meridiem>) -> bool {
match meridiem {
Some(_) => (1..=12).contains(&hour) && minute <= 59 && second <= 59,
None => is_valid_24_hour_time(hour, minute, second),
}
}
#[must_use]
pub fn is_valid_timezone_offset(offset: Timezone) -> bool {
match offset {
Timezone::Utc => true,
Timezone::Offset { total_minutes } => (-720..=840).contains(&total_minutes),
}
}
#[must_use]
pub fn calculate_weekday_offset(
current_day_offset: i64,
target_day_offset: i64,
modifier: Option<WeekdayModifier>,
) -> i64 {
let days_diff = target_day_offset - current_day_offset;
match modifier {
None => {
if days_diff >= 0 {
days_diff
} else {
7 + days_diff
}
}
Some(WeekdayModifier::Next) => {
if days_diff > 0 {
days_diff
} else {
7 + days_diff
}
}
Some(WeekdayModifier::This) => {
days_diff
}
Some(WeekdayModifier::Last) => {
if days_diff < 0 {
days_diff
} else {
days_diff - 7
}
}
}
}
#[must_use]
fn is_leap_year(year: u16) -> bool {
year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400))
}
}
pub mod common {
use super::{AbsoluteTime, TimeExpression, Timezone, time_utils};
use crate::lexer::{Token, lex};
use chumsky::{input::ValueInput, prelude::*};
pub type ParserError<'t, 's> = extra::Err<Rich<'t, Token<'s>>>;
pub trait TokenInput<'t, 's>: ValueInput<'t, Token = Token<'s>, Span = SimpleSpan> {}
impl<'t, 's, I> TokenInput<'t, 's> for I where
I: ValueInput<'t, Token = Token<'s>, Span = SimpleSpan>
{
}
pub type BoxedParser<'t, 's, I, O> = chumsky::Boxed<'t, 't, I, O, ParserError<'t, 's>>;
pub fn token_stream<'t, 's: 't>(
source: &'s str,
tokens: &'t [(Token<'s>, SimpleSpan)],
) -> impl TokenInput<'t, 's> {
let eoi = SimpleSpan::from(source.len()..source.len());
tokens.map(eoi, |(token, span)| (token, span))
}
pub fn space<'t, 's: 't, I>() -> impl Parser<'t, I, (), ParserError<'t, 's>> + Clone
where
I: TokenInput<'t, 's>,
{
just(Token::Space).ignored().labelled("whitespace")
}
pub fn opt_space<'t, 's: 't, I>() -> impl Parser<'t, I, (), ParserError<'t, 's>> + Clone
where
I: TokenInput<'t, 's>,
{
just(Token::Space).or_not().ignored()
}
pub fn punct<'t, 's: 't, I>(c: char) -> impl Parser<'t, I, (), ParserError<'t, 's>> + Clone
where
I: TokenInput<'t, 's>,
{
just(Token::Punct(c)).ignored()
}
pub fn word_ci<'t, 's: 't, I>(
target: &'static str,
) -> impl Parser<'t, I, (), ParserError<'t, 's>> + Clone
where
I: TokenInput<'t, 's>,
{
select! { Token::Word(word) if eq_ignore_case(word, target) => () }.labelled(target)
}
pub fn word_cs<'t, 's: 't, I>(
target: &'static str,
) -> impl Parser<'t, I, (), ParserError<'t, 's>> + Clone
where
I: TokenInput<'t, 's>,
{
select! { Token::Word(word) if word == target => () }.labelled(target)
}
fn eq_ignore_case(a: &str, b: &str) -> bool {
let mut a = a.chars().flat_map(char::to_lowercase);
let mut b = b.chars().flat_map(char::to_lowercase);
loop {
match (a.next(), b.next()) {
(None, None) => return true,
(x, y) if x == y => (),
_ => return false,
}
}
}
pub fn phrase_ci<'t, 's: 't, I>(
target: &'static str,
) -> impl Parser<'t, I, (), ParserError<'t, 's>> + Clone
where
I: TokenInput<'t, 's>,
{
phrase(target, Case::Insensitive)
}
pub fn phrase_cs<'t, 's: 't, I>(
target: &'static str,
) -> impl Parser<'t, I, (), ParserError<'t, 's>> + Clone
where
I: TokenInput<'t, 's>,
{
phrase(target, Case::Sensitive)
}
pub fn phrases_ci<'t, 's: 't, I, T>(
pairs: impl IntoIterator<Item = (&'static str, T)>,
) -> impl Parser<'t, I, T, ParserError<'t, 's>> + Clone
where
I: TokenInput<'t, 's>,
T: Clone + 't,
{
phrase_alternation(pairs, Case::Insensitive)
}
pub fn phrases_cs<'t, 's: 't, I, T>(
pairs: impl IntoIterator<Item = (&'static str, T)>,
) -> impl Parser<'t, I, T, ParserError<'t, 's>> + Clone
where
I: TokenInput<'t, 's>,
T: Clone + 't,
{
phrase_alternation(pairs, Case::Sensitive)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Case {
Sensitive,
Insensitive,
}
fn pattern_token<'t, 's: 't, I>(token: Token<'static>, case: Case) -> BoxedParser<'t, 's, I, ()>
where
I: TokenInput<'t, 's>,
{
match token {
Token::Word(word) => match case {
Case::Sensitive => word_cs(word).boxed(),
Case::Insensitive => word_ci(word).boxed(),
},
Token::Number(digits) => {
select! { Token::Number(found) if found == digits => () }.boxed()
}
Token::Punct(c) => punct(c).boxed(),
Token::Space => space().boxed(),
}
}
fn phrase<'t, 's: 't, I>(target: &'static str, case: Case) -> BoxedParser<'t, 's, I, ()>
where
I: TokenInput<'t, 's>,
{
let mut tokens = lex(target).into_iter().map(|(token, _)| token);
let first = tokens.next().expect("phrase must be non-empty");
let mut parser = pattern_token(first, case);
for token in tokens {
parser = parser.then_ignore(pattern_token(token, case)).boxed();
}
parser.labelled(target).boxed()
}
fn phrase_alternation<'t, 's: 't, I, T>(
pairs: impl IntoIterator<Item = (&'static str, T)>,
case: Case,
) -> BoxedParser<'t, 's, I, T>
where
I: TokenInput<'t, 's>,
T: Clone + 't,
{
let mut pairs: Vec<(&'static str, T)> = pairs.into_iter().collect();
pairs.sort_by_key(|(phrase, _)| {
std::cmp::Reverse((lex(phrase).len(), phrase.chars().count()))
});
let mut pairs = pairs.into_iter();
let (first_phrase, first_value) = pairs.next().expect("phrase set must be non-empty");
let mut parser = phrase(first_phrase, case).to(first_value).boxed();
for (pattern, value) in pairs {
parser = parser.or(phrase(pattern, case).to(value)).boxed();
}
parser
}
pub fn digit_number<'t, 's: 't, I>() -> impl Parser<'t, I, i64, ParserError<'t, 's>> + Clone
where
I: TokenInput<'t, 's>,
{
select! { Token::Number(digits) => digits }
.try_map(|digits: &str, span| {
digits
.parse::<i64>()
.map_err(|e| Rich::custom(span, e.to_string()))
})
.labelled("number")
}
pub fn two_digit_number<'t, 's: 't, I>() -> impl Parser<'t, I, u8, ParserError<'t, 's>> + Clone
where
I: TokenInput<'t, 's>,
{
select! { Token::Number(digits) if matches!(digits.len(), 1 | 2) => digits }.try_map(
|digits: &str, span| {
digits
.parse::<u8>()
.map_err(|e| Rich::custom(span, e.to_string()))
},
)
}
pub fn four_digit_number<'t, 's: 't, I>() -> impl Parser<'t, I, u16, ParserError<'t, 's>> + Clone
where
I: TokenInput<'t, 's>,
{
select! { Token::Number(digits) if digits.len() == 4 => digits }
.try_map(|digits: &str, span| {
digits
.parse::<u16>()
.map_err(|e| Rich::custom(span, e.to_string()))
})
.labelled("4-digit year")
}
fn offset_timezone<'t, 's: 't, I>() -> impl Parser<'t, I, Timezone, ParserError<'t, 's>> + Clone
where
I: TokenInput<'t, 's>,
{
select! { Token::Punct(sign) if sign == '+' || sign == '-' => sign }
.then(two_digit_number())
.then(punct(':').ignore_then(two_digit_number()).or_not())
.try_map(|((sign, hours), minutes), span| {
let minutes = minutes.unwrap_or(0);
if minutes > 59 {
return Err(Rich::custom(span, "timezone minute offset out of range"));
}
let magnitude = i16::from(hours)
.checked_mul(60)
.and_then(|h| h.checked_add(i16::from(minutes)))
.ok_or_else(|| Rich::custom(span, "timezone hour offset out of range"))?;
let total_minutes = if sign == '+' { magnitude } else { -magnitude };
let offset = Timezone::Offset { total_minutes };
if time_utils::is_valid_timezone_offset(offset) {
Ok(offset)
} else {
Err(Rich::custom(span, "invalid timezone offset"))
}
})
}
fn timezone<'t, 's: 't, I>() -> impl Parser<'t, I, Timezone, ParserError<'t, 's>> + Clone
where
I: TokenInput<'t, 's>,
{
choice((word_cs("Z").to(Timezone::Utc), offset_timezone()))
}
fn fractional_seconds<'t, 's: 't, I>() -> impl Parser<'t, I, u32, ParserError<'t, 's>> + Clone
where
I: TokenInput<'t, 's>,
{
select! { Token::Number(digits) => digits }.try_map(|s: &str, span| {
let fraction = if s.len() > 9 { &s[..9] } else { s };
let parsed: u32 = fraction
.parse()
.map_err(|e: std::num::ParseIntError| Rich::custom(span, e.to_string()))?;
let fraction_len =
u32::try_from(fraction.len()).expect("fraction length is capped at 9 digits");
Ok(parsed * 10_u32.pow(9 - fraction_len))
})
}
pub fn iso_datetime<'t, 's: 't, I>()
-> impl Parser<'t, I, TimeExpression, ParserError<'t, 's>> + Clone
where
I: TokenInput<'t, 's>,
{
let date = four_digit_number()
.then_ignore(punct('-'))
.then(two_digit_number())
.then_ignore(punct('-'))
.then(two_digit_number())
.try_map(|((year, month), day), span| {
if time_utils::is_valid_calendar_date(year, month, day) {
Ok((year, month, day))
} else {
Err(Rich::custom(span, "invalid calendar date"))
}
});
let separator = choice((word_cs("T"), space()));
let time = separator
.ignore_then(two_digit_number())
.then_ignore(punct(':'))
.then(two_digit_number())
.then(
punct(':')
.ignore_then(two_digit_number())
.then(punct('.').ignore_then(fractional_seconds()).or_not())
.or_not(),
)
.then(timezone().or_not())
.try_map(|(((hour, minute), sec_part), tz), span| {
let second = sec_part.as_ref().map_or(0, |(s, _)| *s);
if time_utils::is_valid_24_hour_time(hour, minute, second) {
Ok((hour, minute, sec_part, tz))
} else {
Err(Rich::custom(span, "invalid time"))
}
});
date.then(time.or_not())
.map(|((year, month, day), time_opt)| match time_opt {
Some((h, m, sec_part, tz)) => {
let (second, nanosecond) = match sec_part {
Some((s, frac)) => (Some(s), frac),
None => (None, None),
};
TimeExpression::Absolute(AbsoluteTime {
year,
month,
day,
hour: Some(h),
minute: Some(m),
second,
nanosecond,
timezone: tz,
})
}
None => TimeExpression::Absolute(AbsoluteTime {
year,
month,
day,
hour: None,
minute: None,
second: None,
nanosecond: None,
timezone: None,
}),
})
}
#[cfg(test)]
mod tests {
use super::*;
macro_rules! run {
($input:expr, $parser:expr) => {{
let input: &str = $input;
let tokens = lex(input);
$parser
.then_ignore(end())
.parse(token_stream(input, &tokens))
.into_result()
.ok()
}};
}
#[test]
fn word_ci_matches_whole_words_only() {
assert!(run!("day", word_ci("day")).is_some());
assert!(run!("DAY", word_ci("day")).is_some());
assert!(run!("days", word_ci("day")).is_none());
assert!(run!("min", word_ci("m")).is_none());
}
#[test]
fn word_ci_folds_umlauts() {
assert!(run!("nächsten", word_ci("nächsten")).is_some());
assert!(run!("Nächsten", word_ci("nächsten")).is_some());
assert!(run!("NÄCHSTEN", word_ci("nächsten")).is_some());
}
#[test]
fn word_cs_respects_case() {
assert!(run!("Montag", word_cs("Montag")).is_some());
assert!(run!("montag", word_cs("Montag")).is_none());
}
#[test]
fn phrases_span_spaces_and_punctuation() {
assert!(run!("day after tomorrow", phrase_ci("day after tomorrow")).is_some());
assert!(run!("A.M.", phrase_ci("a.m.")).is_some());
assert!(run!("day after", phrase_ci("day after tomorrow")).is_none());
assert!(run!("halfpast", phrase_ci("half past")).is_none());
}
#[test]
fn phrase_alternation_prefers_the_longer_phrase() {
let pairs = || [("a", 1i64), ("a couple of", 2), ("a few", 3)];
assert_eq!(run!("a couple of", phrases_ci(pairs())), Some(2));
assert_eq!(run!("a few", phrases_ci(pairs())), Some(3));
assert_eq!(run!("a", phrases_ci(pairs())), Some(1));
}
#[test]
fn number_widths_are_enforced() {
assert_eq!(run!("7", two_digit_number()), Some(7));
assert_eq!(run!("07", two_digit_number()), Some(7));
assert_eq!(run!("123", two_digit_number()), None);
assert_eq!(run!("2024", four_digit_number()), Some(2024));
assert_eq!(run!("204", four_digit_number()), None);
assert_eq!(run!("12345", digit_number()), Some(12345));
}
#[test]
fn iso_datetime_round_trips() {
let expected = TimeExpression::Absolute(AbsoluteTime {
year: 2024,
month: 1,
day: 15,
hour: Some(14),
minute: Some(30),
second: Some(0),
nanosecond: None,
timezone: Some(Timezone::Utc),
});
assert_eq!(run!("2024-01-15T14:30:00Z", iso_datetime()), Some(expected));
assert_eq!(
run!("2024-01-15T14:30:00-00:30", iso_datetime()),
Some(TimeExpression::Absolute(AbsoluteTime {
year: 2024,
month: 1,
day: 15,
hour: Some(14),
minute: Some(30),
second: Some(0),
nanosecond: None,
timezone: Some(Timezone::Offset { total_minutes: -30 }),
}))
);
assert!(run!("2024-02-30", iso_datetime()).is_none());
assert!(run!("2024-01-15T25:00", iso_datetime()).is_none());
}
fn day_then_optional_part<'t, 's: 't, I>()
-> impl Parser<'t, I, i64, ParserError<'t, 's>> + Clone
where
I: TokenInput<'t, 's>,
{
word_ci("tomorrow")
.ignore_then(space().ignore_then(word_ci("morning")).or_not())
.map(|morning| if morning.is_some() { 2 } else { 1 })
}
#[test]
fn left_factoring_removes_the_shadowing() {
assert_eq!(run!("tomorrow morning", day_then_optional_part()), Some(2));
assert_eq!(run!("tomorrow", day_then_optional_part()), Some(1));
}
}
}
pub mod language {
pub mod english;
pub mod german;
}
pub fn parse(input: &str, language: Language) -> Result<TimeExpression> {
match language {
Language::English => language::english::EnglishParser.parse(input),
Language::German => language::german::GermanParser.parse(input),
}
}