Skip to main content

bible_io_references/
identifiers.rs

1//! Strict OSIS and USFM machine identifiers.
2//!
3//! These parsers intentionally accept only the case-sensitive machine forms
4//! produced by [`MachineIdentifiers`]. They do not normalize whitespace,
5//! punctuation, or book-code casing.
6
7use core::fmt;
8
9use crate::{
10    Book, BookPassage, ChapterPassage, Coordinate, CoordinateError, Passage, PassageSequence,
11    Reference, VersePassage, VerseRange, VerseRef,
12};
13
14/// A machine-readable Bible reference vocabulary.
15#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
16pub enum IdentifierFormat {
17    /// Open Scripture Information Standard identifiers.
18    Osis,
19    /// Unified Standard Format Marker identifiers.
20    Usfm,
21}
22
23impl IdentifierFormat {
24    /// Return the conventional uppercase format name.
25    #[must_use]
26    pub const fn name(self) -> &'static str {
27        match self {
28            Self::Osis => "OSIS",
29            Self::Usfm => "USFM",
30        }
31    }
32}
33
34impl fmt::Display for IdentifierFormat {
35    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
36        formatter.write_str(self.name())
37    }
38}
39
40/// Stable classification for a machine-identifier parse failure.
41#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
42#[non_exhaustive]
43pub enum IdentifierErrorKind {
44    /// The input does not have one of the supported exact shapes.
45    InvalidSyntax,
46    /// The case-sensitive book code is not supported.
47    UnknownBook,
48    /// A chapter is outside the inclusive `1..=999` sanity range.
49    InvalidChapter,
50    /// A verse is outside the inclusive `1..=999` sanity range.
51    InvalidVerse,
52    /// A range is equal or descending in canonical book order.
53    RangeNotAscending,
54}
55
56impl IdentifierErrorKind {
57    /// Return a stable snake-case error code.
58    #[must_use]
59    pub const fn code(self) -> &'static str {
60        match self {
61            Self::InvalidSyntax => "invalid_identifier_syntax",
62            Self::UnknownBook => "unknown_identifier_book",
63            Self::InvalidChapter => "invalid_identifier_chapter",
64            Self::InvalidVerse => "invalid_identifier_verse",
65            Self::RangeNotAscending => "identifier_range_not_ascending",
66        }
67    }
68}
69
70/// A typed failure to parse an OSIS or USFM identifier.
71#[derive(Clone, Debug, Eq, PartialEq)]
72pub struct IdentifierError {
73    format: IdentifierFormat,
74    kind: IdentifierErrorKind,
75    input: String,
76    details: String,
77}
78
79impl IdentifierError {
80    fn new(
81        format: IdentifierFormat,
82        kind: IdentifierErrorKind,
83        input: &str,
84        details: impl Into<String>,
85    ) -> Self {
86        Self {
87            format,
88            kind,
89            input: input.to_owned(),
90            details: details.into(),
91        }
92    }
93
94    fn invalid_syntax(format: IdentifierFormat, input: &str) -> Self {
95        let expected = match format {
96            IdentifierFormat::Osis => {
97                "expected BOOK.CHAPTER.VERSE or \
98                 BOOK.CHAPTER.VERSE-BOOK.CHAPTER.VERSE"
99            }
100            IdentifierFormat::Usfm => {
101                "expected BOOK CHAPTER:VERSE, BOOK CHAPTER:VERSE-VERSE, \
102                 BOOK CHAPTER:VERSE-CHAPTER:VERSE, or \
103                 BOOK-BOOK CHAPTER:VERSE-CHAPTER:VERSE"
104            }
105        };
106        Self::new(format, IdentifierErrorKind::InvalidSyntax, input, expected)
107    }
108
109    fn unknown_book(format: IdentifierFormat, input: &str, book: &str) -> Self {
110        Self::new(
111            format,
112            IdentifierErrorKind::UnknownBook,
113            input,
114            format!("unknown or non-canonical {format} book identifier {book:?}"),
115        )
116    }
117
118    fn invalid_coordinate(
119        format: IdentifierFormat,
120        input: &str,
121        coordinate: Coordinate,
122        value: &str,
123    ) -> Self {
124        let kind = match coordinate {
125            Coordinate::Chapter => IdentifierErrorKind::InvalidChapter,
126            Coordinate::Verse => IdentifierErrorKind::InvalidVerse,
127        };
128        Self::new(
129            format,
130            kind,
131            input,
132            format!("{coordinate} must be between 1 and 999 (got {value})"),
133        )
134    }
135
136    fn range_not_ascending(
137        format: IdentifierFormat,
138        input: &str,
139        start: VerseRef,
140        end: VerseRef,
141    ) -> Self {
142        Self::new(
143            format,
144            IdentifierErrorKind::RangeNotAscending,
145            input,
146            format!("range end {end} must come after start {start}"),
147        )
148    }
149
150    /// Return the identifier vocabulary being parsed.
151    #[must_use]
152    pub const fn format(&self) -> IdentifierFormat {
153        self.format
154    }
155
156    /// Return the stable failure classification.
157    #[must_use]
158    pub const fn kind(&self) -> IdentifierErrorKind {
159        self.kind
160    }
161
162    /// Return the stable machine-readable error code.
163    #[must_use]
164    pub const fn code(&self) -> &'static str {
165        self.kind.code()
166    }
167
168    /// Return the complete rejected identifier.
169    #[must_use]
170    pub fn input(&self) -> &str {
171        &self.input
172    }
173
174    /// Return the input-specific diagnostic.
175    #[must_use]
176    pub fn details(&self) -> &str {
177        &self.details
178    }
179}
180
181impl fmt::Display for IdentifierError {
182    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
183        write!(
184            formatter,
185            "{} {} identifier {:?}: {}",
186            self.code(),
187            self.format,
188            self.input,
189            self.details
190        )
191    }
192}
193
194impl std::error::Error for IdentifierError {}
195
196/// Canonical OSIS and USFM serialization for Bible reference values.
197pub trait MachineIdentifiers {
198    /// Encode this value as a case-sensitive OSIS identifier.
199    #[must_use]
200    fn osis_identifier(&self) -> String;
201
202    /// Encode this value as a case-sensitive USFM identifier.
203    #[must_use]
204    fn usfm_identifier(&self) -> String;
205}
206
207impl MachineIdentifiers for Book {
208    fn osis_identifier(&self) -> String {
209        self.osis().to_owned()
210    }
211
212    fn usfm_identifier(&self) -> String {
213        self.usfm().to_owned()
214    }
215}
216
217impl MachineIdentifiers for VerseRef {
218    fn osis_identifier(&self) -> String {
219        format!("{}.{}.{}", self.book().osis(), self.chapter(), self.verse())
220    }
221
222    fn usfm_identifier(&self) -> String {
223        format!("{} {}:{}", self.book().usfm(), self.chapter(), self.verse())
224    }
225}
226
227impl MachineIdentifiers for VerseRange {
228    fn osis_identifier(&self) -> String {
229        format!(
230            "{}-{}",
231            self.start().osis_identifier(),
232            self.end().osis_identifier()
233        )
234    }
235
236    fn usfm_identifier(&self) -> String {
237        let start = self.start();
238        let end = self.end();
239        if start.book() != end.book() {
240            return format!(
241                "{}-{} {}:{}-{}:{}",
242                start.book().usfm(),
243                end.book().usfm(),
244                start.chapter(),
245                start.verse(),
246                end.chapter(),
247                end.verse()
248            );
249        }
250        if start.chapter() == end.chapter() {
251            return format!(
252                "{} {}:{}-{}",
253                start.book().usfm(),
254                start.chapter(),
255                start.verse(),
256                end.verse()
257            );
258        }
259        format!(
260            "{} {}:{}-{}:{}",
261            start.book().usfm(),
262            start.chapter(),
263            start.verse(),
264            end.chapter(),
265            end.verse()
266        )
267    }
268}
269
270impl MachineIdentifiers for Reference {
271    fn osis_identifier(&self) -> String {
272        match self {
273            Self::Verse(verse) => verse.osis_identifier(),
274            Self::Range(range) => range.osis_identifier(),
275        }
276    }
277
278    fn usfm_identifier(&self) -> String {
279        match self {
280            Self::Verse(verse) => verse.usfm_identifier(),
281            Self::Range(range) => range.usfm_identifier(),
282        }
283    }
284}
285
286impl MachineIdentifiers for BookPassage {
287    fn osis_identifier(&self) -> String {
288        self.book().osis_identifier()
289    }
290
291    fn usfm_identifier(&self) -> String {
292        self.book().usfm_identifier()
293    }
294}
295
296impl MachineIdentifiers for ChapterPassage {
297    fn osis_identifier(&self) -> String {
298        let book = self.book().osis();
299        match self.end_chapter() {
300            Some(end) => format!("{book}.{}-{book}.{end}", self.start_chapter()),
301            None => format!("{book}.{}", self.start_chapter()),
302        }
303    }
304
305    fn usfm_identifier(&self) -> String {
306        let book = self.book().usfm();
307        match self.end_chapter() {
308            Some(end) => format!("{book} {}-{end}", self.start_chapter()),
309            None => format!("{book} {}", self.start_chapter()),
310        }
311    }
312}
313
314impl MachineIdentifiers for VersePassage {
315    fn osis_identifier(&self) -> String {
316        self.selections()
317            .iter()
318            .map(MachineIdentifiers::osis_identifier)
319            .collect::<Vec<_>>()
320            .join(" ")
321    }
322
323    fn usfm_identifier(&self) -> String {
324        let first = self
325            .selections()
326            .first()
327            .expect("VersePassage guarantees at least one selection");
328        let anchor = first.start();
329        let mut identifier = first.usfm_identifier();
330        for selection in &self.selections()[1..] {
331            identifier.push(',');
332            identifier.push_str(&compact_usfm_selection(*selection, anchor));
333        }
334        identifier
335    }
336}
337
338impl MachineIdentifiers for PassageSequence {
339    fn osis_identifier(&self) -> String {
340        self.passages()
341            .iter()
342            .map(MachineIdentifiers::osis_identifier)
343            .collect::<Vec<_>>()
344            .join(" ")
345    }
346
347    fn usfm_identifier(&self) -> String {
348        self.passages()
349            .iter()
350            .map(MachineIdentifiers::usfm_identifier)
351            .collect::<Vec<_>>()
352            .join("; ")
353    }
354}
355
356impl MachineIdentifiers for Passage {
357    fn osis_identifier(&self) -> String {
358        match self {
359            Self::Book(passage) => passage.osis_identifier(),
360            Self::Chapter(passage) => passage.osis_identifier(),
361            Self::Verses(passage) => passage.osis_identifier(),
362            Self::Sequence(passage) => passage.osis_identifier(),
363        }
364    }
365
366    fn usfm_identifier(&self) -> String {
367        match self {
368            Self::Book(passage) => passage.usfm_identifier(),
369            Self::Chapter(passage) => passage.usfm_identifier(),
370            Self::Verses(passage) => passage.usfm_identifier(),
371            Self::Sequence(passage) => passage.usfm_identifier(),
372        }
373    }
374}
375
376fn compact_usfm_selection(selection: Reference, anchor: VerseRef) -> String {
377    match selection {
378        Reference::Verse(verse)
379            if verse.book() == anchor.book() && verse.chapter() == anchor.chapter() =>
380        {
381            verse.verse().to_string()
382        }
383        Reference::Range(range)
384            if range.start().book() == anchor.book()
385                && range.end().book() == anchor.book()
386                && range.start().chapter() == anchor.chapter()
387                && range.end().chapter() == anchor.chapter() =>
388        {
389            format!("{}-{}", range.start().verse(), range.end().verse())
390        }
391        _ => selection.usfm_identifier(),
392    }
393}
394
395/// Resolve an exact, case-sensitive OSIS book identifier.
396pub fn book_from_osis_identifier(identifier: &str) -> Result<Book, IdentifierError> {
397    Book::from_osis(identifier).ok_or_else(|| {
398        IdentifierError::unknown_book(IdentifierFormat::Osis, identifier, identifier)
399    })
400}
401
402/// Resolve an exact, case-sensitive USFM book identifier.
403pub fn book_from_usfm_identifier(identifier: &str) -> Result<Book, IdentifierError> {
404    Book::from_usfm(identifier).ok_or_else(|| {
405        IdentifierError::unknown_book(IdentifierFormat::Usfm, identifier, identifier)
406    })
407}
408
409/// Parse a verse or full-endpoint OSIS range identifier.
410///
411/// Supported shapes are `John.3.16` and
412/// `2Cor.6.14-2Cor.7.1`.
413pub fn reference_from_osis_identifier(identifier: &str) -> Result<Reference, IdentifierError> {
414    let mut endpoint_tokens = identifier.split('-');
415    let start_token = endpoint_tokens
416        .next()
417        .expect("split always produces one token");
418    let end_token = endpoint_tokens.next();
419    if endpoint_tokens.next().is_some() {
420        return Err(IdentifierError::invalid_syntax(
421            IdentifierFormat::Osis,
422            identifier,
423        ));
424    }
425    if !is_valid_osis_endpoint(start_token)
426        || end_token.is_some_and(|endpoint| !is_valid_osis_endpoint(endpoint))
427    {
428        return Err(IdentifierError::invalid_syntax(
429            IdentifierFormat::Osis,
430            identifier,
431        ));
432    }
433
434    let start = parse_osis_endpoint(start_token, identifier)?;
435    let Some(end_token) = end_token else {
436        return Ok(Reference::Verse(start));
437    };
438    let end = parse_osis_endpoint(end_token, identifier)?;
439    checked_range(start, end, IdentifierFormat::Osis, identifier)
440}
441
442/// Parse a standard USFM verse or same-book range identifier.
443///
444/// In addition to `JHN 3:16`, `JHN 3:16-17`, and `JHN 3:16-4:1`, this
445/// accepts the package's reversible cross-book extension
446/// `JHN-ACT 21:25-1:2`.
447pub fn reference_from_usfm_identifier(identifier: &str) -> Result<Reference, IdentifierError> {
448    let Some((book_token, coordinate_token)) = identifier.split_once(' ') else {
449        return Err(IdentifierError::invalid_syntax(
450            IdentifierFormat::Usfm,
451            identifier,
452        ));
453    };
454    if coordinate_token.contains(' ') {
455        return Err(IdentifierError::invalid_syntax(
456            IdentifierFormat::Usfm,
457            identifier,
458        ));
459    }
460
461    if book_token.contains('-') {
462        return parse_cross_book_usfm(book_token, coordinate_token, identifier);
463    }
464    parse_same_book_usfm(book_token, coordinate_token, identifier)
465}
466
467fn parse_osis_endpoint(endpoint: &str, source: &str) -> Result<VerseRef, IdentifierError> {
468    let mut components = endpoint.split('.');
469    let book_token = components.next().expect("split always produces one token");
470    let chapter_token = components.next();
471    let verse_token = components.next();
472    if components.next().is_some()
473        || !is_osis_book_token(book_token)
474        || chapter_token.is_none_or(|token| !is_ascii_number(token))
475        || verse_token.is_none_or(|token| !is_ascii_number(token))
476    {
477        return Err(IdentifierError::invalid_syntax(
478            IdentifierFormat::Osis,
479            source,
480        ));
481    }
482
483    let book = lookup_book(book_token, IdentifierFormat::Osis, source)?;
484    build_verse(
485        book,
486        chapter_token.expect("validated chapter token is present"),
487        verse_token.expect("validated verse token is present"),
488        IdentifierFormat::Osis,
489        source,
490    )
491}
492
493fn is_valid_osis_endpoint(endpoint: &str) -> bool {
494    let mut components = endpoint.split('.');
495    let book = components.next().expect("split always produces one token");
496    let chapter = components.next();
497    let verse = components.next();
498    components.next().is_none()
499        && is_osis_book_token(book)
500        && chapter.is_some_and(is_ascii_number)
501        && verse.is_some_and(is_ascii_number)
502}
503
504fn parse_same_book_usfm(
505    book_token: &str,
506    coordinate_token: &str,
507    source: &str,
508) -> Result<Reference, IdentifierError> {
509    if !is_usfm_book_token(book_token) {
510        return Err(IdentifierError::invalid_syntax(
511            IdentifierFormat::Usfm,
512            source,
513        ));
514    }
515    let mut range_tokens = coordinate_token.split('-');
516    let start_token = range_tokens
517        .next()
518        .expect("split always produces one token");
519    let end_token = range_tokens.next();
520    if range_tokens.next().is_some() {
521        return Err(IdentifierError::invalid_syntax(
522            IdentifierFormat::Usfm,
523            source,
524        ));
525    }
526
527    let (start_chapter, start_verse) = parse_full_usfm_coordinate(start_token, source)?;
528    let end_coordinate = match end_token {
529        Some(token) if token.contains(':') => Some(parse_full_usfm_coordinate(token, source)?),
530        Some(token) if is_ascii_number(token) => Some((start_chapter, token)),
531        Some(_) => {
532            return Err(IdentifierError::invalid_syntax(
533                IdentifierFormat::Usfm,
534                source,
535            ));
536        }
537        None => None,
538    };
539    let book = lookup_book(book_token, IdentifierFormat::Usfm, source)?;
540    let start = build_verse(
541        book,
542        start_chapter,
543        start_verse,
544        IdentifierFormat::Usfm,
545        source,
546    )?;
547    let Some((end_chapter, end_verse)) = end_coordinate else {
548        return Ok(Reference::Verse(start));
549    };
550    let end = build_verse(book, end_chapter, end_verse, IdentifierFormat::Usfm, source)?;
551    checked_range(start, end, IdentifierFormat::Usfm, source)
552}
553
554fn parse_cross_book_usfm(
555    book_token: &str,
556    coordinate_token: &str,
557    source: &str,
558) -> Result<Reference, IdentifierError> {
559    let mut books = book_token.split('-');
560    let start_book_token = books.next().expect("split always produces one token");
561    let end_book_token = books.next();
562    if books.next().is_some()
563        || !is_usfm_book_token(start_book_token)
564        || end_book_token.is_none_or(|token| !is_usfm_book_token(token))
565    {
566        return Err(IdentifierError::invalid_syntax(
567            IdentifierFormat::Usfm,
568            source,
569        ));
570    }
571
572    let mut coordinates = coordinate_token.split('-');
573    let start_coordinate = coordinates.next().expect("split always produces one token");
574    let end_coordinate = coordinates.next();
575    if coordinates.next().is_some() || end_coordinate.is_none() {
576        return Err(IdentifierError::invalid_syntax(
577            IdentifierFormat::Usfm,
578            source,
579        ));
580    }
581
582    let (start_chapter, start_verse) = parse_full_usfm_coordinate(start_coordinate, source)?;
583    let (end_chapter, end_verse) = parse_full_usfm_coordinate(
584        end_coordinate.expect("validated end coordinate is present"),
585        source,
586    )?;
587    let end_book_token = end_book_token.expect("validated end book token is present");
588    let start_book = lookup_book(start_book_token, IdentifierFormat::Usfm, source)?;
589    let end_book = lookup_book(end_book_token, IdentifierFormat::Usfm, source)?;
590    let start = build_verse(
591        start_book,
592        start_chapter,
593        start_verse,
594        IdentifierFormat::Usfm,
595        source,
596    )?;
597    let end = build_verse(
598        end_book,
599        end_chapter,
600        end_verse,
601        IdentifierFormat::Usfm,
602        source,
603    )?;
604    checked_range(start, end, IdentifierFormat::Usfm, source)
605}
606
607fn parse_full_usfm_coordinate<'a>(
608    coordinate: &'a str,
609    source: &str,
610) -> Result<(&'a str, &'a str), IdentifierError> {
611    let mut components = coordinate.split(':');
612    let chapter = components.next().expect("split always produces one token");
613    let verse = components.next();
614    if components.next().is_some()
615        || !is_ascii_number(chapter)
616        || verse.is_none_or(|token| !is_ascii_number(token))
617    {
618        return Err(IdentifierError::invalid_syntax(
619            IdentifierFormat::Usfm,
620            source,
621        ));
622    }
623    Ok((
624        chapter,
625        verse.expect("validated verse coordinate is present"),
626    ))
627}
628
629fn build_verse(
630    book: Book,
631    chapter_token: &str,
632    verse_token: &str,
633    format: IdentifierFormat,
634    source: &str,
635) -> Result<VerseRef, IdentifierError> {
636    let chapter = parse_coordinate(chapter_token, Coordinate::Chapter, format, source)?;
637    let verse = parse_coordinate(verse_token, Coordinate::Verse, format, source)?;
638    VerseRef::new(book, chapter, verse).map_err(|error| coordinate_error(error, format, source))
639}
640
641fn parse_coordinate(
642    token: &str,
643    coordinate: Coordinate,
644    format: IdentifierFormat,
645    source: &str,
646) -> Result<u16, IdentifierError> {
647    token
648        .parse::<u16>()
649        .map_err(|_| IdentifierError::invalid_coordinate(format, source, coordinate, token))
650}
651
652fn coordinate_error(
653    error: CoordinateError,
654    format: IdentifierFormat,
655    source: &str,
656) -> IdentifierError {
657    IdentifierError::invalid_coordinate(
658        format,
659        source,
660        error.coordinate(),
661        &error.value().to_string(),
662    )
663}
664
665fn checked_range(
666    start: VerseRef,
667    end: VerseRef,
668    format: IdentifierFormat,
669    source: &str,
670) -> Result<Reference, IdentifierError> {
671    VerseRange::new(start, end)
672        .map(Reference::Range)
673        .map_err(|_| IdentifierError::range_not_ascending(format, source, start, end))
674}
675
676fn lookup_book(
677    token: &str,
678    format: IdentifierFormat,
679    source: &str,
680) -> Result<Book, IdentifierError> {
681    let book = match format {
682        IdentifierFormat::Osis => Book::from_osis(token),
683        IdentifierFormat::Usfm => Book::from_usfm(token),
684    };
685    book.ok_or_else(|| IdentifierError::unknown_book(format, source, token))
686}
687
688fn is_ascii_number(token: &str) -> bool {
689    !token.is_empty() && token.bytes().all(|byte| byte.is_ascii_digit())
690}
691
692fn is_osis_book_token(token: &str) -> bool {
693    !token.is_empty() && token.bytes().all(|byte| byte.is_ascii_alphanumeric())
694}
695
696fn is_usfm_book_token(token: &str) -> bool {
697    token.len() == 3
698        && token
699            .bytes()
700            .all(|byte| byte.is_ascii_uppercase() || matches!(byte, b'1'..=b'4'))
701}
702
703#[cfg(test)]
704#[path = "../tests/unit/identifiers.rs"]
705mod tests;