1use super::tokenizer::{Analyzer, TokenOffset};
30
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub struct Highlight {
34 pub start: u32,
36 pub end: u32,
38}
39
40#[derive(Clone, Debug, Eq, PartialEq)]
42pub struct Snippet {
43 pub window: Highlight,
45 pub highlights: Vec<Highlight>,
47}
48
49impl Snippet {
50 #[must_use]
52 pub fn text<'text>(&self, field: &'text str) -> Option<&'text str> {
53 let start = usize::try_from(self.window.start).ok()?;
54 let end = usize::try_from(self.window.end).ok()?;
55 field.get(start..end)
56 }
57}
58
59#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub enum SnippetError {
62 FieldNotStored,
67 ZeroWindow,
69}
70
71impl std::fmt::Display for SnippetError {
72 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 match self {
74 Self::FieldNotStored => {
75 formatter.write_str("field is not stored, so it has no match offsets")
76 }
77 Self::ZeroWindow => formatter.write_str("snippet window length must be positive"),
78 }
79 }
80}
81
82impl std::error::Error for SnippetError {}
83
84fn floor_boundary(text: &str, mut offset: usize) -> usize {
86 offset = offset.min(text.len());
87 while offset > 0 && !text.is_char_boundary(offset) {
88 offset -= 1;
89 }
90 offset
91}
92
93fn ceil_boundary(text: &str, mut offset: usize) -> usize {
95 offset = offset.min(text.len());
96 while offset < text.len() && !text.is_char_boundary(offset) {
97 offset += 1;
98 }
99 offset
100}
101
102#[must_use]
106pub fn match_offsets(analyzer: &Analyzer, text: &str, terms: &[Vec<u8>]) -> Vec<Highlight> {
107 let mut ranges: Vec<Highlight> = analyzer
108 .analyze(text)
109 .into_iter()
110 .filter(|token| {
111 terms
112 .iter()
113 .any(|term| term.as_slice() == token.term.as_bytes())
114 })
115 .map(|token| {
116 let TokenOffset { start, end } = token.offset;
117 Highlight { start, end }
118 })
119 .collect();
120 ranges.sort_by_key(|range| (range.start, range.end));
121 ranges.dedup();
123 let mut merged: Vec<Highlight> = Vec::with_capacity(ranges.len());
126 for range in ranges {
127 match merged.last_mut() {
128 Some(last) if range.start < last.end => {
129 last.end = last.end.max(range.end);
130 }
131 _ => merged.push(range),
132 }
133 }
134 merged
135}
136
137pub fn best_window(
144 analyzer: &Analyzer,
145 text: &str,
146 terms: &[Vec<u8>],
147 window_bytes: usize,
148 stored: bool,
149) -> Result<Option<Snippet>, SnippetError> {
150 if !stored {
151 return Err(SnippetError::FieldNotStored);
152 }
153 if window_bytes == 0 {
154 return Err(SnippetError::ZeroWindow);
155 }
156 let matches = match_offsets(analyzer, text, terms);
157 if matches.is_empty() {
158 return Ok(None);
159 }
160
161 let mut best: Option<(usize, usize, Highlight)> = None;
163 for anchor in &matches {
164 let start = floor_boundary(text, usize::try_from(anchor.start).unwrap_or(0));
165 let end = ceil_boundary(text, start.saturating_add(window_bytes));
166 let covered: Vec<&Highlight> = matches
167 .iter()
168 .filter(|candidate| {
169 usize::try_from(candidate.start).unwrap_or(usize::MAX) >= start
170 && usize::try_from(candidate.end).unwrap_or(usize::MAX) <= end
171 })
172 .collect();
173 let distinct = {
174 let mut spans: Vec<(u32, u32)> = covered
175 .iter()
176 .map(|range| (range.start, range.end))
177 .collect();
178 spans.sort_unstable();
179 spans.dedup();
180 spans.len()
181 };
182 let score = (distinct, covered.len());
183 let window = Highlight {
184 start: u32::try_from(start).unwrap_or(u32::MAX),
185 end: u32::try_from(end).unwrap_or(u32::MAX),
186 };
187 let replace = match &best {
188 None => true,
189 Some((distinct_best, count_best, window_best)) => {
192 score.0 > *distinct_best
193 || (score.0 == *distinct_best && score.1 > *count_best)
194 || (score.0 == *distinct_best
195 && score.1 == *count_best
196 && window.start < window_best.start)
197 }
198 };
199 if replace {
200 best = Some((score.0, score.1, window));
201 }
202 }
203
204 let Some((_, _, window)) = best else {
205 return Ok(None);
206 };
207 let highlights = matches
208 .into_iter()
209 .filter(|range| range.start >= window.start && range.end <= window.end)
210 .collect();
211 Ok(Some(Snippet { window, highlights }))
212}
213
214#[cfg(test)]
215#[allow(
216 clippy::expect_used,
217 clippy::indexing_slicing,
218 clippy::panic,
219 clippy::unwrap_used
220)]
221mod tests {
222 use super::*;
223 use crate::fts::tokenizer::{Profile, TokenizerConfig};
224
225 fn analyzer() -> Analyzer {
226 Analyzer::new(TokenizerConfig::text_default()).expect("valid config")
227 }
228
229 fn code_analyzer() -> Analyzer {
230 Analyzer::new(Profile::Code.config()).expect("valid config")
231 }
232
233 fn terms(words: &[&str]) -> Vec<Vec<u8>> {
234 words.iter().map(|word| word.as_bytes().to_vec()).collect()
235 }
236
237 #[test]
238 fn highlights_point_at_the_matched_surface_form() {
239 let text = "the quick brown fox jumps";
240 let highlights = match_offsets(&analyzer(), text, &terms(&["brown"]));
241 assert_eq!(highlights.len(), 1);
242 let range = highlights[0];
243 assert_eq!(&text[range.start as usize..range.end as usize], "brown");
244 }
245
246 #[test]
247 fn snippet_ranges_are_in_bounds_utf8_valid_and_contain_the_matched_surface_form() {
248 let text = "Caf\u{00E9} \u{1F680} na\u{00EF}ve cafe\u{0301} resume \u{4E2D}\u{6587}";
250 let snippet = best_window(&analyzer(), text, &terms(&["cafe"]), 24, true)
251 .expect("stored")
252 .expect("a match exists");
253 let window = snippet.text(text).expect("window is a char boundary");
254 assert!(!window.is_empty());
255 for highlight in &snippet.highlights {
256 let slice = &text[highlight.start as usize..highlight.end as usize];
257 assert!(
258 std::str::from_utf8(slice.as_bytes()).is_ok(),
259 "highlight sliced a character in half"
260 );
261 }
262 assert!(
263 snippet.window.end as usize <= text.len(),
264 "window escaped the field"
265 );
266 }
267
268 #[test]
269 fn snippets_are_deterministic_across_runs() {
270 let text = "alpha beta alpha gamma alpha delta alpha";
271 let analyzer = code_analyzer();
272 let first = best_window(&analyzer, text, &terms(&["alpha"]), 20, true).expect("stored");
273 for _ in 0..8 {
274 let again = best_window(&analyzer, text, &terms(&["alpha"]), 20, true).expect("stored");
275 assert_eq!(first, again, "the snippet moved between runs");
276 }
277 }
278
279 #[test]
280 fn the_window_prefers_more_distinct_terms() {
281 let text = "alpha ................................ alpha beta";
284 let snippet = best_window(&code_analyzer(), text, &terms(&["alpha", "beta"]), 16, true)
285 .expect("stored")
286 .expect("a match exists");
287 let window = snippet.text(text).expect("valid window");
288 assert!(
289 window.contains("beta"),
290 "the window missed the denser region: {window:?}"
291 );
292 }
293
294 #[test]
295 fn an_unstored_field_is_a_typed_error_not_a_guess() {
296 assert_eq!(
297 best_window(&analyzer(), "text", &terms(&["text"]), 10, false),
298 Err(SnippetError::FieldNotStored)
299 );
300 }
301
302 #[test]
303 fn a_zero_length_window_is_a_typed_error() {
304 assert_eq!(
305 best_window(&analyzer(), "text", &terms(&["text"]), 0, true),
306 Err(SnippetError::ZeroWindow)
307 );
308 }
309
310 #[test]
311 fn a_field_with_no_match_yields_no_snippet_rather_than_the_whole_field() {
312 assert_eq!(
313 best_window(&analyzer(), "alpha beta", &terms(&["omega"]), 20, true).expect("stored"),
314 None
315 );
316 }
317
318 #[test]
319 fn highlights_never_overlap_or_nest() {
320 let text = "call put_if_match now";
323 let highlights = match_offsets(
324 &code_analyzer(),
325 text,
326 &terms(&["put_if_match", "put", "match"]),
327 );
328 for pair in highlights.windows(2) {
329 let (Some(left), Some(right)) = (pair.first(), pair.get(1)) else {
330 continue;
331 };
332 assert!(
333 left.end <= right.start,
334 "highlights {left:?} and {right:?} overlap"
335 );
336 }
337 }
338
339 #[test]
340 fn an_empty_field_produces_no_snippet() {
341 assert_eq!(
342 best_window(&analyzer(), "", &terms(&["alpha"]), 10, true).expect("stored"),
343 None
344 );
345 }
346
347 #[test]
348 fn boundary_helpers_never_split_a_character() {
349 let text = "a\u{00E9}\u{4E2D}\u{1F680}b";
350 for offset in 0..=text.len() + 4 {
351 let floored = floor_boundary(text, offset);
352 let ceiled = ceil_boundary(text, offset);
353 assert!(text.is_char_boundary(floored), "floor at {offset}");
354 assert!(text.is_char_boundary(ceiled), "ceil at {offset}");
355 assert!(floored <= text.len() && ceiled <= text.len());
356 }
357 }
358}