pub fn snap_to_words(text: &str, start: usize, end: usize) -> (usize, usize) {
let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '-';
let mut s = start.min(text.len());
let mut e = end.clamp(s, text.len());
while s > 0 && !text.is_char_boundary(s) {
s -= 1;
}
while e < text.len() && !text.is_char_boundary(e) {
e += 1;
}
while s > 0 {
match text[..s].chars().next_back() {
Some(c) if is_word(c) => s -= c.len_utf8(),
_ => break,
}
}
while e < text.len() {
match text[e..].chars().next() {
Some(c) if is_word(c) => e += c.len_utf8(),
_ => break,
}
}
(s, e)
}
pub fn snap_and_merge(text: &str, ranges: &[(usize, usize, String)]) -> Vec<(usize, usize, String)> {
let mut snapped: Vec<(usize, usize, String)> = ranges
.iter()
.map(|(s, e, k)| {
let (ss, se) = snap_to_words(text, *s, *e);
(ss, se, k.clone())
})
.filter(|(s, e, _)| e > s)
.collect();
snapped.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)));
let mut out: Vec<(usize, usize, String)> = Vec::new();
for (s, e, kind) in snapped {
let joins = out.last().is_some_and(|(_, pe, pk)| {
*pk == kind && (s <= *pe || text.get(*pe..s).is_some_and(|gap| gap.chars().all(char::is_whitespace)))
});
if joins {
if let Some((_, pe, _)) = out.last_mut() {
*pe = (*pe).max(e);
}
} else {
out.push((s, e, kind));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_mid_word_prediction_recovers_the_whole_word() {
let t = "Registeel took the win";
let (s, e) = snap_to_words(t, 3, 6);
assert_eq!(&t[s..e], "Registeel");
}
#[test]
fn a_four_digit_year_is_not_truncated() {
let t = "held in 2025 at the venue";
let (s, e) = snap_to_words(t, 8, 11); assert_eq!(&t[s..e], "2025");
}
#[test]
fn an_already_aligned_span_is_unchanged() {
let t = "Sootopolis City hosted it";
let (s, e) = snap_to_words(t, 0, 15);
assert_eq!(&t[s..e], "Sootopolis City");
}
#[test]
fn multibyte_text_does_not_panic_and_slices_cleanly() {
for t in ["a Pokémon named Aggron", "recorded 28 °C at the site", "Café Ecruteak"] {
for start in 0..t.len() {
for end in start..t.len() {
let (s, e) = snap_to_words(t, start, end);
assert!(t.get(s..e).is_some(), "span {s}..{e} must slice {t:?}");
}
}
}
}
#[test]
fn apostrophes_and_hyphens_stay_inside_one_word() {
let t = "Cynthia Ward's 11-minute battle";
let (s, e) = snap_to_words(t, 8, 12); assert_eq!(&t[s..e], "Ward's");
let (s2, e2) = snap_to_words(t, 15, 17); assert_eq!(&t[s2..e2], "11-minute");
}
#[test]
fn two_pieces_of_one_word_merge_to_a_single_span() {
let t = "Sootopolis City";
let ranges = vec![
(0usize, 4usize, "GEO".to_string()), (4usize, 10usize, "GEO".to_string()), ];
let out = snap_and_merge(t, &ranges);
assert_eq!(out.len(), 1, "{out:?}");
assert_eq!(&t[out[0].0..out[0].1], "Sootopolis");
}
#[test]
fn a_multi_word_mention_predicted_piecewise_becomes_one_span() {
let t = "at Sootopolis City today";
let ranges = vec![
(3usize, 8usize, "GEO".to_string()), (14usize, 18usize, "GEO".to_string()), ];
let out = snap_and_merge(t, &ranges);
assert_eq!(out.len(), 1, "{out:?}");
assert_eq!(&t[out[0].0..out[0].1], "Sootopolis City");
}
#[test]
fn different_kinds_are_never_merged() {
let t = "Aggron 1082 m";
let ranges = vec![
(0usize, 6usize, "ENT".to_string()),
(7usize, 13usize, "QTY".to_string()),
];
let out = snap_and_merge(t, &ranges);
assert_eq!(out.len(), 2, "adjacent spans of different kinds must stay separate: {out:?}");
}
#[test]
fn spans_separated_by_real_words_stay_separate() {
let t = "Aggron and also Salamence";
let ranges = vec![
(0usize, 6usize, "ENT".to_string()),
(16usize, 25usize, "ENT".to_string()),
];
let out = snap_and_merge(t, &ranges);
assert_eq!(out.len(), 2, "{out:?}");
}
}