Skip to main content

whichtime_sys/parsers/fr/
time_expression.rs

1//! French time expression parser
2//!
3//! Handles French time expressions like:
4//! - "8h10", "8h10m"
5//! - "8:10 PM"
6//! - "8:10 - 12.32" (ranges)
7//! - "de 8h à 10h" (ranges)
8
9use crate::components::Component;
10use crate::context::ParsingContext;
11use crate::error::Result;
12use crate::parsers::Parser;
13use crate::results::ParsedResult;
14use crate::types::Meridiem;
15use fancy_regex::Regex;
16use std::sync::LazyLock;
17
18// French time pattern: handles 8h10, 8h10m, 8:10, 8:10 PM, etc.
19// Uses negative lookbehind (?<!\d) to ensure hour is not preceded by a digit
20static PRIMARY_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
21    Regex::new(
22        r"(?i)(?<!\d)(?:(?:de\s+)?(?:à\s+|a\s+)?)?(\d{1,2})(?:h(\d{2})?m?|[:\.](\d{2}))(?::(\d{2}))?(?:\s*(a\.?m\.?|p\.?m\.?))?(?:\s*(?:à|a|[\-–~])\s*(\d{1,2})(?:h(\d{2})?m?|[:\.](\d{2}))?(?::(\d{2}))?(?:\s*(a\.?m\.?|p\.?m\.?))?)?(?![\d:a-zA-Z])"
23    ).unwrap()
24});
25
26/// French time expression parser
27pub struct FRTimeExpressionParser;
28
29impl FRTimeExpressionParser {
30    pub fn new() -> Self {
31        Self
32    }
33
34    fn parse_meridiem(s: &str) -> Option<Meridiem> {
35        let lower = s.to_lowercase();
36        if lower.starts_with('p') {
37            Some(Meridiem::PM)
38        } else if lower.starts_with('a') {
39            Some(Meridiem::AM)
40        } else {
41            None
42        }
43    }
44
45    fn adjust_hour(hour: i32, meridiem: Option<Meridiem>) -> i32 {
46        match meridiem {
47            Some(Meridiem::PM) => {
48                if hour < 12 {
49                    hour + 12
50                } else {
51                    hour
52                }
53            }
54            Some(Meridiem::AM) => {
55                if hour == 12 {
56                    0
57                } else {
58                    hour
59                }
60            }
61            None => hour,
62        }
63    }
64}
65
66impl Parser for FRTimeExpressionParser {
67    fn name(&self) -> &'static str {
68        "FRTimeExpressionParser"
69    }
70
71    fn should_apply(&self, context: &ParsingContext) -> bool {
72        let text = context.text;
73        // Must contain digits and time indicators
74        text.bytes().any(|b| b.is_ascii_digit())
75            && (text.contains('h')
76                || text.contains(':')
77                || text.contains('.')
78                || text.to_lowercase().contains("am")
79                || text.to_lowercase().contains("pm"))
80    }
81
82    fn parse(&self, context: &ParsingContext) -> Result<Vec<ParsedResult>> {
83        let mut results = Vec::new();
84        let ref_date = context.reference.instant;
85
86        let mut start = 0;
87        while start < context.text.len() {
88            let search_text = &context.text[start..];
89            let mat = match PRIMARY_PATTERN.find(search_text) {
90                Ok(Some(m)) => m,
91                Ok(None) => break,
92                Err(_) => break,
93            };
94
95            let matched_text = mat.as_str();
96            let index = start + mat.start();
97
98            // Ensure we're not matching a partial number (e.g., "3.12" from "13.12")
99            if index > 0 {
100                let prev_char = context.text.as_bytes().get(index - 1);
101                if let Some(&c) = prev_char
102                    && c.is_ascii_digit()
103                {
104                    start += mat.end();
105                    continue;
106                }
107            }
108
109            let caps = match PRIMARY_PATTERN.captures(matched_text) {
110                Ok(Some(c)) => c,
111                Ok(None) => {
112                    start = index + 1;
113                    continue;
114                }
115                Err(_) => {
116                    start = index + 1;
117                    continue;
118                }
119            };
120
121            // Parse start time
122            let hour1: i32 = caps
123                .get(1)
124                .and_then(|m| m.as_str().parse().ok())
125                .unwrap_or(-1);
126
127            if !(0..=23).contains(&hour1) {
128                start += mat.end();
129                continue;
130            }
131
132            // Minutes can be in group 2 (for 8h10) or group 3 (for 8:10)
133            let minute1: i32 = caps
134                .get(2)
135                .or(caps.get(3))
136                .and_then(|m| m.as_str().parse().ok())
137                .unwrap_or(0);
138
139            // Validate minutes
140            if !(0..=59).contains(&minute1) {
141                start = index + 1;
142                continue;
143            }
144
145            let second1: i32 = caps
146                .get(4)
147                .and_then(|m| m.as_str().parse().ok())
148                .unwrap_or(0);
149
150            // Validate seconds
151            if !(0..=59).contains(&second1) {
152                start += mat.end();
153                continue;
154            }
155
156            let meridiem1 = caps
157                .get(5)
158                .map(|m| m.as_str())
159                .and_then(Self::parse_meridiem);
160
161            // If AM/PM is specified, hour must be in 12-hour format (1-12)
162            // Also check if hour > 12 with decimal (like 13.12)
163            if meridiem1.is_some() && hour1 > 12 {
164                start += mat.end();
165                continue;
166            }
167            // Hour 0 with AM/PM is invalid
168            if meridiem1.is_some() && hour1 == 0 {
169                start += mat.end();
170                continue;
171            }
172
173            // Check for end time (range)
174            let has_end_time = caps.get(6).is_some();
175            let hour2: i32 = caps
176                .get(6)
177                .and_then(|m| m.as_str().parse().ok())
178                .unwrap_or(-1);
179            let minute2: i32 = caps
180                .get(7)
181                .or(caps.get(8))
182                .and_then(|m| m.as_str().parse().ok())
183                .unwrap_or(0);
184            let second2: i32 = caps
185                .get(9)
186                .and_then(|m| m.as_str().parse().ok())
187                .unwrap_or(0);
188            let meridiem2 = caps
189                .get(10)
190                .map(|m| m.as_str())
191                .and_then(Self::parse_meridiem);
192
193            // Adjust hours based on meridiem
194            let adj_hour1 = Self::adjust_hour(hour1, meridiem1);
195
196            // Infer meridiem for second time based on first
197            let effective_meridiem2 = meridiem2.or(meridiem1);
198            let adj_hour2 = if has_end_time && hour2 >= 0 {
199                Self::adjust_hour(hour2, effective_meridiem2)
200            } else {
201                0
202            };
203
204            // Build start components
205            let mut components = context.create_components();
206            components.assign(Component::Hour, adj_hour1);
207            components.assign(Component::Minute, minute1);
208            if caps.get(4).is_some() {
209                components.assign(Component::Second, second1);
210            }
211            if let Some(m) = meridiem1 {
212                components.assign(Component::Meridiem, m as i32);
213            } else if adj_hour1 >= 12 {
214                components.assign(Component::Meridiem, Meridiem::PM as i32);
215            }
216
217            // Build end components if range
218            let end_components = if has_end_time && hour2 >= 0 {
219                let mut end_comp = context.create_components();
220                end_comp.assign(Component::Hour, adj_hour2);
221                end_comp.assign(Component::Minute, minute2);
222                if caps.get(9).is_some() {
223                    end_comp.assign(Component::Second, second2);
224                }
225                if let Some(m) = effective_meridiem2 {
226                    end_comp.assign(Component::Meridiem, m as i32);
227                } else if adj_hour2 >= 12 {
228                    end_comp.assign(Component::Meridiem, Meridiem::PM as i32);
229                }
230
231                // Copy date from reference
232                use chrono::Datelike;
233                end_comp.imply(Component::Year, ref_date.year());
234                end_comp.imply(Component::Month, ref_date.month() as i32);
235                end_comp.imply(Component::Day, ref_date.day() as i32);
236
237                Some(end_comp)
238            } else {
239                None
240            };
241
242            // Calculate actual matched text boundaries
243            let actual_start = matched_text
244                .find(|c: char| c.is_ascii_digit() || c == 'd' || c == 'D' || c == 'à' || c == 'a')
245                .unwrap_or(0);
246            let actual_text = &matched_text[actual_start..];
247            let actual_end = actual_text
248                .rfind(|c: char| c.is_ascii_alphanumeric())
249                .map(|i| i + actual_text[i..].chars().next().map_or(1, char::len_utf8))
250                .unwrap_or(actual_text.len());
251
252            results.push(context.create_result(
253                index + actual_start,
254                index + actual_start + actual_end,
255                components,
256                end_components,
257            ));
258
259            start += mat.end();
260        }
261
262        Ok(results)
263    }
264}
265
266impl Default for FRTimeExpressionParser {
267    fn default() -> Self {
268        Self::new()
269    }
270}