summaryrefslogtreecommitdiff
path: root/lib/citation/parse.ml
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 /lib/citation/parse.ml
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.
Diffstat (limited to 'lib/citation/parse.ml')
-rw-r--r--lib/citation/parse.ml110
1 files changed, 110 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 }))