pub const CONTEXT_WINDOW: usize = 128;
pub const MAX_SOURCE_TOKENS: usize = CONTEXT_WINDOW - 1;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Span {
pub start: usize,
pub end: usize,
}
impl Span {
pub fn of<'a>(&self, src: &'a str) -> &'a str {
&src[self.start..self.end]
}
}
pub trait Segmenter {
fn sentences(&self, src: &str) -> Vec<Span>;
}
fn trimmed_span(src: &str, start: usize, end: usize) -> Option<Span> {
let slice = &src[start..end];
let trimmed = slice.trim();
if trimmed.is_empty() {
return None;
}
let off = trimmed.as_ptr() as usize - slice.as_ptr() as usize;
Some(Span {
start: start + off,
end: start + off + trimmed.len(),
})
}
pub fn reassemble(src: &str, spans: &[Span], outputs: &[String]) -> String {
debug_assert_eq!(spans.len(), outputs.len());
if spans.is_empty() {
return src.to_string();
}
let mut out = String::new();
out.push_str(&src[..spans[0].start]);
for (i, piece) in outputs.iter().enumerate() {
if i > 0 {
out.push_str(&src[spans[i - 1].end..spans[i].start]);
}
out.push_str(piece);
}
out.push_str(&src[spans[spans.len() - 1].end..]);
out
}
fn is_ascii_terminator(c: char) -> bool {
matches!(c, '.' | '!' | '?')
}
fn is_hard_terminator(c: char) -> bool {
matches!(c, '。' | '!' | '?' | '…' | '。')
}
pub struct BasicSegmenter;
impl Segmenter for BasicSegmenter {
fn sentences(&self, src: &str) -> Vec<Span> {
let mut spans = Vec::new();
let mut start = 0usize;
let mut chars = src.char_indices().peekable();
while let Some((i, c)) = chars.next() {
let boundary = if is_hard_terminator(c) {
true
} else if is_ascii_terminator(c) {
match chars.peek() {
None => true,
Some(&(_, next)) => next.is_whitespace(),
}
} else {
false
};
if boundary {
let end = i + c.len_utf8();
if let Some(s) = trimmed_span(src, start, end) {
spans.push(s);
}
start = end;
}
}
if let Some(s) = trimmed_span(src, start, src.len()) {
spans.push(s);
}
spans
}
}
#[cfg(feature = "icu-segmenter")]
pub use icu_impl::IcuSegmenter;
#[cfg(feature = "icu-segmenter")]
mod icu_impl {
use super::{trimmed_span, Segmenter, Span};
use icu_segmenter::options::SentenceBreakInvariantOptions;
use icu_segmenter::SentenceSegmenter;
pub struct IcuSegmenter;
impl IcuSegmenter {
pub fn new() -> IcuSegmenter {
IcuSegmenter
}
}
impl Default for IcuSegmenter {
fn default() -> IcuSegmenter {
IcuSegmenter::new()
}
}
impl Segmenter for IcuSegmenter {
fn sentences(&self, src: &str) -> Vec<Span> {
if src.is_empty() {
return Vec::new();
}
let seg = SentenceSegmenter::new(SentenceBreakInvariantOptions::default());
let bounds: Vec<usize> = seg.segment_str(src).collect();
bounds
.windows(2)
.filter_map(|w| trimmed_span(src, w[0], w[1]))
.collect()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn texts(src: &str, spans: &[Span]) -> Vec<String> {
spans.iter().map(|s| s.of(src).to_string()).collect()
}
#[test]
fn basic_splits_multiple_sentences() {
let src = "Hello world. How are you? I am fine!";
let spans = BasicSegmenter.sentences(src);
assert_eq!(
texts(src, &spans),
["Hello world.", "How are you?", "I am fine!"]
);
}
#[test]
fn basic_single_sentence_is_verbatim() {
let src = "The cat sat on the mat.";
let spans = BasicSegmenter.sentences(src);
assert_eq!(texts(src, &spans), [src]);
}
#[test]
fn basic_trailing_text_without_terminator() {
let src = "First one. Then a tail with no period";
let spans = BasicSegmenter.sentences(src);
assert_eq!(
texts(src, &spans),
["First one.", "Then a tail with no period"]
);
}
#[test]
fn basic_decimal_is_not_a_boundary() {
let src = "Pi is 3.14 today. Yes.";
let spans = BasicSegmenter.sentences(src);
assert_eq!(texts(src, &spans), ["Pi is 3.14 today.", "Yes."]);
}
#[test]
fn basic_cjk_fullwidth_stop_splits() {
let src = "你好世界。天气很好。";
let spans = BasicSegmenter.sentences(src);
assert_eq!(texts(src, &spans), ["你好世界。", "天气很好。"]);
}
#[test]
fn empty_and_whitespace_yield_no_spans() {
assert!(BasicSegmenter.sentences("").is_empty());
assert!(BasicSegmenter.sentences(" \n\t ").is_empty());
}
#[test]
fn reassemble_latin_uses_source_spaces() {
let src = "Hello world. How are you?";
let spans = BasicSegmenter.sentences(src);
let outputs = vec![
"Bonjour le monde.".to_string(),
"Comment ça va ?".to_string(),
];
assert_eq!(
reassemble(src, &spans, &outputs),
"Bonjour le monde. Comment ça va ?"
);
}
#[test]
fn reassemble_cjk_has_no_separator() {
let src = "你好世界。天气很好。";
let spans = BasicSegmenter.sentences(src);
let outputs = vec!["Hello world.".to_string(), "Nice weather.".to_string()];
assert_eq!(
reassemble(src, &spans, &outputs),
"Hello world.Nice weather."
);
}
#[test]
fn reassemble_single_span_is_output_verbatim() {
let src = "Hello world.";
let spans = BasicSegmenter.sentences(src);
let outputs = vec!["Bonjour le monde.".to_string()];
assert_eq!(reassemble(src, &spans, &outputs), "Bonjour le monde.");
}
#[test]
fn reassemble_preserves_outer_whitespace() {
let src = " Hello. World. ";
let spans = BasicSegmenter.sentences(src);
let outputs = vec!["Bonjour.".to_string(), "Monde.".to_string()];
assert_eq!(reassemble(src, &spans, &outputs), " Bonjour. Monde. ");
}
#[test]
fn reassemble_empty_returns_source() {
assert_eq!(reassemble(" ", &[], &[]), " ");
}
#[cfg(feature = "icu-segmenter")]
#[test]
fn icu_splits_latin_and_cjk() {
let seg = IcuSegmenter::new();
let latin = "Hello world. How are you? Fine.";
assert_eq!(seg.sentences(latin).len(), 3);
let cjk = "你好世界。天气很好。";
assert_eq!(seg.sentences(cjk).len(), 2);
}
}