Skip to main content

temps_core/language/
english.rs

1use chumsky::{error::Rich, prelude::*};
2
3use crate::{
4    DayReference, DayTime, Direction, LanguageParser, Meridiem, RelativeTime, Result, StandardDate,
5    Time, TimeExpression, TimeUnit, Weekday, WeekdayModifier,
6    common::{
7        ParserError, TokenInput, digit_number, four_digit_number, iso_datetime, opt_space,
8        phrase_ci, phrases_ci, punct, space, token_stream, two_digit_number, word_ci,
9    },
10    error::rich_errors_to_temps_error,
11    lexer::lex,
12    time_utils,
13};
14
15/// Parser for English natural language time expressions.
16pub struct EnglishParser;
17
18fn number<'t, 's: 't, I>() -> impl Parser<'t, I, i64, ParserError<'t, 's>> + Clone
19where
20    I: TokenInput<'t, 's>,
21{
22    choice((
23        digit_number(),
24        phrases_ci([
25            ("a", 1i64),
26            ("an", 1),
27            ("one", 1),
28            ("two", 2),
29            ("three", 3),
30            ("four", 4),
31            ("five", 5),
32            ("six", 6),
33            ("seven", 7),
34            ("eight", 8),
35            ("nine", 9),
36            ("ten", 10),
37            ("a couple", 2),
38            ("a couple of", 2),
39            ("couple of", 2),
40            ("a few", 3),
41            ("a dozen", 12),
42        ]),
43    ))
44    .labelled("number")
45}
46
47fn time_unit<'t, 's: 't, I>() -> impl Parser<'t, I, TimeUnit, ParserError<'t, 's>> + Clone
48where
49    I: TokenInput<'t, 's>,
50{
51    phrases_ci([
52        ("second", TimeUnit::Second),
53        ("seconds", TimeUnit::Second),
54        ("sec", TimeUnit::Second),
55        ("secs", TimeUnit::Second),
56        ("s", TimeUnit::Second),
57        ("minute", TimeUnit::Minute),
58        ("minutes", TimeUnit::Minute),
59        ("min", TimeUnit::Minute),
60        ("mins", TimeUnit::Minute),
61        ("m", TimeUnit::Minute),
62        ("hour", TimeUnit::Hour),
63        ("hours", TimeUnit::Hour),
64        ("hr", TimeUnit::Hour),
65        ("hrs", TimeUnit::Hour),
66        ("h", TimeUnit::Hour),
67        ("day", TimeUnit::Day),
68        ("days", TimeUnit::Day),
69        ("d", TimeUnit::Day),
70        ("week", TimeUnit::Week),
71        ("weeks", TimeUnit::Week),
72        ("wk", TimeUnit::Week),
73        ("wks", TimeUnit::Week),
74        ("w", TimeUnit::Week),
75        ("month", TimeUnit::Month),
76        ("months", TimeUnit::Month),
77        ("mo", TimeUnit::Month),
78        ("mos", TimeUnit::Month),
79        ("year", TimeUnit::Year),
80        ("years", TimeUnit::Year),
81        ("yr", TimeUnit::Year),
82        ("yrs", TimeUnit::Year),
83        ("y", TimeUnit::Year),
84    ])
85    .labelled("time unit")
86}
87
88/// A unit as written, paired with how many of [`TimeUnit`] one of it is worth.
89///
90/// `TimeUnit` has no `Fortnight` variant and gains none here: a fortnight is
91/// two weeks, so the colloquial unit is carried as the ordinary
92/// [`TimeUnit::Week`] plus a multiplier that [`amount_and_unit`] applies to the
93/// parsed amount.
94fn scaled_time_unit<'t, 's: 't, I>()
95-> impl Parser<'t, I, (TimeUnit, i64), ParserError<'t, 's>> + Clone
96where
97    I: TokenInput<'t, 's>,
98{
99    choice((
100        phrases_ci([("fortnight", ()), ("fortnights", ())]).to((TimeUnit::Week, 2i64)),
101        time_unit().map(|unit| (unit, 1i64)),
102    ))
103    .labelled("time unit")
104}
105
106/// `<number> <unit>`, with a colloquial unit's multiplier already folded into
107/// the amount.
108///
109/// The multiplication is checked because the amount comes straight from user
110/// input: `in 9223372036854775807 fortnights` must be rejected as
111/// unrepresentable rather than wrap into a plausible-looking past date.
112fn amount_and_unit<'t, 's: 't, I>()
113-> impl Parser<'t, I, (i64, TimeUnit), ParserError<'t, 's>> + Clone
114where
115    I: TokenInput<'t, 's>,
116{
117    number()
118        .then_ignore(space())
119        .then(scaled_time_unit())
120        .try_map(|(amount, (unit, per_unit)), span| {
121            amount
122                .checked_mul(per_unit)
123                .map(|amount| (amount, unit))
124                .ok_or_else(|| Rich::custom(span, "amount out of range"))
125        })
126}
127
128fn weekday<'t, 's: 't, I>() -> impl Parser<'t, I, Weekday, ParserError<'t, 's>> + Clone
129where
130    I: TokenInput<'t, 's>,
131{
132    phrases_ci([
133        ("monday", Weekday::Monday),
134        ("mon", Weekday::Monday),
135        ("tuesday", Weekday::Tuesday),
136        ("tue", Weekday::Tuesday),
137        ("wednesday", Weekday::Wednesday),
138        ("wed", Weekday::Wednesday),
139        ("thursday", Weekday::Thursday),
140        ("thu", Weekday::Thursday),
141        ("friday", Weekday::Friday),
142        ("fri", Weekday::Friday),
143        ("saturday", Weekday::Saturday),
144        ("sat", Weekday::Saturday),
145        ("sunday", Weekday::Sunday),
146        ("sun", Weekday::Sunday),
147    ])
148    .labelled("weekday")
149}
150
151fn day_shortcuts<'t, 's: 't, I>() -> impl Parser<'t, I, DayReference, ParserError<'t, 's>> + Clone
152where
153    I: TokenInput<'t, 's>,
154{
155    phrases_ci([
156        ("today", DayReference::Today),
157        ("yesterday", DayReference::Yesterday),
158        ("tomorrow", DayReference::Tomorrow),
159        ("day after tomorrow", DayReference::DayAfterTomorrow),
160        ("day before yesterday", DayReference::DayBeforeYesterday),
161    ])
162}
163
164fn weekday_modifier<'t, 's: 't, I>()
165-> impl Parser<'t, I, WeekdayModifier, ParserError<'t, 's>> + Clone
166where
167    I: TokenInput<'t, 's>,
168{
169    choice((
170        word_ci("last").to(WeekdayModifier::Last),
171        word_ci("next").to(WeekdayModifier::Next),
172    ))
173}
174
175fn modified_weekday<'t, 's: 't, I>() -> impl Parser<'t, I, DayReference, ParserError<'t, 's>> + Clone
176where
177    I: TokenInput<'t, 's>,
178{
179    weekday_modifier()
180        .then_ignore(space())
181        .then(weekday())
182        .map(|(modifier, day)| DayReference::Weekday {
183            day,
184            modifier: Some(modifier),
185        })
186}
187
188fn simple_weekday<'t, 's: 't, I>() -> impl Parser<'t, I, DayReference, ParserError<'t, 's>> + Clone
189where
190    I: TokenInput<'t, 's>,
191{
192    weekday().map(|day| DayReference::Weekday {
193        day,
194        modifier: None,
195    })
196}
197
198fn day_reference<'t, 's: 't, I>() -> impl Parser<'t, I, DayReference, ParserError<'t, 's>> + Clone
199where
200    I: TokenInput<'t, 's>,
201{
202    // A plain `choice` needs no left-factoring here: no alternative below can
203    // succeed on a proper token-prefix of another one's match, which is the
204    // only way `choice`'s commit-on-success could pick the wrong branch.
205    //
206    // The near misses, all of which resolve by backtracking:
207    //   - `the day after tomorrow` / `the day before yesterday` share `the day`
208    //     but diverge on the third token, so neither ever succeeds first;
209    //   - `next weekend` and `next Monday` share `next`, and `weekend` is not a
210    //     weekday, so `modified_weekday` fails without consuming;
211    //   - likewise `this weekend` against `this Monday`.
212    choice((
213        the_day_reference(),
214        day_shortcuts(),
215        modified_weekday(),
216        this_weekday(),
217        weekend_ref(),
218        simple_weekday(),
219    ))
220}
221
222fn meridiem<'t, 's: 't, I>() -> impl Parser<'t, I, Meridiem, ParserError<'t, 's>> + Clone
223where
224    I: TokenInput<'t, 's>,
225{
226    phrases_ci([
227        ("am", Meridiem::AM),
228        ("pm", Meridiem::PM),
229        ("a.m.", Meridiem::AM),
230        ("p.m.", Meridiem::PM),
231    ])
232    .labelled("am/pm")
233}
234
235fn time_with_minutes<'t, 's: 't, I>()
236-> impl Parser<'t, I, (u8, u8, u8, Option<Meridiem>), ParserError<'t, 's>> + Clone
237where
238    I: TokenInput<'t, 's>,
239{
240    two_digit_number()
241        .then_ignore(punct(':'))
242        .then(two_digit_number())
243        .then(punct(':').ignore_then(two_digit_number()).or_not())
244        .then(opt_space().ignore_then(meridiem()).or_not())
245        .try_map(|(((hour, minute), second), mer), span| {
246            let second = second.unwrap_or(0);
247            if time_utils::is_valid_time(hour, minute, second, mer) {
248                Ok((hour, minute, second, mer))
249            } else {
250                Err(Rich::custom(span, "invalid time"))
251            }
252        })
253}
254
255fn hour_meridiem<'t, 's: 't, I>()
256-> impl Parser<'t, I, (u8, u8, u8, Option<Meridiem>), ParserError<'t, 's>> + Clone
257where
258    I: TokenInput<'t, 's>,
259{
260    two_digit_number()
261        .then(opt_space().ignore_then(meridiem()))
262        .try_map(|(hour, mer), span| {
263            if time_utils::is_valid_time(hour, 0, 0, Some(mer)) {
264                Ok((hour, 0, 0, Some(mer)))
265            } else {
266                Err(Rich::custom(span, "invalid time"))
267            }
268        })
269}
270
271fn time_digits<'t, 's: 't, I>()
272-> impl Parser<'t, I, (u8, u8, u8, Option<Meridiem>), ParserError<'t, 's>> + Clone
273where
274    I: TokenInput<'t, 's>,
275{
276    choice((time_with_minutes(), hour_meridiem()))
277}
278
279/// Parse a raw hour (number or named time like "noon") for use in fractional expressions.
280fn raw_hour<'t, 's: 't, I>() -> impl Parser<'t, I, u8, ParserError<'t, 's>> + Clone
281where
282    I: TokenInput<'t, 's>,
283{
284    choice((
285        two_digit_number().try_map(|h, span| {
286            if h <= 23 {
287                Ok(h)
288            } else {
289                Err(Rich::custom(span, "hour must be 0-23"))
290            }
291        }),
292        word_ci("noon").to(12u8),
293        word_ci("midnight").to(0u8),
294    ))
295}
296
297/// Parse fractional time: "half past X", "quarter past X", "quarter to X".
298fn fractional_time<'t, 's: 't, I>()
299-> impl Parser<'t, I, (u8, u8, u8, Option<Meridiem>), ParserError<'t, 's>> + Clone
300where
301    I: TokenInput<'t, 's>,
302{
303    let half_past = phrase_ci("half past")
304        .ignore_then(space())
305        .ignore_then(raw_hour())
306        .map(|h| (h, 30u8, 0u8, None::<Meridiem>));
307
308    let quarter_past = phrase_ci("quarter past")
309        .ignore_then(space())
310        .ignore_then(raw_hour())
311        .map(|h| (h, 15u8, 0u8, None::<Meridiem>));
312
313    let quarter_to = phrase_ci("quarter to")
314        .ignore_then(space())
315        .ignore_then(raw_hour())
316        .map(|h| {
317            if h == 0 {
318                (23u8, 45u8, 0u8, None::<Meridiem>)
319            } else {
320                (h - 1, 45u8, 0u8, None::<Meridiem>)
321            }
322        });
323
324    choice((half_past, quarter_past, quarter_to)).try_map(|(hour, minute, second, mer), span| {
325        if time_utils::is_valid_time(hour, minute, second, mer) {
326            Ok((hour, minute, second, mer))
327        } else {
328            Err(Rich::custom(span, "invalid time"))
329        }
330    })
331}
332
333fn time_expr<'t, 's: 't, I>() -> impl Parser<'t, I, TimeExpression, ParserError<'t, 's>> + Clone
334where
335    I: TokenInput<'t, 's>,
336{
337    choice((
338        fractional_time().map(|(hour, minute, second, meridiem)| {
339            TimeExpression::Time(Time {
340                hour,
341                minute,
342                second,
343                meridiem,
344            })
345        }),
346        time_digits().map(|(hour, minute, second, meridiem)| {
347            TimeExpression::Time(Time {
348                hour,
349                minute,
350                second,
351                meridiem,
352            })
353        }),
354    ))
355}
356
357fn named_time<'t, 's: 't, I>() -> impl Parser<'t, I, TimeExpression, ParserError<'t, 's>> + Clone
358where
359    I: TokenInput<'t, 's>,
360{
361    choice((
362        word_ci("noon").to(TimeExpression::Time(Time {
363            hour: 12,
364            minute: 0,
365            second: 0,
366            meridiem: None,
367        })),
368        word_ci("midnight").to(TimeExpression::Time(Time {
369            hour: 0,
370            minute: 0,
371            second: 0,
372            meridiem: None,
373        })),
374        word_ci("teatime").to(TimeExpression::Time(Time {
375            hour: 16,
376            minute: 0,
377            second: 0,
378            meridiem: None,
379        })),
380    ))
381}
382
383/// Parse part-of-day: "morning", "afternoon", "evening", "night".
384/// Returns a Time with a default hour.
385fn part_of_day<'t, 's: 't, I>() -> impl Parser<'t, I, Time, ParserError<'t, 's>> + Clone
386where
387    I: TokenInput<'t, 's>,
388{
389    choice((
390        word_ci("morning").to(Time {
391            hour: 8,
392            minute: 0,
393            second: 0,
394            meridiem: None,
395        }),
396        word_ci("afternoon").to(Time {
397            hour: 13,
398            minute: 0,
399            second: 0,
400            meridiem: None,
401        }),
402        word_ci("evening").to(Time {
403            hour: 18,
404            minute: 0,
405            second: 0,
406            meridiem: None,
407        }),
408        word_ci("night").to(Time {
409            hour: 20,
410            minute: 0,
411            second: 0,
412            meridiem: None,
413        }),
414    ))
415}
416
417/// "this" + day-like expression: "this morning", "this afternoon", "this evening".
418fn this_part_of_day<'t, 's: 't, I>()
419-> impl Parser<'t, I, TimeExpression, ParserError<'t, 's>> + Clone
420where
421    I: TokenInput<'t, 's>,
422{
423    word_ci("this")
424        .ignore_then(space())
425        .ignore_then(part_of_day())
426        .map(|time| {
427            TimeExpression::DayTime(DayTime {
428                day: DayReference::Today,
429                time,
430            })
431        })
432}
433
434/// "this" + weekday: "this Monday", "this Friday".
435fn this_weekday<'t, 's: 't, I>() -> impl Parser<'t, I, DayReference, ParserError<'t, 's>> + Clone
436where
437    I: TokenInput<'t, 's>,
438{
439    word_ci("this")
440        .ignore_then(space())
441        .ignore_then(weekday())
442        .map(|day| DayReference::Weekday {
443            day,
444            modifier: None,
445        })
446}
447
448/// "this weekend" / "next weekend".
449fn weekend_ref<'t, 's: 't, I>() -> impl Parser<'t, I, DayReference, ParserError<'t, 's>> + Clone
450where
451    I: TokenInput<'t, 's>,
452{
453    choice((
454        phrase_ci("this weekend").to(DayReference::Weekday {
455            day: Weekday::Saturday,
456            modifier: Some(WeekdayModifier::This),
457        }),
458        phrase_ci("next weekend").to(DayReference::Weekday {
459            day: Weekday::Saturday,
460            modifier: Some(WeekdayModifier::Next),
461        }),
462    ))
463}
464
465/// Standalone expressions that map to DayTime.
466fn standalone_daytime<'t, 's: 't, I>()
467-> impl Parser<'t, I, TimeExpression, ParserError<'t, 's>> + Clone
468where
469    I: TokenInput<'t, 's>,
470{
471    choice((
472        word_ci("tonight").to(TimeExpression::DayTime(DayTime {
473            day: DayReference::Today,
474            time: Time {
475                hour: 20,
476                minute: 0,
477                second: 0,
478                meridiem: None,
479            },
480        })),
481        choice((
482            word_ci("eod"),
483            phrase_ci("end of day"),
484            phrase_ci("end of the day"),
485        ))
486        .to(TimeExpression::DayTime(DayTime {
487            day: DayReference::Today,
488            time: Time {
489                hour: 17,
490                minute: 0,
491                second: 0,
492                meridiem: None,
493            },
494        })),
495    ))
496}
497
498/// The `the`-prefixed synonyms: "the day after tomorrow", "the day before
499/// yesterday".
500///
501/// The shared `the` is factored out rather than repeated in two competing
502/// alternatives.
503fn the_day_reference<'t, 's: 't, I>()
504-> impl Parser<'t, I, DayReference, ParserError<'t, 's>> + Clone
505where
506    I: TokenInput<'t, 's>,
507{
508    word_ci("the").ignore_then(space()).ignore_then(phrases_ci([
509        ("day after tomorrow", DayReference::DayAfterTomorrow),
510        ("day before yesterday", DayReference::DayBeforeYesterday),
511    ]))
512}
513
514/// Bare "fortnight" = 2 weeks (future direction assumed for scheduling).
515///
516/// This is only the standalone reading. As a *unit* — `in a fortnight`,
517/// `three fortnights ago` — a fortnight is handled by [`scaled_time_unit`],
518/// which those rules reach through their own leading token (`in`) or trailing
519/// one (`ago`), so neither can succeed on a prefix of the other.
520fn fortnight<'t, 's: 't, I>() -> impl Parser<'t, I, TimeExpression, ParserError<'t, 's>> + Clone
521where
522    I: TokenInput<'t, 's>,
523{
524    word_ci("fortnight").to(TimeExpression::Relative(RelativeTime {
525        amount: 2,
526        unit: TimeUnit::Week,
527        direction: Direction::Future,
528    }))
529}
530
531/// "later" / "later today" — vague future (~2 hours).
532fn later_expr<'t, 's: 't, I>() -> impl Parser<'t, I, TimeExpression, ParserError<'t, 's>> + Clone
533where
534    I: TokenInput<'t, 's>,
535{
536    // Left-factored on the shared `later`, so the bare form can no longer
537    // shadow `later today` and the order of the two readings is irrelevant.
538    word_ci("later")
539        .ignore_then(space().ignore_then(word_ci("today")).or_not())
540        .map(|today| match today {
541            Some(()) => TimeExpression::LaterToday,
542            None => TimeExpression::Relative(RelativeTime {
543                amount: 2,
544                unit: TimeUnit::Hour,
545                direction: Direction::Future,
546            }),
547        })
548}
549
550/// "a week from now" / "a week from today".
551fn week_from_now<'t, 's: 't, I>() -> impl Parser<'t, I, TimeExpression, ParserError<'t, 's>> + Clone
552where
553    I: TokenInput<'t, 's>,
554{
555    choice((phrase_ci("a week from today"), phrase_ci("a week from now"))).to(
556        TimeExpression::Relative(RelativeTime {
557            amount: 1,
558            unit: TimeUnit::Week,
559            direction: Direction::Future,
560        }),
561    )
562}
563
564/// A day reference, optionally qualified by a time of day.
565///
566/// This is the left-factored form of what used to be three competing top-level
567/// alternatives — `tomorrow at 3:30 pm`, `tomorrow morning` and a bare
568/// `tomorrow`. They all start with the same [`day_reference`], so under an
569/// ordered `choice` the bare form would commit on `tomorrow` and strand the
570/// rest of the input. Parsing the shared prefix once and treating the time as
571/// an optional tail removes the ambiguity instead of papering over it.
572fn day_expr<'t, 's: 't, I>() -> impl Parser<'t, I, TimeExpression, ParserError<'t, 's>> + Clone
573where
574    I: TokenInput<'t, 's>,
575{
576    let at_time = word_ci("at")
577        .ignore_then(space())
578        .ignore_then(time_digits())
579        .map(|(hour, minute, second, meridiem)| Time {
580            hour,
581            minute,
582            second,
583            meridiem,
584        });
585
586    day_reference()
587        .then(
588            space()
589                .ignore_then(choice((at_time, part_of_day())))
590                .or_not(),
591        )
592        .map(|(day, time)| match time {
593            Some(time) => TimeExpression::DayTime(DayTime { day, time }),
594            None => TimeExpression::Day(day),
595        })
596}
597
598fn relative_past<'t, 's: 't, I>() -> impl Parser<'t, I, TimeExpression, ParserError<'t, 's>> + Clone
599where
600    I: TokenInput<'t, 's>,
601{
602    amount_and_unit()
603        .then_ignore(space())
604        .then_ignore(word_ci("ago"))
605        .map(|(amount, unit)| {
606            TimeExpression::Relative(RelativeTime {
607                amount,
608                unit,
609                direction: Direction::Past,
610            })
611        })
612}
613
614fn relative_future<'t, 's: 't, I>()
615-> impl Parser<'t, I, TimeExpression, ParserError<'t, 's>> + Clone
616where
617    I: TokenInput<'t, 's>,
618{
619    word_ci("in")
620        .ignore_then(space())
621        .ignore_then(amount_and_unit())
622        .map(|(amount, unit)| {
623            TimeExpression::Relative(RelativeTime {
624                amount,
625                unit,
626                direction: Direction::Future,
627            })
628        })
629}
630
631fn now_expr<'t, 's: 't, I>() -> impl Parser<'t, I, TimeExpression, ParserError<'t, 's>> + Clone
632where
633    I: TokenInput<'t, 's>,
634{
635    word_ci("now").to(TimeExpression::Now)
636}
637
638fn date_format<'t, 's: 't, I>() -> impl Parser<'t, I, TimeExpression, ParserError<'t, 's>> + Clone
639where
640    I: TokenInput<'t, 's>,
641{
642    // Note: a `YYYY-MM-DD` alternative would be dead code here — `iso_datetime()`
643    // is tried first at the top level and accepts exactly that shape.
644    let separator = choice((punct('/').to('/'), punct('-').to('-')));
645
646    two_digit_number()
647        .then(separator.clone())
648        .then(two_digit_number())
649        .then(separator)
650        .then(four_digit_number())
651        .try_map(|((((day, first), month), second), year), span| {
652            if first == second && time_utils::is_valid_calendar_date(year, month, day) {
653                Ok(TimeExpression::Date(StandardDate { day, month, year }))
654            } else {
655                Err(Rich::custom(span, "invalid date"))
656            }
657        })
658}
659
660fn parser<'t, 's: 't, I>() -> impl Parser<'t, I, TimeExpression, ParserError<'t, 's>>
661where
662    I: TokenInput<'t, 's>,
663{
664    // An ordered `choice`, which is only safe because the grammar is
665    // left-factored: every family of expressions sharing a leading token is
666    // parsed by a single alternative that treats the rest as an optional tail
667    // (see [`day_expr`], [`later_expr`], [`iso_datetime`]). What remains are
668    // alternatives that either start on different tokens or fail without
669    // committing, so none can succeed on a proper prefix of another's match
670    // and strand the rest of the input against `end()`. The order below is
671    // therefore documentation, not semantics: reversing it parses every
672    // supported expression identically.
673    choice((
674        iso_datetime().labelled("ISO 8601 datetime"),
675        date_format().labelled("calendar date"),
676        day_expr().labelled("day, optionally with a time"),
677        now_expr().labelled("`now`"),
678        standalone_daytime().labelled("standalone (tonight, EOD)"),
679        this_part_of_day().labelled("this morning/afternoon/evening"),
680        named_time().labelled("named time (noon, midnight, teatime)"),
681        time_expr().labelled("time of day"),
682        relative_past().labelled("`<n> <unit> ago`"),
683        relative_future().labelled("`in <n> <unit>`"),
684        week_from_now().labelled("a week from now/today"),
685        fortnight().labelled("fortnight"),
686        later_expr().labelled("later/later today"),
687    ))
688    .padded_by(opt_space())
689    .then_ignore(end())
690}
691
692impl LanguageParser for EnglishParser {
693    fn parse(&self, input: &str) -> Result<TimeExpression> {
694        let tokens = lex(input);
695        parser()
696            .parse(token_stream(input, &tokens))
697            .into_result()
698            .map_err(|errs| rich_errors_to_temps_error(input, errs))
699    }
700}