Skip to main content

chrono_node/common/
time_expression.rs

1//! upstream `common/parsers/AbstractTimeExpressionParser.ts`.
2//!
3//! Implement [`TimeExpressionParser`] for a locale and wrap it: `Box::new(TimeExpression::new(p))`.
4//! Upstream's `super.extractPrimaryTimeComponents(...)` is [`extract_primary_time_components`],
5//! and `super.extractFollowingTimeComponents(...)` is [`extract_following_time_components`].
6
7use std::sync::OnceLock;
8
9use crate::calculation::duration::{Duration, DurationUnit};
10use crate::core::chrono::{Extracted, Parser};
11use crate::core::components::{meridiem, Component, ParsingComponents};
12use crate::core::context::ParsingContext;
13use crate::core::regex::{JsRegex, RegexMatch};
14use crate::core::result::ParsingResult;
15use crate::core::utils::js::js_parse_int;
16use crate::js_regex;
17
18const DEFAULT_SUFFIX: &str = r"(?!/)(?=\W|$)";
19
20fn primary_time_pattern(left_boundary: &str, primary_prefix: &str, primary_suffix: &str, flags: &str) -> JsRegex {
21    let source = format!(
22        concat!(
23            "{}{}",
24            r"(\d{{1,4}})",
25            r"(?:",
26            r"(?:\.|:|:)",
27            r"(\d{{1,2}})",
28            r"(?:",
29            r"(?::|:)",
30            r"(\d{{2}})",
31            r"(?:\.(\d{{1,6}}))?",
32            r")?",
33            r")?",
34            r"(?:\s*(a\.m\.|p\.m\.|am?|pm?))?",
35            "{}",
36        ),
37        left_boundary, primary_prefix, primary_suffix
38    );
39    JsRegex::new(&source, flags)
40}
41
42fn following_time_pattern(following_phase: &str, following_suffix: &str) -> JsRegex {
43    let source = format!(
44        concat!(
45            r"^({})",
46            r"(\d{{1,4}})",
47            r"(?:",
48            r"(?:\.|\:|\:)",
49            r"(\d{{1,2}})",
50            r"(?:",
51            r"(?:\.|\:|\:)",
52            r"(\d{{1,2}})(?:\.(\d{{1,6}}))?",
53            r")?",
54            r")?",
55            r"(?:\s*(a\.m\.|p\.m\.|am?|pm?))?",
56            "{}",
57        ),
58        following_phase, following_suffix
59    );
60    JsRegex::new(&source, "i")
61}
62
63pub const HOUR_GROUP: usize = 2;
64pub const MINUTE_GROUP: usize = 3;
65pub const SECOND_GROUP: usize = 4;
66pub const MILLI_SECOND_GROUP: usize = 5;
67pub const AM_PM_HOUR_GROUP: usize = 6;
68
69/// The abstract members of `AbstractTimeExpressionParser`, with upstream's defaults.
70pub trait TimeExpressionParser: Send + Sync {
71    fn strict_mode(&self) -> bool;
72    fn primary_prefix(&self) -> &str;
73    fn following_phase(&self) -> &str;
74
75    fn pattern_flags(&self) -> &str {
76        "i"
77    }
78    fn primary_pattern_left_boundary(&self) -> &str {
79        r"(^|\s|T|\b)"
80    }
81    fn primary_suffix(&self) -> &str {
82        DEFAULT_SUFFIX
83    }
84    fn following_suffix(&self) -> &str {
85        DEFAULT_SUFFIX
86    }
87
88    fn extract_primary_time_components(&self, context: &ParsingContext, m: &RegexMatch) -> Option<ParsingComponents> {
89        extract_primary_time_components(self.strict_mode(), context, m)
90    }
91
92    fn extract_following_time_components(
93        &self,
94        context: &ParsingContext,
95        m: &RegexMatch,
96        result: &mut ParsingResult,
97    ) -> Option<ParsingComponents> {
98        extract_following_time_components(context, m, result)
99    }
100}
101
102/// JS truthiness of an optional group: `undefined` and `""` are falsy.
103fn truthy(group: Option<&str>) -> bool {
104    group.is_some_and(|g| !g.is_empty())
105}
106
107fn first_char_lower(s: &str) -> String {
108    s.chars().next().map(|c| c.to_lowercase().collect()).unwrap_or_default()
109}
110
111/// `parseInt(match[group])`, with JS `NaN` as `None`.
112fn int_group(m: &RegexMatch, group: usize) -> Option<i64> {
113    m.get(group).and_then(js_parse_int)
114}
115
116/// `parseInt(match[MILLI_SECOND_GROUP].substring(0, 3))` (the group is ASCII digits).
117fn millisecond_group(m: &RegexMatch) -> Option<i64> {
118    m.get(MILLI_SECOND_GROUP)
119        .and_then(|g| js_parse_int(&g[..g.len().min(3)]))
120}
121
122/// upstream `extractPrimaryTimeComponents` (the base implementation).
123pub fn extract_primary_time_components(
124    strict_mode: bool,
125    context: &ParsingContext,
126    m: &RegexMatch,
127) -> Option<ParsingComponents> {
128    let mut components = context.create_parsing_components(&[]);
129    let mut minute = 0;
130    let mut meridiem_value: Option<i64> = None;
131
132    // ----- Hours
133    let mut hour = int_group(m, HOUR_GROUP)?;
134    if hour > 100 {
135        // When time is like '2019', it is more likely a year.
136        if m.get(HOUR_GROUP).map_or(0, str::len) == 4
137            && m.get(MINUTE_GROUP).is_none()
138            && !truthy(m.get(AM_PM_HOUR_GROUP))
139        {
140            return None;
141        }
142        if strict_mode || m.get(MINUTE_GROUP).is_some() {
143            return None;
144        }
145        minute = hour % 100;
146        hour /= 100;
147    }
148
149    if hour > 24 {
150        return None;
151    }
152
153    // ----- Minutes
154    if let Some(minute_text) = m.get(MINUTE_GROUP) {
155        if minute_text.len() == 1 && !truthy(m.get(AM_PM_HOUR_GROUP)) {
156            // Skip single digit minute e.g. "at 1.1 xx"
157            return None;
158        }
159        minute = int_group(m, MINUTE_GROUP)?;
160    }
161
162    if minute >= 60 {
163        return None;
164    }
165
166    if hour > 12 {
167        meridiem_value = Some(meridiem::PM);
168    }
169
170    // ----- AM & PM
171    if let Some(ampm_text) = m.get(AM_PM_HOUR_GROUP) {
172        if hour > 12 {
173            return None;
174        }
175        let ampm = first_char_lower(ampm_text);
176        if ampm == "a" {
177            meridiem_value = Some(meridiem::AM);
178            if hour == 12 {
179                hour = 0;
180            }
181        }
182        if ampm == "p" {
183            meridiem_value = Some(meridiem::PM);
184            if hour != 12 {
185                hour += 12;
186            }
187        }
188    }
189
190    components.assign(Component::Hour, hour);
191    components.assign(Component::Minute, minute);
192
193    match meridiem_value {
194        Some(v) => {
195            components.assign(Component::Meridiem, v);
196        }
197        None => {
198            components.imply(Component::Meridiem, if hour < 12 { meridiem::AM } else { meridiem::PM });
199        }
200    }
201
202    // ----- Millisecond
203    if m.get(MILLI_SECOND_GROUP).is_some() {
204        let millisecond = millisecond_group(m)?;
205        if millisecond >= 1000 {
206            return None;
207        }
208        components.assign(Component::Millisecond, millisecond);
209    }
210
211    // ----- Second
212    if m.get(SECOND_GROUP).is_some() {
213        let second = int_group(m, SECOND_GROUP)?;
214        if second >= 60 {
215            return None;
216        }
217        components.assign(Component::Second, second);
218    }
219
220    Some(components)
221}
222
223/// upstream `extractFollowingTimeComponents` (the base implementation). May adjust `result.start`.
224pub fn extract_following_time_components(
225    context: &ParsingContext,
226    m: &RegexMatch,
227    result: &mut ParsingResult,
228) -> Option<ParsingComponents> {
229    let mut components = context.create_parsing_components(&[]);
230
231    // ----- Millisecond
232    if m.get(MILLI_SECOND_GROUP).is_some() {
233        let millisecond = millisecond_group(m)?;
234        if millisecond >= 1000 {
235            return None;
236        }
237        components.assign(Component::Millisecond, millisecond);
238    }
239
240    // ----- Second
241    if m.get(SECOND_GROUP).is_some() {
242        let second = int_group(m, SECOND_GROUP)?;
243        if second >= 60 {
244            return None;
245        }
246        components.assign(Component::Second, second);
247    }
248
249    let mut hour = int_group(m, HOUR_GROUP)?;
250    let mut minute = 0;
251    let mut meridiem_value: i64 = -1;
252
253    // ----- Minute
254    if m.get(MINUTE_GROUP).is_some() {
255        minute = int_group(m, MINUTE_GROUP)?;
256    } else if hour > 100 {
257        minute = hour % 100;
258        hour /= 100;
259    }
260
261    if minute >= 60 || hour > 24 {
262        return None;
263    }
264
265    if hour >= 12 {
266        meridiem_value = meridiem::PM;
267    }
268
269    // ----- AM & PM
270    if let Some(ampm_text) = m.get(AM_PM_HOUR_GROUP) {
271        if hour > 12 {
272            return None;
273        }
274        let ampm = first_char_lower(ampm_text);
275        if ampm == "a" {
276            meridiem_value = meridiem::AM;
277            if hour == 12 {
278                hour = 0;
279                if !components.is_certain(Component::Day) {
280                    components.add_duration_as_implied(&mut Duration::from_pairs(&[(DurationUnit::Day, 1.0)]));
281                }
282            }
283        }
284        if ampm == "p" {
285            meridiem_value = meridiem::PM;
286            if hour != 12 {
287                hour += 12;
288            }
289        }
290
291        if !result.start.is_certain(Component::Meridiem) {
292            if meridiem_value == meridiem::AM {
293                result.start.imply(Component::Meridiem, meridiem::AM);
294                if result.start.get(Component::Hour) == Some(12) {
295                    result.start.assign(Component::Hour, 0);
296                }
297            } else {
298                result.start.imply(Component::Meridiem, meridiem::PM);
299                if result.start.get(Component::Hour) != Some(12) {
300                    let start_hour = result.start.get_or_zero(Component::Hour);
301                    result.start.assign(Component::Hour, start_hour + 12);
302                }
303            }
304        }
305    }
306
307    components.assign(Component::Hour, hour);
308    components.assign(Component::Minute, minute);
309
310    if meridiem_value >= 0 {
311        components.assign(Component::Meridiem, meridiem_value);
312    } else {
313        let start_hour = result.start.get_or_zero(Component::Hour);
314        let start_at_pm = result.start.is_certain(Component::Meridiem) && start_hour > 12;
315        if start_at_pm {
316            if start_hour - 12 > hour {
317                // 10pm - 1 (am)
318                components.imply(Component::Meridiem, meridiem::AM);
319            } else if hour <= 12 {
320                components.assign(Component::Hour, hour + 12);
321                components.assign(Component::Meridiem, meridiem::PM);
322            }
323        } else if hour > 12 {
324            components.imply(Component::Meridiem, meridiem::PM);
325        } else if hour <= 12 {
326            components.imply(Component::Meridiem, meridiem::AM);
327        }
328    }
329
330    if let (Some(end), Some(start)) = (components.date().get_time(), result.start.date().get_time()) {
331        if end < start {
332            components.add_duration_as_implied(&mut Duration::from_pairs(&[(DurationUnit::Day, 1.0)]));
333        }
334    }
335
336    Some(components)
337}
338
339/// The concrete `AbstractTimeExpressionParser`: pattern caching and the `extract` flow.
340pub struct TimeExpression<P> {
341    inner: P,
342    primary: OnceLock<JsRegex>,
343    following: OnceLock<JsRegex>,
344}
345
346impl<P: TimeExpressionParser> TimeExpression<P> {
347    pub fn new(inner: P) -> TimeExpression<P> {
348        TimeExpression {
349            inner,
350            primary: OnceLock::new(),
351            following: OnceLock::new(),
352        }
353    }
354
355    fn primary_pattern(&self) -> &JsRegex {
356        self.primary.get_or_init(|| {
357            primary_time_pattern(
358                self.inner.primary_pattern_left_boundary(),
359                self.inner.primary_prefix(),
360                self.inner.primary_suffix(),
361                self.inner.pattern_flags(),
362            )
363        })
364    }
365
366    fn following_pattern(&self) -> &JsRegex {
367        self.following
368            .get_or_init(|| following_time_pattern(self.inner.following_phase(), self.inner.following_suffix()))
369    }
370
371    fn check_and_return_without_following_pattern(&self, result: ParsingResult) -> Option<ParsingResult> {
372        let text = result.text.as_str();
373        // Single digit (e.g "1") should not be counted as time expression (without proper context)
374        if js_regex!(r"^\d$").is_match(text) {
375            return None;
376        }
377        // Three or more digit (e.g. "203", "2014") should not be counted as time expression
378        if js_regex!(r"^\d\d\d+$").is_match(text) {
379            return None;
380        }
381        // Instead of "am/pm", it ends with "a" or "p" (e.g "1a", "123p"), this seems unlikely
382        if js_regex!(r"\d[apAP]$").is_match(text) {
383            return None;
384        }
385        // If it ends only with numbers or dots
386        if let Some(ending) = js_regex!(r"[^\d:.](\d[\d.]+)$").exec(text) {
387            let ending_numbers = ending.get(1).unwrap_or("");
388            // In strict mode (e.g. "at 1" or "at 1.2"), this should not be accepted
389            if self.inner.strict_mode() {
390                return None;
391            }
392            // If it ends only with dot single digit, e.g. "at 1.2"
393            if ending_numbers.contains('.') && !js_regex!(r"\d(\.\d{2})+$").is_match(ending_numbers) {
394                return None;
395            }
396            // If it ends only with numbers above 24, e.g. "at 25"
397            if js_parse_int(ending_numbers).is_some_and(|v| v > 24) {
398                return None;
399            }
400        }
401        Some(result)
402    }
403
404    fn check_and_return_with_following_pattern(&self, result: ParsingResult) -> Option<ParsingResult> {
405        let text = result.text.as_str();
406        if js_regex!(r"^\d+-\d+$").is_match(text) {
407            return None;
408        }
409        // If it ends only with numbers or dots
410        if let Some(ending) = js_regex!(r"[^\d:.](\d[\d.]+)\s*-\s*(\d[\d.]+)$").exec(text) {
411            // In strict mode (e.g. "at 1-3" or "at 1.2 - 2.3"), this should not be accepted
412            if self.inner.strict_mode() {
413                return None;
414            }
415            let starting_numbers = ending.get(1).unwrap_or("");
416            let ending_numbers = ending.get(2).unwrap_or("");
417            // If it ends only with dot single digit, e.g. "at 1.2"
418            if ending_numbers.contains('.') && !js_regex!(r"\d(\.\d{2})+$").is_match(ending_numbers) {
419                return None;
420            }
421            // If it ends only with numbers above 24, e.g. "at 25"
422            let gt24 = |s: &str| js_parse_int(s).is_some_and(|v| v > 24);
423            if gt24(ending_numbers) || gt24(starting_numbers) {
424                return None;
425            }
426        }
427        Some(result)
428    }
429}
430
431impl<P: TimeExpressionParser> Parser for TimeExpression<P> {
432    fn pattern(&self, _context: &ParsingContext) -> JsRegex {
433        self.primary_pattern().clone()
434    }
435
436    fn extract(&self, context: &ParsingContext, m: &mut RegexMatch) -> Extracted {
437        let Some(start_components) = self.inner.extract_primary_time_components(context, m) else {
438            // If the match seem like a year e.g. "2013.12:...", skip the year part and try again.
439            if js_regex!(r"^\d{4}").is_match(m.text()) {
440                m.index += 4;
441                return Extracted::None;
442            }
443            m.index += m.text().len();
444            return Extracted::None;
445        };
446
447        let header_len = m.get(1).map_or(0, str::len);
448        let index = m.index + header_len;
449        let text = m.text()[header_len..].to_string();
450        let mut result = context.create_parsing_result(index, text.clone(), Some(start_components), None);
451        m.index += m.text().len();
452
453        let remaining = context.text.get(m.index..).unwrap_or("");
454        let following = self.following_pattern().exec(remaining);
455
456        // Pattern "456-12", "2022-12" should not be time without proper context
457        if let Some(f) = &following {
458            if js_regex!(r"^\d{3,4}").is_match(&text) {
459                // e.g. "2022-12"
460                if js_regex!(r"^\s*([+-])\s*\d{2,4}$").is_match(f.text()) {
461                    return Extracted::None;
462                }
463                // e.g. "2022-12:01..."
464                if js_regex!(r"^\s*([+-])\s*\d{2}\W\d{2}").is_match(f.text()) {
465                    return Extracted::None;
466                }
467            }
468        }
469
470        let following = match following {
471            // Pattern "YY.YY -XXXX" is more like timezone offset
472            Some(f) if !js_regex!(r"^\s*([+-])\s*\d{3,4}$").is_match(f.text()) => f,
473            _ => {
474                return self
475                    .check_and_return_without_following_pattern(result)
476                    .map_or(Extracted::None, Extracted::Result);
477            }
478        };
479
480        result.end = self
481            .inner
482            .extract_following_time_components(context, &following, &mut result);
483        if result.end.is_some() {
484            result.text.push_str(following.text());
485        }
486
487        self.check_and_return_with_following_pattern(result)
488            .map_or(Extracted::None, Extracted::Result)
489    }
490}