(* 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 }))