aboutsummaryrefslogtreecommitdiff
path: root/lib/citation/parse.ml
blob: 811019e1ebb4a76937f64a74c8bf1cc0509092df (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
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
(* SPDX-License-Identifier: AGPL-3.0-or-later *)

(* Fix wave I2 (final-review.md, 2026-08-25-colitur-of-phases-3-5): a VERSE
   number (never a chapter number -- see [num_opt]'s own citation below) may
   carry a trailing lowercase-letter sub-verse marker, standard lectionary
   notation for "part of this verse" ("11a" = the first clause of verse 11)
   and sometimes several run together ("1bcde" = parts b through e of verse
   1). [suffix] is [""] for the overwhelming majority of verse numbers --
   every EF citation, checked: none carries one at all (grep across
   data/ef/{lectionary,sanctoral,commons}.sexp finds zero) -- so this is
   purely additive for EF and does not change how any existing citation
   parses or renders. Carried through and RENDERED, not stripped: dropping
   it would silently lose real precision a reader can see in the source
   text, trading "wrong format, complete" for "right format, incomplete". *)
type verse_num = { n : int; suffix : string }

(* See parse.mli's own citation for [verse_end]'s rationale. *)
type verse_end = { chapter : int option; verse : verse_num }
type verse_range = { first : verse_num; last : verse_end 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

(* Split at the FIRST ':' only, unlike [split_on ':'] which splits at every
   one. [parse_part] needs this: a "chapter:verses" part whose verses
   themselves contain a chapter-crossing hyphen range ("1:5-2:2") carries a
   SECOND colon that belongs to the verses side, not to a second chapter
   marker -- splitting on every ':' would see three pieces there and fail
   to parse at all (the exact shape [parse_range]'s own citation above
   restores). *)
let split_first_colon s =
  match String.index_opt s ':' with
  | None -> None
  | Some i ->
      Some
        ( String.trim (String.sub s 0 i),
          String.trim (String.sub s (i + 1) (String.length s - i - 1)) )

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

(* [num_opt] is [int_opt] widened to accept a trailing sub-verse letter run
   ([verse_num]'s own citation above) -- used ONLY for verse numbers inside
   [parse_range] below. Chapter numbers ([parse_part]'s own [int_opt c]
   calls) are deliberately left on plain [int_opt]: nothing in the data ever
   attaches a sub-verse letter to a CHAPTER, and keeping chapter-parsing on
   the original, narrower function means this change cannot loosen what a
   chapter number is allowed to look like. *)
let split_num_suffix s =
  let n = String.length s in
  let i = ref 0 in
  while !i < n && s.[!i] >= '0' && s.[!i] <= '9' do incr i done;
  if !i = 0 then None
  else
    let digits = String.sub s 0 !i and suffix = String.sub s !i (n - !i) in
    if String.for_all (fun c -> c >= 'a' && c <= 'z') suffix then Some (digits, suffix) else None

let num_opt s : verse_num option =
  match split_num_suffix (String.trim s) with
  | None -> None
  | Some (digits, suffix) -> ( match int_opt digits with Some n -> Some { n; suffix } | None -> None)

(* "20-32" -> {first=20; last=Some{chapter=None; verse=32}};
   "21" -> {first=21; last=None};
   "35-10:1" -> {first=35; last=Some{chapter=Some 10; verse=1}} -- the
   RIGHT side of a hyphen range may itself carry an explicit "chapter:verse"
   (a range whose hyphen crosses a chapter boundary, [verse_end]'s own
   citation in the .mli). The LEFT side never does: it is always the
   chapter already in effect at that point in the part (parse_part's own
   [c], or the chapter a PRECEDING range in the same part most recently
   crossed into -- an ambiguity the source text itself never resolves and
   this function does not try to, since nothing downstream needs the first
   side's chapter: {!Colitur_citation.Render} only ever prints a part's
   OWN [chapter] once, at the front, from [parse_part]'s [c]). *)
let parse_range s =
  match split_on '-' s with
  | [ a ] -> ( match num_opt a with Some f -> Some { first = f; last = None } | None -> None)
  | [ a; b ] -> (
      match num_opt a with
      | None -> None
      | Some f ->
          if String.contains b ':' then
            (* The right side names its own chapter explicitly: no verse-
               number ordering constraint applies (a later chapter is
               always "ahead", whatever its own verse numbers are). *)
            match split_on ':' b with
            | [ cs; vs ] -> (
                match (int_opt cs, num_opt vs) with
                | Some c, Some v -> Some { first = f; last = Some { chapter = Some c; verse = v } }
                | _ -> None)
            | _ -> None
          else
            match num_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. Compared on the NUMBER only -- "11a-11b"
               is a real, ascending sub-verse range even though nothing
               here orders letters. *)
            | Some l when l.n >= f.n -> Some { first = f; last = Some { chapter = None; verse = 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).
   [single_chapter] ({!Book.is_single_chapter}, fix wave I2) skips rule 3
   entirely: a single-chapter book's citations are bare verses with no
   chapter at all, so a leading comma-piece that looks like a number is
   always a VERSE, never a chapter introduction -- see book.ml's own
   citation for the real, wrongly-parsed example this was found on. *)
let parse_part ~single_chapter ~inherited s =
  match split_first_colon s with
  | Some (c, v) -> (
      (* explicit "chapter:verses" -- [v] may itself carry further colons,
         from a chapter-crossing hyphen range inside it; [parse_ranges] /
         [parse_range] read those, this function does not need to. *)
      match (int_opt c, parse_ranges v) with
      | Some ch, Some vs -> Some { chapter = ch; verses = vs }
      | _ -> None)
  | None -> (
      let only = s in
      if single_chapter then
        match parse_ranges only with
        | Some vs -> Some { chapter = Option.value inherited ~default:1; verses = vs }
        | None -> None
      else (
        (* 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))

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 single_chapter = Book.is_single_chapter book in
          let rec go inherited = function
            | [] -> Ok []
            | p :: rest -> (
                match parse_part ~single_chapter ~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 }))