Skip to main content

whichtime_sys/parsers/common/
casual_time.rs

1//! Multi-locale casual time parser: noon, midnight, morning, afternoon, evening, night
2//!
3//! Handles casual time expressions across all supported locales.
4
5use crate::components::Component;
6use crate::context::ParsingContext;
7use crate::dictionaries::{CasualTimeType, Locale, RelativeModifier};
8use crate::error::Result;
9use crate::parsers::Parser;
10use crate::results::ParsedResult;
11use crate::scanner::TokenType;
12use crate::types::Meridiem;
13use chrono::{Datelike, Duration, Timelike};
14use regex::Regex;
15use std::sync::LazyLock;
16
17// Locale-specific patterns
18static EN_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
19    Regex::new(r"(?i)\b(?:(this|last|next|past|previous)\s+)?(noon|midday|midnight|morning|afternoon|evening|night)\b").unwrap()
20});
21
22static DE_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
23    Regex::new(r"(?i)\b(?:(dieser?|diese[nms]?|letzter?|letzte[nms]?|nächster?|nächste[nms]?|naechster?|naechste[nms]?)\s+)?(mittag|mitternacht|morgens?|vormittags?|nachmittags?|abends?|nachts?)\b").unwrap()
24});
25
26static ES_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
27    Regex::new(r"(?i)\b(?:(este|esta|pasado|pasada|próximo|próxima|proximo|proxima)\s+)?(mediodía|mediodia|medianoche|mañana|manana|tarde|noche)(?:\s+pasad[ao]|)?\b").unwrap()
28});
29
30static FR_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
31    Regex::new(r"(?i)\b(?:(ce|cette|dernier|dernière|derniere|prochain|prochaine)\s+)?(midi|minuit|matin|après-midi|apres-midi|soir|nuit)\b").unwrap()
32});
33
34static IT_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
35    Regex::new(r"(?i)\b(?:(questo|questa|scorso|scorsa|prossimo|prossima)\s+)?(mezzogiorno|mezzanotte|mattina|mattino|pomeriggio|sera|notte)\b").unwrap()
36});
37
38static JA_PATTERN: LazyLock<Regex> =
39    LazyLock::new(|| Regex::new(r"(正午|真夜中|朝|午前|午後|夕方|夜|深夜)").unwrap());
40
41static NL_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
42    Regex::new(r"(?i)\b(?:(deze|vorige|volgende|afgelopen|komende)\s+)?(middag|middernacht|ochtend|'s\s*ochtends|'s\s*middags|'s\s*avonds|avond|nacht|'s\s*nachts)\b").unwrap()
43});
44
45static PT_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
46    Regex::new(r"(?i)\b(?:(este|esta|passado|passada|próximo|próxima|proximo|proxima)\s+)?(meio-dia|meio\s*dia|meia-noite|meia\s*noite|manhã|manha|tarde|noite)\b").unwrap()
47});
48
49static RU_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
50    Regex::new(r"(?i)\b(?:(этот|эта|прошлый|прошлая|следующий|следующая)\s+)?(полдень|в\s*полдень|полночь|в\s*полночь|утром?|днём|днем|вечером?|ночью?)\b").unwrap()
51});
52
53static SV_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
54    Regex::new(r"(?i)\b(?:(denna|förra|forra|nästa|nasta)\s+)?(middag|midnatt|morgon(?:en)?|på\s*morgonen|förmiddag(?:en)?|formiddag(?:en)?|eftermiddag(?:en)?|kväll(?:en)?|kvall(?:en)?|natt(?:en)?)\b").unwrap()
55});
56
57static UK_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
58    Regex::new(r"(?i)\b(?:(цей|ця|минулий|минула|наступний|наступна)\s+)?(полудень|опівдні|опівночі|вранці|ранок|вдень|ввечері|вночі)\b").unwrap()
59});
60
61static ZH_PATTERN: LazyLock<Regex> =
62    LazyLock::new(|| Regex::new(r"(中午|正午|午夜|凌晨|早上|上午|下午|傍晚|晚上|深夜)").unwrap());
63
64/// Multi-locale casual time parser
65pub struct MultiLocaleCasualTimeParser {
66    locale: Locale,
67}
68
69impl MultiLocaleCasualTimeParser {
70    pub fn new(locale: Locale) -> Self {
71        Self { locale }
72    }
73
74    fn get_pattern(&self) -> &'static Regex {
75        match self.locale {
76            Locale::En => &EN_PATTERN,
77            Locale::De => &DE_PATTERN,
78            Locale::Es => &ES_PATTERN,
79            Locale::Fr => &FR_PATTERN,
80            Locale::It => &IT_PATTERN,
81            Locale::Ja => &JA_PATTERN,
82            Locale::Nl => &NL_PATTERN,
83            Locale::Pt => &PT_PATTERN,
84            Locale::Ru => &RU_PATTERN,
85            Locale::Sv => &SV_PATTERN,
86            Locale::Uk => &UK_PATTERN,
87            Locale::Zh => &ZH_PATTERN,
88        }
89    }
90
91    fn lookup_casual_time(&self, text: &str) -> Option<CasualTimeType> {
92        let lower = text.to_lowercase();
93        // Normalize whitespace
94        let normalized: String = lower.split_whitespace().collect::<Vec<_>>().join(" ");
95
96        match self.locale {
97            Locale::En => crate::dictionaries::en::get_casual_time(&normalized),
98            Locale::De => crate::dictionaries::de::get_casual_time(&normalized),
99            Locale::Es => crate::dictionaries::es::get_casual_time(&normalized),
100            Locale::Fr => crate::dictionaries::fr::get_casual_time(&normalized),
101            Locale::It => crate::dictionaries::it::get_casual_time(&normalized),
102            Locale::Ja => crate::dictionaries::ja::get_casual_time(text)
103                .or_else(|| crate::dictionaries::ja::get_casual_time(&normalized)),
104            Locale::Nl => crate::dictionaries::nl::get_casual_time(&normalized),
105            Locale::Pt => crate::dictionaries::pt::get_casual_time(&normalized),
106            Locale::Ru => crate::dictionaries::ru::get_casual_time(&normalized),
107            Locale::Sv => crate::dictionaries::sv::get_casual_time(&normalized),
108            Locale::Uk => crate::dictionaries::uk::get_casual_time(&normalized),
109            Locale::Zh => crate::dictionaries::zh::get_casual_time(text)
110                .or_else(|| crate::dictionaries::zh::get_casual_time(&normalized)),
111        }
112    }
113
114    fn lookup_relative_modifier(&self, text: &str) -> Option<RelativeModifier> {
115        let lower = text.to_lowercase();
116        match self.locale {
117            Locale::En => crate::dictionaries::en::get_relative_modifier(&lower),
118            Locale::De => crate::dictionaries::de::get_relative_modifier(&lower),
119            Locale::Es => crate::dictionaries::es::get_relative_modifier(&lower),
120            Locale::Fr => crate::dictionaries::fr::get_relative_modifier(&lower),
121            Locale::It => crate::dictionaries::it::get_relative_modifier(&lower),
122            Locale::Ja => crate::dictionaries::ja::get_relative_modifier(text)
123                .or_else(|| crate::dictionaries::ja::get_relative_modifier(&lower)),
124            Locale::Nl => crate::dictionaries::nl::get_relative_modifier(&lower),
125            Locale::Pt => crate::dictionaries::pt::get_relative_modifier(&lower),
126            Locale::Ru => crate::dictionaries::ru::get_relative_modifier(&lower),
127            Locale::Sv => crate::dictionaries::sv::get_relative_modifier(&lower),
128            Locale::Uk => crate::dictionaries::uk::get_relative_modifier(&lower),
129            Locale::Zh => crate::dictionaries::zh::get_relative_modifier(text)
130                .or_else(|| crate::dictionaries::zh::get_relative_modifier(&lower)),
131        }
132    }
133
134    fn is_digit_like(ch: char) -> bool {
135        ch.is_ascii_digit()
136            || ('0'..='9').contains(&ch)
137            || matches!(
138                ch,
139                '〇' | '一' | '二' | '三' | '四' | '五' | '六' | '七' | '八' | '九' | '十'
140            )
141    }
142
143    fn has_trailing_number(text: &str, idx: usize) -> bool {
144        if idx >= text.len() {
145            return false;
146        }
147
148        let chars = text[idx..].chars();
149        for ch in chars {
150            if ch.is_whitespace() {
151                continue;
152            }
153            return Self::is_digit_like(ch);
154        }
155        false
156    }
157}
158
159impl Parser for MultiLocaleCasualTimeParser {
160    fn name(&self) -> &'static str {
161        "MultiLocaleCasualTimeParser"
162    }
163
164    fn should_apply(&self, context: &ParsingContext) -> bool {
165        context.has_token_type(TokenType::CasualTime)
166    }
167
168    fn parse(&self, context: &ParsingContext) -> Result<Vec<ParsedResult>> {
169        let mut results = Vec::new();
170        let pattern = self.get_pattern();
171        let ref_date = context.reference.instant;
172
173        for mat in pattern.find_iter(context.text) {
174            let matched_text = mat.as_str();
175            let index = mat.start();
176            if matches!(self.locale, Locale::Ja)
177                && Self::has_trailing_number(context.text, mat.end())
178            {
179                continue;
180            }
181
182            let Some(caps) = pattern.captures(matched_text) else {
183                continue;
184            };
185
186            // Try to get modifier and time word from captures
187            let (modifier_str, time_word) = match self.locale {
188                Locale::Ja | Locale::Zh => {
189                    (None, caps.get(1).map(|m| m.as_str()).unwrap_or_default())
190                }
191                _ => (
192                    caps.get(1).map(|m| m.as_str()),
193                    caps.get(2).map(|m| m.as_str()).unwrap_or_default(),
194                ),
195            };
196
197            let Some(time_type) = self.lookup_casual_time(time_word) else {
198                continue;
199            };
200
201            let modifier = modifier_str.and_then(|s| self.lookup_relative_modifier(s));
202
203            let mut components = context.create_components();
204
205            // Calculate target date based on modifier
206            let target_date = match modifier {
207                Some(RelativeModifier::Last) => {
208                    if ref_date.hour() <= 6 && matches!(time_type, CasualTimeType::Night) {
209                        ref_date
210                    } else {
211                        ref_date - Duration::days(1)
212                    }
213                }
214                Some(RelativeModifier::Next) => ref_date + Duration::days(1),
215                Some(RelativeModifier::This) | None => ref_date,
216            };
217
218            // Set date components
219            components.assign(Component::Year, target_date.year());
220            components.assign(Component::Month, target_date.month() as i32);
221            components.assign(Component::Day, target_date.day() as i32);
222
223            // Set time components based on time_type
224            match time_type {
225                CasualTimeType::Noon => {
226                    components.assign(Component::Hour, 12);
227                    components.assign(Component::Minute, 0);
228                    components.assign(Component::Second, 0);
229                    components.assign(Component::Meridiem, Meridiem::PM as i32);
230                }
231                CasualTimeType::Midnight => {
232                    if matches!(modifier, Some(RelativeModifier::Last)) {
233                        components.assign(Component::Day, target_date.day() as i32);
234                    } else if modifier.is_none() {
235                        let next_day = ref_date + Duration::days(1);
236                        components.assign(Component::Year, next_day.year());
237                        components.assign(Component::Month, next_day.month() as i32);
238                        components.assign(Component::Day, next_day.day() as i32);
239                    }
240                    components.assign(Component::Hour, 0);
241                    components.assign(Component::Minute, 0);
242                    components.assign(Component::Second, 0);
243                }
244                CasualTimeType::Morning => {
245                    components.imply(Component::Hour, 6);
246                    components.imply(Component::Minute, 0);
247                    components.assign(Component::Meridiem, Meridiem::AM as i32);
248                }
249                CasualTimeType::Afternoon => {
250                    components.imply(Component::Hour, 15);
251                    components.imply(Component::Minute, 0);
252                    components.assign(Component::Meridiem, Meridiem::PM as i32);
253                }
254                CasualTimeType::Evening => {
255                    components.imply(Component::Hour, 20);
256                    components.imply(Component::Minute, 0);
257                    components.assign(Component::Meridiem, Meridiem::PM as i32);
258                }
259                CasualTimeType::Night => {
260                    components.imply(Component::Hour, 22);
261                    components.imply(Component::Minute, 0);
262                    components.assign(Component::Meridiem, Meridiem::PM as i32);
263                }
264            }
265
266            results.push(context.create_result(
267                index,
268                index + matched_text.len(),
269                components,
270                None,
271            ));
272        }
273
274        Ok(results)
275    }
276}