Skip to main content

gpui_rhai/
date.rs

1use std::fmt;
2use std::rc::Rc;
3
4use rhai::{
5    Array, Dynamic, Engine, EvalAltResult, FuncRegistration, ImmutableString, Map, Position,
6};
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
11pub struct GregorianDate {
12    year: i32,
13    month: u8,
14    day: u8,
15}
16
17impl GregorianDate {
18    /// Construct a Gregorian date in the supported four-digit ISO range.
19    ///
20    /// # Errors
21    ///
22    /// Returns [`DateError`] for an unsupported year/month or a day outside the
23    /// selected month.
24    pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DateError> {
25        if !(1..=9_999).contains(&year) {
26            return Err(DateError::Year(year));
27        }
28        if !(1..=12).contains(&month) {
29            return Err(DateError::Month(month));
30        }
31        let max = Self::days_in_month(year, month)?;
32        if !(1..=max).contains(&day) {
33            return Err(DateError::Day { year, month, day });
34        }
35        Ok(Self { year, month, day })
36    }
37
38    /// Parse exactly `YYYY-MM-DD`.
39    ///
40    /// # Errors
41    ///
42    /// Returns [`DateError`] for malformed text or an invalid calendar date.
43    pub fn parse_iso(value: &str) -> Result<Self, DateError> {
44        let bytes = value.as_bytes();
45        if bytes.len() != 10
46            || bytes[4] != b'-'
47            || bytes[7] != b'-'
48            || !bytes
49                .iter()
50                .enumerate()
51                .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit())
52        {
53            return Err(DateError::Format(value.to_owned()));
54        }
55        let year = parse_ascii_number(&bytes[0..4]);
56        let month = parse_ascii_number(&bytes[5..7]);
57        let day = parse_ascii_number(&bytes[8..10]);
58        Self::new(
59            i32::from(year),
60            u8::try_from(month).unwrap_or(u8::MAX),
61            u8::try_from(day).unwrap_or(u8::MAX),
62        )
63    }
64
65    #[must_use]
66    pub const fn year(self) -> i32 {
67        self.year
68    }
69
70    #[must_use]
71    pub const fn month(self) -> u8 {
72        self.month
73    }
74
75    #[must_use]
76    pub const fn day(self) -> u8 {
77        self.day
78    }
79
80    #[must_use]
81    pub const fn is_leap_year(year: i32) -> bool {
82        year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
83    }
84
85    /// Return the number of days in a Gregorian month.
86    ///
87    /// # Errors
88    ///
89    /// Returns [`DateError`] for an unsupported year/month.
90    pub fn days_in_month(year: i32, month: u8) -> Result<u8, DateError> {
91        if !(1..=9_999).contains(&year) {
92            return Err(DateError::Year(year));
93        }
94        let days = match month {
95            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
96            4 | 6 | 9 | 11 => 30,
97            2 if Self::is_leap_year(year) => 29,
98            2 => 28,
99            _ => return Err(DateError::Month(month)),
100        };
101        Ok(days)
102    }
103
104    #[must_use]
105    pub fn weekday(self) -> Weekday {
106        Weekday::from_sunday_index(days_from_civil(self.year, self.month, self.day) + 4)
107    }
108
109    /// Add signed calendar days.
110    ///
111    /// # Errors
112    ///
113    /// Returns [`DateError::ArithmeticRange`] when the result leaves years
114    /// 0001..=9999.
115    pub fn checked_add_days(self, days: i64) -> Result<Self, DateError> {
116        let serial = days_from_civil(self.year, self.month, self.day)
117            .checked_add(days)
118            .ok_or(DateError::ArithmeticRange)?;
119        civil_from_days(serial).ok_or(DateError::ArithmeticRange)
120    }
121
122    /// Add signed calendar months, clamping the day into the target month.
123    ///
124    /// # Errors
125    ///
126    /// Returns [`DateError::ArithmeticRange`] when the result leaves years
127    /// 0001..=9999.
128    pub fn checked_add_months(self, months: i32) -> Result<Self, DateError> {
129        let index = self
130            .year
131            .checked_mul(12)
132            .and_then(|value| value.checked_add(i32::from(self.month) - 1))
133            .and_then(|value| value.checked_add(months))
134            .ok_or(DateError::ArithmeticRange)?;
135        let year = index.div_euclid(12);
136        let month =
137            u8::try_from(index.rem_euclid(12) + 1).map_err(|_| DateError::ArithmeticRange)?;
138        if !(1..=9_999).contains(&year) {
139            return Err(DateError::ArithmeticRange);
140        }
141        let day = self.day.min(Self::days_in_month(year, month)?);
142        Self::new(year, month, day)
143    }
144
145    #[must_use]
146    pub fn to_iso(self) -> String {
147        format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
148    }
149}
150
151impl fmt::Display for GregorianDate {
152    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
153        formatter.write_str(&self.to_iso())
154    }
155}
156
157fn parse_ascii_number(bytes: &[u8]) -> u16 {
158    bytes.iter().fold(0_u16, |value, byte| {
159        value
160            .saturating_mul(10)
161            .saturating_add(u16::from(*byte - b'0'))
162    })
163}
164
165// Howard Hinnant's civil calendar transform, shifted so 1970-01-01 is day 0.
166fn days_from_civil(year: i32, month: u8, day: u8) -> i64 {
167    let mut year = i64::from(year);
168    let month = i64::from(month);
169    year -= i64::from(month <= 2);
170    let era = year.div_euclid(400);
171    let year_of_era = year - era * 400;
172    let adjusted_month = month + if month > 2 { -3 } else { 9 };
173    let day_of_year = (153 * adjusted_month + 2) / 5 + i64::from(day) - 1;
174    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
175    era * 146_097 + day_of_era - 719_468
176}
177
178fn civil_from_days(days: i64) -> Option<GregorianDate> {
179    let days = days.checked_add(719_468)?;
180    let era = days.div_euclid(146_097);
181    let day_of_era = days - era * 146_097;
182    let year_of_era =
183        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
184    let mut year = year_of_era + era * 400;
185    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
186    let month_prime = (5 * day_of_year + 2) / 153;
187    let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
188    let month = month_prime + if month_prime < 10 { 3 } else { -9 };
189    year += i64::from(month <= 2);
190    GregorianDate::new(
191        i32::try_from(year).ok()?,
192        u8::try_from(month).ok()?,
193        u8::try_from(day).ok()?,
194    )
195    .ok()
196}
197
198#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
199#[serde(rename_all = "snake_case")]
200pub enum Weekday {
201    Sunday,
202    Monday,
203    Tuesday,
204    Wednesday,
205    Thursday,
206    Friday,
207    Saturday,
208}
209
210impl Weekday {
211    #[must_use]
212    pub const fn sunday_index(self) -> usize {
213        match self {
214            Self::Sunday => 0,
215            Self::Monday => 1,
216            Self::Tuesday => 2,
217            Self::Wednesday => 3,
218            Self::Thursday => 4,
219            Self::Friday => 5,
220            Self::Saturday => 6,
221        }
222    }
223
224    #[must_use]
225    pub fn from_sunday_index(index: i64) -> Self {
226        match index.rem_euclid(7) {
227            0 => Self::Sunday,
228            1 => Self::Monday,
229            2 => Self::Tuesday,
230            3 => Self::Wednesday,
231            4 => Self::Thursday,
232            5 => Self::Friday,
233            _ => Self::Saturday,
234        }
235    }
236
237    #[must_use]
238    pub const fn as_str(self) -> &'static str {
239        match self {
240            Self::Sunday => "sunday",
241            Self::Monday => "monday",
242            Self::Tuesday => "tuesday",
243            Self::Wednesday => "wednesday",
244            Self::Thursday => "thursday",
245            Self::Friday => "friday",
246            Self::Saturday => "saturday",
247        }
248    }
249
250    /// Parse the stable lowercase weekday vocabulary used by locale metadata.
251    ///
252    /// # Errors
253    ///
254    /// Returns [`DateError::Weekday`] for another value.
255    pub fn parse(value: &str) -> Result<Self, DateError> {
256        match value {
257            "sunday" => Ok(Self::Sunday),
258            "monday" => Ok(Self::Monday),
259            "tuesday" => Ok(Self::Tuesday),
260            "wednesday" => Ok(Self::Wednesday),
261            "thursday" => Ok(Self::Thursday),
262            "friday" => Ok(Self::Friday),
263            "saturday" => Ok(Self::Saturday),
264            _ => Err(DateError::Weekday(value.to_owned())),
265        }
266    }
267}
268
269pub(crate) fn register_date_api(engine: &mut Engine) {
270    FuncRegistration::new("date_info")
271        .in_global_namespace()
272        .register_into_engine(engine, date_info);
273    FuncRegistration::new("date_month_start")
274        .in_global_namespace()
275        .register_into_engine(engine, date_month_start);
276    FuncRegistration::new("date_checked_add_days")
277        .in_global_namespace()
278        .register_into_engine(engine, date_checked_add_days);
279    FuncRegistration::new("date_checked_add_months")
280        .in_global_namespace()
281        .register_into_engine(engine, date_checked_add_months);
282    FuncRegistration::new("date_week_edge")
283        .in_global_namespace()
284        .register_into_engine(engine, date_week_edge);
285    FuncRegistration::new("date_month_grid")
286        .in_global_namespace()
287        .register_into_engine(engine, date_month_grid);
288    FuncRegistration::new("date_clamp")
289        .in_global_namespace()
290        .register_into_engine(engine, date_clamp);
291    FuncRegistration::new("date_month_intersects")
292        .in_global_namespace()
293        .register_into_engine(engine, date_month_intersects);
294}
295
296fn date_info(value: ImmutableString) -> Result<Map, Box<EvalAltResult>> {
297    let value: String = value.into();
298    let date = parse_script_date(&value)?;
299    Ok(Map::from_iter([
300        ("iso".into(), Dynamic::from(date.to_iso())),
301        ("year".into(), Dynamic::from_int(i64::from(date.year()))),
302        ("month".into(), Dynamic::from_int(i64::from(date.month()))),
303        ("day".into(), Dynamic::from_int(i64::from(date.day()))),
304        (
305            "weekday".into(),
306            Dynamic::from(date.weekday().as_str().to_owned()),
307        ),
308    ]))
309}
310
311fn date_month_start(value: ImmutableString) -> Result<ImmutableString, Box<EvalAltResult>> {
312    let value: String = value.into();
313    let date = parse_script_date(&value)?;
314    Ok(GregorianDate::new(date.year(), date.month(), 1)
315        .expect("parsed date has a valid month")
316        .to_iso()
317        .into())
318}
319
320fn date_checked_add_days(
321    value: ImmutableString,
322    days: rhai::INT,
323) -> Result<Dynamic, Box<EvalAltResult>> {
324    let value: String = value.into();
325    let date = parse_script_date(&value)?;
326    Ok(date
327        .checked_add_days(days)
328        .map_or(Dynamic::UNIT, |date| Dynamic::from(date.to_iso())))
329}
330
331fn date_checked_add_months(
332    value: ImmutableString,
333    months: rhai::INT,
334) -> Result<Dynamic, Box<EvalAltResult>> {
335    let value: String = value.into();
336    let date = parse_script_date(&value)?;
337    let months = i32::try_from(months)
338        .map_err(|_| Box::new(date_script_error(&DateError::ArithmeticRange)))?;
339    Ok(date
340        .checked_add_months(months)
341        .map_or(Dynamic::UNIT, |date| Dynamic::from(date.to_iso())))
342}
343
344fn date_week_edge(
345    value: ImmutableString,
346    first_weekday: ImmutableString,
347    end: bool,
348) -> Result<Dynamic, Box<EvalAltResult>> {
349    let value: String = value.into();
350    let first_weekday: String = first_weekday.into();
351    let date = parse_script_date(&value)?;
352    let first =
353        Weekday::parse(&first_weekday).map_err(|error| Box::new(date_script_error(&error)))?;
354    let weekday = date.weekday().sunday_index();
355    let from_start = (weekday + 7 - first.sunday_index()) % 7;
356    let delta = if end {
357        i64::try_from(6 - from_start).unwrap_or(0)
358    } else {
359        -i64::try_from(from_start).unwrap_or(0)
360    };
361    Ok(date
362        .checked_add_days(delta)
363        .map_or(Dynamic::UNIT, |date| Dynamic::from(date.to_iso())))
364}
365
366fn date_month_grid(
367    value: ImmutableString,
368    first_weekday: ImmutableString,
369) -> Result<Array, Box<EvalAltResult>> {
370    let value: String = value.into();
371    let first_weekday: String = first_weekday.into();
372    let date = parse_script_date(&value)?;
373    let month =
374        GregorianDate::new(date.year(), date.month(), 1).expect("parsed date has a valid month");
375    let first =
376        Weekday::parse(&first_weekday).map_err(|error| Box::new(date_script_error(&error)))?;
377    let leading = (month.weekday().sunday_index() + 7 - first.sunday_index()) % 7;
378    Ok((0_i64..42)
379        .map(|offset| {
380            let date = month.checked_add_days(offset - i64::try_from(leading).unwrap_or(0));
381            let (iso, day, outside, weekday) = date.map_or_else(
382                |_| (Dynamic::UNIT, Dynamic::UNIT, true, Dynamic::UNIT),
383                |date| {
384                    (
385                        Dynamic::from(date.to_iso()),
386                        Dynamic::from_int(i64::from(date.day())),
387                        date.year() != month.year() || date.month() != month.month(),
388                        Dynamic::from(date.weekday().as_str().to_owned()),
389                    )
390                },
391            );
392            Dynamic::from_map(Map::from_iter([
393                ("date".into(), iso),
394                ("day".into(), day),
395                ("outside".into(), Dynamic::from_bool(outside)),
396                ("weekday".into(), weekday),
397            ]))
398        })
399        .collect())
400}
401
402fn date_clamp(
403    value: ImmutableString,
404    min: Dynamic,
405    max: Dynamic,
406) -> Result<ImmutableString, Box<EvalAltResult>> {
407    let value: String = value.into();
408    let date = parse_script_date(&value)?;
409    let min = optional_script_date(min, "min")?;
410    let max = optional_script_date(max, "max")?;
411    validate_script_range(min, max)?;
412    Ok(min
413        .filter(|min| date < *min)
414        .or_else(|| max.filter(|max| date > *max))
415        .unwrap_or(date)
416        .to_iso()
417        .into())
418}
419
420fn date_month_intersects(
421    value: ImmutableString,
422    min: Dynamic,
423    max: Dynamic,
424) -> Result<bool, Box<EvalAltResult>> {
425    let value: String = value.into();
426    let date = parse_script_date(&value)?;
427    let first =
428        GregorianDate::new(date.year(), date.month(), 1).expect("parsed date has a valid month");
429    let last = GregorianDate::new(
430        date.year(),
431        date.month(),
432        GregorianDate::days_in_month(date.year(), date.month()).expect("parsed valid month"),
433    )
434    .expect("last day is valid");
435    let min = optional_script_date(min, "min")?;
436    let max = optional_script_date(max, "max")?;
437    validate_script_range(min, max)?;
438    Ok(min.is_none_or(|min| last >= min) && max.is_none_or(|max| first <= max))
439}
440
441fn parse_script_date(value: &str) -> Result<GregorianDate, Box<EvalAltResult>> {
442    GregorianDate::parse_iso(value).map_err(|error| Box::new(date_script_error(&error)))
443}
444
445fn optional_script_date(
446    value: Dynamic,
447    name: &str,
448) -> Result<Option<GregorianDate>, Box<EvalAltResult>> {
449    if value.is_unit() {
450        return Ok(None);
451    }
452    value
453        .try_cast::<ImmutableString>()
454        .ok_or_else(|| {
455            Box::new(date_runtime_error(format!(
456                "date range `{name}` must be a string or ()"
457            )))
458        })
459        .and_then(|value| parse_script_date(value.as_str()).map(Some))
460}
461
462fn validate_script_range(
463    min: Option<GregorianDate>,
464    max: Option<GregorianDate>,
465) -> Result<(), Box<EvalAltResult>> {
466    if min.zip(max).is_some_and(|(min, max)| min > max) {
467        Err(Box::new(date_runtime_error(
468            "date range min cannot be after max",
469        )))
470    } else {
471        Ok(())
472    }
473}
474
475fn date_script_error(error: &DateError) -> EvalAltResult {
476    date_runtime_error(error.to_string())
477}
478
479fn date_runtime_error(message: impl Into<String>) -> EvalAltResult {
480    EvalAltResult::ErrorRuntime(message.into().into(), Position::NONE)
481}
482
483pub trait CalendarClockSource: fmt::Debug {
484    fn today(&self) -> GregorianDate;
485}
486
487#[derive(Clone)]
488pub struct CalendarClock(Rc<dyn CalendarClockSource>);
489
490impl CalendarClock {
491    #[must_use]
492    pub fn system() -> Self {
493        Self(Rc::new(SystemCalendarClock))
494    }
495
496    #[must_use]
497    pub fn fixed(date: GregorianDate) -> Self {
498        Self(Rc::new(FixedCalendarClock(date)))
499    }
500
501    #[must_use]
502    pub fn from_source(source: impl CalendarClockSource + 'static) -> Self {
503        Self(Rc::new(source))
504    }
505
506    #[must_use]
507    pub fn today(&self) -> GregorianDate {
508        self.0.today()
509    }
510}
511
512impl Default for CalendarClock {
513    fn default() -> Self {
514        Self::system()
515    }
516}
517
518impl fmt::Debug for CalendarClock {
519    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
520        formatter
521            .debug_tuple("CalendarClock")
522            .field(&self.0)
523            .finish()
524    }
525}
526
527#[derive(Clone, Copy, Debug, Default)]
528struct SystemCalendarClock;
529
530impl CalendarClockSource for SystemCalendarClock {
531    fn today(&self) -> GregorianDate {
532        let date = jiff::Zoned::now().date();
533        GregorianDate::new(
534            i32::from(date.year()),
535            u8::try_from(date.month()).expect("Jiff months fit u8"),
536            u8::try_from(date.day()).expect("Jiff days fit u8"),
537        )
538        .expect("Jiff system dates fit the supported Gregorian range")
539    }
540}
541
542#[derive(Clone, Copy, Debug)]
543struct FixedCalendarClock(GregorianDate);
544
545impl CalendarClockSource for FixedCalendarClock {
546    fn today(&self) -> GregorianDate {
547        self.0
548    }
549}
550
551#[derive(Clone, Debug, Error, Eq, PartialEq)]
552pub enum DateError {
553    #[error("date `{0}` must use strict YYYY-MM-DD format")]
554    Format(String),
555    #[error("Gregorian year {0} must be between 1 and 9999")]
556    Year(i32),
557    #[error("Gregorian month {0} must be between 1 and 12")]
558    Month(u8),
559    #[error("day {day} is invalid for {year:04}-{month:02}")]
560    Day { year: i32, month: u8, day: u8 },
561    #[error("date arithmetic left the supported years 0001 through 9999")]
562    ArithmeticRange,
563    #[error("weekday `{0}` is not in the stable lowercase weekday vocabulary")]
564    Weekday(String),
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    #[test]
572    fn strict_iso_and_leap_year_validation() {
573        assert_eq!(
574            GregorianDate::parse_iso("2024-02-29").unwrap().to_iso(),
575            "2024-02-29"
576        );
577        assert!(GregorianDate::parse_iso("2023-02-29").is_err());
578        assert!(GregorianDate::parse_iso("2024-2-29").is_err());
579        assert!(GregorianDate::parse_iso("0000-01-01").is_err());
580        assert!(GregorianDate::parse_iso("10000-01-01").is_err());
581    }
582
583    #[test]
584    fn arithmetic_crosses_month_year_and_century_boundaries() {
585        assert_eq!(
586            GregorianDate::parse_iso("2024-12-31")
587                .unwrap()
588                .checked_add_days(1)
589                .unwrap()
590                .to_iso(),
591            "2025-01-01"
592        );
593        assert_eq!(
594            GregorianDate::parse_iso("2024-01-31")
595                .unwrap()
596                .checked_add_months(1)
597                .unwrap()
598                .to_iso(),
599            "2024-02-29"
600        );
601        assert_eq!(
602            GregorianDate::parse_iso("2100-01-31")
603                .unwrap()
604                .checked_add_months(1)
605                .unwrap()
606                .to_iso(),
607            "2100-02-28"
608        );
609        let epoch = GregorianDate::parse_iso("1970-01-01").unwrap();
610        assert_eq!(
611            epoch.checked_add_days(i64::MAX),
612            Err(DateError::ArithmeticRange)
613        );
614        assert_eq!(
615            epoch.checked_add_days(i64::MIN),
616            Err(DateError::ArithmeticRange)
617        );
618    }
619
620    #[test]
621    fn weekdays_match_known_dates() {
622        assert_eq!(
623            GregorianDate::parse_iso("1970-01-01").unwrap().weekday(),
624            Weekday::Thursday
625        );
626        assert_eq!(
627            GregorianDate::parse_iso("2024-02-29").unwrap().weekday(),
628            Weekday::Thursday
629        );
630    }
631
632    #[test]
633    fn fixed_clock_is_deterministic() {
634        let date = GregorianDate::parse_iso("2026-08-29").unwrap();
635        assert_eq!(CalendarClock::fixed(date).today(), date);
636    }
637
638    #[test]
639    fn rhai_date_data_api_is_checked_and_month_grid_is_stable() {
640        let runtime = crate::RuntimeEngine::new();
641        let info = runtime
642            .engine()
643            .eval::<Map>(r#"date_info("2024-02-29")"#)
644            .unwrap();
645        assert_eq!(info["weekday"].clone_cast::<ImmutableString>(), "thursday");
646        let grid = runtime
647            .engine()
648            .eval::<Array>(r#"date_month_grid("2024-02-15", "sunday")"#)
649            .unwrap();
650        assert_eq!(grid.len(), 42);
651        let first = grid[0].clone_cast::<Map>();
652        assert_eq!(first["date"].clone_cast::<ImmutableString>(), "2024-01-28");
653        assert!(first["outside"].clone_cast::<bool>());
654        assert!(
655            runtime
656                .engine()
657                .eval::<Dynamic>(r#"date_checked_add_days("9999-12-31", 1)"#)
658                .unwrap()
659                .is_unit()
660        );
661        assert!(
662            runtime
663                .engine()
664                .eval::<bool>(r#"date_month_intersects("2024-02-01", "2024-02-10", "2024-02-20")"#)
665                .unwrap()
666        );
667        assert!(
668            runtime
669                .engine()
670                .eval::<Map>(r#"date_info("2023-02-29")"#)
671                .is_err()
672        );
673    }
674}