Skip to main content

snapper_fmt/sentence/
unicode.rs

1use regex::Regex;
2use std::sync::LazyLock;
3use unicode_segmentation::UnicodeSegmentation;
4
5use crate::abbreviations;
6use crate::sentence::SentenceSplitter;
7
8/// Patterns for inline tokens that should not be split across sentences.
9/// These get replaced with safe placeholders before sentence detection.
10static INLINE_TOKEN_RE: LazyLock<Regex> = LazyLock::new(|| {
11    Regex::new(
12        &[
13            r"\[\[[^\]]*\]\]",           // Org links: [[url]] or [[url][desc]]
14            r"\[\[[^\]]*\]\[[^\]]*\]\]", // Org links with desc
15            r"\[[^\]]+\]\([^)]+\)",      // Markdown links: [text](url)
16            r"!\[[^\]]*\]\([^)]+\)",     // Markdown images: ![alt](url)
17            r"\$[^$]+\$",                // Inline math: $...$
18            r"\\([a-zA-Z]+)\{[^}]*\}",   // LaTeX commands: \cmd{arg}
19            r"~[^~]+~",                  // Org inline code: ~code~
20            r"=[^=]+=",                  // Org verbatim: =text=
21            r"`[^`]+`",                  // Markdown inline code: `code`
22        ]
23        .join("|"),
24    )
25    .expect("valid inline token regex")
26});
27
28// Static patterns removed -- now compiled per-instance in UnicodeSentenceSplitter::for_lang().
29
30/// Sentence splitter using Unicode UAX #29 with abbreviation-aware merging.
31pub struct UnicodeSentenceSplitter {
32    /// Compiled regex for extra user-provided abbreviations, if any.
33    extra_pattern: Option<Regex>,
34    /// Compiled abbreviation pattern for the selected language.
35    lang_abbrev_pattern: Regex,
36    /// Compiled multi-abbreviation pattern for the selected language.
37    lang_multi_pattern: Regex,
38}
39
40impl UnicodeSentenceSplitter {
41    /// Create a splitter with only built-in English abbreviations.
42    pub fn new() -> Self {
43        Self::for_lang("en", &[])
44    }
45
46    /// Create a splitter with additional user-provided abbreviations.
47    pub fn with_extra_abbreviations(extras: &[String]) -> Self {
48        Self::for_lang("en", extras)
49    }
50
51    /// Create a splitter for a specific language, optionally with extra abbreviations.
52    pub fn for_lang(lang: &str, extras: &[String]) -> Self {
53        let abbrevs = abbreviations::abbreviations_for_lang(lang);
54        let multi = abbreviations::multi_abbrevs_for_lang(lang);
55
56        let alts: Vec<&str> = abbrevs.to_vec();
57        let pattern = format!(r#"(?:^|[\s"'`(\[])(?:{})$"#, alts.join("|"));
58        let lang_abbrev_pattern = Regex::new(&pattern).expect("valid abbreviation regex");
59
60        let multi_alts: Vec<String> = multi.iter().map(|a| regex::escape(a)).collect();
61        let multi_pattern = format!(r"(?:^|\s)(?:{})$", multi_alts.join("|"));
62        let lang_multi_pattern =
63            Regex::new(&multi_pattern).expect("valid multi-abbreviation regex");
64
65        let extra_pattern = if extras.is_empty() {
66            None
67        } else {
68            let alts: Vec<String> = extras.iter().map(|a| regex::escape(a)).collect();
69            let pattern = format!(r"(?:^|\s)(?:{})$", alts.join("|"));
70            Some(Regex::new(&pattern).expect("valid extra abbreviation regex"))
71        };
72
73        Self {
74            extra_pattern,
75            lang_abbrev_pattern,
76            lang_multi_pattern,
77        }
78    }
79}
80
81impl Default for UnicodeSentenceSplitter {
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87impl SentenceSplitter for UnicodeSentenceSplitter {
88    fn split(&self, text: &str) -> Vec<String> {
89        let text = text.trim();
90        if text.is_empty() {
91            return vec![];
92        }
93
94        // Replace inline tokens with safe placeholders to prevent
95        // the sentence splitter from breaking inside them.
96        let mut placeholders: Vec<String> = Vec::new();
97        let protected = INLINE_TOKEN_RE.replace_all(text, |caps: &regex::Captures| {
98            let idx = placeholders.len();
99            placeholders.push(caps[0].to_string());
100            // Use a placeholder that won't trigger sentence breaks
101            format!("\x00PH{idx}\x00")
102        });
103
104        let raw_segments: Vec<&str> = protected.unicode_sentences().collect();
105
106        if raw_segments.is_empty() {
107            return vec![text.to_string()];
108        }
109
110        let merged = merge_abbreviation_splits(
111            &raw_segments,
112            &self.lang_abbrev_pattern,
113            &self.lang_multi_pattern,
114            self.extra_pattern.as_ref(),
115        );
116
117        // Restore placeholders and clean up
118        merged
119            .into_iter()
120            .map(|s| {
121                let mut restored = s.trim().to_string();
122                for (i, original) in placeholders.iter().enumerate() {
123                    let ph = format!("\x00PH{i}\x00");
124                    restored = restored.replace(&ph, original);
125                }
126                restored
127            })
128            .filter(|s| !s.is_empty())
129            .collect()
130    }
131}
132
133fn merge_abbreviation_splits(
134    segments: &[&str],
135    abbrev_re: &Regex,
136    multi_re: &Regex,
137    extra: Option<&Regex>,
138) -> Vec<String> {
139    let mut result: Vec<String> = Vec::with_capacity(segments.len());
140
141    for &segment in segments {
142        let should_merge = if let Some(prev) = result.last() {
143            is_abbreviation_ending(prev, abbrev_re, multi_re, extra)
144        } else {
145            false
146        };
147
148        if should_merge {
149            let prev = result.last_mut().unwrap();
150            prev.push_str(segment);
151        } else {
152            result.push(segment.to_string());
153        }
154    }
155
156    result
157}
158
159fn is_abbreviation_ending(
160    s: &str,
161    abbrev_re: &Regex,
162    multi_re: &Regex,
163    extra: Option<&Regex>,
164) -> bool {
165    let trimmed = s.trim_end();
166    if !trimmed.ends_with('.') {
167        return false;
168    }
169    let before_dot = &trimmed[..trimmed.len() - 1];
170
171    if abbrev_re.is_match(before_dot) {
172        return true;
173    }
174
175    if multi_re.is_match(before_dot) {
176        return true;
177    }
178
179    if let Some(re) = extra {
180        if re.is_match(before_dot) {
181            return true;
182        }
183    }
184
185    false
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    fn split(text: &str) -> Vec<String> {
193        UnicodeSentenceSplitter::new().split(text)
194    }
195
196    #[test]
197    fn simple_sentences() {
198        assert_eq!(
199            split("Hello world. This is a test. Another sentence here."),
200            vec!["Hello world.", "This is a test.", "Another sentence here."]
201        );
202    }
203
204    #[test]
205    fn abbreviation_dr() {
206        assert_eq!(
207            split("Dr. Smith went home. He was tired."),
208            vec!["Dr. Smith went home.", "He was tired."]
209        );
210    }
211
212    #[test]
213    fn abbreviation_eg() {
214        assert_eq!(
215            split("Use a formatter, e.g. snapper. It works well."),
216            vec!["Use a formatter, e.g. snapper.", "It works well."]
217        );
218    }
219
220    #[test]
221    fn abbreviation_fig() {
222        assert_eq!(
223            split("See Fig. 3 for details. The results are clear."),
224            vec!["See Fig. 3 for details.", "The results are clear."]
225        );
226    }
227
228    #[test]
229    fn empty_input() {
230        assert_eq!(split(""), Vec::<String>::new());
231    }
232
233    #[test]
234    fn single_sentence() {
235        assert_eq!(split("Just one sentence."), vec!["Just one sentence."]);
236    }
237
238    #[test]
239    fn question_and_exclamation() {
240        assert_eq!(
241            split("Is this working? Yes! It is."),
242            vec!["Is this working?", "Yes!", "It is."]
243        );
244    }
245
246    #[test]
247    fn no_trailing_period() {
248        assert_eq!(
249            split("First sentence. Second without period"),
250            vec!["First sentence.", "Second without period"]
251        );
252    }
253
254    #[test]
255    fn extra_abbreviations() {
256        // "Abstr" is not a built-in abbreviation, so the default splitter
257        // would break at "Abstr." The extra list prevents that.
258        let splitter = UnicodeSentenceSplitter::with_extra_abbreviations(&[
259            "Abstr".to_string(),
260            "Suppl".to_string(),
261        ]);
262        assert_eq!(
263            splitter.split("See Abstr. 5 for details. The results follow."),
264            vec!["See Abstr. 5 for details.", "The results follow."]
265        );
266        // Without extra, "Abstr." would cause a false break:
267        let default = UnicodeSentenceSplitter::new();
268        let result = default.split("See Abstr. 5 for details. The results follow.");
269        // Default splits at "Abstr." since it doesn't know the abbreviation
270        assert!(result.len() > 1);
271    }
272
273    #[test]
274    fn inline_org_link_preserved() {
275        assert_eq!(
276            split("See [[https://example.com][Ex. Site]] for details. Then continue."),
277            vec![
278                "See [[https://example.com][Ex. Site]] for details.",
279                "Then continue."
280            ]
281        );
282    }
283
284    #[test]
285    fn inline_math_preserved() {
286        assert_eq!(
287            split("The value $x = 3.14$ matters. Next sentence."),
288            vec!["The value $x = 3.14$ matters.", "Next sentence."]
289        );
290    }
291
292    #[test]
293    fn inline_markdown_link_preserved() {
294        assert_eq!(
295            split("Visit [Example Inc.](https://example.com) now. Then read more."),
296            vec![
297                "Visit [Example Inc.](https://example.com) now.",
298                "Then read more."
299            ]
300        );
301    }
302
303    #[test]
304    fn inline_code_preserved() {
305        assert_eq!(
306            split("Use `std.io.Read` for input. Then process."),
307            vec!["Use `std.io.Read` for input.", "Then process."]
308        );
309    }
310}