aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-18 13:59:26 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-18 13:59:26 +0200
commit266b10d6cd2d44b4bccc36318054408ded80b8ce (patch)
treefa7e5bdd618902fd1bcfc34318395a181f531254
parent150f8c550d2f9ebbac61d195eb342eb2b2ec6b95 (diff)
parent36daf47dde9b0c16dacef31163ea74effdd5e7f3 (diff)
downloadcolitur-266b10d6cd2d44b4bccc36318054408ded80b8ce.tar.gz
colitur-266b10d6cd2d44b4bccc36318054408ded80b8ce.zip
merge: a flat INI overlay front end
A convenience format for simple local calendars, transpiled to the existing S-expression form and verified against it before emitting.
-rw-r--r--bin/main.ml38
-rw-r--r--lib/kernel/overlay_ini.ml294
-rw-r--r--lib/kernel/overlay_ini.mli69
-rw-r--r--man/colitur-overlay.592
-rw-r--r--man/colitur.19
-rw-r--r--test/cli.t19
-rw-r--r--test/test_colitur.ml2
-rw-r--r--test/test_overlay_ini.ml152
8 files changed, 674 insertions, 1 deletions
diff --git a/bin/main.ml b/bin/main.ml
index b997cbe..9b7c8a7 100644
--- a/bin/main.ml
+++ b/bin/main.ml
@@ -389,6 +389,7 @@ usage:
colitur readings <year> the Mass reading citations, one line per day
colitur day|readings <year> --overlay FILE [--overlay FILE ...]
colitur new-overlay print a starter overlay file to stdout
+ colitur convert FILE.ini flat INI overlay -> S-expression, on stdout
colitur check FILE ... load an overlay, say what it does, exit 2 if not
colitur -h, --help this help
colitur -V, --version print the version and exit
@@ -432,6 +433,10 @@ overlays:
Makefile or a pre-commit hook. It does not check a calendar
against the rubrics -- nothing here can.
+ A flatter INI form exists for simple calendars, converted with
+ `colitur convert`, which verifies its own output before emitting
+ it. See colitur-overlay(5) for both forms.
+
In an added celebration, `citations` and `layer` may be omitted:
they default to empty and to the overlay's own id. Dates may be
(Fixed (month M) (day D)), (Easter_offset N) signed, or
@@ -582,6 +587,36 @@ let check_report paths =
paths;
exit (if !ok then 0 else 2)
+(* `colitur convert FILE.ini` -- the flat INI form to the S-expression one,
+ on stdout for redirection.
+
+ A separate step rather than teaching --overlay to sniff the extension, and
+ deliberately so: the user gets to SEE what their INI became. When a date
+ form was mistyped or an edit silently dropped, "what did the engine
+ actually get" is the question, and an invisible transpile cannot answer it.
+
+ {!Overlay_ini.convert} verifies its own output before returning it -- see
+ that function's own comment. Nothing is written if the round trip fails. *)
+let convert_report path =
+ match
+ (try Ok (In_channel.with_open_text path In_channel.input_all)
+ with Sys_error e -> Error e)
+ with
+ | Error e ->
+ Printf.eprintf "colitur: %s\n" e;
+ exit 2
+ | Ok text -> (
+ match
+ Colitur_kernel.Overlay_ini.convert ~rank_of_string:Rite_ef.Vocab_ef.rank_of_string
+ ~rank_to_sexp:Rite_ef.Vocab_ef.sexp_of_rank ~rank_of_sexp:Rite_ef.Vocab_ef.rank_of_sexp text
+ with
+ | Error e ->
+ Printf.eprintf "colitur: %s: %s\n" path e;
+ exit 2
+ | Ok sexp ->
+ print_string sexp;
+ exit 0)
+
(* `colitur new-overlay` -- a starter file on stdout, for redirection.
Deliberately printed rather than written: the user picks the path, and a
command that creates files where it likes is a worse citizen. Every value is
@@ -660,6 +695,9 @@ let () =
| "check" :: (_ :: _ as files) ->
reject_overlays_for "check" overlays;
check_report files
+ | [ "convert"; path ] ->
+ reject_overlays_for "convert" overlays;
+ convert_report path
| [ "new-overlay" ] ->
reject_overlays_for "new-overlay" overlays;
print_string new_overlay_template;
diff --git a/lib/kernel/overlay_ini.ml b/lib/kernel/overlay_ini.ml
new file mode 100644
index 0000000..6b6249f
--- /dev/null
+++ b/lib/kernel/overlay_ini.ml
@@ -0,0 +1,294 @@
+open Sexplib0.Sexp_conv
+
+type section = { name : string; fields : (string * string) list }
+
+let err fmt = Printf.ksprintf (fun s -> Error s) fmt
+
+(* A minimal INI reader: [section] headers, [key = value] lines, ';' and '#'
+ comments, blank lines ignored. Values keep interior spaces and are trimmed
+ at both ends. Deliberately not a general INI implementation -- no
+ continuations, no quoting, no repeated-key semantics -- because every
+ feature here is one a user can trip over, and the format's whole purpose is
+ to be unsurprising. *)
+let parse_sections text =
+ let lines = String.split_on_char '\n' text in
+ let sections = ref [] and cur = ref None and fields = ref [] in
+ let flush () =
+ match !cur with
+ | Some n -> sections := { name = n; fields = List.rev !fields } :: !sections
+ | None -> ()
+ in
+ let rec go n = function
+ | [] ->
+ flush ();
+ Ok (List.rev !sections)
+ | raw :: rest -> (
+ let line = String.trim raw in
+ let n = n + 1 in
+ if line = "" || line.[0] = ';' || line.[0] = '#' then go n rest
+ else if line.[0] = '[' then
+ if line.[String.length line - 1] <> ']' then
+ err "line %d: %S looks like a section header but does not end with ']'" n line
+ else begin
+ flush ();
+ cur := Some (String.sub line 1 (String.length line - 2));
+ fields := [];
+ go n rest
+ end
+ else
+ match String.index_opt line '=' with
+ | None -> err "line %d: %S is neither a [section] nor a key = value line" n line
+ | Some i ->
+ if !cur = None then
+ err "line %d: %S appears before any [section] header" n line
+ else begin
+ let k = String.trim (String.sub line 0 i) in
+ let v = String.trim (String.sub line (i + 1) (String.length line - i - 1)) in
+ fields := (k, v) :: !fields;
+ go n rest
+ end)
+ in
+ go 0 lines
+
+let get sec k = List.assoc_opt k sec.fields
+let is_yes v = List.mem (String.lowercase_ascii v) [ "yes"; "true"; "1" ]
+
+let months =
+ [ ("jan", 1); ("feb", 2); ("mar", 3); ("apr", 4); ("may", 5); ("jun", 6);
+ ("jul", 7); ("aug", 8); ("sep", 9); ("oct", 10); ("nov", 11); ("dec", 12) ]
+
+let weekdays =
+ [ ("sun", Date.Sun); ("mon", Date.Mon); ("tue", Date.Tue); ("wed", Date.Wed);
+ ("thu", Date.Thu); ("fri", Date.Fri); ("sat", Date.Sat) ]
+
+(* The three {!Date_spec} shapes, flattened. Every failure names the section
+ and shows the three forms, because a wrong date is the commonest mistake
+ and "invalid date" alone does not tell anyone what to type. *)
+let parse_date ~section raw =
+ let bad () =
+ err
+ "section [%s]: date %S is not one of the three forms: MM-DD (a civil \
+ date), easter+N or easter-N (signed days from Easter), or mon/day/nth \
+ such as oct/sun/1 or oct/sun/-1 (nth may be negative to count from the \
+ end of the month)"
+ section raw
+ in
+ let low = String.lowercase_ascii raw in
+ let starts p = String.length low >= String.length p && String.sub low 0 (String.length p) = p in
+ if starts "easter" then
+ let tail = String.sub raw 6 (String.length raw - 6) in
+ match int_of_string_opt (String.trim tail) with
+ | Some n -> (
+ match Date_spec.easter_offset n with Ok d -> Ok d | Error e -> err "section [%s]: %s" section e)
+ | None -> bad ()
+ else
+ match String.split_on_char '/' low with
+ | [ m; w; n ] -> (
+ match (List.assoc_opt m months, List.assoc_opt w weekdays, int_of_string_opt n) with
+ | Some month, Some weekday, Some nth -> (
+ match Date_spec.nth_weekday ~month ~nth ~weekday with
+ | Ok d -> Ok d
+ | Error e -> err "section [%s]: %s" section e)
+ | _ -> bad ())
+ | _ -> (
+ match String.split_on_char '-' low with
+ | [ mm; dd ] -> (
+ match (int_of_string_opt mm, int_of_string_opt dd) with
+ | Some month, Some day -> (
+ match Date_spec.fixed ~month ~day with
+ | Ok d -> Ok d
+ | Error e -> err "section [%s]: %s" section e)
+ | _ -> bad ())
+ | _ -> bad ())
+
+let ( let* ) r f = match r with Ok v -> f v | Error e -> Error e
+
+let parse_names ~section sec =
+ let entries =
+ List.filter_map
+ (fun (k, v) ->
+ if String.length k > 5 && String.sub k 0 5 = "name." then
+ Some (String.sub k 5 (String.length k - 5), v)
+ else None)
+ sec.fields
+ in
+ match entries with
+ | [] -> err "section [%s]: at least one name.<lang> is required (e.g. name.en)" section
+ | _ ->
+ List.fold_left
+ (fun acc (l, v) ->
+ let* names = acc in
+ match Lang.of_string l with
+ | Error e -> err "section [%s]: name.%s: %s" section l e
+ | Ok lang -> Ok (Names.set names lang v))
+ (Ok Names.empty) entries
+
+let celebration ~section ~id ~rank_of_string sec =
+ let* slug =
+ match Slug.of_string section with
+ | Ok s -> Ok s
+ | Error e -> err "section [%s]: %s" section e
+ in
+ let* names = parse_names ~section sec in
+ let* rank =
+ match get sec "rank" with
+ | None -> err "section [%s]: rank is required (class-1, class-2, class-3 or class-4)" section
+ | Some r -> (
+ match rank_of_string r with
+ | Some r -> Ok r
+ | None -> err "section [%s]: unknown rank %S -- expected class-1..class-4" section r)
+ in
+ let* colour =
+ match get sec "colour" with
+ | None -> err "section [%s]: colour is required (white, red, violet, green, black, rose)" section
+ | Some c -> (
+ match Colour.of_string (String.lowercase_ascii c) with
+ | Some c -> Ok c
+ | None ->
+ err "section [%s]: unknown colour %S -- expected white, red, violet, green, black or rose"
+ section c)
+ in
+ let* status =
+ match get sec "status" with
+ | None | Some "feast" -> Ok Celebration.Feast
+ | Some "commemoration" -> Ok Celebration.Commemoration_only
+ | Some s -> err "section [%s]: unknown status %S -- expected feast or commemoration" section s
+ in
+ let* subject =
+ match Option.map String.lowercase_ascii (get sec "subject") with
+ | None | Some "saint" -> Ok Subject.Saint
+ | Some "lord" -> Ok Subject.Lord
+ | Some "bvm" -> Ok Subject.Bvm
+ | Some "temporal" -> Ok Subject.Temporal
+ | Some s -> err "section [%s]: unknown subject %S -- expected lord, bvm, saint or temporal" section s
+ in
+ Ok (Celebration.make ~slug ~names ~rank ~status ~colour ~subject ~citations:[] ~layer:id ())
+
+(* Field edits the flat form can express. Citation edits and Remove_name are
+ deliberately absent: both need a structured key this format has no shape
+ for, and a half-expressible edit is worse than one the parser refuses by
+ name. *)
+let edits ~section ~rank_of_string sec =
+ List.fold_left
+ (fun acc (k, v) ->
+ let* es = acc in
+ match k with
+ | "edit" -> Ok es
+ | "rank" -> (
+ match rank_of_string v with
+ | Some r -> Ok (Overlay.Set_rank r :: es)
+ | None -> err "section [%s]: unknown rank %S" section v)
+ | "colour" -> (
+ match Colour.of_string (String.lowercase_ascii v) with
+ | Some c -> Ok (Overlay.Set_colour c :: es)
+ | None -> err "section [%s]: unknown colour %S" section v)
+ | "subject" -> (
+ match String.lowercase_ascii v with
+ | "lord" -> Ok (Overlay.Set_subject Subject.Lord :: es)
+ | "bvm" -> Ok (Overlay.Set_subject Subject.Bvm :: es)
+ | "saint" -> Ok (Overlay.Set_subject Subject.Saint :: es)
+ | "temporal" -> Ok (Overlay.Set_subject Subject.Temporal :: es)
+ | _ -> err "section [%s]: unknown subject %S" section v)
+ | k when String.length k > 5 && String.sub k 0 5 = "name." -> (
+ let l = String.sub k 5 (String.length k - 5) in
+ match Lang.of_string l with
+ | Ok lang -> Ok (Overlay.Set_name (lang, v) :: es)
+ | Error e -> err "section [%s]: name.%s: %s" section l e)
+ | _ ->
+ err
+ "section [%s]: %S cannot be edited from an INI overlay -- this form \
+ expresses rank, colour, subject and name.<lang> only. Write the \
+ S-expression form for anything else (see colitur-overlay(5))."
+ section k)
+ (Ok []) sec.fields
+ |> Result.map List.rev
+
+let parse ~rank_of_string text =
+ let* sections = parse_sections text in
+ let header, entries = List.partition (fun s -> s.name = "overlay") sections in
+ let* id =
+ match header with
+ | [ h ] -> (
+ match get h "id" with
+ | Some id when String.trim id <> "" -> Ok id
+ | _ -> err "the [overlay] section needs an id (e.g. id = my-parish)")
+ | [] -> err "no [overlay] section: the file must open with one, carrying id = <name>"
+ | _ -> err "more than one [overlay] section"
+ in
+ let* directives =
+ List.fold_left
+ (fun acc sec ->
+ let* ds = acc in
+ let section = sec.name in
+ match (get sec "suppress", get sec "replace", get sec "edit") with
+ | Some v, _, _ when is_yes v -> (
+ match Slug.of_string section with
+ | Ok s -> Ok (Overlay.Suppress s :: ds)
+ | Error e -> err "section [%s]: %s" section e)
+ | _, Some v, _ when is_yes v ->
+ err
+ "section [%s]: replace is not expressible in the INI form -- it \
+ needs a whole entry, which is what the S-expression form is for \
+ (see colitur-overlay(5)). Suppress plus a fresh section is \
+ usually what you want instead."
+ section
+ | _, _, Some v when is_yes v -> (
+ let* es = edits ~section ~rank_of_string sec in
+ match Slug.of_string section with
+ | Ok s when es <> [] -> Ok (Overlay.Edit (s, es) :: ds)
+ | Ok _ -> err "section [%s]: edit = yes but no editable field given" section
+ | Error e -> err "section [%s]: %s" section e)
+ | _ ->
+ let* date =
+ match get sec "date" with
+ | None -> err "section [%s]: date is required" section
+ | Some d -> parse_date ~section d
+ in
+ let* cel = celebration ~section ~id ~rank_of_string sec in
+ Ok (Overlay.Add { Layer.date; cel } :: ds))
+ (Ok []) entries
+ |> Result.map List.rev
+ in
+ Ok { Overlay.id; directives }
+
+let to_sexp_string rank_to_sexp t =
+ Printf.sprintf
+ ";; GENERATED by `colitur convert` from an INI overlay -- edit the INI and\n\
+ ;; regenerate, or adopt this file and drop the INI, but do not maintain\n\
+ ;; both. The conversion verified that this file parses back to exactly\n\
+ ;; what the INI denoted.\n\
+ %s\n"
+ (Sexplib0.Sexp.to_string_hum (Overlay.sexp_of_t rank_to_sexp t))
+
+let convert ~rank_of_string ~rank_to_sexp ~rank_of_sexp text =
+ let* t = parse ~rank_of_string text in
+ let rendered = to_sexp_string rank_to_sexp t in
+ (* The self-check this module exists for. Parse the emitted text back with
+ the SAME function the engine uses, and require the result to equal what
+ the INI denoted. A transpiler emitting valid-but-wrong sexp is the failure
+ a convenience format invites, and `colitur check` could never catch it:
+ the output would parse cleanly and simply mean something else. *)
+ (* Parse the ACTUAL text about to be returned -- not a fresh serialisation of
+ [t], which would make this check vacuous: [t] round-tripping through
+ [sexp_of_t]/[t_of_sexp] is true by construction and proves nothing about
+ [rendered]. The first version of this function did exactly that, and a
+ mutation corrupting the renderer (emitting a different overlay id) sailed
+ straight through it and exited 0. Two tests in test_overlay_ini.ml catch
+ that mutation now; they did not before, because they too must parse the
+ RETURNED text rather than re-derive it. *)
+ match Sexplib.Sexp.of_string rendered with
+ | exception exn ->
+ err "internal: generated sexp does not re-parse (%s) -- this is a bug in colitur, not in your file"
+ (Printexc.to_string exn)
+ | sexp -> (
+ match Overlay.t_of_sexp rank_of_sexp sexp with
+ | exception exn ->
+ err
+ "internal: generated sexp does not load (%s) -- this is a bug in colitur, not in your file"
+ (Printexc.to_string exn)
+ | back ->
+ if back = t then Ok rendered
+ else
+ err
+ "internal: the generated sexp does not mean what the INI said -- \
+ this is a bug in colitur, not in your file. Nothing was written.")
diff --git a/lib/kernel/overlay_ini.mli b/lib/kernel/overlay_ini.mli
new file mode 100644
index 0000000..bace93b
--- /dev/null
+++ b/lib/kernel/overlay_ini.mli
@@ -0,0 +1,69 @@
+(** A flat INI front end for overlay files.
+
+ This is a CONVENIENCE FORMAT, not a second data model. It parses to exactly
+ the {!Overlay.t} the S-expression form parses to, and everything downstream
+ -- merge, diagnostics, validation -- is the same code on the same values.
+ There is deliberately no second semantics to keep in step.
+
+ It is also deliberately LESS EXPRESSIVE than the sexp form. It covers [Add],
+ [Suppress] and single-field [Edit], which is what a diocesan or parish
+ calendar needs; [Replace], multi-field edits and citation edits are not
+ expressible and the parser says so by name rather than failing obscurely.
+ Anything it cannot say is a reason to write sexp, not a reason to grow this.
+
+ {1 Format}
+
+ Section names are slugs. A [\[overlay\]] section carries the file's id.
+
+ {v
+ [overlay]
+ id = my-parish
+
+ [our-patron]
+ date = 07-11
+ rank = class-3
+ colour = white
+ name.en = St Example, Patron
+
+ [some-universal-slug]
+ suppress = yes
+ v}
+
+ Dates take three forms, matching {!Date_spec}: [MM-DD], [easter+N] or
+ [easter-N], and [mon/day/nth] such as [oct/sun/1] or [oct/sun/-1]. *)
+
+(** [parse ~rank_of_string text] is the overlay [text] denotes.
+
+ [rank_of_string] is supplied by the rite, exactly as [Overlay.load] takes
+ [rank_of_sexp]: the kernel does not know one rite's rank vocabulary from
+ another's.
+
+ Errors carry the section name and the offending value, never a source-file
+ path -- this format exists for people who are not reading the source. *)
+val parse :
+ rank_of_string:(string -> 'r option) -> string -> ('r Overlay.t, string) result
+
+(** [to_sexp_string t] renders [t] as the S-expression form, with a header
+ noting that it was generated. *)
+val to_sexp_string : ('r -> Sexplib0.Sexp.t) -> 'r Overlay.t -> string
+
+(** [convert ~rank_of_string ~rank_to_sexp ~rank_of_sexp text] parses [text],
+ renders it, and PROVES the rendering before returning it: the emitted text
+ is parsed back with the very function the engine uses to load an overlay,
+ and the result must equal what the INI denoted.
+
+ That check is the point of this module. A transpiler that emits
+ syntactically valid but semantically wrong output is the failure mode a
+ convenience format invites, and it is one [colitur check] could not catch,
+ since the emitted file would parse cleanly and simply mean something else.
+ Verifying the round trip here makes that class of bug impossible to ship
+ rather than merely unlikely.
+
+ [Error] on a parse failure, and on a round-trip mismatch -- which is a bug
+ in this module, and says so. *)
+val convert :
+ rank_of_string:(string -> 'r option) ->
+ rank_to_sexp:('r -> Sexplib0.Sexp.t) ->
+ rank_of_sexp:(Sexplib0.Sexp.t -> 'r) ->
+ string ->
+ (string, string) result
diff --git a/man/colitur-overlay.5 b/man/colitur-overlay.5
index 00a84a7..a2d2dae 100644
--- a/man/colitur-overlay.5
+++ b/man/colitur-overlay.5
@@ -251,6 +251,98 @@ accordingly. A local feast that never appears in output has usually lost that
contest rather than failed to load \(em
.B colitur check
will confirm it loaded.
+.SH THE FLAT INI FORM
+For a calendar that only adds a few local feasts, drops one or two universal
+entries, and recolours nothing complicated, there is a flatter form converted
+by
+.BR "colitur convert" .
+Section names are slugs; a
+.RB [ overlay ]
+section carries the id.
+.RS
+.nf
+
+[overlay]
+id = my\-parish
+
+[our\-patron]
+date = 07\-11
+rank = class\-3
+colour = white
+name.en = St Example, Patron
+
+[our\-dedication]
+date = oct/sun/1
+rank = class\-1
+colour = white
+name.en = Dedication of Our Church
+
+[stanislaus]
+edit = yes
+colour = red
+
+[barbara]
+suppress = yes
+.fi
+.RE
+.PP
+.I status
+defaults to
+.BR feast ,
+.I subject
+to
+.BR saint ,
+and
+.I layer
+to the file's id, so the common case \(em an ordinary local saint's feast \(em
+says only what distinguishes it. Dates take the three forms
+.IR MM\-DD ,
+.IB easter + N
+or
+.IB easter - N
+, and
+.IB mon / day / nth
+such as
+.B oct/sun/1
+or
+.B oct/sun/\-1
+for the last.
+.PP
+.B This form is deliberately less expressive.
+It covers
+.BR Add ", " Suppress
+and single\-field
+.BR Edit .
+.B Replace
+, multi\-field edits, citation edits and
+.B Remove_name
+are not expressible, and the converter refuses them
+.I by name
+rather than dropping them silently. Anything it cannot say is a reason to
+write the S\-expression form, not a reason to grow this one.
+.PP
+.B The conversion verifies its own output.
+The generated text is parsed back with the same function that loads an
+overlay, and must equal what the INI denoted; nothing is written if it does
+not. This matters because a transpiler emitting
+.I valid but wrong
+S\-expressions is the failure a convenience format invites, and
+.B colitur check
+could never catch it \(em the output would parse cleanly and simply mean
+something else.
+.RS
+.nf
+
+.B colitur convert my\-parish.ini > my\-parish.sexp
+.B colitur check my\-parish.sexp
+.fi
+.RE
+.PP
+The conversion is a separate step rather than something
+.B \-\-overlay
+does invisibly, so you can read what your INI became. When a date form was
+mistyped, "what did the engine actually get" is the question, and an invisible
+transpile cannot answer it.
.SH SEE ALSO
.BR colitur (1)
.SH LICENSE
diff --git a/man/colitur.1 b/man/colitur.1
index 6463415..58e46ff 100644
--- a/man/colitur.1
+++ b/man/colitur.1
@@ -65,6 +65,15 @@ occurrence, commemoration and transfer.
.BI readings " YEAR"
The Mass reading citations, one line per day.
.TP
+.BI convert " FILE" .ini
+Convert a flat INI overlay to the S\-expression form, on standard output. The
+conversion verifies its own output before emitting it: the generated text is
+parsed back with the same function that loads an overlay, and must mean
+exactly what the INI said. A separate step rather than teaching
+.B \-\-overlay
+to sniff the extension, so you can see what your INI became. See
+.BR colitur\-overlay (5).
+.TP
.B new\-overlay
Print a starter overlay file to standard output, for redirection. Every value
in it is a placeholder that will appear in
diff --git a/test/cli.t b/test/cli.t
index d735e69..d981d88 100644
--- a/test/cli.t
+++ b/test/cli.t
@@ -373,3 +373,22 @@ to empty and to the overlay's own id:
tiny.sexp: ok -- overlay tiny, 1 directive(s): 1 add, 0 suppress, 0 replace, 0 edit
every directive found its target
add tiny-feast
+
+`convert` turns the flat INI form into the S-expression one and verifies its
+own output before emitting it. The full pipeline, INI to a resolved day:
+
+ $ printf '[overlay]\nid = my-parish\n[our-patron]\ndate = 07-11\nrank = class-3\ncolour = white\nname.en = St Example\n' > p.ini
+ $ colitur convert p.ini > p.sexp
+ $ colitur check p.sexp
+ p.sexp: ok -- overlay my-parish, 1 directive(s): 1 add, 0 suppress, 0 replace, 0 edit
+ every directive found its target
+ add our-patron
+ $ colitur day 2026 --overlay p.sexp | grep '^2026-07-11'
+ 2026-07-11 saturday time-after-pentecost 6 our-patron class-3 white +pius-i
+
+What the INI form cannot express is refused by name, not dropped silently:
+
+ $ printf '[overlay]\nid = x\n[y]\nreplace = yes\n' > r.ini
+ $ colitur convert r.ini
+ colitur: r.ini: section [y]: replace is not expressible in the INI form -- it needs a whole entry, which is what the S-expression form is for (see colitur-overlay(5)). Suppress plus a fresh section is usually what you want instead.
+ [2]
diff --git a/test/test_colitur.ml b/test/test_colitur.ml
index 1e6b7ed..76ff685 100644
--- a/test/test_colitur.ml
+++ b/test/test_colitur.ml
@@ -2,7 +2,7 @@
let () =
Alcotest.run "colitur"
[ Test_date.suite; Test_computus.suite; Test_colour.suite; Test_slug.suite; Test_names.suite;
- Test_overlay.suite; Test_temporal_ef.suite; Test_validate.suite; Test_precedence.suite;
+ Test_overlay.suite; Test_overlay_ini.suite; Test_temporal_ef.suite; Test_validate.suite; Test_precedence.suite;
Test_calendar.suite; Test_precedence_ef.suite; Test_sanctoral_ef.suite; Test_rite_ef.suite;
Test_differential.suite; Test_oracle.suite; Test_oracle.suite_2038; Test_oracle.suite_2035; Test_golden.suite;
("lectionary", Test_lectionary.suite);
diff --git a/test/test_overlay_ini.ml b/test/test_overlay_ini.ml
new file mode 100644
index 0000000..65e681a
--- /dev/null
+++ b/test/test_overlay_ini.ml
@@ -0,0 +1,152 @@
+(* The INI overlay front end. Its contract is narrow and the tests follow it:
+ the flat form parses to exactly the Overlay.t the sexp form would, the
+ conversion PROVES that before emitting, and everything it cannot express is
+ refused by name rather than silently dropped. *)
+module OI = Colitur_kernel.Overlay_ini
+module O = Colitur_kernel.Overlay
+module L = Colitur_kernel.Layer
+module Cel = Colitur_kernel.Celebration
+module DS = Colitur_kernel.Date_spec
+module S = Colitur_kernel.Slug
+module Col = Colitur_kernel.Colour
+module Sub = Colitur_kernel.Subject
+module V = Rite_ef.Vocab_ef
+
+let parse = OI.parse ~rank_of_string:V.rank_of_string
+
+let convert =
+ OI.convert ~rank_of_string:V.rank_of_string ~rank_to_sexp:V.sexp_of_rank
+ ~rank_of_sexp:V.rank_of_sexp
+
+let ok_exn = function Ok v -> v | Error e -> Alcotest.failf "expected Ok, got: %s" e
+let err_exn = function Error e -> e | Ok _ -> Alcotest.fail "expected Error, got Ok"
+
+let minimal =
+ {|[overlay]
+id = my-parish
+[our-patron]
+date = 07-11
+rank = class-3
+colour = white
+name.en = St Example|}
+
+let test_minimal () =
+ let t = ok_exn (parse minimal) in
+ Alcotest.(check string) "id" "my-parish" t.O.id;
+ match t.O.directives with
+ | [ O.Add e ] ->
+ Alcotest.(check string) "slug" "our-patron" (S.to_string e.L.cel.Cel.slug);
+ Alcotest.(check bool) "colour" true (e.L.cel.Cel.colour = Col.White);
+ (* status and subject default rather than being required: a local feast
+ is overwhelmingly an ordinary saint's feast, and making the common
+ case silent is the whole point of this format. *)
+ Alcotest.(check bool) "status defaults to Feast" true (e.L.cel.Cel.status = Cel.Feast);
+ Alcotest.(check bool) "subject defaults to Saint" true (e.L.cel.Cel.subject = Sub.Saint);
+ (* layer defaults to the file's own id, exactly as the sexp form does *)
+ Alcotest.(check string) "layer" "my-parish" e.L.cel.Cel.layer
+ | _ -> Alcotest.fail "expected one Add"
+
+(* All three Date_spec shapes, since a flat string has to encode what the sexp
+ form spells out, and getting one wrong would be silent. *)
+let test_date_forms () =
+ let one date =
+ match (ok_exn (parse (Printf.sprintf "[overlay]\nid=x\n[s]\ndate=%s\nrank=class-3\ncolour=white\nname.en=N" date))).O.directives with
+ | [ O.Add e ] -> e.L.date
+ | _ -> Alcotest.fail "expected one Add"
+ in
+ Alcotest.(check bool) "MM-DD" true (one "07-11" = ok_exn (DS.fixed ~month:7 ~day:11));
+ Alcotest.(check bool) "easter+N" true (one "easter+60" = ok_exn (DS.easter_offset 60));
+ Alcotest.(check bool) "easter-N" true (one "easter-46" = ok_exn (DS.easter_offset (-46)));
+ Alcotest.(check bool) "nth weekday" true
+ (one "oct/sun/1" = ok_exn (DS.nth_weekday ~month:10 ~nth:1 ~weekday:Colitur_kernel.Date.Sun));
+ Alcotest.(check bool) "nth weekday, from the end" true
+ (one "oct/sun/-1" = ok_exn (DS.nth_weekday ~month:10 ~nth:(-1) ~weekday:Colitur_kernel.Date.Sun))
+
+let test_suppress_and_edit () =
+ let t = ok_exn (parse "[overlay]\nid=x\n[barbara]\nsuppress=yes\n[stanislaus]\nedit=yes\ncolour=red") in
+ match t.O.directives with
+ | [ O.Suppress s; O.Edit (e, [ O.Set_colour Col.Red ]) ] ->
+ Alcotest.(check string) "suppress" "barbara" (S.to_string s);
+ Alcotest.(check string) "edit" "stanislaus" (S.to_string e)
+ | _ -> Alcotest.fail "expected a Suppress then an Edit"
+
+(* THE contract of this module. convert must never hand back text that means
+ something other than the INI said -- a transpiler emitting valid-but-wrong
+ sexp is the failure a convenience format invites, and `colitur check` could
+ not catch it, since the output would parse cleanly and simply differ. *)
+let test_convert_round_trips () =
+ let text = ok_exn (convert minimal) in
+ let reparsed =
+ O.t_of_sexp V.rank_of_sexp (Sexplib.Sexp.of_string (Sexplib.Sexp.to_string (Sexplib.Sexp.of_string
+ (let i = String.index text '(' in String.sub text i (String.length text - i)))))
+ in
+ Alcotest.(check bool) "emitted sexp means exactly what the INI did" true
+ (reparsed = ok_exn (parse minimal))
+
+(* Every date form, through the full convert path, so the round-trip proof
+ covers the shapes most likely to be mis-rendered rather than just one. *)
+let test_convert_round_trips_every_date_form () =
+ List.iter
+ (fun d ->
+ let src = Printf.sprintf "[overlay]\nid=x\n[s]\ndate=%s\nrank=class-3\ncolour=white\nname.en=N" d in
+ match convert src with
+ | Ok _ -> ()
+ | Error e -> Alcotest.failf "convert failed to verify its own output for date %S: %s" d e)
+ [ "07-11"; "02-29"; "easter+60"; "easter-46"; "easter+0"; "oct/sun/1"; "oct/sun/-1"; "jan/mon/3" ]
+
+let test_errors_are_values_not_exceptions () =
+ let cases =
+ [ ("[s]\ndate=07-11\nrank=class-3\ncolour=white\nname.en=N", "no [overlay] section");
+ ("[overlay]\nid=x\n[s]\nrank=class-3\ncolour=white\nname.en=N", "date is required");
+ ("[overlay]\nid=x\n[s]\ndate=07-11\ncolour=white\nname.en=N", "rank is required");
+ ("[overlay]\nid=x\n[s]\ndate=07-11\nrank=class-3\nname.en=N", "colour is required");
+ ("[overlay]\nid=x\n[s]\ndate=07-11\nrank=class-3\ncolour=white", "name.<lang>");
+ (* A date that matches no form at all falls through to the three-forms
+ message. Note 99-99 does NOT belong here: it parses as MM-DD and gets
+ the more specific "month 99 out of range 1..12", which is the better
+ error and worth not regressing. *)
+ ("[overlay]\nid=x\n[s]\ndate=whenever\nrank=class-3\ncolour=white\nname.en=N", "three forms");
+ ("[overlay]\nid=x\n[s]\ndate=99-99\nrank=class-3\ncolour=white\nname.en=N", "month 99");
+ ("[overlay]\nid=x\n[s]\nreplace=yes", "not expressible") ]
+ in
+ List.iter
+ (fun (src, needle) ->
+ let e = err_exn (parse src) in
+ let contains hay n =
+ let nl = String.length n and hl = String.length hay in
+ let rec at i = i + nl <= hl && (String.sub hay i nl = n || at (i + 1)) in
+ at 0
+ in
+ Alcotest.(check bool)
+ (Printf.sprintf "error mentions %S (got: %s)" needle e)
+ true (contains e needle))
+ cases
+
+(* An INI overlay must reach the engine by exactly the path a hand-written sexp
+ does -- if the two diverged, this format would be a second semantics rather
+ than a front door. *)
+let test_same_result_as_handwritten_sexp () =
+ let from_ini = ok_exn (parse minimal) in
+ let handwritten =
+ {|((id my-parish)
+ (directives
+ ((Add ((date (Fixed (month 7) (day 11)))
+ (cel ((slug our-patron) (names ((en "St Example")))
+ (rank Class3) (status Feast) (colour White)
+ (subject Saint) (citations ()) (layer my-parish))))))))|}
+ in
+ let from_sexp = O.t_of_sexp V.rank_of_sexp (Sexplib.Sexp.of_string handwritten) in
+ Alcotest.(check bool) "identical Overlay.t" true (from_ini = from_sexp)
+
+let suite =
+ ( "Overlay_ini",
+ [ Alcotest.test_case "minimal file, with defaults" `Quick test_minimal;
+ Alcotest.test_case "all three date forms" `Quick test_date_forms;
+ Alcotest.test_case "suppress and edit" `Quick test_suppress_and_edit;
+ Alcotest.test_case "convert verifies its own output" `Quick test_convert_round_trips;
+ Alcotest.test_case "convert verifies every date form" `Quick
+ test_convert_round_trips_every_date_form;
+ Alcotest.test_case "errors are values, never exceptions" `Quick
+ test_errors_are_values_not_exceptions;
+ Alcotest.test_case "an INI overlay equals the hand-written sexp" `Quick
+ test_same_result_as_handwritten_sexp ] )