1use core::fmt;
8
9use crate::{
10 Book, BookPassage, ChapterPassage, Coordinate, CoordinateError, Passage, PassageSequence,
11 Reference, VersePassage, VerseRange, VerseRef,
12};
13
14#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
16pub enum IdentifierFormat {
17 Osis,
19 Usfm,
21}
22
23impl IdentifierFormat {
24 #[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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
42#[non_exhaustive]
43pub enum IdentifierErrorKind {
44 InvalidSyntax,
46 UnknownBook,
48 InvalidChapter,
50 InvalidVerse,
52 RangeNotAscending,
54}
55
56impl IdentifierErrorKind {
57 #[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#[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 #[must_use]
152 pub const fn format(&self) -> IdentifierFormat {
153 self.format
154 }
155
156 #[must_use]
158 pub const fn kind(&self) -> IdentifierErrorKind {
159 self.kind
160 }
161
162 #[must_use]
164 pub const fn code(&self) -> &'static str {
165 self.kind.code()
166 }
167
168 #[must_use]
170 pub fn input(&self) -> &str {
171 &self.input
172 }
173
174 #[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
196pub trait MachineIdentifiers {
198 #[must_use]
200 fn osis_identifier(&self) -> String;
201
202 #[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
395pub 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
402pub 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
409pub 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
442pub 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;