From a2cbb6b85e79fbc59b0879362c0f853757d51c07 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 20 Aug 2026 15:09:49 +0200 Subject: feat(citation): render a parsed citation in a configurable style A style is a set of format strings, so punctuation convention is data. Values are unquoted here rather than in Overlay_ini: that parser trims every value and is shared with overlays and [defaults], so teaching it about quotes would change behaviour this feature has no business changing. --- lib/citation/render.ml | 107 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 lib/citation/render.ml (limited to 'lib/citation/render.ml') diff --git a/lib/citation/render.ml b/lib/citation/render.ml new file mode 100644 index 0000000..c66a0ca --- /dev/null +++ b/lib/citation/render.ml @@ -0,0 +1,107 @@ +(* SPDX-License-Identifier: AGPL-3.0-or-later *) + +type style = { + book : [ `Full | `Abbr ]; + book_sep : string; + chapter_verse : string; + range : string; + part_sep : string; + verse_sep : string; +} + +let default_style = + { book = `Abbr; + book_sep = " "; + chapter_verse = "{chapter}:{verses}"; + range = "{first}-{last}"; + part_sep = "; "; + verse_sep = ", " } + +let part_sep st = st.part_sep +let range st = st.range +let book_sep st = st.book_sep + +(* Arabic -> Roman, for the {chapter_roman} placeholder. Lifted from + [Colitur_render.View.roman_numeral], which prints "Hebdomada I" week + headings -- roman numerals are idiomatic throughout this project's + output ("Feria IV"), so a citation style may want them too. *) +let roman_numeral n = + if n <= 0 then string_of_int n + else + let table = + [ (1000, "M"); (900, "CM"); (500, "D"); (400, "CD"); (100, "C"); + (90, "XC"); (50, "L"); (40, "XL"); (10, "X"); (9, "IX"); + (5, "V"); (4, "IV"); (1, "I") ] + in + let b = Buffer.create 8 in + let n = ref n in + List.iter + (fun (v, sym) -> + while !n >= v do + Buffer.add_string b sym; + n := !n - v + done) + table; + Buffer.contents b + +let with_book b st = { st with book = b } + +(* One matching pair of surrounding double quotes, and only that -- see the + .mli for why this is not in Overlay_ini. *) +let unquote s = + let n = String.length s in + if n >= 2 && s.[0] = '"' && s.[n - 1] = '"' then String.sub s 1 (n - 2) else s + +(* Replace {name} with its value. An UNKNOWN placeholder survives + literally: a typo in a hand-written style file must be visible in the + output, not silently swallowed. *) +let subst tmpl pairs = + let n = String.length tmpl in + let b = Buffer.create (n + 16) in + let i = ref 0 in + while !i < n do + if tmpl.[!i] = '{' then + match String.index_from_opt tmpl !i '}' with + | None -> + Buffer.add_char b tmpl.[!i]; + incr i + | Some j -> + let name = String.sub tmpl (!i + 1) (j - !i - 1) in + (match List.assoc_opt name pairs with + | Some v -> Buffer.add_string b v + | None -> Buffer.add_string b (String.sub tmpl !i (j - !i + 1))); + i := j + 1 + else begin + Buffer.add_char b tmpl.[!i]; + incr i + end + done; + Buffer.contents b + +let render st ~names (t : Parse.t) = + let one_range (r : Parse.verse_range) = + match r.Parse.last with + | None -> string_of_int r.Parse.first + | Some l -> + subst st.range + [ ("first", string_of_int r.Parse.first); + ("last", string_of_int l) ] + in + let one_part (p : Parse.part) = + let verses = String.concat st.verse_sep (List.map one_range p.Parse.verses) in + subst st.chapter_verse + [ ("chapter", string_of_int p.Parse.chapter); + ("chapter_roman", roman_numeral p.Parse.chapter); + ("verses", verses) ] + in + let body = String.concat st.part_sep (List.map one_part t.Parse.parts) in + names t.Parse.book st.book ^ st.book_sep ^ body + +let style_of_fields fields = + let get k d = match List.assoc_opt k fields with Some v -> unquote v | None -> d in + { book = (if get "book" "abbr" = "full" then `Full else `Abbr); + book_sep = get "book_sep" default_style.book_sep; + chapter_verse = get "chapter_verse" default_style.chapter_verse; + range = get "range" default_style.range; + part_sep = get "part_sep" default_style.part_sep; + verse_sep = get "verse_sep" default_style.verse_sep } -- cgit v1.3 From 1da70dc7ac03fe33fb92b172a0e26932764170d6 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 20 Aug 2026 19:03:02 +0200 Subject: fix(citation): close the final review's blocking findings The branch was RED and reported green. `dune test` exited 1: test/cli.t pinned the pre-fix output `kings_1 19:3-8`, which the previous commit had already fixed to `1 Reg 19:3-8`. The gate command piped dune through `tail`, so it reported tail's exit status, and cram prints its diff BEFORE the alcotest summary, so the two lines shown were the passing ones. Verify with `dune test; echo $?`, never through a pipe. A style file's own `book` key was unreachable. sigla_book resolved against a hardcoded "abbr" and the result was applied unconditionally, so the documented `[sigla] book = full` could never win. Render gains book_string, and the style's own value is now the default that a flag or config overrides. The unit test pinned style_of_fields correctly while the wiring defeated it. `lang --check` filtered the reference set to the celebration prefix, so a file with no [bible] section at all reported a clean bill of health -- contradicting both the reason the keys change was made and lang.ml's own comment. It now reports missing book names too. The token test missed a FOURTH citation-bearing file: adjustments.sexp writes citations as `Set_citation`, not `(reference ...)`. Its 16 citations all parse, so nothing was broken, but nothing was checking. The first attempt at this fix read the file and extracted NOTHING -- the marker stopped before the opening quote, so every payload was the part label -- which is recorded in the code rather than left as a trap. Also: colitur-config(5) claimed a trailing period the data does not carry, and two la.ini scan quotes silently corrected OCR damage ("Ionae 3, I - I O", "Epistolse") while presenting themselves as verbatim. Both are now marked as corrections. --- bin/main.ml | 31 ++++++++++++++++++++++++++++++- lang/la.ini | 13 +++++++++++-- lib/citation/render.ml | 1 + lib/citation/render.mli | 10 ++++++++++ man/colitur-config.5 | 2 +- test/cli.t | 23 +++++++++++++++-------- test/test_citation.ml | 32 ++++++++++++++++++++++++++------ 7 files changed, 94 insertions(+), 18 deletions(-) (limited to 'lib/citation/render.ml') diff --git a/bin/main.ml b/bin/main.ml index c76a082..1207b1d 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -780,7 +780,11 @@ let load_sigla ~raw ~lang_t ~sigla_style_flag ~sigla_book_flag ~sigla_tradition_ in let sigla_book_value, _ = Colitur_naming.Config.resolve ~flag:sigla_book_flag - ~config:(Colitur_naming.Config.sigla_book config) ~default:"abbr" + ~config:(Colitur_naming.Config.sigla_book config) + (* The STYLE's own [book] key is the default, so a style file can + set it and a flag/config still overrides. A hardcoded "abbr" + here made the documented [sigla] book key unreachable. *) + ~default:(Colitur_citation.Render.book_string style) in let book_form = match sigla_book_value with @@ -1870,11 +1874,36 @@ let lang_check path = in let missing = List.filter (fun s -> not (List.mem s have)) known in let unknown = List.filter (fun s -> not (List.mem s known)) have in + (* Book names are checked the same way, and separately. Without + this the reference set gained a [bible] half that nothing ever + consulted: a file with [bible] entirely absent reported a clean + bill of health while every citation silently fell back to the + data's own Latin spelling. A miss is the TOTAL-lookup contract's + own signature -- [Lang.bible] returns the KEY when there is no + entry -- so equality with the key IS the test. *) + let missing_books = + List.concat_map + (fun id -> + let n = Colitur_citation.Book.to_string id in + List.filter_map + (fun form -> + let key = n ^ "." ^ form in + if Colitur_naming.Lang.bible t key = key then Some key + else None) + [ "full"; "abbr" ]) + Colitur_citation.Book.all + in List.iter (fun s -> Printf.printf "missing: %s\n" s) (List.sort compare missing); + List.iter (fun s -> Printf.printf "missing book: %s\n" s) + (List.sort compare missing_books); List.iter (fun s -> Printf.printf "unknown slug: %s\n" s) (List.sort compare unknown); + let books_total = 2 * List.length Colitur_citation.Book.all in Printf.printf "%s: %d of %d celebrations named, %d missing, %d unknown\n" path (List.length known - List.length missing) (List.length known) (List.length missing) (List.length unknown); + Printf.printf "%s: %d of %d book names, %d missing\n" path + (books_total - List.length missing_books) books_total + (List.length missing_books); if unknown <> [] then exit 1) (* `colitur config --show` -- each effective setting, its resolved value, diff --git a/lang/la.ini b/lang/la.ini index 3783482..821acff 100644 --- a/lang/la.ini +++ b/lang/la.ini @@ -1997,7 +1997,12 @@ jonas.full = Ionas Propheta ; scan1.txt:11021 "Lectio Ionae Prophetae." -- CORRECTS the sourcing note ; (genitive "Ionae" -> nominative "Ionas"). jonas.abbr = Ionae -; scan1.txt:11022 "Ionae 3, 1-10", same page as the .full citation -- the +; scan1.txt:11022 -- OCR-NORMALISED, not verbatim: the line actually reads +; "Ionae 3, I - I O", the scanner having read the digits 1 and 0 as capital +; letter I and letter O. The reading is unambiguous in context (Jonas 3 has +; 10 verses and the pericope below runs to verse 10), but the quote is a +; correction, not a transcription, and is marked so rather than presented as +; if the page said it. Same page as the .full citation -- the ; Missal's own locator uses the genitive form directly, unabbreviated. malachi.full = Malachias Propheta ; scan1.txt:26697 "Lectio Malachiae Prophetae." -- CORRECTS the sourcing @@ -2101,7 +2106,11 @@ hebrews.abbr = Hebr ; --- Catholic epistles -------------------------------------------------- james.full = Epistola beati Iacobi Apostoli -; scan1.txt:20348 "Lectio Epistolae beati Iacobi Apostoli.", also +; scan1.txt:20348 -- OCR-NORMALISED, not verbatim: the line reads +; "Epistolse beati Iacobi Apostoli", the scanner having read the ligature ae +; as "se". The correction is certain (no Latin word "Epistolse" exists, and +; the same phrase is clean elsewhere), but it is a correction and is marked +; as one. Also ; 20432/20509/34828/38918/40963/41197/45872/49193 -- no "ad X" destination ; to shorten to, so the attribution stays; see the header's own note on this ; pattern. diff --git a/lib/citation/render.ml b/lib/citation/render.ml index c66a0ca..79aaa86 100644 --- a/lib/citation/render.ml +++ b/lib/citation/render.ml @@ -20,6 +20,7 @@ let default_style = let part_sep st = st.part_sep let range st = st.range let book_sep st = st.book_sep +let book_string st = match st.book with `Full -> "full" | `Abbr -> "abbr" (* Arabic -> Roman, for the {chapter_roman} placeholder. Lifted from [Colitur_render.View.roman_numeral], which prints "Hebdomada I" week diff --git a/lib/citation/render.mli b/lib/citation/render.mli index d741931..274900c 100644 --- a/lib/citation/render.mli +++ b/lib/citation/render.mli @@ -44,5 +44,15 @@ val range : style -> string [\textasciitilde{}]. *) val book_sep : style -> string +(** The style's own book form, as the string a config file would write + (["full"] or ["abbr"]). + + Exists so a caller can use the STYLE's value as the default when + resolving the [sigla_book] setting. Resolving against a hardcoded + ["abbr"] instead makes a style file's own [book] key unreachable -- + the value is always overwritten before it can apply -- which is what + shipped until this accessor existed. *) +val book_string : style -> string + val render : style -> names:(Book.id -> [ `Full | `Abbr ] -> string) -> Parse.t -> string diff --git a/man/colitur-config.5 b/man/colitur-config.5 index 6b3a574..0e242bf 100644 --- a/man/colitur-config.5 +++ b/man/colitur-config.5 @@ -201,7 +201,7 @@ Overrides the selected style's own setting. Default .BR abbr , giving -.B "Luc. 5:12\-14" +.B "Luc 5:12\-14" rather than .BR "Evangelium secundum Lucam 5:12\-14" . An unrecognised value is a hard error, the same discipline as an unknown diff --git a/test/cli.t b/test/cli.t index d9d8ec5..b44a256 100644 --- a/test/cli.t +++ b/test/cli.t @@ -1261,12 +1261,14 @@ slug the engine can produce over 2020-2045: $ colitur lang --check d.ini d.ini: 725 of 725 celebrations named, 0 missing, 0 unknown + d.ini: 104 of 104 book names, 0 missing `--check` reports what is MISSING (a real slug with no entry): $ printf '[meta]\nlang = zz\n[celebration]\nef-epiphany = Test\n' > partial.ini - $ colitur lang --check partial.ini | tail -1 + $ colitur lang --check partial.ini | tail -2 partial.ini: 1 of 725 celebrations named, 724 missing, 0 unknown + partial.ini: 0 of 104 book names, 104 missing `--check` REJECTS an unknown slug (exit 1), so a typo is visible rather than silently dead -- its author would otherwise never learn why the name they @@ -1431,15 +1433,20 @@ Latin-convention punctuation. The synthetic file below overrides only `--sigla-tradition` renumbers which book an id DENOTES (lang/traditions.ini), independently of style or naming -- `modern` maps `3 Kings` onto the id -`kings_1`. Task 10's `la.ini` now carries a `[bible]` row for `kings_1` (a -tradition target the Vulgate data never cites directly, book.mli), but only -its own bare id -- an honest UNSOURCED placeholder, not a name (la.ini's own -`[bible]` header note) -- so it still prints literally, now BY DESIGN rather -than by the row's absence, still a real, if plain, witness that the -tradition actually applied rather than a no-op: +`kings_1`, whose own Latin name la.ini marks CONSTRUCTED: the 1962 Missal +uses Vulgate numbering throughout, so it can contain no incipit for a book +that exists only under a later convention. In Latin the modern tradition +therefore only really moves Kings and Esdras -- Osee, Ionas, Ecclesiasticus +and the Apocalypse keep their Vulgate names either way, because modern +numbering is a vernacular convention: $ colitur readings 2027 --sigla-tradition modern | grep '^2027-02-17' - 2027-02-17 ef-lent-ember-wed | kings_1 19:3-8 | Matth 12:38-50 | Feria IV Quatuor Temporum Quadragesimae + 2027-02-17 ef-lent-ember-wed | 1 Reg 19:3-8 | Matth 12:38-50 | Feria IV Quatuor Temporum Quadragesimae + +Under an English file the same mapping shows its usual face: + + $ colitur readings 2027 --lang en --sigla-tradition modern | grep '^2027-02-17' + 2027-02-17 ef-lent-ember-wed | 1 Kgs 19:3-8 | Matt 12:38-50 | Lenten Ember Wednesday `table`/`render`, `emit` and `publish` accept the same three flags too -- smoke-tested for exit status alone here (a minimal inline template, the diff --git a/test/test_citation.ml b/test/test_citation.ml index 1efaa10..133553b 100644 --- a/test/test_citation.ml +++ b/test/test_citation.ml @@ -117,8 +117,7 @@ let starts_with_at content pos prefix = [Str]/regex -- a plain forward scan for the marker, then read to the closing quote. Mirrors the coordinator's own survey command (grep -oh over the reference marker, quote-delimited). *) -let references content = - let marker = "(reference \"" in +let references_with marker content = let mlen = String.length marker in let len = String.length content in let rec loop pos acc = @@ -151,14 +150,35 @@ let book_token r = let stop = if !i < len && r.[!i] = '.' then !i + 1 else !i in String.sub r 0 stop +(* A citation is written TWO ways in this project's data, and a survey that + knows only one of them silently under-reads the corpus: + (reference "Isa 60:1-6") -- lectionary/sanctoral/commons + (Set_citation First "Wis 7:7-14") -- adjustments.sexp, an overlay + Both markers are scanned below. *) +let references content = + (* Each marker must include the OPENING QUOTE. [references_with] returns + the text between the marker and the next '"', so a marker stopping at + "(Set_citation " yields "First " / "Gospel " -- the part label, never + the citation -- and every entry is then silently discarded. That was + this function's first version, and it read the file while extracting + nothing at all. *) + references_with "(reference \"" content + @ references_with "(Set_citation First \"" content + @ references_with "(Set_citation Gospel \"" content + let test_every_data_file_token_resolves () = (* The check whose absence caused fix round 1: the brief surveyed only the lectionary and missed 21 tokens, several common, living in - sanctoral.sexp and commons.sexp. Read all three files at test time and - re-derive the token set from them, rather than hardcoding a list, so - this keeps working when the data changes. *) + sanctoral.sexp and commons.sexp. + + adjustments.sexp was then missed AGAIN, by this very test, because it + writes citations as `Set_citation` rather than `(reference ...)` -- the + same "one file too few" shape twice over. All FOUR shipped files are + read here, and the token set is re-derived from them at test time + rather than hardcoded, so this keeps working when the data changes. *) let files = - [ "../data/ef/lectionary.sexp"; "../data/ef/sanctoral.sexp"; "../data/ef/commons.sexp" ] + [ "../data/ef/lectionary.sexp"; "../data/ef/sanctoral.sexp"; + "../data/ef/commons.sexp"; "../data/ef/adjustments.sexp" ] in let tokens = files -- cgit v1.3