Skip to main content

temps_core/language/
german.rs

1use chumsky::{error::Rich, prelude::*};
2
3use crate::{
4    DayReference, DayTime, Direction, LanguageParser, RelativeTime, Result, StandardDate, Time,
5    TimeExpression, TimeUnit, Weekday, WeekdayModifier,
6    common::{
7        ParserError, TokenInput, digit_number, four_digit_number, iso_datetime, opt_space,
8        phrases_ci, phrases_cs, 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 German natural language time expressions.
16///
17/// German nouns (e.g., "Sekunden", "Minuten") are matched case-sensitively
18/// to follow German orthographic rules, while abbreviations (e.g., "sek", "min")
19/// are matched case-insensitively for convenience.
20pub struct GermanParser;
21
22fn number<'t, 's: 't, I>() -> impl Parser<'t, I, i64, ParserError<'t, 's>> + Clone
23where
24    I: TokenInput<'t, 's>,
25{
26    choice((
27        digit_number(),
28        phrases_cs([
29            ("ein", 1i64),
30            ("eine", 1),
31            ("einem", 1),
32            ("einen", 1),
33            ("einer", 1),
34            ("zwei", 2),
35            ("drei", 3),
36            ("vier", 4),
37            ("fünf", 5),
38            ("sechs", 6),
39            ("sieben", 7),
40            ("acht", 8),
41            ("neun", 9),
42            ("zehn", 10),
43        ]),
44    ))
45    .labelled("Zahl")
46}
47
48fn time_unit<'t, 's: 't, I>() -> impl Parser<'t, I, TimeUnit, ParserError<'t, 's>> + Clone
49where
50    I: TokenInput<'t, 's>,
51{
52    choice((
53        // Nouns keep their capitalisation; abbreviations stay case-insensitive.
54        phrases_cs([
55            ("Sekunde", TimeUnit::Second),
56            ("Sekunden", TimeUnit::Second),
57            ("Minute", TimeUnit::Minute),
58            ("Minuten", TimeUnit::Minute),
59            ("Stunde", TimeUnit::Hour),
60            ("Stunden", TimeUnit::Hour),
61            ("Tag", TimeUnit::Day),
62            ("Tage", TimeUnit::Day),
63            ("Tagen", TimeUnit::Day),
64            ("Woche", TimeUnit::Week),
65            ("Wochen", TimeUnit::Week),
66            ("Monat", TimeUnit::Month),
67            ("Monate", TimeUnit::Month),
68            ("Monaten", TimeUnit::Month),
69            ("Jahr", TimeUnit::Year),
70            ("Jahre", TimeUnit::Year),
71            ("Jahren", TimeUnit::Year),
72        ]),
73        phrases_ci([
74            ("sek", TimeUnit::Second),
75            ("min", TimeUnit::Minute),
76            ("std", TimeUnit::Hour),
77        ]),
78    ))
79    .labelled("Zeiteinheit")
80}
81
82fn weekday<'t, 's: 't, I>() -> impl Parser<'t, I, Weekday, ParserError<'t, 's>> + Clone
83where
84    I: TokenInput<'t, 's>,
85{
86    choice((
87        phrases_cs([
88            ("Montag", Weekday::Monday),
89            ("Dienstag", Weekday::Tuesday),
90            ("Mittwoch", Weekday::Wednesday),
91            ("Donnerstag", Weekday::Thursday),
92            ("Freitag", Weekday::Friday),
93            ("Samstag", Weekday::Saturday),
94            ("Sonntag", Weekday::Sunday),
95        ]),
96        phrases_ci([
97            ("mo", Weekday::Monday),
98            ("di", Weekday::Tuesday),
99            ("mi", Weekday::Wednesday),
100            ("do", Weekday::Thursday),
101            ("fr", Weekday::Friday),
102            ("sa", Weekday::Saturday),
103            ("so", Weekday::Sunday),
104        ]),
105    ))
106    .labelled("Wochentag")
107}
108
109fn day_shortcuts<'t, 's: 't, I>() -> impl Parser<'t, I, DayReference, ParserError<'t, 's>> + Clone
110where
111    I: TokenInput<'t, 's>,
112{
113    // All five are adverbs, not nouns, so they stay case-insensitive: a
114    // sentence-initial `Übermorgen` reads the same as `übermorgen`.
115    //
116    // `übermorgen` and `vorgestern` are single words rather than the phrases
117    // English needs ("the day after tomorrow"), and matching is per whole
118    // token, so neither can shadow `morgen`/`gestern` nor be shadowed by them,
119    // and `vorgestern` cannot be mistaken for the `vor <n> <Einheit>`
120    // preposition. `german_lexicon.rs` pins that.
121    phrases_ci([
122        ("heute", DayReference::Today),
123        ("gestern", DayReference::Yesterday),
124        ("morgen", DayReference::Tomorrow),
125        ("übermorgen", DayReference::DayAfterTomorrow),
126        ("vorgestern", DayReference::DayBeforeYesterday),
127    ])
128}
129
130fn weekday_modifier<'t, 's: 't, I>()
131-> impl Parser<'t, I, WeekdayModifier, ParserError<'t, 's>> + Clone
132where
133    I: TokenInput<'t, 's>,
134{
135    choice((
136        word_ci("letzte").to(WeekdayModifier::Last),
137        word_ci("letzten").to(WeekdayModifier::Last),
138        word_ci("nächste").to(WeekdayModifier::Next),
139        word_ci("nächsten").to(WeekdayModifier::Next),
140    ))
141}
142
143fn modified_weekday<'t, 's: 't, I>() -> impl Parser<'t, I, DayReference, ParserError<'t, 's>> + Clone
144where
145    I: TokenInput<'t, 's>,
146{
147    weekday_modifier()
148        .then_ignore(space())
149        .then(weekday())
150        .map(|(modifier, day)| DayReference::Weekday {
151            day,
152            modifier: Some(modifier),
153        })
154}
155
156fn simple_weekday<'t, 's: 't, I>() -> impl Parser<'t, I, DayReference, ParserError<'t, 's>> + Clone
157where
158    I: TokenInput<'t, 's>,
159{
160    weekday().map(|day| DayReference::Weekday {
161        day,
162        modifier: None,
163    })
164}
165
166fn day_reference<'t, 's: 't, I>() -> impl Parser<'t, I, DayReference, ParserError<'t, 's>> + Clone
167where
168    I: TokenInput<'t, 's>,
169{
170    // A plain `choice`: the three alternatives start on disjoint words
171    // (`heute`/`gestern`/`morgen`, a modifier, a weekday), so none can succeed
172    // on a proper prefix of another's match.
173    choice((day_shortcuts(), modified_weekday(), simple_weekday()))
174}
175
176fn time_digits<'t, 's: 't, I>() -> impl Parser<'t, I, (u8, u8, u8), ParserError<'t, 's>> + Clone
177where
178    I: TokenInput<'t, 's>,
179{
180    two_digit_number()
181        .then_ignore(punct(':'))
182        .then(two_digit_number())
183        .then(punct(':').ignore_then(two_digit_number()).or_not())
184        .try_map(|((hour, minute), second), span| {
185            let second = second.unwrap_or(0);
186            if time_utils::is_valid_24_hour_time(hour, minute, second) {
187                Ok((hour, minute, second))
188            } else {
189                Err(Rich::custom(span, "invalid time"))
190            }
191        })
192}
193
194/// The optional `Uhr` that may trail a clock time, as in `14:30 Uhr`.
195fn uhr_suffix<'t, 's: 't, I>() -> impl Parser<'t, I, (), ParserError<'t, 's>> + Clone
196where
197    I: TokenInput<'t, 's>,
198{
199    space().ignore_then(word_ci("uhr")).or_not().ignored()
200}
201
202fn time_expr<'t, 's: 't, I>() -> impl Parser<'t, I, TimeExpression, ParserError<'t, 's>> + Clone
203where
204    I: TokenInput<'t, 's>,
205{
206    time_digits()
207        .then_ignore(uhr_suffix())
208        .map(|(hour, minute, second)| {
209            TimeExpression::Time(Time {
210                hour,
211                minute,
212                second,
213                meridiem: None,
214            })
215        })
216}
217
218/// A day reference, optionally qualified by `um <Uhrzeit>`.
219///
220/// The left-factored form of `morgen` and `morgen um 15:30`, which used to be
221/// two top-level alternatives sharing the same [`day_reference`] prefix. Under
222/// an ordered `choice` the bare form would commit on `morgen` and leave
223/// `um 15:30` for `end()` to reject.
224fn day_expr<'t, 's: 't, I>() -> impl Parser<'t, I, TimeExpression, ParserError<'t, 's>> + Clone
225where
226    I: TokenInput<'t, 's>,
227{
228    let um_time = word_ci("um")
229        .ignore_then(space())
230        .ignore_then(time_digits())
231        .then_ignore(uhr_suffix());
232
233    day_reference()
234        .then(space().ignore_then(um_time).or_not())
235        .map(|(day, time)| match time {
236            Some((hour, minute, second)) => TimeExpression::DayTime(DayTime {
237                day,
238                time: Time {
239                    hour,
240                    minute,
241                    second,
242                    meridiem: None,
243                },
244            }),
245            None => TimeExpression::Day(day),
246        })
247}
248
249fn relative_past<'t, 's: 't, I>() -> impl Parser<'t, I, TimeExpression, ParserError<'t, 's>> + Clone
250where
251    I: TokenInput<'t, 's>,
252{
253    word_ci("vor")
254        .ignore_then(space())
255        .ignore_then(number())
256        .then_ignore(space())
257        .then(time_unit())
258        .map(|(amount, unit)| {
259            TimeExpression::Relative(RelativeTime {
260                amount,
261                unit,
262                direction: Direction::Past,
263            })
264        })
265}
266
267fn relative_future<'t, 's: 't, I>()
268-> impl Parser<'t, I, TimeExpression, ParserError<'t, 's>> + Clone
269where
270    I: TokenInput<'t, 's>,
271{
272    word_ci("in")
273        .ignore_then(space())
274        .ignore_then(number())
275        .then_ignore(space())
276        .then(time_unit())
277        .map(|(amount, unit)| {
278            TimeExpression::Relative(RelativeTime {
279                amount,
280                unit,
281                direction: Direction::Future,
282            })
283        })
284}
285
286fn now_expr<'t, 's: 't, I>() -> impl Parser<'t, I, TimeExpression, ParserError<'t, 's>> + Clone
287where
288    I: TokenInput<'t, 's>,
289{
290    word_ci("jetzt").to(TimeExpression::Now)
291}
292
293fn date_format<'t, 's: 't, I>() -> impl Parser<'t, I, TimeExpression, ParserError<'t, 's>> + Clone
294where
295    I: TokenInput<'t, 's>,
296{
297    two_digit_number()
298        .then_ignore(punct('.'))
299        .then(two_digit_number())
300        .then_ignore(punct('.'))
301        .then(four_digit_number())
302        .try_map(|((day, month), year), span| {
303            if time_utils::is_valid_calendar_date(year, month, day) {
304                Ok(TimeExpression::Date(StandardDate { day, month, year }))
305            } else {
306                Err(Rich::custom(span, "invalid calendar date"))
307            }
308        })
309}
310
311fn parser<'t, 's: 't, I>() -> impl Parser<'t, I, TimeExpression, ParserError<'t, 's>>
312where
313    I: TokenInput<'t, 's>,
314{
315    // An ordered `choice`, safe for the same reason as in the English parser:
316    // the one family that shared a leading token — a day with and without a
317    // time — is left-factored into [`day_expr`], and what is left starts on
318    // disjoint tokens or fails without committing, so the order below is
319    // documentation rather than semantics.
320    choice((
321        iso_datetime().labelled("ISO 8601 datetime"),
322        date_format().labelled("Datum (TT.MM.JJJJ)"),
323        day_expr().labelled("Tagesangabe, optional mit Uhrzeit"),
324        now_expr().labelled("`jetzt`"),
325        time_expr().labelled("Uhrzeit"),
326        relative_past().labelled("`vor <n> <Einheit>`"),
327        relative_future().labelled("`in <n> <Einheit>`"),
328    ))
329    .padded_by(opt_space())
330    .then_ignore(end())
331}
332
333impl LanguageParser for GermanParser {
334    fn parse(&self, input: &str) -> Result<TimeExpression> {
335        let tokens = lex(input);
336        parser()
337            .parse(token_stream(input, &tokens))
338            .into_result()
339            .map_err(|errs| rich_errors_to_temps_error(input, errs))
340    }
341}