pub mod error;
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),
}
#[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 {
hours: i8,
minutes: u8,
},
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
pub enum DayReference {
Today,
Yesterday,
Tomorrow,
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,
}
#[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_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(hours: i8, minutes: u8) -> String {
format!("Invalid timezone offset: {hours}:{minutes}")
}
}
pub mod time_utils {
use crate::{
Meridiem, Timezone, WeekdayModifier,
constants::{SECONDS_PER_HOUR, 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(hours: i8, minutes: u8) -> i32 {
let hour_seconds = i32::from(hours).saturating_mul(SECONDS_PER_HOUR);
let minute_seconds = i32::from(minutes).saturating_mul(SECONDS_PER_MINUTE);
let minute_seconds = if hours < 0 {
-minute_seconds
} else {
minute_seconds
};
hour_seconds.saturating_add(minute_seconds)
}
#[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 { hours, minutes } => {
minutes <= 59
&& match hours {
-12 => minutes == 0,
-11..=13 => true,
14 => minutes == 0,
_ => false,
}
}
}
}
#[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::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 chumsky::{error::Rich, extra, prelude::*, text};
pub type ParserError<'a> = extra::Err<Rich<'a, char>>;
pub fn keyword_ci<'a>(
target: &'static str,
) -> impl Parser<'a, &'a str, (), ParserError<'a>> + Clone {
let mut chars = target.chars();
let first = chars.next().expect("keyword must be non-empty");
let mut parser: chumsky::Boxed<'a, 'a, &'a str, (), ParserError<'a>> =
char_ci(first).ignored().boxed();
for c in chars {
parser = parser.then_ignore(char_ci(c)).boxed();
}
parser.labelled(target)
}
fn char_ci<'a>(target: char) -> chumsky::Boxed<'a, 'a, &'a str, char, ParserError<'a>> {
let lower = target.to_ascii_lowercase();
let upper = target.to_ascii_uppercase();
if lower == upper {
just(target).boxed()
} else {
one_of([lower, upper]).boxed()
}
}
fn ascii_digit<'a>() -> impl Parser<'a, &'a str, char, ParserError<'a>> + Clone {
one_of('0'..='9').labelled("digit")
}
pub fn digit_number<'a>() -> impl Parser<'a, &'a str, i64, ParserError<'a>> + Clone {
ascii_digit()
.repeated()
.at_least(1)
.to_slice()
.try_map(|s: &str, span| {
s.parse::<i64>()
.map_err(|e| Rich::custom(span, e.to_string()))
})
.labelled("number")
}
pub fn two_digit_number<'a>() -> impl Parser<'a, &'a str, u8, ParserError<'a>> + Clone {
ascii_digit()
.repeated()
.at_least(1)
.at_most(2)
.to_slice()
.try_map(|s: &str, span| {
s.parse::<u8>()
.map_err(|e| Rich::custom(span, e.to_string()))
})
}
pub fn four_digit_number<'a>() -> impl Parser<'a, &'a str, u16, ParserError<'a>> + Clone {
ascii_digit()
.repeated()
.exactly(4)
.to_slice()
.try_map(|s: &str, span| {
s.parse::<u16>()
.map_err(|e| Rich::custom(span, e.to_string()))
})
.labelled("4-digit year")
}
fn offset_timezone<'a>() -> impl Parser<'a, &'a str, Timezone, ParserError<'a>> + Clone {
one_of(['+', '-'])
.then(two_digit_number())
.then(just(':').ignore_then(two_digit_number()).or_not())
.try_map(|((sign, hours), minutes), span| {
let minutes = minutes.unwrap_or(0);
let hours = i8::try_from(hours)
.map_err(|_| Rich::custom(span, "timezone hour offset out of range"))?;
let signed_hours = if sign == '+' { hours } else { -hours };
let offset = Timezone::Offset {
hours: signed_hours,
minutes,
};
let can_represent = !(sign == '-' && hours == 0 && minutes > 0);
if can_represent && time_utils::is_valid_timezone_offset(offset) {
Ok(offset)
} else {
Err(Rich::custom(span, "invalid timezone offset"))
}
})
}
fn timezone<'a>() -> impl Parser<'a, &'a str, Timezone, ParserError<'a>> + Clone {
choice((just('Z').to(Timezone::Utc), offset_timezone()))
}
fn fractional_seconds<'a>() -> impl Parser<'a, &'a str, u32, ParserError<'a>> + Clone {
text::digits(10).to_slice().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<'a>() -> impl Parser<'a, &'a str, TimeExpression, ParserError<'a>> + Clone {
let date = four_digit_number()
.then_ignore(just('-'))
.then(two_digit_number())
.then_ignore(just('-'))
.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 time = one_of(['T', ' '])
.ignore_then(two_digit_number())
.then_ignore(just(':'))
.then(two_digit_number())
.then(
just(':')
.ignore_then(two_digit_number())
.then(just('.').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,
}),
})
}
}
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),
}
}