diff options
| -rw-r--r-- | CLAUDE.md | 18 | ||||
| -rw-r--r-- | bin/main.ml | 129 | ||||
| -rw-r--r-- | man/colitur.1 | 76 | ||||
| -rw-r--r-- | test/cli.t | 65 | ||||
| -rw-r--r-- | test/dune | 1 | ||||
| -rw-r--r-- | test/fixtures/overlay-example-diocesan.sexp | 45 |
6 files changed, 310 insertions, 24 deletions
@@ -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 @@ -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] @@ -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)))))))) |
