1pub fn snap_to_words(text: &str, start: usize, end: usize) -> (usize, usize) {
22 let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '-';
23
24 let mut s = start.min(text.len());
25 let mut e = end.clamp(s, text.len());
26 while s > 0 && !text.is_char_boundary(s) {
27 s -= 1;
28 }
29 while e < text.len() && !text.is_char_boundary(e) {
30 e += 1;
31 }
32
33 while s > 0 {
35 match text[..s].chars().next_back() {
36 Some(c) if is_word(c) => s -= c.len_utf8(),
37 _ => break,
38 }
39 }
40 while e < text.len() {
42 match text[e..].chars().next() {
43 Some(c) if is_word(c) => e += c.len_utf8(),
44 _ => break,
45 }
46 }
47 (s, e)
48}
49
50pub fn snap_and_merge(text: &str, ranges: &[(usize, usize, String)]) -> Vec<(usize, usize, String)> {
57 let mut snapped: Vec<(usize, usize, String)> = ranges
58 .iter()
59 .map(|(s, e, k)| {
60 let (ss, se) = snap_to_words(text, *s, *e);
61 (ss, se, k.clone())
62 })
63 .filter(|(s, e, _)| e > s)
64 .collect();
65
66 snapped.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)));
67
68 let mut out: Vec<(usize, usize, String)> = Vec::new();
69 for (s, e, kind) in snapped {
70 let joins = out.last().is_some_and(|(_, pe, pk)| {
72 *pk == kind && (s <= *pe || text.get(*pe..s).is_some_and(|gap| gap.chars().all(char::is_whitespace)))
73 });
74 if joins {
75 if let Some((_, pe, _)) = out.last_mut() {
76 *pe = (*pe).max(e);
77 }
78 } else {
79 out.push((s, e, kind));
80 }
81 }
82 out
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88
89 #[test]
90 fn a_mid_word_prediction_recovers_the_whole_word() {
91 let t = "Registeel took the win";
92 let (s, e) = snap_to_words(t, 3, 6);
94 assert_eq!(&t[s..e], "Registeel");
95 }
96
97 #[test]
98 fn a_four_digit_year_is_not_truncated() {
99 let t = "held in 2025 at the venue";
100 let (s, e) = snap_to_words(t, 8, 11); assert_eq!(&t[s..e], "2025");
102 }
103
104 #[test]
105 fn an_already_aligned_span_is_unchanged() {
106 let t = "Sootopolis City hosted it";
107 let (s, e) = snap_to_words(t, 0, 15);
108 assert_eq!(&t[s..e], "Sootopolis City");
109 }
110
111 #[test]
112 fn multibyte_text_does_not_panic_and_slices_cleanly() {
113 for t in ["a Pokémon named Aggron", "recorded 28 °C at the site", "Café Ecruteak"] {
115 for start in 0..t.len() {
116 for end in start..t.len() {
117 let (s, e) = snap_to_words(t, start, end);
118 assert!(t.get(s..e).is_some(), "span {s}..{e} must slice {t:?}");
119 }
120 }
121 }
122 }
123
124 #[test]
125 fn apostrophes_and_hyphens_stay_inside_one_word() {
126 let t = "Cynthia Ward's 11-minute battle";
127 let (s, e) = snap_to_words(t, 8, 12); assert_eq!(&t[s..e], "Ward's");
129 let (s2, e2) = snap_to_words(t, 15, 17); assert_eq!(&t[s2..e2], "11-minute");
131 }
132
133 #[test]
134 fn two_pieces_of_one_word_merge_to_a_single_span() {
135 let t = "Sootopolis City";
136 let ranges = vec![
138 (0usize, 4usize, "GEO".to_string()), (4usize, 10usize, "GEO".to_string()), ];
141 let out = snap_and_merge(t, &ranges);
142 assert_eq!(out.len(), 1, "{out:?}");
143 assert_eq!(&t[out[0].0..out[0].1], "Sootopolis");
144 }
145
146 #[test]
147 fn a_multi_word_mention_predicted_piecewise_becomes_one_span() {
148 let t = "at Sootopolis City today";
149 let ranges = vec![
150 (3usize, 8usize, "GEO".to_string()), (14usize, 18usize, "GEO".to_string()), ];
153 let out = snap_and_merge(t, &ranges);
154 assert_eq!(out.len(), 1, "{out:?}");
155 assert_eq!(&t[out[0].0..out[0].1], "Sootopolis City");
156 }
157
158 #[test]
159 fn different_kinds_are_never_merged() {
160 let t = "Aggron 1082 m";
161 let ranges = vec![
162 (0usize, 6usize, "ENT".to_string()),
163 (7usize, 13usize, "QTY".to_string()),
164 ];
165 let out = snap_and_merge(t, &ranges);
166 assert_eq!(out.len(), 2, "adjacent spans of different kinds must stay separate: {out:?}");
167 }
168
169 #[test]
170 fn spans_separated_by_real_words_stay_separate() {
171 let t = "Aggron and also Salamence";
172 let ranges = vec![
173 (0usize, 6usize, "ENT".to_string()),
174 (16usize, 25usize, "ENT".to_string()),
175 ];
176 let out = snap_and_merge(t, &ranges);
177 assert_eq!(out.len(), 2, "{out:?}");
178 }
179}