Merge pull request #137 from yuankunzhang/replace-the-regex-parser

Replace the regex parser with winnow-based parse
This commit is contained in:
Terts Diepraam
2025-05-09 11:35:48 +02:00
committed by GitHub
11 changed files with 2242 additions and 313 deletions
Generated
+31 -20
View File
@@ -129,9 +129,9 @@ dependencies = [
[[package]]
name = "num-traits"
version = "0.2.15"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
@@ -148,7 +148,9 @@ version = "0.9.0"
dependencies = [
"chrono",
"nom",
"num-traits",
"regex",
"winnow",
]
[[package]]
@@ -287,9 +289,9 @@ checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3"
[[package]]
name = "windows-targets"
version = "0.52.5"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
@@ -303,48 +305,57 @@ dependencies = [
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.5"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.5"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.5"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.5"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.5"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.5"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.5"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.5"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
dependencies = [
"memchr",
]
+2
View File
@@ -11,3 +11,5 @@ 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"
num-traits = "0.2.19"
+13 -2
View File
@@ -169,9 +169,9 @@ dependencies = [
[[package]]
name = "num-traits"
version = "0.2.15"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
@@ -188,7 +188,9 @@ version = "0.9.0"
dependencies = [
"chrono",
"nom",
"num-traits",
"regex",
"winnow",
]
[[package]]
@@ -394,3 +396,12 @@ name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
dependencies = [
"memchr",
]
+75
View File
@@ -0,0 +1,75 @@
// 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, trace::trace, PResult, Parser};
use crate::items::space;
use super::{
date::{self, Date},
s,
time::{self, Time},
};
#[derive(PartialEq, Debug, Clone, Default)]
pub struct DateTime {
pub(crate) date: Date,
pub(crate) time: Time,
}
pub fn parse(input: &mut &str) -> PResult<DateTime> {
seq!(DateTime {
date: trace("date iso", alt((date::iso1, date::iso2))),
// Note: the `T` is lowercased by the main parse function
_: alt((s('t').void(), (' ', space).void())),
time: trace("time iso", 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}")
}
}
}
+285
View File
@@ -0,0 +1,285 @@
// 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,
stream::AsChar,
token::{take, take_while},
trace::trace,
PResult, Parser,
};
use super::s;
use crate::ParseDateTimeError;
#[derive(PartialEq, Eq, Clone, Debug, Default)]
pub struct Date {
pub day: u32,
pub month: u32,
pub year: Option<u32>,
}
pub fn parse(input: &mut &str) -> PResult<Date> {
alt((iso1, iso2, us, literal1, literal2)).parse_next(input)
}
/// Parse `YYYY-MM-DD` or `YY-MM-DD`
///
/// This is also used by [`combined`](super::combined).
pub fn iso1(input: &mut &str) -> PResult<Date> {
seq!(Date {
year: year.map(Some),
_: s('-'),
month: month,
_: s('-'),
day: day,
})
.parse_next(input)
}
/// Parse `YYYYMMDD`
///
/// This is also used by [`combined`](super::combined).
pub fn iso2(input: &mut &str) -> PResult<Date> {
s((
take(4usize).try_map(|s: &str| s.parse::<u32>()),
take(2usize).try_map(|s: &str| s.parse::<u32>()),
take(2usize).try_map(|s: &str| s.parse::<u32>()),
))
.map(|(year, month, day): (u32, u32, u32)| Date {
day,
month,
year: Some(year),
})
.parse_next(input)
}
/// Parse `MM/DD/YYYY`, `MM/DD/YY` or `MM/DD`
fn us(input: &mut &str) -> PResult<Date> {
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<Date> {
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<Date> {
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)
}
pub fn year(input: &mut &str) -> PResult<u32> {
// 2147485547 is the maximum value accepted
// by GNU, but chrono only behaves like GNU
// for years in the range: [0, 9999], so we
// keep in the range [0, 9999]
trace(
"year",
s(
take_while(1..=4, AsChar::is_dec_digit).map(|number_str: &str| {
let year = number_str.parse::<u32>().unwrap();
if number_str.len() == 2 {
if year <= 68 {
year + 2000
} else {
year + 1900
}
} else {
year
}
}),
),
)
.parse_next(input)
}
fn month(input: &mut &str) -> PResult<u32> {
s(dec_uint)
.try_map(|x| {
(1..=12)
.contains(&x)
.then_some(x)
.ok_or(ParseDateTimeError::InvalidInput)
})
.parse_next(input)
}
fn day(input: &mut &str) -> PResult<u32> {
s(dec_uint)
.try_map(|x| {
(1..=31)
.contains(&x)
.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<u32> {
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);
}
}
#[test]
fn test_year() {
use super::year;
// the minimun input length is 2
// assert!(year(&mut "0").is_err());
// -> GNU accepts year 0
// test $(date -d '1-1-1' '+%Y') -eq '0001'
// test $(date -d '68-1-1' '+%Y') -eq '2068'
// 2-characters are converted to 19XX/20XX
assert_eq!(year(&mut "10").unwrap(), 2010u32);
assert_eq!(year(&mut "68").unwrap(), 2068u32);
assert_eq!(year(&mut "69").unwrap(), 1969u32);
assert_eq!(year(&mut "99").unwrap(), 1999u32);
// 3,4-characters are converted verbatim
assert_eq!(year(&mut "468").unwrap(), 468u32);
assert_eq!(year(&mut "469").unwrap(), 469u32);
assert_eq!(year(&mut "1568").unwrap(), 1568u32);
assert_eq!(year(&mut "1569").unwrap(), 1569u32);
// consumes at most 4 characters from the input
//assert_eq!(year(&mut "1234567").unwrap(), 1234u32);
}
}
+505
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -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<i32> {
alt((text_ordinal, number_ordinal)).parse_next(input)
}
fn number_ordinal(input: &mut &str) -> PResult<i32> {
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<i32> {
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)
}
+190
View File
@@ -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<Relative> {
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<Relative> {
(
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<Relative> {
(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<bool> {
opt(s("ago")).map(|o| o.is_some()).parse_next(input)
}
fn integer_unit(input: &mut &str) -> PResult<Relative> {
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}")
}
}
}
+843
View File
File diff suppressed because it is too large Load Diff
+137
View File
@@ -0,0 +1,137 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
// spell-checker:ignore wednes
//! The GNU docs state:
//!
//! > The explicit mention of a day of the week will forward the date (only if
//! > necessary) to reach that day of the week in the future.
//! >
//! > Days of the week may be spelled out in full: Sunday, Monday,
//! > Tuesday, Wednesday, Thursday, Friday or Saturday. Days may be
//! > abbreviated to their first three letters, optionally followed by a
//! > period. The special abbreviations Tues for Tuesday, Wednes for
//! > Wednesday and Thur or Thurs for Thursday are also allowed.
//! >
//! > A number may precede a day of the week item to move forward supplementary
//! > weeks. It is best used in expression like third monday. In this
//! > context, last day or next day is also acceptable; they move one week
//! > before or after the day that day by itself would represent.
//! >
//! > A comma following a day of the week item is ignored.
use winnow::{ascii::alpha1, combinator::opt, seq, PResult, Parser};
use super::{ordinal::ordinal, s};
#[derive(PartialEq, Eq, Debug)]
pub(crate) enum Day {
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday,
}
#[derive(PartialEq, Eq, Debug)]
pub struct Weekday {
pub(crate) offset: i32,
pub(crate) day: Day,
}
impl From<Day> for chrono::Weekday {
fn from(value: Day) -> Self {
match value {
Day::Monday => chrono::Weekday::Mon,
Day::Tuesday => chrono::Weekday::Tue,
Day::Wednesday => chrono::Weekday::Wed,
Day::Thursday => chrono::Weekday::Thu,
Day::Friday => chrono::Weekday::Fri,
Day::Saturday => chrono::Weekday::Sat,
Day::Sunday => chrono::Weekday::Sun,
}
}
}
pub fn parse(input: &mut &str) -> PResult<Weekday> {
seq!(Weekday {
offset: opt(ordinal).map(|o| o.unwrap_or_default()),
day: day,
})
.parse_next(input)
}
fn day(input: &mut &str) -> PResult<Day> {
s(alpha1)
.verify_map(|s: &str| {
Some(match s {
"monday" | "mon" | "mon." => Day::Monday,
"tuesday" | "tue" | "tue." | "tues" => Day::Tuesday,
"wednesday" | "wed" | "wed." | "wednes" => Day::Wednesday,
"thursday" | "thu" | "thu." | "thur" | "thurs" => Day::Thursday,
"friday" | "fri" | "fri." => Day::Friday,
"saturday" | "sat" | "sat." => Day::Saturday,
"sunday" | "sun" | "sun." => Day::Sunday,
_ => return None,
})
})
.parse_next(input)
}
#[cfg(test)]
mod tests {
use super::{parse, Day, Weekday};
#[test]
fn this_monday() {
for mut s in [
"monday",
"mon",
"mon.",
"this monday",
"this mon",
"this mon.",
"this monday",
"this - monday",
"0 monday",
] {
assert_eq!(
parse(&mut s).unwrap(),
Weekday {
offset: 0,
day: Day::Monday,
}
);
}
}
#[test]
fn next_tuesday() {
for s in ["tuesday", "tue", "tue.", "tues"] {
let s = format!("next {s}");
assert_eq!(
parse(&mut s.as_ref()).unwrap(),
Weekday {
offset: 1,
day: Day::Tuesday,
}
);
}
}
#[test]
fn last_wednesday() {
for s in ["wednesday", "wed", "wed.", "wednes"] {
let s = format!("last {s}");
assert_eq!(
parse(&mut s.as_ref()).unwrap(),
Weekday {
offset: -1,
day: Day::Wednesday,
}
);
}
}
}
+115 -291
View File
@@ -8,39 +8,22 @@
//! * unix timestamps, e.g., "@12"
//! * relative time to now, e.g. "+1 hour"
//!
use regex::Error as RegexError;
use regex::Regex;
use std::error::Error;
use std::fmt::{self, Display};
// Expose parse_datetime
mod parse_relative_time;
mod parse_timestamp;
use chrono::{DateTime, FixedOffset, Local};
mod parse_time_only_str;
mod parse_weekday;
use chrono::{
DateTime, Datelike, Duration, FixedOffset, Local, LocalResult, MappedLocalTime, NaiveDate,
NaiveDateTime, TimeZone, Timelike,
};
use parse_relative_time::parse_relative_time_at_date;
use parse_timestamp::parse_timestamp;
mod items;
#[derive(Debug, PartialEq)]
pub enum ParseDateTimeError {
InvalidRegex(RegexError),
InvalidInput,
}
impl Display for ParseDateTimeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidRegex(err) => {
write!(f, "Invalid regex for time pattern: {err}")
}
Self::InvalidInput => {
ParseDateTimeError::InvalidInput => {
write!(
f,
"Invalid input string: cannot be parsed as a relative time"
@@ -52,118 +35,6 @@ impl Display for ParseDateTimeError {
impl Error for ParseDateTimeError {}
impl From<RegexError> for ParseDateTimeError {
fn from(err: RegexError) -> Self {
Self::InvalidRegex(err)
}
}
/// Formats that parse input can take.
/// Taken from `touch` coreutils
mod format {
pub const ISO_8601: &str = "%Y-%m-%d";
pub const ISO_8601_NO_SEP: &str = "%Y%m%d";
// US format for calendar date items:
// https://www.gnu.org/software/coreutils/manual/html_node/Calendar-date-items.html
pub const MMDDYYYY_SLASH: &str = "%m/%d/%Y";
pub const MMDDYY_SLASH: &str = "%m/%d/%y";
pub const POSIX_LOCALE: &str = "%a %b %e %H:%M:%S %Y";
pub const YYYYMMDDHHMM_DOT_SS: &str = "%Y%m%d%H%M.%S";
pub const YYYYMMDDHHMMSS: &str = "%Y-%m-%d %H:%M:%S.%f";
pub const YYYYMMDDHHMMS: &str = "%Y-%m-%d %H:%M:%S";
pub const YYYY_MM_DD_HH_MM: &str = "%Y-%m-%d %H:%M";
pub const YYYYMMDDHHMM: &str = "%Y%m%d%H%M";
pub const YYYYMMDDHHMM_OFFSET: &str = "%Y%m%d%H%M %z";
pub const YYYYMMDDHHMM_UTC_OFFSET: &str = "%Y%m%d%H%MUTC%z";
pub const YYYYMMDDHHMM_ZULU_OFFSET: &str = "%Y%m%d%H%MZ%z";
pub const YYYYMMDDHHMM_HYPHENATED_OFFSET: &str = "%Y-%m-%d %H:%M %z";
pub const YYYYMMDDHHMMSS_HYPHENATED_OFFSET: &str = "%Y-%m-%d %H:%M:%S %#z";
pub const YYYYMMDDHHMMSS_HYPHENATED_ZULU: &str = "%Y-%m-%d %H:%M:%SZ";
pub const YYYYMMDDHHMMSS_T_SEP_HYPHENATED_OFFSET: &str = "%Y-%m-%dT%H:%M:%S%#z";
pub const YYYYMMDDHHMMSS_T_SEP_HYPHENATED_ZULU: &str = "%Y-%m-%dT%H:%M:%SZ";
pub const YYYYMMDDHHMMSS_T_SEP_HYPHENATED_SPACE_OFFSET: &str = "%Y-%m-%dT%H:%M:%S %#z";
pub const YYYYMMDDHHMMS_T_SEP: &str = "%Y-%m-%dT%H:%M:%S";
pub const UTC_OFFSET: &str = "UTC%#z";
pub const ZULU_OFFSET: &str = "Z%#z";
pub const NAKED_OFFSET: &str = "%#z";
/// Whether the pattern ends in the character `Z`.
pub(crate) fn is_zulu(pattern: &str) -> bool {
pattern.ends_with('Z')
}
/// Patterns for datetimes with timezones.
///
/// These are in decreasing order of length. The same pattern may
/// appear multiple times with different lengths if the pattern
/// accepts input strings of different lengths. For example, the
/// specifier `%#z` accepts two-digit time zone offsets (`+00`)
/// and four-digit time zone offsets (`+0000`).
pub(crate) const PATTERNS_TZ: [(&str, usize); 9] = [
(YYYYMMDDHHMMSS_HYPHENATED_OFFSET, 25),
(YYYYMMDDHHMMSS_T_SEP_HYPHENATED_SPACE_OFFSET, 25),
(YYYYMMDDHHMMSS_T_SEP_HYPHENATED_OFFSET, 24),
(YYYYMMDDHHMMSS_HYPHENATED_OFFSET, 23),
(YYYYMMDDHHMMSS_T_SEP_HYPHENATED_OFFSET, 22),
(YYYYMMDDHHMM_HYPHENATED_OFFSET, 22),
(YYYYMMDDHHMM_UTC_OFFSET, 20),
(YYYYMMDDHHMM_OFFSET, 18),
(YYYYMMDDHHMM_ZULU_OFFSET, 18),
];
/// Patterns for datetimes without timezones.
///
/// These are in decreasing order of length.
pub(crate) const PATTERNS_NO_TZ: [(&str, usize); 9] = [
(YYYYMMDDHHMMSS, 29),
(POSIX_LOCALE, 24),
(YYYYMMDDHHMMSS_HYPHENATED_ZULU, 20),
(YYYYMMDDHHMMSS_T_SEP_HYPHENATED_ZULU, 20),
(YYYYMMDDHHMMS_T_SEP, 19),
(YYYYMMDDHHMMS, 19),
(YYYY_MM_DD_HH_MM, 16),
(YYYYMMDDHHMM_DOT_SS, 15),
(YYYYMMDDHHMM, 12),
];
/// Patterns for dates with neither times nor timezones.
///
/// These are in decreasing order of length. The same pattern may
/// appear multiple times with different lengths if the pattern
/// accepts input strings of different lengths. For example, the
/// specifier `%m` accepts one-digit month numbers (like `2`) and
/// two-digit month numbers (like `02` or `12`).
pub(crate) const PATTERNS_DATE_NO_TZ: [(&str, usize); 8] = [
(ISO_8601, 10),
(MMDDYYYY_SLASH, 10),
(ISO_8601, 9),
(MMDDYYYY_SLASH, 9),
(ISO_8601, 8),
(MMDDYY_SLASH, 8),
(MMDDYYYY_SLASH, 8),
(ISO_8601_NO_SEP, 8),
];
/// Patterns for lone timezone offsets.
///
/// These are in decreasing order of length. The same pattern may
/// appear multiple times with different lengths if the pattern
/// accepts input strings of different lengths. For example, the
/// specifier `%#z` accepts two-digit time zone offsets (`+00`)
/// and four-digit time zone offsets (`+0000`).
pub(crate) const PATTERNS_OFFSET: [(&str, usize); 9] = [
(UTC_OFFSET, 9),
(UTC_OFFSET, 8),
(ZULU_OFFSET, 7),
(UTC_OFFSET, 6),
(ZULU_OFFSET, 6),
(NAKED_OFFSET, 6),
(NAKED_OFFSET, 5),
(ZULU_OFFSET, 4),
(NAKED_OFFSET, 3),
];
}
/// Parses a time string and returns a `DateTime` representing the
/// absolute time of the string.
///
@@ -190,11 +61,14 @@ mod format {
/// This function will return `Err(ParseDateTimeError::InvalidInput)` if the input string
/// cannot be parsed as a relative time.
pub fn parse_datetime<S: AsRef<str> + Clone>(
s: S,
input: S,
) -> Result<DateTime<FixedOffset>, ParseDateTimeError> {
parse_datetime_at_date(Local::now(), s)
let input = input.as_ref().to_ascii_lowercase();
match items::parse(&mut input.as_str()) {
Ok(x) => items::at_local(x),
Err(_) => Err(ParseDateTimeError::InvalidInput),
}
}
/// Parses a time string at a specific date and returns a `DateTime` representing the
/// absolute time of the string.
///
@@ -210,11 +84,11 @@ pub fn parse_datetime<S: AsRef<str> + Clone>(
/// use parse_datetime::parse_datetime_at_date;
///
/// let now = Local::now();
/// let after = parse_datetime_at_date(now, "+3 days");
/// let after = parse_datetime_at_date(now, "2024-09-13UTC +3 days");
///
/// assert_eq!(
/// (now + Duration::days(3)).naive_utc(),
/// after.unwrap().naive_utc()
/// "2024-09-16",
/// after.unwrap().naive_utc().format("%F").to_string()
/// );
/// ```
///
@@ -229,150 +103,12 @@ pub fn parse_datetime<S: AsRef<str> + Clone>(
/// cannot be parsed as a relative time.
pub fn parse_datetime_at_date<S: AsRef<str> + Clone>(
date: DateTime<Local>,
s: S,
input: S,
) -> Result<DateTime<FixedOffset>, ParseDateTimeError> {
// TODO: Replace with a proper customiseable parsing solution using `nom`, `grmtools`, or
// similar
// Try to parse a reference date first. Try parsing from longest
// pattern to shortest pattern. If a reference date can be parsed,
// then try to parse a time delta from the remaining slice. If no
// reference date could be parsed, then try to parse the entire
// string as a time delta. If no time delta could be parsed,
// return an error.
let (ref_date, n) = if let Some((ref_date, n)) = parse_reference_date(date, s.as_ref()) {
(ref_date, n)
} else {
let tz = TimeZone::from_offset(date.offset());
match date.naive_local().and_local_timezone(tz) {
MappedLocalTime::Single(ref_date) => (ref_date, 0),
_ => return Err(ParseDateTimeError::InvalidInput),
}
};
parse_relative_time_at_date(ref_date, &s.as_ref()[n..])
}
/// Parse an absolute datetime from a prefix of s, if possible.
///
/// Try to parse the longest possible absolute datetime at the beginning
/// of string `s`. Return the parsed datetime and the index in `s` at
/// which the datetime ended.
fn parse_reference_date<S>(date: DateTime<Local>, s: S) -> Option<(DateTime<FixedOffset>, usize)>
where
S: AsRef<str>,
{
// HACK: if the string ends with a single digit preceded by a + or -
// sign, then insert a 0 between the sign and the digit to make it
// possible for `chrono` to parse it.
let pattern = Regex::new(r"([\+-])(\d)$").unwrap();
let tmp_s = pattern.replace(s.as_ref(), "${1}0${2}");
for (fmt, n) in format::PATTERNS_TZ {
if tmp_s.len() >= n {
if let Ok(parsed) = DateTime::parse_from_str(&tmp_s[0..n], fmt) {
if tmp_s == s.as_ref() {
return Some((parsed, n));
}
return Some((parsed, n - 1));
}
}
}
// Parse formats with no offset, assume local time
for (fmt, n) in format::PATTERNS_NO_TZ {
if s.as_ref().len() >= n {
if let Ok(parsed) = NaiveDateTime::parse_from_str(&s.as_ref()[0..n], fmt) {
// Special case: `chrono` can only parse a datetime like
// `2000-01-01 01:23:45Z` as a naive datetime, so we
// manually force it to be in UTC.
if format::is_zulu(fmt) {
match FixedOffset::east_opt(0)
.unwrap()
.from_local_datetime(&parsed)
{
MappedLocalTime::Single(datetime) => return Some((datetime, n)),
_ => return None,
}
} else if let Ok(dt) = naive_dt_to_fixed_offset(date, parsed) {
return Some((dt, n));
}
}
}
}
// parse weekday
if let Some(weekday) = parse_weekday::parse_weekday(s.as_ref()) {
let mut beginning_of_day = date
.with_hour(0)
.unwrap()
.with_minute(0)
.unwrap()
.with_second(0)
.unwrap()
.with_nanosecond(0)
.unwrap();
while beginning_of_day.weekday() != weekday {
beginning_of_day += Duration::days(1);
}
let dt = DateTime::<FixedOffset>::from(beginning_of_day);
return Some((dt, s.as_ref().len()));
}
// Parse epoch seconds
if let Ok(timestamp) = parse_timestamp(s.as_ref()) {
if let Some(timestamp_date) = DateTime::from_timestamp(timestamp, 0) {
return Some((timestamp_date.into(), s.as_ref().len()));
}
}
// Parse date only formats - assume midnight local timezone
for (fmt, n) in format::PATTERNS_DATE_NO_TZ {
if s.as_ref().len() >= n {
if let Ok(parsed) = NaiveDate::parse_from_str(&s.as_ref()[0..n], fmt) {
let datetime = parsed.and_hms_opt(0, 0, 0).unwrap();
if let Ok(dt) = naive_dt_to_fixed_offset(date, datetime) {
return Some((dt, n));
}
}
}
}
// Parse offsets. chrono doesn't provide any functionality to parse
// offsets, so instead we replicate parse_date behaviour by getting
// the current date with local, and create a date time string at midnight,
// before trying offset suffixes
let ts = format!("{}0000{}", date.format("%Y%m%d"), tmp_s.as_ref());
for (fmt, n) in format::PATTERNS_OFFSET {
if ts.len() == n + 12 {
let f = format::YYYYMMDDHHMM.to_owned() + fmt;
if let Ok(parsed) = DateTime::parse_from_str(&ts, &f) {
if tmp_s == s.as_ref() {
return Some((parsed, n));
}
return Some((parsed, n - 1));
}
}
}
// parse time only dates
if let Some(date_time) = parse_time_only_str::parse_time_only(date, s.as_ref()) {
return Some((date_time, s.as_ref().len()));
}
None
}
// Convert NaiveDateTime to DateTime<FixedOffset> by assuming the offset
// is local time
fn naive_dt_to_fixed_offset(
local: DateTime<Local>,
dt: NaiveDateTime,
) -> Result<DateTime<FixedOffset>, ()> {
match local.offset().from_local_datetime(&dt) {
LocalResult::Single(dt) => Ok(dt),
_ => Err(()),
let input = input.as_ref().to_ascii_lowercase();
match items::parse(&mut input.as_str()) {
Ok(x) => items::at_date(x, date.into()),
Err(_) => Err(ParseDateTimeError::InvalidInput),
}
}
@@ -650,6 +386,7 @@ mod tests {
#[test]
fn test_readme_code() {
let dt = parse_datetime("2021-02-14 06:37:47");
assert_eq!(
dt.unwrap(),
Local.with_ymd_and_hms(2021, 2, 14, 6, 37, 47).unwrap()
@@ -664,7 +401,6 @@ mod tests {
#[test]
fn test_invalid_input() {
let result = parse_datetime("foobar");
println!("{result:?}");
assert_eq!(result, Err(ParseDateTimeError::InvalidInput));
let result = parse_datetime("invalid 1");
@@ -738,8 +474,6 @@ mod tests {
"1997-01-01T00:00:00",
"1997-01-01 00:00:00",
"1997-01-01 00:00",
"199701010000.00",
"199701010000",
] {
let actual = crate::parse_datetime(s).unwrap();
assert_eq!(actual, expected);
@@ -802,19 +536,25 @@ mod tests {
.fixed_offset();
for s in [
"1997-01-01 00:00:00.000000000 +1 year",
"Wed Jan 1 00:00:00 1997 +1 year",
"1997-01-01T00:00:00 +1 year",
"1997-01-01 00:00:00 +1 year",
"1997-01-01 00:00 +1 year",
"199701010000.00 +1 year",
"199701010000 +1 year",
"1997-01-01 00:00:00.000000000 1 year",
"Wed Jan 1 00:00:00 1997 1 year",
"1997-01-01T00:00:00 1 year",
"1997-01-01 00:00:00 1 year",
"1997-01-01 00:00 1 year",
] {
let actual = crate::parse_datetime(s).unwrap();
assert_eq!(actual, expected);
}
}
#[test]
fn test_parse_invalid_datetime_notz_delta() {
// GNU date does not accept the following formats.
for s in ["199701010000.00 +1 year", "199701010000 +1 year"] {
assert!(crate::parse_datetime(s).is_err());
}
}
#[test]
fn test_parse_date_notz_delta() {
std::env::set_var("TZ", "UTC0");
@@ -880,4 +620,88 @@ mod tests {
assert_eq!(actual, expected);
}
}
mod test_relative {
use crate::parse_datetime;
use std::env;
#[test]
fn test_month() {
env::set_var("TZ", "UTC");
assert_eq!(
parse_datetime("28 feb + 1 month")
.expect("parse_datetime")
.format("%m%d")
.to_string(),
"0328"
);
// 29 feb 2025 is invalid
assert!(parse_datetime("29 feb + 1 year").is_err());
// 29 feb 2025 is an invalid date
assert!(parse_datetime("29 feb 2025").is_err());
// because 29 feb 2025 is invalid, 29 feb 2025 + 1 day is invalid
// arithmetic does not operate on invalid dates
assert!(parse_datetime("29 feb 2025 + 1 day").is_err());
// 28 feb 2023 + 1 day = 1 mar
assert_eq!(
parse_datetime("28 feb 2023 + 1 day")
.unwrap()
.format("%+")
.to_string(),
"2023-03-01T00:00:00+00:00"
);
}
#[test]
fn month_overflow() {
env::set_var("TZ", "UTC");
assert_eq!(
parse_datetime("2024-01-31 + 1 month")
.unwrap()
.format("%+")
.to_string(),
"2024-03-02T00:00:00+00:00",
);
assert_eq!(
parse_datetime("2024-02-29 + 1 month")
.unwrap()
.format("%+")
.to_string(),
"2024-03-29T00:00:00+00:00",
);
}
}
mod test_gnu {
use crate::parse_datetime;
#[test]
fn gnu_compat() {
const FMT: &str = "%Y-%m-%d %H:%M:%S";
let input = "0000-03-02 00:00:00";
assert_eq!(
input,
parse_datetime(input).unwrap().format(FMT).to_string()
);
let input = "2621-03-10 00:00:00";
assert_eq!(
input,
parse_datetime(input).unwrap().format(FMT).to_string()
);
let input = "1038-03-10 00:00:00";
assert_eq!(
input,
parse_datetime(input).unwrap().format(FMT).to_string()
);
}
}
}