aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-20 14:59:03 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-20 14:59:03 +0200
commit56e36188c6afc208b96b15b9a1d3d62bbbe2f705 (patch)
tree5d60c75005c36ab9595a70ef06f261ed9b7efed4
parenta4eb9ed3cd93cbac493a873991dd5817906aad56 (diff)
downloadcolitur-56e36188c6afc208b96b15b9a1d3d62bbbe2f705.tar.gz
colitur-56e36188c6afc208b96b15b9a1d3d62bbbe2f705.zip
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.
-rw-r--r--lib/citation/parse.ml110
-rw-r--r--lib/citation/parse.mli15
-rw-r--r--test/test_citation.ml54
-rw-r--r--test/test_colitur.ml1
4 files changed, 180 insertions, 0 deletions
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;