aboutsummaryrefslogtreecommitdiff
path: root/lib/citation/parse.ml
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-20 22:40:13 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-20 22:40:13 +0200
commitf73bd5d0e33146c24af1e98bf9ea26bd8cbf004b (patch)
tree790100dcc0077532d9446478e51d4cd40209c1c6 /lib/citation/parse.ml
parent2d8942e08edcbe5430f97270bdb1233e287600ee (diff)
parent671c4264707ec8c81845059746632b00f2481d88 (diff)
downloadcolitur-f73bd5d0e33146c24af1e98bf9ea26bd8cbf004b.tar.gz
colitur-f73bd5d0e33146c24af1e98bf9ea26bd8cbf004b.zip
Merge branch 'robustness-and-hackability'
An audit of the shipped program, plus the fixes it found. The citation parser accepted OCaml integer-literal syntax, so a typo like 'Luke 1_1:5' silently became a different chapter. Four pairs of different books shared a full title -- 1 and 2 Corinthians both rendered 'Epistola ad Corinthios' -- leaving 108 citations in 2027 alone that a reader could not resolve to a book. Spec section 8.5 is now delivered rather than recorded: shipped styles re-parse their own output. Overlay errors no longer name OCaml source files at the reader. Also: the new-overlay scaffold shows citations at the right nesting level, config --show validates before printing, error messages no longer echo whole file lines, and a month answers to both spellings of its own name.
Diffstat (limited to 'lib/citation/parse.ml')
-rw-r--r--lib/citation/parse.ml54
1 files changed, 52 insertions, 2 deletions
diff --git a/lib/citation/parse.ml b/lib/citation/parse.ml
index 45fbb72..2f2316e 100644
--- a/lib/citation/parse.ml
+++ b/lib/citation/parse.ml
@@ -9,7 +9,41 @@ 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 *)
@@ -25,7 +59,21 @@ let split_book s =
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)
+(* 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 =
@@ -33,7 +81,9 @@ let parse_range s =
| [ 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 }
+ (* 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