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' => {
171 if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
172 let before = s[..i].trim();
173 if !before.is_empty() && !is_only_list_marker(before) {
174 return Some(i);
175 }
176 }
177 }
178 _ => {}
179 }
180 i += 1;
181 }
182 None
183}
184
185fn prev_is_sentence_letter(bytes: &[u8], dot_idx: usize) -> bool {
186 if dot_idx == 0 {
187 return false;
188 }
189 let p = bytes[dot_idx - 1];
190 p.is_ascii_lowercase() || p.is_ascii_uppercase()
191}
192
193fn is_only_list_marker(s: &str) -> bool {
194 let t = s.trim();
195 let b = t.as_bytes();
196 if b.is_empty() || b[b.len() - 1] != b'.' {
197 return false;
198 }
199 looks_like_ordered_list_marker(t, t.len() - 1)
200}
201
202fn looks_like_ordered_list_marker(s: &str, dot_idx: usize) -> bool {
204 let bytes = s.as_bytes();
205 let mut start = dot_idx;
206 while start > 0 && bytes[start - 1].is_ascii_digit() {
207 start -= 1;
208 }
209 if start == dot_idx {
210 return false;
211 }
212 let digit_len = dot_idx - start;
213 if !(1..=3).contains(&digit_len) {
214 return false;
215 }
216 if start > 0 && !bytes[start - 1].is_ascii_whitespace() {
218 return false;
219 }
220 match bytes.get(dot_idx + 1) {
222 None => true,
223 Some(b) if b.is_ascii_whitespace() => true,
224 Some(b) if *b == b'*' || *b == b'_' || *b == b'`' || *b == b'[' => true,
225 _ => false,
226 }
227}
228
229fn looks_like_abbreviation_or_decimal(s: &str, dot_idx: usize) -> bool {
230 let bytes = s.as_bytes();
231 let prev = bytes.get(dot_idx.wrapping_sub(1)).copied();
232 let next = bytes.get(dot_idx + 1).copied();
233 if prev.is_some_and(|b| b.is_ascii_digit()) && next.is_some_and(|b| b.is_ascii_digit()) {
234 return true;
235 }
236 if prev.is_some_and(|b| b.is_ascii_alphabetic()) {
238 let mut start = dot_idx;
239 while start > 0 && bytes[start - 1].is_ascii_alphabetic() {
240 start -= 1;
241 }
242 let len = dot_idx - start;
243 if (1..=3).contains(&len) {
244 if len == 1 {
245 return true;
246 }
247 let word = &s[start..dot_idx];
248 const ABBREVS: &[&str] = &[
249 "Mr", "Mrs", "Ms", "Dr", "Prof", "Sr", "Jr", "vs", "etc", "Inc", "Ltd", "St",
250 ];
251 if ABBREVS.iter().any(|a| a.eq_ignore_ascii_case(word)) {
252 return true;
253 }
254 }
255 }
256 let mut t = dot_idx;
257 while t > 0 && !bytes[t - 1].is_ascii_whitespace() {
258 t -= 1;
259 }
260 let end = (dot_idx + 1).min(s.len());
261 let token = &s[t..end];
262 if token.contains("://") || token.contains("www.") {
263 return true;
264 }
265 if token.contains('/') {
266 return true;
267 }
268 false
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 fn contents(done: &[CompletedSentence]) -> Vec<String> {
276 done.iter().map(|c| c.content.clone()).collect()
277 }
278
279 #[test]
280 fn splits_on_period_space() {
281 let mut s = SentenceSegmenter::new();
282 let a = s.push("Hello world. ");
283 assert_eq!(contents(&a), vec!["Hello world.".to_string()]);
284 let b = s.push("Next one! ");
285 assert_eq!(contents(&b), vec!["Next one!".to_string()]);
286 }
287
288 #[test]
289 fn does_not_emit_partial() {
290 let mut s = SentenceSegmenter::new();
291 assert!(s.push("The build uses std::").is_empty());
292 let done = s.push("sync::Arc to share the handle. ");
293 assert_eq!(
294 contents(&done),
295 vec!["The build uses std::sync::Arc to share the handle.".to_string()]
296 );
297 }
298
299 #[test]
300 fn decimal_not_boundary() {
301 let mut s = SentenceSegmenter::new();
302 assert_eq!(s.push("Version 1.2.3 is ready. ").len(), 1);
303 }
304
305 #[test]
306 fn list_markers_stay_with_item() {
307 let mut s = SentenceSegmenter::new();
308 assert!(s.push("1.\n\n").is_empty());
310 let done = s.push("**CREATE** — Wrote the file with `hello monoloop crud`.\n\n");
311 assert_eq!(done.len(), 1, "{done:?}");
312 assert!(
313 done[0].content.starts_with("1."),
314 "list marker must stay attached: {}",
315 done[0].content
316 );
317 assert!(done[0].content.contains("**CREATE**"));
318 }
319
320 #[test]
321 fn list_sequence_does_not_emit_bare_numbers() {
322 let mut s = SentenceSegmenter::new();
323 let text = "1. **CREATE** — Wrote the file.\n\n2. **READ** — File contained x.\n\n3. **UPDATE** — Done.\n\n";
324 let done = s.push(text);
325 assert_eq!(done.len(), 3, "{done:?}");
326 assert!(done.iter().all(|x| !x
327 .content
328 .trim()
329 .chars()
330 .all(|c| c.is_ascii_digit() || c == '.')));
331 assert!(done[0].content.contains("CREATE"));
332 assert!(done[1].content.contains("READ"));
333 assert!(done[2].content.contains("UPDATE"));
334 }
335
336 #[test]
337 fn missing_space_after_period_splits() {
338 let mut s = SentenceSegmenter::new();
339 assert!(s.push("starting with create.").is_empty());
341 let done = s.push("CRUD exercise on the file only:\n\n");
342 assert_eq!(done.len(), 2, "{done:?}");
343 assert_eq!(done[0].content, "starting with create.");
344 assert!(done[1].content.starts_with("CRUD exercise"));
345 }
346
347 #[test]
349 fn live_grok_crud_token_stream_assembles_cleanly() {
350 let chunks = [
351 "I'll",
352 " run",
353 " the",
354 " five",
355 " CRUD",
356 " steps",
357 " on",
358 " that",
359 " one",
360 " file",
361 " only",
362 ",",
363 " starting",
364 " with",
365 " create",
366 ".",
367 "CRUD",
368 " exercise",
369 " on",
370 " `",
371 "mon",
372 "olo",
373 "op",
374 "_",
375 "live",
376 "_",
377 "crud",
378 "_",
379 "test",
380 ".txt",
381 "`",
382 " only",
383 ":\n\n",
384 "1",
385 ".",
386 " **",
387 "CREATE",
388 "**",
389 " —",
390 " Wrote",
391 " the",
392 " file",
393 " with",
394 " `",
395 "hello",
396 " mon",
397 "olo",
398 "op",
399 " crud",
400 "`.",
401 "\n",
402 "2",
403 ".",
404 " **",
405 "READ",
406 "**",
407 " —",
408 " File",
409 " contained",
410 " `",
411 "hello",
412 " mon",
413 "olo",
414 "op",
415 " crud",
416 "`.",
417 "\n",
418 "3",
419 ".",
420 " **",
421 "UPDATE",
422 "**",
423 " —",
424 " Over",
425 "wrote",
426 " it",
427 " with",
428 " `",
429 "hello",
430 " mon",
431 "olo",
432 "op",
433 " crud",
434 " UPD",
435 "ATED",
436 "`.",
437 "\n",
438 "4",
439 ".",
440 " **",
441 "READ",
442 "**",
443 " —",
444 " File",
445 " contained",
446 " `",
447 "hello",
448 " mon",
449 "olo",
450 "op",
451 " crud",
452 " UPD",
453 "ATED",
454 "`.",
455 "\n",
456 "5",
457 ".",
458 " **",
459 "DELETE",
460 "**",
461 " —",
462 " Removed",
463 " the",
464 " file",
465 " (`",
466 "rm",
467 "`",
468 " exited",
469 " ",
470 "0",
471 ").",
472 "\n\n",
473 "No",
474 " other",
475 " files",
476 " were",
477 " touched",
478 ".",
479 ];
480 let mut s = SentenceSegmenter::new();
481 let mut done = Vec::new();
482 for c in chunks {
483 done.extend(s.push(c));
484 }
485 done.extend(s.seal_at_clean_end());
486 let texts = contents(&done);
487
488 assert!(
490 texts.iter().all(|x| !x.contains("create.CRUD")),
491 "must split missing-space: {texts:?}"
492 );
493 assert!(
494 texts
495 .iter()
496 .all(|x| !is_only_list_marker(x) && x.trim() != "1." && x.trim() != "2."),
497 "bare list markers leaked: {texts:?}"
498 );
499 assert!(
500 texts.iter().any(|x| x.ends_with("create.")),
501 "expected sentence ending create.: {texts:?}"
502 );
503 assert!(
504 texts.iter().any(|x| x.starts_with("CRUD exercise")),
505 "expected CRUD exercise sentence: {texts:?}"
506 );
507 assert_eq!(
508 texts
509 .iter()
510 .filter(|x| x.contains("**CREATE**")
511 || x.contains("**READ**")
512 || x.contains("**UPDATE**")
513 || x.contains("**DELETE**"))
514 .count(),
515 5,
516 "five step sentences: {texts:?}"
517 );
518 for step in texts.iter().filter(|x| {
519 x.contains("**CREATE**")
520 || x.contains("**READ**")
521 || x.contains("**UPDATE**")
522 || x.contains("**DELETE**")
523 }) {
524 assert!(
525 step.chars().next().is_some_and(|c| c.is_ascii_digit()),
526 "list marker must attach: {step}"
527 );
528 }
529 assert!(
530 texts
531 .iter()
532 .any(|x| x.contains("No other files were touched")),
533 "{texts:?}"
534 );
535 }
536
537 #[test]
538 fn seal_done_without_punct() {
539 let mut s = SentenceSegmenter::new();
540 s.push("Done");
541 let sealed = s.seal_at_clean_end();
542 assert_eq!(contents(&sealed), vec!["Done".to_string()]);
543 }
544
545 #[test]
546 fn abrupt_does_not_promote() {
547 let mut s = SentenceSegmenter::new();
548 s.push("The implementation will");
549 let unresolved = s.take_unresolved();
550 assert_eq!(unresolved, "The implementation will");
551 }
552}