From 0c0a054f7082061b05a919ea1e938a24080bb8c4 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Mon, 17 Feb 2025 14:51:34 +0100 Subject: [PATCH 01/89] README.md: fix incorrect sentence --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8895f43..5a0e0df 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ A Rust crate for parsing human-readable relative time strings and human-readable ## Usage -Add this to your `Cargo.toml`: +Add `parse_datetime` to your `Cargo.toml` with: ``` cargo add parse_datetime From 25825df8b7200a784aad1c48ce2c350e287fcb6c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 26 Feb 2025 11:56:48 +0000 Subject: [PATCH 02/89] Update Rust crate chrono to v0.4.40 --- Cargo.lock | 12 +++++++++--- fuzz/Cargo.lock | 14 ++++++++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ec5e584..46af8c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,14 +52,14 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.39" +version = "0.4.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" dependencies = [ "android-tzdata", "iana-time-zone", "num-traits", - "windows-targets", + "windows-link", ] [[package]] @@ -279,6 +279,12 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-link" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" + [[package]] name = "windows-targets" version = "0.52.5" diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index f1ad88b..00a62a6 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -63,14 +63,14 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.39" +version = "0.4.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" dependencies = [ "android-tzdata", "iana-time-zone", "num-traits", - "windows-targets", + "windows-link", ] [[package]] @@ -184,7 +184,7 @@ checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" [[package]] name = "parse_datetime" -version = "0.7.0" +version = "0.8.0" dependencies = [ "chrono", "nom", @@ -325,6 +325,12 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-link" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" + [[package]] name = "windows-targets" version = "0.52.6" From e7ed14e8a4175e6c1275336ee8cc99ca2ee25c90 Mon Sep 17 00:00:00 2001 From: Dan Hipschman <48698358+dan-hipschman@users.noreply.github.com> Date: Tue, 22 Apr 2025 00:44:09 -0700 Subject: [PATCH 03/89] Allow spaces between +/- and number (#129) * Allow spaces between +/- and number * Remove space to make fmt job pass --------- Co-authored-by: Daniel Hofstetter --- src/parse_relative_time.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/parse_relative_time.rs b/src/parse_relative_time.rs index 689af5b..782949b 100644 --- a/src/parse_relative_time.rs +++ b/src/parse_relative_time.rs @@ -59,7 +59,7 @@ pub fn parse_relative_time_at_date( } let time_pattern: Regex = Regex::new( r"(?x) - (?:(?P[-+]?\d*)\s*)? + (?:(?P[-+]?\s*\d*)\s*)? (\s*(?Pnext|this|last)?\s*)? (?Pyears?|months?|fortnights?|weeks?|days?|hours?|h|minutes?|mins?|m|seconds?|secs?|s|yesterday|tomorrow|now|today) (\s*(?Pand|,)?\s*)? @@ -73,10 +73,13 @@ pub fn parse_relative_time_at_date( for capture in time_pattern.captures_iter(s) { captures_processed += 1; - let value_str = capture + let value_str: String = capture .name("value") .ok_or(ParseDateTimeError::InvalidInput)? - .as_str(); + .as_str() + .chars() + .filter(|c| !c.is_whitespace()) // Remove potential space between +/- and number + .collect(); let value = if value_str.is_empty() { 1 } else { @@ -510,6 +513,19 @@ mod tests { ); } + #[test] + fn test_spaces() { + let now = Utc::now(); + assert_eq!( + parse_relative_time_at_date(now, "+ 1 hour").unwrap(), + now.checked_add_signed(Duration::hours(1)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "- 1 hour").unwrap(), + now.checked_sub_signed(Duration::hours(1)).unwrap() + ); + } + #[test] fn test_invalid_input() { let result = parse_duration("foobar"); From 46a82b70b3d20b62d590a1f375cc8b34d57d95c8 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Tue, 22 Apr 2025 09:52:19 +0200 Subject: [PATCH 04/89] Rename TWELVEHOUR to TWELVE_HOUR --- src/parse_time_only_str.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/parse_time_only_str.rs b/src/parse_time_only_str.rs index 119c2e6..f531d31 100644 --- a/src/parse_time_only_str.rs +++ b/src/parse_time_only_str.rs @@ -4,7 +4,7 @@ use regex::Regex; mod time_only_formats { pub const HH_MM: &str = "%R"; pub const HH_MM_SS: &str = "%T"; - pub const TWELVEHOUR: &str = "%r"; + pub const TWELVE_HOUR: &str = "%r"; } /// Convert a military time zone string to a time zone offset. @@ -55,7 +55,7 @@ fn parse_time_with_offset_multi( for fmt in [ time_only_formats::HH_MM, time_only_formats::HH_MM_SS, - time_only_formats::TWELVEHOUR, + time_only_formats::TWELVE_HOUR, ] { let parsed = match NaiveTime::parse_from_str(s, fmt) { Ok(t) => t, From 9fc966308ed8f25547377fc366697b410ae86e61 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Tue, 22 Apr 2025 10:12:20 +0200 Subject: [PATCH 05/89] clippy: remove unnecessary semicolon --- src/parse_time_only_str.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parse_time_only_str.rs b/src/parse_time_only_str.rs index f531d31..9fe9c1b 100644 --- a/src/parse_time_only_str.rs +++ b/src/parse_time_only_str.rs @@ -84,7 +84,7 @@ pub(crate) fn parse_time_only(date: DateTime, s: &str) -> Option().unwrap() * 60; } _ => (), - }; + } offset_in_sec *= if &captures["sign"] == "-" { -1 } else { 1 }; FixedOffset::east_opt(offset_in_sec) } From 1a93bb4e3e8e7912a64dbc921b3561c6bcd74609 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Tue, 22 Apr 2025 10:27:13 +0200 Subject: [PATCH 06/89] clippy: fix warning from needless_continue lint --- src/parse_time_only_str.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/parse_time_only_str.rs b/src/parse_time_only_str.rs index 9fe9c1b..9d485d0 100644 --- a/src/parse_time_only_str.rs +++ b/src/parse_time_only_str.rs @@ -62,9 +62,8 @@ fn parse_time_with_offset_multi( Err(_) => continue, }; let parsed_dt = date.date_naive().and_time(parsed); - match offset.from_local_datetime(&parsed_dt).single() { - Some(dt) => return Some(dt), - None => continue, + if let Some(dt) = offset.from_local_datetime(&parsed_dt).single() { + return Some(dt); } } None From f6b6b3e4ea2531b991d0861c154bf84a93a1a7e3 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Tue, 22 Apr 2025 10:31:02 +0200 Subject: [PATCH 07/89] clippy: fix warning from manual_let_else lint --- src/parse_time_only_str.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/parse_time_only_str.rs b/src/parse_time_only_str.rs index 9d485d0..6816953 100644 --- a/src/parse_time_only_str.rs +++ b/src/parse_time_only_str.rs @@ -57,9 +57,8 @@ fn parse_time_with_offset_multi( time_only_formats::HH_MM_SS, time_only_formats::TWELVE_HOUR, ] { - let parsed = match NaiveTime::parse_from_str(s, fmt) { - Ok(t) => t, - Err(_) => continue, + let Ok(parsed) = NaiveTime::parse_from_str(s, fmt) else { + continue; }; let parsed_dt = date.date_naive().and_time(parsed); if let Some(dt) = offset.from_local_datetime(&parsed_dt).single() { From 28b788a300e9854de9e2a4776a06ca1ebf0bc8d2 Mon Sep 17 00:00:00 2001 From: Daniel Hofstetter Date: Tue, 22 Apr 2025 10:34:39 +0200 Subject: [PATCH 08/89] clippy: fix warnings from redundant_else lint --- src/lib.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index f6148be..c94d108 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -271,9 +271,8 @@ where if let Ok(parsed) = DateTime::parse_from_str(&tmp_s[0..n], fmt) { if tmp_s == s.as_ref() { return Some((parsed, n)); - } else { - return Some((parsed, n - 1)); } + return Some((parsed, n - 1)); } } } @@ -351,9 +350,8 @@ where if let Ok(parsed) = DateTime::parse_from_str(&ts, &f) { if tmp_s == s.as_ref() { return Some((parsed, n)); - } else { - return Some((parsed, n - 1)); } + return Some((parsed, n - 1)); } } } From 65fb9140d2142c24a420878f8ee5218d916e9606 Mon Sep 17 00:00:00 2001 From: Dan Hipschman <48698358+dan-hipschman@users.noreply.github.com> Date: Mon, 21 Apr 2025 12:01:00 -0700 Subject: [PATCH 09/89] Parse relative weekdays like "next monday" --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/parse_relative_time.rs | 263 +++++++++++++++++++++++++++++++++---- 3 files changed, 239 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 46af8c9..064a8fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -144,7 +144,7 @@ checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" [[package]] name = "parse_datetime" -version = "0.8.0" +version = "0.9.0" dependencies = [ "chrono", "nom", diff --git a/Cargo.toml b/Cargo.toml index 1c75c90..751b38f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "parse_datetime" description = "parsing human-readable time strings and converting them to a DateTime" -version = "0.8.0" +version = "0.9.0" edition = "2021" license = "MIT" repository = "https://github.com/uutils/parse_datetime" diff --git a/src/parse_relative_time.rs b/src/parse_relative_time.rs index 782949b..1c193bf 100644 --- a/src/parse_relative_time.rs +++ b/src/parse_relative_time.rs @@ -1,8 +1,9 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -use crate::ParseDateTimeError; +use crate::{parse_weekday::parse_weekday, ParseDateTimeError}; use chrono::{ - DateTime, Datelike, Days, Duration, LocalResult, Months, NaiveDate, NaiveDateTime, TimeZone, + DateTime, Datelike, Days, Duration, LocalResult, Months, NaiveDate, NaiveDateTime, NaiveTime, + TimeZone, Weekday, }; use regex::Regex; @@ -61,7 +62,7 @@ pub fn parse_relative_time_at_date( r"(?x) (?:(?P[-+]?\s*\d*)\s*)? (\s*(?Pnext|this|last)?\s*)? - (?Pyears?|months?|fortnights?|weeks?|days?|hours?|h|minutes?|mins?|m|seconds?|secs?|s|yesterday|tomorrow|now|today) + (?Pyears?|months?|fortnights?|weeks?|days?|hours?|h|minutes?|mins?|m|seconds?|secs?|s|yesterday|tomorrow|now|today|(?P[a-z]{3,9}))\b (\s*(?Pand|,)?\s*)? (\s*(?Pago)?)?", )?; @@ -80,16 +81,19 @@ pub fn parse_relative_time_at_date( .chars() .filter(|c| !c.is_whitespace()) // Remove potential space between +/- and number .collect(); + let direction = capture.name("direction").map_or("", |d| d.as_str()); let value = if value_str.is_empty() { - 1 + if direction == "this" { + 0 + } else { + 1 + } } else { value_str .parse::() .map_err(|_| ParseDateTimeError::InvalidInput)? }; - let direction = capture.name("direction").map_or("", |d| d.as_str()); - if direction == "last" { is_ago = true; } @@ -103,27 +107,26 @@ pub fn parse_relative_time_at_date( is_ago = true; } - let new_datetime = if direction == "this" { - add_days(datetime, 0, is_ago) - } else { - match unit { - "years" | "year" => add_months(datetime, value * 12, is_ago), - "months" | "month" => add_months(datetime, value, is_ago), - "fortnights" | "fortnight" => add_days(datetime, value * 14, is_ago), - "weeks" | "week" => add_days(datetime, value * 7, is_ago), - "days" | "day" => add_days(datetime, value, is_ago), - "hours" | "hour" | "h" => add_duration(datetime, Duration::hours(value), is_ago), - "minutes" | "minute" | "mins" | "min" | "m" => { - add_duration(datetime, Duration::minutes(value), is_ago) - } - "seconds" | "second" | "secs" | "sec" | "s" => { - add_duration(datetime, Duration::seconds(value), is_ago) - } - "yesterday" => add_days(datetime, 1, true), - "tomorrow" => add_days(datetime, 1, false), - "now" | "today" => Some(datetime), - _ => None, + let new_datetime = match unit { + "years" | "year" => add_months(datetime, value * 12, is_ago), + "months" | "month" => add_months(datetime, value, is_ago), + "fortnights" | "fortnight" => add_days(datetime, value * 14, is_ago), + "weeks" | "week" => add_days(datetime, value * 7, is_ago), + "days" | "day" => add_days(datetime, value, is_ago), + "hours" | "hour" | "h" => add_duration(datetime, Duration::hours(value), is_ago), + "minutes" | "minute" | "mins" | "min" | "m" => { + add_duration(datetime, Duration::minutes(value), is_ago) + } + "seconds" | "second" | "secs" | "sec" | "s" => { + add_duration(datetime, Duration::seconds(value), is_ago) } + "yesterday" => add_days(datetime, 1, true), + "tomorrow" => add_days(datetime, 1, false), + "now" | "today" => Some(datetime), + _ => capture + .name("weekday") + .and_then(|weekday| parse_weekday(weekday.as_str())) + .and_then(|weekday| adjust_for_weekday(datetime, weekday, value, is_ago)), }; datetime = match new_datetime { Some(dt) => dt, @@ -148,6 +151,25 @@ pub fn parse_relative_time_at_date( } } +fn adjust_for_weekday( + mut datetime: DateTime, + weekday: Weekday, + mut amount: i64, + is_ago: bool, +) -> Option> { + let mut same_day = true; + // last/this/next truncates the time to midnight + datetime = datetime.with_time(NaiveTime::MIN).unwrap(); + while datetime.weekday() != weekday { + datetime = add_days(datetime, 1, is_ago)?; + same_day = false; + } + if !same_day && 0 < amount { + amount -= 1; + } + add_days(datetime, amount * 7, is_ago) +} + fn add_months( datetime: DateTime, months: i64, @@ -810,4 +832,193 @@ mod tests { let result = parse_relative_time_at_date(now, "invalid 1r"); assert_eq!(result, Err(ParseDateTimeError::InvalidInput)); } + + #[test] + fn test_parse_relative_time_at_date_this_weekday() { + // Jan 1 2025 is a Wed + let now = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + )); + // Check "this " + assert_eq!( + parse_relative_time_at_date(now, "this wednesday").unwrap(), + now + ); + assert_eq!(parse_relative_time_at_date(now, "this wed").unwrap(), now); + // Other days + assert_eq!( + parse_relative_time_at_date(now, "this thursday").unwrap(), + now.checked_add_days(Days::new(1)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this thur").unwrap(), + now.checked_add_days(Days::new(1)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this thu").unwrap(), + now.checked_add_days(Days::new(1)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this friday").unwrap(), + now.checked_add_days(Days::new(2)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this fri").unwrap(), + now.checked_add_days(Days::new(2)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this saturday").unwrap(), + now.checked_add_days(Days::new(3)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this sat").unwrap(), + now.checked_add_days(Days::new(3)).unwrap() + ); + // "this" with a day of the week that comes before today should return the next instance of + // that day + assert_eq!( + parse_relative_time_at_date(now, "this sunday").unwrap(), + now.checked_add_days(Days::new(4)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this sun").unwrap(), + now.checked_add_days(Days::new(4)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this monday").unwrap(), + now.checked_add_days(Days::new(5)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this mon").unwrap(), + now.checked_add_days(Days::new(5)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this tuesday").unwrap(), + now.checked_add_days(Days::new(6)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "this tue").unwrap(), + now.checked_add_days(Days::new(6)).unwrap() + ); + } + + #[test] + fn test_parse_relative_time_at_date_last_weekday() { + // Jan 1 2025 is a Wed + let now = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + )); + // Check "last " + assert_eq!( + parse_relative_time_at_date(now, "last wed").unwrap(), + now.checked_sub_days(Days::new(7)).unwrap() + ); + // Check "last " + assert_eq!( + parse_relative_time_at_date(now, "last thu").unwrap(), + now.checked_sub_days(Days::new(6)).unwrap() + ); + // Check "last " + assert_eq!( + parse_relative_time_at_date(now, "last tue").unwrap(), + now.checked_sub_days(Days::new(1)).unwrap() + ); + } + + #[test] + fn test_parse_relative_time_at_date_next_weekday() { + // Jan 1 2025 is a Wed + let now = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + )); + // Check "next " + assert_eq!( + parse_relative_time_at_date(now, "next wed").unwrap(), + now.checked_add_days(Days::new(7)).unwrap() + ); + // Check "next " + assert_eq!( + parse_relative_time_at_date(now, "next thu").unwrap(), + now.checked_add_days(Days::new(1)).unwrap() + ); + // Check "next " + assert_eq!( + parse_relative_time_at_date(now, "next tue").unwrap(), + now.checked_add_days(Days::new(6)).unwrap() + ); + } + + #[test] + fn test_parse_relative_time_at_date_number_weekday() { + // Jan 1 2025 is a Wed + let now = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + )); + assert_eq!( + parse_relative_time_at_date(now, "1 wed").unwrap(), + now.checked_add_days(Days::new(7)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "1 thu").unwrap(), + now.checked_add_days(Days::new(1)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "1 tue").unwrap(), + now.checked_add_days(Days::new(6)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "2 wed").unwrap(), + now.checked_add_days(Days::new(14)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "2 thu").unwrap(), + now.checked_add_days(Days::new(8)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "2 tue").unwrap(), + now.checked_add_days(Days::new(13)).unwrap() + ); + } + + #[test] + fn test_parse_relative_time_at_date_weekday_truncates_time() { + // Jan 1 2025 is a Wed + let now = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + NaiveTime::from_hms_opt(12, 0, 0).unwrap(), + )); + let now_midnight = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + )); + assert_eq!( + parse_relative_time_at_date(now, "this wed").unwrap(), + now_midnight + ); + assert_eq!( + parse_relative_time_at_date(now, "last wed").unwrap(), + now_midnight.checked_sub_days(Days::new(7)).unwrap() + ); + assert_eq!( + parse_relative_time_at_date(now, "next wed").unwrap(), + now_midnight.checked_add_days(Days::new(7)).unwrap() + ); + } + + #[test] + fn test_parse_relative_time_at_date_invalid_weekday() { + // Jan 1 2025 is a Wed + let now = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + )); + assert_eq!( + parse_relative_time_at_date(now, "this fooday"), + Err(ParseDateTimeError::InvalidInput) + ); + } } From 3bdafeb2cff2d3cbe7acd6144823ae631a6fcc4a Mon Sep 17 00:00:00 2001 From: Dan Hipschman <48698358+dan-hipschman@users.noreply.github.com> Date: Sat, 26 Apr 2025 07:16:29 -0700 Subject: [PATCH 10/89] Allow uppercase for words like "today", "next", etc. --- src/parse_relative_time.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/parse_relative_time.rs b/src/parse_relative_time.rs index 1c193bf..ea4a190 100644 --- a/src/parse_relative_time.rs +++ b/src/parse_relative_time.rs @@ -59,7 +59,7 @@ pub fn parse_relative_time_at_date( return Ok(datetime); } let time_pattern: Regex = Regex::new( - r"(?x) + r"(?ix) (?:(?P[-+]?\s*\d*)\s*)? (\s*(?Pnext|this|last)?\s*)? (?Pyears?|months?|fortnights?|weeks?|days?|hours?|h|minutes?|mins?|m|seconds?|secs?|s|yesterday|tomorrow|now|today|(?P[a-z]{3,9}))\b @@ -67,7 +67,7 @@ pub fn parse_relative_time_at_date( (\s*(?Pago)?)?", )?; - let mut is_ago = s.contains(" ago"); + let mut is_ago = s.to_ascii_lowercase().contains(" ago"); let mut captures_processed = 0; let mut total_length = 0; @@ -81,7 +81,10 @@ pub fn parse_relative_time_at_date( .chars() .filter(|c| !c.is_whitespace()) // Remove potential space between +/- and number .collect(); - let direction = capture.name("direction").map_or("", |d| d.as_str()); + let direction = capture + .name("direction") + .map_or("", |d| d.as_str()) + .to_ascii_lowercase(); let value = if value_str.is_empty() { if direction == "this" { 0 @@ -107,7 +110,7 @@ pub fn parse_relative_time_at_date( is_ago = true; } - let new_datetime = match unit { + let new_datetime = match unit.to_ascii_lowercase().as_str() { "years" | "year" => add_months(datetime, value * 12, is_ago), "months" | "month" => add_months(datetime, value, is_ago), "fortnights" | "fortnight" => add_days(datetime, value * 14, is_ago), @@ -1021,4 +1024,16 @@ mod tests { Err(ParseDateTimeError::InvalidInput) ); } + + #[test] + fn test_parse_relative_time_at_date_with_uppercase() { + let tests = vec!["today", "last week", "next month", "1 year ago"]; + let now = Utc::now(); + for t in tests { + assert_eq!( + parse_relative_time_at_date(now, &t.to_uppercase()).unwrap(), + parse_relative_time_at_date(now, t).unwrap(), + ); + } + } } From a38746337328bc2ed823d5688dbcec94ab38e015 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 29 Apr 2025 15:59:06 +0000 Subject: [PATCH 11/89] fix(deps): update rust crate chrono to v0.4.41 --- Cargo.lock | 4 ++-- fuzz/Cargo.lock | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 064a8fc..c4cc03d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,9 +52,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.40" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 00a62a6..6d63155 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -63,9 +63,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.40" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", @@ -184,7 +184,7 @@ checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" [[package]] name = "parse_datetime" -version = "0.8.0" +version = "0.9.0" dependencies = [ "chrono", "nom", From 07d4b80ea53747ee629a2104a9a41bea16fcbf4e Mon Sep 17 00:00:00 2001 From: Terts Diepraam Date: Fri, 26 Jan 2024 17:17:36 +0100 Subject: [PATCH 12/89] start parsing date with winnow --- Cargo.lock | 10 + Cargo.toml | 1 + src/items/combined.rs | 76 +++++++ src/items/date.rs | 232 ++++++++++++++++++++++ src/items/mod.rs | 158 +++++++++++++++ src/items/number.rs | 45 +++++ src/items/ordinal.rs | 46 +++++ src/items/relative.rs | 190 ++++++++++++++++++ src/items/time.rs | 438 +++++++++++++++++++++++++++++++++++++++++ src/items/time_zone.rs | 14 ++ src/items/weekday.rs | 124 ++++++++++++ src/lib.rs | 349 +------------------------------- 12 files changed, 1342 insertions(+), 341 deletions(-) create mode 100644 src/items/combined.rs create mode 100644 src/items/date.rs create mode 100644 src/items/mod.rs create mode 100644 src/items/number.rs create mode 100644 src/items/ordinal.rs create mode 100644 src/items/relative.rs create mode 100644 src/items/time.rs create mode 100644 src/items/time_zone.rs create mode 100644 src/items/weekday.rs diff --git a/Cargo.lock b/Cargo.lock index c4cc03d..6de1e00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -149,6 +149,7 @@ dependencies = [ "chrono", "nom", "regex", + "winnow", ] [[package]] @@ -348,3 +349,12 @@ name = "windows_x86_64_msvc" version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] diff --git a/Cargo.toml b/Cargo.toml index 751b38f..daa557f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,3 +11,4 @@ readme = "README.md" regex = "1.10.4" chrono = { version="0.4.38", default-features=false, features=["std", "alloc", "clock"] } nom = "8.0.0" +winnow = "0.5.34" diff --git a/src/items/combined.rs b/src/items/combined.rs new file mode 100644 index 0000000..241fe32 --- /dev/null +++ b/src/items/combined.rs @@ -0,0 +1,76 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Parse an ISO 8601 date and time item +//! +//! The GNU docs state: +//! +//! > The ISO 8601 date and time of day extended format consists of an ISO 8601 +//! > date, a ‘T’ character separator, and an ISO 8601 time of day. This format +//! > is also recognized if the ‘T’ is replaced by a space. +//! > +//! > In this format, the time of day should use 24-hour notation. Fractional +//! > seconds are allowed, with either comma or period preceding the fraction. +//! > ISO 8601 fractional minutes and hours are not supported. Typically, hosts +//! > support nanosecond timestamp resolution; excess precision is silently discarded. + +use winnow::{combinator::alt, seq, PResult, Parser}; + +use crate::items::space; + +use super::{ + date::{self, Date}, + s, + time::{self, Time}, +}; + +#[derive(PartialEq, Debug, Clone)] +pub struct DateTime { + date: Date, + time: Time, +} + +pub fn parse(input: &mut &str) -> PResult { + seq!(DateTime { + date: date::iso, + // Note: the `T` is lowercased by the main parse function + _: alt((s('t').void(), (' ', space).void())), + time: time::iso, + }) + .parse_next(input) +} + +#[cfg(test)] +mod tests { + use super::{parse, DateTime}; + use crate::items::{date::Date, time::Time}; + + #[test] + fn some_date() { + let reference = Some(DateTime { + date: Date { + day: 10, + month: 10, + year: Some(2022), + }, + time: Time { + hour: 10, + minute: 10, + second: 55.0, + offset: None, + }, + }); + + for mut s in [ + "2022-10-10t10:10:55", + "2022-10-10 10:10:55", + "2022-10-10 t 10:10:55", + "2022-10-10 10:10:55", + "2022-10-10 (A comment!) t 10:10:55", + "2022-10-10 (A comment!) 10:10:55", + ] { + let old_s = s.to_owned(); + assert_eq!(parse(&mut s).ok(), reference, "Failed string: {old_s}") + } + } +} diff --git a/src/items/date.rs b/src/items/date.rs new file mode 100644 index 0000000..f6f3096 --- /dev/null +++ b/src/items/date.rs @@ -0,0 +1,232 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Parse a date item (without time component) +//! +//! The GNU docs say: +//! +//! > A calendar date item specifies a day of the year. It is specified +//! > differently, depending on whether the month is specified numerically +//! > or literally. +//! > +//! > ... +//! > +//! > For numeric months, the ISO 8601 format ‘year-month-day’ is allowed, +//! > where year is any positive number, month is a number between 01 and +//! > 12, and day is a number between 01 and 31. A leading zero must be +//! > present if a number is less than ten. If year is 68 or smaller, then +//! > 2000 is added to it; otherwise, if year is less than 100, then 1900 +//! > is added to it. The construct ‘month/day/year’, popular in the United +//! > States, is accepted. Also ‘month/day’, omitting the year. +//! > +//! > Literal months may be spelled out in full: ‘January’, ‘February’, +//! > ‘March’, ‘April’, ‘May’, ‘June’, ‘July’, ‘August’, ‘September’, +//! > ‘October’, ‘November’ or ‘December’. Literal months may be +//! > abbreviated to their first three letters, possibly followed by an +//! > abbreviating dot. It is also permitted to write ‘Sept’ instead of +//! > ‘September’. + +use winnow::{ + ascii::{alpha1, dec_uint}, + combinator::{alt, opt, preceded}, + seq, + token::take, + PResult, Parser, +}; + +use super::s; +use crate::ParseDateTimeError; + +#[derive(PartialEq, Eq, Clone, Debug)] +pub struct Date { + pub day: u32, + pub month: u32, + pub year: Option, +} + +pub fn parse(input: &mut &str) -> PResult { + alt((iso, us, literal1, literal2)).parse_next(input) +} + +/// Parse `YYYY-MM-DD` or `YY-MM-DD` +/// +/// This is also used by [`combined`](super::combined). +pub fn iso(input: &mut &str) -> PResult { + seq!(Date { + year: year.map(Some), + _: s('-'), + month: month, + _: s('-'), + day: day, + }) + .parse_next(input) +} + +/// Parse `MM/DD/YYYY`, `MM/DD/YY` or `MM/DD` +fn us(input: &mut &str) -> PResult { + seq!(Date { + month: month, + _: s('/'), + day: day, + year: opt(preceded(s('/'), year)), + }) + .parse_next(input) +} + +/// Parse `14 November 2022`, `14 Nov 2022`, "14nov2022", "14-nov-2022", "14-nov2022", "14nov-2022" +fn literal1(input: &mut &str) -> PResult { + seq!(Date { + day: day, + _: opt(s('-')), + month: literal_month, + year: opt(preceded(opt(s('-')), year)), + }) + .parse_next(input) +} + +/// Parse `November 14, 2022` and `Nov 14, 2022` +fn literal2(input: &mut &str) -> PResult { + seq!(Date { + month: literal_month, + day: day, + // FIXME: GNU requires _some_ space between the day and the year, + // probably to distinguish with floats. + year: opt(preceded(s(","), year)), + }) + .parse_next(input) +} + +fn year(input: &mut &str) -> PResult { + s(alt(( + take(4usize).try_map(|x: &str| x.parse()), + take(3usize).try_map(|x: &str| x.parse()), + take(2usize).try_map(|x: &str| x.parse()).map( + |x: u32| { + if x <= 68 { + x + 2000 + } else { + x + 1900 + } + }, + ), + ))) + .parse_next(input) +} + +fn month(input: &mut &str) -> PResult { + s(dec_uint) + .try_map(|x| { + (x >= 1 && x <= 12) + .then_some(x) + .ok_or(ParseDateTimeError::InvalidInput) + }) + .parse_next(input) +} + +fn day(input: &mut &str) -> PResult { + s(dec_uint) + .try_map(|x| { + (x >= 1 && x <= 31) + .then_some(x) + .ok_or(ParseDateTimeError::InvalidInput) + }) + .parse_next(input) +} + +/// Parse the name of a month (case-insensitive) +fn literal_month(input: &mut &str) -> PResult { + s(alpha1) + .verify_map(|s: &str| { + Some(match s { + "january" | "jan" => 1, + "february" | "feb" => 2, + "march" | "mar" => 3, + "april" | "apr" => 4, + "may" => 5, + "june" | "jun" => 6, + "july" | "jul" => 7, + "august" | "aug" => 8, + "september" | "sep" | "sept" => 9, + "october" | "oct" => 10, + "november" | "nov" => 11, + "december" | "dec" => 12, + _ => return None, + }) + }) + .parse_next(input) +} + +#[cfg(test)] +mod tests { + use super::{parse, Date}; + + // Test cases from the GNU docs: + // + // ``` + // 2022-11-14 # ISO 8601. + // 22-11-14 # Assume 19xx for 69 through 99, + // # 20xx for 00 through 68 (not recommended). + // 11/14/2022 # Common U.S. writing. + // 14 November 2022 + // 14 Nov 2022 # Three-letter abbreviations always allowed. + // November 14, 2022 + // 14-nov-2022 + // 14nov2022 + // ``` + + #[test] + fn with_year() { + let reference = Date { + year: Some(2022), + month: 11, + day: 14, + }; + + for mut s in [ + "2022-11-14", + "2022 - 11 - 14", + "22-11-14", + "2022---11----14", + "22(comment 1)-11(comment 2)-14", + "11/14/2022", + "11--/14--/2022", + "11(comment 1)/(comment 2)14(comment 3)/(comment 4)2022", + "11 / 14 / 2022", + "11/14/22", + "14 november 2022", + "14 nov 2022", + "november 14, 2022", + "november 14 , 2022", + "nov 14, 2022", + "14-nov-2022", + "14nov2022", + "14nov 2022", + ] { + let old_s = s.to_owned(); + assert_eq!(parse(&mut s).unwrap(), reference, "Format string: {old_s}"); + } + } + + #[test] + fn no_year() { + let reference = Date { + year: None, + month: 11, + day: 14, + }; + for mut s in [ + "11/14", + "14 november", + "14 nov", + "14(comment!)nov", + "november 14", + "november(comment!)14", + "nov 14", + "14-nov", + "14nov", + "14(comment????)nov", + ] { + assert_eq!(parse(&mut s).unwrap(), reference); + } + } +} diff --git a/src/items/mod.rs b/src/items/mod.rs new file mode 100644 index 0000000..347cdf0 --- /dev/null +++ b/src/items/mod.rs @@ -0,0 +1,158 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore multispace0 + +//! From the GNU docs: +//! +//! > A date is a string, possibly empty, containing many items separated by +//! > whitespace. The whitespace may be omitted when no ambiguity arises. The +//! > empty string means the beginning of today (i.e., midnight). Order of the +//! > items is immaterial. A date string may contain many flavors of items: +//! > - calendar date items +//! > - time of day items +//! > - time zone items +//! > - combined date and time of day items +//! > - day of the week items +//! > - relative items +//! > - pure numbers. +//! +//! We put all of those in separate modules: +//! - [`date`] +//! - [`time`] +//! - [`time_zone`] +//! - [`combined`] +//! - [`weekday`] +//! - [`relative`] +//! - [`number] + +mod combined; +mod date; +mod ordinal; +mod relative; +mod time; +mod time_zone; +mod weekday; +mod number {} + +use winnow::{ + ascii::multispace0, + combinator::{alt, delimited, not, peek, preceded, repeat, separated, terminated}, + error::ParserError, + stream::AsChar, + token::{none_of, take_while}, + PResult, Parser, +}; + +#[derive(PartialEq, Debug)] +pub enum Item { + DateTime(combined::DateTime), + Date(date::Date), + Time(time::Time), + Weekday(weekday::Weekday), + Relative(relative::Relative), + TimeZone(()), +} + +/// Allow spaces and comments before a parser +/// +/// Every token parser should be wrapped in this to allow spaces and comments. +/// It is only preceding, because that allows us to check mandatory whitespace +/// after running the parser. +fn s<'a, O, E>(p: impl Parser<&'a str, O, E>) -> impl Parser<&'a str, O, E> +where + E: ParserError<&'a str>, +{ + preceded(space, p) +} + +/// Parse the space in-between tokens +/// +/// You probably want to use the [`s`] combinator instead. +fn space<'a, E>(input: &mut &'a str) -> PResult<(), E> +where + E: ParserError<&'a str>, +{ + separated(0.., multispace0, alt((comment, ignored_hyphen_or_plus))).parse_next(input) +} + +/// A hyphen or plus is ignored when it is not followed by a digit +/// +/// This includes being followed by a comment! Compare these inputs: +/// ```txt +/// - 12 weeks +/// - (comment) 12 weeks +/// ``` +/// The last comment should be ignored. +/// +/// The plus is undocumented, but it seems to be ignored. +fn ignored_hyphen_or_plus<'a, E>(input: &mut &'a str) -> PResult<(), E> +where + E: ParserError<&'a str>, +{ + ( + alt(('-', '+')), + multispace0, + peek(not(take_while(1, AsChar::is_dec_digit))), + ) + .void() + .parse_next(input) +} + +/// Parse a comment +/// +/// A comment is given between parentheses, which must be balanced. Any other +/// tokens can be within the comment. +fn comment<'a, E>(input: &mut &'a str) -> PResult<(), E> +where + E: ParserError<&'a str>, +{ + delimited( + '(', + repeat(0.., alt((none_of(['(', ')']).void(), comment))), + ')', + ) + .parse_next(input) +} + +/// Parse an item +pub fn parse_one(input: &mut &str) -> PResult { + alt(( + combined::parse.map(Item::DateTime), + date::parse.map(Item::Date), + time::parse.map(Item::Time), + relative::parse.map(Item::Relative), + weekday::parse.map(Item::Weekday), + // time_zone::parse.map(Item::TimeZone), + )) + .parse_next(input) +} + +pub fn parse(input: &mut &str) -> Option> { + terminated(repeat(0.., parse_one), space).parse(input).ok() +} + +#[cfg(test)] +mod tests { + use super::{date::Date, parse, time::Time, Item}; + + #[test] + fn date_and_time() { + assert_eq!( + parse(&mut " 10:10 2022-12-12 "), + Some(vec![ + Item::Time(Time { + hour: 10, + minute: 10, + second: 0.0, + offset: None, + }), + Item::Date(Date { + day: 12, + month: 12, + year: Some(2022) + }) + ]) + ) + } +} diff --git a/src/items/number.rs b/src/items/number.rs new file mode 100644 index 0000000..e795263 --- /dev/null +++ b/src/items/number.rs @@ -0,0 +1,45 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Numbers without other symbols +//! +//! The GNU docs state: +//! +//! > If the decimal number is of the form yyyymmdd and no other calendar date +//! > item (see 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. + +use winnow::{combinator::cond, PResult}; + +enum Number { + Date, + Time, + Year, +} + +pub fn parse(seen_date: bool, seen_time: bool, input: &mut &str) -> PResult { + alt(( + cond(!seen_date, date_number), + cond(!seen_time, time_number), + cond(seen_date && seen_time, year_number), + )) + .parse_next(input) +} + +fn date_number(input: &mut &str) -> PResult { + todo!() +} + +fn time_number(input: &mut &str) -> PResult { + todo!() +} + +fn year_number(input: &mut &str) -> PResult { + todo!() +} diff --git a/src/items/ordinal.rs b/src/items/ordinal.rs new file mode 100644 index 0000000..8bf65f4 --- /dev/null +++ b/src/items/ordinal.rs @@ -0,0 +1,46 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use super::s; +use winnow::{ + ascii::{alpha1, dec_uint}, + combinator::{alt, opt}, + PResult, Parser, +}; + +pub fn ordinal(input: &mut &str) -> PResult { + alt((text_ordinal, number_ordinal)).parse_next(input) +} + +fn number_ordinal(input: &mut &str) -> PResult { + let sign = opt(alt(('+'.value(1), '-'.value(-1)))).map(|s| s.unwrap_or(1)); + (s(sign), s(dec_uint)) + .verify_map(|(s, u): (i32, u32)| { + let i: i32 = u.try_into().ok()?; + Some(s * i) + }) + .parse_next(input) +} + +fn text_ordinal(input: &mut &str) -> PResult { + s(alpha1) + .verify_map(|s: &str| { + Some(match s { + "last" => -1, + "this" => 0, + "next" | "first" => 1, + "third" => 3, + "fourth" => 4, + "fifth" => 5, + "sixth" => 6, + "seventh" => 7, + "eight" => 8, + "ninth" => 9, + "tenth" => 10, + "eleventh" => 11, + "twelfth" => 12, + _ => return None, + }) + }) + .parse_next(input) +} diff --git a/src/items/relative.rs b/src/items/relative.rs new file mode 100644 index 0000000..7e7cb81 --- /dev/null +++ b/src/items/relative.rs @@ -0,0 +1,190 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Parse a relative datetime item +//! +//! The GNU docs state: +//! +//! > The unit of time displacement may be selected by the string ‘year’ or +//! > ‘month’ for moving by whole years or months. These are fuzzy units, as +//! > years and months are not all of equal duration. More precise units are +//! > ‘fortnight’ which is worth 14 days, ‘week’ worth 7 days, ‘day’ worth 24 +//! > hours, ‘hour’ worth 60 minutes, ‘minute’ or ‘min’ worth 60 seconds, and +//! > ‘second’ or ‘sec’ worth one second. An ‘s’ suffix on these units is +//! > accepted and ignored. +//! > +//! > The unit of time may be preceded by a multiplier, given as an optionally +//! > signed number. Unsigned numbers are taken as positively signed. No number +//! > at all implies 1 for a multiplier. Following a relative item by the +//! > string ‘ago’ is equivalent to preceding the unit by a multiplier with +//! > value -1. +//! > +//! > The string ‘tomorrow’ is worth one day in the future (equivalent to +//! > ‘day’), the string ‘yesterday’ is worth one day in the past (equivalent +//! > to ‘day ago’). +//! > +//! > The strings ‘now’ or ‘today’ are relative items corresponding to +//! > zero-valued time displacement, these strings come from the fact a +//! > zero-valued time displacement represents the current time when not +//! > otherwise changed by previous items. They may be used to stress other +//! > items, like in ‘12:00 today’. The string ‘this’ also has the meaning of a +//! > zero-valued time displacement, but is preferred in date strings like +//! > ‘this thursday’. + +use winnow::{ + ascii::{alpha1, float}, + combinator::{alt, opt}, + PResult, Parser, +}; + +use super::{ordinal::ordinal, s}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Relative { + Years(i32), + Months(i32), + Days(i32), + Hours(i32), + Minutes(i32), + // Seconds are special because they can be given as a float + Seconds(f64), +} + +impl Relative { + fn mul(self, n: i32) -> Self { + match self { + Self::Years(x) => Self::Years(n * x), + Self::Months(x) => Self::Months(n * x), + Self::Days(x) => Self::Days(n * x), + Self::Hours(x) => Self::Hours(n * x), + Self::Minutes(x) => Self::Minutes(n * x), + Self::Seconds(x) => Self::Seconds(f64::from(n) * x), + } + } +} + +pub fn parse(input: &mut &str) -> PResult { + alt(( + s("tomorrow").value(Relative::Days(1)), + s("yesterday").value(Relative::Days(-1)), + // For "today" and "now", the unit is arbitrary + s("today").value(Relative::Days(0)), + s("now").value(Relative::Days(0)), + seconds, + other, + )) + .parse_next(input) +} + +fn seconds(input: &mut &str) -> PResult { + ( + opt(alt((s(float), ordinal.map(|x| x as f64)))), + s(alpha1).verify(|s: &str| matches!(s, "seconds" | "second" | "sec" | "secs")), + ago, + ) + .map(|(n, _, ago)| Relative::Seconds(n.unwrap_or(1.0) * if ago { -1.0 } else { 1.0 })) + .parse_next(input) +} + +fn other(input: &mut &str) -> PResult { + (opt(ordinal), integer_unit, ago) + .map(|(n, unit, ago)| unit.mul(n.unwrap_or(1) * if ago { -1 } else { 1 })) + .parse_next(input) +} + +fn ago(input: &mut &str) -> PResult { + opt(s("ago")).map(|o| o.is_some()).parse_next(input) +} + +fn integer_unit(input: &mut &str) -> PResult { + s(alpha1) + .verify_map(|s: &str| { + Some(match s.strip_suffix('s').unwrap_or(&s) { + "year" => Relative::Years(1), + "month" => Relative::Months(1), + "fortnight" => Relative::Days(14), + "week" => Relative::Days(7), + "day" => Relative::Days(1), + "hour" => Relative::Hours(1), + "minute" | "min" => Relative::Minutes(1), + _ => return None, + }) + }) + .parse_next(input) +} + +#[cfg(test)] +mod tests { + use super::{parse, Relative}; + + #[test] + fn all() { + for (s, rel) in [ + // Seconds + ("second", Relative::Seconds(1.0)), + ("sec", Relative::Seconds(1.0)), + ("seconds", Relative::Seconds(1.0)), + ("secs", Relative::Seconds(1.0)), + ("second ago", Relative::Seconds(-1.0)), + ("3 seconds", Relative::Seconds(3.0)), + ("3.5 seconds", Relative::Seconds(3.5)), + // ("+3.5 seconds", Relative::Seconds(3.5)), + ("3.5 seconds ago", Relative::Seconds(-3.5)), + ("-3.5 seconds ago", Relative::Seconds(3.5)), + // Minutes + ("minute", Relative::Minutes(1)), + ("minutes", Relative::Minutes(1)), + ("min", Relative::Minutes(1)), + ("mins", Relative::Minutes(1)), + ("10 minutes", Relative::Minutes(10)), + ("-10 minutes", Relative::Minutes(-10)), + ("10 minutes ago", Relative::Minutes(-10)), + ("-10 minutes ago", Relative::Minutes(10)), + // Hours + ("hour", Relative::Hours(1)), + ("hours", Relative::Hours(1)), + ("10 hours", Relative::Hours(10)), + ("+10 hours", Relative::Hours(10)), + ("-10 hours", Relative::Hours(-10)), + ("10 hours ago", Relative::Hours(-10)), + ("-10 hours ago", Relative::Hours(10)), + // Days + ("day", Relative::Days(1)), + ("days", Relative::Days(1)), + ("10 days", Relative::Days(10)), + ("+10 days", Relative::Days(10)), + ("-10 days", Relative::Days(-10)), + ("10 days ago", Relative::Days(-10)), + ("-10 days ago", Relative::Days(10)), + // Multiple days + ("fortnight", Relative::Days(14)), + ("fortnights", Relative::Days(14)), + ("2 fortnights ago", Relative::Days(-28)), + ("+2 fortnights ago", Relative::Days(-28)), + ("week", Relative::Days(7)), + ("weeks", Relative::Days(7)), + ("2 weeks ago", Relative::Days(-14)), + // Other + ("year", Relative::Years(1)), + ("years", Relative::Years(1)), + ("month", Relative::Months(1)), + ("months", Relative::Months(1)), + // Special + ("yesterday", Relative::Days(-1)), + ("tomorrow", Relative::Days(1)), + ("today", Relative::Days(0)), + ("now", Relative::Days(0)), + // This something + ("this day", Relative::Days(0)), + ("this second", Relative::Seconds(0.0)), + ("this year", Relative::Years(0)), + // Weird stuff + ("next week ago", Relative::Days(-7)), + ("last week ago", Relative::Days(7)), + ("this week ago", Relative::Days(0)), + ] { + let mut t = s; + assert_eq!(parse(&mut t).ok(), Some(rel), "Failed string: {s}") + } + } +} diff --git a/src/items/time.rs b/src/items/time.rs new file mode 100644 index 0000000..7d58896 --- /dev/null +++ b/src/items/time.rs @@ -0,0 +1,438 @@ +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore shhmm colonless + +//! Parse a time item (without a date) +//! +//! The GNU docs state: +//! +//! > More generally, the time of day may be given as ‘hour:minute:second’, +//! > where hour is a number between 0 and 23, minute is a number between 0 and +//! > 59, and second is a number between 0 and 59 possibly followed by ‘.’ or +//! > ‘,’ and a fraction containing one or more digits. Alternatively, +//! > ‘:second’ can be omitted, in which case it is taken to be zero. On the +//! > rare hosts that support leap seconds, second may be 60. +//! > +//! > If the time is followed by ‘am’ or ‘pm’ (or ‘a.m.’ or ‘p.m.’), hour is +//! > restricted to run from 1 to 12, and ‘:minute’ may be omitted (taken to be +//! > zero). ‘am’ indicates the first half of the day, ‘pm’ indicates the +//! > second half of the day. In this notation, 12 is the predecessor of 1: +//! > midnight is ‘12am’ while noon is ‘12pm’. (This is the zero-oriented +//! > interpretation of ‘12am’ and ‘12pm’, as opposed to the old tradition +//! > derived from Latin which uses ‘12m’ for noon and ‘12pm’ for midnight.) +//! > +//! > The time may alternatively be followed by a time zone correction, +//! > expressed as ‘shhmm’, where s is ‘+’ or ‘-’, hh is a number of zone hours +//! > and mm is a number of zone minutes. The zone minutes term, mm, may be +//! > omitted, in which case the one- or two-digit correction is interpreted as +//! > a number of hours. You can also separate hh from mm with a colon. When a +//! > time zone correction is given this way, it forces interpretation of the +//! > time relative to Coordinated Universal Time (UTC), overriding any +//! > previous specification for the time zone or the local time zone. For +//! > example, ‘+0530’ and ‘+05:30’ both stand for the time zone 5.5 hours +//! > ahead of UTC (e.g., India). This is the best way to specify a time zone +//! > correction by fractional parts of an hour. The maximum zone correction is +//! > 24 hours. +//! > +//! > Either ‘am’/‘pm’ or a time zone correction may be specified, but not both. + +use winnow::{ + ascii::{dec_uint, float}, + combinator::{alt, opt, preceded}, + seq, + stream::AsChar, + token::take_while, + PResult, Parser, +}; + +use super::s; + +#[derive(PartialEq, Clone, Debug)] +pub struct Time { + pub hour: u32, + pub minute: u32, + pub second: f64, + pub offset: Option, +} + +#[derive(PartialEq, Debug, Clone)] +pub struct Offset { + negative: bool, + hours: u32, + minutes: u32, +} + +#[derive(Clone)] +enum Suffix { + Am, + Pm, +} + +pub fn parse(input: &mut &str) -> PResult