Skip to main content

rumdl_lib/utils/
sentence_utils.rs

1//! Sentence detection utilities
2//!
3//! This module provides shared functionality for detecting sentence boundaries
4//! in markdown text. Used by both text reflow (MD013) and the multiple spaces
5//! rule (MD064).
6//!
7//! Features:
8//! - Common abbreviation detection (Mr., Dr., Prof., etc.)
9//! - CJK punctuation support (。, !, ?)
10//! - Closing quote detection (straight and curly)
11//! - Both forward-looking (reflow) and backward-looking (MD064) sentence detection
12
13use std::collections::HashSet;
14
15/// Default abbreviations that should NOT be treated as sentence endings.
16///
17/// Only includes abbreviations that:
18/// 1. Conventionally ALWAYS have a period in standard writing
19/// 2. Are almost always followed by something, not sentence-final
20///
21/// Does NOT include:
22/// - Abbreviations that commonly end sentences (etc., Inc., Ph.D., U.S.)
23const DEFAULT_ABBREVIATIONS: &[&str] = &[
24    // Titles - always have period, always followed by a name
25    "mr", "mrs", "ms", "dr", "prof", "sr", "jr", "st",
26    // Latin - always written with periods, introduce examples/references
27    "i.e", "e.g", // Reference abbreviations - followed by what they refer to
28    "vs", "fig", "no", "vol", "ch", "sec", "al",
29];
30
31/// Get the effective abbreviations set based on custom additions
32/// All abbreviations are normalized to lowercase for case-insensitive matching
33/// Custom abbreviations are always merged with built-in defaults
34pub fn get_abbreviations(custom: &Option<Vec<String>>) -> HashSet<String> {
35    let mut abbreviations: HashSet<String> = DEFAULT_ABBREVIATIONS.iter().map(|s| s.to_lowercase()).collect();
36
37    // Always extend defaults with custom abbreviations
38    // Strip any trailing periods and normalize to lowercase for consistent matching
39    if let Some(custom_list) = custom {
40        for abbr in custom_list {
41            let normalized = abbr.trim_end_matches('.').to_lowercase();
42            if !normalized.is_empty() {
43                abbreviations.insert(normalized);
44            }
45        }
46    }
47
48    abbreviations
49}
50
51/// Check if text ends with a common abbreviation followed by a period
52///
53/// Abbreviations only count when followed by a period, not ! or ?.
54/// This prevents false positives where words ending in abbreviation-like
55/// letter sequences (e.g., "paradigms" ending in "ms") are incorrectly
56/// detected as abbreviations.
57///
58/// Examples:
59///   - "Dr." -> true (abbreviation)
60///   - "Dr?" -> false (question, not abbreviation)
61///   - "paradigms." -> false (not in abbreviation list)
62///   - "paradigms?" -> false (question mark, not abbreviation)
63pub fn text_ends_with_abbreviation(text: &str, abbreviations: &HashSet<String>) -> bool {
64    // Only check if text ends with a period (abbreviations require periods)
65    if !text.ends_with('.') {
66        return false;
67    }
68
69    // Remove the trailing period
70    let without_period = text.trim_end_matches('.');
71
72    // Get the last word by splitting on whitespace
73    let last_word = without_period.split_whitespace().last().unwrap_or("");
74
75    if last_word.is_empty() {
76        return false;
77    }
78
79    // Strip leading punctuation (parentheses, brackets, quotes, emphasis markers)
80    // that may precede the abbreviation, e.g. "(e.g." or "[i.e."
81    let stripped = last_word.trim_start_matches(|c: char| !c.is_alphanumeric() && c != '.');
82
83    // Check the full stripped word first (covers simple cases like "Dr.", "Prof.")
84    if abbreviations.contains(&stripped.to_lowercase()) {
85        return true;
86    }
87
88    // Also check the last hyphen-separated component so that hyphenated place names
89    // like "Wrangell-St." are recognized via the "st" abbreviation entry.
90    if let Some(after_hyphen) = stripped.rsplit('-').next()
91        && !after_hyphen.is_empty()
92        && after_hyphen != stripped
93    {
94        return abbreviations.contains(&after_hyphen.to_lowercase());
95    }
96
97    false
98}
99
100/// Check if a character is CJK sentence-ending punctuation
101/// These include: 。(ideographic full stop), !(fullwidth exclamation), ?(fullwidth question)
102pub fn is_cjk_sentence_ending(c: char) -> bool {
103    matches!(c, '。' | '!' | '?')
104}
105
106/// Check if a character is a closing quote mark
107/// Includes straight quotes and curly/smart quotes
108pub fn is_closing_quote(c: char) -> bool {
109    // " (straight double), ' (straight single), " (U+201D right double), ' (U+2019 right single)
110    // » (right guillemet), › (single right guillemet)
111    matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | '»' | '›')
112}
113
114/// Check if a character is an ASCII closing bracket
115pub fn is_ascii_closing_bracket(c: char) -> bool {
116    matches!(c, ')' | ']' | '}')
117}
118
119/// Check if a character is a fullwidth or CJK closing bracket
120///
121/// Covers the fullwidth forms of the ASCII brackets and the corner, lenticular,
122/// tortoise-shell and angle brackets CJK text encloses an aside in, together
123/// with their halfwidth and vertical presentation forms.
124pub fn is_cjk_closing_bracket(c: char) -> bool {
125    matches!(
126        c,
127        ')' | ']'
128            | '}'
129            | '」'
130            | '』'
131            | '】'
132            | '〕'
133            | '》'
134            | '〉'
135            | '〙'
136            | '〛'
137            | '\u{FF63}'
138            | '\u{FE42}'
139            | '\u{FE44}'
140    )
141}
142
143/// Check if a character closes a bracketed aside
144///
145/// The two sets stay separate above so a caller can stay with ASCII brackets
146/// alone where widening the set would change English prose.
147pub fn is_closing_bracket(c: char) -> bool {
148    is_ascii_closing_bracket(c) || is_cjk_closing_bracket(c)
149}
150
151/// Check if a character is an opening quote mark
152/// Includes straight quotes and curly/smart quotes
153pub fn is_opening_quote(c: char) -> bool {
154    // " (straight double), ' (straight single), " (U+201C left double), ' (U+2018 left single)
155    // « (left guillemet), ‹ (single left guillemet)
156    matches!(c, '"' | '\'' | '\u{201C}' | '\u{2018}' | '«' | '‹')
157}
158
159/// Check if a character is a CJK character (Chinese, Japanese, Korean)
160pub fn is_cjk_char(c: char) -> bool {
161    // CJK Unified Ideographs and common extensions
162    matches!(c,
163        '\u{4E00}'..='\u{9FFF}' |   // CJK Unified Ideographs
164        '\u{3400}'..='\u{4DBF}' |   // CJK Unified Ideographs Extension A
165        '\u{3040}'..='\u{309F}' |   // Hiragana
166        '\u{30A0}'..='\u{30FF}' |   // Katakana
167        '\u{AC00}'..='\u{D7AF}'     // Hangul Syllables
168    )
169}
170
171/// Check if a character is closing punctuation that can follow sentence-ending punctuation
172/// This includes closing quotes, parentheses, and brackets
173fn is_trailing_close_punctuation(c: char) -> bool {
174    is_closing_quote(c) || is_ascii_closing_bracket(c)
175}
176
177/// Check if multiple spaces occur immediately after sentence-ending punctuation.
178/// This is a backward-looking check used by MD064.
179///
180/// Returns true if the character(s) immediately before `match_start` constitute
181/// a sentence ending, supporting the traditional two-space-after-sentence convention.
182///
183/// Recognized sentence-ending patterns:
184/// - Direct punctuation: `.`, `!`, `?`, `。`, `!`, `?`
185/// - With closing quotes: `."`, `!"`, `?"`, `.'`, `!'`, `?'`, `."`, `?"`, `!"`
186/// - With closing parenthesis: `.)`, `!)`, `?)`
187/// - With closing bracket: `.]`, `!]`, `?]`
188/// - Ellipsis: `...`
189/// - Combinations: `.")`  (quote then paren), `?')`
190///
191/// Does NOT treat as sentence ending:
192/// - Abbreviations: `Dr.`, `Mr.`, `Prof.`, etc. (when detectable)
193/// - Single letters followed by period: `A.` (likely initials or list markers)
194pub fn is_after_sentence_ending(text: &str, match_start: usize) -> bool {
195    is_after_sentence_ending_with_abbreviations(text, match_start, &get_abbreviations(&None))
196}
197
198/// Check if multiple spaces occur immediately after sentence-ending punctuation,
199/// with a custom abbreviations set.
200///
201/// Note: `match_start` is a byte position (from regex). This function handles
202/// multi-byte UTF-8 characters correctly by working with character iterators.
203fn is_after_sentence_ending_with_abbreviations(
204    text: &str,
205    match_start: usize,
206    abbreviations: &HashSet<String>,
207) -> bool {
208    if match_start == 0 || match_start > text.len() {
209        return false;
210    }
211
212    // Safely get the portion of the text before the spaces
213    // match_start is a byte position, so we need to ensure it's a valid char boundary
214    let Some(before) = text.get(..match_start) else {
215        return false; // Invalid byte position
216    };
217
218    // Collect chars for iteration (we need random access for some checks)
219    let chars: Vec<char> = before.chars().collect();
220    if chars.is_empty() {
221        return false;
222    }
223
224    let mut idx = chars.len() - 1;
225
226    // Skip through any trailing closing punctuation (quotes, parens, brackets)
227    // These can appear after the sentence-ending punctuation
228    // e.g., `sentence."  Next` or `sentence.)  Next` or `sentence.")`
229    while idx > 0 && is_trailing_close_punctuation(chars[idx]) {
230        idx -= 1;
231    }
232
233    // Now check if we're at sentence-ending punctuation
234    let current = chars[idx];
235
236    // Check for CJK sentence-ending punctuation
237    if is_cjk_sentence_ending(current) {
238        return true;
239    }
240
241    // Direct sentence-ending punctuation (! and ?)
242    if current == '!' || current == '?' {
243        return true;
244    }
245
246    // Period - need more careful handling
247    if current == '.' {
248        // Check for ellipsis (...) - always a valid sentence ending
249        if idx >= 2 && chars[idx - 1] == '.' && chars[idx - 2] == '.' {
250            return true;
251        }
252
253        // Build the text before the period by collecting chars up to idx
254        // (not including the period itself)
255        let text_before_period: String = chars[..idx].iter().collect();
256
257        // Check if this is an abbreviation
258        if text_ends_with_abbreviation(&format!("{text_before_period}."), abbreviations) {
259            return false;
260        }
261
262        // Check what comes before the period
263        if idx > 0 {
264            let prev = chars[idx - 1];
265
266            // Single letter before period - likely initial or list marker, not sentence
267            // e.g., "A." "B." but allow "a." at end of sentence
268            if prev.is_ascii_uppercase() {
269                // Check if it's preceded by whitespace or start of text (isolated initial)
270                if idx >= 2 {
271                    if chars[idx - 2].is_whitespace() {
272                        // "word A." - isolated initial, not sentence ending
273                        return false;
274                    }
275                } else {
276                    // "A." at start - not a sentence ending
277                    return false;
278                }
279            }
280
281            // If previous char is alphanumeric, closing quote/paren, or markdown inline delimiters, treat as sentence end
282            // Markdown inline elements that can end before punctuation:
283            // - `)` `]` - links, images, footnote refs
284            // - `` ` `` - inline code
285            // - `*` `_` - emphasis/bold
286            // - `~` - strikethrough
287            // - `=` - highlight (extended markdown)
288            // - `^` - superscript (extended markdown)
289            if prev.is_alphanumeric()
290                || is_closing_quote(prev)
291                || matches!(prev, ')' | ']' | '`' | '*' | '_' | '~' | '=' | '^')
292                || is_cjk_char(prev)
293            {
294                return true;
295            }
296        }
297
298        // Period at start or after non-word char - not a sentence ending
299        return false;
300    }
301
302    false
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    // === Abbreviation tests ===
310
311    #[test]
312    fn test_get_abbreviations_default() {
313        let abbrevs = get_abbreviations(&None);
314        assert!(abbrevs.contains("dr"));
315        assert!(abbrevs.contains("mr"));
316        assert!(abbrevs.contains("prof"));
317        assert!(abbrevs.contains("i.e"));
318        assert!(abbrevs.contains("e.g"));
319        assert!(abbrevs.contains("st"));
320    }
321
322    #[test]
323    fn test_st_abbreviation_not_sentence_boundary() {
324        let abbrevs = get_abbreviations(&None);
325
326        // Plain "St." is recognized as an abbreviation
327        assert!(text_ends_with_abbreviation("St.", &abbrevs));
328
329        // Hyphenated prefix form: "Wrangell-St." matches via the "st" component
330        assert!(text_ends_with_abbreviation("Wrangell-St.", &abbrevs));
331
332        // Non-abbreviation words are not affected
333        assert!(!text_ends_with_abbreviation("paradigms.", &abbrevs));
334        assert!(!text_ends_with_abbreviation("starts.", &abbrevs));
335
336        // Hyphenated word where suffix is NOT an abbreviation must NOT match
337        assert!(!text_ends_with_abbreviation("word-foo.", &abbrevs));
338        assert!(!text_ends_with_abbreviation("end-street.", &abbrevs));
339
340        // Other known abbreviations still work
341        assert!(text_ends_with_abbreviation("Dr.", &abbrevs));
342        assert!(text_ends_with_abbreviation("Mr.", &abbrevs));
343    }
344
345    #[test]
346    fn test_get_abbreviations_custom() {
347        let custom = Some(vec!["Corp".to_string(), "Ltd.".to_string()]);
348        let abbrevs = get_abbreviations(&custom);
349        // Should include defaults
350        assert!(abbrevs.contains("dr"));
351        // Should include custom (normalized)
352        assert!(abbrevs.contains("corp"));
353        assert!(abbrevs.contains("ltd"));
354    }
355
356    #[test]
357    fn test_text_ends_with_abbreviation() {
358        let abbrevs = get_abbreviations(&None);
359        assert!(text_ends_with_abbreviation("Dr.", &abbrevs));
360        assert!(text_ends_with_abbreviation("Hello Dr.", &abbrevs));
361        assert!(text_ends_with_abbreviation("Prof.", &abbrevs));
362        assert!(!text_ends_with_abbreviation("Doctor.", &abbrevs));
363        assert!(!text_ends_with_abbreviation("Dr?", &abbrevs)); // Not a period
364        assert!(!text_ends_with_abbreviation("paradigms.", &abbrevs));
365    }
366
367    #[test]
368    fn test_text_ends_with_abbreviation_after_punctuation() {
369        let abbrevs = get_abbreviations(&None);
370        // Abbreviations preceded by opening parenthesis
371        assert!(text_ends_with_abbreviation("(e.g.", &abbrevs));
372        assert!(text_ends_with_abbreviation("(i.e.", &abbrevs));
373        assert!(text_ends_with_abbreviation("word (e.g.", &abbrevs));
374        assert!(text_ends_with_abbreviation("word (i.e.", &abbrevs));
375        // Abbreviations preceded by opening bracket
376        assert!(text_ends_with_abbreviation("[e.g.", &abbrevs));
377        assert!(text_ends_with_abbreviation("[Dr.", &abbrevs));
378        // Abbreviations preceded by quotes
379        assert!(text_ends_with_abbreviation("\"Dr.", &abbrevs));
380        // Abbreviations preceded by emphasis markers
381        assert!(text_ends_with_abbreviation("*e.g.", &abbrevs));
382        assert!(text_ends_with_abbreviation("**e.g.", &abbrevs));
383        // Nested punctuation (quote + paren)
384        assert!(text_ends_with_abbreviation("(\"e.g.", &abbrevs));
385        assert!(text_ends_with_abbreviation("([Dr.", &abbrevs));
386        // Non-abbreviations with leading punctuation should still not match
387        assert!(!text_ends_with_abbreviation("(paradigms.", &abbrevs));
388        assert!(!text_ends_with_abbreviation("[Doctor.", &abbrevs));
389    }
390
391    // === Punctuation helper tests ===
392
393    #[test]
394    fn test_is_closing_quote() {
395        assert!(is_closing_quote('"'));
396        assert!(is_closing_quote('\''));
397        assert!(is_closing_quote('\u{201D}')); // "
398        assert!(is_closing_quote('\u{2019}')); // '
399        assert!(is_closing_quote('»'));
400        assert!(is_closing_quote('›'));
401        assert!(!is_closing_quote('a'));
402        assert!(!is_closing_quote('.'));
403    }
404
405    #[test]
406    fn test_is_closing_bracket() {
407        for c in [')', ']', '}'] {
408            assert!(is_ascii_closing_bracket(c));
409            assert!(is_closing_bracket(c));
410            assert!(!is_cjk_closing_bracket(c));
411        }
412        for c in [
413            ')', ']', '}', '」', '』', '】', '〕', '》', '〉', '〙', '〛',
414            // Halfwidth right corner bracket, and the vertical presentation
415            // forms of the corner and white corner brackets.
416            '\u{FF63}', '\u{FE42}', '\u{FE44}',
417        ] {
418            assert!(is_cjk_closing_bracket(c), "{c:?}");
419            assert!(is_closing_bracket(c), "{c:?}");
420            assert!(!is_ascii_closing_bracket(c), "{c:?}");
421        }
422        // Openers and ordinary characters are not closers.
423        for c in ['(', '[', '{', '(', '「', '【', '〈', 'a', '。', ','] {
424            assert!(!is_closing_bracket(c));
425        }
426    }
427
428    #[test]
429    fn test_is_cjk_sentence_ending() {
430        assert!(is_cjk_sentence_ending('。'));
431        assert!(is_cjk_sentence_ending('!'));
432        assert!(is_cjk_sentence_ending('?'));
433        assert!(!is_cjk_sentence_ending('.'));
434        assert!(!is_cjk_sentence_ending('!'));
435    }
436
437    #[test]
438    fn test_is_cjk_char() {
439        assert!(is_cjk_char('中'));
440        assert!(is_cjk_char('あ')); // Hiragana
441        assert!(is_cjk_char('ア')); // Katakana
442        assert!(is_cjk_char('한')); // Hangul
443        assert!(!is_cjk_char('a'));
444        assert!(!is_cjk_char('A'));
445    }
446
447    // === is_after_sentence_ending tests ===
448
449    #[test]
450    fn test_after_period() {
451        assert!(is_after_sentence_ending("Hello.  ", 6));
452        assert!(is_after_sentence_ending("End of sentence.  Next", 16));
453    }
454
455    #[test]
456    fn test_after_exclamation() {
457        assert!(is_after_sentence_ending("Wow!  ", 4));
458        assert!(is_after_sentence_ending("Great!  Next", 6));
459    }
460
461    #[test]
462    fn test_after_question() {
463        assert!(is_after_sentence_ending("Really?  ", 7));
464        assert!(is_after_sentence_ending("What?  Next", 5));
465    }
466
467    #[test]
468    fn test_after_closing_quote() {
469        assert!(is_after_sentence_ending("He said \"Hello.\"  Next", 16));
470        assert!(is_after_sentence_ending("She said 'Hi.'  Next", 14));
471    }
472
473    #[test]
474    fn test_after_curly_quotes() {
475        let content = format!("He said {}Hello.{}  Next", '\u{201C}', '\u{201D}');
476        // Find the position after the closing quote
477        let pos = content.find("  ").unwrap();
478        assert!(is_after_sentence_ending(&content, pos));
479    }
480
481    #[test]
482    fn test_after_closing_paren() {
483        assert!(is_after_sentence_ending("(See note.)  Next", 11));
484        assert!(is_after_sentence_ending("(Really!)  Next", 9));
485    }
486
487    #[test]
488    fn test_after_closing_bracket() {
489        assert!(is_after_sentence_ending("[Citation.]  Next", 11));
490    }
491
492    #[test]
493    fn test_after_ellipsis() {
494        assert!(is_after_sentence_ending("And so...  Next", 9));
495        assert!(is_after_sentence_ending("Hmm...  Let me think", 6));
496    }
497
498    #[test]
499    fn test_not_after_abbreviation() {
500        // Dr. should NOT be treated as sentence ending
501        assert!(!is_after_sentence_ending("Dr.  Smith", 3));
502        assert!(!is_after_sentence_ending("Mr.  Jones", 3));
503        assert!(!is_after_sentence_ending("Prof.  Williams", 5));
504    }
505
506    #[test]
507    fn test_not_after_single_initial() {
508        // Single capital letter + period is likely an initial, not sentence end
509        assert!(!is_after_sentence_ending("John A.  Smith", 7));
510        // But lowercase should work (end of sentence)
511        assert!(is_after_sentence_ending("letter a.  Next", 9));
512    }
513
514    #[test]
515    fn test_mid_sentence_not_detected() {
516        // Spaces not after sentence punctuation
517        assert!(!is_after_sentence_ending("word  word", 4));
518        assert!(!is_after_sentence_ending("multiple  spaces", 8));
519    }
520
521    #[test]
522    fn test_cjk_sentence_ending() {
523        // CJK chars are 3 bytes each in UTF-8
524        // 日(3)+本(3)+語(3)+。(3) = 12 bytes before the spaces
525        assert!(is_after_sentence_ending("日本語。  Next", 12)); // After 。
526        // 中(3)+文(3)+!(3) = 9 bytes before the spaces
527        assert!(is_after_sentence_ending("中文!  Next", 9)); // After !
528        // 한(3)+국(3)+어(3)+?(3) = 12 bytes before the spaces
529        assert!(is_after_sentence_ending("한국어?  Next", 12)); // After ?
530    }
531
532    #[test]
533    fn test_complex_endings() {
534        // Multiple closing punctuation
535        assert!(is_after_sentence_ending("(He said \"Yes.\")  Next", 16));
536        // Quote then paren
537        assert!(is_after_sentence_ending("\"End.\")  Next", 7));
538    }
539
540    #[test]
541    fn test_guillemets() {
542        assert!(is_after_sentence_ending("Il dit «Oui.»  Next", 13));
543    }
544
545    #[test]
546    fn test_empty_and_edge_cases() {
547        assert!(!is_after_sentence_ending("", 0));
548        assert!(!is_after_sentence_ending(".", 0));
549        assert!(!is_after_sentence_ending("a", 0));
550    }
551
552    #[test]
553    fn test_latin_abbreviations() {
554        // i.e. and e.g. should not be sentence endings
555        assert!(!is_after_sentence_ending("i.e.  example", 4));
556        assert!(!is_after_sentence_ending("e.g.  example", 4));
557    }
558
559    #[test]
560    fn test_abbreviations_after_opening_punctuation() {
561        // Abbreviations preceded by parentheses, brackets, quotes
562        assert!(!is_after_sentence_ending("(e.g.  Wasm)", 5));
563        assert!(!is_after_sentence_ending("(i.e.  PyO3)", 5));
564        assert!(!is_after_sentence_ending("[e.g.  Chapter]", 5));
565        assert!(!is_after_sentence_ending("(Dr.  Smith)", 4));
566        // Nested punctuation: quote + paren
567        assert!(!is_after_sentence_ending("(\"e.g.  something\")", 6));
568    }
569
570    #[test]
571    fn test_after_inline_code() {
572        // Issue #345: Sentence ending with inline code should be recognized
573        // "Hello from `backticks`.  How's it going?"
574        // Position 23 is after the period following the closing backtick
575        assert!(is_after_sentence_ending("Hello from `backticks`.  Next", 23));
576
577        // Simple case: just code and period
578        assert!(is_after_sentence_ending("`code`.  Next", 7));
579
580        // Multiple inline code spans
581        assert!(is_after_sentence_ending("Use `foo` and `bar`.  Next", 20));
582
583        // With exclamation mark
584        assert!(is_after_sentence_ending("`important`!  Next", 12));
585
586        // With question mark
587        assert!(is_after_sentence_ending("Is it `true`?  Next", 13));
588
589        // Inline code in the middle shouldn't affect sentence detection
590        assert!(is_after_sentence_ending("The `code` works.  Next", 17));
591    }
592
593    #[test]
594    fn test_after_inline_code_with_quotes() {
595        // Inline code before closing quote before period
596        assert!(is_after_sentence_ending("He said \"use `code`\".  Next", 21));
597
598        // Inline code in parentheses
599        assert!(is_after_sentence_ending("(see `example`).  Next", 16));
600    }
601
602    #[test]
603    fn test_after_emphasis() {
604        // Asterisk emphasis
605        assert!(is_after_sentence_ending("The word is *important*.  Next", 24));
606
607        // Underscore emphasis
608        assert!(is_after_sentence_ending("The word is _important_.  Next", 24));
609
610        // With exclamation
611        assert!(is_after_sentence_ending("This is *urgent*!  Next", 17));
612
613        // With question
614        assert!(is_after_sentence_ending("Is it _true_?  Next", 13));
615    }
616
617    #[test]
618    fn test_after_bold() {
619        // Asterisk bold
620        assert!(is_after_sentence_ending("The word is **critical**.  Next", 25));
621
622        // Underscore bold
623        assert!(is_after_sentence_ending("The word is __critical__.  Next", 25));
624    }
625
626    #[test]
627    fn test_after_strikethrough() {
628        // GFM strikethrough
629        assert!(is_after_sentence_ending("This is ~~wrong~~.  Next", 18));
630
631        // With exclamation
632        assert!(is_after_sentence_ending("That was ~~bad~~!  Next", 17));
633    }
634
635    #[test]
636    fn test_after_extended_markdown() {
637        // Highlight syntax (some flavors)
638        assert!(is_after_sentence_ending("This is ==highlighted==.  Next", 24));
639
640        // Superscript syntax (some flavors)
641        assert!(is_after_sentence_ending("E equals mc^2^.  Next", 15));
642    }
643}