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. --- lib/citation/book.ml | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++ lib/citation/book.mli | 49 ++++++++++++++++++++++++++++++++++ lib/citation/dune | 2 ++ 3 files changed, 125 insertions(+) create mode 100644 lib/citation/book.ml create mode 100644 lib/citation/book.mli create mode 100644 lib/citation/dune (limited to 'lib/citation') diff --git a/lib/citation/book.ml b/lib/citation/book.ml new file mode 100644 index 0000000..a2f9678 --- /dev/null +++ b/lib/citation/book.ml @@ -0,0 +1,74 @@ +(* SPDX-License-Identifier: AGPL-3.0-or-later *) + +type id = string + +let to_string t = t + +(* Every book the shipped EF lectionary cites, with every spelling it uses. + The dotted/undotted pairs are inherited from lectio -- see book.mli. *) +let table = + [ ("genesis", [ "Gen" ]); + ("exodus", [ "Ex" ]); + ("leviticus", [ "Lev" ]); + ("numbers", [ "Num" ]); + ("kings_3", [ "3 Kings"; "3 Kgs." ]); + ("kings_4", [ "4 Kings" ]); + ("esdras_2", [ "2 Esd." ]); + ("esther", [ "Esther" ]); + ("ecclesiasticus", [ "Ecclus" ]); + ("isaiah", [ "Isa"; "Isa." ]); + ("jeremiah", [ "Jer" ]); + ("ezekiel", [ "Ezech" ]); + ("daniel", [ "Dan" ]); + ("osee", [ "Osee" ]); + ("joel", [ "Joel" ]); + ("jonas", [ "Jonas" ]); + ("matthew", [ "Matt"; "Matt." ]); + ("mark", [ "Mark" ]); + ("luke", [ "Luke" ]); + ("john", [ "John" ]); + ("acts", [ "Acts" ]); + ("romans", [ "Rom" ]); + ("corinthians_1", [ "1 Cor"; "1 Cor." ]); + ("corinthians_2", [ "2 Cor." ]); + ("galatians", [ "Gal" ]); + ("ephesians", [ "Eph"; "Eph." ]); + ("philippians", [ "Phil" ]); + ("colossians", [ "Col" ]); + ("thessalonians_1", [ "1 Thess"; "1 Thess." ]); + ("thessalonians_2", [ "2 Thess" ]); + ("titus", [ "Titus" ]); + ("hebrews", [ "Heb" ]); + ("james", [ "Jas" ]); + ("peter_1", [ "1 Pet"; "1 Pet." ]); + ("john_1", [ "1 John" ]) ] + +(* Targets a tradition can map ONTO that the Vulgate data never cites + directly. Present so [tradition_of_fields] can validate both sides. *) +let tradition_targets = + [ "kings_1"; "kings_2"; "nehemiah"; "sirach"; "hosea"; "jonah" ] + +let all = List.map fst table @ tradition_targets + +let tokens = + List.concat_map (fun (id, sp) -> List.map (fun s -> (s, id)) sp) table + +let of_token s = + let s = String.trim s in + List.assoc_opt s tokens + +type tradition = (string * string) list + +let vulgate = [] + +let known id = List.mem id all + +let tradition_of_fields fields = + List.filter (fun (a, b) -> known a && known b) fields + +let unknown_fields fields = + List.filter_map + (fun (a, b) -> if known a && known b then None else Some a) + fields + +let map tr id = match List.assoc_opt id tr with Some x -> x | None -> id diff --git a/lib/citation/book.mli b/lib/citation/book.mli new file mode 100644 index 0000000..dd04e0d --- /dev/null +++ b/lib/citation/book.mli @@ -0,0 +1,49 @@ +(* SPDX-License-Identifier: AGPL-3.0-or-later *) + +(** Bible books: one canonical id per book, and the tradition that decides + which book an id denotes. + + Ids follow the VULGATE structure ([kings_3], [esdras_2], + [ecclesiasticus]), because the Vulgate is the default tradition and the + shipped 1962 data is Vulgate throughout. An id is internal -- it is never + shown to a reader, exactly as a slug is never shown. *) + +type id + +val to_string : id -> string + +(** Resolve one spelling as it appears in the data. Returns [None] for + anything not in {!tokens}. + + SEVEN books arrive in two spellings ([Isa]/[Isa.], [3 Kgs.]/[3 Kings], + and five more). That inconsistency is INHERITED from lectio's own ini, + which is itself generated from missalemeum/Divinum Officium -- it is not + a colitur transcription error, and the data is deliberately left + untouched. Both spellings resolve here instead. *) +val of_token : string -> id option + +(** Every id this build knows, for coverage checks. *) +val all : id list + +(** Every accepted spelling paired with its id. *) +val tokens : (string * id) list + +(** A numbering tradition: which book an id denotes. Separate from NAMING + (what a book is called), which lives in a language file's [\[bible\]] + section, because naming varies by language and this does not -- "modern + numbering" is the same decision in Latin, Polish and English. *) +type tradition + +(** The identity tradition: the Vulgate, as the 1962 Missal prints it. The + default; colitur never silently renumbers. *) +val vulgate : tradition + +(** Build a tradition from an ini section's fields. An entry naming an + unknown id on either side is IGNORED, not fatal: a traditions file + written for a newer colitur must still work on an older one. Use + {!unknown_fields} to report them. *) +val tradition_of_fields : (string * string) list -> tradition + +val unknown_fields : (string * string) list -> string list + +val map : tradition -> id -> id diff --git a/lib/citation/dune b/lib/citation/dune new file mode 100644 index 0000000..e6cec3c --- /dev/null +++ b/lib/citation/dune @@ -0,0 +1,2 @@ +(library + (name colitur_citation)) -- cgit v1.3 From a4eb9ed3cd93cbac493a873991dd5817906aad56 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 20 Aug 2026 14:54:01 +0200 Subject: fix(citation): survey all three citation-bearing files, not just the lectionary The book table was built against data/ef/lectionary.sexp alone. That undercounts: sanctoral.sexp carries more citations than the lectionary and, together with commons.sexp, cites 21 book tokens the table was missing entirely (62 distinct tokens across all three files, not 42), several of them common (2 Tim appears 8+ times). Add the missing spellings to existing ids (2 Cor, Col., Exod, Ezek, James, Sir, Eccli) and ten new ids for books not cited before (1-2 Timothy, 2 Peter, Apocalypse, Judith, Malachi, Proverbs, Song of Songs, Tobit, Wisdom). Sir and Rev are modern spellings sitting inside Vulgate data, so both resolve to their Vulgate ids (ecclesiasticus, apocalypse) rather than to the sirach/revelation tradition targets -- mapping them to a second id would double-map the same book. Add a duplicate-spelling invariant test (List.assoc_opt would silently prefer the first match on a collision) and a test that re-derives the token set from all three data files at test time and asserts every token resolves, rather than trusting a survey performed once by hand. --- lib/citation/book.ml | 47 ++++++++++++++++----- lib/citation/book.mli | 21 +++++++--- test/test_citation.ml | 113 ++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 161 insertions(+), 20 deletions(-) (limited to 'lib/citation') diff --git a/lib/citation/book.ml b/lib/citation/book.ml index a2f9678..545e324 100644 --- a/lib/citation/book.ml +++ b/lib/citation/book.ml @@ -4,25 +4,36 @@ type id = string let to_string t = t -(* Every book the shipped EF lectionary cites, with every spelling it uses. - The dotted/undotted pairs are inherited from lectio -- see book.mli. *) +(* Every book cited across the shipped EF data (lectionary, sanctoral + propers, and commons), with every spelling any of the three files uses. + The dotted/undotted and modern/Vulgate pairs are inherited from lectio -- + see book.mli. Surveyed directly against the data, all three files, not + the lectionary alone -- sanctoral.sexp alone carries more citations than + the lectionary and was the source of every token missed in the first + pass. *) let table = [ ("genesis", [ "Gen" ]); - ("exodus", [ "Ex" ]); + ("exodus", [ "Ex"; "Exod" ]); ("leviticus", [ "Lev" ]); ("numbers", [ "Num" ]); ("kings_3", [ "3 Kings"; "3 Kgs." ]); ("kings_4", [ "4 Kings" ]); ("esdras_2", [ "2 Esd." ]); + ("tobit", [ "Tob" ]); + ("judith", [ "Judith" ]); ("esther", [ "Esther" ]); - ("ecclesiasticus", [ "Ecclus" ]); + ("proverbs", [ "Prov" ]); + ("song_of_songs", [ "Song" ]); + ("wisdom", [ "Wis"; "Wis." ]); + ("ecclesiasticus", [ "Ecclus"; "Sir"; "Eccli" ]); ("isaiah", [ "Isa"; "Isa." ]); ("jeremiah", [ "Jer" ]); - ("ezekiel", [ "Ezech" ]); + ("ezekiel", [ "Ezech"; "Ezek" ]); ("daniel", [ "Dan" ]); ("osee", [ "Osee" ]); ("joel", [ "Joel" ]); ("jonas", [ "Jonas" ]); + ("malachi", [ "Mal" ]); ("matthew", [ "Matt"; "Matt." ]); ("mark", [ "Mark" ]); ("luke", [ "Luke" ]); @@ -30,23 +41,37 @@ let table = ("acts", [ "Acts" ]); ("romans", [ "Rom" ]); ("corinthians_1", [ "1 Cor"; "1 Cor." ]); - ("corinthians_2", [ "2 Cor." ]); + ("corinthians_2", [ "2 Cor"; "2 Cor." ]); ("galatians", [ "Gal" ]); ("ephesians", [ "Eph"; "Eph." ]); ("philippians", [ "Phil" ]); - ("colossians", [ "Col" ]); + ("colossians", [ "Col"; "Col." ]); ("thessalonians_1", [ "1 Thess"; "1 Thess." ]); ("thessalonians_2", [ "2 Thess" ]); + ("timothy_1", [ "1 Tim." ]); + ("timothy_2", [ "2 Tim"; "2 Tim." ]); ("titus", [ "Titus" ]); ("hebrews", [ "Heb" ]); - ("james", [ "Jas" ]); + ("james", [ "Jas"; "James" ]); ("peter_1", [ "1 Pet"; "1 Pet." ]); - ("john_1", [ "1 John" ]) ] + ("peter_2", [ "2 Pet." ]); + ("john_1", [ "1 John" ]); + (* "Apoc" is the Vulgate spelling and "Rev" its modern equivalent, but + BOTH sit inside Vulgate-tradition data, so both resolve to the same + Vulgate id here -- see book.mli's note by [apocalypse] never being an + [of_token] result under that name. Do not add "revelation" as a + spelling: it exists only as a tradition target (below), and giving it + an [of_token] entry would let one book carry two different ids. *) + ("apocalypse", [ "Apoc"; "Rev" ]) ] (* Targets a tradition can map ONTO that the Vulgate data never cites - directly. Present so [tradition_of_fields] can validate both sides. *) + directly. Present so [tradition_of_fields] can validate both sides. + [sirach] and [revelation] exist ONLY here, never as an [of_token] result: + "Sir" and "Rev" already resolve to the Vulgate ids [ecclesiasticus] and + [apocalypse] above, so a modern-numbering tradition maps ONTO these + targets rather than data ever citing them directly. *) let tradition_targets = - [ "kings_1"; "kings_2"; "nehemiah"; "sirach"; "hosea"; "jonah" ] + [ "kings_1"; "kings_2"; "nehemiah"; "sirach"; "hosea"; "jonah"; "revelation" ] let all = List.map fst table @ tradition_targets diff --git a/lib/citation/book.mli b/lib/citation/book.mli index dd04e0d..8918bc3 100644 --- a/lib/citation/book.mli +++ b/lib/citation/book.mli @@ -15,11 +15,19 @@ val to_string : id -> string (** Resolve one spelling as it appears in the data. Returns [None] for anything not in {!tokens}. - SEVEN books arrive in two spellings ([Isa]/[Isa.], [3 Kgs.]/[3 Kings], - and five more). That inconsistency is INHERITED from lectio's own ini, - which is itself generated from missalemeum/Divinum Officium -- it is not - a colitur transcription error, and the data is deliberately left - untouched. Both spellings resolve here instead. *) + SIXTEEN books arrive in more than one spelling, surveyed across all + three citation-bearing files ([lectionary.sexp], [sanctoral.sexp], + [commons.sexp] -- not the lectionary alone, which undercounts: the + sanctoral propers alone carry more citations than the lectionary does). + Most are a dotted/undotted pair ([Isa]/[Isa.], [3 Kgs.]/[3 Kings], and + others); two also carry a MODERN spelling sitting inside otherwise- + Vulgate data ([Sir] alongside [Ecclus]/[Eccli], [Rev] alongside [Apoc]). + All of this is INHERITED from lectio's own ini, which is itself + generated from missalemeum/Divinum Officium -- it is not a colitur + transcription error, and the data is deliberately left untouched. Every + accepted spelling resolves here to the same, single Vulgate id: [Sir] + resolves to [ecclesiasticus] and [Rev] to [apocalypse], never to the + tradition-only targets [sirach]/[revelation] -- see those below. *) val of_token : string -> id option (** Every id this build knows, for coverage checks. *) @@ -44,6 +52,9 @@ val vulgate : tradition {!unknown_fields} to report them. *) val tradition_of_fields : (string * string) list -> tradition +(** The fields {!tradition_of_fields} silently dropped -- either side naming + an id outside {!all}. Never raises; a caller that cares can report these, + a caller that does not can ignore the return value entirely. *) val unknown_fields : (string * string) list -> string list val map : tradition -> id -> id diff --git a/test/test_citation.ml b/test/test_citation.ml index 0694d8b..7bc8ac7 100644 --- a/test/test_citation.ml +++ b/test/test_citation.ml @@ -3,8 +3,11 @@ module B = Colitur_citation.Book let s = Alcotest.string let test_both_spellings_are_one_book () = - (* The seven inherited duplicate spellings must collapse. This is the - whole reason the parser exists rather than a regex. *) + (* The books listed here arrive in more than one spelling and must + collapse onto one id. This is the whole reason the parser exists rather + than a regex. Most pairs are dotted/undotted; "Sir"/"Ecclus" and + "Apoc"/"Rev" are a modern spelling sitting inside otherwise-Vulgate + data -- see book.mli. *) let same a b = match B.of_token a, B.of_token b with | Some x, Some y -> @@ -17,7 +20,16 @@ let test_both_spellings_are_one_book () = same "1 Pet" "1 Pet."; same "1 Thess" "1 Thess."; same "Eph" "Eph."; - same "3 Kgs." "3 Kings" + same "3 Kgs." "3 Kings"; + same "Sir" "Ecclus"; + same "Apoc" "Rev"; + same "2 Cor" "2 Cor."; + same "Col" "Col."; + same "Wis" "Wis."; + same "2 Tim" "2 Tim."; + same "Ex" "Exod"; + same "Ezech" "Ezek"; + same "Jas" "James" let test_unknown_token_is_none () = Alcotest.(check bool) "not a book" true (B.of_token "Nonesuch" = None) @@ -35,9 +47,102 @@ let test_modern_renumbers () = | Some k -> Alcotest.(check s) "renumbered" "kings_1" (B.to_string (B.map modern k)) +let test_tokens_has_no_duplicate_spelling () = + (* [of_token] resolves via [List.assoc_opt], which silently prefers the + first match on a duplicate key. A copy-paste collision in the table + would therefore mis-map a book in total silence, never an exception -- + assert the invariant directly rather than trust it by inspection. *) + let spellings = List.map fst B.tokens in + let sorted = List.sort compare spellings in + let rec find_dup = function + | a :: (b :: _ as rest) -> if a = b then Some a else find_dup rest + | _ -> None + in + match find_dup sorted with + | None -> () + | Some dup -> Alcotest.failf "duplicate spelling in Book.tokens: %S" dup + +(* Read a whole file into a string. Test-only I/O; the library itself never + touches the filesystem. *) +let read_file path = + let ic = open_in_bin path in + let n = in_channel_length ic in + let content = really_input_string ic n in + close_in ic; + content + +let starts_with_at content pos prefix = + let plen = String.length prefix in + pos + plen <= String.length content && String.sub content pos plen = prefix + +(* Every [(reference ...)] payload in a data file, in file order. No + [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 mlen = String.length marker in + let len = String.length content in + let rec loop pos acc = + if pos >= len then List.rev acc + else if starts_with_at content pos marker then + let start = pos + mlen in + match String.index_from_opt content start '"' with + | None -> List.rev acc + | Some close -> + let payload = String.sub content start (close - start) in + loop (close + 1) (payload :: acc) + else loop (pos + 1) acc + in + loop 0 [] + +let is_alpha c = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') +let is_one_to_four c = c >= '1' && c <= '4' + +(* The leading book token of one reference payload, e.g. ["2 Tim 4:1-8"] -> + ["2 Tim"], ["Wis. 5:1-5"] -> ["Wis."]. Mirrors the coordinator's own + survey command's second stage: + `sed -E 's/^(([1-4] )?[A-Za-z]+\.?).*/\1/'`. *) +let book_token r = + let len = String.length r in + let start = if len >= 2 && is_one_to_four r.[0] && r.[1] = ' ' then 2 else 0 in + let i = ref start in + while !i < len && is_alpha r.[!i] do + incr i + done; + let stop = if !i < len && r.[!i] = '.' then !i + 1 else !i in + String.sub r 0 stop + +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. *) + let files = + [ "../data/ef/lectionary.sexp"; "../data/ef/sanctoral.sexp"; "../data/ef/commons.sexp" ] + in + let tokens = + files + |> List.concat_map (fun f -> references (read_file f)) + |> List.map book_token + |> List.sort_uniq compare + in + Alcotest.(check bool) "at least one token found" true (List.length tokens > 0); + let unresolved = List.filter (fun t -> B.of_token t = None) tokens in + match unresolved with + | [] -> () + | _ -> + Alcotest.failf "unresolved book tokens in shipped data: %s" + (String.concat ", " unresolved) + let suite = ( "book", [ Alcotest.test_case "both spellings one book" `Quick test_both_spellings_are_one_book; Alcotest.test_case "unknown token" `Quick test_unknown_token_is_none; Alcotest.test_case "vulgate identity" `Quick test_vulgate_is_identity; - Alcotest.test_case "modern renumbers" `Quick test_modern_renumbers ] ) + Alcotest.test_case "modern renumbers" `Quick test_modern_renumbers; + 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 ] ) -- cgit v1.3 From 56e36188c6afc208b96b15b9a1d3d62bbbe2f705 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 20 Aug 2026 14:59:03 +0200 Subject: feat(citation): parse citations into structure The parsed form is a book and a LIST of chapter-parts: the data cites across chapters and lists disjoint verse ranges within one. Two rules the shipped data forces and that are not obvious: a semicolon-separated part may inherit the previous chapter rather than restate it, and a chapter may be separated from its verses by a comma. --- lib/citation/parse.ml | 110 +++++++++++++++++++++++++++++++++++++++++++++++++ lib/citation/parse.mli | 15 +++++++ test/test_citation.ml | 54 ++++++++++++++++++++++++ test/test_colitur.ml | 1 + 4 files changed, 180 insertions(+) create mode 100644 lib/citation/parse.ml create mode 100644 lib/citation/parse.mli (limited to 'lib/citation') diff --git a/lib/citation/parse.ml b/lib/citation/parse.ml new file mode 100644 index 0000000..45fbb72 --- /dev/null +++ b/lib/citation/parse.ml @@ -0,0 +1,110 @@ +(* SPDX-License-Identifier: AGPL-3.0-or-later *) + +type verse_range = { first : int; last : int option } +type part = { chapter : int; verses : verse_range list } +type t = { book : Book.id; parts : part list } + +let split_on c s = String.split_on_char c s |> List.map String.trim + +(* The book is the longest leading run of non-digit words, allowing one + leading ordinal ("1 Cor", "3 Kings"). Everything after it is the + reference tail. *) +let split_book s = + let n = String.length s in + let i = ref 0 in + (* optional leading ordinal digit *) + if !i < n && s.[!i] >= '1' && s.[!i] <= '4' then begin + incr i; + while !i < n && s.[!i] = ' ' do incr i done + end; + (* letters and dots *) + while !i < n && (s.[!i] = '.' || (s.[!i] >= 'A' && s.[!i] <= 'z')) do incr i done; + if !i = 0 then None + else + let book = String.trim (String.sub s 0 !i) in + let tail = String.trim (String.sub s !i (n - !i)) in + if book = "" || tail = "" then None else Some (book, tail) + +let int_opt s = int_of_string_opt (String.trim s) + +(* "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; b ] -> ( + match (int_opt a, int_opt b) with + | Some f, Some l -> Some { first = f; last = Some l } + | _ -> None) + | _ -> None + +let parse_ranges s = + let pieces = split_on ',' s in + List.fold_right + (fun p acc -> + match (parse_range p, acc) with + | Some r, Some rest -> Some (r :: rest) + | _ -> None) + 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 = + 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) + | _ -> None + +let parse s = + let s = String.trim s in + (* A trailing period is decoration, not data: 22 citations carry one. *) + let s = + let n = String.length s in + if n > 0 && s.[n - 1] = '.' then String.sub s 0 (n - 1) else s + in + match split_book s with + | None -> Error "no book" + | Some (btok, tail) -> ( + match Book.of_token btok with + | None -> Error ("unknown book: " ^ btok) + | Some book -> + (* 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 rec go inherited = function + | [] -> Ok [] + | p :: rest -> ( + match parse_part ~inherited p with + | None -> Error ("cannot read reference: " ^ p) + | Some part -> ( + match go (Some part.chapter) rest with + | Error e -> Error e + | Ok more -> Ok (part :: more))) + in + (match go None pieces with + | Error e -> Error e + | Ok [] -> Error "empty reference" + | Ok parts -> Ok { book; parts })) diff --git a/lib/citation/parse.mli b/lib/citation/parse.mli new file mode 100644 index 0000000..3d1053c --- /dev/null +++ b/lib/citation/parse.mli @@ -0,0 +1,15 @@ +(* SPDX-License-Identifier: AGPL-3.0-or-later *) + +(** A citation, parsed. Never raises; an unrecognised string is an [Error] + naming what could not be read, never a silent pass-through. + + The shape is a book and a LIST of chapter-parts, not one chapter and one + verse range, because the shipped data really does cite across chapters + ([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 } +type part = { chapter : int; verses : verse_range list } +type t = { book : Book.id; parts : part list } + +val parse : string -> (t, string) result diff --git a/test/test_citation.ml b/test/test_citation.ml index 7bc8ac7..75fffe8 100644 --- a/test/test_citation.ml +++ b/test/test_citation.ml @@ -146,3 +146,57 @@ let suite = test_tokens_has_no_duplicate_spelling; Alcotest.test_case "every data file token resolves" `Quick test_every_data_file_token_resolves ] ) + +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". *) +let show (t : P.t) = + let range (r : P.verse_range) = + match r.P.last with + | None -> string_of_int r.P.first + | Some l -> Printf.sprintf "%d-%d" r.P.first l + in + let part (p : P.part) = + Printf.sprintf "%d:%s" p.P.chapter + (String.concat "," (List.map range p.P.verses)) + in + B.to_string t.P.book ^ "|" ^ String.concat "|" (List.map part t.P.parts) + +let parses input expected () = + match P.parse input with + | Error e -> Alcotest.failf "%s did not parse: %s" input e + | Ok t -> Alcotest.(check s) input expected (show t) + +let test_rejects_unknown_book () = + Alcotest.(check bool) "error" true (Result.is_error (P.parse "Nonesuch 1:1")) + +let test_rejects_garbage () = + List.iter + (fun bad -> + Alcotest.(check bool) bad true (Result.is_error (P.parse bad))) + [ ""; "Luke"; "Luke :"; "Luke 1:"; "Luke abc:1" ] + +let parse_suite = + [ ("simple", `Quick, parses "1 Cor 11:20-32" "corinthians_1|11:20-32"); + ("trailing period", `Quick, parses "1 John 3:13-18." "john_1|3:13-18"); + ("single verse", `Quick, parses "Luke 2:21" "luke|2:21"); + ("dotted spelling", `Quick, parses "Isa. 1:16-19" "isaiah|1:16-19"); + ("verse list", `Quick, parses "Acts 10:34, 42-48" "acts|10:34,42-48"); + ("new chapter", `Quick, parses "1 Cor. 9:24-27; 10:1-5" + "corinthians_1|9:24-27|10:1-5"); + (* Rule 1: the second part names no chapter, so it inherits chapter 2. *) + ("inherited chapter", `Quick, parses "Joel 2:23-24; 26-27" "joel|2:23-24|2:26-27"); + (* Rule 2: comma separates chapter from verses in the second part. *) + ("comma chapter", `Quick, parses "Mark 14:32-72; 15, 1-46" + "mark|14:32-72|15:1-46"); + ("four ranges", `Quick, parses "Dan 13:1-9, 15-17, 19-30, 33-62." + "daniel|13:1-9,15-17,19-30,33-62"); + ("mixed", `Quick, parses "Num 20:1, 3; 6-13." "numbers|20:1,3|20:6-13"); + ("trailing semicolon", `Quick, parses "1 Cor 1:18-25; 1:30;" + "corinthians_1|1:18-25|1:30"); + ("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"); + ("unknown book", `Quick, test_rejects_unknown_book); + ("garbage", `Quick, test_rejects_garbage) ] diff --git a/test/test_colitur.ml b/test/test_colitur.ml index 46a31aa..6fe212d 100644 --- a/test/test_colitur.ml +++ b/test/test_colitur.ml @@ -5,6 +5,7 @@ let () = Test_lang.suite; Test_lang_coverage.suite; Test_citation.suite; + ("parse", Test_citation.parse_suite); Test_config.suite; Test_overlay.suite; Test_overlay_ini.suite; Test_temporal_ef.suite; Test_validate.suite; Test_precedence.suite; Test_calendar.suite; Test_precedence_ef.suite; Test_sanctoral_ef.suite; Test_rite_ef.suite; -- cgit v1.3 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 ++++++++++++++++++++++++++++++++++++++++++++++ lib/citation/render.mli | 48 +++++++++++++++++++++ test/test_citation.ml | 111 ++++++++++++++++++++++++++++++++++++++++++++++++ test/test_colitur.ml | 1 + 4 files changed, 267 insertions(+) create mode 100644 lib/citation/render.ml create mode 100644 lib/citation/render.mli (limited to 'lib/citation') 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 } diff --git a/lib/citation/render.mli b/lib/citation/render.mli new file mode 100644 index 0000000..d741931 --- /dev/null +++ b/lib/citation/render.mli @@ -0,0 +1,48 @@ +(* SPDX-License-Identifier: AGPL-3.0-or-later *) + +(** Turn a parsed citation back into text, in a configurable style. + + A style is data, not code, so a tradition of punctuation is a file + someone can write: [Luke 5:12-14], [Lk 5:12-14], [Luke 5.12-14], + [{L}k 5, 12-14] are all the same citation in different conventions. + + Placeholders: [{chapter}], [{chapter_roman}] and [{verses}] in + [chapter_verse]; [{first}] and [{last}] in [range]. An UNKNOWN + placeholder survives literally, so a typo is visible in the output + rather than silently swallowed. *) + +type style + +(** The Vulgate/Latin convention: abbreviated book, [chapter:verses], + [first-last], ["; "] between parts, [", "] between verse ranges. *) +val default_style : style + +(** Read a style from a language file's [\[sigla\]] section. + + Values are UNQUOTED here: one matching pair of surrounding double quotes + is stripped, so a separator's significant trailing space survives. + {!Colitur_kernel.Overlay_ini} trims every value and has no quote + handling, and it is shared with overlays and [\[defaults\]] -- so the + unquoting belongs here, not there. An unrecognised key is ignored; + a missing key keeps {!default_style}'s value. *) +val style_of_fields : (string * string) list -> style + +(** Override the book form, for the [sigla_book] config key. *) +val with_book : [ `Full | `Abbr ] -> style -> style + +val part_sep : style -> string +val range : style -> string + +(** What separates the book name from the reference. Default [" "]. + + Settable because a typeset booklet wants a NON-BREAKING space here -- a + line break between "Luc." and "3, 1" is exactly the ugliness this + prevents. Set it to a literal U+00A0: the escapers match ASCII bytes + only, so a UTF-8 multibyte sequence passes through every flavour + untouched (verified for latex, typst, groff, html, xml, ics). A LaTeX + tie [~] does NOT work -- {!Colitur_render.Escape} turns it into + [\textasciitilde{}]. *) +val book_sep : style -> string + +val render : + style -> names:(Book.id -> [ `Full | `Abbr ] -> string) -> Parse.t -> string diff --git a/test/test_citation.ml b/test/test_citation.ml index 75fffe8..e234942 100644 --- a/test/test_citation.ml +++ b/test/test_citation.ml @@ -200,3 +200,114 @@ let parse_suite = ("modern name, vulgate id", `Quick, parses "Rev 12:1" "apocalypse|12:1"); ("unknown book", `Quick, test_rejects_unknown_book); ("garbage", `Quick, test_rejects_garbage) ] + +module R = Colitur_citation.Render + +let names id form = + let n = B.to_string id in + match form with `Abbr -> (if n = "luke" then "Luc." else n) + | `Full -> (if n = "luke" then "Evangelium secundum Lucam" else n) + +let render_with fields input expected () = + let style = R.style_of_fields fields in + match P.parse input with + | Error e -> Alcotest.failf "%s did not parse: %s" input e + | Ok t -> Alcotest.(check s) input expected (R.render style ~names t) + +let test_unquotes_trailing_space () = + let st = R.style_of_fields [ ("part_sep", "\"; \"") ] in + Alcotest.(check s) "quotes stripped, space kept" "; " (R.part_sep st) + +let test_bare_value_untouched () = + let st = R.style_of_fields [ ("range", "{first}-{last}") ] in + Alcotest.(check s) "no quotes" "{first}-{last}" (R.range st) + +(* [subst] has no test pressure of its own anywhere else in the suite, so + these three isolate its contract directly through the only surface that + calls it: a [range]/[chapter_verse] template chosen so nothing else in + the pipeline (verse-list joining, book naming) can mask the result. *) +let test_subst_no_placeholder () = + (* A template with no "{" at all passes through completely unchanged. *) + render_with [ ("book", "abbr"); ("chapter_verse", "fixed-text") ] "Luke 2:21" + "Luc. fixed-text" () + +let test_subst_two_placeholders () = + (* Two DIFFERENT known placeholders, reordered relative to the record's + own field order ([last] before [first]) -- proves substitution is by + name, not by position. *) + render_with + [ ("book", "abbr"); ("range", "{last}~{first}") ] + "Luke 5:12-14" "Luc. 5:14~12" () + +let test_subst_unknown_placeholder_alone () = + (* A template that is NOTHING but an unrecognised placeholder: it must + survive byte-for-byte, proving [subst] never touches an unmatched + "{...}" run even when there is no surrounding literal text to anchor + on. *) + render_with [ ("book", "abbr"); ("range", "{nope}") ] "Luke 5:12-14" + "Luc. 5:{nope}" () + +(* [roman_numeral] is private to Render (not in the .mli, matching + [View.roman_numeral]'s own precedent -- tested indirectly, never + exposed). Reached through [render] with a [chapter_verse] template of + bare [{chapter_roman}], [book_sep] emptied and [names] returning "", so + the assertion isolates exactly the numeral and nothing else. *) +let roman_of n = + let luke = match B.of_token "Luke" with Some id -> id | None -> assert false in + let t : P.t = { P.book = luke; parts = [ { P.chapter = n; verses = [ { P.first = 1; last = None } ] } ] } in + let style = R.style_of_fields [ ("book_sep", ""); ("chapter_verse", "{chapter_roman}") ] in + R.render style ~names:(fun _ _ -> "") t + +let test_roman_numeral_values () = + List.iter + (fun (n, expected) -> Alcotest.(check s) (string_of_int n) expected (roman_of n)) + [ (1, "I"); (4, "IV"); (9, "IX"); (14, "XIV"); (40, "XL"); (150, "CL") ] + +let test_roman_numeral_non_positive () = + (* The one input that could loop forever in a naive implementation: a + non-positive chapter returns the arabic form instead. Completing at + all is part of what this test proves. *) + Alcotest.(check s) "zero" "0" (roman_of 0); + Alcotest.(check s) "negative" "-3" (roman_of (-3)) + +let render_suite = + ( "Citation/render", + [ Alcotest.test_case "latin default" `Quick + (render_with [ ("book", "abbr") ] "Luke 5:12-14" "Luc. 5:12-14"); + Alcotest.test_case "full name" `Quick + (render_with [ ("book", "full") ] "Luke 5:12-14" + "Evangelium secundum Lucam 5:12-14"); + Alcotest.test_case "comma style" `Quick + (render_with + [ ("book", "abbr"); ("chapter_verse", "{chapter}, {verses}") ] + "Luke 5:12-14" "Luc. 5, 12-14"); + Alcotest.test_case "multi part" `Quick + (render_with [ ("book", "abbr"); ("part_sep", "\"; \"") ] + "Joel 2:23-24; 26-27" "joel 2:23-24; 2:26-27"); + Alcotest.test_case "unquote" `Quick test_unquotes_trailing_space; + Alcotest.test_case "bare value" `Quick test_bare_value_untouched; + (* The Missal's own convention, scan-verified: "Matth. 11, 2" / + "Ioann. 1, 1" -- dotted abbreviation, comma, ARABIC chapter. *) + Alcotest.test_case "missal convention" `Quick + (render_with + [ ("book", "abbr"); ("chapter_verse", "{chapter}, {verses}") ] + "Luke 5:12-14" "Luc. 5, 12-14"); + Alcotest.test_case "roman chapter" `Quick + (render_with + [ ("book", "abbr"); ("chapter_verse", "{chapter_roman}, {verses}") ] + "Luke 5:12-14" "Luc. V, 12-14"); + (* U+00A0 between book and reference, for typeset output. *) + Alcotest.test_case "nbsp book sep" `Quick + (render_with [ ("book", "abbr"); ("book_sep", "\"\xc2\xa0\"") ] + "Luke 5:12-14" "Luc.\xc2\xa05:12-14"); + Alcotest.test_case "unknown placeholder survives" `Quick + (render_with + [ ("book", "abbr"); ("chapter_verse", "{chapter}:{nope}") ] + "Luke 5:12-14" "Luc. 5:{nope}"); + Alcotest.test_case "subst: no placeholder" `Quick test_subst_no_placeholder; + Alcotest.test_case "subst: two placeholders" `Quick test_subst_two_placeholders; + Alcotest.test_case "subst: unknown placeholder alone" `Quick + test_subst_unknown_placeholder_alone; + Alcotest.test_case "roman_numeral: table values" `Quick test_roman_numeral_values; + Alcotest.test_case "roman_numeral: non-positive" `Quick + test_roman_numeral_non_positive ] ) diff --git a/test/test_colitur.ml b/test/test_colitur.ml index fb2c20c..6084adb 100644 --- a/test/test_colitur.ml +++ b/test/test_colitur.ml @@ -7,6 +7,7 @@ let () = Test_citation_coverage.suite; Test_citation.suite; ("parse", Test_citation.parse_suite); + Test_citation.render_suite; Test_config.suite; Test_overlay.suite; Test_overlay_ini.suite; Test_temporal_ef.suite; Test_validate.suite; Test_precedence.suite; Test_calendar.suite; Test_precedence_ef.suite; Test_sanctoral_ef.suite; Test_rite_ef.suite; -- cgit v1.3 From ec70286daa72fd2a2a78a1cbab0fe2c268e5c743 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 20 Aug 2026 15:13:37 +0200 Subject: feat(citation): default_spelling, and close two test gaps Book.default_spelling returns the first registered spelling for an id. It is the fallback display name, and it exists because the alternative is worse: a language file's [bible] lookup is total and returns THE KEY on a miss, so a book with no entry would render as "luke.abbr 5:12-14". Falling back to the data's own spelling makes it render as "Luke 5:12-14" instead -- what colitur printed before this feature existed. The degraded case is the old behaviour, the same principle Lang states for its own key-returning misses. Two test gaps closed, both found by mutation rather than by reading: Parse's split_book scans a leading ordinal digit over '1'..'4', and no case in the suite used an ordinal above 1. Narrowing the range to '1'..'3' passed every test while seven real citations depend on it ("3 Kings 17:8-16", "4 Kings 5:1-15"). Parse-layer cases added; the first attempt at this test asserted through Book.of_token, which is a table lookup and never reaches split_book at all. default_spelling is asserted to round-trip: every cited id's fallback spelling must itself resolve back to that id, or Render and Parse disagree the moment a book goes unnamed. --- lib/citation/book.ml | 5 +++++ lib/citation/book.mli | 19 +++++++++++++++++++ test/test_citation.ml | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) (limited to 'lib/citation') diff --git a/lib/citation/book.ml b/lib/citation/book.ml index 545e324..2a2b9c0 100644 --- a/lib/citation/book.ml +++ b/lib/citation/book.ml @@ -78,6 +78,11 @@ let all = List.map fst table @ tradition_targets let tokens = List.concat_map (fun (id, sp) -> List.map (fun s -> (s, id)) sp) table +let default_spelling id = + match List.assoc_opt id table with + | Some (first :: _) -> first + | Some [] | None -> id + let of_token s = let s = String.trim s in List.assoc_opt s tokens diff --git a/lib/citation/book.mli b/lib/citation/book.mli index 8918bc3..0ca229c 100644 --- a/lib/citation/book.mli +++ b/lib/citation/book.mli @@ -36,6 +36,25 @@ val all : id list (** Every accepted spelling paired with its id. *) val tokens : (string * id) list +(** The first spelling registered for an id -- the form the shipped data + itself uses ([luke] -> ["Luke"], [kings_3] -> ["3 Kings"]). + + This is the FALLBACK display name, and it exists because the obvious + alternative is actively worse. A language file's [\[bible\]] lookup is + total and returns THE KEY on a miss, so a book with no entry would + otherwise render as ["luke.abbr 5:12-14"]. Falling back here instead makes + an unnamed book render as ["Luke 5:12-14"] -- exactly what colitur printed + before this feature existed. The degraded case is the OLD behaviour, not a + broken page, the same principle {!Colitur_naming.Lang} states for its own + key-returning misses. + + An id with no registered spelling -- only a {!tradition} target such as + [kings_1] or [sirach], which the Vulgate data never cites -- returns the id + itself. Reachable only from a user language file that selects a tradition + without naming its target books; every SHIPPED language file is asserted + complete over {!all}. *) +val default_spelling : id -> string + (** A numbering tradition: which book an id denotes. Separate from NAMING (what a book is called), which lives in a language file's [\[bible\]] section, because naming varies by language and this does not -- "modern diff --git a/test/test_citation.ml b/test/test_citation.ml index e234942..9405d39 100644 --- a/test/test_citation.ml +++ b/test/test_citation.ml @@ -31,6 +31,44 @@ let test_both_spellings_are_one_book () = same "Ezech" "Ezek"; same "Jas" "James" +(* The ordinal spellings resolve in the token table. NOTE: this does NOT + exercise [Parse.split_book]'s leading-digit scan -- of_token is a plain + table lookup. The parse-layer cases in [parse_suite] cover that; both are + needed, and confusing the two is how the gap survived review once already. *) +let test_ordinal_books_beyond_one () = + let id t = + match B.of_token t with + | Some x -> B.to_string x + | None -> Alcotest.failf "%s did not resolve" t + in + Alcotest.(check s) "3 Kings" "kings_3" (id "3 Kings"); + Alcotest.(check s) "3 Kgs." "kings_3" (id "3 Kgs."); + Alcotest.(check s) "4 Kings" "kings_4" (id "4 Kings") + +(* The fallback display name: what a book renders as when a language file has + no [bible] entry for it. Must be the data's own spelling, never the id -- + and must itself re-parse, or Render/Parse round-tripping breaks. *) +let test_default_spelling () = + let sp t = + match B.of_token t with + | Some x -> B.default_spelling x + | None -> Alcotest.failf "%s did not resolve" t + in + Alcotest.(check s) "luke" "Luke" (sp "Luke"); + Alcotest.(check s) "kings_3 uses first spelling" "3 Kings" (sp "3 Kgs."); + let cited = List.map snd B.tokens in + List.iter + (fun id -> + if List.mem id cited then begin + let d = B.default_spelling id in + match B.of_token d with + | Some back when B.to_string back = B.to_string id -> () + | _ -> + Alcotest.failf "default_spelling %s = %S does not resolve back" + (B.to_string id) d + end) + B.all + let test_unknown_token_is_none () = Alcotest.(check bool) "not a book" true (B.of_token "Nonesuch" = None) @@ -139,6 +177,8 @@ let test_every_data_file_token_resolves () = let suite = ( "book", [ Alcotest.test_case "both spellings one book" `Quick test_both_spellings_are_one_book; + Alcotest.test_case "ordinal books 3 and 4" `Quick test_ordinal_books_beyond_one; + Alcotest.test_case "default spelling round-trips" `Quick test_default_spelling; Alcotest.test_case "unknown token" `Quick test_unknown_token_is_none; Alcotest.test_case "vulgate identity" `Quick test_vulgate_is_identity; Alcotest.test_case "modern renumbers" `Quick test_modern_renumbers; @@ -198,6 +238,14 @@ 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"); + (* 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 + Book.of_token test does NOT cover this -- that is a table lookup and + never reaches split_book. *) + ("ordinal 3 parses", `Quick, parses "3 Kings 17:8-16" "kings_3|17:8-16"); + ("ordinal 3 dotted", `Quick, parses "3 Kgs. 19:3-8" "kings_3|19:3-8"); + ("ordinal 4 parses", `Quick, parses "4 Kings 5:1-15" "kings_4|5:1-15"); ("unknown book", `Quick, test_rejects_unknown_book); ("garbage", `Quick, test_rejects_garbage) ] -- cgit v1.3 From 987175105ead0ea78b960c0e638a81c6fd2384cf Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 20 Aug 2026 15:44:29 +0200 Subject: feat(citation): the Sigla facade, total by construction format never raises and returns an unparseable citation unchanged, so a gap degrades to today's behaviour rather than to a crash. The coverage test asserts separately that no shipped citation takes that path. verbatim is what --raw uses: an identity name table would still reformat punctuation and renumber, which would break byte-exact diffing against lectio. The existing citation-coverage walk (1970-2070) now also drives a round-trip check in the same pass: parse -> render -> parse must reach the same structure, using Book.default_spelling for names (Book.to_string returns the internal id, which is not a registered token and cannot be read back). Proved with a mutation: changing the default style's part_sep to a separator the parser does not accept reddened the round-trip case on 31 multi-part citations and nothing else; reverted. --- lib/citation/sigla.ml | 22 ++++++++++ lib/citation/sigla.mli | 30 ++++++++++++++ test/test_citation.ml | 21 ++++++++++ test/test_citation_coverage.ml | 94 +++++++++++++++++++++++++++++++----------- test/test_colitur.ml | 1 + 5 files changed, 144 insertions(+), 24 deletions(-) create mode 100644 lib/citation/sigla.ml create mode 100644 lib/citation/sigla.mli (limited to 'lib/citation') diff --git a/lib/citation/sigla.ml b/lib/citation/sigla.ml new file mode 100644 index 0000000..a4f4555 --- /dev/null +++ b/lib/citation/sigla.ml @@ -0,0 +1,22 @@ +(* SPDX-License-Identifier: AGPL-3.0-or-later *) + +type t = + | Verbatim + | Styled of { + style : Render.style; + tradition : Book.tradition; + names : Book.id -> [ `Full | `Abbr ] -> string; + } + +let verbatim = Verbatim +let make ~style ~tradition ~names = Styled { style; tradition; names } + +let format t s = + match t with + | Verbatim -> s + | Styled { style; tradition; names } -> ( + match Parse.parse s with + | Error _ -> s + | Ok c -> + let c = { c with Parse.book = Book.map tradition c.Parse.book } in + Render.render style ~names c) diff --git a/lib/citation/sigla.mli b/lib/citation/sigla.mli new file mode 100644 index 0000000..d21877a --- /dev/null +++ b/lib/citation/sigla.mli @@ -0,0 +1,30 @@ +(* SPDX-License-Identifier: AGPL-3.0-or-later *) + +(** The whole citation pipeline as one TOTAL function, so a call site needs + one value and one call rather than a parse/map/render dance. + + {!format} never raises and never drops text: a citation that does not + parse is returned UNCHANGED. That combination is deliberate -- graceful + in production, while [test_citation_coverage.ml] asserts strictly that + no shipped citation actually takes that path. *) + +type t + +val make : + style:Render.style -> + tradition:Book.tradition -> + names:(Book.id -> [ `Full | `Abbr ] -> string) -> + t + +(** Returns every citation exactly as given. This is what [--raw] uses, the + same shape as {!Colitur_naming.Lang.raw}: raw output is one value passed + around, not a special case threaded through every call site. + + [--raw] must NOT merely use an identity name table -- that would still + reformat punctuation and apply a tradition. Byte-exact output is what + makes [--raw] usable for diffing against lectio, and it also keeps the + raw view independent of the parser, so a parser bug cannot corrupt the + output used to diagnose it. *) +val verbatim : t + +val format : t -> string -> string diff --git a/test/test_citation.ml b/test/test_citation.ml index 9405d39..25fc260 100644 --- a/test/test_citation.ml +++ b/test/test_citation.ml @@ -359,3 +359,24 @@ let render_suite = Alcotest.test_case "roman_numeral: table values" `Quick test_roman_numeral_values; Alcotest.test_case "roman_numeral: non-positive" `Quick test_roman_numeral_non_positive ] ) + +module S = Colitur_citation.Sigla + +let test_verbatim_is_identity () = + List.iter + (fun c -> Alcotest.(check s) c c (S.format S.verbatim c)) + [ "3 Kgs. 19:3-8"; "Isa. 1:16-19"; "not a citation at all" ] + +let test_unparseable_survives_unchanged () = + let sg = S.make ~style:R.default_style ~tradition:B.vulgate ~names in + Alcotest.(check s) "passed through" "Nonesuch 1:1" (S.format sg "Nonesuch 1:1") + +let test_tradition_applies () = + let modern = B.tradition_of_fields [ ("kings_3", "kings_1") ] in + let sg = S.make ~style:R.default_style ~tradition:modern ~names in + Alcotest.(check s) "renumbered" "kings_1 19:3-8" (S.format sg "3 Kings 19:3-8") + +let sigla_suite = + [ ("verbatim identity", `Quick, test_verbatim_is_identity); + ("unparseable survives", `Quick, test_unparseable_survives_unchanged); + ("tradition applies", `Quick, test_tradition_applies) ] diff --git a/test/test_citation_coverage.ml b/test/test_citation_coverage.ml index 31d49eb..a131d50 100644 --- a/test/test_citation_coverage.ml +++ b/test/test_citation_coverage.ml @@ -6,33 +6,78 @@ Same shape as test_lang_coverage.ml, which this file deliberately mirrors: a miss there is a slug with no name, a miss here is a citation the parser - cannot read. *) + cannot read. + + The walk below also drives the round-trip check (Sigla facade, Task 5): + ONE pass over 1970-2070 collects into TWO tables, [bad_parse] and + [bad_round_trip], rather than walking the whole range twice for two + independent Alcotest cases -- measured, a second walk would cost ~1.5s of + a 5.65s fast suite for zero extra coverage. *) module P = Colitur_citation.Parse +let bad_parse : (string, string) Hashtbl.t = Hashtbl.create 64 +let bad_round_trip : (string, string) Hashtbl.t = Hashtbl.create 16 +let walked = ref false + +(* Parse -> render -> parse must reach the same structure. + + NOTE the [names] function: [Book.default_spelling], NOT [Book.to_string]. + [to_string] returns the internal id ("corinthians_1"), which is not a + registered token, so rendering with it produces something Parse cannot + read back -- 0 of 738 round-trip. Measured during Task 4; do not + "simplify" this back to to_string. A renderer that emits an unparseable + string, or a style whose own output it cannot read, fails here. Compares + STRUCTURE, not text: the rendered form legitimately differs from the + input (a normalised book spelling, a dropped trailing period), and + asserting text equality would just pin those differences. *) +let sg = + Colitur_citation.Sigla.make ~style:Colitur_citation.Render.default_style + ~tradition:Colitur_citation.Book.vulgate + ~names:(fun id _form -> Colitur_citation.Book.default_spelling id) + +let walk () = + if not !walked then begin + walked := true; + let layer = + match Test_support.load_ef_layer () with + | Ok l -> l + | Error e -> Alcotest.failf "%s" e + in + let ctx = Test_support.ef_context () in + for y = 1970 to 2070 do + Array.iter + (fun (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) -> + List.iter + (fun (c : Colitur_kernel.Citation.t) -> + let r = c.Colitur_kernel.Citation.reference in + match P.parse r with + | Error e -> if not (Hashtbl.mem bad_parse r) then Hashtbl.add bad_parse r e + | Ok first -> + let rendered = Colitur_citation.Sigla.format sg r in + (match P.parse rendered with + | Error e -> + Hashtbl.replace bad_round_trip r ("re-parse failed: " ^ e) + | Ok again -> + if again <> first then + Hashtbl.replace bad_round_trip r ("structure changed: " ^ rendered))) + d.Colitur_kernel.Liturgical_day.citations) + (Colitur_kernel.Calendar.year ctx layer y) + done + end + let test_every_citation_parses () = - let layer = - match Test_support.load_ef_layer () with - | Ok l -> l - | Error e -> Alcotest.failf "%s" e - in - let ctx = Test_support.ef_context () in - let bad = Hashtbl.create 64 in - for y = 1970 to 2070 do - Array.iter - (fun (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) -> - List.iter - (fun (c : Colitur_kernel.Citation.t) -> - let r = c.Colitur_kernel.Citation.reference in - match P.parse r with - | Ok _ -> () - | Error e -> if not (Hashtbl.mem bad r) then Hashtbl.add bad r e) - d.Colitur_kernel.Liturgical_day.citations) - (Colitur_kernel.Calendar.year ctx layer y) - done; - if Hashtbl.length bad > 0 then begin - Hashtbl.iter (fun r e -> Printf.eprintf "UNPARSED %S: %s\n" r e) bad; - Alcotest.failf "%d citation(s) did not parse" (Hashtbl.length bad) + walk (); + if Hashtbl.length bad_parse > 0 then begin + Hashtbl.iter (fun r e -> Printf.eprintf "UNPARSED %S: %s\n" r e) bad_parse; + Alcotest.failf "%d citation(s) did not parse" (Hashtbl.length bad_parse) + end + +let test_round_trip () = + walk (); + if Hashtbl.length bad_round_trip > 0 then begin + Hashtbl.iter (fun r e -> Printf.eprintf "ROUND-TRIP %S: %s\n" r e) bad_round_trip; + Alcotest.failf "%d citation(s) failed round-trip" (Hashtbl.length bad_round_trip) end (* This project has ONE test executable. Every test_.ml exposes a @@ -40,4 +85,5 @@ let test_every_citation_parses () = Alcotest.run here. Marked `Slow like test_lang_coverage's own year walk. *) let suite = ( "citation-coverage", - [ Alcotest.test_case "every citation parses" `Slow test_every_citation_parses ] ) + [ Alcotest.test_case "every citation parses" `Slow test_every_citation_parses; + Alcotest.test_case "sigla round-trip" `Slow test_round_trip ] ) diff --git a/test/test_colitur.ml b/test/test_colitur.ml index 6084adb..c873945 100644 --- a/test/test_colitur.ml +++ b/test/test_colitur.ml @@ -8,6 +8,7 @@ let () = Test_citation.suite; ("parse", Test_citation.parse_suite); Test_citation.render_suite; + ("sigla", Test_citation.sigla_suite); Test_config.suite; Test_overlay.suite; Test_overlay_ini.suite; Test_temporal_ef.suite; Test_validate.suite; Test_precedence.suite; Test_calendar.suite; Test_precedence_ef.suite; Test_sanctoral_ef.suite; Test_rite_ef.suite; -- 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') 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