summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-18 13:55:18 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-18 13:55:18 +0200
commit36daf47dde9b0c16dacef31163ea74effdd5e7f3 (patch)
treefa7e5bdd618902fd1bcfc34318395a181f531254
parent150f8c550d2f9ebbac61d195eb342eb2b2ec6b95 (diff)
downloadcolitur-36daf47dde9b0c16dacef31163ea74effdd5e7f3.tar.gz
colitur-36daf47dde9b0c16dacef31163ea74effdd5e7f3.zip
feat(overlay): a flat INI front end, which verifies its own output
A convenience format for calendars that add a few local feasts and drop one or two universal entries. Section names are slugs, a [overlay] section carries the id, and status/subject/layer default so the common case -- an ordinary local saint's feast -- says only what distinguishes it. It is a FRONT DOOR, not a second data model. It parses to exactly the Overlay.t the S-expression form parses to, and everything downstream is the same code on the same values; a test asserts an INI overlay and its hand-written sexp equivalent produce identical Overlay.t values. It is also deliberately less expressive -- Add, Suppress and single-field Edit only -- and refuses Replace, multi-field edits and citation edits BY NAME rather than dropping them silently. Anything it cannot say is a reason to write sexp. Little of this is new machinery: tools/bootstrap_sanctoral.ml has parsed INI and mapped it to celebrations since the sanctoral was bootstrapped from lectio. The dates needed extending, since that mapping handled only MM-DD; the flat forms are easter+N/easter-N and mon/day/nth, with nth negative to count from the end. `colitur convert` is a separate step rather than --overlay sniffing the extension, so the author can read what their INI became. When a date form was mistyped, "what did the engine actually get" is the question, and an invisible transpile cannot answer it. The conversion verifies its own output: the emitted text is parsed back with the same function that loads an overlay and must equal what the INI denoted, or nothing is written. That is the point of the module. 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 mean something else. That check was WRONG on the first attempt, in exactly the way it exists to prevent. It re-serialised the parsed value instead of parsing the text being returned, so it verified t -> sexp -> t, which is true by construction and proves nothing. Found by mutation: corrupting the renderer to emit a different overlay id sailed through and exited 0. It now parses the returned text, the mutation is caught with exit 2, and two tests fail under it where none did before.
-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 ] )