aboutsummaryrefslogtreecommitdiff
path: root/lib/kernel/date_spec.ml
blob: 6b328df3570f61aa24e9baa9e3cfa8f30ab60a5f (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
open Sexplib0.Sexp_conv

(* How a sanctoral entry expresses its date.

   Plan 2 shipped [Fixed] alone, and this file's own header then said
   "Sunday-relative and Easter-relative forms arrive with the OF sanctoral".
   They arrive here instead, ahead of OF, because two things needed them at
   once: user-supplied overlays carrying LOCAL MOVABLE FEASTS (a patronal
   feast on "the first Sunday of October" was simply inexpressible), and
   ROGATION WEDNESDAY's own commemoration (RG 87/88/89), recorded as
   architecturally blocked since 2026-08-13 for exactly one reason -- its
   trigger is Easter+38, and there is no (month, day) pair a [Fixed] spec
   could anchor to.

   Sunday-relative forms ("the Sunday on or after 2 November") are still NOT
   built: no entry in this repository needs one, and the same mechanism adds
   them when one does. See
   docs/superpowers/specs/2026-08-17-colitur-movable-date-specs-design.md. *)
module Repr = struct
  type t =
    | Fixed of { month : int; day : int }
    | Easter_offset of int
    | Nth_weekday of { month : int; nth : int; weekday : Date.weekday }
  [@@deriving sexp]
end

type t = Repr.t =
  | Fixed of { month : int; day : int }
  | Easter_offset of int
  | Nth_weekday of { month : int; nth : int; weekday : Date.weekday }

(* Leap-year maximum, so 29 February is constructible; it simply does not
   resolve in a common year. *)
let max_day = function
  | 1 | 3 | 5 | 7 | 8 | 10 | 12 -> 31
  | 4 | 6 | 9 | 11 -> 30
  | 2 -> 29
  | _ -> 0

let fixed ~month ~day =
  if month < 1 || month > 12 then Error (Printf.sprintf "date_spec: month %d out of range 1..12" month)
  else if day < 1 || day > max_day month then
    Error (Printf.sprintf "date_spec: day %d out of range for month %d" day month)
  else Ok (Fixed { month; day })

(* Deliberately generous rather than tight. Septuagesima is Easter-63 and Time
   after Pentecost runs well past Easter+180, so a bound that merely LOOKED
   precise would reject legitimate specs; a full year either way is the point
   past which a spec is certainly a data error rather than a calendar. *)
let easter_offset n =
  if n < -365 || n > 365 then
    Error (Printf.sprintf "date_spec: easter offset %d is further than a year from Easter" n)
  else Ok (Easter_offset n)

(* [nth] is 1-based forward, or negative from the end (-1 = the last). Zero is
   meaningless, and no month has a sixth of any weekday. *)
let nth_weekday ~month ~nth ~weekday =
  if month < 1 || month > 12 then Error (Printf.sprintf "date_spec: month %d out of range 1..12" month)
  else if nth = 0 then Error "date_spec: nth = 0 is meaningless (1 is the first, -1 the last)"
  else if nth < -5 || nth > 5 then
    Error (Printf.sprintf "date_spec: nth %d out of range (no month has six of a weekday)" nth)
  else Ok (Nth_weekday { month; nth; weekday })

(* The domain check on [Easter_offset] is load-bearing, not defensive
   decoration: date.mli states plainly that [add_days] is "unbounded total
   arithmetic (they may denote a date outside the domain)", so Easter+200 in
   9999 yields a perfectly well-formed [Date.t] that is nonetheless outside
   1583..9999. Returning it would leak an out-of-domain date into a [Layer]
   index and from there into resolution. [None] is the same answer 29 February
   already gives in a common year: "this spec does not occur". *)
let in_domain d = Date.year d >= 1583 && Date.year d <= 9999

let weekday_index = function
  | Date.Sun -> 0
  | Date.Mon -> 1
  | Date.Tue -> 2
  | Date.Wed -> 3
  | Date.Thu -> 4
  | Date.Fri -> 5
  | Date.Sat -> 6

(* Total: every branch returns [option], and nothing here raises on an in-range
   year. *)
let resolve t ~year ~easter =
  match t with
  | Fixed { month; day } -> Result.to_option (Date.make ~year ~month ~day)
  | Easter_offset n ->
      (* [easter] is the caller's Easter for THIS civil year -- the rite
         supplies it ({!Rite.t}'s own [easter] field), so a Julian-reckoning
         rite is never silently handed a Gregorian date. *)
      let d = Date.add_days easter n in
      if in_domain d then Some d else None
  | Nth_weekday { month; nth; weekday } -> (
      match Date.make ~year ~month ~day:1 with
      | Error _ -> None
      | Ok first ->
          (* [Date.make] is the authority on month length, so no leap rule is
             duplicated here: probe downward from 31 for the last day that
             actually constructs. Every month has at least 28. *)
          let rec last_day d =
            if d <= 28 then d else if Result.is_ok (Date.make ~year ~month ~day:d) then d else last_day (d - 1)
          in
          let days_in_month = last_day 31 in
          let offset_to_first = (weekday_index weekday - weekday_index (Date.weekday first) + 7) mod 7 in
          let first_occurrence = 1 + offset_to_first in
          let count = ((days_in_month - first_occurrence) / 7) + 1 in
          (* Negative [nth] counts back from the end: -1 is the last. *)
          let index = if nth > 0 then nth else count + nth + 1 in
          if index < 1 || index > count then None
          else Result.to_option (Date.make ~year ~month ~day:(first_occurrence + ((index - 1) * 7))))

let sexp_of_t = Repr.sexp_of_t

(* Validating parser, matching Slug and Lang: [ppx_sexp_conv]'s derived
   [t_of_sexp] (relocated to [Repr] above) accepts any in-range int, so
   [(Fixed (month 13) (day 40))] or [(Nth_weekday (month 10) (nth 0) ...)]
   would otherwise deserialise into a spec that silently never resolves -- a
   celebration quietly vanishing with no diagnostic. Re-running the value
   through the smart constructors closes that gap the same way loaders already
   close it for slugs and language codes.

   EVERY variant is re-validated here. Adding one without extending this
   function reopens exactly the hole it exists to close, and the failure mode
   is invisible: nothing errors, a feast simply never appears. *)
let t_of_sexp sexp =
  let reject msg = Sexplib0.Sexp_conv.of_sexp_error msg sexp in
  match Repr.t_of_sexp sexp with
  | Fixed { month; day } -> ( match fixed ~month ~day with Ok t -> t | Error msg -> reject msg)
  | Easter_offset n -> ( match easter_offset n with Ok t -> t | Error msg -> reject msg)
  | Nth_weekday { month; nth; weekday } -> (
      match nth_weekday ~month ~nth ~weekday with Ok t -> t | Error msg -> reject msg)