aboutsummaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/citation/book.ml107
-rw-r--r--lib/citation/book.mli20
-rw-r--r--lib/citation/parse.ml105
-rw-r--r--lib/citation/parse.mli9
-rw-r--r--lib/citation/render.ml8
5 files changed, 203 insertions, 46 deletions
diff --git a/lib/citation/book.ml b/lib/citation/book.ml
index 986a898..b43cdd8 100644
--- a/lib/citation/book.ml
+++ b/lib/citation/book.ml
@@ -19,26 +19,26 @@ let table =
("kings_3", [ "3 Kings"; "3 Kgs."; "3 Reg"; "Liber Regum III" ]);
("kings_4", [ "4 Kings"; "4 Reg"; "Liber Regum IV"; "4 Kgs." ]);
("esdras_2", [ "2 Esd."; "2 Esdr"; "Liber Esdrae"; "2 Esdras" ]);
- ("tobit", [ "Tob"; "Liber Tobiae"; "Tobias" ]);
+ ("tobit", [ "Tob"; "Liber Tobiae"; "Tobias"; "Tobit" ]);
("judith", [ "Judith"; "Iudith"; "Liber Iudith"; "Jth" ]);
("esther", [ "Esther"; "Esth"; "Liber Esther" ]);
("proverbs", [ "Prov"; "Proverbs" ]);
- ("song_of_songs", [ "Song"; "Cant."; "Canticle of Canticles" ]);
+ ("song_of_songs", [ "Song"; "Cant."; "Canticle of Canticles"; "Song of Solomon" ]);
("wisdom", [ "Wis"; "Wis."; "Sap"; "Liber Sapientiae"; "Wisdom" ]);
("ecclesiasticus", [ "Ecclus"; "Sir"; "Eccli"; "Ecclesiasticus" ]);
- ("isaiah", [ "Isa"; "Isa."; "Isai"; "Isaias Propheta"; "Isaias" ]);
- ("jeremiah", [ "Jer"; "Ier"; "Ieremias Propheta"; "Jeremias" ]);
- ("ezekiel", [ "Ezech"; "Ezek"; "Ezechiel Propheta"; "Ezechiel" ]);
+ ("isaiah", [ "Isa"; "Isa."; "Isai"; "Isaias Propheta"; "Isaias"; "Isaiah" ]);
+ ("jeremiah", [ "Jer"; "Ier"; "Ieremias Propheta"; "Jeremias"; "Jeremiah" ]);
+ ("ezekiel", [ "Ezech"; "Ezek"; "Ezechiel Propheta"; "Ezechiel"; "Ezekiel" ]);
("daniel", [ "Dan"; "Daniel Propheta"; "Daniel" ]);
("osee", [ "Osee"; "Osee Propheta" ]);
("joel", [ "Joel"; "Ioel"; "Ioel Propheta" ]);
("jonas", [ "Jonas"; "Ionae"; "Ionas Propheta" ]);
- ("malachi", [ "Mal"; "Malach"; "Malachias Propheta"; "Malachias" ]);
- ("matthew", [ "Matt"; "Matt."; "Matth"; "Evangelium secundum Matthaeum"; "Matthew" ]);
+ ("malachi", [ "Mal"; "Malach"; "Malachias Propheta"; "Malachias"; "Malachi" ]);
+ ("matthew", [ "Matt"; "Matt."; "Matth"; "Mat"; "Evangelium secundum Matthaeum"; "Matthew" ]);
("mark", [ "Mark"; "Marc"; "Evangelium secundum Marcum" ]);
("luke", [ "Luke"; "Luc"; "Evangelium secundum Lucam" ]);
("john", [ "John"; "Ioann"; "Evangelium secundum Ioannem" ]);
- ("acts", [ "Acts"; "Act"; "Actus Apostolorum"; "Acts of the Apostles" ]);
+ ("acts", [ "Acts"; "Act"; "Actus Apostolorum"; "Acts of the Apostles"; "The Acts" ]);
("romans", [ "Rom"; "Epistola ad Romanos"; "Romans" ]);
("corinthians_1", [ "1 Cor"; "1 Cor."; "Epistola I ad Corinthios"; "1 Corinthians" ]);
("corinthians_2", [ "2 Cor"; "2 Cor."; "Epistola II ad Corinthios"; "2 Corinthians" ]);
@@ -64,6 +64,73 @@ let table =
an [of_token] entry would let one book carry two different ids. *)
("apocalypse", [ "Apoc"; "Rev"; "Liber Apocalypsis"; "Apocalypse" ]) ]
+(* Fix wave I2 (final-review.md, 2026-08-25-colitur-of-phases-3-5): books
+ data/of/lectionary.sexp cites that no EF file ever needed, so the survey
+ above never registered them -- 345 of 730 OF citation fields printed
+ unconverted (35%) because [Book.of_token] had never heard of them, not
+ because [Parse]'s grammar itself could not read the reference. English-
+ canonical spellings only, verified directly against that one file
+ (`grep`, not invented) -- the SAME "every spelling any of the files uses"
+ discipline [table] above states, extended to a fourth file.
+
+ Deliberately a SEPARATE table, not folded into [table] above, for one
+ reason that matters: [table]'s ids are covered by [all] below, and
+ book.mli's own contract for [all] is "every SHIPPED language file is
+ asserted complete over" it (test_lang_coverage.ml's [test_every_book_
+ named] and its siblings). lang/la.ini's own header is equally strict --
+ "do not invent a Latin title", every `.full`/`.abbr` row cited to an
+ exact scan line -- and NONE of these 25 books is attested anywhere in
+ the EF's own scans (the EF lectionary/propers/commons never read from
+ Ruth, Job, Judges, Philemon, ... at all). Registering them in [table]
+ would force a choice between fabricating 50 uncited Latin titles (the
+ one thing this project refuses hardest) and quietly weakening book.mli's
+ own stated promise for the ids that DO have one. Neither is right, so
+ these ids are resolvable ([of_token]/[tokens]/[default_spelling] below
+ all include them) but excluded from [all] -- a citation using one still
+ PARSES and RENDERS (bin/main.ml's own [names_of] already falls back to
+ {!default_spelling} on a lang-table miss, book.mli's own documented
+ reason that function exists), it just renders in its own English
+ spelling rather than a Latin one no primary source has ever supplied,
+ the exact "degraded, not broken" contract {!default_spelling} documents.
+
+ [samuel_1]/[samuel_2] in particular are NOT [kings_1]/[kings_2] below:
+ those tradition targets already denote (via traditions.ini's own
+ [modern] mapping, "kings_3 = kings_1") the SAME physical book as the
+ Vulgate's own [kings_3]/[kings_4] ("3/4 Kings" = modern "1/2 Kings") --
+ a different pair of books from Samuel ("Liber Regum I/II" in the
+ Vulgate's own naming, never cited by any shipped EF data and so never
+ given an id before now). *)
+let of_lectionary_table =
+ [ ("samuel_1", [ "1 Sam"; "1 Samuel" ]);
+ ("samuel_2", [ "2 Sam"; "2 Samuel" ]);
+ ("joshua", [ "Josh"; "Joshua" ]);
+ ("judges", [ "Judg"; "Judges" ]);
+ ("ruth", [ "Ruth" ]);
+ ("deuteronomy", [ "Deut"; "Deuteronomy" ]);
+ ("chronicles_1", [ "1 Chron"; "1 Chronicles" ]);
+ ("chronicles_2", [ "2 Chron"; "2 Chronicles" ]);
+ ("ezra", [ "Ezra" ]);
+ ("job", [ "Job" ]);
+ ("ecclesiastes", [ "Eccl"; "Ecclesiastes" ]);
+ ("baruch", [ "Bar"; "Baruch" ]);
+ ("lamentations", [ "Lam"; "Lamentations" ]);
+ ("amos", [ "Amos" ]);
+ ("micah", [ "Mic"; "Micah" ]);
+ ("nahum", [ "Nah"; "Nahum" ]);
+ ("habakkuk", [ "Hab"; "Habakkuk" ]);
+ ("zephaniah", [ "Zeph"; "Zephaniah" ]);
+ ("haggai", [ "Hag"; "Haggai" ]);
+ ("zechariah", [ "Zech"; "Zechariah" ]);
+ ("maccabees_1", [ "1 Macc"; "1 Maccabees" ]);
+ ("maccabees_2", [ "2 Macc"; "2 Maccabees" ]);
+ ("philemon", [ "Phlm"; "Philemon" ]);
+ ("jude", [ "Jude" ]);
+ (* [john_2]/[john_3], siblings of [john_1] above -- three separate
+ Johannine epistles, three separate ids, the same one-id-per-physical-
+ book rule every other entry in this file follows. *)
+ ("john_2", [ "2 John" ]);
+ ("john_3", [ "3 John" ]) ]
+
(* Targets a tradition can map ONTO that the Vulgate data never cites
directly. Present so [tradition_of_fields] can validate both sides.
[sirach] and [revelation] exist ONLY here, never as an [of_token] result:
@@ -89,15 +156,20 @@ let tradition_target_table =
let tradition_targets = List.map fst tradition_target_table
+(* Deliberately NOT [of_lectionary_table] -- see that table's own header for
+ why: those ids have no la.ini-verified name, so they stay out of the set
+ {!Colitur_naming.Lang}-completeness checks (test_lang_coverage.ml) run
+ against, without weakening what those checks still guarantee for every id
+ here. *)
let all = List.map fst table @ tradition_targets
let tokens =
List.concat_map
(fun (id, sp) -> List.map (fun s -> (s, id)) sp)
- (table @ tradition_target_table)
+ (table @ tradition_target_table @ of_lectionary_table)
let default_spelling id =
- match List.assoc_opt id (table @ tradition_target_table) with
+ match List.assoc_opt id (table @ tradition_target_table @ of_lectionary_table) with
| Some (first :: _) -> first
| Some [] | None -> id
@@ -120,3 +192,18 @@ let unknown_fields fields =
fields
let map tr id = match List.assoc_opt id tr with Some x -> x | None -> id
+
+(* Fix wave I2: books with exactly one chapter are conventionally cited by
+ VERSE ALONE, no chapter number at all ("Jude 17, 20b-25", "Phlm 7-22") --
+ found while adding [of_lectionary_table] above: [jude]'s own citation
+ "Jude 17,20b-25" used to fail as "unknown book" (safe, if unconverted);
+ registering [jude] made it PARSE, but WRONGLY -- [Parse]'s existing
+ "a leading comma-piece that looks like a number introduces a chapter"
+ rule (parse.ml's own [parse_part], written for real multi-chapter data
+ like "15, 1-46") misread "17" as chapter 17, which does not exist in a
+ 25-verse, one-chapter book. A parse that SUCCEEDS with the wrong
+ structure is worse than one that fails: nothing downstream would ever
+ suspect it. [Parse.parse] consults this to skip that rule entirely for
+ these four ids and default to chapter 1. *)
+let single_chapter_ids = [ "jude"; "philemon"; "john_2"; "john_3" ]
+let is_single_chapter id = List.mem id single_chapter_ids
diff --git a/lib/citation/book.mli b/lib/citation/book.mli
index 0ca229c..4e91c6c 100644
--- a/lib/citation/book.mli
+++ b/lib/citation/book.mli
@@ -30,7 +30,18 @@ val to_string : id -> string
tradition-only targets [sirach]/[revelation] -- see those below. *)
val of_token : string -> id option
-(** Every id this build knows, for coverage checks. *)
+(** Every id surveyed against the EF's own three citation-bearing files
+ (lectionary, sanctoral propers, commons) -- for coverage checks: every
+ SHIPPED language file's [\[bible\]] section is asserted complete over
+ this list (test_lang_coverage.ml). NOT every id {!of_token} can resolve:
+ book.ml's own [of_lectionary_table] adds a further ~25 ids the OF's
+ English-canonical lectionary cites that no EF file ever needed and that
+ no primary EF source attests a Latin title for -- resolvable ([of_token]/
+ {!default_spelling} both cover them) but deliberately excluded here,
+ rather than either fabricating an uncited Latin name or weakening this
+ list's own completeness promise for the ids that DO have one. Such a
+ book still renders, in its own English spelling ({!default_spelling}'s
+ own fallback), never a raw internal id. *)
val all : id list
(** Every accepted spelling paired with its id. *)
@@ -77,3 +88,10 @@ val tradition_of_fields : (string * string) list -> tradition
val unknown_fields : (string * string) list -> string list
val map : tradition -> id -> id
+
+(** Whether [id] names a book with exactly one chapter (Jude, Philemon,
+ 2 John, 3 John), conventionally cited by verse alone with no chapter
+ number ("Jude 17", not "Jude 1:17"). {!Colitur_citation.Parse.parse}
+ consults this to avoid misreading a leading verse number as a chapter
+ -- see book.ml's own citation for the real citation this was found on. *)
+val is_single_chapter : id -> bool
diff --git a/lib/citation/parse.ml b/lib/citation/parse.ml
index 2f2316e..2c73600 100644
--- a/lib/citation/parse.ml
+++ b/lib/citation/parse.ml
@@ -1,6 +1,19 @@
(* SPDX-License-Identifier: AGPL-3.0-or-later *)
-type verse_range = { first : int; last : int option }
+(* Fix wave I2 (final-review.md, 2026-08-25-colitur-of-phases-3-5): a VERSE
+ number (never a chapter number -- see [num_opt]'s own citation below) may
+ carry a trailing lowercase-letter sub-verse marker, standard lectionary
+ notation for "part of this verse" ("11a" = the first clause of verse 11)
+ and sometimes several run together ("1bcde" = parts b through e of verse
+ 1). [suffix] is [""] for the overwhelming majority of verse numbers --
+ every EF citation, checked: none carries one at all (grep across
+ data/ef/{lectionary,sanctoral,commons}.sexp finds zero) -- so this is
+ purely additive for EF and does not change how any existing citation
+ parses or renders. Carried through and RENDERED, not stripped: dropping
+ it would silently lose real precision a reader can see in the source
+ text, trading "wrong format, complete" for "right format, incomplete". *)
+type verse_num = { n : int; suffix : string }
+type verse_range = { first : verse_num; last : verse_num option }
type part = { chapter : int; verses : verse_range list }
type t = { book : Book.id; parts : part list }
@@ -75,15 +88,38 @@ let int_opt s =
if not ok then None
else match int_of_string_opt s with Some n when n > 0 -> Some n | _ -> None
+(* [num_opt] is [int_opt] widened to accept a trailing sub-verse letter run
+ ([verse_num]'s own citation above) -- used ONLY for verse numbers inside
+ [parse_range] below. Chapter numbers ([parse_part]'s own [int_opt c]
+ calls) are deliberately left on plain [int_opt]: nothing in the data ever
+ attaches a sub-verse letter to a CHAPTER, and keeping chapter-parsing on
+ the original, narrower function means this change cannot loosen what a
+ chapter number is allowed to look like. *)
+let split_num_suffix s =
+ let n = String.length s in
+ let i = ref 0 in
+ while !i < n && s.[!i] >= '0' && s.[!i] <= '9' do incr i done;
+ if !i = 0 then None
+ else
+ let digits = String.sub s 0 !i and suffix = String.sub s !i (n - !i) in
+ if String.for_all (fun c -> c >= 'a' && c <= 'z') suffix then Some (digits, suffix) else None
+
+let num_opt s : verse_num option =
+ match split_num_suffix (String.trim s) with
+ | None -> None
+ | Some (digits, suffix) -> ( match int_opt digits with Some n -> Some { n; suffix } | None -> None)
+
(* "20-32" -> {first=20; last=Some 32}; "21" -> {first=21; last=None} *)
let parse_range s =
match split_on '-' s with
- | [ a ] -> ( match int_opt a with Some f -> Some { first = f; last = None } | None -> None)
+ | [ a ] -> ( match num_opt a with Some f -> Some { first = f; last = None } | None -> None)
| [ a; b ] -> (
- match (int_opt a, int_opt b) with
+ match (num_opt a, num_opt b) with
(* A descending range ("1:20-10") is always a transcription error;
- accepting it would render back out as a citation nobody can follow. *)
- | Some f, Some l when l >= f -> Some { first = f; last = Some l }
+ accepting it would render back out as a citation nobody can follow.
+ Compared on the NUMBER only -- "11a-11b" is a real, ascending
+ sub-verse range even though nothing here orders letters. *)
+ | Some f, Some l when l.n >= f.n -> Some { first = f; last = Some l }
| _ -> None)
| _ -> None
@@ -97,35 +133,45 @@ let parse_ranges s =
pieces (Some [])
(* One ";"-separated part. [inherited] is the chapter of the previous part,
- used when this one names none (rule 1 in the grammar table). *)
-let parse_part ~inherited s =
+ used when this one names none (rule 1 in the grammar table).
+ [single_chapter] ({!Book.is_single_chapter}, fix wave I2) skips rule 3
+ entirely: a single-chapter book's citations are bare verses with no
+ chapter at all, so a leading comma-piece that looks like a number is
+ always a VERSE, never a chapter introduction -- see book.ml's own
+ citation for the real, wrongly-parsed example this was found on. *)
+let parse_part ~single_chapter ~inherited s =
match split_on ':' s with
| [ c; v ] -> (
(* explicit "chapter:verses" *)
match (int_opt c, parse_ranges v) with
| Some ch, Some vs -> Some { chapter = ch; verses = vs }
| _ -> None)
- | [ only ] -> (
- (* Either "chapter, verses" (rule 3) or bare verses inheriting a
- chapter. Distinguish on whether the FIRST comma-piece is a lone
- number followed by more pieces -- a leading number followed by
- at least one further piece is a chapter introduction ("15, 1-46");
- a lone piece on its own, or a part with no more pieces to follow,
- can only be verses inheriting the previous chapter. *)
- let pieces = split_on ',' only in
- match (pieces, inherited) with
- | first :: (_ :: _ as rest), _ when int_opt first <> None && String.contains only ',' -> (
- (* "15, 1-46" -> chapter 15. This never fires for a part that
- already named its chapter via ":" -- those match the [c; v]
- branch above and never reach here. *)
- match (int_opt first, parse_ranges (String.concat "," rest)) with
- | Some ch, Some vs -> Some { chapter = ch; verses = vs }
- | _ -> None)
- | _, Some ch -> (
- match parse_ranges only with
- | Some vs -> Some { chapter = ch; verses = vs }
- | None -> None)
- | _, None -> None)
+ | [ only ] ->
+ if single_chapter then
+ match parse_ranges only with
+ | Some vs -> Some { chapter = Option.value inherited ~default:1; verses = vs }
+ | None -> None
+ else (
+ (* Either "chapter, verses" (rule 3) or bare verses inheriting a
+ chapter. Distinguish on whether the FIRST comma-piece is a lone
+ number followed by more pieces -- a leading number followed by
+ at least one further piece is a chapter introduction ("15, 1-46");
+ a lone piece on its own, or a part with no more pieces to follow,
+ can only be verses inheriting the previous chapter. *)
+ let pieces = split_on ',' only in
+ match (pieces, inherited) with
+ | first :: (_ :: _ as rest), _ when int_opt first <> None && String.contains only ',' -> (
+ (* "15, 1-46" -> chapter 15. This never fires for a part that
+ already named its chapter via ":" -- those match the [c; v]
+ branch above and never reach here. *)
+ match (int_opt first, parse_ranges (String.concat "," rest)) with
+ | Some ch, Some vs -> Some { chapter = ch; verses = vs }
+ | _ -> None)
+ | _, Some ch -> (
+ match parse_ranges only with
+ | Some vs -> Some { chapter = ch; verses = vs }
+ | None -> None)
+ | _, None -> None)
| _ -> None
let parse s =
@@ -144,10 +190,11 @@ let parse s =
(* A trailing ";" leaves an empty piece: drop it rather than
failing. Only if nothing remains is it an error. *)
let pieces = List.filter (fun p -> p <> "") (split_on ';' tail) in
+ let single_chapter = Book.is_single_chapter book in
let rec go inherited = function
| [] -> Ok []
| p :: rest -> (
- match parse_part ~inherited p with
+ match parse_part ~single_chapter ~inherited p with
| None -> Error ("cannot read reference: " ^ p)
| Some part -> (
match go (Some part.chapter) rest with
diff --git a/lib/citation/parse.mli b/lib/citation/parse.mli
index 3d1053c..ea38d24 100644
--- a/lib/citation/parse.mli
+++ b/lib/citation/parse.mli
@@ -8,7 +8,14 @@
([John 18:1-40; 19:1-42]) and really does list disjoint verse ranges
within a chapter ([Dan 13:1-9, 15-17, 19-30, 33-62]). *)
-type verse_range = { first : int; last : int option }
+(** A verse number, plus an optional trailing sub-verse letter run ([""] for
+ the overwhelming majority -- see parse.ml's own citation): ["11a"] is
+ [{ n = 11; suffix = "a" }], preserved through parsing and rendering
+ rather than dropped, so a reader never loses precision the source text
+ actually carried. *)
+type verse_num = { n : int; suffix : string }
+
+type verse_range = { first : verse_num; last : verse_num option }
type part = { chapter : int; verses : verse_range list }
type t = { book : Book.id; parts : part list }
diff --git a/lib/citation/render.ml b/lib/citation/render.ml
index 79aaa86..2b40ab3 100644
--- a/lib/citation/render.ml
+++ b/lib/citation/render.ml
@@ -80,13 +80,11 @@ let subst tmpl pairs =
Buffer.contents b
let render st ~names (t : Parse.t) =
+ let show_num (v : Parse.verse_num) = string_of_int v.Parse.n ^ v.Parse.suffix in
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) ]
+ | None -> show_num r.Parse.first
+ | Some l -> subst st.range [ ("first", show_num r.Parse.first); ("last", show_num l) ]
in
let one_part (p : Parse.part) =
let verses = String.concat st.verse_sep (List.map one_range p.Parse.verses) in