You've already forked parse_datetime
mirror of
https://github.com/uutils/parse_datetime.git
synced 2026-06-10 16:13:15 -07:00
start parsing date with winnow
This commit is contained in:
committed by
yuankunzhang
parent
b481a2bdec
commit
07d4b80ea5
Generated
+10
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<DateTime> {
|
||||
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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<u32>,
|
||||
}
|
||||
|
||||
pub fn parse(input: &mut &str) -> PResult<Date> {
|
||||
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<Date> {
|
||||
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<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)
|
||||
}
|
||||
|
||||
fn year(input: &mut &str) -> PResult<u32> {
|
||||
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<u32> {
|
||||
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<u32> {
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Item> {
|
||||
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<Vec<Item>> {
|
||||
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)
|
||||
})
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<Number> {
|
||||
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<Number> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn time_number(input: &mut &str) -> PResult<Number> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn year_number(input: &mut &str) -> PResult<Number> {
|
||||
todo!()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Offset>,
|
||||
}
|
||||
|
||||
#[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<Time> {
|
||||
alt((am_pm_time, iso)).parse_next(input)
|
||||
}
|
||||
|
||||
/// Parse an ISO 8601 time string
|
||||
///
|
||||
/// Also used by the [`combined`](super::combined) module
|
||||
pub fn iso(input: &mut &str) -> PResult<Time> {
|
||||
alt((
|
||||
(hour24, timezone).map(|(hour, offset)| Time {
|
||||
hour,
|
||||
minute: 0,
|
||||
second: 0.0,
|
||||
offset: Some(offset),
|
||||
}),
|
||||
seq!( Time {
|
||||
hour: hour24,
|
||||
_: colon,
|
||||
minute: minute,
|
||||
second: opt(preceded(colon, second)).map(|s| s.unwrap_or(0.0)),
|
||||
offset: opt(timezone)
|
||||
}),
|
||||
))
|
||||
.parse_next(input)
|
||||
}
|
||||
|
||||
/// Parse a time ending with AM or PM
|
||||
///
|
||||
/// The hours are restricted to 12 or lower in this format
|
||||
fn am_pm_time(input: &mut &str) -> PResult<Time> {
|
||||
seq!(
|
||||
hour12,
|
||||
opt(preceded(colon, minute)),
|
||||
opt(preceded(colon, second)),
|
||||
alt((
|
||||
s("am").value(Suffix::Am),
|
||||
s("a.m.").value(Suffix::Am),
|
||||
s("pm").value(Suffix::Pm),
|
||||
s("p.m.").value(Suffix::Pm)
|
||||
)),
|
||||
)
|
||||
.map(|(h, m, s, suffix)| {
|
||||
let mut h = h % 12;
|
||||
if let Suffix::Pm = suffix {
|
||||
h += 12;
|
||||
}
|
||||
Time {
|
||||
hour: h,
|
||||
minute: m.unwrap_or(0),
|
||||
second: s.unwrap_or(0.0),
|
||||
offset: None,
|
||||
}
|
||||
})
|
||||
.parse_next(input)
|
||||
}
|
||||
|
||||
/// Parse a colon preceded by whitespace
|
||||
fn colon(input: &mut &str) -> PResult<()> {
|
||||
s(':').void().parse_next(input)
|
||||
}
|
||||
|
||||
/// Parse a number of hours in `0..24`(preceded by whitespace)
|
||||
fn hour24(input: &mut &str) -> PResult<u32> {
|
||||
s(dec_uint).verify(|x| *x < 24).parse_next(input)
|
||||
}
|
||||
|
||||
/// Parse a number of hours in `0..=12` (preceded by whitespace)
|
||||
fn hour12(input: &mut &str) -> PResult<u32> {
|
||||
s(dec_uint).verify(|x| *x <= 12).parse_next(input)
|
||||
}
|
||||
|
||||
/// Parse a number of minutes (preceded by whitespace)
|
||||
fn minute(input: &mut &str) -> PResult<u32> {
|
||||
s(dec_uint).verify(|x| *x < 60).parse_next(input)
|
||||
}
|
||||
|
||||
/// Parse a number of seconds (preceded by whitespace)
|
||||
fn second(input: &mut &str) -> PResult<f64> {
|
||||
s(float).verify(|x| *x < 60.0).parse_next(input)
|
||||
}
|
||||
|
||||
/// Parse a timezone starting with `+` or `-`
|
||||
fn timezone(input: &mut &str) -> PResult<Offset> {
|
||||
seq!(plus_or_minus, alt((timezone_colon, timezone_colonless)))
|
||||
.map(|(negative, (hours, minutes))| Offset {
|
||||
negative,
|
||||
hours,
|
||||
minutes,
|
||||
})
|
||||
.parse_next(input)
|
||||
}
|
||||
|
||||
/// Parse a timezone offset with a colon separating hours and minutes
|
||||
fn timezone_colon(input: &mut &str) -> PResult<(u32, u32)> {
|
||||
seq!(
|
||||
s(take_while(1..=2, AsChar::is_dec_digit)).try_map(|x: &str| {
|
||||
// parse will fail on empty input
|
||||
if x == "" {
|
||||
Ok(0)
|
||||
} else {
|
||||
x.parse()
|
||||
}
|
||||
}),
|
||||
_: colon,
|
||||
s(take_while(1..=2, AsChar::is_dec_digit)).try_map(|x: &str| x.parse()),
|
||||
)
|
||||
.parse_next(input)
|
||||
}
|
||||
|
||||
/// Parse a timezone offset without colon
|
||||
fn timezone_colonless(input: &mut &str) -> PResult<(u32, u32)> {
|
||||
s(take_while(0..=4, AsChar::is_dec_digit))
|
||||
.verify_map(|x: &str| {
|
||||
Some(match x.len() {
|
||||
0 => (0, 0),
|
||||
1 | 2 => (x.parse().ok()?, 0),
|
||||
// The minutes are the last two characters here, for some reason.
|
||||
3 => (x[..1].parse().ok()?, x[1..].parse().ok()?),
|
||||
4 => (x[..2].parse().ok()?, x[2..].parse().ok()?),
|
||||
_ => unreachable!("We only take up to 4 characters"),
|
||||
})
|
||||
})
|
||||
.parse_next(input)
|
||||
}
|
||||
|
||||
/// Parse the plus or minus character and return whether it was negative
|
||||
fn plus_or_minus(input: &mut &str) -> PResult<bool> {
|
||||
s(alt(("+".value(false), "-".value(true)))).parse_next(input)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Offset, Time};
|
||||
use crate::items::time::parse;
|
||||
|
||||
#[test]
|
||||
fn simple() {
|
||||
let reference = Time {
|
||||
hour: 20,
|
||||
minute: 2,
|
||||
second: 0.0,
|
||||
offset: None,
|
||||
};
|
||||
|
||||
for mut s in [
|
||||
"20:02:00.000000",
|
||||
"20:02:00",
|
||||
"20:02+:00",
|
||||
"20:02-:00",
|
||||
"20----:02--(these hyphens are ignored)--:00",
|
||||
"20++++:02++(these plusses are ignored)++:00",
|
||||
"20: (A comment!) 02 (Another comment!) :00",
|
||||
"20:02 (A nested (comment!)) :00",
|
||||
"20:02 (So (many (nested) comments!!!!)) :00",
|
||||
"20 : 02 : 00.000000",
|
||||
"20:02",
|
||||
"20 : 02",
|
||||
"8:02pm",
|
||||
"8: 02 pm",
|
||||
"8:02p.m.",
|
||||
"8: 02 p.m.",
|
||||
] {
|
||||
let old_s = s.to_owned();
|
||||
assert_eq!(
|
||||
parse(&mut s).ok(),
|
||||
Some(reference.clone()),
|
||||
"Format string: {old_s}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hours_only() {
|
||||
let reference = Time {
|
||||
hour: 11,
|
||||
minute: 0,
|
||||
second: 0.0,
|
||||
offset: None,
|
||||
};
|
||||
|
||||
for mut s in [
|
||||
"11am",
|
||||
"11 am",
|
||||
"11 - am",
|
||||
"11 + am",
|
||||
"11 a.m.",
|
||||
"11 : 00",
|
||||
"11:00:00",
|
||||
] {
|
||||
let old_s = s.to_owned();
|
||||
assert_eq!(
|
||||
parse(&mut s).ok(),
|
||||
Some(reference.clone()),
|
||||
"Format string: {old_s}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noon() {
|
||||
let reference = Time {
|
||||
hour: 12,
|
||||
minute: 0,
|
||||
second: 0.0,
|
||||
offset: None,
|
||||
};
|
||||
|
||||
for mut s in [
|
||||
"12:00",
|
||||
"12pm",
|
||||
"12 pm",
|
||||
"12 (A comment!) pm",
|
||||
"12 pm",
|
||||
"12 p.m.",
|
||||
] {
|
||||
let old_s = s.to_owned();
|
||||
assert_eq!(
|
||||
parse(&mut s).ok(),
|
||||
Some(reference.clone()),
|
||||
"Format string: {old_s}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midnight() {
|
||||
let reference = Time {
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
second: 0.0,
|
||||
offset: None,
|
||||
};
|
||||
|
||||
for mut s in ["00:00", "12am"] {
|
||||
let old_s = s.to_owned();
|
||||
assert_eq!(
|
||||
parse(&mut s).ok(),
|
||||
Some(reference.clone()),
|
||||
"Format string: {old_s}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_hours() {
|
||||
let reference = Time {
|
||||
hour: 1,
|
||||
minute: 23,
|
||||
second: 0.0,
|
||||
offset: Some(Offset {
|
||||
negative: false,
|
||||
hours: 5,
|
||||
minutes: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
for mut s in [
|
||||
"1:23+5",
|
||||
"1:23 + 5",
|
||||
"1:23+05",
|
||||
"1:23 + 5 : 00",
|
||||
"1:23+05:00",
|
||||
"1:23+05:0",
|
||||
] {
|
||||
let old_s = s.to_owned();
|
||||
assert_eq!(
|
||||
parse(&mut s).ok(),
|
||||
Some(reference.clone()),
|
||||
"Format string: {old_s}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_hours_and_minutes() {
|
||||
let reference = Time {
|
||||
hour: 3,
|
||||
minute: 45,
|
||||
second: 0.0,
|
||||
offset: Some(Offset {
|
||||
negative: false,
|
||||
hours: 5,
|
||||
minutes: 35,
|
||||
}),
|
||||
};
|
||||
|
||||
for mut s in [
|
||||
"3:45+535",
|
||||
"3:45-+535",
|
||||
"03:45+535",
|
||||
"3 : 45 + 535",
|
||||
"3:45+0535",
|
||||
"3:45+5:35",
|
||||
"3:45+05:35",
|
||||
"3:45 + 05 : 35",
|
||||
] {
|
||||
let old_s = s.to_owned();
|
||||
assert_eq!(
|
||||
parse(&mut s).ok(),
|
||||
Some(reference.clone()),
|
||||
"Format string: {old_s}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_minutes() {
|
||||
let reference = Time {
|
||||
hour: 3,
|
||||
minute: 45,
|
||||
second: 0.0,
|
||||
offset: Some(Offset {
|
||||
negative: false,
|
||||
hours: 0,
|
||||
minutes: 35,
|
||||
}),
|
||||
};
|
||||
|
||||
for mut s in [
|
||||
"3:45+035",
|
||||
"03:45+035",
|
||||
"3 : 45 + 035",
|
||||
"3:45+0035",
|
||||
"3:45+0:35",
|
||||
"3:45+00:35",
|
||||
"3:45 + 00 : 35",
|
||||
] {
|
||||
let old_s = s.to_owned();
|
||||
assert_eq!(
|
||||
parse(&mut s).ok(),
|
||||
Some(reference.clone()),
|
||||
"Format string: {old_s}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_negative() {
|
||||
let reference = Time {
|
||||
hour: 3,
|
||||
minute: 45,
|
||||
second: 0.0,
|
||||
offset: Some(Offset {
|
||||
negative: true,
|
||||
hours: 5,
|
||||
minutes: 35,
|
||||
}),
|
||||
};
|
||||
|
||||
for mut s in [
|
||||
"3:45-535",
|
||||
"03:45-535",
|
||||
"3 : 45 - 535",
|
||||
"3:45-0535",
|
||||
"3:45-5:35",
|
||||
"3:45-05:35",
|
||||
"3:45 - 05 : 35",
|
||||
] {
|
||||
let old_s = s.to_owned();
|
||||
assert_eq!(
|
||||
parse(&mut s).ok(),
|
||||
Some(reference.clone()),
|
||||
"Format string: {old_s}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// For the full copyright and license information, please view the LICENSE
|
||||
// file that was distributed with this source code.
|
||||
|
||||
//! Parse a time zone items
|
||||
//!
|
||||
//! The GNU docs state:
|
||||
//!
|
||||
//! > Normally, dates are interpreted using the rules of the current time zone,
|
||||
//! > which in turn are specified by the TZ environment variable, or by a
|
||||
//! > system default if TZ is not set. To specify a different set of default
|
||||
//! > time zone rules that apply just to one date, start the date with a string
|
||||
//! > of the form ‘TZ="rule"’. The two quote characters (‘"’) must be present
|
||||
//! > in the date, and any quotes or backslashes within rule must be escaped by
|
||||
//! > a backslash.
|
||||
@@ -0,0 +1,124 @@
|
||||
// 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)]
|
||||
enum Day {
|
||||
Monday,
|
||||
Tuesday,
|
||||
Wednesday,
|
||||
Thursday,
|
||||
Friday,
|
||||
Saturday,
|
||||
Sunday,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug)]
|
||||
pub struct Weekday {
|
||||
offset: i32,
|
||||
day: Day,
|
||||
}
|
||||
|
||||
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.", "wednesday"] {
|
||||
let s = format!("last {s}");
|
||||
assert_eq!(
|
||||
parse(&mut s.as_ref()).unwrap(),
|
||||
Weekday {
|
||||
offset: -1,
|
||||
day: Day::Wednesday,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-341
@@ -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 items::Item;
|
||||
|
||||
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,327 +35,11 @@ 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.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `s` - A string slice representing the time.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use chrono::{DateTime, Utc, TimeZone};
|
||||
/// let time = parse_datetime::parse_datetime("2023-06-03 12:00:01Z");
|
||||
/// assert_eq!(time.unwrap(), Utc.with_ymd_and_hms(2023, 06, 03, 12, 00, 01).unwrap());
|
||||
/// ```
|
||||
///
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(DateTime<FixedOffset>)` - If the input string can be parsed as a time
|
||||
/// * `Err(ParseDateTimeError)` - If the input string cannot be parsed as a relative time
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 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,
|
||||
) -> Result<DateTime<FixedOffset>, ParseDateTimeError> {
|
||||
parse_datetime_at_date(Local::now(), s)
|
||||
}
|
||||
|
||||
/// Parses a time string at a specific date and returns a `DateTime` representing the
|
||||
/// absolute time of the string.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * date - The date represented in local time
|
||||
/// * `s` - A string slice representing the time.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use chrono::{Duration, Local};
|
||||
/// use parse_datetime::parse_datetime_at_date;
|
||||
///
|
||||
/// let now = Local::now();
|
||||
/// let after = parse_datetime_at_date(now, "+3 days");
|
||||
///
|
||||
/// assert_eq!(
|
||||
/// (now + Duration::days(3)).naive_utc(),
|
||||
/// after.unwrap().naive_utc()
|
||||
/// );
|
||||
/// ```
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(DateTime<FixedOffset>)` - If the input string can be parsed as a time
|
||||
/// * `Err(ParseDateTimeError)` - If the input string cannot be parsed as a relative time
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This function will return `Err(ParseDateTimeError::InvalidInput)` if the input string
|
||||
/// cannot be parsed as a relative time.
|
||||
pub fn parse_datetime_at_date<S: AsRef<str> + Clone>(
|
||||
date: DateTime<Local>,
|
||||
s: 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(()),
|
||||
pub fn parse_datetime(input: &str) -> Result<Vec<Item>, ParseDateTimeError> {
|
||||
let input = input.to_ascii_lowercase();
|
||||
match items::parse(&mut input.as_ref()) {
|
||||
Some(x) => Ok(x),
|
||||
None => Err(ParseDateTimeError::InvalidInput),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user