summaryrefslogtreecommitdiff
path: root/lib/citation/parse.ml
blob: 2f2316e016346fe264cc9200755fcd22235b6792 (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
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
(* 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. *)
(* Longest REGISTERED token that prefixes [s] and is followed by a space and
   a digit. This is what lets a MULTI-WORD title parse: the heuristic below
   stops at the first space, so "Evangelium secundum Lucam 5:12-14" would
   otherwise split as the book "Evangelium" and fail.

   Longest-match matters and is not decoration: "Liber Regum III" and
   "Liber Regum IV" share a prefix with each other, and a shortest-match
   would read both as some other book entirely. *)
let longest_token_prefix s =
  let n = String.length s in
  let best = ref None in
  List.iter
    (fun (tok, _) ->
      let tl = String.length tok in
      if
        tl < n
        && String.sub s 0 tl = tok
        && s.[tl] = ' '
        (* a digit must follow, or "Job" would swallow the start of a
           different book whose name merely begins the same way *)
        && (let j = ref (tl + 1) in
            while !j < n && s.[!j] = ' ' do incr j done;
            !j < n && s.[!j] >= '0' && s.[!j] <= '9')
      then
        match !best with
        | Some (b, _) when String.length b >= tl -> ()
        | _ -> best := Some (tok, String.trim (String.sub s tl (n - tl)))
    )
    Book.tokens;
  !best

let split_book s =
  match longest_token_prefix s with
  | Some (book, tail) when tail <> "" -> Some (book, tail)
  | _ ->
  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)

(* A citation number is PLAIN DIGITS and positive -- nothing else.
   [int_of_string_opt] also accepts OCaml's own integer-literal syntax, so
   "1_1" would read as 11 and "+5" as 5: a transcription typo silently
   becoming a DIFFERENT chapter, which nothing downstream could detect. A
   user overlay supplies arbitrary strings, so this is reachable, not
   theoretical. Chapter and verse numbering both start at 1, so zero is
   rejected too. *)
let int_opt s =
  let s = String.trim s in
  let ok =
    s <> ""
    && String.for_all (function '0' .. '9' -> true | _ -> false) s
  in
  if not ok then None
  else match int_of_string_opt s with Some n when n > 0 -> Some n | _ -> None

(* "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
      (* A descending range ("1:20-10") is always a transcription error;
         accepting it would render back out as a citation nobody can follow. *)
      | Some f, Some l when l >= f -> 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 }))