summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md18
-rw-r--r--bin/main.ml129
-rw-r--r--man/colitur.176
-rw-r--r--test/cli.t65
-rw-r--r--test/dune1
-rw-r--r--test/fixtures/overlay-example-diocesan.sexp45
6 files changed, 310 insertions, 24 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index f09c555..8edcdb2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -71,8 +71,8 @@ overlays**.
year into a rata-die table, with the domain filter in `Layer.index` itself
(both edges, 1582 and 10000, bit during development). This unblocked
**Rogation Wednesday** (RG 87/88/89, register ยง6.10) and is what a
- user-supplied overlay needs for a local movable feast. The `--overlay` CLI
- plumbing is **not yet built**.
+ user-supplied overlay needs for a local movable feast. The `--overlay` CLI plumbing
+ is **built** (see below).
- **Kernel** (`lib/kernel`, pure, total, bounded 1583โ€“9999): `Computus` (Gregorian
+ Julian Easter + anchors), `Date` (proleptic Gregorian arithmetic), `Overlay`
(ordered layer-merge algebra: field-level add/suppress/replace/edit, last-writer-
@@ -303,10 +303,22 @@ M20 added by the `ef-major-litanies` task, M18 394 not 395 accordingly).
Fixtures live in `test/fixtures/` with asserted SHA-256s.
**CLI**: `colitur easter <year>`, `temporal <year>`, `day <year>`,
-`readings <year>`, `-h`/`--help`, `-V`/`--version`. Man page in
+`readings <year>`, `-h`/`--help`, `-V`/`--version`, and `--overlay FILE`
+(repeatable, ordered; `day`/`readings` only). Man page in
`man/colitur.1`; `Makefile` installs binary + data + man page into `~/.local`
by default. Tagged **v0.1.0**.
+**User overlays** apply ON TOP of the shipped `adjustments.sexp`, never
+instead of it โ€” replacing would silently drop RG 110's companion, the Major
+Litanies, Barbara and Rogation Wednesday. `Overlay.merge` is
+last-writer-wins, so a local calendar can still override a universal entry
+by naming its slug. Refused on `easter`/`temporal` (they read no sanctoral
+data) rather than silently ignored. **An overlay is applied, not validated**
+โ€” the five layers assert things about the SHIPPED data and cannot vouch for
+a user file; a directive matching nothing warns on stderr and continues, a
+file that fails to load is fatal. Worked example, both a fixed and a movable
+local feast: `test/fixtures/overlay-example-diocesan.sexp`.
+
`--help` prints to **stdout** and exits **0**; a usage error prints one line
to **stderr** and exits **2**. The distinction is asserted in `test/cli.t`,
both directions, because it is the sort of thing that silently rots.
diff --git a/bin/main.ml b/bin/main.ml
index 141f016..06732b3 100644
--- a/bin/main.ml
+++ b/bin/main.ml
@@ -137,17 +137,40 @@ let data_dir () =
so any that come back -- expected to be none in the committed data; see
the overlay file's own comment on when one WOULD fire -- are printed to
stderr, loudly, without aborting the run. *)
-let load_ef_layer () =
+(* [user_overlays] are applied AFTER the shipped adjustments, in the order
+ given, never instead of them. That ordering is the whole point: the shipped
+ overlay carries RG 110's own 30 June companion, the Major Litanies, St
+ Barbara and Rogation Wednesday, and a user file that REPLACED it would
+ silently drop all four while looking like it had merely added a local
+ feast. {!Overlay.merge}'s last-writer-wins is what lets a local calendar
+ still override a universal entry deliberately, by naming its slug.
+
+ Diagnostics stay loud but non-fatal, and that matters more for a user file
+ than for the shipped one: a directive naming a slug that does not exist (a
+ typo in a diocesan calendar) prints to stderr and the run continues, rather
+ than the entry silently doing nothing. A file that fails to LOAD is fatal,
+ exactly as the shipped overlay is -- a malformed calendar is not something
+ to carry on past. *)
+let load_ef_layer ?(user_overlays = []) () =
let dir = data_dir () in
let sanctoral_path = Filename.concat dir "sanctoral.sexp" in
let adjustments_path = Filename.concat dir "adjustments.sexp" in
+ let load_overlay path =
+ match Colitur_kernel.Overlay.load Rite_ef.Vocab_ef.rank_of_sexp path with
+ | Error e -> Error (Printf.sprintf "failed to load %s: %s" path e)
+ | Ok o -> Ok o
+ in
+ let rec load_all acc = function
+ | [] -> Ok (List.rev acc)
+ | p :: rest -> ( match load_overlay p with Error e -> Error e | Ok o -> load_all (o :: acc) rest)
+ in
match Colitur_kernel.Layer.load Rite_ef.Vocab_ef.rank_of_sexp sanctoral_path with
| Error e -> Error (Printf.sprintf "failed to load %s: %s" sanctoral_path e)
| Ok layer -> (
- match Colitur_kernel.Overlay.load Rite_ef.Vocab_ef.rank_of_sexp adjustments_path with
- | Error e -> Error (Printf.sprintf "failed to load %s: %s" adjustments_path e)
- | Ok overlay ->
- let layer, diagnostics = Colitur_kernel.Overlay.apply layer overlay in
+ match load_all [] (adjustments_path :: user_overlays) with
+ | Error e -> Error e
+ | Ok overlays ->
+ let layer, diagnostics = Colitur_kernel.Overlay.merge layer overlays in
List.iter
(fun d -> Printf.eprintf "colitur: %s\n" (Colitur_kernel.Overlay.diagnostic_to_string d))
diagnostics;
@@ -271,8 +294,8 @@ let readings_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_
block. Every loader already returns [(_, string) result] (never raises,
never reads at module-initialisation time -- see [load_ef_lectionary]),
so chaining them costs nothing and keeps that promise intact. *)
-let load_ef_data () =
- match load_ef_layer () with
+let load_ef_data ?(user_overlays = []) () =
+ match load_ef_layer ~user_overlays () with
| Error msg -> Error msg
| Ok layer -> (
match load_ef_lectionary () with
@@ -287,8 +310,8 @@ let load_ef_data () =
indexing below (with its own reasoning about civil-vs-liturgical spans) is
exactly the part that must not be duplicated and drift. [line] is the only
difference between the two commands. *)
-let resolved_year_report ~line y =
- match load_ef_data () with
+let resolved_year_report ~line ~overlays y =
+ match load_ef_data ~user_overlays:overlays () with
| Error msg ->
Printf.eprintf "colitur: %s\n" msg;
exit 2
@@ -327,8 +350,8 @@ let resolved_year_report ~line y =
d := D.add_days !d 1
done
-let day_report y = resolved_year_report ~line:day_line y
-let readings_report y = resolved_year_report ~line:readings_line y
+let day_report ~overlays y = resolved_year_report ~line:day_line ~overlays y
+let readings_report ~overlays y = resolved_year_report ~line:readings_line ~overlays y
(* Help and usage are deliberately DIFFERENT things, and the difference is the
Unix convention rather than a preference: asking for help is a request that
@@ -355,6 +378,7 @@ usage:
colitur temporal <year> the temporal cycle, one line per day
colitur day <year> the resolved day identity, one line per day
colitur readings <year> the Mass reading citations, one line per day
+ colitur day|readings <year> --overlay FILE [--overlay FILE ...]
colitur -h, --help this help
colitur -V, --version print the version and exit
@@ -371,6 +395,20 @@ output formats:
day stays space-separated; that is why they are separate commands rather
than extra columns.
+overlays:
+ --overlay FILE (repeatable, ordered; -o) applies a user calendar ON TOP of
+ the shipped universal one, never instead of it, so local feasts
+ add to it rather than replacing it. Later files win over earlier
+ ones, and over the universal calendar, when they name the same
+ slug. Accepted on `day` and `readings` only -- the other commands
+ read no sanctoral data, so the flag is refused there rather than
+ silently ignored.
+
+ An overlay is applied, NOT validated: colitur's test layers assert
+ things about the shipped calendar and cannot vouch for a file you
+ supply. A directive naming a slug that does not exist warns on
+ stderr and the run continues; a file that fails to load is fatal.
+
environment:
COLITUR_DATA_DIR
Read the calendar data from this directory instead of the
@@ -403,14 +441,63 @@ let with_year ys f =
exit 2
| None -> usage ()
+(* Flags are stripped first, then the remaining words are matched as
+ command + year. The alternative -- extending the exact-array patterns
+ below -- does not survive a REPEATABLE flag: [--overlay a --overlay b] is a
+ different array shape from [--overlay a], and every additional flag would
+ multiply the patterns again. Hand-rolled because the dependency list is
+ frozen and this is fifteen lines.
+
+ [--overlay] accumulates in the order given, and that order is load-bearing
+ ({!Overlay.merge} is last-writer-wins), so the list is reversed exactly
+ once at the end rather than callers guessing. *)
+let parse_args argv =
+ let rec go overlays positional = function
+ | [] -> Ok (List.rev overlays, List.rev positional)
+ | ("--overlay" | "-o") :: path :: rest -> go (path :: overlays) positional rest
+ | [ ("--overlay" | "-o") ] -> Error "--overlay needs a file path"
+ (* The recognised bare flags pass through as positional words for the
+ dispatch below to match; anything else beginning with '-' is rejected
+ rather than silently treated as a command or a year. *)
+ | arg :: _
+ when String.length arg > 1
+ && arg.[0] = '-'
+ && not (List.mem arg [ "-h"; "--help"; "-V"; "--version" ]) ->
+ Error (Printf.sprintf "unknown option %s" arg)
+ | arg :: rest -> go overlays (arg :: positional) rest
+ in
+ go [] [] argv
+
+(* [easter] reads no calendar data at all, and [temporal] deliberately runs the
+ temporal cycle BEFORE any sanctoral layer exists, so an overlay could not
+ affect either. Accepting the flag there and silently ignoring it is the
+ failure mode this project refuses everywhere else -- it is an error. *)
+let reject_overlays_for cmd overlays =
+ if overlays <> [] then begin
+ Printf.eprintf "colitur: --overlay has no effect on `%s` (it reads no sanctoral data); refusing rather than ignoring it\n" cmd;
+ exit 2
+ end
+
let () =
- match Sys.argv with
- | [| _; ("-h" | "--help" | "help") |] -> print_help ()
- | [| _; ("-V" | "--version" | "version") |] ->
- print_endline version;
- exit 0
- | [| _; "easter"; ys |] -> with_year ys easter_report
- | [| _; "temporal"; ys |] -> with_year ys temporal_report
- | [| _; "day"; ys |] -> with_year ys day_report
- | [| _; "readings"; ys |] -> with_year ys readings_report
- | _ -> usage ()
+ match parse_args (List.tl (Array.to_list Sys.argv)) with
+ | Error msg ->
+ Printf.eprintf "colitur: %s\n" msg;
+ usage ()
+ | Ok (overlays, positional) -> (
+ match positional with
+ | [ ("-h" | "--help" | "help") ] ->
+ reject_overlays_for "--help" overlays;
+ print_help ()
+ | [ ("-V" | "--version" | "version") ] ->
+ reject_overlays_for "--version" overlays;
+ print_endline version;
+ exit 0
+ | [ "easter"; ys ] ->
+ reject_overlays_for "easter" overlays;
+ with_year ys easter_report
+ | [ "temporal"; ys ] ->
+ reject_overlays_for "temporal" overlays;
+ with_year ys temporal_report
+ | [ "day"; ys ] -> with_year ys (day_report ~overlays)
+ | [ "readings"; ys ] -> with_year ys (readings_report ~overlays)
+ | _ -> usage ())
diff --git a/man/colitur.1 b/man/colitur.1
index c65cc58..a18b299 100644
--- a/man/colitur.1
+++ b/man/colitur.1
@@ -7,6 +7,12 @@ colitur \- deterministic liturgical calendar and lectionary engine (Roman rite,
.I YEAR
.br
.B colitur
+.BR day | readings
+.I YEAR
+.BI \-\-overlay " FILE"
+.RI [ ... ]
+.br
+.B colitur
.BR \-h | \-\-help
.SH DESCRIPTION
.B colitur
@@ -59,8 +65,20 @@ occurrence, commemoration and transfer.
.BI readings " YEAR"
The Mass reading citations, one line per day.
.TP
+.BI \-\-overlay " FILE"
+Apply a user calendar on top of the shipped one. Repeatable and ordered;
+.B day
+and
+.B readings
+only. See
+.B OVERLAYS
+below.
+.TP
.BR \-h ", " \-\-help
Print a usage summary to standard output and exit 0.
+.TP
+.BR \-V ", " \-\-version
+Print the version and exit 0.
.SH OUTPUT FORMAT
.SS day
.RS
@@ -114,6 +132,55 @@ Both reports are one line per day and ordered by date, so they compose with
and
.BR join (1)
in the ordinary way.
+.SH OVERLAYS
+.TP
+.BI \-\-overlay " FILE"
+.RB ( \-o )
+Apply a user\-supplied calendar on top of the shipped universal one. Repeatable
+and ordered.
+.PP
+Overlays are applied
+.I on top of
+the 1962 universal calendar, never instead of it. The shipped adjustments \(em
+which carry the inseparable Peter/Paul commemoration, the Major Litanies, St
+Barbara and Rogation Wednesday \(em are applied first, then each
+.B \-\-overlay
+in the order given. Last writer wins, so a later file may deliberately override
+an earlier one, or a universal entry, by naming its slug.
+.PP
+An overlay is an S\-expression file with an
+.I id
+and a list of directives:
+.BR Add ", " Suppress ", " Replace " and " Edit .
+An added entry carries its own date specification, which may be a fixed
+.RI ( month ", " day )
+pair, an
+.I Easter_offset
+in days, or an
+.I Nth_weekday
+of a month \(em so a patronal feast on "the first Sunday of October" or a
+dedication anniversary reckoned from Easter are both expressible. See
+.I test/fixtures/overlay\-example\-diocesan.sexp
+in the source distribution for a worked example of both shapes.
+.PP
+Accepted on
+.B day
+and
+.B readings
+only. The other commands read no sanctoral data at all, so the flag would have
+no effect there and is
+.I refused
+rather than silently ignored.
+.PP
+.B An overlay is applied, not validated.
+This program's test layers \(em properties over every year in the domain, a
+differential against a sibling engine, two published\-calendar oracles, and
+hand\-verified pins \(em assert things about the
+.I shipped
+calendar. None of them can vouch for a file you supply. A directive naming a
+slug that does not exist prints a warning to standard error and the run
+continues, so a typo in a local calendar is visible rather than silent; a file
+that fails to load is fatal.
.SH ENVIRONMENT
.TP
.B COLITUR_DATA_DIR
@@ -182,6 +249,15 @@ Days carrying at least one commemoration:
.fi
.RE
.PP
+A local calendar on top of the universal one:
+.RS
+.nf
+
+.B colitur day 2026 \-\-overlay ~/calendars/diocese.sexp
+.B colitur day 2026 \-\-overlay ~/calendars/diocese.sexp \-\-overlay ~/calendars/parish.sexp
+.fi
+.RE
+.PP
Run against a checkout's data rather than the installed copy:
.RS
.nf
diff --git a/test/cli.t b/test/cli.t
index 419e7f0..9771c56 100644
--- a/test/cli.t
+++ b/test/cli.t
@@ -259,3 +259,68 @@ tree to read dune-project from. It is asserted in the Makefile's release
target instead, which rewrites both and then requires the freshly built
binary to report the version it just wrote -- the point in time where a drift
between the two could actually be introduced.
+
+A user-supplied overlay is applied ON TOP of the shipped universal calendar,
+never instead of it. That ordering is the point: data/ef/adjustments.sexp
+carries RG 110's own 30 June companion, the Major Litanies, St Barbara and
+Rogation Wednesday, and a user file that replaced it would silently drop all
+four while appearing merely to add a local feast.
+
+A fixed-date local patron, observed on its own day:
+
+ $ colitur day 2026 --overlay fixtures/overlay-example-diocesan.sexp | grep '^2026-07-11 '
+ 2026-07-11 saturday time-after-pentecost 6 example-local-patron class-3 white +pius-i
+
+A MOVABLE one -- the capability the Date_spec work existed to unlock. "The
+first Sunday of October" is the shape a patronal or dedication feast usually
+takes, and it lands on a different civil date every year:
+
+ $ for y in 2026 2027 2028; do colitur day $y --overlay fixtures/overlay-example-diocesan.sexp | grep 'example-dedication'; done
+ 2026-10-04 sunday time-after-pentecost 19 example-dedication class-1 white +ef-time-after-pentecost-sunday-19
+ 2027-10-03 sunday time-after-pentecost 20 example-dedication class-1 white +ef-time-after-pentecost-sunday-20
+ 2028-10-01 sunday time-after-pentecost 17 example-dedication class-1 white +ef-time-after-pentecost-sunday-17
+
+The shipped calendar is untouched without the flag -- the same date, no local
+feast, the ordinary Sunday restored:
+
+ $ colitur day 2026 | grep '^2026-10-04 '
+ 2026-10-04 sunday time-after-pentecost 19 ef-time-after-pentecost-sunday-19 class-2 green
+
+And the shipped overlay's own entries survive alongside the user's, which is
+what "on top of, not instead of" means in practice:
+
+ $ colitur day 2024 --overlay fixtures/overlay-example-diocesan.sexp | grep -c 'rogation-wednesday'
+ 1
+
+An unreadable overlay is fatal, not a warning: a calendar that failed to load
+is not something to carry on past.
+
+ $ colitur day 2026 --overlay /nonexistent/diocese.sexp
+ colitur: failed to load /nonexistent/diocese.sexp: /nonexistent/diocese.sexp: No such file or directory
+ [2]
+
+--overlay is refused on the commands it could not affect, rather than accepted
+and silently ignored. `easter` reads no calendar data at all, and `temporal`
+runs the temporal cycle before any sanctoral layer exists:
+
+ $ colitur easter 2026 --overlay fixtures/overlay-example-diocesan.sexp
+ colitur: --overlay has no effect on `easter` (it reads no sanctoral data); refusing rather than ignoring it
+ [2]
+
+ $ colitur temporal 2026 --overlay fixtures/overlay-example-diocesan.sexp
+ colitur: --overlay has no effect on `temporal` (it reads no sanctoral data); refusing rather than ignoring it
+ [2]
+
+A flag needing a value, given none:
+
+ $ colitur day 2026 --overlay
+ colitur: --overlay needs a file path
+ colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> (try: colitur --help)
+ [2]
+
+An unknown option is rejected rather than treated as a positional word:
+
+ $ colitur day 2026 --diocese
+ colitur: unknown option --diocese
+ colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> (try: colitur --help)
+ [2]
diff --git a/test/dune b/test/dune
index 0851e34..502c10e 100644
--- a/test/dune
+++ b/test/dune
@@ -17,6 +17,7 @@
(cram
(deps
%{bin:colitur}
+ fixtures/overlay-example-diocesan.sexp
../data/ef/sanctoral.sexp
../data/ef/adjustments.sexp
../data/ef/lectionary.sexp
diff --git a/test/fixtures/overlay-example-diocesan.sexp b/test/fixtures/overlay-example-diocesan.sexp
new file mode 100644
index 0000000..e66b67e
--- /dev/null
+++ b/test/fixtures/overlay-example-diocesan.sexp
@@ -0,0 +1,45 @@
+; A worked example of a user-supplied overlay: what a diocese, religious
+; house or personal parish would hand to `colitur --overlay`.
+;
+; NOT calendar data. This is a TEST FIXTURE and a documentation example, and
+; the entries in it are invented -- deliberately so, with slugs that could not
+; collide with anything real. colitur's five validation layers assert things
+; about the SHIPPED universal calendar; none of them can vouch for a file a
+; user supplies, and this one is not claiming otherwise.
+;
+; It exercises the two things a real local calendar needs and the universal
+; data cannot demonstrate on its own:
+;
+; 1. a FIXED-date local feast (the common case), and
+; 2. a MOVABLE one, via the Date_spec variants added 2026-08-17 -- the
+; capability the whole exercise existed to unlock. "The first Sunday of
+; October" is the shape a patronal or dedication feast usually takes and
+; was simply inexpressible before.
+;
+; Overlays apply AFTER the shipped data/ef/adjustments.sexp, in the order
+; given on the command line, last writer wins.
+((id example-diocesan)
+ (directives
+ ; A fixed-date local patron, III class, as an ordinary diocesan proper.
+ ((Add
+ ((date (Fixed (month 7) (day 11)))
+ (cel
+ ((slug example-local-patron)
+ (names ((la "Sancti Exempli Patroni") (en "St Example, Patron")))
+ (rank Class3) (status Feast) (colour White) (subject Saint)
+ (citations ()) (layer example-diocesan)))))
+ ; A MOVABLE local feast: the first Sunday of October. Before
+ ; Date_spec.Nth_weekday this could not be written at all.
+ (Add
+ ((date (Nth_weekday (month 10) (nth 1) (weekday Sun)))
+ (cel
+ ((slug example-dedication)
+ (names ((la "Dedicatio Ecclesiae Exempli") (en "Dedication of the Example Church")))
+ ; I class, and not for effect: the anniversary of a church's own
+ ; dedication is a I-class feast IN THAT CHURCH (RG 91 entry 4,
+ ; "Festum Dedicationis... ecclesiae propriae"). At III class it
+ ; would lose to the II-class Sunday it lands on every year, which
+ ; is what a first draft of this fixture demonstrated -- correctly,
+ ; but uselessly as an example.
+ (rank Class1) (status Feast) (colour White) (subject Saint)
+ (citations ()) (layer example-diocesan))))))))