Skip to main content

bible_io_references/
parser.rs

1//! Reusable, configurable parsing for verse references and ranges.
2
3use std::{
4    collections::{BTreeMap, HashMap, HashSet},
5    sync::{Arc, OnceLock},
6};
7
8use crate::{
9    Book, Language, ParseError, ParseErrorKind, Reference, VerseRange, VerseRef,
10    language_data::aliases as localized_aliases,
11    normalize::{normalize as normalize_syntax, normalize_for_parsing},
12    reference::{MAX_CHAPTER_NUMBER, MAX_VERSE_NUMBER},
13};
14
15/// Built-in language priority after English during automatic detection.
16pub const AUTO_LANGUAGE_PRECEDENCE: &[Language] = &[
17    Language::Arabic,
18    Language::Chinese,
19    Language::French,
20    Language::German,
21    Language::Hebrew,
22    Language::Hindi,
23    Language::Indonesian,
24    Language::Korean,
25    Language::Portuguese,
26    Language::Russian,
27    Language::Spanish,
28    Language::Tagalog,
29];
30
31/// Controls how a parser handles aliases that name distinct books.
32#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
33pub enum AmbiguityPolicy {
34    /// Select the first match in configured language priority order.
35    #[default]
36    PreferLanguagePriority,
37    /// Reject an alias when it resolves to more than one distinct book.
38    Reject,
39}
40
41/// One candidate considered while resolving a book token.
42#[derive(Clone, Debug, Eq, Hash, PartialEq)]
43pub struct BookCandidate {
44    book: Book,
45    alias: Arc<str>,
46    language: Option<Language>,
47    custom: bool,
48}
49
50impl BookCandidate {
51    /// Return the candidate book.
52    #[must_use]
53    pub const fn book(&self) -> Book {
54        self.book
55    }
56
57    /// Return the registered alias that produced this candidate.
58    #[must_use]
59    pub fn alias(&self) -> &str {
60        &self.alias
61    }
62
63    /// Return the language that contributed the alias.
64    ///
65    /// A language-neutral custom alias returns `None`.
66    #[must_use]
67    pub const fn language(&self) -> Option<Language> {
68        self.language
69    }
70
71    /// Whether this candidate came from parser configuration.
72    #[must_use]
73    pub const fn is_custom(&self) -> bool {
74        self.custom
75    }
76}
77
78/// Resolution details for one explicit book token.
79#[derive(Clone, Debug, Eq, PartialEq)]
80pub struct BookMatch {
81    token: String,
82    selected: BookCandidate,
83    alternatives: Vec<BookCandidate>,
84}
85
86impl BookMatch {
87    /// Return the normalized token as it appeared in the parsed input.
88    #[must_use]
89    pub fn token(&self) -> &str {
90        &self.token
91    }
92
93    /// Return the selected candidate.
94    #[must_use]
95    pub const fn selected(&self) -> &BookCandidate {
96        &self.selected
97    }
98
99    /// Return all candidates not selected by the parser.
100    #[must_use]
101    pub fn alternatives(&self) -> &[BookCandidate] {
102        &self.alternatives
103    }
104
105    /// Whether distinct books matched this token.
106    #[must_use]
107    pub fn is_ambiguous(&self) -> bool {
108        self.alternatives
109            .iter()
110            .any(|candidate| candidate.book != self.selected.book)
111    }
112}
113
114/// Metadata captured during a successful parse.
115#[derive(Clone, Debug, Eq, PartialEq)]
116pub struct ParseMetadata {
117    normalized_input: String,
118    book_matches: Vec<BookMatch>,
119}
120
121impl ParseMetadata {
122    pub(crate) fn from_parts(normalized_input: String, book_matches: Vec<BookMatch>) -> Self {
123        Self {
124            normalized_input,
125            book_matches,
126        }
127    }
128
129    /// Return the parser-ready normalized input.
130    #[must_use]
131    pub fn normalized_input(&self) -> &str {
132        &self.normalized_input
133    }
134
135    /// Return book matches in source order.
136    #[must_use]
137    pub fn book_matches(&self) -> &[BookMatch] {
138        &self.book_matches
139    }
140
141    /// Return the selected languages, without duplicates, in source order.
142    #[must_use]
143    pub fn detected_languages(&self) -> Vec<Language> {
144        let mut languages = Vec::new();
145        for language in self
146            .book_matches
147            .iter()
148            .filter_map(|book_match| book_match.selected.language)
149        {
150            if !languages.contains(&language) {
151                languages.push(language);
152            }
153        }
154        languages
155    }
156
157    /// Return a language when every localized match selected the same one.
158    #[must_use]
159    pub fn detected_language(&self) -> Option<Language> {
160        let languages = self.detected_languages();
161        match languages.as_slice() {
162            [language] => Some(*language),
163            _ => None,
164        }
165    }
166
167    /// Whether any token had candidates for distinct books.
168    #[must_use]
169    pub fn has_ambiguity(&self) -> bool {
170        self.book_matches.iter().any(BookMatch::is_ambiguous)
171    }
172
173    /// Iterate over every non-selected book candidate.
174    pub fn alternate_matches(&self) -> impl Iterator<Item = &BookCandidate> {
175        self.book_matches
176            .iter()
177            .flat_map(|book_match| book_match.alternatives.iter())
178    }
179}
180
181#[derive(Clone, Debug, Eq, PartialEq)]
182struct CustomAlias {
183    normalized: String,
184    alias: Arc<str>,
185    book: Book,
186}
187
188/// A parsed value paired with detection metadata.
189#[derive(Clone, Debug, Eq, PartialEq)]
190pub struct Parsed<T> {
191    value: T,
192    metadata: ParseMetadata,
193}
194
195impl<T> Parsed<T> {
196    /// Construct a parsed value. Primarily useful to parser adapters.
197    #[must_use]
198    pub const fn new(value: T, metadata: ParseMetadata) -> Self {
199        Self { value, metadata }
200    }
201
202    /// Borrow the parsed value.
203    #[must_use]
204    pub const fn value(&self) -> &T {
205        &self.value
206    }
207
208    /// Borrow parse metadata.
209    #[must_use]
210    pub const fn metadata(&self) -> &ParseMetadata {
211        &self.metadata
212    }
213
214    /// Consume the wrapper and return the parsed value.
215    #[must_use]
216    pub fn into_value(self) -> T {
217        self.value
218    }
219
220    /// Consume the wrapper and return both components.
221    #[must_use]
222    pub fn into_parts(self) -> (T, ParseMetadata) {
223        (self.value, self.metadata)
224    }
225}
226
227/// Builder for a reusable [`ReferenceParser`].
228#[derive(Clone, Debug, Default)]
229pub struct ParserBuilder {
230    aliases: Vec<(String, Book)>,
231    aliases_by_language: Vec<(Language, String, Book)>,
232    preferred_languages: Vec<Language>,
233    ambiguity_policy: AmbiguityPolicy,
234}
235
236impl ParserBuilder {
237    /// Register a language-neutral alias that takes precedence over bundled data.
238    #[must_use]
239    pub fn alias(mut self, alias: impl Into<String>, book: Book) -> Self {
240        self.aliases.push((alias.into(), book));
241        self
242    }
243
244    /// Register an alias that is available only for one language.
245    #[must_use]
246    pub fn language_alias(
247        mut self,
248        language: Language,
249        alias: impl Into<String>,
250        book: Book,
251    ) -> Self {
252        self.aliases_by_language
253            .push((language, alias.into(), book));
254        self
255    }
256
257    /// Set languages that should rank before the built-in automatic order.
258    #[must_use]
259    pub fn preferred_languages(mut self, languages: impl IntoIterator<Item = Language>) -> Self {
260        self.preferred_languages.clear();
261        for language in languages {
262            if !self.preferred_languages.contains(&language) {
263                self.preferred_languages.push(language);
264            }
265        }
266        self
267    }
268
269    /// Set the ambiguity policy.
270    #[must_use]
271    pub const fn ambiguity_policy(mut self, policy: AmbiguityPolicy) -> Self {
272        self.ambiguity_policy = policy;
273        self
274    }
275
276    /// Validate configuration and construct a reusable parser.
277    pub fn build(self) -> Result<ReferenceParser, ParseError> {
278        if self.preferred_languages.contains(&Language::Auto) {
279            return Err(ParseError::new(
280                ParseErrorKind::UnsupportedLanguage,
281                "preferred languages must be concrete languages, not auto mode",
282            ));
283        }
284        let mut aliases = Vec::new();
285        for (alias, book) in self.aliases {
286            let key = normalize_book_term(&alias);
287            if key.is_empty() {
288                return Err(ParseError::new(
289                    ParseErrorKind::EmptyBookToken,
290                    "custom aliases must contain a book token",
291                ));
292            }
293            aliases.push(CustomAlias {
294                normalized: key,
295                alias: Arc::from(alias),
296                book,
297            });
298        }
299
300        let mut aliases_by_language = Vec::new();
301        for (language, alias, book) in self.aliases_by_language {
302            if language.is_auto() {
303                return Err(ParseError::new(
304                    ParseErrorKind::UnsupportedLanguage,
305                    "language-specific aliases cannot use auto mode",
306                ));
307            }
308            let key = normalize_book_term(&alias);
309            if key.is_empty() {
310                return Err(ParseError::new(
311                    ParseErrorKind::EmptyBookToken,
312                    "custom aliases must contain a book token",
313                ));
314            }
315            aliases_by_language.push((
316                language,
317                CustomAlias {
318                    normalized: key,
319                    alias: Arc::from(alias),
320                    book,
321                },
322            ));
323        }
324
325        Ok(ReferenceParser {
326            aliases,
327            aliases_by_language,
328            preferred_languages: self.preferred_languages,
329            ambiguity_policy: self.ambiguity_policy,
330        })
331    }
332}
333
334/// A reusable parser with immutable alias and ambiguity configuration.
335#[derive(Clone, Debug, Default)]
336pub struct ReferenceParser {
337    aliases: Vec<CustomAlias>,
338    aliases_by_language: Vec<(Language, CustomAlias)>,
339    preferred_languages: Vec<Language>,
340    ambiguity_policy: AmbiguityPolicy,
341}
342
343impl ReferenceParser {
344    /// Construct a parser with bundled aliases and automatic detection.
345    #[must_use]
346    pub fn new() -> Self {
347        Self::default()
348    }
349
350    /// Start configuring a parser.
351    #[must_use]
352    pub fn builder() -> ParserBuilder {
353        ParserBuilder::default()
354    }
355
356    /// Return configured preferred languages.
357    #[must_use]
358    pub fn preferred_languages(&self) -> &[Language] {
359        &self.preferred_languages
360    }
361
362    /// Iterate over configured language-neutral aliases in registration order.
363    pub fn aliases(&self) -> impl Iterator<Item = (&str, Book)> {
364        self.aliases
365            .iter()
366            .map(|alias| (alias.alias.as_ref(), alias.book))
367    }
368
369    /// Iterate over configured language-specific aliases in registration
370    /// order.
371    pub fn aliases_by_language(&self) -> impl Iterator<Item = (Language, &str, Book)> {
372        self.aliases_by_language
373            .iter()
374            .map(|(language, alias)| (*language, alias.alias.as_ref(), alias.book))
375    }
376
377    /// Return this parser's ambiguity policy.
378    #[must_use]
379    pub const fn ambiguity_policy(&self) -> AmbiguityPolicy {
380        self.ambiguity_policy
381    }
382
383    /// Parse a single verse or an inclusive range using automatic detection.
384    pub fn parse(&self, input: &str) -> Result<Reference, ParseError> {
385        self.parse_detailed(input).map(Parsed::into_value)
386    }
387
388    /// Parse a reference using one explicit language.
389    pub fn parse_with_language(
390        &self,
391        input: &str,
392        language: Language,
393    ) -> Result<Reference, ParseError> {
394        self.parse_detailed_with_language(input, language)
395            .map(Parsed::into_value)
396    }
397
398    /// Parse a reference and retain normalization and language metadata.
399    pub fn parse_detailed(&self, input: &str) -> Result<Parsed<Reference>, ParseError> {
400        self.parse_detailed_inner(input, Language::Auto)
401    }
402
403    /// Parse with an explicit language and retain metadata.
404    pub fn parse_detailed_with_language(
405        &self,
406        input: &str,
407        language: Language,
408    ) -> Result<Parsed<Reference>, ParseError> {
409        self.parse_detailed_inner(input, language)
410    }
411
412    /// Return `None` rather than an error for invalid input.
413    #[must_use]
414    pub fn try_parse(&self, input: &str) -> Option<Reference> {
415        self.parse(input).ok()
416    }
417
418    /// Parse with an explicit language, returning `None` for invalid input.
419    #[must_use]
420    pub fn try_parse_with_language(&self, input: &str, language: Language) -> Option<Reference> {
421        self.parse_with_language(input, language).ok()
422    }
423
424    /// Resolve a standalone book token using automatic language detection.
425    pub fn parse_book(&self, input: &str) -> Result<Book, ParseError> {
426        self.parse_book_detailed(input).map(Parsed::into_value)
427    }
428
429    /// Resolve a standalone book token using an explicit language.
430    pub fn parse_book_with_language(
431        &self,
432        input: &str,
433        language: Language,
434    ) -> Result<Book, ParseError> {
435        self.parse_book_detailed_with_language(input, language)
436            .map(Parsed::into_value)
437    }
438
439    /// Resolve a standalone book token and retain detection metadata.
440    pub fn parse_book_detailed(&self, input: &str) -> Result<Parsed<Book>, ParseError> {
441        self.parse_book_detailed_with_language(input, Language::Auto)
442    }
443
444    /// Resolve a standalone book token with an explicit language and metadata.
445    pub fn parse_book_detailed_with_language(
446        &self,
447        input: &str,
448        language: Language,
449    ) -> Result<Parsed<Book>, ParseError> {
450        let normalized = checked_normalized_input(input)?;
451        let mut matches = Vec::new();
452        let book = self.resolve_book(&normalized, language, &mut matches)?;
453        Ok(Parsed::new(
454            book,
455            ParseMetadata::from_parts(normalized, matches),
456        ))
457    }
458
459    /// Parse only a single verse.
460    pub fn parse_verse(&self, input: &str) -> Result<VerseRef, ParseError> {
461        self.parse_verse_detailed(input).map(Parsed::into_value)
462    }
463
464    /// Parse only a single verse with an explicit language.
465    pub fn parse_verse_with_language(
466        &self,
467        input: &str,
468        language: Language,
469    ) -> Result<VerseRef, ParseError> {
470        self.parse_verse_detailed_with_language(input, language)
471            .map(Parsed::into_value)
472    }
473
474    /// Parse only a single verse and retain detection metadata.
475    pub fn parse_verse_detailed(&self, input: &str) -> Result<Parsed<VerseRef>, ParseError> {
476        self.parse_verse_detailed_inner(input, Language::Auto)
477    }
478
479    /// Parse only a single verse with an explicit language and metadata.
480    pub fn parse_verse_detailed_with_language(
481        &self,
482        input: &str,
483        language: Language,
484    ) -> Result<Parsed<VerseRef>, ParseError> {
485        self.parse_verse_detailed_inner(input, language)
486    }
487
488    /// Parse only a single verse, returning `None` for invalid input.
489    #[must_use]
490    pub fn try_parse_verse(&self, input: &str) -> Option<VerseRef> {
491        self.parse_verse(input).ok()
492    }
493
494    /// Parse only a single verse with an explicit language, returning `None`
495    /// for invalid input.
496    #[must_use]
497    pub fn try_parse_verse_with_language(
498        &self,
499        input: &str,
500        language: Language,
501    ) -> Option<VerseRef> {
502        self.parse_verse_with_language(input, language).ok()
503    }
504
505    /// Parse only a range.
506    pub fn parse_range(&self, input: &str) -> Result<VerseRange, ParseError> {
507        self.parse_range_detailed(input).map(Parsed::into_value)
508    }
509
510    /// Parse only a range with an explicit language.
511    pub fn parse_range_with_language(
512        &self,
513        input: &str,
514        language: Language,
515    ) -> Result<VerseRange, ParseError> {
516        self.parse_range_detailed_with_language(input, language)
517            .map(Parsed::into_value)
518    }
519
520    /// Parse only a range and retain detection metadata.
521    pub fn parse_range_detailed(&self, input: &str) -> Result<Parsed<VerseRange>, ParseError> {
522        self.parse_range_detailed_inner(input, Language::Auto)
523    }
524
525    /// Parse only a range with an explicit language and metadata.
526    pub fn parse_range_detailed_with_language(
527        &self,
528        input: &str,
529        language: Language,
530    ) -> Result<Parsed<VerseRange>, ParseError> {
531        self.parse_range_detailed_inner(input, language)
532    }
533
534    /// Parse only a range, returning `None` for invalid input.
535    #[must_use]
536    pub fn try_parse_range(&self, input: &str) -> Option<VerseRange> {
537        self.parse_range(input).ok()
538    }
539
540    /// Parse only a range with an explicit language, returning `None` for
541    /// invalid input.
542    #[must_use]
543    pub fn try_parse_range_with_language(
544        &self,
545        input: &str,
546        language: Language,
547    ) -> Option<VerseRange> {
548        self.parse_range_with_language(input, language).ok()
549    }
550
551    fn parse_detailed_inner(
552        &self,
553        input: &str,
554        language: Language,
555    ) -> Result<Parsed<Reference>, ParseError> {
556        let normalized = checked_normalized_input(input)?;
557        if find_range_separator(&normalized).is_some() {
558            self.parse_normalized_range(&normalized, language)
559                .map(|parsed| Parsed::new(Reference::Range(parsed.value), parsed.metadata))
560        } else {
561            self.parse_normalized_verse(&normalized, language)
562                .map(|parsed| Parsed::new(Reference::Verse(parsed.value), parsed.metadata))
563        }
564    }
565
566    fn parse_verse_detailed_inner(
567        &self,
568        input: &str,
569        language: Language,
570    ) -> Result<Parsed<VerseRef>, ParseError> {
571        let normalized = checked_normalized_input(input)?;
572        if find_range_separator(&normalized).is_some() {
573            return Err(ParseError::new(
574                ParseErrorKind::PatternMismatch,
575                "expected a single verse, found range syntax",
576            ));
577        }
578        self.parse_normalized_verse(&normalized, language)
579    }
580
581    fn parse_range_detailed_inner(
582        &self,
583        input: &str,
584        language: Language,
585    ) -> Result<Parsed<VerseRange>, ParseError> {
586        let normalized = checked_normalized_input(input)?;
587        if find_range_separator(&normalized).is_none() {
588            return Err(ParseError::new(
589                ParseErrorKind::PatternMismatch,
590                "expected a verse range, found no range separator",
591            ));
592        }
593        self.parse_normalized_range(&normalized, language)
594    }
595
596    fn parse_normalized_verse(
597        &self,
598        normalized: &str,
599        language: Language,
600    ) -> Result<Parsed<VerseRef>, ParseError> {
601        let mut matches = Vec::new();
602        let verse = self.parse_full_endpoint(normalized, language, &mut matches)?;
603        Ok(Parsed::new(
604            verse,
605            ParseMetadata {
606                normalized_input: normalized.to_owned(),
607                book_matches: matches,
608            },
609        ))
610    }
611
612    fn parse_normalized_range(
613        &self,
614        normalized: &str,
615        language: Language,
616    ) -> Result<Parsed<VerseRange>, ParseError> {
617        let separator = find_range_separator(normalized).ok_or_else(|| {
618            ParseError::new(ParseErrorKind::PatternMismatch, "missing range separator")
619        })?;
620        let start_text = &normalized[..separator];
621        let end_text = &normalized[separator + 1..];
622        if find_range_separator(end_text).is_some()
623            || start_text.trim().is_empty()
624            || end_text.trim().is_empty()
625        {
626            return Err(ParseError::new(
627                ParseErrorKind::PatternMismatch,
628                "range must contain exactly two non-empty endpoints",
629            ));
630        }
631
632        let mut matches = Vec::new();
633        let start = self.parse_full_endpoint(start_text, language, &mut matches)?;
634        let end = self.parse_range_end(end_text, start, language, &mut matches)?;
635        let range = VerseRange::new(start, end).map_err(|_| {
636            let kind = if start.book() == end.book() {
637                ParseErrorKind::SameBookRangeNotAscending
638            } else {
639                ParseErrorKind::CrossBookRangeNotAscending
640            };
641            ParseError::new(
642                kind,
643                format!("range end {end} must come after start {start}"),
644            )
645        })?;
646
647        Ok(Parsed::new(
648            range,
649            ParseMetadata {
650                normalized_input: normalized.to_owned(),
651                book_matches: matches,
652            },
653        ))
654    }
655
656    fn parse_full_endpoint(
657        &self,
658        input: &str,
659        language: Language,
660        matches: &mut Vec<BookMatch>,
661    ) -> Result<VerseRef, ParseError> {
662        let (left, verse_token) = split_coordinate(input)?;
663        let (book_token, chapter_token) = split_trailing_number(left, "chapter")?;
664        let book = self.resolve_book(book_token, language, matches)?;
665        let chapter = parse_number(chapter_token, "chapter", MAX_CHAPTER_NUMBER)?;
666        let verse = parse_number(verse_token, "verse", MAX_VERSE_NUMBER)?;
667        VerseRef::new(book, chapter, verse).map_err(|error| {
668            ParseError::new(ParseErrorKind::NumericTokenOutOfRange, error.to_string())
669        })
670    }
671
672    fn parse_range_end(
673        &self,
674        input: &str,
675        start: VerseRef,
676        language: Language,
677        matches: &mut Vec<BookMatch>,
678    ) -> Result<VerseRef, ParseError> {
679        let trimmed = input.trim();
680        if trimmed.bytes().all(|byte| byte.is_ascii_digit()) {
681            let verse = parse_number(trimmed, "end verse", MAX_VERSE_NUMBER)?;
682            return VerseRef::new(start.book(), start.chapter(), verse).map_err(|error| {
683                ParseError::new(ParseErrorKind::NumericTokenOutOfRange, error.to_string())
684            });
685        }
686
687        let (left, verse_token) = split_coordinate(trimmed)?;
688        let (book_token, chapter_token) = split_trailing_number(left, "end chapter")?;
689        let book = if book_token.trim().is_empty() {
690            start.book()
691        } else {
692            self.resolve_book(book_token, language, matches)?
693        };
694        let chapter = parse_number(chapter_token, "end chapter", MAX_CHAPTER_NUMBER)?;
695        let verse = parse_number(verse_token, "end verse", MAX_VERSE_NUMBER)?;
696        VerseRef::new(book, chapter, verse).map_err(|error| {
697            ParseError::new(ParseErrorKind::NumericTokenOutOfRange, error.to_string())
698        })
699    }
700
701    fn resolve_book(
702        &self,
703        token: &str,
704        language: Language,
705        matches: &mut Vec<BookMatch>,
706    ) -> Result<Book, ParseError> {
707        let display_token = token.trim();
708        let key = normalize_book_term(display_token);
709        if key.is_empty() {
710            return Err(ParseError::new(
711                ParseErrorKind::EmptyBookToken,
712                "book token is empty after normalization",
713            ));
714        }
715
716        if !language.is_auto()
717            && !language.is_parsing_supported()
718            && !self
719                .aliases_by_language
720                .iter()
721                .any(|(alias_language, _)| *alias_language == language)
722        {
723            return Err(ParseError::new(
724                ParseErrorKind::UnsupportedLanguage,
725                format!("no aliases are registered for language {}", language.code()),
726            ));
727        }
728
729        let mut candidates = Vec::new();
730        for alias in self.aliases.iter().filter(|alias| alias.normalized == key) {
731            candidates.push(BookCandidate {
732                book: alias.book,
733                alias: Arc::clone(&alias.alias),
734                language: None,
735                custom: true,
736            });
737        }
738
739        if language.is_auto() {
740            self.collect_auto_candidates(&key, &mut candidates);
741        } else {
742            self.collect_language_candidates(language, &key, &mut candidates);
743        }
744
745        let language_priority = self.language_priority();
746        candidates.sort_by_key(|candidate| {
747            let custom_rank = u8::from(!candidate.custom);
748            let language_rank = candidate.language.map_or(0, |language| {
749                language_priority
750                    .iter()
751                    .position(|candidate| *candidate == language)
752                    .map_or(language_priority.len() + 1, |index| index + 1)
753            });
754            (custom_rank, language_rank, candidate.book)
755        });
756        deduplicate_candidates(&mut candidates);
757        let Some(selected) = candidates.first().cloned() else {
758            return Err(ParseError::new(
759                ParseErrorKind::UnknownBook,
760                if language.is_auto() {
761                    format!("book token {display_token:?} did not match a known book")
762                } else {
763                    format!(
764                        "book token {display_token:?} is unknown for language {}",
765                        language.code()
766                    )
767                },
768            ));
769        };
770
771        let ambiguity_candidates = if candidates.iter().any(|candidate| candidate.custom) {
772            candidates
773                .iter()
774                .filter(|candidate| candidate.custom)
775                .collect::<Vec<_>>()
776        } else {
777            candidates.iter().collect::<Vec<_>>()
778        };
779        let mut distinct_books = Vec::new();
780        for candidate in ambiguity_candidates {
781            if !distinct_books.contains(&candidate.book) {
782                distinct_books.push(candidate.book);
783            }
784        }
785        if self.ambiguity_policy == AmbiguityPolicy::Reject && distinct_books.len() > 1 {
786            let names = distinct_books
787                .iter()
788                .map(|book| book.full_name())
789                .collect::<Vec<_>>()
790                .join(", ");
791            return Err(ParseError::new(
792                ParseErrorKind::AmbiguousBook,
793                format!("book token {display_token:?} matches {names}"),
794            ));
795        }
796
797        matches.push(BookMatch {
798            token: display_token.to_owned(),
799            selected: selected.clone(),
800            alternatives: candidates.into_iter().skip(1).collect(),
801        });
802        Ok(selected.book)
803    }
804
805    fn collect_auto_candidates(&self, key: &str, candidates: &mut Vec<BookCandidate>) {
806        let mut languages = self.language_priority();
807        for language in self
808            .aliases_by_language
809            .iter()
810            .map(|(language, _)| *language)
811        {
812            if !language.is_auto() && !languages.contains(&language) {
813                languages.push(language);
814            }
815        }
816
817        // Configuration is intentional and therefore ranks ahead of every
818        // bundled alias, while language priority still breaks custom ties.
819        for &language in &languages {
820            for (_, alias) in self
821                .aliases_by_language
822                .iter()
823                .filter(|(alias_language, alias)| {
824                    *alias_language == language && alias.normalized == key
825                })
826            {
827                candidates.push(BookCandidate {
828                    book: alias.book,
829                    alias: Arc::clone(&alias.alias),
830                    language: Some(language),
831                    custom: true,
832                });
833            }
834        }
835        for &language in &languages {
836            if let Some(records) = bundled_index().get(key) {
837                candidates.extend(
838                    records
839                        .iter()
840                        .filter(|candidate| candidate.language == Some(language))
841                        .cloned(),
842                );
843            }
844        }
845    }
846
847    fn language_priority(&self) -> Vec<Language> {
848        let mut languages = Vec::new();
849        for language in self
850            .preferred_languages
851            .iter()
852            .copied()
853            .chain([Language::English])
854            .chain(AUTO_LANGUAGE_PRECEDENCE.iter().copied())
855            .chain(Language::SUPPORTED)
856        {
857            if !language.is_auto() && !languages.contains(&language) {
858                languages.push(language);
859            }
860        }
861        languages
862    }
863
864    fn collect_language_candidates(
865        &self,
866        language: Language,
867        key: &str,
868        candidates: &mut Vec<BookCandidate>,
869    ) {
870        for (_, alias) in self
871            .aliases_by_language
872            .iter()
873            .filter(|(alias_language, alias)| {
874                *alias_language == language && alias.normalized == key
875            })
876        {
877            candidates.push(BookCandidate {
878                book: alias.book,
879                alias: Arc::clone(&alias.alias),
880                language: Some(language),
881                custom: true,
882            });
883        }
884
885        if let Some(records) = bundled_index().get(key) {
886            candidates.extend(
887                records
888                    .iter()
889                    .filter(|candidate| candidate.language == Some(language))
890                    .cloned(),
891            );
892        }
893    }
894}
895
896fn checked_normalized_input(input: &str) -> Result<String, ParseError> {
897    let normalized = normalize_for_parsing(input);
898    if normalized.is_empty() {
899        return Err(ParseError::new(
900            ParseErrorKind::EmptyReference,
901            "reference must not be empty",
902        ));
903    }
904    Ok(normalized)
905}
906
907fn find_range_separator(input: &str) -> Option<usize> {
908    input
909        .match_indices('-')
910        .find_map(|(index, _)| split_coordinate(&input[..index]).is_ok().then_some(index))
911}
912
913fn split_coordinate(input: &str) -> Result<(&str, &str), ParseError> {
914    let separator = input
915        .char_indices()
916        .rev()
917        .find(|(_, character)| matches!(character, ':' | '.'))
918        .map(|(index, character)| (index, character.len_utf8()))
919        .ok_or_else(|| {
920            ParseError::new(
921                ParseErrorKind::MissingNumericToken,
922                "reference is missing a chapter/verse separator",
923            )
924        })?;
925    let left = &input[..separator.0];
926    let right = input[separator.0 + separator.1..].trim();
927    if right.is_empty() {
928        return Err(ParseError::new(
929            ParseErrorKind::MissingNumericToken,
930            "reference is missing a verse number",
931        ));
932    }
933    if !right.bytes().all(|byte| byte.is_ascii_digit()) {
934        return Err(ParseError::new(
935            ParseErrorKind::InvalidNumericToken,
936            format!("verse token {right:?} is not an integer"),
937        ));
938    }
939    Ok((left, right))
940}
941
942fn split_trailing_number<'a>(
943    input: &'a str,
944    component: &str,
945) -> Result<(&'a str, &'a str), ParseError> {
946    let trimmed = input.trim_end();
947    let digit_start = trimmed
948        .char_indices()
949        .rev()
950        .take_while(|(_, character)| character.is_ascii_digit())
951        .last()
952        .map(|(index, _)| index)
953        .ok_or_else(|| {
954            ParseError::new(
955                ParseErrorKind::MissingNumericToken,
956                format!("reference is missing a {component} number"),
957            )
958        })?;
959    Ok((&trimmed[..digit_start], &trimmed[digit_start..]))
960}
961
962fn parse_number(token: &str, component: &str, maximum: u16) -> Result<u16, ParseError> {
963    let value = token.parse::<u32>().map_err(|_| {
964        ParseError::new(
965            ParseErrorKind::InvalidNumericToken,
966            format!("{component} token {token:?} is not an integer"),
967        )
968    })?;
969    if value == 0 {
970        return Err(ParseError::new(
971            ParseErrorKind::NonPositiveNumericToken,
972            format!("{component} must be greater than zero"),
973        ));
974    }
975    if value > u32::from(maximum) {
976        return Err(ParseError::new(
977            ParseErrorKind::NumericTokenOutOfRange,
978            format!("{component} {value} exceeds the sanity limit {maximum}"),
979        ));
980    }
981    Ok(value as u16)
982}
983
984fn normalize_book_term(term: &str) -> String {
985    normalize_syntax(term)
986        .chars()
987        .flat_map(char::to_lowercase)
988        .filter(|character| !character.is_whitespace() && *character != '.')
989        .collect()
990}
991
992fn deduplicate_candidates(candidates: &mut Vec<BookCandidate>) {
993    let mut seen = HashSet::new();
994    candidates
995        .retain(|candidate| seen.insert((candidate.book, candidate.language, candidate.custom)));
996}
997
998/// Return every bundled automatic-detection alias that can resolve to
999/// distinct books.
1000///
1001/// Keys use the parser's case-folded, period-free, whitespace-free lookup
1002/// form. Books retain canonical order, making the result deterministic.
1003#[must_use]
1004pub fn auto_language_collisions() -> &'static BTreeMap<String, Vec<Book>> {
1005    static COLLISIONS: OnceLock<BTreeMap<String, Vec<Book>>> = OnceLock::new();
1006    COLLISIONS.get_or_init(|| {
1007        let mut collisions = BTreeMap::new();
1008        for (alias, candidates) in bundled_index() {
1009            let mut books = candidates
1010                .iter()
1011                .map(BookCandidate::book)
1012                .collect::<Vec<_>>();
1013            books.sort_unstable();
1014            books.dedup();
1015            if books.len() > 1 {
1016                collisions.insert(alias.clone(), books);
1017            }
1018        }
1019        collisions
1020    })
1021}
1022
1023fn bundled_index() -> &'static HashMap<String, Vec<BookCandidate>> {
1024    static INDEX: OnceLock<HashMap<String, Vec<BookCandidate>>> = OnceLock::new();
1025    INDEX.get_or_init(|| {
1026        let mut index: HashMap<String, Vec<BookCandidate>> = HashMap::new();
1027        for &book in Book::ALL {
1028            for alias in [book.full_name(), book.abbreviation()] {
1029                register_bundled_alias(&mut index, alias, book, Language::English);
1030            }
1031        }
1032        for &(alias, book) in COMMON_ENGLISH_ALIASES {
1033            register_bundled_alias(&mut index, alias, book, Language::English);
1034        }
1035        for &language in AUTO_LANGUAGE_PRECEDENCE {
1036            if let Some(books) = localized_aliases(language) {
1037                for record in books {
1038                    for alias in record.all_aliases() {
1039                        register_bundled_alias(&mut index, alias, record.book, language);
1040                    }
1041                }
1042            }
1043        }
1044        index
1045    })
1046}
1047
1048fn register_bundled_alias(
1049    index: &mut HashMap<String, Vec<BookCandidate>>,
1050    alias: &str,
1051    book: Book,
1052    language: Language,
1053) {
1054    index
1055        .entry(normalize_book_term(alias))
1056        .or_default()
1057        .push(BookCandidate {
1058            book,
1059            alias: Arc::from(alias),
1060            language: Some(language),
1061            custom: false,
1062        });
1063}
1064
1065// Common English abbreviations supplement the Dart wire abbreviations. This
1066// deliberately omits `Jn`, whose normalized form is the package's canonical
1067// Jonah abbreviation and would make explicit-English parsing unstable.
1068const COMMON_ENGLISH_ALIASES: &[(&str, Book)] = &[
1069    ("Gen", Book::Genesis),
1070    ("Exod", Book::Exodus),
1071    ("Lev", Book::Leviticus),
1072    ("Num", Book::Numbers),
1073    ("Deut", Book::Deuteronomy),
1074    ("Josh", Book::Joshua),
1075    ("Judg", Book::Judges),
1076    ("1 Sam", Book::FirstSamuel),
1077    ("2 Sam", Book::SecondSamuel),
1078    ("1 Kings", Book::FirstKings),
1079    ("2 Kings", Book::SecondKings),
1080    ("1 Chr", Book::FirstChronicles),
1081    ("2 Chr", Book::SecondChronicles),
1082    ("Neh", Book::Nehemiah),
1083    ("Est", Book::Esther),
1084    ("Prov", Book::Proverbs),
1085    ("Eccl", Book::Ecclesiastes),
1086    ("Song", Book::SongOfSolomon),
1087    ("Isa", Book::Isaiah),
1088    ("Jer", Book::Jeremiah),
1089    ("Lam", Book::Lamentations),
1090    ("Ezek", Book::Ezekiel),
1091    ("Dan", Book::Daniel),
1092    ("Hos", Book::Hosea),
1093    ("Obad", Book::Obadiah),
1094    ("Mic", Book::Micah),
1095    ("Nah", Book::Nahum),
1096    ("Hab", Book::Habakkuk),
1097    ("Zeph", Book::Zephaniah),
1098    ("Zech", Book::Zechariah),
1099    ("Matt", Book::Matthew),
1100    ("Mar", Book::Mark),
1101    ("Luk", Book::Luke),
1102    ("Joh", Book::John),
1103    ("Rom", Book::Romans),
1104    ("1 Cor", Book::FirstCorinthians),
1105    ("2 Cor", Book::SecondCorinthians),
1106    ("Gal", Book::Galatians),
1107    ("Phil", Book::Philippians),
1108    ("Col", Book::Colossians),
1109    ("1 Thess", Book::FirstThessalonians),
1110    ("2 Thess", Book::SecondThessalonians),
1111    ("1 Tim", Book::FirstTimothy),
1112    ("2 Tim", Book::SecondTimothy),
1113    ("Phlm", Book::Philemon),
1114    ("Heb", Book::Hebrews),
1115    ("Jas", Book::James),
1116    ("1 Pet", Book::FirstPeter),
1117    ("2 Pet", Book::SecondPeter),
1118    ("1 John", Book::FirstJohn),
1119    ("2 John", Book::SecondJohn),
1120    ("3 John", Book::ThirdJohn),
1121    ("Rev", Book::Revelation),
1122];
1123
1124#[cfg(test)]
1125#[path = "../tests/unit/parser.rs"]
1126mod tests;