//! Snippet / highlight rendering for search results. //! //! We store extracted text in the `documents_text` sidecar (zstd-compressed) //! rather than in FTS5, so SQLite's built-in `snippet()` / `highlight()` //! auxiliary functions aren't available (contentless FTS5 doesn't support //! them). This module reproduces the parts we actually need in Rust: find //! a window of context around the first match, bold every query-term //! occurrence inside that window, trim with ellipsis markers. //! //! Matching is ASCII-case-insensitive on the *rendering* side. That aligns //! with the search path which is already case-insensitive via the trigram //! tokenizer; exact-case-only snippets aren't a feature users expect here. //! Unicode accent folding isn't applied at the rendering layer — a query //! for `cafe` will still *find* a file containing `café` (because the FTS //! tokenizer strips diacritics) but the snippet won't highlight the //! accented occurrence. The surrounding text is still returned verbatim. //! //! The API is intentionally small: one `render` function plus an `Options` //! struct. Callers that want different pre/post tags, ellipsis, or window //! size pass them in; there are sensible defaults for the GUI case. /// Options controlling snippet rendering. The defaults mirror the old /// `snippet(searchabletext, 1, '', '', '...', 64)` call that /// the GUI used to run directly as SQL. #[derive(Debug, Clone)] pub struct Options<'a> { pub pre: &'a str, pub post: &'a str, pub ellipsis: &'a str, /// Approximate character budget for the returned snippet. Matches /// expand the window if needed to keep their tags on; the budget is a /// soft target, not a hard cap. pub approx_chars: usize, } impl<'a> Default for Options<'a> { fn default() -> Self { Options { pre: "", post: "", ellipsis: "...", approx_chars: 200, } } } /// Render a snippet from `text` highlighting every occurrence of any term /// in `terms`. Returns a string with `pre`/`post` wrapping each match, and /// `ellipsis` prepended/appended when the returned window doesn't reach /// the text's edges. /// /// If no term matches, returns the first `approx_chars` of `text` (char- /// aligned), suffixed with `ellipsis` when truncated. pub fn render(text: &str, terms: &[&str], opts: &Options<'_>) -> String { // Short-circuit trivial inputs. if text.is_empty() { return String::new(); } let effective_terms: Vec<&str> = terms .iter() .map(|t| t.trim()) .filter(|t| !t.is_empty()) .collect(); if effective_terms.is_empty() { return truncate_head(text, opts.approx_chars, opts.ellipsis); } // Case-fold once; we do all positioning on the folded buffer and emit // slices from the original. Both buffers have identical byte layout // because `to_ascii_lowercase` is a byte-for-byte map that preserves // multi-byte UTF-8 sequences unchanged (it only touches ASCII letters). let folded = text.to_ascii_lowercase(); let folded_bytes = folded.as_bytes(); let mut matches: Vec<(usize, usize)> = Vec::new(); for term in &effective_terms { let pattern: String = term.to_ascii_lowercase(); let pbytes = pattern.as_bytes(); if pbytes.is_empty() { continue; } let mut start = 0; while start + pbytes.len() <= folded_bytes.len() { if let Some(rel) = memfind(&folded_bytes[start..], pbytes) { let at = start + rel; matches.push((at, at + pbytes.len())); // Advance past this match to avoid zero-width loops on // empty patterns (already guarded above) and to allow // overlapping matches of *different* terms in the next // outer-loop iteration. start = at + pbytes.len(); } else { break; } } } if matches.is_empty() { return truncate_head(text, opts.approx_chars, opts.ellipsis); } // Dedupe + sort so overlapping matches from different terms (e.g. // "rust" and "rustc") don't produce nested tags. matches.sort_by_key(|(a, _)| *a); matches = coalesce_overlapping(matches); // Pick the window. Start a third of the budget before the first match // so the hit isn't pinned to the left edge. Round both ends to char // boundaries so we never slice a multi-byte UTF-8 sequence. let pre_pad = opts.approx_chars / 3; let first_match_start = matches[0].0; let mut win_start = first_match_start.saturating_sub(pre_pad); let mut win_end = win_start + opts.approx_chars; if win_end > text.len() { win_end = text.len(); } while win_start > 0 && !text.is_char_boundary(win_start) { win_start -= 1; } while win_end < text.len() && !text.is_char_boundary(win_end) { win_end += 1; } // Expand the window to include the full end of any match that would // otherwise be cut off mid-tag. Keeps rendering sane when a long term // sits at the right edge of the budget. let last_match_in_window = matches.iter().rfind(|(s, _)| *s < win_end); if let Some((_, end)) = last_match_in_window { if *end > win_end { win_end = *end; while win_end < text.len() && !text.is_char_boundary(win_end) { win_end += 1; } } } // Render: walk matches that fall inside the window, splicing pre/post // around each. Prepend/append ellipsis when we've chopped off content. let mut out = String::with_capacity(win_end - win_start + 32); if win_start > 0 { out.push_str(opts.ellipsis); } let mut cursor = win_start; for (ms, me) in matches.iter() { if *me <= win_start || *ms >= win_end { continue; } // Clamp to the window. let ms = (*ms).max(win_start); let me = (*me).min(win_end); if ms > cursor { out.push_str(&text[cursor..ms]); } out.push_str(opts.pre); out.push_str(&text[ms..me]); out.push_str(opts.post); cursor = me; } if cursor < win_end { out.push_str(&text[cursor..win_end]); } if win_end < text.len() { out.push_str(opts.ellipsis); } out } /// Return the first `n` characters of `text`, suffixed with `ellipsis` if /// truncation actually happened. Respects UTF-8 char boundaries. fn truncate_head(text: &str, n: usize, ellipsis: &str) -> String { if text.len() <= n { return text.to_string(); } let mut cut = n; while cut > 0 && !text.is_char_boundary(cut) { cut -= 1; } let mut out = String::with_capacity(cut + ellipsis.len()); out.push_str(&text[..cut]); out.push_str(ellipsis); out } /// Merge adjacent / overlapping (start, end) ranges in place. Input must be /// sorted by start. fn coalesce_overlapping(mut v: Vec<(usize, usize)>) -> Vec<(usize, usize)> { if v.len() < 2 { return v; } let mut out = Vec::with_capacity(v.len()); let mut cur = v.remove(0); for next in v { if next.0 <= cur.1 { cur.1 = cur.1.max(next.1); } else { out.push(cur); cur = next; } } out.push(cur); out } /// Locate the first occurrence of `needle` in `hay`. A byte-level search; /// callers have already lowercased both sides so case is normalized. fn memfind(hay: &[u8], needle: &[u8]) -> Option { if needle.is_empty() || needle.len() > hay.len() { return None; } let first = needle[0]; let mut i = 0; while i + needle.len() <= hay.len() { if hay[i] == first && &hay[i..i + needle.len()] == needle { return Some(i); } i += 1; } None } #[cfg(test)] mod tests { use super::*; fn opts_small() -> Options<'static> { Options { pre: "", post: "", ellipsis: "…", approx_chars: 40, } } #[test] fn empty_text_returns_empty() { let s = render("", &["foo"], &Options::default()); assert_eq!(s, ""); } #[test] fn no_terms_returns_head_with_ellipsis_when_truncated() { let long = "abcdefghijklmnop".repeat(10); let s = render(&long, &[], &opts_small()); assert!(s.ends_with("…")); assert!(s.len() < long.len() + 4); } #[test] fn no_terms_untruncated_has_no_ellipsis() { let s = render("short text", &[], &opts_small()); assert_eq!(s, "short text"); } #[test] fn simple_highlight_wraps_matches() { let s = render("the quick brown fox", &["quick"], &opts_small()); assert!(s.contains("quick")); } #[test] fn case_insensitive_match() { let s = render("The QUICK brown fox", &["quick"], &opts_small()); assert!(s.contains("QUICK"), "got {s}"); } #[test] fn multiple_terms_both_highlighted() { let s = render( "the quick brown fox jumps over the lazy dog", &["quick", "lazy"], &Options { approx_chars: 200, ..Options::default() }, ); assert!(s.contains("quick"), "got {s}"); assert!(s.contains("lazy"), "got {s}"); } #[test] fn window_trims_with_ellipsis_on_both_sides() { let text = "prefix ".repeat(20) + "MATCH in middle " + &"suffix ".repeat(20); let s = render(&text, &["MATCH"], &opts_small()); assert!(s.starts_with("…"), "got {s}"); assert!(s.ends_with("…"), "got {s}"); assert!(s.contains("MATCH"), "got {s}"); } #[test] fn match_at_start_has_no_leading_ellipsis() { let s = render("MATCH right at the start of this paragraph", &["match"], &opts_small()); assert!(!s.starts_with("…"), "got {s}"); } #[test] fn no_match_on_tail_returns_head() { let text = "alpha beta gamma delta epsilon zeta eta theta iota kappa"; let s = render(text, &["nomatch"], &opts_small()); assert!(!s.contains("")); assert!(s.starts_with("alpha")); } #[test] fn overlapping_terms_do_not_nest_tags() { // Two terms matching the same span must coalesce. let s = render("the RUSTC compiler", &["rust", "rustc"], &opts_small()); assert!(s.contains("RUSTC"), "got {s}"); // No nested tags. assert!(!s.contains(""), "got {s}"); } #[test] fn utf8_boundary_safe_truncation() { // Insert multi-byte chars near the window boundary. let text = "café café café café café café café café café café"; let s = render(text, &["nope"], &opts_small()); // Returned string must be valid UTF-8 (push_str guarantees this only // if we sliced on char boundaries). Assert by round-trip. assert_eq!(s.as_str(), &s.clone()); } #[test] fn match_near_right_edge_is_fully_shown() { let prefix = "x".repeat(30); let text = format!("{}{}", prefix, "LONGMATCHTERMTEXT"); let s = render(&text, &["LONGMATCHTERMTEXT"], &opts_small()); assert!(s.contains("LONGMATCHTERMTEXT"), "got {s}"); } #[test] fn empty_query_term_ignored() { let s = render("hello world", &["", "world"], &opts_small()); assert!(s.contains("world"), "got {s}"); } }