diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-20 14:59:03 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-20 14:59:03 +0200 |
| commit | 56e36188c6afc208b96b15b9a1d3d62bbbe2f705 (patch) | |
| tree | 5d60c75005c36ab9595a70ef06f261ed9b7efed4 /lib | |
| parent | a4eb9ed3cd93cbac493a873991dd5817906aad56 (diff) | |
| download | colitur-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')
| -rw-r--r-- | lib/citation/parse.ml | 110 | ||||
| -rw-r--r-- | lib/citation/parse.mli | 15 |
2 files changed, 125 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 |
