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. --- lib/citation/parse.mli | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) (limited to 'lib/citation/parse.mli') diff --git a/lib/citation/parse.mli b/lib/citation/parse.mli index ea38d24..bc08728 100644 --- a/lib/citation/parse.mli +++ b/lib/citation/parse.mli @@ -15,7 +15,19 @@ actually carried. *) type verse_num = { n : int; suffix : string } -type verse_range = { first : verse_num; last : verse_num option } +(** The end of a verse range, when it needs to say more than a bare verse + number. [chapter = None] is the overwhelming common case (the range + ends in the same chapter its [part] already names); [chapter = Some c] + is a range whose hyphen crosses into a LATER chapter -- the shipped OF + lectionary really does cite this shape ([1 John 1:5-2:2]: the range + starts in chapter 1, the part's own [chapter], and ends at 2:2). A + dedicated record, rather than a bare [int option] living alongside + [verse_range.last] as a second field, so "a chapter with no verse" is + not a state this type can even represent -- see parse.ml's own + [parse_range] for how a hyphen range decides which shape it is. *) +type verse_end = { chapter : int option; verse : verse_num } + +type verse_range = { first : verse_num; last : verse_end option } type part = { chapter : int; verses : verse_range list } type t = { book : Book.id; parts : part list } -- cgit v1.3