Skip to main content

bible_io_references/
extractor.rs

1//! Parser-driven extraction and replacement of passages embedded in prose.
2
3use core::{fmt, ops::Range};
4use std::{collections::BTreeMap, fmt::Write as _};
5
6use crate::{
7    Book, Language, ParseMetadata,
8    normalize::{NormalizedInput, SourceSpan, normalize_detailed},
9    passage::{Passage, PassageParser},
10};
11
12/// Default normalized-byte search distance before a numeric anchor.
13pub const DEFAULT_MAX_LOOKBEHIND: usize = 96;
14
15/// Default normalized-byte search distance after a numeric anchor.
16pub const DEFAULT_MAX_LOOKAHEAD: usize = 256;
17
18/// Smallest accepted lookbehind or lookahead window.
19pub const MIN_LOOKAROUND: usize = 1;
20
21/// Largest accepted lookbehind or lookahead window.
22pub const MAX_LOOKAROUND: usize = 4096;
23
24/// A successfully parsed passage and its exact byte location in source text.
25#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct PassageMatch {
27    passage: Passage,
28    start: usize,
29    end: usize,
30    source_text: String,
31    metadata: ParseMetadata,
32}
33
34impl PassageMatch {
35    /// Return the parsed passage.
36    #[must_use]
37    pub const fn passage(&self) -> &Passage {
38        &self.passage
39    }
40
41    /// Return the inclusive UTF-8 byte offset in the original source.
42    #[must_use]
43    pub const fn start(&self) -> usize {
44        self.start
45    }
46
47    /// Return the exclusive UTF-8 byte offset in the original source.
48    #[must_use]
49    pub const fn end(&self) -> usize {
50        self.end
51    }
52
53    /// Alias for [`Self::start`] emphasizing that the offset is measured in bytes.
54    #[must_use]
55    pub const fn start_offset(&self) -> usize {
56        self.start
57    }
58
59    /// Alias for [`Self::end`] emphasizing that the offset is measured in bytes.
60    #[must_use]
61    pub const fn end_offset(&self) -> usize {
62        self.end
63    }
64
65    /// Return the half-open UTF-8 byte range in the original source.
66    #[must_use]
67    pub const fn range(&self) -> Range<usize> {
68        self.start..self.end
69    }
70
71    /// Alias for [`Self::range`].
72    #[must_use]
73    pub const fn byte_range(&self) -> Range<usize> {
74        self.range()
75    }
76
77    /// Return the matched length in UTF-8 bytes.
78    #[must_use]
79    pub const fn len(&self) -> usize {
80        self.end - self.start
81    }
82
83    /// Return whether the source range is empty.
84    #[must_use]
85    pub const fn is_empty(&self) -> bool {
86        self.start == self.end
87    }
88
89    /// Return the exact substring copied from the original source.
90    #[must_use]
91    pub fn source_text(&self) -> &str {
92        &self.source_text
93    }
94
95    /// Alias for [`Self::source_text`].
96    #[must_use]
97    pub fn exact_source(&self) -> &str {
98        self.source_text()
99    }
100
101    /// Return metadata produced by the passage parser.
102    #[must_use]
103    pub const fn metadata(&self) -> &ParseMetadata {
104        &self.metadata
105    }
106
107    /// Consume the match and return its parsed passage.
108    #[must_use]
109    pub fn into_passage(self) -> Passage {
110        self.passage
111    }
112}
113
114/// Identifies an invalid extractor window setting.
115#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
116pub enum ExtractorWindow {
117    /// The search distance before an anchor.
118    Lookbehind,
119    /// The search distance after an anchor.
120    Lookahead,
121}
122
123impl fmt::Display for ExtractorWindow {
124    fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
125        output.write_str(match self {
126            Self::Lookbehind => "max lookbehind",
127            Self::Lookahead => "max lookahead",
128        })
129    }
130}
131
132/// Error returned for an extractor window outside `1..=4096`.
133#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
134pub struct ExtractorConfigError {
135    window: ExtractorWindow,
136    value: usize,
137}
138
139impl ExtractorConfigError {
140    /// Return the invalid setting.
141    #[must_use]
142    pub const fn window(self) -> ExtractorWindow {
143        self.window
144    }
145
146    /// Return the rejected value.
147    #[must_use]
148    pub const fn value(self) -> usize {
149        self.value
150    }
151}
152
153impl fmt::Display for ExtractorConfigError {
154    fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
155        write!(
156            output,
157            "{} must be between {MIN_LOOKAROUND} and {MAX_LOOKAROUND} bytes (got {})",
158            self.window, self.value
159        )
160    }
161}
162
163impl std::error::Error for ExtractorConfigError {}
164
165/// Builder for a configurable [`ReferenceExtractor`].
166#[derive(Clone, Debug)]
167pub struct ReferenceExtractorBuilder {
168    parser: PassageParser,
169    include_bare_books: bool,
170    max_lookbehind: usize,
171    max_lookahead: usize,
172}
173
174impl ReferenceExtractorBuilder {
175    /// Replace the parser used to validate candidate spans.
176    #[must_use]
177    pub fn parser(mut self, parser: PassageParser) -> Self {
178        self.parser = parser;
179        self
180    }
181
182    /// Configure whether whole-book passages are extracted.
183    #[must_use]
184    pub const fn include_bare_books(mut self, include: bool) -> Self {
185        self.include_bare_books = include;
186        self
187    }
188
189    /// Set the normalized-byte search distance before numeric anchors.
190    ///
191    /// The value is checked by [`Self::build`].
192    #[must_use]
193    pub const fn max_lookbehind(mut self, value: usize) -> Self {
194        self.max_lookbehind = value;
195        self
196    }
197
198    /// Set the normalized-byte search distance after numeric anchors.
199    ///
200    /// The value is checked by [`Self::build`].
201    #[must_use]
202    pub const fn max_lookahead(mut self, value: usize) -> Self {
203        self.max_lookahead = value;
204        self
205    }
206
207    /// Validate the settings and construct an extractor.
208    pub fn build(self) -> Result<ReferenceExtractor, ExtractorConfigError> {
209        ReferenceExtractor::with_options(
210            self.parser,
211            self.include_bare_books,
212            self.max_lookbehind,
213            self.max_lookahead,
214        )
215    }
216}
217
218impl Default for ReferenceExtractorBuilder {
219    fn default() -> Self {
220        Self {
221            parser: PassageParser::new(),
222            include_bare_books: false,
223            max_lookbehind: DEFAULT_MAX_LOOKBEHIND,
224            max_lookahead: DEFAULT_MAX_LOOKAHEAD,
225        }
226    }
227}
228
229/// Finds parseable Bible passages embedded in arbitrary prose.
230///
231/// Results are returned from left to right, never overlap, and prefer the
232/// longest successful parse beginning at a given byte offset.
233#[derive(Clone, Debug)]
234pub struct ReferenceExtractor {
235    parser: PassageParser,
236    include_bare_books: bool,
237    max_lookbehind: usize,
238    max_lookahead: usize,
239}
240
241impl ReferenceExtractor {
242    /// Construct an extractor with a default [`PassageParser`].
243    #[must_use]
244    pub fn new() -> Self {
245        Self::default()
246    }
247
248    /// Start configuring an extractor.
249    #[must_use]
250    pub fn builder() -> ReferenceExtractorBuilder {
251        ReferenceExtractorBuilder::default()
252    }
253
254    /// Construct an extractor around an existing parser.
255    #[must_use]
256    pub fn from_parser(parser: PassageParser) -> Self {
257        Self {
258            parser,
259            ..Self::default()
260        }
261    }
262
263    /// Construct an extractor from validated configuration values.
264    pub fn with_options(
265        parser: PassageParser,
266        include_bare_books: bool,
267        max_lookbehind: usize,
268        max_lookahead: usize,
269    ) -> Result<Self, ExtractorConfigError> {
270        validate_window(ExtractorWindow::Lookbehind, max_lookbehind)?;
271        validate_window(ExtractorWindow::Lookahead, max_lookahead)?;
272        Ok(Self {
273            parser,
274            include_bare_books,
275            max_lookbehind,
276            max_lookahead,
277        })
278    }
279
280    /// Return the parser used to validate candidate spans.
281    #[must_use]
282    pub const fn parser(&self) -> &PassageParser {
283        &self.parser
284    }
285
286    /// Return whether whole-book passages are included.
287    #[must_use]
288    pub const fn include_bare_books(&self) -> bool {
289        self.include_bare_books
290    }
291
292    /// Return the normalized-byte search distance before numeric anchors.
293    #[must_use]
294    pub const fn max_lookbehind(&self) -> usize {
295        self.max_lookbehind
296    }
297
298    /// Return the normalized-byte search distance after numeric anchors.
299    #[must_use]
300    pub const fn max_lookahead(&self) -> usize {
301        self.max_lookahead
302    }
303
304    /// Return a copy configured to include or exclude whole-book passages.
305    #[must_use]
306    pub fn with_include_bare_books(mut self, include: bool) -> Self {
307        self.include_bare_books = include;
308        self
309    }
310
311    /// Return a copy with a validated lookbehind window.
312    pub fn with_max_lookbehind(mut self, value: usize) -> Result<Self, ExtractorConfigError> {
313        validate_window(ExtractorWindow::Lookbehind, value)?;
314        self.max_lookbehind = value;
315        Ok(self)
316    }
317
318    /// Return a copy with a validated lookahead window.
319    pub fn with_max_lookahead(mut self, value: usize) -> Result<Self, ExtractorConfigError> {
320        validate_window(ExtractorWindow::Lookahead, value)?;
321        self.max_lookahead = value;
322        Ok(self)
323    }
324
325    /// Find deterministic, non-overlapping passages in `source`.
326    #[must_use]
327    pub fn extract(&self, source: &str) -> Vec<PassageMatch> {
328        self.extract_inner(source, None)
329    }
330
331    /// Find passages while resolving book names in one explicit language.
332    #[must_use]
333    pub fn extract_with_language(&self, source: &str, language: Language) -> Vec<PassageMatch> {
334        self.extract_inner(source, Some(language))
335    }
336
337    /// Alias for [`Self::extract`].
338    #[must_use]
339    pub fn find_all(&self, source: &str) -> Vec<PassageMatch> {
340        self.extract(source)
341    }
342
343    /// Language-specific alias for [`Self::extract_with_language`].
344    #[must_use]
345    pub fn find_all_with_language(&self, source: &str, language: Language) -> Vec<PassageMatch> {
346        self.extract_with_language(source, language)
347    }
348
349    /// Replace all matches in one pass without scanning replacement text.
350    #[must_use]
351    pub fn replace_matches<F, R>(&self, source: &str, replacement: F) -> String
352    where
353        F: FnMut(&PassageMatch) -> R,
354        R: fmt::Display,
355    {
356        self.replace_matches_inner(source, None, replacement)
357    }
358
359    /// Replace language-specific matches in one pass.
360    #[must_use]
361    pub fn replace_matches_with_language<F, R>(
362        &self,
363        source: &str,
364        language: Language,
365        replacement: F,
366    ) -> String
367    where
368        F: FnMut(&PassageMatch) -> R,
369        R: fmt::Display,
370    {
371        self.replace_matches_inner(source, Some(language), replacement)
372    }
373
374    /// Linkify matches with a caller-provided renderer.
375    #[must_use]
376    pub fn linkify<F, R>(&self, source: &str, link_builder: F) -> String
377    where
378        F: FnMut(&PassageMatch) -> R,
379        R: fmt::Display,
380    {
381        self.replace_matches(source, link_builder)
382    }
383
384    /// Linkify matches resolved with one explicit language.
385    #[must_use]
386    pub fn linkify_with_language<F, R>(
387        &self,
388        source: &str,
389        language: Language,
390        link_builder: F,
391    ) -> String
392    where
393        F: FnMut(&PassageMatch) -> R,
394        R: fmt::Display,
395    {
396        self.replace_matches_with_language(source, language, link_builder)
397    }
398
399    /// Linkify matches as Markdown using each exact source slice as its label.
400    #[must_use]
401    pub fn linkify_markdown<F, U>(&self, source: &str, mut uri_builder: F) -> String
402    where
403        F: FnMut(&PassageMatch) -> U,
404        U: fmt::Display,
405    {
406        self.replace_matches(source, |passage_match| {
407            markdown_link(passage_match.source_text(), &uri_builder(passage_match))
408        })
409    }
410
411    /// Linkify explicitly localized matches as Markdown using exact source labels.
412    #[must_use]
413    pub fn linkify_markdown_with_language<F, U>(
414        &self,
415        source: &str,
416        language: Language,
417        mut uri_builder: F,
418    ) -> String
419    where
420        F: FnMut(&PassageMatch) -> U,
421        U: fmt::Display,
422    {
423        self.replace_matches_with_language(source, language, |passage_match| {
424            markdown_link(passage_match.source_text(), &uri_builder(passage_match))
425        })
426    }
427
428    /// Linkify matches as Markdown with caller-provided labels and destinations.
429    #[must_use]
430    pub fn linkify_markdown_with_label<F, U, L>(
431        &self,
432        source: &str,
433        mut uri_builder: F,
434        mut label_builder: L,
435    ) -> String
436    where
437        F: FnMut(&PassageMatch) -> U,
438        U: fmt::Display,
439        L: FnMut(&PassageMatch) -> String,
440    {
441        self.replace_matches(source, |passage_match| {
442            markdown_link(&label_builder(passage_match), &uri_builder(passage_match))
443        })
444    }
445
446    /// Linkify explicitly localized matches with custom Markdown labels.
447    #[must_use]
448    pub fn linkify_markdown_with_label_and_language<F, U, L>(
449        &self,
450        source: &str,
451        language: Language,
452        mut uri_builder: F,
453        mut label_builder: L,
454    ) -> String
455    where
456        F: FnMut(&PassageMatch) -> U,
457        U: fmt::Display,
458        L: FnMut(&PassageMatch) -> String,
459    {
460        self.replace_matches_with_language(source, language, |passage_match| {
461            markdown_link(&label_builder(passage_match), &uri_builder(passage_match))
462        })
463    }
464
465    /// Alias for [`Self::linkify_markdown`].
466    #[must_use]
467    pub fn markdown_linkify<F, U>(&self, source: &str, uri_builder: F) -> String
468    where
469        F: FnMut(&PassageMatch) -> U,
470        U: fmt::Display,
471    {
472        self.linkify_markdown(source, uri_builder)
473    }
474
475    /// Language-specific alias for [`Self::linkify_markdown_with_language`].
476    #[must_use]
477    pub fn markdown_linkify_with_language<F, U>(
478        &self,
479        source: &str,
480        language: Language,
481        uri_builder: F,
482    ) -> String
483    where
484        F: FnMut(&PassageMatch) -> U,
485        U: fmt::Display,
486    {
487        self.linkify_markdown_with_language(source, language, uri_builder)
488    }
489
490    fn extract_inner(&self, source: &str, language: Option<Language>) -> Vec<PassageMatch> {
491        if source.is_empty() {
492            return Vec::new();
493        }
494
495        let normalization = normalize_detailed(source);
496        let search_text = normalization.normalized();
497        let mut candidates = BTreeMap::new();
498
499        for (anchor_start, anchor_end) in numeric_anchors(search_text) {
500            let minimum_start = anchor_start.saturating_sub(self.max_lookbehind);
501            let maximum_end = anchor_end
502                .saturating_add(self.max_lookahead)
503                .min(search_text.len());
504            self.search_window(
505                search_text,
506                &normalization,
507                minimum_start,
508                anchor_start,
509                anchor_end,
510                maximum_end,
511                language,
512                &mut candidates,
513            );
514        }
515
516        if self.include_bare_books {
517            self.search_bare_passages(search_text, &normalization, language, &mut candidates);
518        }
519
520        let mut ordered = candidates.into_values().collect::<Vec<_>>();
521        ordered.sort_unstable_by(|left, right| {
522            left.start
523                .cmp(&right.start)
524                .then_with(|| right.end.cmp(&left.end))
525        });
526
527        let mut selected = Vec::new();
528        let mut consumed_through = 0;
529        for candidate in ordered {
530            if candidate.start < consumed_through {
531                continue;
532            }
533            consumed_through = candidate.end;
534            selected.push(candidate);
535        }
536        selected
537    }
538
539    #[allow(clippy::too_many_arguments)]
540    fn search_window(
541        &self,
542        search_text: &str,
543        normalization: &NormalizedInput,
544        minimum_start: usize,
545        required_start_before: usize,
546        required_end_after: usize,
547        maximum_end: usize,
548        language: Option<Language>,
549        output: &mut BTreeMap<(usize, usize), PassageMatch>,
550    ) {
551        let bounded_start = next_char_boundary(search_text, minimum_start);
552        let starts = if bounded_start < required_start_before {
553            search_text[bounded_start..required_start_before]
554                .char_indices()
555                .map(|(index, _)| bounded_start + index)
556                .filter(|&index| can_start_at(search_text, index))
557                .collect::<Vec<_>>()
558        } else {
559            Vec::new()
560        };
561
562        let bounded_end = previous_char_boundary(search_text, maximum_end);
563        let mut ends = Vec::new();
564        if required_end_after <= bounded_end
565            && search_text[..required_end_after]
566                .chars()
567                .next_back()
568                .is_some_and(|character| character.is_ascii_digit())
569        {
570            ends.push(required_end_after);
571        }
572        if required_end_after < bounded_end {
573            ends.extend(
574                search_text[required_end_after..bounded_end]
575                    .char_indices()
576                    .filter_map(|(index, character)| {
577                        let end = required_end_after + index + character.len_utf8();
578                        character.is_ascii_digit().then_some(end)
579                    }),
580            );
581        }
582        ends.reverse();
583
584        for start in starts {
585            for &end in &ends {
586                if end <= start || !has_safe_outer_boundaries(search_text, start, end) {
587                    continue;
588                }
589                if self.try_candidate(search_text, start, end, normalization, language, output) {
590                    break;
591                }
592            }
593        }
594    }
595
596    fn search_bare_passages(
597        &self,
598        search_text: &str,
599        normalization: &NormalizedInput,
600        language: Option<Language>,
601        output: &mut BTreeMap<(usize, usize), PassageMatch>,
602    ) {
603        for (start, _) in search_text.char_indices() {
604            if !can_start_at(search_text, start) {
605                continue;
606            }
607            let maximum_end = start
608                .saturating_add(self.max_lookahead)
609                .min(search_text.len());
610            let maximum_end = previous_char_boundary(search_text, maximum_end);
611            let mut ends = search_text[start..maximum_end]
612                .char_indices()
613                .filter_map(|(index, character)| {
614                    let end = start + index + character.len_utf8();
615                    can_end_at(search_text, end).then_some(end)
616                })
617                .collect::<Vec<_>>();
618            ends.reverse();
619
620            for end in ends {
621                if !has_safe_outer_boundaries(search_text, start, end) {
622                    continue;
623                }
624                self.try_candidate(search_text, start, end, normalization, language, output);
625            }
626        }
627    }
628
629    fn try_candidate(
630        &self,
631        search_text: &str,
632        start: usize,
633        end: usize,
634        normalization: &NormalizedInput,
635        language: Option<Language>,
636        output: &mut BTreeMap<(usize, usize), PassageMatch>,
637    ) -> bool {
638        let Some(normalized_candidate) = search_text.get(start..end) else {
639            return false;
640        };
641        let Some(parsed) = self.parse_candidate(normalized_candidate, language) else {
642            return false;
643        };
644        let (mut passage, mut metadata) = parsed.into_parts();
645        if !self.include_bare_books
646            && (contains_bare_book(&passage) || !contains_reference_number(normalized_candidate))
647        {
648            return false;
649        }
650
651        let Some(mapped_span) = normalization.map_normalized_span(start, end) else {
652            return false;
653        };
654        let original_span = trim_bidi_controls(normalization.original(), mapped_span);
655        let key = (original_span.start(), original_span.end());
656        if output.contains_key(&key) {
657            return true;
658        }
659        let Some(source_text) = normalization.original().get(original_span.as_range()) else {
660            return false;
661        };
662
663        if let Some(original) = self.parse_candidate(source_text, language) {
664            (passage, metadata) = original.into_parts();
665        }
666        if looks_like_common_word(&metadata, language) {
667            return false;
668        }
669
670        output.insert(
671            key,
672            PassageMatch {
673                passage,
674                start: original_span.start(),
675                end: original_span.end(),
676                source_text: source_text.to_owned(),
677                metadata,
678            },
679        );
680        true
681    }
682
683    fn parse_candidate(
684        &self,
685        candidate: &str,
686        language: Option<Language>,
687    ) -> Option<crate::Parsed<Passage>> {
688        match language {
689            Some(language) => self
690                .parser
691                .parse_detailed_with_language(candidate, language)
692                .ok(),
693            None => self.parser.parse_detailed(candidate).ok(),
694        }
695    }
696
697    fn replace_matches_inner<F, R>(
698        &self,
699        source: &str,
700        language: Option<Language>,
701        mut replacement: F,
702    ) -> String
703    where
704        F: FnMut(&PassageMatch) -> R,
705        R: fmt::Display,
706    {
707        let matches = self.extract_inner(source, language);
708        if matches.is_empty() {
709            return source.to_owned();
710        }
711
712        let mut output = String::with_capacity(source.len());
713        let mut copied_through = 0;
714        for passage_match in &matches {
715            output.push_str(&source[copied_through..passage_match.start]);
716            write!(output, "{}", replacement(passage_match))
717                .expect("writing into a String cannot fail");
718            copied_through = passage_match.end;
719        }
720        output.push_str(&source[copied_through..]);
721        output
722    }
723}
724
725impl Default for ReferenceExtractor {
726    fn default() -> Self {
727        Self {
728            parser: PassageParser::new(),
729            include_bare_books: false,
730            max_lookbehind: DEFAULT_MAX_LOOKBEHIND,
731            max_lookahead: DEFAULT_MAX_LOOKAHEAD,
732        }
733    }
734}
735
736fn validate_window(window: ExtractorWindow, value: usize) -> Result<(), ExtractorConfigError> {
737    if (MIN_LOOKAROUND..=MAX_LOOKAROUND).contains(&value) {
738        Ok(())
739    } else {
740        Err(ExtractorConfigError { window, value })
741    }
742}
743
744fn numeric_anchors(source: &str) -> Vec<(usize, usize)> {
745    let bytes = source.as_bytes();
746    let mut anchors = Vec::new();
747    let mut index = 0;
748    while index < bytes.len() {
749        if !bytes[index].is_ascii_digit() {
750            index += 1;
751            continue;
752        }
753        let start = index;
754        while index < bytes.len() && bytes[index].is_ascii_digit() {
755            index += 1;
756        }
757        anchors.push((start, index));
758    }
759    anchors
760}
761
762fn next_char_boundary(source: &str, mut index: usize) -> usize {
763    index = index.min(source.len());
764    while index < source.len() && !source.is_char_boundary(index) {
765        index += 1;
766    }
767    index
768}
769
770fn previous_char_boundary(source: &str, mut index: usize) -> usize {
771    index = index.min(source.len());
772    while index > 0 && !source.is_char_boundary(index) {
773        index -= 1;
774    }
775    index
776}
777
778fn contains_reference_number(value: &str) -> bool {
779    value.bytes().any(|byte| byte.is_ascii_digit())
780}
781
782fn contains_bare_book(passage: &Passage) -> bool {
783    match passage {
784        Passage::Book(_) => true,
785        Passage::Chapter(_) | Passage::Verses(_) => false,
786        Passage::Sequence(sequence) => sequence.passages().iter().any(contains_bare_book),
787    }
788}
789
790fn can_start_at(source: &str, index: usize) -> bool {
791    if index >= source.len() || !source.is_char_boundary(index) {
792        return false;
793    }
794    let current = source[index..]
795        .chars()
796        .next()
797        .expect("a valid non-terminal boundary has a character");
798    if is_edge_delimiter(current) {
799        return false;
800    }
801    if index == 0 {
802        return true;
803    }
804    let previous = source[..index]
805        .chars()
806        .next_back()
807        .expect("a positive character boundary has a previous character");
808    !forms_word_continuation(previous, current)
809}
810
811fn can_end_at(source: &str, index: usize) -> bool {
812    if index == 0 || index > source.len() || !source.is_char_boundary(index) {
813        return false;
814    }
815    let previous = source[..index]
816        .chars()
817        .next_back()
818        .expect("a positive character boundary has a previous character");
819    !is_edge_delimiter(previous)
820}
821
822fn has_safe_outer_boundaries(source: &str, start: usize, end: usize) -> bool {
823    if start > 0 {
824        let previous = source[..start]
825            .chars()
826            .next_back()
827            .expect("a positive character boundary has a previous character");
828        let current = source[start..]
829            .chars()
830            .next()
831            .expect("a candidate start precedes its end");
832        if forms_word_continuation(previous, current) {
833            return false;
834        }
835    }
836    if end < source.len() {
837        let remaining = source[end..].trim_start();
838        if let Some(punctuation) = remaining.chars().next() {
839            if matches!(punctuation, ':' | '.' | '-' | ',')
840                && remaining[punctuation.len_utf8()..]
841                    .trim_start()
842                    .starts_with(|character: char| character.is_ascii_digit())
843            {
844                return false;
845            }
846        }
847        let previous = source[..end]
848            .chars()
849            .next_back()
850            .expect("a candidate end follows its start");
851        let current = source[end..]
852            .chars()
853            .next()
854            .expect("a non-terminal boundary has a following character");
855        if forms_word_continuation(previous, current) {
856            return false;
857        }
858    }
859    true
860}
861
862fn trim_bidi_controls(source: &str, span: SourceSpan) -> SourceSpan {
863    let mut start = span.start();
864    let mut end = span.end();
865    while start < end {
866        let Some(character) = source[start..end].chars().next() else {
867            break;
868        };
869        if !is_bidi_control(character) {
870            break;
871        }
872        start += character.len_utf8();
873    }
874    while end > start {
875        let Some(character) = source[start..end].chars().next_back() else {
876            break;
877        };
878        if !is_bidi_control(character) {
879            break;
880        }
881        end -= character.len_utf8();
882    }
883    SourceSpan::new(start, end)
884}
885
886fn looks_like_common_word(metadata: &ParseMetadata, language: Option<Language>) -> bool {
887    let automatic = language.is_none_or(Language::is_auto);
888    for book_match in metadata.book_matches() {
889        let book = book_match.selected().book();
890        let token = book_match.token().trim_start();
891        let compact_token = token
892            .chars()
893            .filter(|character| !matches!(character, ' ' | '.'))
894            .flat_map(char::to_lowercase)
895            .collect::<String>();
896        if automatic && matches!(compact_token.as_str(), "am" | "at" | "is" | "so") {
897            return true;
898        }
899
900        let mut first_ascii_letter_is_lowercase = false;
901        let mut ascii_letter_count = 0;
902        let mut is_simple_ascii_alias = true;
903        for character in token.chars() {
904            if character.is_ascii_uppercase() {
905                ascii_letter_count += 1;
906            } else if character.is_ascii_lowercase() {
907                first_ascii_letter_is_lowercase |= ascii_letter_count == 0;
908                ascii_letter_count += 1;
909            } else if character != ' ' && character != '.' && !character.is_ascii_digit() {
910                is_simple_ascii_alias = false;
911            }
912        }
913        if !first_ascii_letter_is_lowercase {
914            continue;
915        }
916        if matches!(book, Book::Mark | Book::Job) {
917            return true;
918        }
919        if automatic && is_simple_ascii_alias && ascii_letter_count <= 3 {
920            return true;
921        }
922    }
923    false
924}
925
926fn forms_word_continuation(left: char, right: char) -> bool {
927    if is_east_asian(left) || is_east_asian(right) {
928        return false;
929    }
930    (left.is_alphanumeric() || left == '_') && (right.is_alphanumeric() || right == '_')
931}
932
933fn is_east_asian(character: char) -> bool {
934    matches!(
935        character as u32,
936        0x1100..=0x11ff
937            | 0x2e80..=0x2fff
938            | 0x3040..=0x30ff
939            | 0x3130..=0x318f
940            | 0x3400..=0x4dbf
941            | 0x4e00..=0x9fff
942            | 0xac00..=0xd7af
943            | 0xf900..=0xfaff
944            | 0x20000..=0x2fa1f
945    )
946}
947
948fn is_bidi_control(character: char) -> bool {
949    matches!(
950        character as u32,
951        0x061c | 0x200e | 0x200f | 0x202a..=0x202e | 0x2066..=0x206f
952    )
953}
954
955fn is_edge_delimiter(character: char) -> bool {
956    character <= '\u{20}'
957        || character == '\u{7f}'
958        || is_bidi_control(character)
959        || matches!(
960            character,
961            '.' | ','
962                | '!'
963                | '?'
964                | ';'
965                | ':'
966                | '('
967                | ')'
968                | '['
969                | ']'
970                | '{'
971                | '}'
972                | '<'
973                | '>'
974                | '"'
975                | '\''
976        )
977}
978
979fn markdown_link(label: &str, destination: &impl fmt::Display) -> String {
980    let label = escape_markdown_label(label);
981    let destination = escape_markdown_destination(&destination.to_string());
982    format!("[{label}]({destination})")
983}
984
985fn escape_markdown_label(value: &str) -> String {
986    value
987        .replace('\\', "\\\\")
988        .replace('[', "\\[")
989        .replace(']', "\\]")
990}
991
992fn escape_markdown_destination(value: &str) -> String {
993    value.replace('(', "%28").replace(')', "%29")
994}
995
996#[cfg(test)]
997#[path = "../tests/unit/extractor.rs"]
998mod tests;