Skip to main content

edtf_core/
bounds.rs

1//! Earliest/latest calendar-day bounds for EDTF expressions.
2//!
3//! Every EDTF expression denotes a region of the time axis. `bounds()`
4//! reports the earliest and latest proleptic-Gregorian calendar day that
5//! region touches — the primitive that range queries (`edtf_min`/`edtf_max`
6//! in SQL, filtering in an application) are built from.
7//!
8//! Conventions (documented, spec-derived where the spec speaks):
9//! - Bounds are day-granular; time-of-day refines within a day and does not
10//!   change them.
11//! - Qualification (`?~%`) does not move bounds: `1985~` still bounds to the
12//!   calendar year 1985 (ISO 8601-2 §8.4.2 NOTE — approximation widens
13//!   confidence, not the written value).
14//! - Unspecified digits bound to the full set of matching completions; `XXXX`
15//!   is "a four-digit year" (ISO 8601-2 §9.2.1.2 c 4), so it bounds to
16//!   0000-01-01..9999-12-31.
17//! - Season/sub-year boundaries use the fixed month table in
18//!   `docs/spec-notes.md` D12 (ISO leaves seasons location-dependent).
19//! - Open interval ends (`..`) are infinite; unknown ends (empty) are
20//!   [`Bound::Unknown`].
21
22use crate::types::{Date, DateField, Edtf, Interval, IntervalEndpoint, Set, SetElement, YearKind};
23
24/// A concrete proleptic-Gregorian calendar day used as a bound.
25///
26/// Ordering is calendrical (year, then month, then day). Year is
27/// astronomical numbering: 0 is a leap year, -1 precedes it.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
29#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
30pub struct BoundDate {
31    /// Astronomical year number.
32    pub year: i64,
33    /// Calendar month 1–12.
34    pub month: u8,
35    /// Calendar day of month 1–31.
36    pub day: u8,
37}
38
39impl core::fmt::Display for BoundDate {
40    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
41        if self.year < 0 {
42            write!(f, "-{:04}-{:02}-{:02}", -self.year, self.month, self.day)
43        } else {
44            write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
45        }
46    }
47}
48
49/// One side of a [`Bounds`] result.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
52pub enum Bound {
53    /// Unbounded in the past (open interval start, `..date` endpoints).
54    NegativeInfinity,
55    /// A concrete calendar day.
56    Date(BoundDate),
57    /// Unbounded in the future (open interval end, `date..` endpoints).
58    PositiveInfinity,
59    /// Not determinable: unknown interval endpoints, or year values beyond
60    /// the numeric range this library computes with.
61    Unknown,
62}
63
64/// The earliest and latest calendar day an expression touches.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
67pub struct Bounds {
68    /// Earliest calendar day (inclusive).
69    pub earliest: Bound,
70    /// Latest calendar day (inclusive).
71    pub latest: Bound,
72}
73
74impl Edtf {
75    /// Compute the earliest/latest calendar-day bounds of this expression.
76    #[must_use]
77    pub fn bounds(&self) -> Bounds {
78        match self {
79            Self::Date(d) => date_bounds(d),
80            Self::DateTime(dt) => date_bounds(&dt.date),
81            Self::Interval(iv) => interval_bounds(iv),
82            Self::Set(s) => set_bounds(s),
83        }
84    }
85}
86
87pub(crate) fn date_bounds(d: &Date) -> Bounds {
88    // Y-prefixed / exponential years are year-precision by construction.
89    if !matches!(d.year.kind, YearKind::Standard { .. }) {
90        let Some(value) = d.year.value() else {
91            return Bounds {
92                earliest: Bound::Unknown,
93                latest: Bound::Unknown,
94            };
95        };
96        let Some((lo, hi)) =
97            significant_range(value, d.year.significant_digits, big_width(&d.year.kind))
98        else {
99            return Bounds {
100                earliest: Bound::Unknown,
101                latest: Bound::Unknown,
102            };
103        };
104        return Bounds {
105            earliest: Bound::Date(BoundDate {
106                year: lo,
107                month: 1,
108                day: 1,
109            }),
110            latest: Bound::Date(BoundDate {
111                year: hi,
112                month: 12,
113                day: 31,
114            }),
115        };
116    }
117
118    // Standard year with significant digits is year-precision only.
119    if d.year.significant_digits.is_some() {
120        // S excludes X digits, and a four-digit sweep cannot overflow; if
121        // either invariant ever broke, bound to Unknown rather than panic.
122        let Some((lo, hi)) = d
123            .year
124            .value()
125            .and_then(|value| significant_range(value, d.year.significant_digits, 4))
126        else {
127            return Bounds {
128                earliest: Bound::Unknown,
129                latest: Bound::Unknown,
130            };
131        };
132        return Bounds {
133            earliest: Bound::Date(BoundDate {
134                year: lo,
135                month: 1,
136                day: 1,
137            }),
138            latest: Bound::Date(BoundDate {
139                year: hi,
140                month: 12,
141                day: 31,
142            }),
143        };
144    }
145
146    // Season / sub-year grouping in the month slot.
147    if let Some(code) = d.month.and_then(DateField::value) {
148        if (21..=41).contains(&code) {
149            return season_bounds(d, code);
150        }
151    }
152
153    let earliest = extremum(d, true);
154    let latest = extremum(d, false);
155    match (earliest, latest) {
156        (Some(a), Some(b)) => Bounds {
157            earliest: Bound::Date(a),
158            latest: Bound::Date(b),
159        },
160        // Validation guarantees at least one completion, so this is
161        // unreachable; stay total anyway.
162        _ => Bounds {
163            earliest: Bound::Unknown,
164            latest: Bound::Unknown,
165        },
166    }
167}
168
169/// Digit width of the year form, for significant-digit ranges.
170pub(crate) fn big_width(kind: &YearKind) -> u32 {
171    match kind {
172        YearKind::Standard { .. } => 4,
173        YearKind::Big { value } => decimal_digits(value.unsigned_abs()),
174        YearKind::Exponential {
175            significand,
176            exponent,
177        } => decimal_digits(significand.unsigned_abs()) + exponent,
178    }
179}
180
181const fn decimal_digits(mut v: u64) -> u32 {
182    let mut n = 1;
183    while v >= 10 {
184        v /= 10;
185        n += 1;
186    }
187    n
188}
189
190/// The year range denoted by a value with `S<precision>` significant digits
191/// over a `width`-digit form (ISO 8601-2 §4.4.3): keep the leading
192/// `precision` digits, sweep the rest 0..9. `None` when the top of the swept
193/// range exceeds the numeric range this library computes with (e.g.
194/// `Y9E18S1`), mirroring how un-valuable years bound to `Unknown`.
195pub(crate) fn significant_range(
196    value: i64,
197    precision: Option<u32>,
198    width: u32,
199) -> Option<(i64, i64)> {
200    let Some(p) = precision else {
201        return Some((value, value));
202    };
203    let sweep = width.saturating_sub(p);
204    let Some(modulus) = 10i64.checked_pow(sweep) else {
205        return Some((value, value));
206    };
207    let mag = i64::try_from(value.unsigned_abs()).ok()?;
208    let lo_mag = mag - mag.rem_euclid(modulus);
209    let hi_mag = lo_mag.checked_add(modulus - 1)?;
210    Some(if value < 0 {
211        (-hi_mag, -lo_mag)
212    } else {
213        (lo_mag, hi_mag)
214    })
215}
216
217/// First and last month of each sub-year grouping code (docs/spec-notes.md
218/// D12). `wraps` means the grouping ends in the following year.
219fn season_months(code: u8) -> (u8, u8, bool) {
220    match code {
221        21 | 25 | 31 => (3, 5, false),  // spring (N), autumn (S)
222        22 | 26 | 32 => (6, 8, false),  // summer (N), winter (S)
223        23 | 27 | 29 => (9, 11, false), // autumn (N), spring (S)
224        24 | 28 | 30 => (12, 2, true),  // winter (N), summer (S)
225        33 => (1, 3, false),
226        34 => (4, 6, false),
227        35 => (7, 9, false),
228        36 => (10, 12, false),
229        37 => (1, 4, false),
230        38 => (5, 8, false),
231        39 => (9, 12, false),
232        40 => (1, 6, false),
233        41 => (7, 12, false),
234        _ => unreachable!("validated season code"),
235    }
236}
237
238fn season_bounds(d: &Date, code: u8) -> Bounds {
239    let (first, last, wraps) = season_months(code);
240    let (y_lo, y_hi) = year_range(d);
241    let end_year = if wraps { y_hi + 1 } else { y_hi };
242    Bounds {
243        earliest: Bound::Date(BoundDate {
244            year: y_lo,
245            month: first,
246            day: 1,
247        }),
248        latest: Bound::Date(BoundDate {
249            year: end_year,
250            month: last,
251            day: last_day(last, is_leap(end_year)),
252        }),
253    }
254}
255
256/// Min and max year completions of a standard year: masked digits take 0
257/// for the minimum and 9 for the maximum (fixed digits pin their place, so
258/// the extremes are independent per digit).
259fn year_range(d: &Date) -> (i64, i64) {
260    match d.year.kind {
261        YearKind::Standard { negative, digits } => d.year.value().map_or_else(
262            || {
263                debug_assert!(!negative);
264                (year_value(digits, 0), year_value(digits, 9))
265            },
266            |v| (v, v),
267        ),
268        _ => unreachable!("caller checked Standard"),
269    }
270}
271
272/// The year denoted by `digits` with every masked digit replaced by `fill`.
273fn year_value(digits: [Option<u8>; 4], fill: u8) -> i64 {
274    digits
275        .iter()
276        .fold(0, |acc, d| acc * 10 + i64::from(d.unwrap_or(fill)))
277}
278
279/// Completions of a masked year, ascending or descending. The masked
280/// positions form an odometer (most significant first), so consecutive
281/// counter values yield consecutive matching years in order.
282fn year_completions(digits: [Option<u8>; 4], ascending: bool) -> impl Iterator<Item = i64> {
283    let mut masked = [0usize; 4];
284    let mut n: u32 = 0;
285    for (i, d) in digits.iter().enumerate() {
286        if d.is_none() {
287            masked[n as usize] = i;
288            n += 1;
289        }
290    }
291    let count = 10u32.pow(n);
292    (0..count).map(move |i| {
293        let mut rem = if ascending { i } else { count - 1 - i };
294        let mut filled = digits.map(|d| d.unwrap_or(0));
295        for pos in masked[..n as usize].iter().rev() {
296            filled[*pos] = (rem % 10) as u8;
297            rem /= 10;
298        }
299        filled.iter().fold(0, |acc, d| acc * 10 + i64::from(*d))
300    })
301}
302
303/// Earliest (`ascending`) or latest completion of a masked/plain date.
304///
305/// For masked years the completions are visited lazily in order; almost
306/// every pattern resolves on the first year, and the only patterns that
307/// advance further are leap-sensitive (a February 29 that the first
308/// candidate year lacks), which validation guarantees resolve eventually.
309fn extremum(d: &Date, ascending: bool) -> Option<BoundDate> {
310    if let YearKind::Standard { digits, .. } = d.year.kind {
311        if d.year.value().is_none() {
312            return year_completions(digits, ascending)
313                .find_map(|y| extremum_in_year(d, y, ascending));
314        }
315    }
316    let (y, _) = year_range(d);
317    extremum_in_year(d, y, ascending)
318}
319
320/// Earliest (`ascending`) or latest day within year `y` matching the
321/// month/day pattern of `d`, if the year admits one.
322fn extremum_in_year(d: &Date, y: i64, ascending: bool) -> Option<BoundDate> {
323    let Some(month) = &d.month else {
324        return Some(if ascending {
325            BoundDate {
326                year: y,
327                month: 1,
328                day: 1,
329            }
330        } else {
331            BoundDate {
332                year: y,
333                month: 12,
334                day: 31,
335            }
336        });
337    };
338    let mut months = month_candidates_of(*month);
339    if !ascending {
340        months.reverse();
341    }
342    for m in months {
343        let Some(day) = &d.day else {
344            return Some(if ascending {
345                BoundDate {
346                    year: y,
347                    month: m,
348                    day: 1,
349                }
350            } else {
351                BoundDate {
352                    year: y,
353                    month: m,
354                    day: last_day(m, is_leap(y)),
355                }
356            });
357        };
358        let mut days = day_candidates_of(*day);
359        if !ascending {
360            days.reverse();
361        }
362        for dd in days {
363            if dd <= last_day(m, is_leap(y)) {
364                return Some(BoundDate {
365                    year: y,
366                    month: m,
367                    day: dd,
368                });
369            }
370        }
371    }
372    None
373}
374
375pub(crate) fn month_candidates_of(f: DateField) -> alloc::vec::Vec<u8> {
376    f.value().map_or_else(
377        || (1..=12).filter(|v| field_matches(f, *v)).collect(),
378        |v| alloc::vec![v],
379    )
380}
381
382pub(crate) fn day_candidates_of(f: DateField) -> alloc::vec::Vec<u8> {
383    f.value().map_or_else(
384        || (1..=31).filter(|v| field_matches(f, *v)).collect(),
385        |v| alloc::vec![v],
386    )
387}
388
389fn field_matches(f: DateField, v: u8) -> bool {
390    f.digits[0].is_none_or(|p| p == v / 10) && f.digits[1].is_none_or(|p| p == v % 10)
391}
392
393/// Proleptic Gregorian leap rule on the astronomical year number.
394pub(crate) const fn is_leap(y: i64) -> bool {
395    y.rem_euclid(4) == 0 && (y.rem_euclid(100) != 0 || y.rem_euclid(400) == 0)
396}
397
398pub(crate) fn last_day(month: u8, leap: bool) -> u8 {
399    match month {
400        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
401        4 | 6 | 9 | 11 => 30,
402        2 => {
403            if leap {
404                29
405            } else {
406                28
407            }
408        },
409        _ => unreachable!("month is 1-12"),
410    }
411}
412
413fn interval_bounds(iv: &Interval) -> Bounds {
414    let earliest = match &iv.start {
415        IntervalEndpoint::Open | IntervalEndpoint::OnOrBefore(_) => Bound::NegativeInfinity,
416        IntervalEndpoint::Unknown => Bound::Unknown,
417        IntervalEndpoint::Date(d) | IntervalEndpoint::OnOrAfter(d) => date_bounds(d).earliest,
418    };
419    let latest = match &iv.end {
420        IntervalEndpoint::Open | IntervalEndpoint::OnOrAfter(_) => Bound::PositiveInfinity,
421        IntervalEndpoint::Unknown => Bound::Unknown,
422        IntervalEndpoint::Date(d) | IntervalEndpoint::OnOrBefore(d) => date_bounds(d).latest,
423    };
424    Bounds { earliest, latest }
425}
426
427fn set_bounds(s: &Set) -> Bounds {
428    let mut earliest: Option<Bound> = None;
429    let mut latest: Option<Bound> = None;
430    for element in &s.elements {
431        let (e, l) = match element {
432            SetElement::Date(d) => {
433                let b = date_bounds(d);
434                (b.earliest, b.latest)
435            },
436            SetElement::OnOrBefore(d) => (Bound::NegativeInfinity, date_bounds(d).latest),
437            SetElement::OnOrAfter(d) => (date_bounds(d).earliest, Bound::PositiveInfinity),
438            SetElement::Range(a, b) => (date_bounds(a).earliest, date_bounds(b).latest),
439        };
440        earliest = Some(earliest.map_or(e, |cur| min_bound(cur, e)));
441        latest = Some(latest.map_or(l, |cur| max_bound(cur, l)));
442    }
443    Bounds {
444        earliest: earliest.unwrap_or(Bound::Unknown),
445        latest: latest.unwrap_or(Bound::Unknown),
446    }
447}
448
449fn min_bound(a: Bound, b: Bound) -> Bound {
450    match (a, b) {
451        (Bound::NegativeInfinity, _) | (_, Bound::NegativeInfinity) => Bound::NegativeInfinity,
452        (Bound::Unknown, _) | (_, Bound::Unknown) => Bound::Unknown,
453        (Bound::PositiveInfinity, x) | (x, Bound::PositiveInfinity) => x,
454        (Bound::Date(x), Bound::Date(y)) => Bound::Date(x.min(y)),
455    }
456}
457
458fn max_bound(a: Bound, b: Bound) -> Bound {
459    match (a, b) {
460        (Bound::PositiveInfinity, _) | (_, Bound::PositiveInfinity) => Bound::PositiveInfinity,
461        (Bound::Unknown, _) | (_, Bound::Unknown) => Bound::Unknown,
462        (Bound::NegativeInfinity, x) | (x, Bound::NegativeInfinity) => x,
463        (Bound::Date(x), Bound::Date(y)) => Bound::Date(x.max(y)),
464    }
465}