Skip to main content

temps_core/language/
english.rs

1use chumsky::{error::Rich, prelude::*, text};
2
3use crate::{
4    DayReference, DayTime, Direction, LanguageParser, Meridiem, RelativeTime, Result, StandardDate,
5    Time, TimeExpression, TimeUnit, Weekday, WeekdayModifier,
6    common::{
7        ParserError, digit_number, four_digit_number, iso_datetime, keyword_ci, two_digit_number,
8    },
9    error::rich_errors_to_temps_error,
10    time_utils,
11};
12
13/// Parser for English natural language time expressions.
14pub struct EnglishParser;
15
16fn whitespace_required<'a>() -> impl Parser<'a, &'a str, (), ParserError<'a>> + Clone {
17    one_of(" \t\n\r")
18        .labelled("whitespace")
19        .repeated()
20        .at_least(1)
21        .ignored()
22}
23
24fn number<'a>() -> impl Parser<'a, &'a str, i64, ParserError<'a>> + Clone {
25    choice((
26        digit_number(),
27        keyword_ci("an").to(1),
28        keyword_ci("one").to(1),
29        keyword_ci("a").to(1),
30        keyword_ci("two").to(2),
31        keyword_ci("three").to(3),
32        keyword_ci("four").to(4),
33        keyword_ci("five").to(5),
34        keyword_ci("six").to(6),
35        keyword_ci("seven").to(7),
36        keyword_ci("eight").to(8),
37        keyword_ci("nine").to(9),
38        keyword_ci("ten").to(10),
39    ))
40    .labelled("number")
41}
42
43fn time_unit<'a>() -> impl Parser<'a, &'a str, TimeUnit, ParserError<'a>> + Clone {
44    choice((
45        choice((
46            keyword_ci("seconds").to(TimeUnit::Second),
47            keyword_ci("second").to(TimeUnit::Second),
48            keyword_ci("secs").to(TimeUnit::Second),
49            keyword_ci("sec").to(TimeUnit::Second),
50        )),
51        choice((
52            keyword_ci("minutes").to(TimeUnit::Minute),
53            keyword_ci("minute").to(TimeUnit::Minute),
54            keyword_ci("mins").to(TimeUnit::Minute),
55            keyword_ci("min").to(TimeUnit::Minute),
56        )),
57        choice((
58            keyword_ci("hours").to(TimeUnit::Hour),
59            keyword_ci("hour").to(TimeUnit::Hour),
60            keyword_ci("hrs").to(TimeUnit::Hour),
61            keyword_ci("hr").to(TimeUnit::Hour),
62        )),
63        choice((
64            keyword_ci("days").to(TimeUnit::Day),
65            keyword_ci("day").to(TimeUnit::Day),
66        )),
67        choice((
68            keyword_ci("weeks").to(TimeUnit::Week),
69            keyword_ci("week").to(TimeUnit::Week),
70            keyword_ci("wks").to(TimeUnit::Week),
71            keyword_ci("wk").to(TimeUnit::Week),
72        )),
73        choice((
74            keyword_ci("months").to(TimeUnit::Month),
75            keyword_ci("month").to(TimeUnit::Month),
76            keyword_ci("mos").to(TimeUnit::Month),
77            keyword_ci("mo").to(TimeUnit::Month),
78        )),
79        choice((
80            keyword_ci("years").to(TimeUnit::Year),
81            keyword_ci("year").to(TimeUnit::Year),
82            keyword_ci("yrs").to(TimeUnit::Year),
83            keyword_ci("yr").to(TimeUnit::Year),
84        )),
85        choice((
86            keyword_ci("s").to(TimeUnit::Second),
87            keyword_ci("h").to(TimeUnit::Hour),
88            keyword_ci("d").to(TimeUnit::Day),
89            keyword_ci("w").to(TimeUnit::Week),
90            keyword_ci("y").to(TimeUnit::Year),
91            keyword_ci("m").to(TimeUnit::Minute),
92        )),
93    ))
94    .labelled("time unit")
95}
96
97fn weekday<'a>() -> impl Parser<'a, &'a str, Weekday, ParserError<'a>> + Clone {
98    choice((
99        choice((
100            keyword_ci("monday").to(Weekday::Monday),
101            keyword_ci("mon").to(Weekday::Monday),
102        )),
103        choice((
104            keyword_ci("tuesday").to(Weekday::Tuesday),
105            keyword_ci("tue").to(Weekday::Tuesday),
106        )),
107        choice((
108            keyword_ci("wednesday").to(Weekday::Wednesday),
109            keyword_ci("wed").to(Weekday::Wednesday),
110        )),
111        choice((
112            keyword_ci("thursday").to(Weekday::Thursday),
113            keyword_ci("thu").to(Weekday::Thursday),
114        )),
115        choice((
116            keyword_ci("friday").to(Weekday::Friday),
117            keyword_ci("fri").to(Weekday::Friday),
118        )),
119        choice((
120            keyword_ci("saturday").to(Weekday::Saturday),
121            keyword_ci("sat").to(Weekday::Saturday),
122        )),
123        choice((
124            keyword_ci("sunday").to(Weekday::Sunday),
125            keyword_ci("sun").to(Weekday::Sunday),
126        )),
127    ))
128    .labelled("weekday")
129}
130
131fn day_shortcuts<'a>() -> impl Parser<'a, &'a str, DayReference, ParserError<'a>> + Clone {
132    choice((
133        keyword_ci("today").to(DayReference::Today),
134        keyword_ci("yesterday").to(DayReference::Yesterday),
135        keyword_ci("tomorrow").to(DayReference::Tomorrow),
136    ))
137}
138
139fn weekday_modifier<'a>() -> impl Parser<'a, &'a str, WeekdayModifier, ParserError<'a>> + Clone {
140    choice((
141        keyword_ci("last").to(WeekdayModifier::Last),
142        keyword_ci("next").to(WeekdayModifier::Next),
143    ))
144}
145
146fn modified_weekday<'a>() -> impl Parser<'a, &'a str, DayReference, ParserError<'a>> + Clone {
147    weekday_modifier()
148        .then_ignore(whitespace_required())
149        .then(weekday())
150        .map(|(modifier, day)| DayReference::Weekday {
151            day,
152            modifier: Some(modifier),
153        })
154}
155
156fn simple_weekday<'a>() -> impl Parser<'a, &'a str, DayReference, ParserError<'a>> + Clone {
157    weekday().map(|day| DayReference::Weekday {
158        day,
159        modifier: None,
160    })
161}
162
163fn day_reference<'a>() -> impl Parser<'a, &'a str, DayReference, ParserError<'a>> + Clone {
164    choice((day_shortcuts(), modified_weekday(), simple_weekday()))
165}
166
167fn meridiem<'a>() -> impl Parser<'a, &'a str, Meridiem, ParserError<'a>> + Clone {
168    choice((
169        keyword_ci("a.m.").to(Meridiem::AM),
170        keyword_ci("p.m.").to(Meridiem::PM),
171        keyword_ci("am").to(Meridiem::AM),
172        keyword_ci("pm").to(Meridiem::PM),
173    ))
174    .labelled("am/pm")
175}
176
177fn time_with_minutes<'a>()
178-> impl Parser<'a, &'a str, (u8, u8, u8, Option<Meridiem>), ParserError<'a>> + Clone {
179    two_digit_number()
180        .then_ignore(just(':'))
181        .then(two_digit_number())
182        .then(just(':').ignore_then(two_digit_number()).or_not())
183        .then(text::whitespace().ignore_then(meridiem()).or_not())
184        .try_map(|(((hour, minute), second), mer), span| {
185            let second = second.unwrap_or(0);
186            if time_utils::is_valid_time(hour, minute, second, mer) {
187                Ok((hour, minute, second, mer))
188            } else {
189                Err(Rich::custom(span, "invalid time"))
190            }
191        })
192}
193
194fn hour_meridiem<'a>()
195-> impl Parser<'a, &'a str, (u8, u8, u8, Option<Meridiem>), ParserError<'a>> + Clone {
196    two_digit_number()
197        .then(text::whitespace().ignore_then(meridiem()))
198        .try_map(|(hour, mer), span| {
199            if time_utils::is_valid_time(hour, 0, 0, Some(mer)) {
200                Ok((hour, 0, 0, Some(mer)))
201            } else {
202                Err(Rich::custom(span, "invalid time"))
203            }
204        })
205}
206
207fn time_digits<'a>()
208-> impl Parser<'a, &'a str, (u8, u8, u8, Option<Meridiem>), ParserError<'a>> + Clone {
209    choice((time_with_minutes(), hour_meridiem()))
210}
211
212fn time_expr<'a>() -> impl Parser<'a, &'a str, TimeExpression, ParserError<'a>> + Clone {
213    time_digits().map(|(hour, minute, second, meridiem)| {
214        TimeExpression::Time(Time {
215            hour,
216            minute,
217            second,
218            meridiem,
219        })
220    })
221}
222
223fn day_at_time<'a>() -> impl Parser<'a, &'a str, TimeExpression, ParserError<'a>> + Clone {
224    day_reference()
225        .then_ignore(whitespace_required())
226        .then_ignore(keyword_ci("at"))
227        .then_ignore(whitespace_required())
228        .then(time_digits())
229        .map(|(day, (hour, minute, second, meridiem))| {
230            TimeExpression::DayTime(DayTime {
231                day,
232                time: Time {
233                    hour,
234                    minute,
235                    second,
236                    meridiem,
237                },
238            })
239        })
240}
241
242fn relative_past<'a>() -> impl Parser<'a, &'a str, TimeExpression, ParserError<'a>> + Clone {
243    number()
244        .then_ignore(whitespace_required())
245        .then(time_unit())
246        .then_ignore(whitespace_required())
247        .then_ignore(keyword_ci("ago"))
248        .map(|(amount, unit)| {
249            TimeExpression::Relative(RelativeTime {
250                amount,
251                unit,
252                direction: Direction::Past,
253            })
254        })
255}
256
257fn relative_future<'a>() -> impl Parser<'a, &'a str, TimeExpression, ParserError<'a>> + Clone {
258    keyword_ci("in")
259        .ignore_then(whitespace_required())
260        .ignore_then(number())
261        .then_ignore(whitespace_required())
262        .then(time_unit())
263        .map(|(amount, unit)| {
264            TimeExpression::Relative(RelativeTime {
265                amount,
266                unit,
267                direction: Direction::Future,
268            })
269        })
270}
271
272fn now_expr<'a>() -> impl Parser<'a, &'a str, TimeExpression, ParserError<'a>> + Clone {
273    keyword_ci("now").to(TimeExpression::Now)
274}
275
276fn date_format<'a>() -> impl Parser<'a, &'a str, TimeExpression, ParserError<'a>> + Clone {
277    let iso_like = four_digit_number()
278        .then_ignore(just('-'))
279        .then(two_digit_number())
280        .then_ignore(just('-'))
281        .then(two_digit_number())
282        .try_map(|((year, month), day), span| {
283            if time_utils::is_valid_calendar_date(year, month, day) {
284                Ok(TimeExpression::Date(StandardDate { day, month, year }))
285            } else {
286                Err(Rich::custom(span, "invalid calendar date"))
287            }
288        });
289
290    let international = two_digit_number()
291        .then(one_of(['/', '-']))
292        .then(two_digit_number())
293        .then(one_of(['/', '-']))
294        .then(four_digit_number())
295        .try_map(|((((day, first), month), second), year), span| {
296            if first == second && time_utils::is_valid_calendar_date(year, month, day) {
297                Ok(TimeExpression::Date(StandardDate { day, month, year }))
298            } else {
299                Err(Rich::custom(span, "invalid date"))
300            }
301        });
302
303    choice((iso_like, international))
304}
305
306fn parser<'a>() -> impl Parser<'a, &'a str, TimeExpression, ParserError<'a>> {
307    choice((
308        iso_datetime().labelled("ISO 8601 datetime"),
309        date_format().labelled("calendar date"),
310        day_at_time().labelled("day with time"),
311        now_expr().labelled("`now`"),
312        day_reference()
313            .map(TimeExpression::Day)
314            .labelled("day reference"),
315        time_expr().labelled("time of day"),
316        relative_past().labelled("`<n> <unit> ago`"),
317        relative_future().labelled("`in <n> <unit>`"),
318    ))
319    .padded()
320    .then_ignore(end())
321}
322
323impl LanguageParser for EnglishParser {
324    fn parse(&self, input: &str) -> Result<TimeExpression> {
325        parser()
326            .parse(input)
327            .into_result()
328            .map_err(|errs| rich_errors_to_temps_error(input, errs))
329    }
330}