diff --git a/src/items/builder.rs b/src/items/builder.rs new file mode 100644 index 0000000..c6860ad --- /dev/null +++ b/src/items/builder.rs @@ -0,0 +1,303 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, TimeZone, Timelike}; + +use super::{date, relative, time, weekday}; + +/// The builder is used to construct a DateTime object from various components. +/// The parser creates a `DateTimeBuilder` object with the parsed components, +/// but without the baseline date and time. So you normally need to set the base +/// date and time using the `set_base()` method before calling `build()`, or +/// leave it unset to use the current date and time as the base. +#[derive(Debug, Default)] +pub struct DateTimeBuilder { + base: Option>, + timestamp: Option, + date: Option, + time: Option, + weekday: Option, + timezone: Option, + relative: Vec, +} + +impl DateTimeBuilder { + pub(super) fn new() -> Self { + Self::default() + } + + /// Sets the base date and time for the builder. If not set, the current + /// date and time will be used. + pub(super) fn set_base(mut self, base: DateTime) -> Self { + self.base = Some(base); + self + } + + pub(super) fn set_timestamp(mut self, ts: i32) -> Result { + if self.timestamp.is_some() { + Err("timestamp cannot appear more than once") + } else { + self.timestamp = Some(ts); + Ok(self) + } + } + + pub(super) fn set_year(mut self, year: u32) -> Result { + if let Some(date) = self.date.as_mut() { + if date.year.is_some() { + Err("year cannot appear more than once") + } else { + date.year = Some(year); + Ok(self) + } + } else { + self.date = Some(date::Date { + day: 1, + month: 1, + year: Some(year), + }); + Ok(self) + } + } + + pub(super) fn set_date(mut self, date: date::Date) -> Result { + if self.date.is_some() || self.timestamp.is_some() { + Err("date cannot appear more than once") + } else { + self.date = Some(date); + Ok(self) + } + } + + pub(super) fn set_time(mut self, time: time::Time) -> Result { + if self.time.is_some() || self.timestamp.is_some() { + Err("time cannot appear more than once") + } else if self.timezone.is_some() && time.offset.is_some() { + Err("time offset and timezone are mutually exclusive") + } else { + self.time = Some(time); + Ok(self) + } + } + + pub(super) fn set_weekday(mut self, weekday: weekday::Weekday) -> Result { + if self.weekday.is_some() { + Err("weekday cannot appear more than once") + } else { + self.weekday = Some(weekday); + Ok(self) + } + } + + pub(super) fn set_timezone(mut self, timezone: time::Offset) -> Result { + if self.timezone.is_some() { + Err("timezone cannot appear more than once") + } else if self.time.as_ref().and_then(|t| t.offset.as_ref()).is_some() { + Err("time offset and timezone are mutually exclusive") + } else { + self.timezone = Some(timezone); + Ok(self) + } + } + + pub(super) fn push_relative(mut self, relative: relative::Relative) -> Self { + self.relative.push(relative); + self + } + + pub(super) fn build(self) -> Option> { + let base = self.base.unwrap_or_else(|| chrono::Local::now().into()); + let mut dt = new_date( + base.year(), + base.month(), + base.day(), + 0, + 0, + 0, + 0, + *base.offset(), + )?; + + if let Some(ts) = self.timestamp { + dt = chrono::Utc + .timestamp_opt(ts.into(), 0) + .unwrap() + .with_timezone(&dt.timezone()); + } + + if let Some(date::Date { year, month, day }) = self.date { + dt = new_date( + year.map(|x| x as i32).unwrap_or(dt.year()), + month, + day, + dt.hour(), + dt.minute(), + dt.second(), + dt.nanosecond(), + *dt.offset(), + )?; + } + + if let Some(time::Time { + hour, + minute, + second, + ref offset, + }) = self.time + { + let offset = offset + .clone() + .and_then(|o| chrono::FixedOffset::try_from(o).ok()) + .unwrap_or(*dt.offset()); + + dt = new_date( + dt.year(), + dt.month(), + dt.day(), + hour, + minute, + second as u32, + (second.fract() * 10f64.powi(9)).round() as u32, + offset, + )?; + } + + if let Some(weekday::Weekday { offset, day }) = self.weekday { + if self.time.is_none() { + dt = new_date(dt.year(), dt.month(), dt.day(), 0, 0, 0, 0, *dt.offset())?; + } + + let mut offset = offset; + let day = day.into(); + + // If the current day is not the target day, we need to adjust + // the x value to ensure we find the correct day. + // + // Consider this: + // Assuming today is Monday, next Friday is actually THIS Friday; + // but next Monday is indeed NEXT Monday. + if dt.weekday() != day && offset > 0 { + offset -= 1; + } + + // Calculate the delta to the target day. + // + // Assuming today is Thursday, here are some examples: + // + // Example 1: last Thursday (x = -1, day = Thursday) + // delta = (3 - 3) % 7 + (-1) * 7 = -7 + // + // Example 2: last Monday (x = -1, day = Monday) + // delta = (0 - 3) % 7 + (-1) * 7 = -3 + // + // Example 3: next Monday (x = 1, day = Monday) + // delta = (0 - 3) % 7 + (0) * 7 = 4 + // (Note that we have adjusted the x value above) + // + // Example 4: next Thursday (x = 1, day = Thursday) + // delta = (3 - 3) % 7 + (1) * 7 = 7 + let delta = (day.num_days_from_monday() as i32 + - dt.weekday().num_days_from_monday() as i32) + .rem_euclid(7) + + offset.checked_mul(7)?; + + dt = if delta < 0 { + dt.checked_sub_days(chrono::Days::new((-delta) as u64))? + } else { + dt.checked_add_days(chrono::Days::new(delta as u64))? + } + } + + for rel in self.relative { + if self.timestamp.is_none() + && self.date.is_none() + && self.time.is_none() + && self.weekday.is_none() + { + dt = base; + } + + match rel { + relative::Relative::Years(x) => { + dt = dt.with_year(dt.year() + x)?; + } + relative::Relative::Months(x) => { + // *NOTE* This is done in this way to conform to + // GNU behavior. + let days = last_day_of_month(dt.year(), dt.month()); + if x >= 0 { + dt += dt + .date_naive() + .checked_add_days(chrono::Days::new((days * x as u32) as u64))? + .signed_duration_since(dt.date_naive()); + } else { + dt += dt + .date_naive() + .checked_sub_days(chrono::Days::new((days * -x as u32) as u64))? + .signed_duration_since(dt.date_naive()); + } + } + relative::Relative::Days(x) => dt += chrono::Duration::days(x.into()), + relative::Relative::Hours(x) => dt += chrono::Duration::hours(x.into()), + relative::Relative::Minutes(x) => { + dt += chrono::Duration::try_minutes(x.into())?; + } + // Seconds are special because they can be given as a float + relative::Relative::Seconds(x) => { + dt += chrono::Duration::try_seconds(x as i64)?; + } + } + } + + if let Some(offset) = self.timezone { + dt = with_timezone_restore(offset, dt)?; + } + + Some(dt) + } +} + +#[allow(clippy::too_many_arguments)] +fn new_date( + year: i32, + month: u32, + day: u32, + hour: u32, + minute: u32, + second: u32, + nano: u32, + offset: FixedOffset, +) -> Option> { + let newdate = NaiveDate::from_ymd_opt(year, month, day) + .and_then(|naive| naive.and_hms_nano_opt(hour, minute, second, nano))?; + + Some(DateTime::::from_local(newdate, offset)) +} + +/// Restores year, month, day, etc after applying the timezone +/// returns None if timezone overflows the date +fn with_timezone_restore( + offset: time::Offset, + at: DateTime, +) -> Option> { + let offset: FixedOffset = chrono::FixedOffset::try_from(offset).ok()?; + let copy = at; + let x = at + .with_timezone(&offset) + .with_day(copy.day())? + .with_month(copy.month())? + .with_year(copy.year())? + .with_hour(copy.hour())? + .with_minute(copy.minute())? + .with_second(copy.second())? + .with_nanosecond(copy.nanosecond())?; + Some(x) +} + +fn last_day_of_month(year: i32, month: u32) -> u32 { + NaiveDate::from_ymd_opt(year, month + 1, 1) + .unwrap_or(NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap()) + .pred_opt() + .unwrap() + .day() +} diff --git a/src/items/mod.rs b/src/items/mod.rs index b79910f..8b483de 100644 --- a/src/items/mod.rs +++ b/src/items/mod.rs @@ -20,24 +20,29 @@ //! We put all of those in separate modules: //! - [`date`] //! - [`time`] -//! - [`time_zone`] +//! - [`timezone`] //! - [`combined`] //! - [`weekday`] //! - [`relative`] -//! - [`number] #![allow(deprecated)] + +// date and time items mod combined; mod date; mod epoch; -mod ordinal; -mod primitive; mod relative; mod time; mod timezone; mod weekday; -use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, TimeZone, Timelike}; +// utility modules +mod builder; +mod ordinal; +mod primitive; + +use builder::DateTimeBuilder; +use chrono::{DateTime, FixedOffset}; use primitive::space; use winnow::{ combinator::{alt, trace}, @@ -60,7 +65,9 @@ pub enum Item { TimeZone(time::Offset), } -// Parse an item +/// Parse an item. +/// TODO: timestamp values are exclusive with other items. See +/// https://github.com/uutils/parse_datetime/issues/156 pub fn parse_one(input: &mut &str) -> ModalResult { trace( "parse_one", @@ -86,77 +93,47 @@ fn expect_error(input: &mut &str, reason: &'static str) -> ErrMode ) } -pub fn parse(input: &mut &str) -> ModalResult> { - let mut items = Vec::new(); - let mut date_seen = false; - let mut time_seen = false; - let mut year_seen = false; - let mut tz_seen = false; +pub fn parse(input: &mut &str) -> ModalResult { + let mut builder = DateTimeBuilder::new(); loop { match parse_one.parse_next(input) { - Ok(item) => { - match item { - Item::DateTime(ref dt) => { - if date_seen || time_seen { - return Err(expect_error( - input, - "date or time cannot appear more than once", - )); - } - - date_seen = true; - time_seen = true; - if dt.date.year.is_some() { - year_seen = true; - } - } - Item::Date(ref d) => { - if date_seen { - return Err(expect_error(input, "date cannot appear more than once")); - } - - date_seen = true; - if d.year.is_some() { - year_seen = true; - } - } - Item::Time(ref t) => { - if time_seen { - return Err(expect_error(input, "time cannot appear more than once")); - } - - if t.offset.is_some() { - if tz_seen { - return Err(expect_error( - input, - "timezone cannot appear more than once", - )); - } - tz_seen = true; - } - - time_seen = true; - } - Item::Year(_) => { - if year_seen { - return Err(expect_error(input, "year cannot appear more than once")); - } - year_seen = true; - } - Item::TimeZone(_) => { - if tz_seen { - return Err(expect_error( - input, - "timezone cannot appear more than once", - )); - } - tz_seen = true; - } - _ => {} + Ok(item) => match item { + Item::Timestamp(ts) => { + builder = builder + .set_timestamp(ts) + .map_err(|e| expect_error(input, e))?; + } + Item::Year(year) => { + builder = builder.set_year(year).map_err(|e| expect_error(input, e))?; + } + Item::DateTime(dt) => { + builder = builder + .set_date(dt.date) + .map_err(|e| expect_error(input, e))? + .set_time(dt.time) + .map_err(|e| expect_error(input, e))?; + } + Item::Date(d) => { + builder = builder.set_date(d).map_err(|e| expect_error(input, e))?; } - items.push(item); - } + Item::Time(t) => { + builder = builder.set_time(t).map_err(|e| expect_error(input, e))?; + } + Item::Weekday(weekday) => { + builder = builder + .set_weekday(weekday) + .map_err(|e| expect_error(input, e))?; + } + Item::TimeZone(tz) => { + builder = builder + .set_timezone(tz) + .map_err(|e| expect_error(input, e))?; + } + Item::Relative(rel) => { + builder = builder.push_relative(rel); + } + }, Err(ErrMode::Backtrack(_)) => break, Err(e) => return Err(e), } @@ -167,256 +144,34 @@ pub fn parse(input: &mut &str) -> ModalResult> { return Err(expect_error(input, "unexpected input")); } - Ok(items) -} - -#[allow(clippy::too_many_arguments)] -fn new_date( - year: i32, - month: u32, - day: u32, - hour: u32, - minute: u32, - second: u32, - nano: u32, - offset: FixedOffset, -) -> Option> { - let newdate = NaiveDate::from_ymd_opt(year, month, day) - .and_then(|naive| naive.and_hms_nano_opt(hour, minute, second, nano))?; - - Some(DateTime::::from_local(newdate, offset)) -} - -/// Restores year, month, day, etc after applying the timezone -/// returns None if timezone overflows the date -fn with_timezone_restore( - offset: time::Offset, - at: DateTime, -) -> Option> { - let offset: FixedOffset = chrono::FixedOffset::try_from(offset).ok()?; - let copy = at; - let x = at - .with_timezone(&offset) - .with_day(copy.day())? - .with_month(copy.month())? - .with_year(copy.year())? - .with_hour(copy.hour())? - .with_minute(copy.minute())? - .with_second(copy.second())? - .with_nanosecond(copy.nanosecond())?; - Some(x) -} - -fn last_day_of_month(year: i32, month: u32) -> u32 { - NaiveDate::from_ymd_opt(year, month + 1, 1) - .unwrap_or(NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap()) - .pred_opt() - .unwrap() - .day() -} - -fn at_date_inner(date: Vec, at: DateTime) -> Option> { - let mut d = at - .with_hour(0) - .unwrap() - .with_minute(0) - .unwrap() - .with_second(0) - .unwrap() - .with_nanosecond(0) - .unwrap(); - - // This flag is used by relative items to determine which date/time to use. - // If any date/time item is set, it will use that; otherwise, it will use - // the `at` value. - let date_time_set = date.iter().any(|item| { - matches!( - item, - Item::Timestamp(_) - | Item::Date(_) - | Item::DateTime(_) - | Item::Year(_) - | Item::Time(_) - | Item::Weekday(_) - ) - }); - - for item in date { - match item { - Item::Timestamp(ts) => { - d = chrono::Utc - .timestamp_opt(ts.into(), 0) - .unwrap() - .with_timezone(&d.timezone()) - } - Item::Date(date::Date { day, month, year }) => { - d = new_date( - year.map(|x| x as i32).unwrap_or(d.year()), - month, - day, - d.hour(), - d.minute(), - d.second(), - d.nanosecond(), - *d.offset(), - )?; - } - Item::DateTime(combined::DateTime { - date: date::Date { day, month, year }, - time: - time::Time { - hour, - minute, - second, - offset, - }, - .. - }) => { - let offset = offset - .and_then(|o| chrono::FixedOffset::try_from(o).ok()) - .unwrap_or(*d.offset()); - - d = new_date( - year.map(|x| x as i32).unwrap_or(d.year()), - month, - day, - hour, - minute, - second as u32, - (second.fract() * 10f64.powi(9)).round() as u32, - offset, - )?; - } - Item::Year(year) => d = d.with_year(year as i32).unwrap_or(d), - Item::Time(time::Time { - hour, - minute, - second, - offset, - }) => { - let offset = offset - .and_then(|o| chrono::FixedOffset::try_from(o).ok()) - .unwrap_or(*d.offset()); - - d = new_date( - d.year(), - d.month(), - d.day(), - hour, - minute, - second as u32, - (second.fract() * 10f64.powi(9)).round() as u32, - offset, - )?; - } - Item::Weekday(weekday::Weekday { offset: x, day }) => { - let mut x = x; - let day = day.into(); - - // If the current day is not the target day, we need to adjust - // the x value to ensure we find the correct day. - // - // Consider this: - // Assuming today is Monday, next Friday is actually THIS Friday; - // but next Monday is indeed NEXT Monday. - if d.weekday() != day && x > 0 { - x -= 1; - } - - // Calculate the delta to the target day. - // - // Assuming today is Thursday, here are some examples: - // - // Example 1: last Thursday (x = -1, day = Thursday) - // delta = (3 - 3) % 7 + (-1) * 7 = -7 - // - // Example 2: last Monday (x = -1, day = Monday) - // delta = (0 - 3) % 7 + (-1) * 7 = -3 - // - // Example 3: next Monday (x = 1, day = Monday) - // delta = (0 - 3) % 7 + (0) * 7 = 4 - // (Note that we have adjusted the x value above) - // - // Example 4: next Thursday (x = 1, day = Thursday) - // delta = (3 - 3) % 7 + (1) * 7 = 7 - let delta = (day.num_days_from_monday() as i32 - - d.weekday().num_days_from_monday() as i32) - .rem_euclid(7) - + x.checked_mul(7)?; - - d = if delta < 0 { - d.checked_sub_days(chrono::Days::new((-delta) as u64))? - } else { - d.checked_add_days(chrono::Days::new(delta as u64))? - } - } - Item::Relative(rel) => { - // If date and/or time is set, use the set value; otherwise, use - // the reference value. - if !date_time_set { - d = at; - } - - match rel { - relative::Relative::Years(x) => { - d = d.with_year(d.year() + x)?; - } - relative::Relative::Months(x) => { - // *NOTE* This is done in this way to conform to - // GNU behavior. - let days = last_day_of_month(d.year(), d.month()); - if x >= 0 { - d += d - .date_naive() - .checked_add_days(chrono::Days::new((days * x as u32) as u64))? - .signed_duration_since(d.date_naive()); - } else { - d += d - .date_naive() - .checked_sub_days(chrono::Days::new((days * -x as u32) as u64))? - .signed_duration_since(d.date_naive()); - } - } - relative::Relative::Days(x) => d += chrono::Duration::days(x.into()), - relative::Relative::Hours(x) => d += chrono::Duration::hours(x.into()), - relative::Relative::Minutes(x) => { - d += chrono::Duration::try_minutes(x.into())?; - } - // Seconds are special because they can be given as a float - relative::Relative::Seconds(x) => { - d += chrono::Duration::try_seconds(x as i64)?; - } - } - } - Item::TimeZone(offset) => { - d = with_timezone_restore(offset, d)?; - } - } - } - - Some(d) + Ok(builder) } pub(crate) fn at_date( - date: Vec, - at: DateTime, + builder: DateTimeBuilder, + base: DateTime, ) -> Result, ParseDateTimeError> { - at_date_inner(date, at).ok_or(ParseDateTimeError::InvalidInput) + builder + .set_base(base) + .build() + .ok_or(ParseDateTimeError::InvalidInput) } -pub(crate) fn at_local(date: Vec) -> Result, ParseDateTimeError> { - at_date(date, chrono::Local::now().into()) +pub(crate) fn at_local( + builder: DateTimeBuilder, +) -> Result, ParseDateTimeError> { + builder.build().ok_or(ParseDateTimeError::InvalidInput) } #[cfg(test)] mod tests { - use super::{at_date, date::Date, parse, time::Time, Item}; + use super::{at_date, parse, DateTimeBuilder}; use chrono::{ DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Timelike, Utc, }; - fn at_utc(date: Vec) -> DateTime { - at_date(date, Utc::now().fixed_offset()).unwrap() + fn at_utc(builder: DateTimeBuilder) -> DateTime { + at_date(builder, Utc::now().fixed_offset()).unwrap() } fn test_eq_fmt(fmt: &str, input: &str) -> String { @@ -432,20 +187,8 @@ mod tests { #[test] fn date_and_time() { assert_eq!( - parse(&mut " 10:10 2022-12-12 "), - Ok(vec![ - Item::Time(Time { - hour: 10, - minute: 10, - second: 0.0, - offset: None, - }), - Item::Date(Date { - day: 12, - month: 12, - year: Some(2022) - }) - ]) + "2022-12-12", + test_eq_fmt("%Y-%m-%d", " 10:10 2022-12-12 ") ); // format, expected output, input @@ -504,7 +247,7 @@ mod tests { assert!(result .unwrap_err() .to_string() - .contains("date or time cannot appear more than once")); + .contains("date cannot appear more than once")); let result = parse(&mut "2025-05-19 2024-05-20"); assert!(result.is_err()); @@ -539,7 +282,7 @@ mod tests { assert!(result .unwrap_err() .to_string() - .contains("timezone cannot appear more than once")); + .contains("time offset and timezone are mutually exclusive")); let result = parse(&mut "2025-05-19 abcdef"); assert!(result.is_err()); diff --git a/src/items/weekday.rs b/src/items/weekday.rs index 7a64749..d9f05db 100644 --- a/src/items/weekday.rs +++ b/src/items/weekday.rs @@ -29,7 +29,7 @@ use winnow::{ use super::{ordinal::ordinal, primitive::s}; -#[derive(PartialEq, Eq, Debug)] +#[derive(PartialEq, Eq, Debug, Copy, Clone)] pub(crate) enum Day { Monday, Tuesday,