Skip to main content

temps_core/
lib.rs

1//! # temps-core
2//!
3//! Core functionality for parsing human-readable time expressions.
4//!
5//! This crate provides the fundamental types and traits for parsing natural language
6//! time expressions like "in 5 minutes", "yesterday at 3pm", or "next Monday".
7//! It is designed to be backend-agnostic, allowing different datetime libraries
8//! (chrono, jiff, etc.) to implement the parsing logic.
9//!
10//! ## Overview
11//!
12//! The crate consists of several key components:
13//!
14//! - **Types**: Core data structures representing different time expressions
15//! - **Traits**: Interfaces for implementing time parsing with different backends
16//! - **Parsers**: Language-specific parsers (English and German)
17//! - **Utilities**: Helper functions for time calculations and conversions
18//!
19//! ## Example
20//!
21//! ```
22//! use temps_core::{parse, Language, TimeExpression};
23//!
24//! // Parse a relative time expression
25//! let expr = parse("in 5 minutes", Language::English).unwrap();
26//! match expr {
27//!     TimeExpression::Relative(rel) => {
28//!         println!("Amount: {}, Unit: {:?}", rel.amount, rel.unit);
29//!     }
30//!     _ => {}
31//! }
32//!
33//! // Parse with German language
34//! let expr = parse("in 5 Minuten", Language::German).unwrap();
35//! ```
36//!
37//! ## Supported Languages
38//!
39//! - English
40//! - German
41//!
42//! ## Error Handling
43//!
44//! All parsing operations return a `Result<T, TempsError>` where `TempsError`
45//! provides detailed information about what went wrong during parsing or
46//! date calculations.
47
48// ===== Error Module =====
49pub mod error;
50pub mod lexer;
51pub use error::{Result, TempsError};
52
53// ===== Core Types =====
54
55/// Represents a parsed time expression.
56///
57/// This is the main output type of the parsing functions. It can represent
58/// various forms of time expressions from natural language input.
59///
60/// # Examples
61///
62/// ```
63/// use temps_core::{parse, Language, TimeExpression};
64///
65/// // "now" -> TimeExpression::Now
66/// // "in 5 minutes" -> TimeExpression::Relative(...)
67/// // "2024-01-15T14:30:00Z" -> TimeExpression::Absolute(...)
68/// // "tomorrow" -> TimeExpression::Day(...)
69/// // "3:30 pm" -> TimeExpression::Time(...)
70/// // "tomorrow at 3:30 pm" -> TimeExpression::DayTime(...)
71/// ```
72#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
73pub enum TimeExpression {
74    /// The current moment in time (e.g., "now", "jetzt")
75    Now,
76    /// A time relative to now (e.g., "in 5 minutes", "3 days ago")
77    Relative(RelativeTime),
78    /// An absolute date/time (e.g., "2024-01-15T14:30:00Z")
79    Absolute(AbsoluteTime),
80    /// A day reference (e.g., "tomorrow", "next Monday")
81    Day(DayReference),
82    /// A time of day (e.g., "3:30 pm", "14:30")
83    Time(Time),
84    /// A calendar date (e.g., "15/03/2024", "31-12-2025")
85    Date(StandardDate),
86    /// A day with a specific time (e.g., "tomorrow at 3:30 pm")
87    DayTime(DayTime),
88    /// A short way into the future, clamped so it cannot leave today
89    /// (e.g., "later today"). Resolves to `now + 2h`, or the last second of
90    /// today if that would cross midnight.
91    LaterToday,
92}
93
94/// Represents a time relative to the current moment.
95///
96/// # Examples
97///
98/// ```
99/// use temps_core::{RelativeTime, TimeUnit, Direction};
100///
101/// // "in 5 minutes"
102/// let future = RelativeTime {
103///     amount: 5,
104///     unit: TimeUnit::Minute,
105///     direction: Direction::Future,
106/// };
107///
108/// // "3 days ago"
109/// let past = RelativeTime {
110///     amount: 3,
111///     unit: TimeUnit::Day,
112///     direction: Direction::Past,
113/// };
114/// ```
115#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
116pub struct RelativeTime {
117    /// The numeric amount (e.g., 5 in "5 minutes")
118    pub amount: i64,
119    /// The time unit (second, minute, hour, etc.)
120    pub unit: TimeUnit,
121    /// Whether this is in the past or future
122    pub direction: Direction,
123}
124
125/// Represents an absolute date and time.
126///
127/// This type can represent various levels of precision, from just a date
128/// to a full timestamp with timezone and nanosecond precision.
129///
130/// # Examples
131///
132/// ```
133/// use temps_core::{AbsoluteTime, Timezone};
134///
135/// // Date only: "2024-01-15"
136/// let date_only = AbsoluteTime {
137///     year: 2024,
138///     month: 1,
139///     day: 15,
140///     hour: None,
141///     minute: None,
142///     second: None,
143///     nanosecond: None,
144///     timezone: None,
145/// };
146///
147/// // Full timestamp: "2024-01-15T14:30:00Z"
148/// let full_timestamp = AbsoluteTime {
149///     year: 2024,
150///     month: 1,
151///     day: 15,
152///     hour: Some(14),
153///     minute: Some(30),
154///     second: Some(0),
155///     nanosecond: None,
156///     timezone: Some(Timezone::Utc),
157/// };
158/// ```
159#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
160pub struct AbsoluteTime {
161    /// The year (e.g., 2024)
162    pub year: u16,
163    /// The month (1-12)
164    pub month: u8,
165    /// The day of month (1-31)
166    pub day: u8,
167    /// The hour (0-23), if specified
168    pub hour: Option<u8>,
169    /// The minute (0-59), if specified
170    pub minute: Option<u8>,
171    /// The second (0-59), if specified
172    pub second: Option<u8>,
173    /// The nanosecond (0-999999999), if specified
174    pub nanosecond: Option<u32>,
175    /// The timezone, if specified
176    pub timezone: Option<Timezone>,
177}
178
179/// Represents a timezone specification.
180///
181/// # Examples
182///
183/// ```
184/// use temps_core::Timezone;
185///
186/// // UTC timezone ("Z")
187/// let utc = Timezone::Utc;
188///
189/// // Offset timezone ("+02:00")
190/// let offset = Timezone::Offset { total_minutes: 120 };
191///
192/// // Negative offset ("-05:30")
193/// let negative = Timezone::Offset { total_minutes: -330 };
194///
195/// // Negative sub-hour offset ("-00:30")
196/// let half_hour_west = Timezone::Offset { total_minutes: -30 };
197/// ```
198#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
199pub enum Timezone {
200    /// UTC timezone (represented as "Z" in ISO format)
201    Utc,
202    /// Timezone offset from UTC, in minutes east of UTC.
203    ///
204    /// A single signed field so that negative sub-hour offsets such as
205    /// `-00:30` are representable; a split hour/minute pair cannot carry the
206    /// sign when the hour component is zero.
207    Offset {
208        /// Offset from UTC in minutes, from -720 (-12:00) to +840 (+14:00)
209        total_minutes: i16,
210    },
211}
212
213/// Represents a reference to a specific day.
214///
215/// # Examples
216///
217/// ```
218/// use temps_core::{DayReference, Weekday, WeekdayModifier};
219///
220/// // "today"
221/// let today = DayReference::Today;
222///
223/// // "next Monday"
224/// let next_monday = DayReference::Weekday {
225///     day: Weekday::Monday,
226///     modifier: Some(WeekdayModifier::Next),
227/// };
228///
229/// // "Friday" (upcoming Friday)
230/// let friday = DayReference::Weekday {
231///     day: Weekday::Friday,
232///     modifier: None,
233/// };
234/// ```
235#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
236pub enum DayReference {
237    /// Today's date
238    Today,
239    /// Yesterday's date
240    Yesterday,
241    /// Tomorrow's date
242    Tomorrow,
243    /// The day before yesterday's date
244    DayBeforeYesterday,
245    /// Day after tomorrow's date
246    DayAfterTomorrow,
247    /// A specific weekday
248    Weekday {
249        /// The day of the week
250        day: Weekday,
251        /// Optional modifier (next/last)
252        modifier: Option<WeekdayModifier>,
253    },
254}
255
256/// Represents a time of day.
257///
258/// Can represent both 12-hour (with AM/PM) and 24-hour formats.
259///
260/// # Examples
261///
262/// ```
263/// use temps_core::{Time, Meridiem};
264///
265/// // "3:30 PM"
266/// let afternoon = Time {
267///     hour: 3,
268///     minute: 30,
269///     second: 0,
270///     meridiem: Some(Meridiem::PM),
271/// };
272///
273/// // "14:30" (24-hour format)
274/// let military = Time {
275///     hour: 14,
276///     minute: 30,
277///     second: 0,
278///     meridiem: None,
279/// };
280/// ```
281#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
282pub struct Time {
283    /// Hour (0-23 for 24-hour format, 1-12 for 12-hour format)
284    pub hour: u8,
285    /// Minute (0-59)
286    pub minute: u8,
287    /// Second (0-59)
288    pub second: u8,
289    /// AM/PM indicator for 12-hour format
290    pub meridiem: Option<Meridiem>,
291}
292
293/// Represents a calendar date.
294///
295/// Used for parsing date formats like "15/03/2024" or "31-12-2025".
296///
297/// # Examples
298///
299/// ```
300/// use temps_core::StandardDate;
301///
302/// // "15/03/2024"
303/// let date = StandardDate {
304///     day: 15,
305///     month: 3,
306///     year: 2024,
307/// };
308/// ```
309#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
310pub struct StandardDate {
311    /// Day of month (1-31)
312    pub day: u8,
313    /// Month (1-12)
314    pub month: u8,
315    /// Year (e.g., 2024)
316    pub year: u16,
317}
318
319/// Represents a combination of a day reference and a specific time.
320///
321/// Used for expressions like "tomorrow at 3:30 pm" or "next Monday at 9:00 am".
322///
323/// # Examples
324///
325/// ```
326/// use temps_core::{DayTime, DayReference, Time, Meridiem};
327///
328/// // "tomorrow at 3:30 pm"
329/// let tomorrow_afternoon = DayTime {
330///     day: DayReference::Tomorrow,
331///     time: Time {
332///         hour: 3,
333///         minute: 30,
334///         second: 0,
335///         meridiem: Some(Meridiem::PM),
336///     },
337/// };
338/// ```
339#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
340pub struct DayTime {
341    /// The day reference
342    pub day: DayReference,
343    /// The specific time on that day
344    pub time: Time,
345}
346
347/// Units of time used in relative expressions.
348///
349/// # Examples
350///
351/// ```
352/// use temps_core::TimeUnit;
353///
354/// // Used in expressions like:
355/// // "5 seconds", "10 minutes", "2 hours", "3 days",
356/// // "1 week", "6 months", "2 years"
357/// ```
358#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
359pub enum TimeUnit {
360    Second,
361    Minute,
362    Hour,
363    Day,
364    Week,
365    Month,
366    Year,
367}
368
369/// Direction of time relative to now.
370///
371/// # Examples
372///
373/// ```
374/// use temps_core::Direction;
375///
376/// // "5 minutes ago" -> Direction::Past
377/// // "in 5 minutes" -> Direction::Future
378/// ```
379#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
380pub enum Direction {
381    Past,
382    Future,
383}
384
385/// Days of the week.
386///
387/// Used in expressions like "next Monday" or "last Friday".
388#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
389pub enum Weekday {
390    Monday,
391    Tuesday,
392    Wednesday,
393    Thursday,
394    Friday,
395    Saturday,
396    Sunday,
397}
398
399/// Modifiers for weekday references.
400///
401/// # Examples
402///
403/// ```
404/// use temps_core::WeekdayModifier;
405///
406/// // "last Monday" -> WeekdayModifier::Last
407/// // "next Friday" -> WeekdayModifier::Next
408/// // "Monday" (no modifier) -> finds the next occurrence
409/// ```
410#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
411pub enum WeekdayModifier {
412    Last,
413    Next,
414    /// The occurrence within the current Monday-to-Sunday week, which may be
415    /// in the past (e.g. "this weekend" asked on a Sunday).
416    This,
417}
418
419/// AM/PM indicator for 12-hour time format.
420#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
421pub enum Meridiem {
422    AM,
423    PM,
424}
425
426/// Supported languages for parsing time expressions.
427///
428/// # Examples
429///
430/// ```
431/// use temps_core::{parse, Language};
432///
433/// // Parse English
434/// let expr = parse("in 5 minutes", Language::English);
435///
436/// // Parse German
437/// let expr = parse("in 5 Minuten", Language::German);
438/// ```
439#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
440pub enum Language {
441    English,
442    German,
443}
444
445// ===== Traits =====
446
447/// Trait for implementing time parsing with a specific datetime backend.
448///
449/// This trait should be implemented by datetime libraries (chrono, jiff, etc.)
450/// to provide the actual time calculation logic.
451///
452/// # Examples
453///
454/// ```
455/// use temps_core::{TimeParser, TimeExpression, Result};
456///
457/// struct MyTimeParser;
458///
459/// impl TimeParser for MyTimeParser {
460///     type DateTime = String; // Your datetime type
461///
462///     fn now(&self) -> Self::DateTime {
463///         "2024-01-15T14:30:00Z".to_string()
464///     }
465///
466///     fn parse_expression(&self, expr: TimeExpression) -> Result<Self::DateTime> {
467///         // Implementation here
468///         Ok(self.now())
469///     }
470/// }
471/// ```
472pub trait TimeParser {
473    /// The datetime type used by this implementation
474    type DateTime;
475
476    /// Get the current date and time
477    fn now(&self) -> Self::DateTime;
478
479    /// Parse a time expression into a concrete datetime
480    ///
481    /// # Errors
482    ///
483    /// Returns `TempsError` if:
484    /// - Date calculation results in an invalid date
485    /// - Arithmetic overflow occurs
486    /// - The backend library returns an error
487    fn parse_expression(&self, expr: TimeExpression) -> Result<Self::DateTime>;
488}
489
490/// Trait for implementing language-specific parsers.
491///
492/// This trait is implemented by language modules to provide
493/// natural language parsing for different languages.
494///
495/// # Examples
496///
497/// ```
498/// use temps_core::{LanguageParser, TimeExpression, Result};
499///
500/// struct MyLanguageParser;
501///
502/// impl LanguageParser for MyLanguageParser {
503///     fn parse(&self, input: &str) -> Result<TimeExpression> {
504///         // Parse language-specific input
505///         Ok(TimeExpression::Now)
506///     }
507/// }
508/// ```
509pub trait LanguageParser {
510    /// Parse a natural language time expression
511    ///
512    /// # Errors
513    ///
514    /// Returns `TempsError::ParseError` if the input cannot be parsed
515    fn parse(&self, input: &str) -> Result<TimeExpression>;
516}
517
518// ===== Constants Module =====
519
520pub mod constants {
521    //! Common constants used across the temps library
522
523    /// Number of seconds in one hour
524    pub const SECONDS_PER_HOUR: i32 = 3600;
525
526    /// Number of seconds in one minute  
527    pub const SECONDS_PER_MINUTE: i32 = 60;
528
529    /// Number of minutes in one hour
530    pub const MINUTES_PER_HOUR: i32 = 60;
531
532    /// Number of hours in one day
533    pub const HOURS_PER_DAY: i32 = 24;
534
535    /// Number of days in one week
536    pub const DAYS_PER_WEEK: i32 = 7;
537
538    /// Number of months in one year
539    pub const MONTHS_PER_YEAR: i32 = 12;
540}
541
542// ===== Errors Module =====
543
544pub mod errors {
545    //! Common error messages and error handling utilities
546
547    /// Error message for when month amount must be positive
548    pub const ERR_MONTH_POSITIVE: &str = "Month amount must be a positive number";
549
550    /// Error message for when year amount must be positive
551    pub const ERR_YEAR_POSITIVE: &str = "Year amount must be a positive number";
552
553    /// Error message for invalid date calculation
554    pub const ERR_DATE_CALC_INVALID: &str = "Date calculation resulted in invalid date";
555
556    /// Error message for year calculation overflow
557    pub const ERR_YEAR_OVERFLOW: &str = "Year calculation overflow";
558
559    /// Error message for a relative amount too large for the backend to represent
560    pub const ERR_AMOUNT_OUT_OF_RANGE: &str = "Relative amount is too large to represent as a date";
561
562    /// Error message for invalid date
563    pub const ERR_INVALID_DATE: &str = "Invalid date";
564
565    /// Error message for invalid time
566    pub const ERR_INVALID_TIME: &str = "Invalid time";
567
568    /// Error message for ambiguous local time
569    pub const ERR_AMBIGUOUS_TIME: &str = "Ambiguous or invalid local time";
570
571    /// Error message for failed midnight time creation
572    pub const ERR_MIDNIGHT_FAILED: &str = "Failed to create midnight time";
573
574    /// Error message for date calculation errors
575    pub const ERR_DATE_CALC_ERROR: &str = "Date calculation error";
576
577    /// Error message for timezone conversion errors
578    pub const ERR_TIMEZONE_CONVERSION: &str = "Timezone conversion error";
579
580    /// Error message for negative relative amounts
581    pub const ERR_RELATIVE_AMOUNT_NON_NEGATIVE: &str = "Relative amount must be non-negative";
582
583    /// Format error message for invalid date with components
584    #[must_use]
585    pub fn format_invalid_date(year: u16, month: u8, day: u8) -> String {
586        format!("Invalid date: {year}-{month}-{day}")
587    }
588
589    /// Format error message for invalid time with components
590    #[must_use]
591    pub fn format_invalid_time(hour: u8, minute: u8, second: u8) -> String {
592        format!("Invalid time: {hour}:{minute}:{second}")
593    }
594
595    /// Format error message for invalid timezone offset
596    #[must_use]
597    pub fn format_invalid_timezone_offset(total_minutes: i16) -> String {
598        let sign = if total_minutes < 0 { '-' } else { '+' };
599        let magnitude = total_minutes.unsigned_abs();
600        format!(
601            "Invalid timezone offset: {sign}{:02}:{:02}",
602            magnitude / 60,
603            magnitude % 60
604        )
605    }
606}
607
608// ===== Time Utils Module =====
609
610pub mod time_utils {
611    //! Time conversion and calculation utilities
612
613    use crate::{Meridiem, Timezone, WeekdayModifier, constants::SECONDS_PER_MINUTE};
614
615    /// Convert 12-hour time format to 24-hour format
616    ///
617    /// # Examples
618    /// ```
619    /// use temps_core::{Meridiem, time_utils::convert_12_to_24_hour};
620    ///
621    /// assert_eq!(convert_12_to_24_hour(12, Some(&Meridiem::AM)), 0);  // 12 AM -> 0
622    /// assert_eq!(convert_12_to_24_hour(12, Some(&Meridiem::PM)), 12); // 12 PM -> 12
623    /// assert_eq!(convert_12_to_24_hour(3, Some(&Meridiem::PM)), 15);  // 3 PM -> 15
624    /// assert_eq!(convert_12_to_24_hour(14, None), 14);                // 24-hour format
625    /// ```
626    #[must_use]
627    pub fn convert_12_to_24_hour(hour: u8, meridiem: Option<&Meridiem>) -> u8 {
628        match meridiem {
629            Some(Meridiem::AM) => {
630                if hour == 12 {
631                    0
632                } else {
633                    hour
634                }
635            }
636            Some(Meridiem::PM) => {
637                if hour >= 12 {
638                    // 12 PM is noon; anything above 12 is not a valid 12-hour
639                    // clock hour, so pass it through rather than overflowing.
640                    hour
641                } else {
642                    hour + 12
643                }
644            }
645            None => hour,
646        }
647    }
648
649    /// Calculate total seconds for a timezone offset
650    ///
651    /// Uses saturating arithmetic to prevent overflow
652    #[must_use]
653    pub fn calculate_timezone_offset_seconds(total_minutes: i16) -> i32 {
654        i32::from(total_minutes).saturating_mul(SECONDS_PER_MINUTE)
655    }
656
657    /// Check whether the date components form a real calendar date.
658    #[must_use]
659    pub fn is_valid_calendar_date(year: u16, month: u8, day: u8) -> bool {
660        let days_in_month = match month {
661            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
662            4 | 6 | 9 | 11 => 30,
663            2 if is_leap_year(year) => 29,
664            2 => 28,
665            _ => return false,
666        };
667
668        (1..=days_in_month).contains(&day)
669    }
670
671    /// Check whether the time components form a valid 24-hour clock time.
672    #[must_use]
673    pub fn is_valid_24_hour_time(hour: u8, minute: u8, second: u8) -> bool {
674        hour <= 23 && minute <= 59 && second <= 59
675    }
676
677    /// Check whether time components are valid for either 24-hour or AM/PM notation.
678    #[must_use]
679    pub fn is_valid_time(hour: u8, minute: u8, second: u8, meridiem: Option<Meridiem>) -> bool {
680        match meridiem {
681            Some(_) => (1..=12).contains(&hour) && minute <= 59 && second <= 59,
682            None => is_valid_24_hour_time(hour, minute, second),
683        }
684    }
685
686    /// Check whether a timezone offset is in the supported UTC-12:00..=UTC+14:00 range.
687    #[must_use]
688    pub fn is_valid_timezone_offset(offset: Timezone) -> bool {
689        match offset {
690            Timezone::Utc => true,
691            Timezone::Offset { total_minutes } => (-720..=840).contains(&total_minutes),
692        }
693    }
694
695    /// Calculate the day offset for weekday calculations
696    ///
697    /// Returns the number of days to add/subtract to reach the target weekday
698    ///
699    /// # Arguments
700    /// * `current_day_offset` - Current weekday as offset from Monday (0-6)
701    /// * `target_day_offset` - Target weekday as offset from Monday (0-6)
702    /// * `modifier` - Whether to get next, last, or closest occurrence
703    #[must_use]
704    pub fn calculate_weekday_offset(
705        current_day_offset: i64,
706        target_day_offset: i64,
707        modifier: Option<WeekdayModifier>,
708    ) -> i64 {
709        let days_diff = target_day_offset - current_day_offset;
710
711        match modifier {
712            None => {
713                // Get the next occurrence (including today if it matches)
714                if days_diff >= 0 {
715                    days_diff
716                } else {
717                    7 + days_diff
718                }
719            }
720            Some(WeekdayModifier::Next) => {
721                // Next occurrence (not including today)
722                if days_diff > 0 {
723                    days_diff
724                } else {
725                    7 + days_diff
726                }
727            }
728            Some(WeekdayModifier::This) => {
729                // Same Monday-to-Sunday week, looking backwards if already passed
730                days_diff
731            }
732            Some(WeekdayModifier::Last) => {
733                // Previous occurrence (not including today)
734                if days_diff < 0 {
735                    days_diff
736                } else {
737                    days_diff - 7
738                }
739            }
740        }
741    }
742
743    #[must_use]
744    fn is_leap_year(year: u16) -> bool {
745        year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400))
746    }
747}
748
749// ===== Common Parsing Module =====
750
751/// Common parsing utilities shared across language implementations.
752///
753/// Every parser here consumes [`Token`](crate::lexer::Token)s produced by
754/// [`lex`](crate::lexer::lex) rather than characters. Lexing first is what
755/// makes keyword matching *whole-word* matching: `word_ci("day")` compares the
756/// entire `Word("days")` slice and fails, where the old character-level
757/// `keyword_ci("day")` matched the prefix and needed a hand-rolled word-boundary
758/// assertion plus longest-first ordering to stay correct.
759///
760/// # Writing a parser against this module
761///
762/// Parsers are generic over the input so they compose with whatever concrete
763/// token stream the caller builds:
764///
765/// ```
766/// use chumsky::prelude::*;
767/// use temps_core::common::{ParserError, TokenInput, word_ci};
768///
769/// fn now_expr<'t, 's: 't, I>() -> impl Parser<'t, I, (), ParserError<'t, 's>> + Clone
770/// where
771///     I: TokenInput<'t, 's>,
772/// {
773///     word_ci("now")
774/// }
775/// ```
776///
777/// and are driven by lexing the source and mapping the token slice into an
778/// input:
779///
780/// ```
781/// use chumsky::prelude::*;
782/// use temps_core::{common::{token_stream, word_ci}, lexer::lex};
783///
784/// let input = "now";
785/// let tokens = lex(input);
786/// let result = word_ci("now")
787///     .then_ignore(end())
788///     .parse(token_stream(input, &tokens))
789///     .into_result();
790/// assert!(result.is_ok());
791/// ```
792pub mod common {
793    use super::{AbsoluteTime, TimeExpression, Timezone, time_utils};
794    use crate::lexer::{Token, lex};
795    use chumsky::{input::ValueInput, prelude::*};
796
797    /// The error type used throughout the parsers.
798    ///
799    /// `'t` is the lifetime of the token slice being parsed; `'s` is the
800    /// lifetime of the source string those tokens borrow their slices from.
801    /// `'s` always outlives `'t`.
802    pub type ParserError<'t, 's> = extra::Err<Rich<'t, Token<'s>>>;
803
804    /// The input bound every parser in this module is generic over.
805    ///
806    /// This is a blanket-implemented convenience for
807    /// `ValueInput<'t, Token = Token<'s>, Span = SimpleSpan>`, which is what
808    /// [`token_stream`] produces. Written out in a `where` clause on every
809    /// parser function that bound is most of the signature; naming it keeps the
810    /// grammar readable.
811    pub trait TokenInput<'t, 's>: ValueInput<'t, Token = Token<'s>, Span = SimpleSpan> {}
812
813    impl<'t, 's, I> TokenInput<'t, 's> for I where
814        I: ValueInput<'t, Token = Token<'s>, Span = SimpleSpan>
815    {
816    }
817
818    /// A boxed token parser.
819    ///
820    /// Needed wherever a homogeneous collection of parsers is required — most
821    /// notably when [`phrase`] folds a phrase's tokens into a single parser.
822    pub type BoxedParser<'t, 's, I, O> = chumsky::Boxed<'t, 't, I, O, ParserError<'t, 's>>;
823
824    /// Turn a source string and its lexed tokens into a parser input.
825    ///
826    /// The end-of-input span is `source.len()..source.len()` so that an error
827    /// at the end of the input still carries a byte offset the diagnostics
828    /// layer can translate.
829    ///
830    /// ```
831    /// use temps_core::{common::token_stream, lexer::lex};
832    ///
833    /// let input = "in 5 minutes";
834    /// let tokens = lex(input);
835    /// let stream = token_stream(input, &tokens);
836    /// ```
837    pub fn token_stream<'t, 's: 't>(
838        source: &'s str,
839        tokens: &'t [(Token<'s>, SimpleSpan)],
840    ) -> impl TokenInput<'t, 's> {
841        let eoi = SimpleSpan::from(source.len()..source.len());
842        tokens.map(eoi, |(token, span)| (token, span))
843    }
844
845    // ----- Whitespace and punctuation -----
846
847    /// Match exactly one [`Token::Space`].
848    ///
849    /// Whitespace is a token rather than something skipped implicitly because
850    /// `5 minutes` is a time expression and `5minutes` is not. A `Space` token
851    /// stands for a whole run of whitespace, so this also covers the repeated
852    /// `one_of(" \t\n\r").at_least(1)` the character-level grammar used.
853    pub fn space<'t, 's: 't, I>() -> impl Parser<'t, I, (), ParserError<'t, 's>> + Clone
854    where
855        I: TokenInput<'t, 's>,
856    {
857        just(Token::Space).ignored().labelled("whitespace")
858    }
859
860    /// Match an optional [`Token::Space`].
861    ///
862    /// The token-level replacement for `text::whitespace()`. Combine it with
863    /// [`Parser::padded_by`] to replace a top-level `.padded()`.
864    pub fn opt_space<'t, 's: 't, I>() -> impl Parser<'t, I, (), ParserError<'t, 's>> + Clone
865    where
866        I: TokenInput<'t, 's>,
867    {
868        just(Token::Space).or_not().ignored()
869    }
870
871    /// Match a single punctuation character, e.g. `punct(':')`.
872    ///
873    /// The lexer emits every non-alphanumeric, non-whitespace character as its
874    /// own [`Token::Punct`], so this is the token-level `just(':')`.
875    pub fn punct<'t, 's: 't, I>(c: char) -> impl Parser<'t, I, (), ParserError<'t, 's>> + Clone
876    where
877        I: TokenInput<'t, 's>,
878    {
879        just(Token::Punct(c)).ignored()
880    }
881
882    // ----- Words -----
883
884    /// Match a whole [`Token::Word`] against `target`, case-insensitively.
885    ///
886    /// The comparison is Unicode-aware (`char::to_lowercase`), not
887    /// `eq_ignore_ascii_case`, because German keywords contain umlauts:
888    /// `word_ci("nächsten")` must accept `Nächsten`.
889    ///
890    /// Matching is whole-slice: `word_ci("day")` never matches `days`, and
891    /// `word_ci("m")` never matches `min`, whatever order alternatives appear
892    /// in. Use [`phrase_ci`] for anything containing a space or punctuation.
893    pub fn word_ci<'t, 's: 't, I>(
894        target: &'static str,
895    ) -> impl Parser<'t, I, (), ParserError<'t, 's>> + Clone
896    where
897        I: TokenInput<'t, 's>,
898    {
899        select! { Token::Word(word) if eq_ignore_case(word, target) => () }.labelled(target)
900    }
901
902    /// Match a whole [`Token::Word`] against `target`, case-**sensitively**.
903    ///
904    /// For languages where capitalisation carries meaning — German nouns
905    /// (`Tagen`, `Montag`) and the ISO 8601 `T` and `Z` designators.
906    pub fn word_cs<'t, 's: 't, I>(
907        target: &'static str,
908    ) -> impl Parser<'t, I, (), ParserError<'t, 's>> + Clone
909    where
910        I: TokenInput<'t, 's>,
911    {
912        select! { Token::Word(word) if word == target => () }.labelled(target)
913    }
914
915    /// Compare two strings for equality under Unicode simple lowercase folding.
916    fn eq_ignore_case(a: &str, b: &str) -> bool {
917        let mut a = a.chars().flat_map(char::to_lowercase);
918        let mut b = b.chars().flat_map(char::to_lowercase);
919        loop {
920            match (a.next(), b.next()) {
921                (None, None) => return true,
922                (x, y) if x == y => (),
923                _ => return false,
924            }
925        }
926    }
927
928    // ----- Phrases -----
929
930    /// Match a multi-token phrase case-insensitively, e.g.
931    /// `phrase_ci("day after tomorrow")` or `phrase_ci("a.m.")`.
932    ///
933    /// `target` is lexed with the very same [`lex`] the input goes through, and
934    /// the resulting tokens are matched in sequence. A space in `target`
935    /// therefore requires a [`Token::Space`] in the input (one whitespace run,
936    /// of any width), and punctuation matches punctuation.
937    ///
938    /// A single-word `target` is simply [`word_ci`], so this is always the safe
939    /// choice when the phrase is built from a table of keywords.
940    pub fn phrase_ci<'t, 's: 't, I>(
941        target: &'static str,
942    ) -> impl Parser<'t, I, (), ParserError<'t, 's>> + Clone
943    where
944        I: TokenInput<'t, 's>,
945    {
946        phrase(target, Case::Insensitive)
947    }
948
949    /// Case-sensitive counterpart of [`phrase_ci`].
950    pub fn phrase_cs<'t, 's: 't, I>(
951        target: &'static str,
952    ) -> impl Parser<'t, I, (), ParserError<'t, 's>> + Clone
953    where
954        I: TokenInput<'t, 's>,
955    {
956        phrase(target, Case::Sensitive)
957    }
958
959    /// Build a case-insensitive alternation over `(phrase, value)` pairs,
960    /// trying the phrase with the most tokens first.
961    ///
962    /// Tokenising removes *sub-word* shadowing but not *phrase-prefix*
963    /// shadowing: `choice` still commits to the first alternative that
964    /// succeeds, so `"a"` listed before `"a couple of"` would consume the `a`
965    /// of `a couple of days ago`, leave `couple` behind, and doom the enclosing
966    /// rule. Sorting here makes the source order of the table irrelevant
967    /// instead of load-bearing.
968    ///
969    /// # Panics
970    ///
971    /// Panics if `pairs` is empty.
972    pub fn phrases_ci<'t, 's: 't, I, T>(
973        pairs: impl IntoIterator<Item = (&'static str, T)>,
974    ) -> impl Parser<'t, I, T, ParserError<'t, 's>> + Clone
975    where
976        I: TokenInput<'t, 's>,
977        T: Clone + 't,
978    {
979        phrase_alternation(pairs, Case::Insensitive)
980    }
981
982    /// Case-sensitive counterpart of [`phrases_ci`].
983    ///
984    /// # Panics
985    ///
986    /// Panics if `pairs` is empty.
987    pub fn phrases_cs<'t, 's: 't, I, T>(
988        pairs: impl IntoIterator<Item = (&'static str, T)>,
989    ) -> impl Parser<'t, I, T, ParserError<'t, 's>> + Clone
990    where
991        I: TokenInput<'t, 's>,
992        T: Clone + 't,
993    {
994        phrase_alternation(pairs, Case::Sensitive)
995    }
996
997    #[derive(Clone, Copy, PartialEq, Eq)]
998    enum Case {
999        Sensitive,
1000        Insensitive,
1001    }
1002
1003    /// Match one token of a lexed phrase pattern.
1004    fn pattern_token<'t, 's: 't, I>(token: Token<'static>, case: Case) -> BoxedParser<'t, 's, I, ()>
1005    where
1006        I: TokenInput<'t, 's>,
1007    {
1008        match token {
1009            Token::Word(word) => match case {
1010                Case::Sensitive => word_cs(word).boxed(),
1011                Case::Insensitive => word_ci(word).boxed(),
1012            },
1013            Token::Number(digits) => {
1014                select! { Token::Number(found) if found == digits => () }.boxed()
1015            }
1016            Token::Punct(c) => punct(c).boxed(),
1017            Token::Space => space().boxed(),
1018        }
1019    }
1020
1021    /// Lex `target` and match its tokens in sequence.
1022    fn phrase<'t, 's: 't, I>(target: &'static str, case: Case) -> BoxedParser<'t, 's, I, ()>
1023    where
1024        I: TokenInput<'t, 's>,
1025    {
1026        let mut tokens = lex(target).into_iter().map(|(token, _)| token);
1027        let first = tokens.next().expect("phrase must be non-empty");
1028        let mut parser = pattern_token(first, case);
1029        for token in tokens {
1030            parser = parser.then_ignore(pattern_token(token, case)).boxed();
1031        }
1032        parser.labelled(target).boxed()
1033    }
1034
1035    fn phrase_alternation<'t, 's: 't, I, T>(
1036        pairs: impl IntoIterator<Item = (&'static str, T)>,
1037        case: Case,
1038    ) -> BoxedParser<'t, 's, I, T>
1039    where
1040        I: TokenInput<'t, 's>,
1041        T: Clone + 't,
1042    {
1043        let mut pairs: Vec<(&'static str, T)> = pairs.into_iter().collect();
1044        // Most tokens first; character count breaks ties so the ordering is
1045        // total and deterministic.
1046        pairs.sort_by_key(|(phrase, _)| {
1047            std::cmp::Reverse((lex(phrase).len(), phrase.chars().count()))
1048        });
1049
1050        let mut pairs = pairs.into_iter();
1051        let (first_phrase, first_value) = pairs.next().expect("phrase set must be non-empty");
1052        let mut parser = phrase(first_phrase, case).to(first_value).boxed();
1053        for (pattern, value) in pairs {
1054            parser = parser.or(phrase(pattern, case).to(value)).boxed();
1055        }
1056        parser
1057    }
1058
1059    // ----- Numbers -----
1060
1061    /// Parse a [`Token::Number`] of any width as an `i64`.
1062    pub fn digit_number<'t, 's: 't, I>() -> impl Parser<'t, I, i64, ParserError<'t, 's>> + Clone
1063    where
1064        I: TokenInput<'t, 's>,
1065    {
1066        select! { Token::Number(digits) => digits }
1067            .try_map(|digits: &str, span| {
1068                digits
1069                    .parse::<i64>()
1070                    .map_err(|e| Rich::custom(span, e.to_string()))
1071            })
1072            .labelled("number")
1073    }
1074
1075    /// Parse a 1 or 2 digit [`Token::Number`] as a `u8`.
1076    ///
1077    /// The width check is what makes `123:45` fail: the lexer produces a single
1078    /// `Number("123")` token, which cannot be split into `12` plus a leftover
1079    /// `3`, so no alternative can quietly consume part of it.
1080    pub fn two_digit_number<'t, 's: 't, I>() -> impl Parser<'t, I, u8, ParserError<'t, 's>> + Clone
1081    where
1082        I: TokenInput<'t, 's>,
1083    {
1084        select! { Token::Number(digits) if matches!(digits.len(), 1 | 2) => digits }.try_map(
1085            |digits: &str, span| {
1086                digits
1087                    .parse::<u8>()
1088                    .map_err(|e| Rich::custom(span, e.to_string()))
1089            },
1090        )
1091    }
1092
1093    /// Parse an exactly-4-digit [`Token::Number`] as a `u16`.
1094    pub fn four_digit_number<'t, 's: 't, I>() -> impl Parser<'t, I, u16, ParserError<'t, 's>> + Clone
1095    where
1096        I: TokenInput<'t, 's>,
1097    {
1098        select! { Token::Number(digits) if digits.len() == 4 => digits }
1099            .try_map(|digits: &str, span| {
1100                digits
1101                    .parse::<u16>()
1102                    .map_err(|e| Rich::custom(span, e.to_string()))
1103            })
1104            .labelled("4-digit year")
1105    }
1106
1107    // ----- ISO 8601 -----
1108
1109    fn offset_timezone<'t, 's: 't, I>() -> impl Parser<'t, I, Timezone, ParserError<'t, 's>> + Clone
1110    where
1111        I: TokenInput<'t, 's>,
1112    {
1113        select! { Token::Punct(sign) if sign == '+' || sign == '-' => sign }
1114            .then(two_digit_number())
1115            .then(punct(':').ignore_then(two_digit_number()).or_not())
1116            .try_map(|((sign, hours), minutes), span| {
1117                let minutes = minutes.unwrap_or(0);
1118                if minutes > 59 {
1119                    return Err(Rich::custom(span, "timezone minute offset out of range"));
1120                }
1121                let magnitude = i16::from(hours)
1122                    .checked_mul(60)
1123                    .and_then(|h| h.checked_add(i16::from(minutes)))
1124                    .ok_or_else(|| Rich::custom(span, "timezone hour offset out of range"))?;
1125                let total_minutes = if sign == '+' { magnitude } else { -magnitude };
1126                let offset = Timezone::Offset { total_minutes };
1127
1128                if time_utils::is_valid_timezone_offset(offset) {
1129                    Ok(offset)
1130                } else {
1131                    Err(Rich::custom(span, "invalid timezone offset"))
1132                }
1133            })
1134    }
1135
1136    fn timezone<'t, 's: 't, I>() -> impl Parser<'t, I, Timezone, ParserError<'t, 's>> + Clone
1137    where
1138        I: TokenInput<'t, 's>,
1139    {
1140        // `Z` is a designator, not a word to be case-folded: `z` is not UTC.
1141        choice((word_cs("Z").to(Timezone::Utc), offset_timezone()))
1142    }
1143
1144    fn fractional_seconds<'t, 's: 't, I>() -> impl Parser<'t, I, u32, ParserError<'t, 's>> + Clone
1145    where
1146        I: TokenInput<'t, 's>,
1147    {
1148        select! { Token::Number(digits) => digits }.try_map(|s: &str, span| {
1149            let fraction = if s.len() > 9 { &s[..9] } else { s };
1150            let parsed: u32 = fraction
1151                .parse()
1152                .map_err(|e: std::num::ParseIntError| Rich::custom(span, e.to_string()))?;
1153            let fraction_len =
1154                u32::try_from(fraction.len()).expect("fraction length is capped at 9 digits");
1155            Ok(parsed * 10_u32.pow(9 - fraction_len))
1156        })
1157    }
1158
1159    /// Parse ISO 8601 datetime format.
1160    ///
1161    /// Supports:
1162    /// - Date only: `2024-01-15`
1163    /// - Date and time: `2024-01-15T14:30:00`
1164    /// - With timezone: `2024-01-15T14:30:00Z`
1165    /// - With offset: `2024-01-15T14:30:00+02:00`
1166    /// - With fractional seconds: `2024-01-15T14:30:00.123Z`
1167    pub fn iso_datetime<'t, 's: 't, I>()
1168    -> impl Parser<'t, I, TimeExpression, ParserError<'t, 's>> + Clone
1169    where
1170        I: TokenInput<'t, 's>,
1171    {
1172        let date = four_digit_number()
1173            .then_ignore(punct('-'))
1174            .then(two_digit_number())
1175            .then_ignore(punct('-'))
1176            .then(two_digit_number())
1177            .try_map(|((year, month), day), span| {
1178                if time_utils::is_valid_calendar_date(year, month, day) {
1179                    Ok((year, month, day))
1180                } else {
1181                    Err(Rich::custom(span, "invalid calendar date"))
1182                }
1183            });
1184
1185        // The date/time separator is either the ISO `T` designator — lexed as a
1186        // one-letter word between two numbers — or a space.
1187        let separator = choice((word_cs("T"), space()));
1188
1189        let time = separator
1190            .ignore_then(two_digit_number())
1191            .then_ignore(punct(':'))
1192            .then(two_digit_number())
1193            .then(
1194                punct(':')
1195                    .ignore_then(two_digit_number())
1196                    .then(punct('.').ignore_then(fractional_seconds()).or_not())
1197                    .or_not(),
1198            )
1199            .then(timezone().or_not())
1200            .try_map(|(((hour, minute), sec_part), tz), span| {
1201                let second = sec_part.as_ref().map_or(0, |(s, _)| *s);
1202                if time_utils::is_valid_24_hour_time(hour, minute, second) {
1203                    Ok((hour, minute, sec_part, tz))
1204                } else {
1205                    Err(Rich::custom(span, "invalid time"))
1206                }
1207            });
1208
1209        date.then(time.or_not())
1210            .map(|((year, month, day), time_opt)| match time_opt {
1211                Some((h, m, sec_part, tz)) => {
1212                    let (second, nanosecond) = match sec_part {
1213                        Some((s, frac)) => (Some(s), frac),
1214                        None => (None, None),
1215                    };
1216                    TimeExpression::Absolute(AbsoluteTime {
1217                        year,
1218                        month,
1219                        day,
1220                        hour: Some(h),
1221                        minute: Some(m),
1222                        second,
1223                        nanosecond,
1224                        timezone: tz,
1225                    })
1226                }
1227                None => TimeExpression::Absolute(AbsoluteTime {
1228                    year,
1229                    month,
1230                    day,
1231                    hour: None,
1232                    minute: None,
1233                    second: None,
1234                    nanosecond: None,
1235                    timezone: None,
1236                }),
1237            })
1238    }
1239
1240    #[cfg(test)]
1241    mod tests {
1242        use super::*;
1243
1244        /// Lex `$input`, run `$parser` over the whole of it, and yield
1245        /// `Option<Output>`.
1246        ///
1247        /// A macro rather than a function because the input type
1248        /// [`token_stream`] returns is opaque, so a caller cannot name it in a
1249        /// `where` clause.
1250        macro_rules! run {
1251            ($input:expr, $parser:expr) => {{
1252                let input: &str = $input;
1253                let tokens = lex(input);
1254                $parser
1255                    .then_ignore(end())
1256                    .parse(token_stream(input, &tokens))
1257                    .into_result()
1258                    .ok()
1259            }};
1260        }
1261
1262        #[test]
1263        fn word_ci_matches_whole_words_only() {
1264            assert!(run!("day", word_ci("day")).is_some());
1265            assert!(run!("DAY", word_ci("day")).is_some());
1266            // The bug the lexer exists to prevent: `day` inside `days`.
1267            assert!(run!("days", word_ci("day")).is_none());
1268            assert!(run!("min", word_ci("m")).is_none());
1269        }
1270
1271        #[test]
1272        fn word_ci_folds_umlauts() {
1273            assert!(run!("nächsten", word_ci("nächsten")).is_some());
1274            assert!(run!("Nächsten", word_ci("nächsten")).is_some());
1275            assert!(run!("NÄCHSTEN", word_ci("nächsten")).is_some());
1276        }
1277
1278        #[test]
1279        fn word_cs_respects_case() {
1280            assert!(run!("Montag", word_cs("Montag")).is_some());
1281            assert!(run!("montag", word_cs("Montag")).is_none());
1282        }
1283
1284        #[test]
1285        fn phrases_span_spaces_and_punctuation() {
1286            assert!(run!("day after tomorrow", phrase_ci("day after tomorrow")).is_some());
1287            assert!(run!("A.M.", phrase_ci("a.m.")).is_some());
1288            assert!(run!("day after", phrase_ci("day after tomorrow")).is_none());
1289            // No implicit whitespace: the phrase's space is a real token.
1290            assert!(run!("halfpast", phrase_ci("half past")).is_none());
1291        }
1292
1293        #[test]
1294        fn phrase_alternation_prefers_the_longer_phrase() {
1295            let pairs = || [("a", 1i64), ("a couple of", 2), ("a few", 3)];
1296            assert_eq!(run!("a couple of", phrases_ci(pairs())), Some(2));
1297            assert_eq!(run!("a few", phrases_ci(pairs())), Some(3));
1298            assert_eq!(run!("a", phrases_ci(pairs())), Some(1));
1299        }
1300
1301        #[test]
1302        fn number_widths_are_enforced() {
1303            assert_eq!(run!("7", two_digit_number()), Some(7));
1304            assert_eq!(run!("07", two_digit_number()), Some(7));
1305            // A 3-digit number is one token and cannot be truncated to two.
1306            assert_eq!(run!("123", two_digit_number()), None);
1307            assert_eq!(run!("2024", four_digit_number()), Some(2024));
1308            assert_eq!(run!("204", four_digit_number()), None);
1309            assert_eq!(run!("12345", digit_number()), Some(12345));
1310        }
1311
1312        #[test]
1313        fn iso_datetime_round_trips() {
1314            let expected = TimeExpression::Absolute(AbsoluteTime {
1315                year: 2024,
1316                month: 1,
1317                day: 15,
1318                hour: Some(14),
1319                minute: Some(30),
1320                second: Some(0),
1321                nanosecond: None,
1322                timezone: Some(Timezone::Utc),
1323            });
1324            assert_eq!(run!("2024-01-15T14:30:00Z", iso_datetime()), Some(expected));
1325
1326            assert_eq!(
1327                run!("2024-01-15T14:30:00-00:30", iso_datetime()),
1328                Some(TimeExpression::Absolute(AbsoluteTime {
1329                    year: 2024,
1330                    month: 1,
1331                    day: 15,
1332                    hour: Some(14),
1333                    minute: Some(30),
1334                    second: Some(0),
1335                    nanosecond: None,
1336                    timezone: Some(Timezone::Offset { total_minutes: -30 }),
1337                }))
1338            );
1339
1340            // Invalid calendar date and invalid clock time are both rejected.
1341            assert!(run!("2024-02-30", iso_datetime()).is_none());
1342            assert!(run!("2024-01-15T25:00", iso_datetime()).is_none());
1343        }
1344
1345        /// The shadowing hazard the grammar is left-factored to avoid: a bare
1346        /// `tomorrow` listed first under `choice` commits, strands `morning`,
1347        /// and the enclosing `end()` then fails. Factoring the shared prefix
1348        /// and making the tail optional is what removes it.
1349        fn day_then_optional_part<'t, 's: 't, I>()
1350        -> impl Parser<'t, I, i64, ParserError<'t, 's>> + Clone
1351        where
1352            I: TokenInput<'t, 's>,
1353        {
1354            word_ci("tomorrow")
1355                .ignore_then(space().ignore_then(word_ci("morning")).or_not())
1356                .map(|morning| if morning.is_some() { 2 } else { 1 })
1357        }
1358
1359        #[test]
1360        fn left_factoring_removes_the_shadowing() {
1361            assert_eq!(run!("tomorrow morning", day_then_optional_part()), Some(2));
1362            assert_eq!(run!("tomorrow", day_then_optional_part()), Some(1));
1363        }
1364    }
1365}
1366
1367// ===== Language Support =====
1368
1369/// Language-specific parser implementations.
1370///
1371/// Each submodule contains a parser for a specific language.
1372/// All parsers implement the `LanguageParser` trait.
1373pub mod language {
1374    /// English language parser.
1375    ///
1376    /// Supports expressions like:
1377    /// - "in 5 minutes", "3 days ago"
1378    /// - "tomorrow at 3:30 pm"
1379    /// - "next Monday", "last Friday"
1380    pub mod english;
1381
1382    /// German language parser.
1383    ///
1384    /// Supports expressions like:
1385    /// - "in 5 Minuten", "vor 3 Tagen"
1386    /// - "morgen um 15:30"
1387    /// - "nächsten Montag", "letzten Freitag"
1388    pub mod german;
1389}
1390
1391// ===== Main Parsing Function =====
1392
1393/// Parse a natural language time expression.
1394///
1395/// This is the main entry point for parsing time expressions. It takes
1396/// a string input and a language, and returns a parsed `TimeExpression`.
1397///
1398/// # Arguments
1399///
1400/// * `input` - The natural language time expression to parse
1401/// * `language` - The language to use for parsing
1402///
1403/// # Returns
1404///
1405/// Returns `Ok(TimeExpression)` if parsing succeeds, or `Err(TempsError)`
1406/// if the input cannot be parsed.
1407///
1408/// # Examples
1409///
1410/// ```
1411/// use temps_core::{parse, Language, TimeExpression};
1412///
1413/// // Parse English expressions
1414/// let expr = parse("in 5 minutes", Language::English).unwrap();
1415/// let expr = parse("tomorrow at 3:30 pm", Language::English).unwrap();
1416/// let expr = parse("next Monday", Language::English).unwrap();
1417///
1418/// // Parse German expressions
1419/// let expr = parse("in 5 Minuten", Language::German).unwrap();
1420/// let expr = parse("morgen um 15:30", Language::German).unwrap();
1421/// let expr = parse("nächsten Montag", Language::German).unwrap();
1422///
1423/// // Parse ISO datetime (works in any language)
1424/// let expr = parse("2024-01-15T14:30:00Z", Language::English).unwrap();
1425/// ```
1426///
1427/// # Supported Formats
1428///
1429/// ## Relative Time
1430/// - "in 5 minutes", "5 minutes ago"
1431/// - "in 2 hours", "an hour ago"
1432/// - "in 3 days", "2 days ago"
1433/// - "in a week", "2 weeks ago"
1434/// - "in 6 months", "a month ago"
1435/// - "in 2 years", "a year ago"
1436///
1437/// ## Day References
1438/// - "today", "yesterday", "tomorrow"
1439/// - "Monday", "Tuesday", etc.
1440/// - "next Monday", "last Friday"
1441///
1442/// ## Times
1443/// - "3:30 pm", "10:15 am"
1444/// - "14:30", "09:00"
1445///
1446/// ## Dates
1447/// - "15/03/2024", "31-12-2025"
1448///
1449/// ## Combined
1450/// - "tomorrow at 3:30 pm"
1451/// - "next Monday at 9:00 am"
1452///
1453/// ## ISO Format
1454/// - "2024-01-15T14:30:00Z"
1455/// - "2024-01-15T14:30:00+02:00"
1456/// - "2024-01-15T14:30:00.123Z"
1457pub fn parse(input: &str, language: Language) -> Result<TimeExpression> {
1458    match language {
1459        Language::English => language::english::EnglishParser.parse(input),
1460        Language::German => language::german::GermanParser.parse(input),
1461    }
1462}