Skip to main content

squeeze/
datetime.rs

1use super::{Anchor, ByteSet, Finder, RunClass, RunRule};
2use std::ops::Range;
3
4#[derive(Default)]
5pub struct Datetime {}
6
7impl Datetime {
8    fn try_date(input: &[u8], idx: usize) -> Option<Range<usize>> {
9        // YYYY-MM-DD with optional time component
10        if idx + 10 > input.len() {
11            return None;
12        }
13
14        // Boundary before: not preceded by a digit. A preceding '-' is a
15        // valid boundary when it is not glued to a digit run, so kebab names
16        // like `backup-2024-01-15` match while `01-2024-01-15` stays vetoed.
17        if idx > 0 {
18            let prev = input[idx - 1];
19            if prev.is_ascii_digit() {
20                return None;
21            }
22            if prev == b'-' && idx >= 2 && input[idx - 2].is_ascii_digit() {
23                return None;
24            }
25        }
26
27        // YYYY
28        if !Self::is_4_digits(input, idx) {
29            return None;
30        }
31        let year = Self::parse_num(input, idx, 4);
32        if !(1000..=9999).contains(&year) {
33            return None;
34        }
35
36        if input[idx + 4] != b'-' {
37            return None;
38        }
39
40        // MM
41        if !Self::is_2_digits(input, idx + 5) {
42            return None;
43        }
44        let month = Self::parse_num(input, idx + 5, 2);
45        if !(1..=12).contains(&month) {
46            return None;
47        }
48
49        if input[idx + 7] != b'-' {
50            return None;
51        }
52
53        // DD
54        if !Self::is_2_digits(input, idx + 8) {
55            return None;
56        }
57        let day = Self::parse_num(input, idx + 8, 2);
58        if !(1..=31).contains(&day) {
59            return None;
60        }
61
62        let mut end = idx + 10;
63
64        // Optional time component: T/t (RFC 3339) or space followed by HH:MM
65        if end < input.len()
66            && (input[end] == b'T' || input[end] == b't' || input[end] == b' ')
67            && let Some(time_end) = Self::try_time(input, end + 1)
68        {
69            end = time_end;
70        }
71
72        // Boundary after: never followed by a digit. A trailing '-' is only a
73        // veto when a digit follows and no timezone consumed it
74        // (`2024-01-15-01` stays ambiguous), while `2024-01-15-my-post` is a
75        // kebab-case boundary.
76        if end < input.len() {
77            let next = input[end];
78            if next.is_ascii_digit() {
79                return None;
80            }
81            if next == b'-' && end + 1 < input.len() && input[end + 1].is_ascii_digit() {
82                return None;
83            }
84        }
85
86        Some(idx..end)
87    }
88
89    fn try_time(input: &[u8], idx: usize) -> Option<usize> {
90        // HH:MM[:SS[.fractional]]
91        if idx + 5 > input.len() {
92            return None;
93        }
94
95        if !Self::is_2_digits(input, idx) {
96            return None;
97        }
98        let hour = Self::parse_num(input, idx, 2);
99        if hour > 23 {
100            return None;
101        }
102
103        if input[idx + 2] != b':' {
104            return None;
105        }
106
107        if !Self::is_2_digits(input, idx + 3) {
108            return None;
109        }
110        let minute = Self::parse_num(input, idx + 3, 2);
111        if minute > 59 {
112            return None;
113        }
114
115        let mut end = idx + 5;
116
117        // Optional :SS
118        if end + 3 <= input.len() && input[end] == b':' && Self::is_2_digits(input, end + 1) {
119            let second = Self::parse_num(input, end + 1, 2);
120            if second <= 60 {
121                end += 3;
122
123                // Optional fractional seconds: '.' or the ISO-preferred ','
124                // (only consumed when at least one digit follows)
125                if end < input.len() && (input[end] == b'.' || input[end] == b',') {
126                    let frac_start = end + 1;
127                    let mut frac_end = frac_start;
128                    while frac_end < input.len() && input[frac_end].is_ascii_digit() {
129                        frac_end += 1;
130                    }
131                    if frac_end > frac_start {
132                        end = frac_end;
133                    }
134                }
135            }
136        }
137
138        // Optional timezone (only ever after a time component): Z/z,
139        // ±HH:MM, ±HHMM, or ±HH.
140        if end < input.len() && (input[end] == b'Z' || input[end] == b'z') {
141            end += 1;
142        } else if end < input.len() && (input[end] == b'+' || input[end] == b'-') {
143            // Count the digits directly after the sign (capped: 5 means
144            // "5 or more", which is never a valid offset).
145            let mut digits = 0;
146            while digits < 5
147                && end + 1 + digits < input.len()
148                && input[end + 1 + digits].is_ascii_digit()
149            {
150                digits += 1;
151            }
152            if digits == 2 {
153                if end + 6 <= input.len()
154                    && input[end + 3] == b':'
155                    && Self::is_2_digits(input, end + 4)
156                {
157                    end += 6; // ±HH:MM
158                } else {
159                    end += 3; // ±HH
160                }
161            } else if digits == 4 {
162                end += 5; // ±HHMM
163            }
164            // Any other digit count: leave the sign unconsumed; the
165            // boundary check in try_date decides what happens next.
166        }
167
168        Some(end)
169    }
170
171    fn is_2_digits(input: &[u8], pos: usize) -> bool {
172        pos + 2 <= input.len() && input[pos].is_ascii_digit() && input[pos + 1].is_ascii_digit()
173    }
174
175    fn is_4_digits(input: &[u8], pos: usize) -> bool {
176        pos + 4 <= input.len()
177            && input[pos].is_ascii_digit()
178            && input[pos + 1].is_ascii_digit()
179            && input[pos + 2].is_ascii_digit()
180            && input[pos + 3].is_ascii_digit()
181    }
182
183    fn parse_num(input: &[u8], pos: usize, len: usize) -> u32 {
184        let mut n = 0u32;
185        for i in 0..len {
186            n = n * 10 + (input[pos + i] - b'0') as u32;
187        }
188        n
189    }
190}
191
192impl Finder for Datetime {
193    fn line_agnostic(&self) -> bool {
194        // Matches never contain a line terminator and `\n`/`\r` end every
195        // walk exactly like the end of the input does.
196        true
197    }
198
199    fn id(&self) -> &'static str {
200        "datetime"
201    }
202
203    fn dispatchable(&self) -> bool {
204        true
205    }
206
207    fn could_start_at(&self, byte: u8) -> bool {
208        byte.is_ascii_digit()
209    }
210
211    fn could_start_after(&self, prev: u8, _cur: u8) -> bool {
212        !prev.is_ascii_digit()
213    }
214
215    fn could_continue_with(&self, _cur: u8, next: u8) -> bool {
216        next.is_ascii_digit()
217    }
218
219    fn anchor(&self) -> Option<Anchor> {
220        // `YYYY-MM-DD`: the dash after the month follows three bytes on.
221        Some(
222            Anchor::new(
223                ByteSet::from_bytes(b"-"),
224                ByteSet::from_fn(|b| b.is_ascii_digit()),
225            )
226            .confirm(b"-", &[3], b"-")
227            .back(4),
228        )
229    }
230
231    fn run_rules(&self) -> Vec<RunRule> {
232        // `YYYY-MM-`.
233        vec![
234            RunRule::new(RunClass::Digit, 4, 4)
235                .followed_by(b"-")
236                .then(RunClass::Digit, 2, 2)
237                .followed_by(b"-"),
238        ]
239    }
240
241    fn try_at(&self, input: &[u8], pos: usize) -> Option<Range<usize>> {
242        if !input[pos].is_ascii_digit() {
243            return None;
244        }
245        Self::try_date(input, pos)
246    }
247
248    fn find(&self, s: &str) -> Option<Range<usize>> {
249        let input = s.as_bytes();
250        let mut idx = 0;
251
252        while idx + 10 <= input.len() {
253            if input[idx].is_ascii_digit()
254                && let Some(range) = Self::try_date(input, idx)
255            {
256                return Some(range);
257            }
258            idx += 1;
259        }
260
261        None
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn id_should_return_datetime() {
271        let finder = Datetime::default();
272        assert_eq!("datetime", finder.id());
273    }
274
275    #[test]
276    fn find_should_extract_date() {
277        let finder = Datetime::default();
278        let input = "created on 2024-01-15 by admin";
279        let range = finder.find(input).unwrap();
280        assert_eq!("2024-01-15", &input[range]);
281    }
282
283    #[test]
284    fn find_should_extract_datetime_with_t() {
285        let finder = Datetime::default();
286        let input = "timestamp: 2024-01-15T10:30:00";
287        let range = finder.find(input).unwrap();
288        assert_eq!("2024-01-15T10:30:00", &input[range]);
289    }
290
291    #[test]
292    fn find_should_extract_datetime_with_timezone_z() {
293        let finder = Datetime::default();
294        let input = "2024-01-15T10:30:00Z";
295        let range = finder.find(input).unwrap();
296        assert_eq!("2024-01-15T10:30:00Z", &input[range]);
297    }
298
299    #[test]
300    fn find_should_extract_datetime_with_timezone_offset() {
301        let finder = Datetime::default();
302        let input = "2024-01-15T10:30:00+05:30";
303        let range = finder.find(input).unwrap();
304        assert_eq!("2024-01-15T10:30:00+05:30", &input[range]);
305    }
306
307    #[test]
308    fn find_should_extract_datetime_with_negative_offset() {
309        let finder = Datetime::default();
310        let input = "2024-01-15T10:30:00-08:00";
311        let range = finder.find(input).unwrap();
312        assert_eq!("2024-01-15T10:30:00-08:00", &input[range]);
313    }
314
315    #[test]
316    fn find_should_extract_datetime_with_fractional_seconds() {
317        let finder = Datetime::default();
318        let input = "2024-01-15T10:30:00.123Z";
319        let range = finder.find(input).unwrap();
320        assert_eq!("2024-01-15T10:30:00.123Z", &input[range]);
321    }
322
323    #[test]
324    fn find_should_extract_datetime_with_fractional_and_offset() {
325        let finder = Datetime::default();
326        let input = "2024-01-15T10:30:00.123456+02:00";
327        let range = finder.find(input).unwrap();
328        assert_eq!("2024-01-15T10:30:00.123456+02:00", &input[range]);
329    }
330
331    #[test]
332    fn find_should_extract_datetime_without_seconds() {
333        let finder = Datetime::default();
334        let input = "2024-01-15T10:30Z";
335        let range = finder.find(input).unwrap();
336        assert_eq!("2024-01-15T10:30Z", &input[range]);
337    }
338
339    #[test]
340    fn find_should_extract_date_in_text() {
341        let finder = Datetime::default();
342        let input = "deployed on 2024-12-25 successfully";
343        let range = finder.find(input).unwrap();
344        assert_eq!("2024-12-25", &input[range]);
345    }
346
347    #[test]
348    fn find_should_reject_invalid_month() {
349        let finder = Datetime::default();
350        assert!(finder.find("2024-13-01").is_none());
351    }
352
353    #[test]
354    fn find_should_reject_invalid_day() {
355        let finder = Datetime::default();
356        assert!(finder.find("2024-01-32").is_none());
357    }
358
359    #[test]
360    fn find_should_reject_month_zero() {
361        let finder = Datetime::default();
362        assert!(finder.find("2024-00-15").is_none());
363    }
364
365    #[test]
366    fn find_should_reject_day_zero() {
367        let finder = Datetime::default();
368        assert!(finder.find("2024-01-00").is_none());
369    }
370
371    #[test]
372    fn find_should_reject_preceded_by_digit() {
373        let finder = Datetime::default();
374        assert!(finder.find("x12024-01-15").is_none());
375    }
376
377    #[test]
378    fn find_should_reject_followed_by_digit() {
379        let finder = Datetime::default();
380        assert!(finder.find("2024-01-155").is_none());
381    }
382
383    #[test]
384    fn find_should_handle_empty_input() {
385        let finder = Datetime::default();
386        assert!(finder.find("").is_none());
387    }
388
389    #[test]
390    fn find_should_extract_multiple_datetimes_iteratively() {
391        let finder = Datetime::default();
392        let input = "2024-01-15T10:00:00Z to 2024-01-16T12:00:00Z";
393
394        let mut results = Vec::new();
395        let mut idx = 0;
396        while idx < input.len() {
397            if let Some(range) = finder.find(&input[idx..]) {
398                results.push(&input[idx + range.start..idx + range.end]);
399                idx += range.end;
400            } else {
401                break;
402            }
403        }
404
405        assert_eq!(
406            vec!["2024-01-15T10:00:00Z", "2024-01-16T12:00:00Z"],
407            results
408        );
409    }
410
411    #[test]
412    fn find_should_extract_date_at_start() {
413        let finder = Datetime::default();
414        let input = "2024-01-15 is the date";
415        let range = finder.find(input).unwrap();
416        assert_eq!("2024-01-15", &input[range]);
417    }
418
419    #[test]
420    fn find_should_extract_date_at_end() {
421        let finder = Datetime::default();
422        let input = "the date is 2024-01-15";
423        let range = finder.find(input).unwrap();
424        assert_eq!("2024-01-15", &input[range]);
425    }
426
427    #[test]
428    fn find_should_reject_invalid_hour() {
429        let finder = Datetime::default();
430        let input = "2024-01-15T25:00:00";
431        let range = finder.find(input).unwrap();
432        assert_eq!("2024-01-15", &input[range]);
433    }
434
435    #[test]
436    fn find_should_reject_invalid_minute() {
437        let finder = Datetime::default();
438        let input = "2024-01-15T10:61:00";
439        let range = finder.find(input).unwrap();
440        assert_eq!("2024-01-15", &input[range]);
441    }
442
443    #[test]
444    fn find_should_extract_datetime_with_space_separator() {
445        let finder = Datetime::default();
446        let input = "log: 2024-01-15 10:30:00";
447        let range = finder.find(input).unwrap();
448        assert_eq!("2024-01-15 10:30:00", &input[range]);
449    }
450
451    #[test]
452    fn try_at_date_at_start() {
453        let finder = Datetime::default();
454        let input = b"2024-01-15 rest";
455        assert_eq!(finder.try_at(input, 0), Some(0..10));
456    }
457
458    #[test]
459    fn try_at_non_digit() {
460        let finder = Datetime::default();
461        let input = b"abc";
462        assert!(finder.try_at(input, 0).is_none());
463    }
464
465    #[test]
466    fn try_at_short_input() {
467        let finder = Datetime::default();
468        let input = b"2024";
469        assert!(finder.try_at(input, 0).is_none());
470    }
471
472    #[test]
473    fn try_at_preceded_by_digit() {
474        let finder = Datetime::default();
475        let input = b"12024-01-15";
476        assert!(finder.try_at(input, 1).is_none());
477    }
478
479    #[test]
480    fn find_boundary_year_1000() {
481        let finder = Datetime::default();
482        let input = "1000-01-01";
483        assert!(finder.find(input).is_some());
484    }
485
486    #[test]
487    fn find_boundary_year_9999() {
488        let finder = Datetime::default();
489        let input = "9999-12-31";
490        assert!(finder.find(input).is_some());
491    }
492
493    #[test]
494    fn find_leap_second() {
495        let finder = Datetime::default();
496        let input = "2024-06-30T23:59:60Z";
497        let range = finder.find(input).unwrap();
498        assert_eq!("2024-06-30T23:59:60Z", &input[range]);
499    }
500
501    #[test]
502    fn find_fractional_seconds_many_digits() {
503        let finder = Datetime::default();
504        let input = "2024-01-15T10:30:00.123456789Z";
505        let range = finder.find(input).unwrap();
506        assert_eq!("2024-01-15T10:30:00.123456789Z", &input[range]);
507    }
508
509    #[test]
510    fn find_single_digit_input() {
511        let finder = Datetime::default();
512        assert!(finder.find("1").is_none());
513    }
514}