snapper_fmt/sentence/
unicode.rs1use regex::Regex;
2use std::sync::LazyLock;
3use unicode_segmentation::UnicodeSegmentation;
4
5use crate::abbreviations;
6use crate::sentence::SentenceSplitter;
7
8static INLINE_TOKEN_RE: LazyLock<Regex> = LazyLock::new(|| {
11 Regex::new(
12 &[
13 r"\[\[[^\]]*\]\]", r"\[\[[^\]]*\]\[[^\]]*\]\]", r"\[[^\]]+\]\([^)]+\)", r"!\[[^\]]*\]\([^)]+\)", r"\$[^$]+\$", r"\\([a-zA-Z]+)\{[^}]*\}", r"~[^~]+~", r"=[^=]+=", r"`[^`]+`", ]
23 .join("|"),
24 )
25 .expect("valid inline token regex")
26});
27
28pub struct UnicodeSentenceSplitter {
32 extra_pattern: Option<Regex>,
34 lang_abbrev_pattern: Regex,
36 lang_multi_pattern: Regex,
38}
39
40impl UnicodeSentenceSplitter {
41 pub fn new() -> Self {
43 Self::for_lang("en", &[])
44 }
45
46 pub fn with_extra_abbreviations(extras: &[String]) -> Self {
48 Self::for_lang("en", extras)
49 }
50
51 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 let mut placeholders: Vec<String> = Vec::new();
97 let protected = INLINE_TOKEN_RE.replace_all(text, |caps: ®ex::Captures| {
98 let idx = placeholders.len();
99 placeholders.push(caps[0].to_string());
100 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 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 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 let default = UnicodeSentenceSplitter::new();
268 let result = default.split("See Abstr. 5 for details. The results follow.");
269 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 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}