From 405910d2fd245e7a11e09eecb8c6fffb68d2169c Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 20 Aug 2026 14:40:12 +0200 Subject: feat(citation): canonical book ids and tradition mapping Seven books arrive in two spellings, inherited from lectio's ini and ultimately from Divinum Officium. Collapse them onto one id here rather than editing generated data. Naming and renumbering are kept apart: a tradition decides which book an id denotes, a language file decides what it is called. --- test/dune | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'test/dune') diff --git a/test/dune b/test/dune index 84c5ec7..a90b715 100644 --- a/test/dune +++ b/test/dune @@ -1,6 +1,6 @@ (test (name test_colitur) - (libraries colitur_kernel colitur_naming colitur_render rite_ef alcotest qcheck qcheck-alcotest sexplib) + (libraries colitur_kernel colitur_naming colitur_render colitur_citation rite_ef alcotest qcheck qcheck-alcotest sexplib) (deps ../data/ef/sanctoral.sexp ../data/ef/adjustments.sexp -- cgit v1.3 From 9dfa264f2941f1ca38eff08b6157a94d332cffbd Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 20 Aug 2026 16:01:39 +0200 Subject: feat(lang): traditions.ini, and its install rule Which book a reference denotes does not vary by language, so it lives in its own file rather than in la.ini beside [weekday]. Six mappings, not four: Osee/Jonas are the same question as 3 Kings in transliteration form. The install rule is added in the same commit deliberately -- lang/ once shipped without one, so the feature worked from the source tree and was broken once installed. bin/dune gains colitur_citation as a linked library, needed for the new loader; lang_list's directory scan now excludes traditions.ini, which is not a language file and does not parse as one. --- bin/dune | 7 ++++- bin/main.ml | 74 +++++++++++++++++++++++++++++++++++++++++++++++++-- lang/dune | 3 ++- lang/traditions.ini | 21 +++++++++++++++ test/dune | 6 ++++- test/test_citation.ml | 21 ++++++++++++++- 6 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 lang/traditions.ini (limited to 'test/dune') diff --git a/bin/dune b/bin/dune index 28c04d7..0702a84 100644 --- a/bin/dune +++ b/bin/dune @@ -6,4 +6,9 @@ ; colitur.opam's frozen depends, only a new library this executable links ; against. Used by Task 12's [mkdir_p] (colitur publish, recursive ; directory creation) and nowhere else. - (libraries colitur_kernel rite_ef colitur_render colitur_naming unix)) + ; + ; [colitur_citation] backs [load_tradition]'s own reading of + ; lang/traditions.ini (which book a reference DENOTES, {!Colitur_citation.Book}) -- + ; a sibling library to [colitur_naming] (what a book is CALLED), not a + ; replacement for it. + (libraries colitur_kernel rite_ef colitur_render colitur_naming colitur_citation unix)) diff --git a/bin/main.ml b/bin/main.ml index cf6cb33..5c9f342 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -641,6 +641,67 @@ let load_lang ~raw ~flag ~config = | Error _ -> t | Ok base -> Colitur_naming.Lang.with_fallback t base)))) +(* lang/traditions.ini answers a different question from la.ini/en.ini's own + [\[bible\]] section: which book a reference DENOTES, not what it is + CALLED. Naming varies by language (a [\[bible\]] section per language + file); denoting does not -- "modern numbering" is the same decision in + Latin, Polish and English -- so it gets its own file, read once here + rather than duplicated per language. + + Reuses {!lang_dir} rather than adding a second probe: a tradition file + must resolve the same way a language file does, or an installed binary + could find one and not the other. Note carefully what that reuse implies + -- [lang_dir]'s own installed-vs-source-tree choice is gated on [la.ini] + existing, so it can legitimately return a directory that HAS [la.ini] but + NOT [traditions.ini] (an older install upgraded in place, from before + this file shipped). That case, like an unknown [name] naming no section + in the file, degrades to {!Colitur_citation.Book.vulgate} with a WARNING + on stderr -- never fatal. This deliberately does NOT mirror [load_lang]'s + own PRIMARY-file behaviour (a missing/unparsing la.ini is fatal, [exit 2]) + -- it mirrors [load_lang]'s own FALLBACK-chain behaviour just above, + which already degrades silently rather than taking a good language table + down over a defect in a file it merely NAMES. Asking for a renumbering is + optional the way asking for a language is not: a run should not be lost + over a typo in [--sigla-tradition] (wired in Task 8) the way it is lost + over a typo in [--lang]. + + [name = "vulgate"] takes no shortcut around the file: [\[vulgate\]] is + shipped as a deliberately empty section (see traditions.ini's own + comment), and an empty field list is exactly {!Colitur_citation.Book.vulgate} + ([[]]) via the same {!Colitur_citation.Book.tradition_of_fields} path + every other tradition uses -- one code path, not a special case for the + default. + + Not called from anywhere in this file yet: citations reach output at + exactly two places, [View.citation_ref] and [part_ref] below, and + threading [Sigla.t] (which this feeds) through both is a dedicated task + on its own, deliberately not done here alongside the loader. [-32] is + silenced for exactly that reason -- this is a complete, correct, + ready-to-call function with no caller yet, not dead code. *) +let[@warning "-32"] load_tradition name = + let path = Filename.concat (lang_dir ()) "traditions.ini" in + let vulgate () = Colitur_citation.Book.vulgate in + match read_file path with + | Error _ -> + Printf.eprintf "colitur: no %s; tradition %S falls back to the Vulgate\n" path name; + vulgate () + | Ok text -> ( + match Colitur_kernel.Overlay_ini.parse_sections text with + | Error e -> + Printf.eprintf "colitur: %s: %s; tradition %S falls back to the Vulgate\n" path e name; + vulgate () + | Ok sections -> ( + match List.find_opt (fun (s : Colitur_kernel.Overlay_ini.section) -> s.name = name) sections with + | None -> + Printf.eprintf "colitur: %s: no tradition %S; falling back to the Vulgate\n" path name; + vulgate () + | Some sec -> + List.iter + (fun k -> + Printf.eprintf "colitur: %s: [%s]: unknown book %S (ignored)\n" path name k) + (Colitur_citation.Book.unknown_fields sec.fields); + Colitur_citation.Book.tradition_of_fields sec.fields)) + let extension path = match String.rindex_opt path '.' with | Some i -> String.sub path i (String.length path - i) @@ -1514,7 +1575,16 @@ let convert_report path = [lang_dir ()], the same probe [load_lang] itself uses. Each is opened and parsed (not merely listed by filename) so a malformed file is flagged here rather than only failing later when someone actually tries to use - it. *) + it. + + [traditions.ini] lives in the same directory but is not a language file + -- it answers which book a reference DENOTES, not what it is CALLED (see + [load_tradition] above) -- and does not even parse as one ([Lang.of_string] + requires its own [\[meta\]] section, which traditions.ini has no reason to + carry). Without this exclusion it would list here as "(unreadable: ...)", + which is not a defect in the file, only a mismatch between what this scan + assumes every [.ini] in the directory is and what is actually shipped + there now. *) let lang_list () = let dir = lang_dir () in match Sys.readdir dir with @@ -1525,7 +1595,7 @@ let lang_list () = Array.sort compare files; Array.iter (fun f -> - if Filename.check_suffix f ".ini" then begin + if Filename.check_suffix f ".ini" && f <> "traditions.ini" then begin let code = Filename.remove_extension f in match read_file (Filename.concat dir f) with | Ok t -> ( diff --git a/lang/dune b/lang/dune index 057e95a..929dd42 100644 --- a/lang/dune +++ b/lang/dune @@ -14,4 +14,5 @@ (package colitur) (files (la.ini as lang/la.ini) - (en.ini as lang/en.ini))) + (en.ini as lang/en.ini) + (traditions.ini as lang/traditions.ini))) diff --git a/lang/traditions.ini b/lang/traditions.ini new file mode 100644 index 0000000..e2d53ab --- /dev/null +++ b/lang/traditions.ini @@ -0,0 +1,21 @@ +; lang/traditions.ini -- which book a reference DENOTES. +; +; Separate from a language file's [bible] section, which decides what a book +; is CALLED: naming varies by language, this does not. "Modern numbering" is +; the same decision in Latin, Polish and English. +; +; The DEFAULT is [vulgate], as the 1962 Missal prints it. colitur never +; renumbers unless a tradition is chosen explicitly. +; +; Ids follow the Vulgate structure, because that is what the shipped data is. + +[vulgate] +; identity -- deliberately empty + +[modern] +kings_3 = kings_1 +kings_4 = kings_2 +esdras_2 = nehemiah +ecclesiasticus = sirach +osee = hosea +jonas = jonah diff --git a/test/dune b/test/dune index a90b715..d883d2f 100644 --- a/test/dune +++ b/test/dune @@ -6,6 +6,7 @@ ../data/ef/adjustments.sexp ../lang/la.ini ../lang/en.ini + ../lang/traditions.ini ../data/ef/expected-divergences.sexp ../data/ef/expected-divergences-missalemeum.sexp ../data/ef/lectionary.sexp @@ -55,4 +56,7 @@ ; back to the workspace root's own default alias), so schema/day-v1.json ; needs its own entry here too, exactly like every other runtime file ; above. - ../schema/day-v1.json)) + ../schema/day-v1.json + ; Same trap, same fix: Task 8's --sigla-tradition flag makes the CLI read + ; this file, and a cram sandbox holds only what this stanza names. + ../lang/traditions.ini)) diff --git a/test/test_citation.ml b/test/test_citation.ml index 25fc260..d9be86e 100644 --- a/test/test_citation.ml +++ b/test/test_citation.ml @@ -174,6 +174,23 @@ let test_every_data_file_token_resolves () = Alcotest.failf "unresolved book tokens in shipped data: %s" (String.concat ", " unresolved) +(* lang/traditions.ini's own [modern] section, duplicated here on purpose: + the shipped file and this expectation are written twice and must agree, + so a typo in either is visible. Two ids mapping onto the SAME target is + always a typo -- it would silently merge two books -- so this asserts the + mapping is injective, not merely that every id is known. *) +let test_modern_tradition_is_injective () = + let fields = + [ ("kings_3", "kings_1"); ("kings_4", "kings_2"); + ("esdras_2", "nehemiah"); ("ecclesiasticus", "sirach"); + ("osee", "hosea"); ("jonas", "jonah") ] + in + Alcotest.(check (list string)) "all ids known" [] (B.unknown_fields fields); + let targets = List.map snd fields in + let uniq = List.sort_uniq compare targets in + Alcotest.(check int) "no two ids share a target" + (List.length targets) (List.length uniq) + let suite = ( "book", [ Alcotest.test_case "both spellings one book" `Quick test_both_spellings_are_one_book; @@ -185,7 +202,9 @@ let suite = Alcotest.test_case "tokens has no duplicate spelling" `Quick test_tokens_has_no_duplicate_spelling; Alcotest.test_case "every data file token resolves" `Quick - test_every_data_file_token_resolves ] ) + test_every_data_file_token_resolves; + Alcotest.test_case "modern tradition is injective" `Quick + test_modern_tradition_is_injective ] ) module P = Colitur_citation.Parse -- cgit v1.3