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, 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 German natural language time expressions.
14///
15/// German nouns (e.g., "Sekunden", "Minuten") are matched case-sensitively
16/// to follow German orthographic rules, while abbreviations (e.g., "sek", "min")
17/// are matched case-insensitively for convenience.
18pub struct GermanParser;
19
20fn whitespace_required<'a>() -> impl Parser<'a, &'a str, (), ParserError<'a>> + Clone {
21    one_of(" \t\n\r")
22        .labelled("whitespace")
23        .repeated()
24        .at_least(1)
25        .ignored()
26}
27
28fn number<'a>() -> impl Parser<'a, &'a str, i64, ParserError<'a>> + Clone {
29    choice((
30        digit_number(),
31        choice((
32            just("einem").to(1),
33            just("einer").to(1),
34            just("einen").to(1),
35            just("eine").to(1),
36            just("ein").to(1),
37        )),
38        choice((
39            just("zwei").to(2),
40            just("drei").to(3),
41            just("vier").to(4),
42            just("fünf").to(5),
43            just("sechs").to(6),
44        )),
45        choice((
46            just("sieben").to(7),
47            just("acht").to(8),
48            just("neun").to(9),
49            just("zehn").to(10),
50        )),
51    ))
52    .labelled("Zahl")
53}
54
55fn time_unit<'a>() -> impl Parser<'a, &'a str, TimeUnit, ParserError<'a>> + Clone {
56    choice((
57        choice((
58            just("Sekunden").to(TimeUnit::Second),
59            just("Sekunde").to(TimeUnit::Second),
60            keyword_ci("sek").to(TimeUnit::Second),
61        )),
62        choice((
63            just("Minuten").to(TimeUnit::Minute),
64            just("Minute").to(TimeUnit::Minute),
65            keyword_ci("min").to(TimeUnit::Minute),
66        )),
67        choice((
68            just("Stunden").to(TimeUnit::Hour),
69            just("Stunde").to(TimeUnit::Hour),
70            keyword_ci("std").to(TimeUnit::Hour),
71        )),
72        choice((
73            just("Tagen").to(TimeUnit::Day),
74            just("Tage").to(TimeUnit::Day),
75            just("Tag").to(TimeUnit::Day),
76        )),
77        choice((
78            just("Wochen").to(TimeUnit::Week),
79            just("Woche").to(TimeUnit::Week),
80        )),
81        choice((
82            just("Monaten").to(TimeUnit::Month),
83            just("Monate").to(TimeUnit::Month),
84            just("Monat").to(TimeUnit::Month),
85        )),
86        choice((
87            just("Jahren").to(TimeUnit::Year),
88            just("Jahre").to(TimeUnit::Year),
89            just("Jahr").to(TimeUnit::Year),
90        )),
91    ))
92    .labelled("Zeiteinheit")
93}
94
95fn weekday<'a>() -> impl Parser<'a, &'a str, Weekday, ParserError<'a>> + Clone {
96    choice((
97        choice((
98            just("Montag").to(Weekday::Monday),
99            keyword_ci("mo").to(Weekday::Monday),
100        )),
101        choice((
102            just("Dienstag").to(Weekday::Tuesday),
103            keyword_ci("di").to(Weekday::Tuesday),
104        )),
105        choice((
106            just("Mittwoch").to(Weekday::Wednesday),
107            keyword_ci("mi").to(Weekday::Wednesday),
108        )),
109        choice((
110            just("Donnerstag").to(Weekday::Thursday),
111            keyword_ci("do").to(Weekday::Thursday),
112        )),
113        choice((
114            just("Freitag").to(Weekday::Friday),
115            keyword_ci("fr").to(Weekday::Friday),
116        )),
117        choice((
118            just("Samstag").to(Weekday::Saturday),
119            keyword_ci("sa").to(Weekday::Saturday),
120        )),
121        choice((
122            just("Sonntag").to(Weekday::Sunday),
123            keyword_ci("so").to(Weekday::Sunday),
124        )),
125    ))
126    .labelled("Wochentag")
127}
128
129fn day_shortcuts<'a>() -> impl Parser<'a, &'a str, DayReference, ParserError<'a>> + Clone {
130    choice((
131        keyword_ci("heute").to(DayReference::Today),
132        keyword_ci("gestern").to(DayReference::Yesterday),
133        keyword_ci("morgen").to(DayReference::Tomorrow),
134    ))
135}
136
137fn weekday_modifier<'a>() -> impl Parser<'a, &'a str, WeekdayModifier, ParserError<'a>> + Clone {
138    choice((
139        just("letzten").to(WeekdayModifier::Last),
140        just("letzte").to(WeekdayModifier::Last),
141        just("nächsten").to(WeekdayModifier::Next),
142        just("nächste").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 time_digits<'a>() -> impl Parser<'a, &'a str, (u8, u8, u8), ParserError<'a>> + Clone {
168    two_digit_number()
169        .then_ignore(just(':'))
170        .then(two_digit_number())
171        .then(just(':').ignore_then(two_digit_number()).or_not())
172        .try_map(|((hour, minute), second), span| {
173            let second = second.unwrap_or(0);
174            if time_utils::is_valid_24_hour_time(hour, minute, second) {
175                Ok((hour, minute, second))
176            } else {
177                Err(Rich::custom(span, "invalid time"))
178            }
179        })
180}
181
182fn time_expr<'a>() -> impl Parser<'a, &'a str, TimeExpression, ParserError<'a>> + Clone {
183    time_digits()
184        .then_ignore(
185            whitespace_required()
186                .ignore_then(keyword_ci("uhr"))
187                .or_not(),
188        )
189        .map(|(hour, minute, second)| {
190            TimeExpression::Time(Time {
191                hour,
192                minute,
193                second,
194                meridiem: None,
195            })
196        })
197}
198
199fn day_at_time<'a>() -> impl Parser<'a, &'a str, TimeExpression, ParserError<'a>> + Clone {
200    day_reference()
201        .then_ignore(whitespace_required())
202        .then_ignore(keyword_ci("um"))
203        .then_ignore(whitespace_required())
204        .then(time_digits())
205        .then_ignore(
206            whitespace_required()
207                .ignore_then(keyword_ci("uhr"))
208                .or_not(),
209        )
210        .map(|(day, (hour, minute, second))| {
211            TimeExpression::DayTime(DayTime {
212                day,
213                time: Time {
214                    hour,
215                    minute,
216                    second,
217                    meridiem: None,
218                },
219            })
220        })
221}
222
223fn relative_past<'a>() -> impl Parser<'a, &'a str, TimeExpression, ParserError<'a>> + Clone {
224    keyword_ci("vor")
225        .ignore_then(whitespace_required())
226        .ignore_then(number())
227        .then_ignore(whitespace_required())
228        .then(time_unit())
229        .map(|(amount, unit)| {
230            TimeExpression::Relative(RelativeTime {
231                amount,
232                unit,
233                direction: Direction::Past,
234            })
235        })
236}
237
238fn relative_future<'a>() -> impl Parser<'a, &'a str, TimeExpression, ParserError<'a>> + Clone {
239    keyword_ci("in")
240        .ignore_then(whitespace_required())
241        .ignore_then(number())
242        .then_ignore(whitespace_required())
243        .then(time_unit())
244        .map(|(amount, unit)| {
245            TimeExpression::Relative(RelativeTime {
246                amount,
247                unit,
248                direction: Direction::Future,
249            })
250        })
251}
252
253fn now_expr<'a>() -> impl Parser<'a, &'a str, TimeExpression, ParserError<'a>> + Clone {
254    keyword_ci("jetzt").to(TimeExpression::Now)
255}
256
257fn date_format<'a>() -> impl Parser<'a, &'a str, TimeExpression, ParserError<'a>> + Clone {
258    two_digit_number()
259        .then_ignore(just('.'))
260        .then(two_digit_number())
261        .then_ignore(just('.'))
262        .then(four_digit_number())
263        .try_map(|((day, month), year), span| {
264            if time_utils::is_valid_calendar_date(year, month, day) {
265                Ok(TimeExpression::Date(StandardDate { day, month, year }))
266            } else {
267                Err(Rich::custom(span, "invalid calendar date"))
268            }
269        })
270}
271
272fn parser<'a>() -> impl Parser<'a, &'a str, TimeExpression, ParserError<'a>> {
273    choice((
274        iso_datetime().labelled("ISO 8601 datetime"),
275        date_format().labelled("Datum (TT.MM.JJJJ)"),
276        day_at_time().labelled("Tag mit Uhrzeit"),
277        now_expr().labelled("`jetzt`"),
278        day_reference()
279            .map(TimeExpression::Day)
280            .labelled("Tagesangabe"),
281        time_expr().labelled("Uhrzeit"),
282        relative_past().labelled("`vor <n> <Einheit>`"),
283        relative_future().labelled("`in <n> <Einheit>`"),
284    ))
285    .padded()
286    .then_ignore(end())
287}
288
289impl LanguageParser for GermanParser {
290    fn parse(&self, input: &str) -> Result<TimeExpression> {
291        parser()
292            .parse(input)
293            .into_result()
294            .map_err(|errs| rich_errors_to_temps_error(input, errs))
295    }
296}