Skip to main content

bible_io_references/
reference.rs

1//! Checked verse, range, and reference value types.
2
3use core::{fmt, str::FromStr};
4
5use crate::{Book, Language, ParseError, ReferenceParser};
6
7/// Broad upper limit for chapter coordinates.
8pub const MAX_CHAPTER_NUMBER: u16 = 999;
9
10/// Broad upper limit for verse coordinates.
11pub const MAX_VERSE_NUMBER: u16 = 999;
12
13/// Identifies one coordinate in a verse reference.
14#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
15pub enum Coordinate {
16    /// Chapter coordinate.
17    Chapter,
18    /// Verse coordinate.
19    Verse,
20}
21
22impl fmt::Display for Coordinate {
23    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
24        formatter.write_str(match self {
25            Self::Chapter => "chapter",
26            Self::Verse => "verse",
27        })
28    }
29}
30
31/// Error returned when constructing a verse with an invalid coordinate.
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub struct CoordinateError {
34    coordinate: Coordinate,
35    value: u16,
36    maximum: u16,
37}
38
39impl CoordinateError {
40    /// Return which coordinate failed validation.
41    #[must_use]
42    pub const fn coordinate(self) -> Coordinate {
43        self.coordinate
44    }
45
46    /// Return the rejected value.
47    #[must_use]
48    pub const fn value(self) -> u16 {
49        self.value
50    }
51
52    /// Return the inclusive sanity limit.
53    #[must_use]
54    pub const fn maximum(self) -> u16 {
55        self.maximum
56    }
57}
58
59impl fmt::Display for CoordinateError {
60    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
61        write!(
62            formatter,
63            "{} must be between 1 and {} (got {})",
64            self.coordinate, self.maximum, self.value
65        )
66    }
67}
68
69impl std::error::Error for CoordinateError {}
70
71/// A single Bible verse coordinate.
72#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
73pub struct VerseRef {
74    book: Book,
75    chapter: u16,
76    verse: u16,
77}
78
79impl VerseRef {
80    /// Construct a validated verse coordinate.
81    pub const fn new(book: Book, chapter: u16, verse: u16) -> Result<Self, CoordinateError> {
82        if chapter == 0 || chapter > MAX_CHAPTER_NUMBER {
83            return Err(CoordinateError {
84                coordinate: Coordinate::Chapter,
85                value: chapter,
86                maximum: MAX_CHAPTER_NUMBER,
87            });
88        }
89        if verse == 0 || verse > MAX_VERSE_NUMBER {
90            return Err(CoordinateError {
91                coordinate: Coordinate::Verse,
92                value: verse,
93                maximum: MAX_VERSE_NUMBER,
94            });
95        }
96        Ok(Self {
97            book,
98            chapter,
99            verse,
100        })
101    }
102
103    /// Parse a verse with an optional explicit book-name language.
104    pub fn parse_with_language(input: &str, language: Language) -> Result<Self, ParseError> {
105        ReferenceParser::new().parse_verse_with_language(input, language)
106    }
107
108    /// Parse a verse, returning `None` instead of an error for invalid input.
109    #[must_use]
110    pub fn try_parse(input: &str) -> Option<Self> {
111        input.parse().ok()
112    }
113
114    /// Parse a verse with an explicit language, returning `None` for invalid
115    /// input.
116    #[must_use]
117    pub fn try_parse_with_language(input: &str, language: Language) -> Option<Self> {
118        Self::parse_with_language(input, language).ok()
119    }
120
121    /// Return the book.
122    #[must_use]
123    pub const fn book(self) -> Book {
124        self.book
125    }
126
127    /// Return the one-based chapter.
128    #[must_use]
129    pub const fn chapter(self) -> u16 {
130        self.chapter
131    }
132
133    /// Return the one-based verse.
134    #[must_use]
135    pub const fn verse(self) -> u16 {
136        self.verse
137    }
138
139    /// Return a copy with a different book.
140    #[must_use]
141    pub const fn with_book(self, book: Book) -> Self {
142        Self { book, ..self }
143    }
144
145    /// Return a validated copy with a different chapter.
146    pub const fn with_chapter(self, chapter: u16) -> Result<Self, CoordinateError> {
147        Self::new(self.book, chapter, self.verse)
148    }
149
150    /// Return a validated copy with a different verse.
151    pub const fn with_verse(self, verse: u16) -> Result<Self, CoordinateError> {
152        Self::new(self.book, self.chapter, verse)
153    }
154}
155
156impl fmt::Display for VerseRef {
157    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
158        write!(
159            formatter,
160            "{} {}:{}",
161            self.book.full_name(),
162            self.chapter,
163            self.verse
164        )
165    }
166}
167
168impl FromStr for VerseRef {
169    type Err = ParseError;
170
171    fn from_str(input: &str) -> Result<Self, Self::Err> {
172        ReferenceParser::new().parse_verse(input)
173    }
174}
175
176/// Error returned when an inclusive range does not have ascending endpoints.
177#[derive(Clone, Copy, Debug, Eq, PartialEq)]
178pub struct RangeOrderError {
179    start: VerseRef,
180    end: VerseRef,
181}
182
183impl RangeOrderError {
184    /// Return the proposed inclusive start.
185    #[must_use]
186    pub const fn start(self) -> VerseRef {
187        self.start
188    }
189
190    /// Return the proposed inclusive end.
191    #[must_use]
192    pub const fn end(self) -> VerseRef {
193        self.end
194    }
195}
196
197impl fmt::Display for RangeOrderError {
198    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
199        write!(
200            formatter,
201            "range end {} must come after start {}",
202            self.end, self.start
203        )
204    }
205}
206
207impl std::error::Error for RangeOrderError {}
208
209/// A strictly ascending, inclusive range of Bible verses.
210#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
211pub struct VerseRange {
212    start: VerseRef,
213    end: VerseRef,
214}
215
216impl VerseRange {
217    /// Construct a validated inclusive range.
218    pub const fn new(start: VerseRef, end: VerseRef) -> Result<Self, RangeOrderError> {
219        // A const-friendly spelling of `start >= end`.
220        let ascending = (start.book as u8) < (end.book as u8)
221            || ((start.book as u8) == (end.book as u8)
222                && (start.chapter < end.chapter
223                    || (start.chapter == end.chapter && start.verse < end.verse)));
224        if !ascending {
225            return Err(RangeOrderError { start, end });
226        }
227        Ok(Self { start, end })
228    }
229
230    /// Return a validated copy with a different inclusive start.
231    pub const fn with_start(self, start: VerseRef) -> Result<Self, RangeOrderError> {
232        Self::new(start, self.end)
233    }
234
235    /// Return a validated copy with a different inclusive end.
236    pub const fn with_end(self, end: VerseRef) -> Result<Self, RangeOrderError> {
237        Self::new(self.start, end)
238    }
239
240    /// Parse a range with an optional explicit book-name language.
241    pub fn parse_with_language(input: &str, language: Language) -> Result<Self, ParseError> {
242        ReferenceParser::new().parse_range_with_language(input, language)
243    }
244
245    /// Parse a range, returning `None` instead of an error for invalid input.
246    #[must_use]
247    pub fn try_parse(input: &str) -> Option<Self> {
248        input.parse().ok()
249    }
250
251    /// Parse a range with an explicit language, returning `None` for invalid
252    /// input.
253    #[must_use]
254    pub fn try_parse_with_language(input: &str, language: Language) -> Option<Self> {
255        Self::parse_with_language(input, language).ok()
256    }
257
258    /// Return the inclusive start.
259    #[must_use]
260    pub const fn start(self) -> VerseRef {
261        self.start
262    }
263
264    /// Return the inclusive end.
265    #[must_use]
266    pub const fn end(self) -> VerseRef {
267        self.end
268    }
269}
270
271impl fmt::Display for VerseRange {
272    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
273        let start = self.start;
274        let end = self.end;
275        if start.book == end.book {
276            if start.chapter == end.chapter {
277                return write!(
278                    formatter,
279                    "{} {}:{}-{}",
280                    start.book.full_name(),
281                    start.chapter,
282                    start.verse,
283                    end.verse
284                );
285            }
286            return write!(
287                formatter,
288                "{} {}:{}-{}:{}",
289                start.book.full_name(),
290                start.chapter,
291                start.verse,
292                end.chapter,
293                end.verse
294            );
295        }
296        write!(formatter, "{start}-{end}")
297    }
298}
299
300impl FromStr for VerseRange {
301    type Err = ParseError;
302
303    fn from_str(input: &str) -> Result<Self, Self::Err> {
304        ReferenceParser::new().parse_range(input)
305    }
306}
307
308/// A single verse or a contiguous inclusive verse range.
309#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
310pub enum Reference {
311    /// A single verse.
312    Verse(VerseRef),
313    /// A contiguous inclusive range.
314    Range(VerseRange),
315}
316
317impl Reference {
318    /// Parse a reference with an explicit book-name language.
319    pub fn parse_with_language(input: &str, language: Language) -> Result<Self, ParseError> {
320        ReferenceParser::new().parse_with_language(input, language)
321    }
322
323    /// Parse a reference, returning `None` instead of an error for invalid
324    /// input.
325    #[must_use]
326    pub fn try_parse(input: &str) -> Option<Self> {
327        input.parse().ok()
328    }
329
330    /// Parse a reference with an explicit language, returning `None` for
331    /// invalid input.
332    #[must_use]
333    pub fn try_parse_with_language(input: &str, language: Language) -> Option<Self> {
334        Self::parse_with_language(input, language).ok()
335    }
336
337    /// Return the first verse represented by this value.
338    #[must_use]
339    pub const fn start(self) -> VerseRef {
340        match self {
341            Self::Verse(verse) => verse,
342            Self::Range(range) => range.start,
343        }
344    }
345
346    /// Return the last verse represented by this value.
347    #[must_use]
348    pub const fn end(self) -> VerseRef {
349        match self {
350            Self::Verse(verse) => verse,
351            Self::Range(range) => range.end,
352        }
353    }
354
355    /// Return the inner verse when this is a single-verse reference.
356    #[must_use]
357    pub const fn as_verse(self) -> Option<VerseRef> {
358        match self {
359            Self::Verse(verse) => Some(verse),
360            Self::Range(_) => None,
361        }
362    }
363
364    /// Return the inner range when this is a range reference.
365    #[must_use]
366    pub const fn as_range(self) -> Option<VerseRange> {
367        match self {
368            Self::Verse(_) => None,
369            Self::Range(range) => Some(range),
370        }
371    }
372}
373
374impl From<VerseRef> for Reference {
375    fn from(value: VerseRef) -> Self {
376        Self::Verse(value)
377    }
378}
379
380impl From<VerseRange> for Reference {
381    fn from(value: VerseRange) -> Self {
382        Self::Range(value)
383    }
384}
385
386impl fmt::Display for Reference {
387    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
388        match self {
389            Self::Verse(verse) => verse.fmt(formatter),
390            Self::Range(range) => range.fmt(formatter),
391        }
392    }
393}
394
395impl FromStr for Reference {
396    type Err = ParseError;
397
398    fn from_str(input: &str) -> Result<Self, Self::Err> {
399        ReferenceParser::new().parse(input)
400    }
401}
402
403#[cfg(test)]
404#[path = "../tests/unit/reference.rs"]
405mod tests;