Skip to main content

kosher_rust/calendar/
mod.rs

1//! Hebrew calendar extensions built on ICU4X.
2//!
3//! This module adds Jewish-calendar logic on top of [`icu_calendar::Date`] internally:
4//! holidays, weekly Torah readings (*parshiyot*), month constants, and year-length
5//! helpers. Callers typically use [`jiff::civil::Date`]; ICU and other date types also
6//! work via [`HebrewCalendarDate`].
7//!
8//! # Submodules
9//!
10//! - [`month`] — named [`Month`] constants and Hebrew month names
11//! - [`holiday`] — Yom Tov, fast days, and other observances for a Hebrew date
12//! - [`parsha`] — [`Parsha`] enum and annual reading schedules
13//!
14//! # Quick start
15//!
16//! ```
17//! use jiff::civil;
18//! use kosher_rust::calendar::prelude::*;
19//!
20//! let date = civil::date(2023, 9, 25);
21//! assert!(date.is_assur_bemelacha(false));
22//! assert!(date.holidays(false, false).any(|h| h == Holiday::YomKippur));
23//! ```
24
25/// Named ICU [`Month`] values for every Hebrew month, including Adar I in leap years.
26pub mod month;
27
28/// Jewish holidays, fast days, and related calendar events.
29pub mod holiday;
30
31/// Weekly Torah portions (*parshiyot*) and special Shabbat designations.
32pub mod parsha;
33
34/// Convenience re-exports for calendar traits, types, and month constants.
35///
36/// ```
37/// use kosher_rust::calendar::prelude::*;
38/// ```
39pub mod prelude {
40    pub use super::holiday::{Holiday, HolidayIterator};
41    pub use super::month;
42    pub use super::month::HebrewMonthExt;
43    pub use super::parsha::Parsha;
44    pub use super::{HebrewCalendar, HebrewCalendarDate, HebrewHolidayCalendar, YearLengthType};
45}
46
47#[cfg(test)]
48#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
49mod tests;
50
51use holiday::{Holiday, HolidayIterator};
52use icu_calendar::options::DateAddOptions;
53use icu_calendar::types::{DateDuration, Month, Weekday};
54use icu_calendar::{AsCalendar, Date, Iso, cal::Hebrew};
55use jiff_icu::ConvertFrom;
56use month::*;
57use num_enum::{IntoPrimitive, TryFromPrimitive};
58pub use parsha::Parsha;
59use parsha::get_parsha_list;
60
61/// Any date type that can be converted to a Hebrew calendar date.
62///
63/// Implemented for [`icu_calendar::Date`] (any calendar) and [`jiff::civil::Date`].
64/// Other wrappers can implement this trait to gain [`HebrewHolidayCalendar`] for free.
65pub trait HebrewCalendarDate {
66    /// Returns this date converted to a [`icu_calendar::Date<Hebrew>`] date.
67    fn hebrew_date(&self) -> Date<Hebrew>;
68}
69
70impl<C> HebrewCalendarDate for Date<C>
71where
72    C: AsCalendar,
73{
74    #[inline]
75    fn hebrew_date(&self) -> Date<Hebrew> {
76        self.to_calendar(Hebrew)
77    }
78}
79
80impl HebrewCalendarDate for jiff::civil::Date {
81    #[inline]
82    fn hebrew_date(&self) -> Date<Hebrew> {
83        let iso_date = Date::<Iso>::convert_from(*self);
84        iso_date.to_calendar(Hebrew)
85    }
86}
87
88/// Represents the length type of a Hebrew year based on Cheshvan and Kislev.
89///
90/// - `Chaserim`: Both months are short (Cheshvan 29 days, Kislev 29 days)
91/// - `Kesidran`: Normal length (Cheshvan 29 days, Kislev 30 days)
92/// - `Shelaimim`: Both months are long (Cheshvan 30 days, Kislev 30 days)
93#[derive(Debug, PartialEq, Eq, Clone, Copy, IntoPrimitive, TryFromPrimitive)]
94#[repr(u8)]
95pub enum YearLengthType {
96    /// Cheshvan and Kislev are both short (29 days).
97    Chaserim = 0,
98    /// Cheshvan is short and Kislev is long (typical year).
99    Kesidran = 1,
100    /// Cheshvan and Kislev are both long (30 days).
101    Shelaimim = 2,
102}
103
104/// Hebrew calendar queries for a date: holidays, parsha, and related observances.
105///
106/// Blanket-implemented for every [`HebrewCalendarDate`]. Most methods take
107/// `in_israel` because diaspora communities observe an extra day of Yom Tov.
108pub trait HebrewHolidayCalendar: HebrewCalendarDate {
109    /// Iterator over holidays occurring on a specific date.
110    type HolidayIter: Iterator<Item = Holiday>;
111
112    /// Returns this date's month as an ICU [`Month`] (Tishrei-based numbering).
113    fn input_month(&self) -> Month;
114
115    /// Returns an iterator over holidays occurring on this date.
116    ///
117    /// # Arguments
118    ///
119    /// * `in_israel` - Whether to use Israeli customs (affects second day observances)
120    /// * `use_modern_holidays` - Whether to include modern Israeli holidays
121    fn holidays(&self, in_israel: bool, use_modern_holidays: bool) -> Self::HolidayIter;
122
123    /// Returns whether work is forbidden (assur bemelacha) on this date.
124    fn is_assur_bemelacha(&self, in_israel: bool) -> bool;
125
126    /// Returns whether candle lighting should occur on this date (day before Yom Tov/Shabbat).
127    fn has_candle_lighting(&self, in_israel: bool) -> bool;
128
129    /// Returns whether this date falls during the Ten Days of Repentance.
130    fn is_aseres_yemei_teshuva(&self) -> bool;
131
132    /// Returns the weekly Torah reading (parsha) if this is Shabbat and not a holiday.
133    fn todays_parsha(&self, in_israel: bool) -> Option<Parsha>;
134
135    /// Returns the special Shabbat designation if applicable (e.g., Shekalim, Zachor).
136    fn special_parsha(&self, in_israel: bool) -> Option<Parsha>;
137
138    /// Returns the Torah reading for the next Shabbat on or after this date, skipping
139    /// Shabbatot when Yom Tov replaces the regular weekly portion.
140    ///
141    /// If today is Shabbat, this targets the *next* Shabbat (seven days ahead), not
142    /// today's reading; use [`Self::todays_parsha`] for the current Shabbat.
143    ///
144    /// This function will return `None` if the date is out of the bounds that
145    /// `icu_calendar::Date<Hebrew>` can handle. Otherwise it will return the next Shabbat's parsha.
146    fn upcoming_parsha(&self, in_israel: bool) -> Option<Parsha>;
147}
148
149impl<T> HebrewHolidayCalendar for T
150where
151    T: HebrewCalendarDate,
152{
153    type HolidayIter = HolidayIterator;
154
155    #[inline]
156    fn is_assur_bemelacha(&self, in_israel: bool) -> bool {
157        self.hebrew_date().weekday() == Weekday::Saturday
158            || self.holidays(in_israel, false).any(|i| i.is_assur_bemelacha())
159    }
160    #[inline]
161    fn has_candle_lighting(&self, in_israel: bool) -> bool {
162        self.hebrew_date()
163            .try_added_with_options(DateDuration::for_days(1), DateAddOptions::default())
164            .map(|next_day| next_day.is_assur_bemelacha(in_israel))
165            .unwrap_or(false)
166    }
167
168    #[inline]
169    fn input_month(&self) -> Month {
170        self.hebrew_date().month().to_input()
171    }
172
173    #[inline]
174    fn holidays(&self, in_israel: bool, use_modern_holidays: bool) -> Self::HolidayIter {
175        HolidayIterator {
176            iter: holiday::all_rules().iter(),
177            date: self.hebrew_date(),
178            in_israel,
179            use_modern_holidays,
180        }
181    }
182
183    fn is_aseres_yemei_teshuva(&self) -> bool {
184        let date = self.hebrew_date();
185        date.input_month() == TISHREI && date.day_of_month().0 <= 10
186    }
187
188    fn todays_parsha(&self, in_israel: bool) -> Option<Parsha> {
189        let date = self.hebrew_date();
190        if date.weekday() != Weekday::Saturday {
191            return None;
192        }
193
194        let parsha_list = get_parsha_list(&date, in_israel)?;
195
196        let rosh_hashana_day_of_week = get_hebrew_elapsed_days(date.year().extended_year())? % 7;
197        let day = rosh_hashana_day_of_week + date.day_of_year().0 as i32;
198        let week_index = usize::try_from(day / 7).ok()?;
199        parsha_list.get(week_index).copied().flatten()
200    }
201
202    fn special_parsha(&self, in_israel: bool) -> Option<Parsha> {
203        let date = self.hebrew_date();
204        if date.weekday() != Weekday::Saturday {
205            return None;
206        }
207
208        let month = date.input_month();
209        let day = date.day_of_month().0;
210        let is_leap = Hebrew::is_hebrew_leap_year(date.year().extended_year());
211
212        // Shkalim
213        if ((month == SHEVAT && !is_leap) || (month == ADARI && is_leap)) && (day == 25 || day == 27 || day == 29) {
214            return Some(Parsha::Shekalim);
215        }
216
217        if month == ADAR {
218            if day == 1 {
219                return Some(Parsha::Shekalim);
220            }
221            // Zachor
222            if day == 8 || day == 9 || day == 11 || day == 13 {
223                return Some(Parsha::Zachor);
224            }
225            // Para
226            if day == 18 || day == 20 || day == 22 || day == 23 {
227                return Some(Parsha::Parah);
228            }
229            // Hachodesh
230            if day == 25 || day == 27 || day == 29 {
231                return Some(Parsha::Hachodesh);
232            }
233        }
234
235        if month == NISAN {
236            if day == 1 {
237                return Some(Parsha::Hachodesh);
238            }
239            // Hagadol
240            if (8..=14).contains(&day) {
241                return Some(Parsha::Hagadol);
242            }
243        }
244
245        if month == AV {
246            // Chazon
247            if (4..=9).contains(&day) {
248                return Some(Parsha::Chazon);
249            }
250            // Nachamu
251            if (10..=16).contains(&day) {
252                return Some(Parsha::Nachamu);
253            }
254        }
255
256        if month == TISHREI {
257            // Shuva
258            if (3..=8).contains(&day) {
259                return Some(Parsha::Shuva);
260            }
261        }
262
263        // Shira
264        if self.todays_parsha(in_israel) == Some(Parsha::Beshalach) {
265            return Some(Parsha::Shira);
266        }
267
268        None
269    }
270
271    fn upcoming_parsha(&self, in_israel: bool) -> Option<Parsha> {
272        let days_to_shabbos = match self.hebrew_date().weekday() {
273            Weekday::Monday => 5,
274            Weekday::Tuesday => 4,
275            Weekday::Wednesday => 3,
276            Weekday::Thursday => 2,
277            Weekday::Friday => 1,
278            Weekday::Saturday => 7,
279            Weekday::Sunday => 6,
280        };
281
282        let mut date = self
283            .hebrew_date()
284            .try_added_with_options(DateDuration::for_days(days_to_shabbos), DateAddOptions::default())
285            .ok()?;
286
287        // Avoid an unbounded search near ICU's supported date limits.
288        for _ in 0..60 {
289            if let Some(parshah) = date.todays_parsha(in_israel) {
290                return Some(parshah);
291            }
292
293            date = date
294                .try_added_with_options(DateDuration::for_days(7), DateAddOptions::default())
295                .ok()?;
296        }
297
298        None
299    }
300}
301
302/// Number of chalakim (parts) from the molad tohu (theoretical first new moon)
303const CHALAKIM_MOLAD_TOHU: i64 = 31524;
304/// Number of chalakim in a lunar month
305const CHALAKIM_PER_MONTH: i64 = 765433;
306/// Number of chalakim in a day
307const CHALAKIM_PER_DAY: i64 = 25920;
308
309/// Returns the number of chalakim from the original hypothetical Molad Tohu
310pub(crate) fn chalakim_since_molad_tohu(year: i32, month: Month) -> Option<i64> {
311    let month_of_year = month.hebrew_month_of_year(year)?;
312    let months_elapsed = (235 * ((year - 1) / 19))
313        + (12 * ((year - 1) % 19))
314        + ((7 * ((year - 1) % 19) + 1) / 19)
315        + (month_of_year as i32 - 1);
316
317    Some(CHALAKIM_MOLAD_TOHU + (CHALAKIM_PER_MONTH * months_elapsed as i64))
318}
319
320/// Returns elapsed days through Rosh Hashana of `year`, including the standard
321/// molad postponement rules.
322pub(super) fn get_hebrew_elapsed_days(year: i32) -> Option<i32> {
323    let chalakim_since = chalakim_since_molad_tohu(year, TISHREI)?;
324    let molad_day = chalakim_since / CHALAKIM_PER_DAY;
325    let molad_parts = chalakim_since - molad_day * CHALAKIM_PER_DAY;
326    let mut rosh_hashana_day = molad_day;
327
328    if (molad_parts >= 19440)
329        || (((molad_day % 7) == 2) && (molad_parts >= 9924) && !Hebrew::is_hebrew_leap_year(year))
330        || (((molad_day % 7) == 1) && (molad_parts >= 16789) && (Hebrew::is_hebrew_leap_year(year - 1)))
331    {
332        rosh_hashana_day += 1;
333    }
334
335    if ((rosh_hashana_day % 7) == 0) || ((rosh_hashana_day % 7) == 3) || ((rosh_hashana_day % 7) == 5) {
336        rosh_hashana_day += 1;
337    }
338
339    Some(rosh_hashana_day as i32)
340}
341
342/// Year-level Hebrew calendar calculations for ICU [`Hebrew`].
343///
344/// Import this trait to call `Hebrew::days_in_hebrew_year(...)` and related helpers.
345pub trait HebrewCalendar {
346    /// Returns the number of days in the given Hebrew year.
347    fn days_in_hebrew_year(year: i32) -> Option<i32>;
348
349    /// Returns the number of days in the given Hebrew month for a specific year.
350    ///
351    /// Returns `None` if `month` is not valid for `year`.
352    fn days_in_hebrew_month(year: i32, month: Month) -> Option<u8>;
353
354    /// Returns whether Cheshvan has 30 days (long) in the given year.
355    fn is_cheshvan_long(year: i32) -> Option<bool>;
356
357    /// Returns whether Kislev has 29 days (short) in the given year.
358    fn is_kislev_short(year: i32) -> Option<bool>;
359
360    /// Returns whether the given year is a Hebrew leap year.
361    fn is_hebrew_leap_year(year: i32) -> bool;
362
363    /// Returns the year type based on the lengths of Cheshvan and Kislev.
364    fn cheshvan_kislev_kviah(year: i32) -> Option<YearLengthType>;
365}
366
367impl HebrewCalendar for Hebrew {
368    #[inline]
369    fn days_in_hebrew_year(year: i32) -> Option<i32> {
370        Some(get_hebrew_elapsed_days(year + 1)? - get_hebrew_elapsed_days(year)?)
371    }
372
373    #[inline]
374    fn days_in_hebrew_month(year: i32, month: Month) -> Option<u8> {
375        month.hebrew_month_of_year(year)?;
376
377        Some(match month {
378            IYYAR | TAMMUZ | ELUL | TEVET => 29,
379            ḤESHVAN if Self::is_cheshvan_long(year)? => 30,
380            ḤESHVAN => 29,
381            KISLEV if Self::is_kislev_short(year)? => 29,
382            KISLEV => 30,
383            ADARI => 30,
384            ADAR => 29,
385            TISHREI | SHEVAT | NISAN | SIVAN | AV => 30,
386            _ => return None,
387        })
388    }
389
390    #[inline]
391    fn is_cheshvan_long(year: i32) -> Option<bool> {
392        Some(Self::days_in_hebrew_year(year)? % 10 == 5)
393    }
394
395    #[inline]
396    fn is_kislev_short(year: i32) -> Option<bool> {
397        Some(Self::days_in_hebrew_year(year)? % 10 == 3)
398    }
399
400    #[inline]
401    fn is_hebrew_leap_year(year: i32) -> bool {
402        let year_in_cycle = ((year - 1) % 19) + 1;
403        matches!(year_in_cycle, 3 | 6 | 8 | 11 | 14 | 17 | 19)
404    }
405
406    #[inline]
407    fn cheshvan_kislev_kviah(year: i32) -> Option<YearLengthType> {
408        Some(if Self::is_cheshvan_long(year)? && !Self::is_kislev_short(year)? {
409            YearLengthType::Shelaimim
410        } else if !Self::is_cheshvan_long(year)? && Self::is_kislev_short(year)? {
411            YearLengthType::Chaserim
412        } else {
413            YearLengthType::Kesidran
414        })
415    }
416}