1pub const SENTENCE_SEGMENTER_VERSION: &str = "v2";
8
9#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct CompletedSentence {
12 pub content: String,
14 pub content_bytes: usize,
17 pub bytes_consumed: usize,
20}
21
22#[derive(Clone, Debug, Default)]
26pub struct SentenceSegmenter {
27 buf: String,
28}
29
30impl SentenceSegmenter {
31 pub fn new() -> Self {
33 Self::default()
34 }
35
36 pub fn buffered_bytes(&self) -> usize {
38 self.buf.len()
39 }
40
41 pub fn push(&mut self, text: &str) -> Vec<CompletedSentence> {
45 self.buf.push_str(text);
46 self.drain_complete(false)
47 }
48
49 pub fn seal_at_clean_end(&mut self) -> Vec<CompletedSentence> {
51 self.drain_complete(true)
52 }
53
54 pub fn take_unresolved(&mut self) -> String {
56 std::mem::take(&mut self.buf)
57 }
58
59 fn drain_complete(&mut self, seal_remainder: bool) -> Vec<CompletedSentence> {
60 let mut out = Vec::new();
61 while let Some(end) = find_sentence_end(&self.buf) {
62 let raw = self.buf[..end].to_string();
63 let content = raw.trim_end().to_string();
64 let content_bytes = content.len();
67 let mut consume = end;
70 while consume < self.buf.len() && self.buf.as_bytes()[consume].is_ascii_whitespace() {
71 consume += 1;
72 }
73 self.buf = self.buf[consume..].to_string();
74 if !content.is_empty() {
75 out.push(CompletedSentence {
76 content,
77 content_bytes,
78 bytes_consumed: consume,
79 });
80 }
81 }
82 if seal_remainder && !self.buf.trim().is_empty() {
83 let bytes_consumed = self.buf.len();
84 let content = self.buf.trim().to_string();
85 let content_bytes = content.len();
86 self.buf.clear();
87 if !content.is_empty() {
88 out.push(CompletedSentence {
89 content,
90 content_bytes,
91 bytes_consumed,
92 });
93 }
94 }
95 out
96 }
97}
98
99fn find_sentence_end(s: &str) -> Option<usize> {
101 let bytes = s.as_bytes();
102 let mut i = 0;
103 let mut in_code_span = false;
104 let mut paren = 0i32;
105 let mut bracket = 0i32;
106 let mut brace = 0i32;
107
108 while i < bytes.len() {
109 let c = bytes[i] as char;
110
111 if c == '`' {
112 in_code_span = !in_code_span;
113 i += 1;
114 continue;
115 }
116 if in_code_span {
117 i += 1;
118 continue;
119 }
120
121 match c {
122 '(' => paren += 1,
123 ')' => paren = (paren - 1).max(0),
124 '[' => bracket += 1,
125 ']' => bracket = (bracket - 1).max(0),
126 '{' => brace += 1,
127 '}' => brace = (brace - 1).max(0),
128 '.' | '!' | '?' => {
129 if paren > 0 || bracket > 0 || brace > 0 {
130 i += 1;
131 continue;
132 }
133 if c == '.' && looks_like_ordered_list_marker(s, i) {
135 i += 1;
136 continue;
137 }
138 if c == '.' && looks_like_abbreviation_or_decimal(s, i) {
140 i += 1;
141 continue;
142 }
143
144 let next = bytes.get(i + 1).copied();
145 match next {
146 None => {}
148 Some(b) if b.is_ascii_whitespace() => {
149 return Some(i + 1);
150 }
151 Some(b) if b == b'"' || b == b'\'' || b == b')' || b == b']' => {
152 return Some(i + 1);
153 }
154 Some(b)
156 if c == '.'
157 && b.is_ascii_uppercase()
158 && prev_is_sentence_letter(bytes, i) =>
159 {
160 return Some(i + 1);
161 }
162 Some(b) if c == '.' && b.is_ascii_alphanumeric() => {}
164 _ => {}
165 }
166 }
167 '\n' if i + 1 < bytes.len() && bytes[i + 1] == b'\n' => {
171 let before = s[..i].trim();
172 if !before.is_empty() && !is_only_list_marker(before) {
173 return Some(i);
174 }
175 }
176 _ => {}
177 }
178 i += 1;
179 }
180 None
181}
182
183fn prev_is_sentence_letter(bytes: &[u8], dot_idx: usize) -> bool {
184 if dot_idx == 0 {
185 return false;
186 }
187 let p = bytes[dot_idx - 1];
188 p.is_ascii_lowercase() || p.is_ascii_uppercase()
189}
190
191fn is_only_list_marker(s: &str) -> bool {
192 let t = s.trim();
193 let b = t.as_bytes();
194 if b.is_empty() || b[b.len() - 1] != b'.' {
195 return false;
196 }
197 looks_like_ordered_list_marker(t, t.len() - 1)
198}
199
200fn looks_like_ordered_list_marker(s: &str, dot_idx: usize) -> bool {
202 let bytes = s.as_bytes();
203 let mut start = dot_idx;
204 while start > 0 && bytes[start - 1].is_ascii_digit() {
205 start -= 1;
206 }
207 if start == dot_idx {
208 return false;
209 }
210 let digit_len = dot_idx - start;
211 if !(1..=3).contains(&digit_len) {
212 return false;
213 }
214 if start > 0 && !bytes[start - 1].is_ascii_whitespace() {
216 return false;
217 }
218 match bytes.get(dot_idx + 1) {
220 None => true,
221 Some(b) if b.is_ascii_whitespace() => true,
222 Some(b) if *b == b'*' || *b == b'_' || *b == b'`' || *b == b'[' => true,
223 _ => false,
224 }
225}
226
227fn looks_like_abbreviation_or_decimal(s: &str, dot_idx: usize) -> bool {
228 let bytes = s.as_bytes();
229 let prev = bytes.get(dot_idx.wrapping_sub(1)).copied();
230 let next = bytes.get(dot_idx + 1).copied();
231 if prev.is_some_and(|b| b.is_ascii_digit()) && next.is_some_and(|b| b.is_ascii_digit()) {
232 return true;
233 }
234 if prev.is_some_and(|b| b.is_ascii_alphabetic()) {
236 let mut start = dot_idx;
237 while start > 0 && bytes[start - 1].is_ascii_alphabetic() {
238 start -= 1;
239 }
240 let len = dot_idx - start;
241 if (1..=3).contains(&len) {
242 if len == 1 {
243 return true;
244 }
245 let word = &s[start..dot_idx];
246 const ABBREVS: &[&str] = &[
247 "Mr", "Mrs", "Ms", "Dr", "Prof", "Sr", "Jr", "vs", "etc", "Inc", "Ltd", "St",
248 ];
249 if ABBREVS.iter().any(|a| a.eq_ignore_ascii_case(word)) {
250 return true;
251 }
252 }
253 }
254 let mut t = dot_idx;
255 while t > 0 && !bytes[t - 1].is_ascii_whitespace() {
256 t -= 1;
257 }
258 let end = (dot_idx + 1).min(s.len());
259 let token = &s[t..end];
260 if token.contains("://") || token.contains("www.") {
261 return true;
262 }
263 if token.contains('/') {
264 return true;
265 }
266 false
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 fn contents(done: &[CompletedSentence]) -> Vec<String> {
274 done.iter().map(|c| c.content.clone()).collect()
275 }
276
277 #[test]
278 fn splits_on_period_space() {
279 let mut s = SentenceSegmenter::new();
280 let a = s.push("Hello world. ");
281 assert_eq!(contents(&a), vec!["Hello world.".to_string()]);
282 let b = s.push("Next one! ");
283 assert_eq!(contents(&b), vec!["Next one!".to_string()]);
284 }
285
286 #[test]
287 fn does_not_emit_partial() {
288 let mut s = SentenceSegmenter::new();
289 assert!(s.push("The build uses std::").is_empty());
290 let done = s.push("sync::Arc to share the handle. ");
291 assert_eq!(
292 contents(&done),
293 vec!["The build uses std::sync::Arc to share the handle.".to_string()]
294 );
295 }
296
297 #[test]
298 fn decimal_not_boundary() {
299 let mut s = SentenceSegmenter::new();
300 assert_eq!(s.push("Version 1.2.3 is ready. ").len(), 1);
301 }
302
303 #[test]
304 fn list_markers_stay_with_item() {
305 let mut s = SentenceSegmenter::new();
306 assert!(s.push("1.\n\n").is_empty());
308 let done = s.push("**CREATE** — Wrote the file with `hello monoloop crud`.\n\n");
309 assert_eq!(done.len(), 1, "{done:?}");
310 assert!(
311 done[0].content.starts_with("1."),
312 "list marker must stay attached: {}",
313 done[0].content
314 );
315 assert!(done[0].content.contains("**CREATE**"));
316 }
317
318 #[test]
319 fn list_sequence_does_not_emit_bare_numbers() {
320 let mut s = SentenceSegmenter::new();
321 let text = "1. **CREATE** — Wrote the file.\n\n2. **READ** — File contained x.\n\n3. **UPDATE** — Done.\n\n";
322 let done = s.push(text);
323 assert_eq!(done.len(), 3, "{done:?}");
324 assert!(done.iter().all(|x| !x
325 .content
326 .trim()
327 .chars()
328 .all(|c| c.is_ascii_digit() || c == '.')));
329 assert!(done[0].content.contains("CREATE"));
330 assert!(done[1].content.contains("READ"));
331 assert!(done[2].content.contains("UPDATE"));
332 }
333
334 #[test]
335 fn missing_space_after_period_splits() {
336 let mut s = SentenceSegmenter::new();
337 assert!(s.push("starting with create.").is_empty());
339 let done = s.push("CRUD exercise on the file only:\n\n");
340 assert_eq!(done.len(), 2, "{done:?}");
341 assert_eq!(done[0].content, "starting with create.");
342 assert!(done[1].content.starts_with("CRUD exercise"));
343 }
344
345 #[test]
347 fn live_grok_crud_token_stream_assembles_cleanly() {
348 let chunks = [
349 "I'll",
350 " run",
351 " the",
352 " five",
353 " CRUD",
354 " steps",
355 " on",
356 " that",
357 " one",
358 " file",
359 " only",
360 ",",
361 " starting",
362 " with",
363 " create",
364 ".",
365 "CRUD",
366 " exercise",
367 " on",
368 " `",
369 "mon",
370 "olo",
371 "op",
372 "_",
373 "live",
374 "_",
375 "crud",
376 "_",
377 "test",
378 ".txt",
379 "`",
380 " only",
381 ":\n\n",
382 "1",
383 ".",
384 " **",
385 "CREATE",
386 "**",
387 " —",
388 " Wrote",
389 " the",
390 " file",
391 " with",
392 " `",
393 "hello",
394 " mon",
395 "olo",
396 "op",
397 " crud",
398 "`.",
399 "\n",
400 "2",
401 ".",
402 " **",
403 "READ",
404 "**",
405 " —",
406 " File",
407 " contained",
408 " `",
409 "hello",
410 " mon",
411 "olo",
412 "op",
413 " crud",
414 "`.",
415 "\n",
416 "3",
417 ".",
418 " **",
419 "UPDATE",
420 "**",
421 " —",
422 " Over",
423 "wrote",
424 " it",
425 " with",
426 " `",
427 "hello",
428 " mon",
429 "olo",
430 "op",
431 " crud",
432 " UPD",
433 "ATED",
434 "`.",
435 "\n",
436 "4",
437 ".",
438 " **",
439 "READ",
440 "**",
441 " —",
442 " File",
443 " contained",
444 " `",
445 "hello",
446 " mon",
447 "olo",
448 "op",
449 " crud",
450 " UPD",
451 "ATED",
452 "`.",
453 "\n",
454 "5",
455 ".",
456 " **",
457 "DELETE",
458 "**",
459 " —",
460 " Removed",
461 " the",
462 " file",
463 " (`",
464 "rm",
465 "`",
466 " exited",
467 " ",
468 "0",
469 ").",
470 "\n\n",
471 "No",
472 " other",
473 " files",
474 " were",
475 " touched",
476 ".",
477 ];
478 let mut s = SentenceSegmenter::new();
479 let mut done = Vec::new();
480 for c in chunks {
481 done.extend(s.push(c));
482 }
483 done.extend(s.seal_at_clean_end());
484 let texts = contents(&done);
485
486 assert!(
488 texts.iter().all(|x| !x.contains("create.CRUD")),
489 "must split missing-space: {texts:?}"
490 );
491 assert!(
492 texts
493 .iter()
494 .all(|x| !is_only_list_marker(x) && x.trim() != "1." && x.trim() != "2."),
495 "bare list markers leaked: {texts:?}"
496 );
497 assert!(
498 texts.iter().any(|x| x.ends_with("create.")),
499 "expected sentence ending create.: {texts:?}"
500 );
501 assert!(
502 texts.iter().any(|x| x.starts_with("CRUD exercise")),
503 "expected CRUD exercise sentence: {texts:?}"
504 );
505 assert_eq!(
506 texts
507 .iter()
508 .filter(|x| x.contains("**CREATE**")
509 || x.contains("**READ**")
510 || x.contains("**UPDATE**")
511 || x.contains("**DELETE**"))
512 .count(),
513 5,
514 "five step sentences: {texts:?}"
515 );
516 for step in texts.iter().filter(|x| {
517 x.contains("**CREATE**")
518 || x.contains("**READ**")
519 || x.contains("**UPDATE**")
520 || x.contains("**DELETE**")
521 }) {
522 assert!(
523 step.chars().next().is_some_and(|c| c.is_ascii_digit()),
524 "list marker must attach: {step}"
525 );
526 }
527 assert!(
528 texts
529 .iter()
530 .any(|x| x.contains("No other files were touched")),
531 "{texts:?}"
532 );
533 }
534
535 #[test]
536 fn seal_done_without_punct() {
537 let mut s = SentenceSegmenter::new();
538 s.push("Done");
539 let sealed = s.seal_at_clean_end();
540 assert_eq!(contents(&sealed), vec!["Done".to_string()]);
541 }
542
543 #[test]
544 fn abrupt_does_not_promote() {
545 let mut s = SentenceSegmenter::new();
546 s.push("The implementation will");
547 let unresolved = s.take_unresolved();
548 assert_eq!(unresolved, "The implementation will");
549 }
550}