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}