summaryrefslogtreecommitdiff
path: root/lib/citation/parse.ml
blob: 45fbb722509db51b6f3056f0648db464fffe9a99 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
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 }))