1use 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
12pub const DEFAULT_MAX_LOOKBEHIND: usize = 96;
14
15pub const DEFAULT_MAX_LOOKAHEAD: usize = 256;
17
18pub const MIN_LOOKAROUND: usize = 1;
20
21pub const MAX_LOOKAROUND: usize = 4096;
23
24#[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 #[must_use]
37 pub const fn passage(&self) -> &Passage {
38 &self.passage
39 }
40
41 #[must_use]
43 pub const fn start(&self) -> usize {
44 self.start
45 }
46
47 #[must_use]
49 pub const fn end(&self) -> usize {
50 self.end
51 }
52
53 #[must_use]
55 pub const fn start_offset(&self) -> usize {
56 self.start
57 }
58
59 #[must_use]
61 pub const fn end_offset(&self) -> usize {
62 self.end
63 }
64
65 #[must_use]
67 pub const fn range(&self) -> Range<usize> {
68 self.start..self.end
69 }
70
71 #[must_use]
73 pub const fn byte_range(&self) -> Range<usize> {
74 self.range()
75 }
76
77 #[must_use]
79 pub const fn len(&self) -> usize {
80 self.end - self.start
81 }
82
83 #[must_use]
85 pub const fn is_empty(&self) -> bool {
86 self.start == self.end
87 }
88
89 #[must_use]
91 pub fn source_text(&self) -> &str {
92 &self.source_text
93 }
94
95 #[must_use]
97 pub fn exact_source(&self) -> &str {
98 self.source_text()
99 }
100
101 #[must_use]
103 pub const fn metadata(&self) -> &ParseMetadata {
104 &self.metadata
105 }
106
107 #[must_use]
109 pub fn into_passage(self) -> Passage {
110 self.passage
111 }
112}
113
114#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
116pub enum ExtractorWindow {
117 Lookbehind,
119 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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
134pub struct ExtractorConfigError {
135 window: ExtractorWindow,
136 value: usize,
137}
138
139impl ExtractorConfigError {
140 #[must_use]
142 pub const fn window(self) -> ExtractorWindow {
143 self.window
144 }
145
146 #[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#[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 #[must_use]
177 pub fn parser(mut self, parser: PassageParser) -> Self {
178 self.parser = parser;
179 self
180 }
181
182 #[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 #[must_use]
193 pub const fn max_lookbehind(mut self, value: usize) -> Self {
194 self.max_lookbehind = value;
195 self
196 }
197
198 #[must_use]
202 pub const fn max_lookahead(mut self, value: usize) -> Self {
203 self.max_lookahead = value;
204 self
205 }
206
207 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#[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 #[must_use]
244 pub fn new() -> Self {
245 Self::default()
246 }
247
248 #[must_use]
250 pub fn builder() -> ReferenceExtractorBuilder {
251 ReferenceExtractorBuilder::default()
252 }
253
254 #[must_use]
256 pub fn from_parser(parser: PassageParser) -> Self {
257 Self {
258 parser,
259 ..Self::default()
260 }
261 }
262
263 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 #[must_use]
282 pub const fn parser(&self) -> &PassageParser {
283 &self.parser
284 }
285
286 #[must_use]
288 pub const fn include_bare_books(&self) -> bool {
289 self.include_bare_books
290 }
291
292 #[must_use]
294 pub const fn max_lookbehind(&self) -> usize {
295 self.max_lookbehind
296 }
297
298 #[must_use]
300 pub const fn max_lookahead(&self) -> usize {
301 self.max_lookahead
302 }
303
304 #[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 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 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 #[must_use]
327 pub fn extract(&self, source: &str) -> Vec<PassageMatch> {
328 self.extract_inner(source, None)
329 }
330
331 #[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 #[must_use]
339 pub fn find_all(&self, source: &str) -> Vec<PassageMatch> {
340 self.extract(source)
341 }
342
343 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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;