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