| //! ## Parsing English Dates |
| //! |
| //! I've always admired the ability of the GNU `date` command to |
| //! convert "English" expressions to dates and times with `date -d expr`. |
| //! `chrono-english` does similar expressions, although with extensions, so |
| //! that for instance you can specify both the day and the time "next friday 8pm". |
| //! No attempt at full natural language parsing is made - only a limited set of |
| //! patterns is supported. |
| //! |
| //! ## Supported Formats |
| //! |
| //! `chrono-english` does _absolute_ dates: ISO-like dates "2018-04-01" and the month name forms |
| //! "1 April 2018" and "April 1, 2018". (There's no ambiguity so both of these forms are fine) |
| //! |
| //! The informal "01/04/18" or American form "04/01/18" is supported. |
| //! There is a `Dialect` enum to specify what kind of date English you would like to speak. |
| //! Both short and long years are accepted in this form; short dates pivot between 1940 and 2040. |
| //! |
| //! Then there are are _relative_ dates like 'April 1' and '9/11' (this |
| //! if using `Dialect::Us`). The current year is assumed, but this can be modified by 'next' |
| //! and 'last'. For instance, it is now the 13th of March, 2018: 'April 1' and 'next April 1' |
| //! are in 2018; 'last April 1' is in 2017. |
| //! |
| //! Another relative form is simply a month name |
| //! like 'apr' or 'April' (case-insensitive, only first three letters significant) where the |
| //! day is assumed to be the 1st. |
| //! |
| //! A week-day works in the same way: 'friday' means this |
| //! coming Friday, relative to today. 'last Friday' is unambiguous, |
| //! but 'next Friday' has different meanings; in the US it means the same as 'Friday' |
| //! but otherwise it means the Friday of next week (plus 7 days) |
| //! |
| //! Date and time can be specified also by a number of time units. So "2 days", "3 hours". |
| //! Again, first three letters, but 'd','m' and 'y' are understood (so "3h"). We make |
| //! a distinction between _second_ intervals (seconds,minutes,hours,days,weeks) and _month_ |
| //! intervals (months,years). Month intervals always give us the same date, if possible |
| //! But adding a month to "30 Jan" will give "28 Feb" or "29 Feb" depending if a leap year. |
| //! |
| //! Finally, dates may be followed by time. Either 'formal' like 18:03, with optional |
| //! second (like 18:03:40) or 'informal' like 6.03pm. So one gets "next friday 8pm' and so |
| //! forth. |
| //! |
| //! ## API |
| //! |
| //! There are two entry points: `parse_date_string` and `parse_duration`. The |
| //! first is given the date string, a `DateTime` from which relative dates and |
| //! times operate, and a dialect (either `Dialect::Uk` or `Dialect::Us` |
| //! currently.) The base time also specifies the desired timezone. |
| //! |
| //! ```ignore |
| //! extern crate chrono_english; |
| //! extern crate chrono; |
| //! use chrono_english::{parse_date_string,Dialect}; |
| //! |
| //! use chrono::prelude::*; |
| //! |
| //! let date_time = parse_date_string("next friday 8pm", Local::now(), Dialect::Uk)?; |
| //! println!("{}",date_time.format("%c")); |
| //! ``` |
| //! |
| //! There is a little command-line program `parse-date` in the `examples` folder which can be used to play |
| //! with these expressions. |
| //! |
| //! The other function, `parse_duration`, lets you access just the relative part |
| //! of a string like 'two days ago' or '12 hours'. If successful, returns an |
| //! `Interval`, which is a number of seconds, days, or months. |
| //! |
| //! ``` |
| //! use chrono_english::{parse_duration,Interval}; |
| //! |
| //! assert_eq!(parse_duration("15m ago").unwrap(), Interval::Seconds(-15 * 60)); |
| //! ``` |
| //! |
| |
| extern crate chrono; |
| extern crate scanlex; |
| use chrono::prelude::*; |
| |
| mod errors; |
| mod parser; |
| mod types; |
| use errors::*; |
| use types::*; |
| |
| pub use errors::{DateError, DateResult}; |
| pub use types::Interval; |
| |
| #[derive(Clone, Copy, Debug)] |
| pub enum Dialect { |
| Uk, |
| Us, |
| } |
| |
| pub fn parse_date_string<Tz: TimeZone>( |
| s: &str, |
| now: DateTime<Tz>, |
| dialect: Dialect, |
| ) -> DateResult<DateTime<Tz>> |
| where |
| Tz::Offset: Copy, |
| { |
| let mut dp = parser::DateParser::new(s); |
| if let Dialect::Us = dialect { |
| dp = dp.american_date(); |
| } |
| let d = dp.parse()?; |
| |
| // we may have explicit hour:minute:sec |
| let tspec = match d.time { |
| Some(tspec) => tspec, |
| None => TimeSpec::new_empty(), |
| }; |
| if tspec.offset.is_some() { |
| // return DateTime::fix()::parse_from_rfc3339(s); |
| } |
| let date_time = if let Some(dspec) = d.date { |
| dspec |
| .to_date_time(now, tspec, dp.american) |
| .or_err("bad date")? |
| } else { |
| // no date, time set for today's date |
| tspec.to_date_time(now.date()).or_err("bad time")? |
| }; |
| Ok(date_time) |
| } |
| |
| pub fn parse_duration(s: &str) -> DateResult<Interval> { |
| let mut dp = parser::DateParser::new(s); |
| let d = dp.parse()?; |
| |
| if d.time.is_some() { |
| return date_result("unexpected time component"); |
| } |
| |
| // shouldn't happen, but. |
| if d.date.is_none() { |
| return date_result("could not parse date"); |
| } |
| |
| match d.date.unwrap() { |
| DateSpec::Absolute(_) => date_result("unexpected absolute date"), |
| DateSpec::FromName(_) => date_result("unexpected date component"), |
| DateSpec::Relative(skip) => Ok(skip.to_interval()), |
| } |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| |
| const FMT_ISO: &str = "%+"; |
| |
| fn display(t: DateResult<DateTime<Utc>>) -> String { |
| t.unwrap().format(FMT_ISO).to_string() |
| } |
| |
| #[test] |
| fn basics() { |
| let base = parse_date_string("2018-03-21 11:00", Utc::now(), Dialect::Uk).unwrap(); |
| |
| // Day of week - relative to today. May have a time part |
| assert_eq!( |
| display(parse_date_string("friday", base, Dialect::Uk)), |
| "2018-03-23T00:00:00+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string("friday 10:30", base, Dialect::Uk)), |
| "2018-03-23T10:30:00+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string("friday 8pm", base, Dialect::Uk)), |
| "2018-03-23T20:00:00+00:00" |
| ); |
| |
| // The day of week is the _next_ day after today, so "Tuesday" is the next Tuesday after Wednesday |
| assert_eq!( |
| display(parse_date_string("tues", base, Dialect::Uk)), |
| "2018-03-27T00:00:00+00:00" |
| ); |
| |
| // The expression 'next Monday' is ambiguous; in the US it means the day following (same as 'Monday') |
| // (This is how the `date` command interprets it) |
| assert_eq!( |
| display(parse_date_string("next mon", base, Dialect::Us)), |
| "2018-03-26T00:00:00+00:00" |
| ); |
| // but otherwise it means the day in the next week.. |
| assert_eq!( |
| display(parse_date_string("next mon", base, Dialect::Uk)), |
| "2018-04-02T00:00:00+00:00" |
| ); |
| |
| assert_eq!( |
| display(parse_date_string("last fri 9.30", base, Dialect::Uk)), |
| "2018-03-16T09:30:00+00:00" |
| ); |
| |
| // date expressed as month, day - relative to today. May have a time part |
| assert_eq!( |
| display(parse_date_string("9/11", base, Dialect::Us)), |
| "2018-09-11T00:00:00+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string("last 9/11", base, Dialect::Us)), |
| "2017-09-11T00:00:00+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string("last 9/11 9am", base, Dialect::Us)), |
| "2017-09-11T09:00:00+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string("April 1 8.30pm", base, Dialect::Uk)), |
| "2018-04-01T20:30:00+00:00" |
| ); |
| |
| // advance by time unit from today |
| // without explicit time, use base time - otherwise override |
| assert_eq!( |
| display(parse_date_string("2d", base, Dialect::Uk)), |
| "2018-03-23T11:00:00+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string("2d 03:00", base, Dialect::Uk)), |
| "2018-03-23T03:00:00+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string("3 weeks", base, Dialect::Uk)), |
| "2018-04-11T11:00:00+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string("3h", base, Dialect::Uk)), |
| "2018-03-21T14:00:00+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string("6 months", base, Dialect::Uk)), |
| "2018-09-21T00:00:00+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string("6 months ago", base, Dialect::Uk)), |
| "2017-09-21T00:00:00+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string("3 hours ago", base, Dialect::Uk)), |
| "2018-03-21T08:00:00+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string(" -3h", base, Dialect::Uk)), |
| "2018-03-21T08:00:00+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string(" -3 month", base, Dialect::Uk)), |
| "2017-12-21T00:00:00+00:00" |
| ); |
| |
| // absolute date with year, month, day - formal ISO and informal UK or US |
| assert_eq!( |
| display(parse_date_string("2017-06-30", base, Dialect::Uk)), |
| "2017-06-30T00:00:00+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string("30/06/17", base, Dialect::Uk)), |
| "2017-06-30T00:00:00+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string("06/30/17", base, Dialect::Us)), |
| "2017-06-30T00:00:00+00:00" |
| ); |
| |
| // may be followed by time part, formal and informal |
| assert_eq!( |
| display(parse_date_string("2017-06-30 08:20:30", base, Dialect::Uk)), |
| "2017-06-30T08:20:30+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string( |
| "2017-06-30 08:20:30 +02:00", |
| base, |
| Dialect::Uk |
| )), |
| "2017-06-30T06:20:30+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string( |
| "2017-06-30 08:20:30 +0200", |
| base, |
| Dialect::Uk |
| )), |
| "2017-06-30T06:20:30+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string("2017-06-30T08:20:30Z", base, Dialect::Uk)), |
| "2017-06-30T08:20:30+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string("2017-06-30T08:20:30", base, Dialect::Uk)), |
| "2017-06-30T08:20:30+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string("2017-06-30 8.20", base, Dialect::Uk)), |
| "2017-06-30T08:20:00+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string("2017-06-30 8.30pm", base, Dialect::Uk)), |
| "2017-06-30T20:30:00+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string("2017-06-30 8:30pm", base, Dialect::Uk)), |
| "2017-06-30T20:30:00+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string("2017-06-30 2am", base, Dialect::Uk)), |
| "2017-06-30T02:00:00+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string("30 June 2018", base, Dialect::Uk)), |
| "2018-06-30T00:00:00+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string("June 30, 2018", base, Dialect::Uk)), |
| "2018-06-30T00:00:00+00:00" |
| ); |
| assert_eq!( |
| display(parse_date_string("June 30, 2018", base, Dialect::Uk)), |
| "2018-06-30T00:00:00+00:00" |
| ); |
| } |
| |
| fn get_err(r: DateResult<Interval>) -> String { |
| r.err().unwrap().to_string() |
| } |
| |
| #[test] |
| fn durations() { |
| assert_eq!(parse_duration("6h").unwrap(), Interval::Seconds(6 * 3600)); |
| assert_eq!( |
| parse_duration("4 hours ago").unwrap(), |
| Interval::Seconds(-4 * 3600) |
| ); |
| assert_eq!(parse_duration("5 min").unwrap(), Interval::Seconds(5 * 60)); |
| assert_eq!(parse_duration("10m").unwrap(), Interval::Seconds(10 * 60)); |
| assert_eq!( |
| parse_duration("15m ago").unwrap(), |
| Interval::Seconds(-15 * 60) |
| ); |
| |
| assert_eq!(parse_duration("1 day").unwrap(), Interval::Days(1)); |
| assert_eq!(parse_duration("2 days ago").unwrap(), Interval::Days(-2)); |
| assert_eq!(parse_duration("3 weeks").unwrap(), Interval::Days(21)); |
| assert_eq!(parse_duration("2 weeks ago").unwrap(), Interval::Days(-14)); |
| |
| assert_eq!(parse_duration("1 month").unwrap(), Interval::Months(1)); |
| assert_eq!(parse_duration("6 months").unwrap(), Interval::Months(6)); |
| assert_eq!(parse_duration("8 years").unwrap(), Interval::Months(12 * 8)); |
| |
| // errors |
| assert_eq!( |
| get_err(parse_duration("2020-01-01")), |
| "unexpected absolute date" |
| ); |
| assert_eq!( |
| get_err(parse_duration("2 days 15:00")), |
| "unexpected time component" |
| ); |
| assert_eq!( |
| get_err(parse_duration("tuesday")), |
| "unexpected date component" |
| ); |
| assert_eq!( |
| get_err(parse_duration("bananas")), |
| "expected week day or month name" |
| ); |
| } |
| } |