Skip to main content

edtf_core/
bounds.rs

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