From 90584d87e763789808329847c46dbaa8e22dca47 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 26 Aug 2026 23:53:49 +0200 Subject: fix(citation): represent chapter-crossing verse ranges (W4) Parse.verse_range's [last] endpoint gains an optional chapter (Parse.verse_end: { chapter : int option; verse : verse_num }), so a hyphen range whose two endpoints lie in different chapters ("1 John 1:5-2:2") can be represented at all. Rejected: a bare [int option] living alongside [last] as a second field on verse_range -- that would let 'a chapter with no verse' exist as a constructible value. parse_part now splits a part's leading "chapter:" at the FIRST colon only (not every colon), so the verses side can itself carry a second colon from a crossing range. parse_range detects a crossing by checking whether the range's right-hand side contains ':', and skips the same-chapter descending-range guard for that case (a later chapter is always "ahead", whatever its own verse numbers are). Handles the compound shape too -- a crossing range followed by further, same-chapter verse references in the same comma list ("Matthew 9:35-10:1,5a,6-8") -- since those trailing pieces parse as ordinary bare verses/ranges, unaffected by the preceding crossing. Render's one_range renders a crossing [last] through the same chapter_verse template one_part already uses for the part's own leading "chapter:verses", so a style that reconfigures the chapter/verse separator renders a crossing endpoint in that same convention rather than a hardcoded ':'. This closes the W4 known-wrong: 41 (now 49, after an intervening Second-reading extraction) of the OF lectionary's citations printed unconverted, every one this exact shape. test_citation_coverage_of.ml's pinned residual is now empty and asserted exactly, over the full 1725-field data/of/lectionary.sexp population, including the round-trip check (parse -> render -> parse structural equality). test_citation.ml gains direct parse-suite cases for the basic crossing, the compound shape, a mid-list crossing, a crossing with a sub-verse letter, and a malformed-crossing rejection. data/of/lectionary.sexp is regenerated via its own generator (tools/bootstrap_lectionary_of.ml, whose own embedded header text is updated to match); only comment lines change, confirmed by diff -- no lectionary entry differs. test_lectionary_of.ml's whole-file SHA-256 pin is updated to match. EF is unaffected: data/ef/ is untouched since v1.0.0, and a direct byte comparison of `colitur day`/`colitur readings` for 2026, 1583 and 9999 against a git-worktree build of 1c0137d is identical on all six outputs. --- test/test_citation.ml | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) (limited to 'test/test_citation.ml') diff --git a/test/test_citation.ml b/test/test_citation.ml index 3cbc8c6..ffbefe8 100644 --- a/test/test_citation.ml +++ b/test/test_citation.ml @@ -265,13 +265,18 @@ let suite = module P = Colitur_citation.Parse (* Render a parse back to a debug string so a test can assert shape - compactly: "book|chapter:v-v,v-v|chapter:v". *) + compactly: "book|chapter:v-v,v-v|chapter:v". A [last] that names its own, + later chapter (a chapter-crossing range) shows as "v-C:v", the same + "chapter:verse" shape a plain part already uses -- so a crossing is + visible in the debug string exactly where the source text put it. *) let show (t : P.t) = let num (v : P.verse_num) = string_of_int v.P.n ^ v.P.suffix in let range (r : P.verse_range) = match r.P.last with | None -> num r.P.first - | Some l -> Printf.sprintf "%s-%s" (num r.P.first) (num l) + | Some { P.chapter = None; verse } -> Printf.sprintf "%s-%s" (num r.P.first) (num verse) + | Some { P.chapter = Some c; verse } -> + Printf.sprintf "%s-%d:%s" (num r.P.first) c (num verse) in let part (p : P.part) = Printf.sprintf "%d:%s" p.P.chapter @@ -319,6 +324,29 @@ let parse_suite = ("four parts", `Quick, parses "Eccli 24:5; 14:7; 14:9-11; 24:30-31" "ecclesiasticus|24:5|14:7|14:9-11|24:30-31"); ("modern name, vulgate id", `Quick, parses "Rev 12:1" "apocalypse|12:1"); + (* Chapter-crossing ranges (W4): a hyphen range whose two endpoints lie + in different chapters -- real shapes from data/of/lectionary.sexp, + previously unparseable in their entirety (parse.mli's own + [verse_end]). *) + ("cross-chapter range", `Quick, parses "1 John 1:5-2:2" "john_1|1:5-2:2"); + (* The compound shape: a chapter-crossing range FOLLOWED by further, + same-chapter verse references in the same comma list -- "5a" and + "6-8" belong to chapter 10, the chapter the preceding range just + crossed into, never chapter 9. A naive fix that only special-cased + "the whole part is one crossing range" breaks exactly here. *) + ("cross-chapter then more verses (compound)", `Quick, + parses "Matthew 9:35-10:1,5a,6-8" "matthew|9:35-10:1,5a,6-8"); + (* The crossing is not the first comma-piece, and a normal same-chapter + range ("24-25a") sits between two others -- proves the crossing is + detected per-piece, not merely at a fixed position in the list. *) + ("cross-chapter mid-list", `Quick, + parses "2 Samuel 18:9-10,14b,24-25a,30-19:3" "samuel_2|18:9-10,14b,24-25a,30-19:3"); + (* Verse sub-letters survive on a chapter-crossing endpoint too. *) + ("cross-chapter with sub-verse letter", `Quick, + parses "Genesis 1:20-2:4a" "genesis|1:20-2:4a"); + (* A malformed crossing (no verse named after the second chapter) is + rejected, not silently misread. *) + ("rejects malformed cross-chapter", `Quick, rejects "Luke 1:5-2:"); (* Ordinals 3 and 4 must survive [split_book]'s leading-digit scan. Narrowing its '1'..'4' range to '1'..'3' passes every OTHER case in this suite silently, while seven real citations depend on it. A -- cgit v1.3