Skip to main content

lox_core/time/
calendar_dates.rs

1// SPDX-FileCopyrightText: 2023 Angus Morrison <github@angus-morrison.com>
2// SPDX-FileCopyrightText: 2023 Helge Eichhorn <git@helgeeichhorn.de>
3//
4// SPDX-License-Identifier: MPL-2.0
5
6/*!
7    `calendar_dates` exposes a concrete [Date] struct and the [CalendarDate] trait for working with
8    human-readable dates.
9*/
10
11use std::{
12    cmp::Ordering,
13    fmt::{Display, Formatter},
14    str::FromStr,
15    sync::OnceLock,
16};
17
18use crate::time::deltas::TimeDelta;
19use num::ToPrimitive;
20use thiserror::Error;
21
22use regex::Regex;
23
24use super::julian_dates::{Epoch, JulianDate, Unit};
25use crate::i64::consts::{SECONDS_PER_DAY, SECONDS_PER_HALF_DAY};
26
27fn iso_regex() -> &'static Regex {
28    static ISO: OnceLock<Regex> = OnceLock::new();
29    ISO.get_or_init(|| Regex::new(r"(?<year>-?\d{4,})-(?<month>\d{2})-(?<day>\d{2})").unwrap())
30}
31
32/// Error type returned when attempting to construct a [Date] from invalid inputs.
33#[derive(Debug, Clone, Error, PartialEq, Eq, PartialOrd, Ord)]
34pub enum DateError {
35    /// The given year, month, and day do not form a valid date.
36    #[error("invalid date `{0}-{1}-{2}`")]
37    InvalidDate(i64, u8, u8),
38    /// The input string is not a valid ISO 8601 date.
39    #[error("invalid ISO string `{0}`")]
40    InvalidIsoString(String),
41    /// Day 366 was requested for a non-leap year.
42    #[error("day of year cannot be 366 for a non-leap year")]
43    NonLeapYear,
44}
45
46/// The calendars supported by Lox.
47#[derive(Debug, Copy, Clone, PartialEq, Eq)]
48#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
49pub enum Calendar {
50    /// The Proleptic Julian calendar (year < 1).
51    ProlepticJulian,
52    /// The Julian calendar (year 1 to October 4, 1582).
53    Julian,
54    /// The Gregorian calendar (October 15, 1582 onwards).
55    Gregorian,
56}
57
58/// A calendar date.
59#[derive(Debug, Copy, Clone, PartialEq, Eq)]
60#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
61pub struct Date {
62    calendar: Calendar,
63    year: i64,
64    month: u8,
65    day: u8,
66}
67
68impl Display for Date {
69    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
70        write!(f, "{}-{:02}-{:02}", self.year, self.month, self.day)
71    }
72}
73
74impl FromStr for Date {
75    type Err = DateError;
76
77    fn from_str(iso: &str) -> Result<Self, Self::Err> {
78        Self::from_iso(iso)
79    }
80}
81
82impl Default for Date {
83    /// [Date] defaults to 2000-01-01 of the Gregorian calendar.
84    fn default() -> Self {
85        Self {
86            calendar: Calendar::Gregorian,
87            year: 2000,
88            month: 1,
89            day: 1,
90        }
91    }
92}
93
94impl PartialOrd for Date {
95    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
96        Some(self.cmp(other))
97    }
98}
99
100impl Ord for Date {
101    // The implementation of `Ord` for `Date` assumes that the `Calendar`s of the inputs date are
102    // the same. This assumption is true at 2024-03-30, since the `Date` constructor doesn't allow
103    // the creation of overlapping dates in different calendars, and `Date`s are immutable.
104    //
105    // If this changes, the implementation of `Ord` for `Date` must be updated too.
106    // See https://github.com/lox-space/lox/issues/87.
107    fn cmp(&self, other: &Self) -> Ordering {
108        match self.year.cmp(&other.year) {
109            Ordering::Equal => match self.month.cmp(&other.month) {
110                Ordering::Equal => self.day.cmp(&other.day),
111                other => other,
112            },
113            other => other,
114        }
115    }
116}
117
118const LAST_PROLEPTIC_JULIAN_DAY_J2K: i64 = -730122;
119const LAST_JULIAN_DAY_J2K: i64 = -152384;
120
121impl Date {
122    /// Returns the calendar system of this date.
123    pub fn calendar(&self) -> Calendar {
124        self.calendar
125    }
126
127    /// Returns the year.
128    pub fn year(&self) -> i64 {
129        self.year
130    }
131
132    /// Returns the month (1–12).
133    pub fn month(&self) -> u8 {
134        self.month
135    }
136
137    /// Returns the day of the month (1–31).
138    pub fn day(&self) -> u8 {
139        self.day
140    }
141
142    /// Construct a new [Date] from a year, month and day. The [Calendar] is inferred from the input
143    /// fields.
144    ///
145    /// # Errors
146    ///
147    /// - [DateError::InvalidDate] if the input fields do not represent a valid date.
148    pub fn new(year: i64, month: u8, day: u8) -> Result<Self, DateError> {
149        if !(1..=12).contains(&month) {
150            Err(DateError::InvalidDate(year, month, day))
151        } else {
152            let calendar = calendar(year, month, day);
153            let check = Date::from_days_since_j2000(j2000_day_number(calendar, year, month, day));
154
155            if check.year() != year || check.month() != month || check.day() != day {
156                Err(DateError::InvalidDate(year, month, day))
157            } else {
158                Ok(Date {
159                    calendar,
160                    year,
161                    month,
162                    day,
163                })
164            }
165        }
166    }
167
168    /// Constructs a new [Date] without validation. The [Calendar] is inferred.
169    pub const fn new_unchecked(year: i64, month: u8, day: u8) -> Self {
170        let calendar = calendar(year, month, day);
171        Date {
172            calendar,
173            year,
174            month,
175            day,
176        }
177    }
178
179    /// Constructs a new [Date] from an ISO 8601 string.
180    ///
181    /// # Errors
182    ///
183    /// - [DateError::InvalidIsoString] if the input string does not contain a valid ISO 8601 date.
184    /// - [DateError::InvalidDate] if the date parsed from the ISO 8601 string is invalid.
185    pub fn from_iso(iso: &str) -> Result<Self, DateError> {
186        let caps = iso_regex()
187            .captures(iso)
188            .ok_or(DateError::InvalidIsoString(iso.to_owned()))?;
189        let year: i64 = caps["year"]
190            .parse()
191            .map_err(|_| DateError::InvalidIsoString(iso.to_owned()))?;
192        let month = caps["month"]
193            .parse()
194            .map_err(|_| DateError::InvalidIsoString(iso.to_owned()))?;
195        let day = caps["day"]
196            .parse()
197            .map_err(|_| DateError::InvalidIsoString(iso.to_owned()))?;
198        Date::new(year, month, day)
199    }
200
201    /// Constructs a new [Date] from a signed number of days since J2000. The [Calendar] is
202    /// inferred.
203    pub fn from_days_since_j2000(days: i64) -> Self {
204        let calendar = if days < LAST_JULIAN_DAY_J2K {
205            if days > LAST_PROLEPTIC_JULIAN_DAY_J2K {
206                Calendar::Julian
207            } else {
208                Calendar::ProlepticJulian
209            }
210        } else {
211            Calendar::Gregorian
212        };
213
214        let year = find_year(calendar, days);
215        let leap = is_leap_year(calendar, year);
216        let day_of_year = (days - last_day_of_year_j2k(calendar, year - 1)) as u16;
217        let month = find_month(day_of_year, leap);
218        let day = find_day(day_of_year, month, leap).unwrap_or_else(|err| {
219            unreachable!("{} is not a valid day of the year: {}", day_of_year, err)
220        });
221
222        Date {
223            calendar,
224            year,
225            month,
226            day,
227        }
228    }
229
230    /// Constructs a new [Date] from a signed number of seconds since J2000. The [Calendar] is
231    /// inferred.
232    pub fn from_seconds_since_j2000(seconds: i64) -> Self {
233        let seconds = seconds + SECONDS_PER_HALF_DAY;
234        let mut time = seconds % SECONDS_PER_DAY;
235        if time < 0 {
236            time += SECONDS_PER_DAY;
237        }
238        let days = (seconds - time) / SECONDS_PER_DAY;
239        Self::from_days_since_j2000(days)
240    }
241
242    /// Constructs a new [Date] from a year and a day number within that year. The [Calendar] is
243    /// inferred.
244    ///
245    /// # Errors
246    ///
247    /// - [DateError::NonLeapYear] if the input day number is 366 and the year is not a leap year.
248    pub fn from_day_of_year(year: i64, day_of_year: u16) -> Result<Self, DateError> {
249        let calendar = calendar(year, 1, 1);
250        let leap = is_leap_year(calendar, year);
251        let month = find_month(day_of_year, leap);
252        let day = find_day(day_of_year, month, leap)?;
253
254        Ok(Date {
255            calendar,
256            year,
257            month,
258            day,
259        })
260    }
261
262    /// Returns the day number of `self` relative to J2000.
263    pub const fn j2000_day_number(&self) -> i64 {
264        j2000_day_number(self.calendar, self.year, self.month, self.day)
265    }
266
267    /// Converts this date to a [`TimeDelta`] relative to J2000.
268    pub const fn to_delta(&self) -> TimeDelta {
269        let seconds = self.j2000_day_number() * SECONDS_PER_DAY - SECONDS_PER_HALF_DAY;
270        TimeDelta::from_seconds(seconds)
271    }
272}
273
274impl JulianDate for Date {
275    fn julian_date(&self, epoch: Epoch, unit: Unit) -> f64 {
276        self.to_delta().julian_date(epoch, unit)
277    }
278}
279
280fn find_year(calendar: Calendar, j2000day: i64) -> i64 {
281    match calendar {
282        Calendar::ProlepticJulian => -((-4 * j2000day - 2920488) / 1461),
283        Calendar::Julian => -((-4 * j2000day - 2921948) / 1461),
284        Calendar::Gregorian => {
285            let year = (400 * j2000day + 292194288) / 146097;
286            if j2000day <= last_day_of_year_j2k(Calendar::Gregorian, year - 1) {
287                year - 1
288            } else {
289                year
290            }
291        }
292    }
293}
294
295const fn last_day_of_year_j2k(calendar: Calendar, year: i64) -> i64 {
296    match calendar {
297        Calendar::ProlepticJulian => 365 * year + (year + 1) / 4 - 730123,
298        Calendar::Julian => 365 * year + year / 4 - 730122,
299        Calendar::Gregorian => 365 * year + year / 4 - year / 100 + year / 400 - 730120,
300    }
301}
302
303const fn is_leap_year(calendar: Calendar, year: i64) -> bool {
304    match calendar {
305        Calendar::ProlepticJulian | Calendar::Julian => year % 4 == 0,
306        Calendar::Gregorian => year % 4 == 0 && (year % 400 == 0 || year % 100 != 0),
307    }
308}
309
310const PREVIOUS_MONTH_END_DAY_LEAP: [u16; 12] =
311    [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335];
312
313const PREVIOUS_MONTH_END_DAY: [u16; 12] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
314
315fn find_month(day_in_year: u16, is_leap: bool) -> u8 {
316    let offset = if is_leap { 313 } else { 323 };
317    let month = if day_in_year < 32 {
318        1
319    } else {
320        (10 * day_in_year + offset) / 306
321    };
322    month
323        .to_u8()
324        .unwrap_or_else(|| unreachable!("month could not be represented as u8: {}", month))
325}
326
327fn find_day(day_in_year: u16, month: u8, is_leap: bool) -> Result<u8, DateError> {
328    if !is_leap && day_in_year > 365 {
329        Err(DateError::NonLeapYear)
330    } else {
331        let previous_days = if is_leap {
332            PREVIOUS_MONTH_END_DAY_LEAP
333        } else {
334            PREVIOUS_MONTH_END_DAY
335        };
336        let day = day_in_year - previous_days[(month - 1) as usize];
337        Ok(day
338            .to_u8()
339            .unwrap_or_else(|| unreachable!("day could not be represented as u8: {}", day)))
340    }
341}
342
343const fn find_day_in_year(month: u8, day: u8, is_leap: bool) -> u16 {
344    let previous_days = if is_leap {
345        PREVIOUS_MONTH_END_DAY_LEAP
346    } else {
347        PREVIOUS_MONTH_END_DAY
348    };
349    day as u16 + previous_days[(month - 1) as usize]
350}
351
352const fn calendar(year: i64, month: u8, day: u8) -> Calendar {
353    if year < 1583 {
354        if year < 1 {
355            Calendar::ProlepticJulian
356        } else if year < 1582 || month < 10 || (month < 11 && day < 5) {
357            Calendar::Julian
358        } else {
359            Calendar::Gregorian
360        }
361    } else {
362        Calendar::Gregorian
363    }
364}
365
366const fn j2000_day_number(calendar: Calendar, year: i64, month: u8, day: u8) -> i64 {
367    let d1 = last_day_of_year_j2k(calendar, year - 1);
368    let d2 = find_day_in_year(month, day, is_leap_year(calendar, year));
369    d1 + d2 as i64
370}
371
372/// `CalendarDate` allows any date-time format to report its date in a human-readable way.
373pub trait CalendarDate {
374    /// Returns the date component.
375    fn date(&self) -> Date;
376
377    /// Returns the year.
378    fn year(&self) -> i64 {
379        self.date().year()
380    }
381
382    /// Returns the month (1–12).
383    fn month(&self) -> u8 {
384        self.date().month()
385    }
386
387    /// Returns the day of the month (1–31).
388    fn day(&self) -> u8 {
389        self.date().day()
390    }
391
392    /// Returns the day number within the year (1–366).
393    fn day_of_year(&self) -> u16 {
394        let date = self.date();
395        let leap = is_leap_year(date.calendar(), date.year());
396        find_day_in_year(date.month(), date.day(), leap)
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use crate::f64::consts::{DAYS_PER_JULIAN_CENTURY, SECONDS_PER_JULIAN_CENTURY};
403    use rstest::rstest;
404
405    use super::*;
406
407    #[rstest]
408    #[case::equal_same_calendar(Date { calendar: Calendar::Gregorian, year: 2000, month: 1, day: 1}, Date { calendar: Calendar::Gregorian, year: 2000, month: 1, day: 1}, Ordering::Equal)]
409    #[case::equal_different_calendar(Date { calendar: Calendar::Gregorian, year: 2000, month: 1, day: 1}, Date { calendar: Calendar::Julian, year: 2000, month: 1, day: 1}, Ordering::Equal)]
410    #[case::less_than_year(Date { calendar: Calendar::Gregorian, year: 1999, month: 1, day: 1}, Date { calendar: Calendar::Gregorian, year: 2000, month: 1, day: 1}, Ordering::Less)]
411    #[case::less_than_month(Date { calendar: Calendar::Gregorian, year: 2000, month: 1, day: 1}, Date { calendar: Calendar::Gregorian, year: 2000, month: 2, day: 1}, Ordering::Less)]
412    #[case::less_than_day(Date { calendar: Calendar::Gregorian, year: 2000, month: 1, day: 1}, Date { calendar: Calendar::Gregorian, year: 2000, month: 1, day: 2}, Ordering::Less)]
413    #[case::greater_than_year(Date { calendar: Calendar::Gregorian, year: 2001, month: 1, day: 1}, Date { calendar: Calendar::Gregorian, year: 2000, month: 1, day: 1}, Ordering::Greater)]
414    #[case::greater_than_month(Date { calendar: Calendar::Gregorian, year: 2000, month: 2, day: 1}, Date { calendar: Calendar::Gregorian, year: 2000, month: 1, day: 1}, Ordering::Greater)]
415    #[case::greater_than_day(Date { calendar: Calendar::Gregorian, year: 2000, month: 1, day: 2}, Date { calendar: Calendar::Gregorian, year: 2000, month: 1, day: 1}, Ordering::Greater)]
416    fn test_date_ord(#[case] lhs: Date, #[case] rhs: Date, #[case] expected: Ordering) {
417        assert_eq!(expected, lhs.cmp(&rhs));
418    }
419
420    #[rstest]
421    #[case::j2000("2000-01-01", Date { calendar: Calendar::Gregorian, year: 2000, month: 1, day: 1})]
422    #[case::j2000("0000-01-01", Date { calendar: Calendar::ProlepticJulian, year: 0, month: 1, day: 1})]
423    fn test_date_iso(#[case] str: &str, #[case] expected: Date) {
424        let actual = Date::from_iso(str).expect("date should parse");
425        assert_eq!(actual, expected);
426    }
427
428    #[test]
429    fn test_date_unchecked() {
430        let date = Date::new_unchecked(2026, 2, 11);
431        assert_eq!(date.calendar, Calendar::Gregorian);
432        assert_eq!(date.year, 2026);
433        assert_eq!(date.month, 2);
434        assert_eq!(date.day, 11);
435    }
436
437    #[test]
438    fn test_date_from_day_of_year() {
439        let date = Date::from_day_of_year(2000, 366).unwrap();
440        assert_eq!(date.year(), 2000);
441        assert_eq!(date.month(), 12);
442        assert_eq!(date.day(), 31);
443    }
444
445    #[test]
446    fn test_date_from_invalid_day_of_year() {
447        let actual = Date::from_day_of_year(2001, 366);
448        let expected = Err(DateError::NonLeapYear);
449        assert_eq!(actual, expected);
450    }
451
452    #[test]
453    fn test_date_jd_epoch() {
454        let date = Date::default();
455        assert_eq!(date.days_since_julian_epoch(), 2451544.5);
456    }
457
458    #[test]
459    fn test_date_julian_date() {
460        let date = Date::default();
461        assert_eq!(date.days_since_julian_epoch(), 2451544.5);
462
463        let date = Date::new(2100, 1, 1).unwrap();
464        assert_eq!(
465            date.seconds_since_j2000(),
466            SECONDS_PER_JULIAN_CENTURY - SECONDS_PER_HALF_DAY as f64
467        );
468        assert_eq!(date.days_since_j2000(), DAYS_PER_JULIAN_CENTURY - 0.5);
469        assert_eq!(
470            date.centuries_since_j2000(),
471            1.0 - 0.5 / DAYS_PER_JULIAN_CENTURY
472        );
473        assert_eq!(
474            date.centuries_since_j1950(),
475            1.5 - 0.5 / DAYS_PER_JULIAN_CENTURY
476        );
477        assert_eq!(
478            date.centuries_since_modified_julian_epoch(),
479            2.411211498973306 - 0.5 / DAYS_PER_JULIAN_CENTURY
480        );
481        assert_eq!(
482            date.centuries_since_julian_epoch(),
483            68.11964407939767 - 0.5 / DAYS_PER_JULIAN_CENTURY
484        );
485    }
486}