mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-23 03:07:13 +00:00
182 lines
5.7 KiB
Rust
182 lines
5.7 KiB
Rust
//! Cutting text into sentences and paragraphs.
|
||||
|
|
//!
|
|||
|
|
//! Both segmenters reproduce what Python's `re` module does with
|
|||
|
|
//! `(?<=[.!?])\s+` and `\n\s*\n`, because the chunk boundaries already in
|
|||
|
|
//! existing indexes were cut that way. Where the obvious Rust spelling would
|
|||
|
|
//! differ, the difference is spelled out below rather than smoothed over.
|
|||
|
|
|
|||
|
|
/// Whether a character is whitespace, by Python's definition.
|
|||
|
|
///
|
|||
|
|
/// Python's `str.isspace` and its `\s` class cover the Unicode `White_Space`
|
|||
|
|
/// property *and* the four ASCII separators `U+001C`–`U+001F`, which Rust's
|
|||
|
|
/// [`char::is_whitespace`] does not. Trimming with the narrower definition
|
|||
|
|
/// leaves a separator on the front of a piece, which changes its token count,
|
|||
|
|
/// which moves a chunk boundary.
|
|||
|
|
pub fn is_space(c: char) -> bool {
|
|||
|
|
c.is_whitespace() || matches!(c, '\u{1c}'..='\u{1f}')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Trim leading and trailing whitespace, by Python's definition.
|
|||
|
|
fn trim(s: &str) -> &str {
|
|||
|
|
s.trim_matches(is_space)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Drop the empty pieces and trim what is left, which is what every caller of
|
|||
|
|
/// a Python `re.split` here does immediately afterwards.
|
|||
|
|
fn tidy(pieces: Vec<&str>) -> Vec<&str> {
|
|||
|
|
pieces
|
|||
|
|
.into_iter()
|
|||
|
|
.map(trim)
|
|||
|
|
.filter(|piece| !piece.is_empty())
|
|||
|
|
.collect()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Split text into sentences at whitespace that follows `.`, `!` or `?`.
|
|||
|
|
///
|
|||
|
|
/// Crude on purpose — it does not know that `Mr.` or `p. 41` are not
|
|||
|
|
/// sentence ends. This is the fallback path for a paragraph too large to
|
|||
|
|
/// chunk whole, where a wrong cut costs a little context and a clever cut
|
|||
|
|
/// that disagrees with the existing index costs a re-embedding.
|
|||
|
|
pub fn sentences(text: &str) -> Vec<&str> {
|
|||
|
|
let mut pieces = Vec::new();
|
|||
|
|
let mut piece_start = 0;
|
|||
|
|
let mut previous = None;
|
|||
|
|
let mut chars = text.char_indices().peekable();
|
|||
|
|
|
|||
|
|
while let Some((at, c)) = chars.next() {
|
|||
|
|
if !is_space(c) {
|
|||
|
|
previous = Some(c);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
// Whitespace is consumed a whole run at a time, because `\s+` is
|
|||
|
|
// greedy: the separator is the run, not its first character.
|
|||
|
|
let mut run_end = at + c.len_utf8();
|
|||
|
|
while let Some(&(next_at, next)) = chars.peek() {
|
|||
|
|
if !is_space(next) {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
run_end = next_at + next.len_utf8();
|
|||
|
|
chars.next();
|
|||
|
|
}
|
|||
|
|
if matches!(previous, Some('.' | '!' | '?')) {
|
|||
|
|
pieces.push(&text[piece_start..at]);
|
|||
|
|
piece_start = run_end;
|
|||
|
|
}
|
|||
|
|
previous = None;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pieces.push(&text[piece_start..]);
|
|||
|
|
tidy(pieces)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Split text into paragraphs at blank lines.
|
|||
|
|
///
|
|||
|
|
/// A separator is a newline, any run of whitespace, and a newline — so one
|
|||
|
|
/// blank line and four of them separate equally, and trailing spaces on an
|
|||
|
|
/// otherwise blank line do not stop it being blank.
|
|||
|
|
pub fn paragraphs(text: &str) -> Vec<&str> {
|
|||
|
|
let mut pieces = Vec::new();
|
|||
|
|
let mut piece_start = 0;
|
|||
|
|
let mut chars = text.char_indices().peekable();
|
|||
|
|
|
|||
|
|
while let Some((at, c)) = chars.next() {
|
|||
|
|
if c != '\n' {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
// The greedy `\s*` in `\n\s*\n` backtracks to the *last* newline in
|
|||
|
|
// the whitespace run, so that is where the next paragraph begins.
|
|||
|
|
let mut last_newline = None;
|
|||
|
|
while let Some(&(next_at, next)) = chars.peek() {
|
|||
|
|
if !is_space(next) {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
if next == '\n' {
|
|||
|
|
last_newline = Some(next_at);
|
|||
|
|
}
|
|||
|
|
chars.next();
|
|||
|
|
}
|
|||
|
|
if let Some(newline) = last_newline {
|
|||
|
|
pieces.push(&text[piece_start..at]);
|
|||
|
|
piece_start = newline + 1;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pieces.push(&text[piece_start..]);
|
|||
|
|
tidy(pieces)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[cfg(test)]
|
|||
|
|
mod tests {
|
|||
|
|
use super::*;
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn sentences_split_after_terminators() {
|
|||
|
|
assert_eq!(
|
|||
|
|
sentences("One. Two! Three? Four"),
|
|||
|
|
["One.", "Two!", "Three?", "Four"]
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_terminator_without_following_whitespace_is_not_a_boundary() {
|
|||
|
|
assert_eq!(sentences("etc.and on"), ["etc.and on"]);
|
|||
|
|
assert_eq!(sentences("3.14 is pi"), ["3.14 is pi"]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn the_whole_whitespace_run_is_the_separator() {
|
|||
|
|
assert_eq!(sentences("One.\n\n Two."), ["One.", "Two."]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn leading_whitespace_is_not_a_boundary() {
|
|||
|
|
assert_eq!(sentences(" One. Two."), ["One.", "Two."]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn empty_and_blank_text_yield_nothing() {
|
|||
|
|
assert!(sentences("").is_empty());
|
|||
|
|
assert!(sentences(" \n ").is_empty());
|
|||
|
|
assert!(paragraphs("").is_empty());
|
|||
|
|
assert!(paragraphs("\n\n\n").is_empty());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn paragraphs_split_on_a_blank_line() {
|
|||
|
|
assert_eq!(paragraphs("one\n\ntwo"), ["one", "two"]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_single_newline_is_not_a_paragraph_break() {
|
|||
|
|
assert_eq!(paragraphs("one\ntwo"), ["one\ntwo"]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn a_blank_line_of_spaces_still_separates() {
|
|||
|
|
assert_eq!(paragraphs("one\n \t \ntwo"), ["one", "two"]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn many_blank_lines_separate_once() {
|
|||
|
|
assert_eq!(paragraphs("one\n\n\n\n two"), ["one", "two"]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn carriage_returns_survive_the_split() {
|
|||
|
|
assert_eq!(paragraphs("one\r\n\r\ntwo"), ["one", "two"]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn ascii_separators_count_as_whitespace() {
|
|||
|
|
// Python's str.isspace is true for these; Rust's is_whitespace is not.
|
|||
|
|
assert!(is_space('\u{1c}'));
|
|||
|
|
assert_eq!(sentences("One.\u{1c}Two."), ["One.", "Two."]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn multi_byte_characters_do_not_shift_the_boundaries() {
|
|||
|
|
assert_eq!(sentences("Café. Naïve."), ["Café.", "Naïve."]);
|
|||
|
|
assert_eq!(paragraphs("Café\n\nNaïve"), ["Café", "Naïve"]);
|
|||
|
|
}
|
|||
|
|
}
|