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 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