diff --git a/src/items/builder.rs b/src/items/builder.rs index 3543bda..23c6f5c 100644 --- a/src/items/builder.rs +++ b/src/items/builder.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, TimeZone, Timelike}; -use super::{date, relative, time, weekday}; +use super::{date, relative, time, weekday, year}; /// The builder is used to construct a DateTime object from various components. /// The parser creates a `DateTimeBuilder` object with the parsed components, @@ -40,24 +40,6 @@ impl DateTimeBuilder { 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") @@ -103,6 +85,39 @@ impl DateTimeBuilder { self } + /// Sets a pure number that can be interpreted as either a year or time + /// depending on the current state of the builder. + /// + /// If a date is already set but lacks a year, the number is interpreted as + /// a year. Otherwise, it's interpreted as a time in HHMM, HMM, HH, or H + /// format. + pub(super) fn set_pure(mut self, pure: String) -> Result { + if let Some(date) = self.date.as_mut() { + if date.year.is_none() { + date.year = Some(year::year_from_str(&pure)?); + return Ok(self); + } + } + + let (mut hour_str, mut minute_str) = match pure.len() { + 1..=2 => (pure.as_str(), "0"), + 3..=4 => pure.split_at(pure.len() - 2), + _ => { + return Err("pure number must be 1-4 digits when interpreted as time"); + } + }; + + let hour = time::hour24(&mut hour_str).map_err(|_| "invalid hour in pure number")?; + let minute = time::minute(&mut minute_str).map_err(|_| "invalid minute in pure number")?; + + let time = time::Time { + hour, + minute, + ..Default::default() + }; + self.set_time(time) + } + pub(super) fn build(self) -> Option> { let base = self.base.unwrap_or_else(|| chrono::Local::now().into()); diff --git a/src/items/mod.rs b/src/items/mod.rs index 6795800..bd6784f 100644 --- a/src/items/mod.rs +++ b/src/items/mod.rs @@ -33,6 +33,7 @@ mod combined; mod date; mod epoch; +mod pure; mod relative; mod time; mod timezone; @@ -59,13 +60,13 @@ use crate::ParseDateTimeError; #[derive(PartialEq, Debug)] pub(crate) enum Item { Timestamp(f64), - Year(u32), DateTime(combined::DateTime), Date(date::Date), Time(time::Time), Weekday(weekday::Weekday), Relative(relative::Relative), TimeZone(time::Offset), + Pure(String), } /// Build a `DateTime` from a `DateTimeBuilder` and a base date. @@ -97,7 +98,7 @@ pub(crate) fn at_local( /// timestamp = "@" , float ; /// /// items = item , { item } ; -/// item = datetime | date | time | relative | weekday | timezone | year ; +/// item = datetime | date | time | relative | weekday | timezone | pure ; /// /// datetime = date , [ "t" | whitespace ] , iso_time ; /// @@ -178,6 +179,8 @@ pub(crate) fn at_local( /// /// timezone = named_zone , [ time_offset ] ; /// +/// pure = { digit } +/// /// optional_whitespace = { whitespace } ; /// ``` pub(crate) fn parse(input: &mut &str) -> ModalResult { @@ -217,9 +220,6 @@ fn parse_items(input: &mut &str) -> ModalResult { .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) @@ -246,6 +246,9 @@ fn parse_items(input: &mut &str) -> ModalResult { Item::Relative(rel) => { builder = builder.push_relative(rel); } + Item::Pure(pure) => { + builder = builder.set_pure(pure).map_err(|e| expect_error(input, e))?; + } }, Err(ErrMode::Backtrack(_)) => break, Err(e) => return Err(e), @@ -271,7 +274,7 @@ fn parse_item(input: &mut &str) -> ModalResult { relative::parse.map(Item::Relative), weekday::parse.map(Item::Weekday), timezone::parse.map(Item::TimeZone), - year::parse.map(Item::Year), + pure::parse.map(Item::Pure), )), ) .parse_next(input) @@ -290,7 +293,8 @@ fn expect_error(input: &mut &str, reason: &'static str) -> ErrMode mod tests { use super::{at_date, parse, DateTimeBuilder}; use chrono::{ - DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Timelike, Utc, + DateTime, Datelike, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Timelike, + Utc, }; fn at_utc(builder: DateTimeBuilder) -> DateTime { @@ -386,13 +390,6 @@ mod tests { .to_string() .contains("time cannot appear more than once")); - let result = parse(&mut "2025-05-19 2024"); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("year cannot appear more than once")); - let result = parse(&mut "2025-05-19 +00:00 +01:00"); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("unexpected input")); @@ -415,6 +412,46 @@ mod tests { let result = parse(&mut "2025-05-19 @1690466034"); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("unexpected input")); + + // Pure number as year (too large). + let result = parse(&mut "jul 18 12:30 10000"); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("year must be no greater than 9999")); + + // Pure number as time (too long). + let result = parse(&mut "01:02 12345"); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("pure number must be 1-4 digits when interpreted as time")); + + // Pure number as time (repeated time). + let result = parse(&mut "01:02 1234"); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("time cannot appear more than once")); + + // Pure number as time (invalid hour). + let result = parse(&mut "jul 18 2025 2400"); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("invalid hour in pure number")); + + // Pure number as time (invalid minute). + let result = parse(&mut "jul 18 2025 2360"); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("invalid minute in pure number")); } #[test] @@ -498,4 +535,30 @@ mod tests { assert_eq!(result.minute(), 0); assert_eq!(result.second(), 0); } + + #[test] + fn pure() { + let now = Utc::now().fixed_offset(); + + // Pure number as year. + let result = at_date(parse(&mut "jul 18 12:30 2025").unwrap(), now).unwrap(); + assert_eq!(result.year(), 2025); + + // Pure number as time. + let result = at_date(parse(&mut "1230").unwrap(), now).unwrap(); + assert_eq!(result.hour(), 12); + assert_eq!(result.minute(), 30); + + let result = at_date(parse(&mut "123").unwrap(), now).unwrap(); + assert_eq!(result.hour(), 1); + assert_eq!(result.minute(), 23); + + let result = at_date(parse(&mut "12").unwrap(), now).unwrap(); + assert_eq!(result.hour(), 12); + assert_eq!(result.minute(), 0); + + let result = at_date(parse(&mut "1").unwrap(), now).unwrap(); + assert_eq!(result.hour(), 1); + assert_eq!(result.minute(), 0); + } } diff --git a/src/items/pure.rs b/src/items/pure.rs new file mode 100644 index 0000000..9efcb9c --- /dev/null +++ b/src/items/pure.rs @@ -0,0 +1,33 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Parse a pure number string. +//! +//! The GNU docs say: +//! +//! > The precise interpretation of a pure decimal number depends on the +//! > context in the date string. +//! > +//! > If the decimal number is of the form YYYYMMDD and no other calendar +//! > date item (*note Calendar date items::) appears before it in the date +//! > string, then YYYY is read as the year, MM as the month number and DD as +//! > the day of the month, for the specified calendar date. +//! > +//! > If the decimal number is of the form HHMM and no other time of day +//! > item appears before it in the date string, then HH is read as the hour +//! > of the day and MM as the minute of the hour, for the specified time of +//! > day. MM can also be omitted. +//! > +//! > If both a calendar date and a time of day appear to the left of a +//! > number in the date string, but no relative item, then the number +//! > overrides the year. + +use winnow::{stream::AsChar, token::take_while, ModalResult, Parser}; + +use super::primitive::s; + +pub(super) fn parse(input: &mut &str) -> ModalResult { + s(take_while(1.., AsChar::is_dec_digit)) + .map(|s: &str| s.to_owned()) + .parse_next(input) +} diff --git a/src/items/time.rs b/src/items/time.rs index 8cc678e..24064a0 100644 --- a/src/items/time.rs +++ b/src/items/time.rs @@ -214,7 +214,7 @@ fn colon(input: &mut &str) -> ModalResult<()> { } /// Parse a number of hours in `0..24`(preceded by whitespace) -fn hour24(input: &mut &str) -> ModalResult { +pub(super) fn hour24(input: &mut &str) -> ModalResult { s(dec_uint).verify(|x| *x < 24).parse_next(input) } @@ -224,7 +224,7 @@ fn hour12(input: &mut &str) -> ModalResult { } /// Parse a number of minutes (preceded by whitespace) -fn minute(input: &mut &str) -> ModalResult { +pub(super) fn minute(input: &mut &str) -> ModalResult { s(dec_uint).verify(|x| *x < 60).parse_next(input) } diff --git a/src/items/year.rs b/src/items/year.rs index 9d1ae3f..412779c 100644 --- a/src/items/year.rs +++ b/src/items/year.rs @@ -10,13 +10,9 @@ //! strings. For example, `"00"` is interpreted as `2000`, whereas `"0"`, //! `"000"`, or `"0000"` are interpreted as `0`. -use winnow::{error::ErrMode, stream::AsChar, token::take_while, ModalResult, Parser}; +use winnow::{stream::AsChar, token::take_while, ModalResult, Parser}; -use super::primitive::{ctx_err, s}; - -pub(super) fn parse(input: &mut &str) -> ModalResult { - year_from_str(year_str(input)?).map_err(|e| ErrMode::Cut(ctx_err(e))) -} +use super::primitive::s; // TODO: Leverage `TryFrom` trait. pub(super) fn year_from_str(year_str: &str) -> Result { @@ -56,23 +52,23 @@ pub(super) fn year_str<'a>(input: &mut &'a str) -> ModalResult<&'a str> { #[cfg(test)] mod tests { - use super::parse; + use super::year_from_str; #[test] fn test_year() { // 2-characters are converted to 19XX/20XX - assert_eq!(parse(&mut "10").unwrap(), 2010u32); - assert_eq!(parse(&mut "68").unwrap(), 2068u32); - assert_eq!(parse(&mut "69").unwrap(), 1969u32); - assert_eq!(parse(&mut "99").unwrap(), 1999u32); + assert_eq!(year_from_str("10").unwrap(), 2010u32); + assert_eq!(year_from_str("68").unwrap(), 2068u32); + assert_eq!(year_from_str("69").unwrap(), 1969u32); + assert_eq!(year_from_str("99").unwrap(), 1999u32); // 3,4-characters are converted verbatim - assert_eq!(parse(&mut "468").unwrap(), 468u32); - assert_eq!(parse(&mut "469").unwrap(), 469u32); - assert_eq!(parse(&mut "1568").unwrap(), 1568u32); - assert_eq!(parse(&mut "1569").unwrap(), 1569u32); + assert_eq!(year_from_str("468").unwrap(), 468u32); + assert_eq!(year_from_str("469").unwrap(), 469u32); + assert_eq!(year_from_str("1568").unwrap(), 1568u32); + assert_eq!(year_from_str("1569").unwrap(), 1569u32); // years greater than 9999 are not accepted - assert!(parse(&mut "10000").is_err()); + assert!(year_from_str("10000").is_err()); } }