1use std::collections::HashSet;
14
15const DEFAULT_ABBREVIATIONS: &[&str] = &[
24 "mr", "mrs", "ms", "dr", "prof", "sr", "jr", "st",
26 "i.e", "e.g", "vs", "fig", "no", "vol", "ch", "sec", "al",
29];
30
31pub 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 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
51pub fn text_ends_with_abbreviation(text: &str, abbreviations: &HashSet<String>) -> bool {
64 if !text.ends_with('.') {
66 return false;
67 }
68
69 let without_period = text.trim_end_matches('.');
71
72 let last_word = without_period.split_whitespace().last().unwrap_or("");
74
75 if last_word.is_empty() {
76 return false;
77 }
78
79 let stripped = last_word.trim_start_matches(|c: char| !c.is_alphanumeric() && c != '.');
82
83 if abbreviations.contains(&stripped.to_lowercase()) {
85 return true;
86 }
87
88 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
100pub fn is_cjk_sentence_ending(c: char) -> bool {
103 matches!(c, '。' | '!' | '?')
104}
105
106pub fn is_closing_quote(c: char) -> bool {
109 matches!(c, '"' | '\'' | '\u{201D}' | '\u{2019}' | '»' | '›')
112}
113
114pub fn is_ascii_closing_bracket(c: char) -> bool {
116 matches!(c, ')' | ']' | '}')
117}
118
119pub 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
143pub fn is_closing_bracket(c: char) -> bool {
148 is_ascii_closing_bracket(c) || is_cjk_closing_bracket(c)
149}
150
151pub fn is_opening_quote(c: char) -> bool {
154 matches!(c, '"' | '\'' | '\u{201C}' | '\u{2018}' | '«' | '‹')
157}
158
159pub fn is_cjk_char(c: char) -> bool {
161 matches!(c,
163 '\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' | '\u{3040}'..='\u{309F}' | '\u{30A0}'..='\u{30FF}' | '\u{AC00}'..='\u{D7AF}' )
169}
170
171fn is_trailing_close_punctuation(c: char) -> bool {
174 is_closing_quote(c) || is_ascii_closing_bracket(c)
175}
176
177pub 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
198fn 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 let Some(before) = text.get(..match_start) else {
215 return false; };
217
218 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 while idx > 0 && is_trailing_close_punctuation(chars[idx]) {
230 idx -= 1;
231 }
232
233 let current = chars[idx];
235
236 if is_cjk_sentence_ending(current) {
238 return true;
239 }
240
241 if current == '!' || current == '?' {
243 return true;
244 }
245
246 if current == '.' {
248 if idx >= 2 && chars[idx - 1] == '.' && chars[idx - 2] == '.' {
250 return true;
251 }
252
253 let text_before_period: String = chars[..idx].iter().collect();
256
257 if text_ends_with_abbreviation(&format!("{text_before_period}."), abbreviations) {
259 return false;
260 }
261
262 if idx > 0 {
264 let prev = chars[idx - 1];
265
266 if prev.is_ascii_uppercase() {
269 if idx >= 2 {
271 if chars[idx - 2].is_whitespace() {
272 return false;
274 }
275 } else {
276 return false;
278 }
279 }
280
281 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 return false;
300 }
301
302 false
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[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 assert!(text_ends_with_abbreviation("St.", &abbrevs));
328
329 assert!(text_ends_with_abbreviation("Wrangell-St.", &abbrevs));
331
332 assert!(!text_ends_with_abbreviation("paradigms.", &abbrevs));
334 assert!(!text_ends_with_abbreviation("starts.", &abbrevs));
335
336 assert!(!text_ends_with_abbreviation("word-foo.", &abbrevs));
338 assert!(!text_ends_with_abbreviation("end-street.", &abbrevs));
339
340 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 assert!(abbrevs.contains("dr"));
351 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)); 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 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 assert!(text_ends_with_abbreviation("[e.g.", &abbrevs));
377 assert!(text_ends_with_abbreviation("[Dr.", &abbrevs));
378 assert!(text_ends_with_abbreviation("\"Dr.", &abbrevs));
380 assert!(text_ends_with_abbreviation("*e.g.", &abbrevs));
382 assert!(text_ends_with_abbreviation("**e.g.", &abbrevs));
383 assert!(text_ends_with_abbreviation("(\"e.g.", &abbrevs));
385 assert!(text_ends_with_abbreviation("([Dr.", &abbrevs));
386 assert!(!text_ends_with_abbreviation("(paradigms.", &abbrevs));
388 assert!(!text_ends_with_abbreviation("[Doctor.", &abbrevs));
389 }
390
391 #[test]
394 fn test_is_closing_quote() {
395 assert!(is_closing_quote('"'));
396 assert!(is_closing_quote('\''));
397 assert!(is_closing_quote('\u{201D}')); assert!(is_closing_quote('\u{2019}')); 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 '\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 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('あ')); assert!(is_cjk_char('ア')); assert!(is_cjk_char('한')); assert!(!is_cjk_char('a'));
444 assert!(!is_cjk_char('A'));
445 }
446
447 #[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 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 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 assert!(!is_after_sentence_ending("John A. Smith", 7));
510 assert!(is_after_sentence_ending("letter a. Next", 9));
512 }
513
514 #[test]
515 fn test_mid_sentence_not_detected() {
516 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 assert!(is_after_sentence_ending("日本語。 Next", 12)); assert!(is_after_sentence_ending("中文! Next", 9)); assert!(is_after_sentence_ending("한국어? Next", 12)); }
531
532 #[test]
533 fn test_complex_endings() {
534 assert!(is_after_sentence_ending("(He said \"Yes.\") Next", 16));
536 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 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 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 assert!(!is_after_sentence_ending("(\"e.g. something\")", 6));
568 }
569
570 #[test]
571 fn test_after_inline_code() {
572 assert!(is_after_sentence_ending("Hello from `backticks`. Next", 23));
576
577 assert!(is_after_sentence_ending("`code`. Next", 7));
579
580 assert!(is_after_sentence_ending("Use `foo` and `bar`. Next", 20));
582
583 assert!(is_after_sentence_ending("`important`! Next", 12));
585
586 assert!(is_after_sentence_ending("Is it `true`? Next", 13));
588
589 assert!(is_after_sentence_ending("The `code` works. Next", 17));
591 }
592
593 #[test]
594 fn test_after_inline_code_with_quotes() {
595 assert!(is_after_sentence_ending("He said \"use `code`\". Next", 21));
597
598 assert!(is_after_sentence_ending("(see `example`). Next", 16));
600 }
601
602 #[test]
603 fn test_after_emphasis() {
604 assert!(is_after_sentence_ending("The word is *important*. Next", 24));
606
607 assert!(is_after_sentence_ending("The word is _important_. Next", 24));
609
610 assert!(is_after_sentence_ending("This is *urgent*! Next", 17));
612
613 assert!(is_after_sentence_ending("Is it _true_? Next", 13));
615 }
616
617 #[test]
618 fn test_after_bold() {
619 assert!(is_after_sentence_ending("The word is **critical**. Next", 25));
621
622 assert!(is_after_sentence_ending("The word is __critical__. Next", 25));
624 }
625
626 #[test]
627 fn test_after_strikethrough() {
628 assert!(is_after_sentence_ending("This is ~~wrong~~. Next", 18));
630
631 assert!(is_after_sentence_ending("That was ~~bad~~! Next", 17));
633 }
634
635 #[test]
636 fn test_after_extended_markdown() {
637 assert!(is_after_sentence_ending("This is ==highlighted==. Next", 24));
639
640 assert!(is_after_sentence_ending("E equals mc^2^. Next", 15));
642 }
643}