Skip to main content

whichtime_sys/parsers/es/
time_expression.rs

1//! Spanish time expression parser
2//!
3//! Handles Spanish time expressions like:
4//! - "6.13 AM" (dot separator)
5//! - "las 6:30pm"
6//! - "de 6:30pm a 11:00pm" (ranges)
7//! - "8:10 - 12.32" (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// Primary pattern: handles single times and "de X a Y" ranges
19// Supports both : and . as separators
20static PRIMARY_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
21    Regex::new(
22        r"(?i)(?:^|[^\d])(?:de\s+)?(?:las?\s+)?(\d{1,2})(?:[:\.](\d{2}))?(?::(\d{2}))?(?:\s*(a\.?m\.?|p\.?m\.?))?(?:\s*(?:a(?:\s+las?)?|[\-–~])\s*(\d{1,2})(?:[:\.](\d{2}))?(?::(\d{2}))?(?:\s*(a\.?m\.?|p\.?m\.?))?)?(?=[^\d]|$)"
23    ).unwrap()
24});
25
26/// Spanish time expression parser
27pub struct ESTimeExpressionParser;
28
29impl ESTimeExpressionParser {
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 ESTimeExpressionParser {
67    fn name(&self) -> &'static str {
68        "ESTimeExpressionParser"
69    }
70
71    fn should_apply(&self, context: &ParsingContext) -> bool {
72        let text = context.text;
73        // Must contain digits and time separators or meridiem indicators
74        text.bytes().any(|b| b.is_ascii_digit())
75            && (text.contains(':')
76                || text.contains('.')
77                || text.to_lowercase().contains("am")
78                || text.to_lowercase().contains("pm")
79                || text.to_lowercase().contains("a.m")
80                || text.to_lowercase().contains("p.m"))
81    }
82
83    fn parse(&self, context: &ParsingContext) -> Result<Vec<ParsedResult>> {
84        let mut results = Vec::new();
85        let ref_date = context.reference.instant;
86
87        let mut start = 0;
88        while start < context.text.len() {
89            let search_text = &context.text[start..];
90            let mat = match PRIMARY_PATTERN.find(search_text) {
91                Ok(Some(m)) => m,
92                Ok(None) => break,
93                Err(_) => break,
94            };
95
96            let matched_text = mat.as_str();
97            let index = start + mat.start();
98
99            let caps = match PRIMARY_PATTERN.captures(matched_text) {
100                Ok(Some(c)) => c,
101                Ok(None) => {
102                    start = index + 1;
103                    continue;
104                }
105                Err(_) => {
106                    start = index + 1;
107                    continue;
108                }
109            };
110
111            // Parse start time
112            let hour1: i32 = caps
113                .get(1)
114                .and_then(|m| m.as_str().parse().ok())
115                .unwrap_or(-1);
116
117            if !(0..=23).contains(&hour1) {
118                start = index + 1;
119                continue;
120            }
121
122            let minute1: i32 = caps
123                .get(2)
124                .and_then(|m| m.as_str().parse().ok())
125                .unwrap_or(0);
126            let second1: i32 = caps
127                .get(3)
128                .and_then(|m| m.as_str().parse().ok())
129                .unwrap_or(0);
130            let meridiem1 = caps
131                .get(4)
132                .map(|m| m.as_str())
133                .and_then(Self::parse_meridiem);
134
135            // Check for end time (range)
136            let has_end_time = caps.get(5).is_some();
137            let hour2: i32 = caps
138                .get(5)
139                .and_then(|m| m.as_str().parse().ok())
140                .unwrap_or(-1);
141            let minute2: i32 = caps
142                .get(6)
143                .and_then(|m| m.as_str().parse().ok())
144                .unwrap_or(0);
145            let second2: i32 = caps
146                .get(7)
147                .and_then(|m| m.as_str().parse().ok())
148                .unwrap_or(0);
149            let meridiem2 = caps
150                .get(8)
151                .map(|m| m.as_str())
152                .and_then(Self::parse_meridiem);
153
154            // If no explicit meridiem and no minutes, and not in a range, this might not be a time
155            // Be strict about standalone numbers without context
156            if meridiem1.is_none() && caps.get(2).is_none() && !has_end_time {
157                // Check if there's a time prefix like "las" or "a las"
158                let prefix =
159                    &matched_text[..matched_text.find(|c: char| c.is_ascii_digit()).unwrap_or(0)];
160                let lower_prefix = prefix.to_lowercase();
161                if !lower_prefix.contains("las") && !lower_prefix.contains("de") {
162                    start = index + 1;
163                    continue;
164                }
165            }
166
167            // Adjust hours based on meridiem
168            let adj_hour1 = Self::adjust_hour(hour1, meridiem1);
169
170            // Infer meridiem for second time based on first
171            let effective_meridiem2 = meridiem2.or(meridiem1);
172            let adj_hour2 = if has_end_time {
173                Self::adjust_hour(hour2, effective_meridiem2)
174            } else {
175                0
176            };
177
178            // Build start components
179            let mut components = context.create_components();
180            components.assign(Component::Hour, adj_hour1);
181            components.assign(Component::Minute, minute1);
182            if caps.get(3).is_some() {
183                components.assign(Component::Second, second1);
184            }
185            if let Some(m) = meridiem1 {
186                components.assign(Component::Meridiem, m as i32);
187            } else if adj_hour1 >= 12 {
188                components.assign(Component::Meridiem, Meridiem::PM as i32);
189            }
190
191            // Build end components if range
192            let end_components = if has_end_time && hour2 >= 0 {
193                let mut end_comp = context.create_components();
194                end_comp.assign(Component::Hour, adj_hour2);
195                end_comp.assign(Component::Minute, minute2);
196                if caps.get(7).is_some() {
197                    end_comp.assign(Component::Second, second2);
198                }
199                if let Some(m) = effective_meridiem2 {
200                    end_comp.assign(Component::Meridiem, m as i32);
201                } else if adj_hour2 >= 12 {
202                    end_comp.assign(Component::Meridiem, Meridiem::PM as i32);
203                }
204
205                // Copy date from reference for end component
206                use chrono::Datelike;
207                end_comp.imply(Component::Year, ref_date.year());
208                end_comp.imply(Component::Month, ref_date.month() as i32);
209                end_comp.imply(Component::Day, ref_date.day() as i32);
210
211                Some(end_comp)
212            } else {
213                None
214            };
215
216            // Calculate actual matched text boundaries
217            let actual_start = matched_text
218                .find(|c: char| c.is_ascii_digit() || c == 'd' || c == 'D' || c == 'l' || c == 'L')
219                .unwrap_or(0);
220            let actual_text = &matched_text[actual_start..];
221            let actual_end = actual_text
222                .rfind(|c: char| c.is_ascii_alphanumeric())
223                .map(|i| i + actual_text[i..].chars().next().map_or(1, char::len_utf8))
224                .unwrap_or(actual_text.len());
225
226            results.push(context.create_result(
227                index + actual_start,
228                index + actual_start + actual_end,
229                components,
230                end_components,
231            ));
232
233            start += mat.end();
234        }
235
236        Ok(results)
237    }
238}
239
240impl Default for ESTimeExpressionParser {
241    fn default() -> Self {
242        Self::new()
243    }
244}