Skip to main content

uqa_analysis/
highlight.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Search-result highlighting.
8//!
9//! Highlighting operates in two phases:
10//!
11//! 1. Build a set of *analyzed* query terms (lower-cased + stemmed +
12//!    char/token filtered through the same [`Analyzer`] pipeline used
13//!    for indexing). When the caller does not supply an analyzer, the
14//!    fallback is a plain ASCII lower-case fold so the highlighter
15//!    still works as a stand-alone helper.
16//! 2. Walk the source text with a `\w+` tokenizer; every token whose
17//!    analyzed form intersects the query-term set becomes a highlight
18//!    span. Spans are wrapped with the configured `start_tag` /
19//!    `end_tag`, or projected into a fragment view when
20//!    `max_fragments > 0`.
21//!
22//! The matcher operates on character offsets rather than byte offsets, so
23//! highlight spans align correctly in CJK and other multibyte text.
24//!
25//! ```rust
26//! use uqa_analysis::{highlight, HighlightOptions};
27//!
28//! let out = highlight(
29//!     "the quick brown fox jumps over the lazy dog",
30//!     &["fox".into(), "dog".into()],
31//!     None,
32//!     &HighlightOptions::default(),
33//! ).unwrap();
34//! assert!(out.contains("<b>fox</b>"));
35//! assert!(out.contains("<b>dog</b>"));
36//! ```
37
38#![allow(
39    clippy::similar_names,
40    clippy::explicit_counter_loop,
41    clippy::needless_range_loop,
42    clippy::stable_sort_primitive,
43    clippy::manual_midpoint,
44    clippy::map_unwrap_or
45)]
46
47use std::collections::BTreeSet;
48
49use regex::Regex;
50
51use crate::analyzer::Analyzer;
52use crate::error::AnalysisResult;
53
54/// Per-call configuration. Defaults use `<b>` / `</b>` tags, a full-text
55/// highlight with no fragment cap, and
56/// 150-char fragments when `max_fragments > 0`.
57#[derive(Debug, Clone)]
58pub struct HighlightOptions {
59    pub start_tag: String,
60    pub end_tag: String,
61    /// `0` keeps the whole text and just wraps matches; `> 0`
62    /// extracts that many fragments centred on the densest match
63    /// clusters.
64    pub max_fragments: usize,
65    pub fragment_size: usize,
66}
67
68impl Default for HighlightOptions {
69    fn default() -> Self {
70        Self {
71            start_tag: "<b>".into(),
72            end_tag: "</b>".into(),
73            max_fragments: 0,
74            fragment_size: 150,
75        }
76    }
77}
78
79fn word_regex() -> AnalysisResult<&'static Regex> {
80    use std::sync::OnceLock;
81    static RE: OnceLock<Result<Regex, String>> = OnceLock::new();
82    RE.get_or_init(|| Regex::new(r"\w+").map_err(|error| error.to_string()))
83        .as_ref()
84        .map_err(|message| crate::error::AnalysisError::BuiltInRegex {
85            component: "highlighter word scanner",
86            message: message.clone(),
87        })
88}
89
90/// Wrap matched query terms in `text` with the configured tags.
91///
92/// `analyzer` is optional: when supplied, both the query terms and
93/// the source text are run through the same pipeline so stemming /
94/// lower-casing / accent folding agree. When omitted, ASCII
95/// lower-case is used instead.
96pub fn highlight(
97    text: &str,
98    query_terms: &[String],
99    analyzer: Option<&Analyzer>,
100    opts: &HighlightOptions,
101) -> AnalysisResult<String> {
102    if text.is_empty() || query_terms.is_empty() {
103        return Ok(text.to_string());
104    }
105
106    let analyzed: BTreeSet<String> = match analyzer {
107        Some(a) => {
108            let mut analyzed = BTreeSet::new();
109            for query_term in query_terms {
110                analyzed.extend(a.analyze(query_term)?);
111            }
112            analyzed
113        }
114        None => query_terms.iter().map(|qt| qt.to_lowercase()).collect(),
115    };
116
117    if analyzed.is_empty() {
118        return Ok(text.to_string());
119    }
120
121    // Walk the text once, collecting (char_start, char_end) spans
122    // for every token whose analyzed form intersects the query-term
123    // set. Char offsets are tracked alongside the regex byte offsets
124    // so the highlight wrappers slice correctly on multi-byte text.
125    //
126    // `byte_to_char[byte_idx]` is the char count of the prefix
127    // ending at `byte_idx`. The last entry maps `text.len()` to the
128    // total char count so a regex match end past the final byte
129    // still maps cleanly.
130    let total_chars = text.chars().count();
131    let mut byte_to_char: Vec<usize> = vec![0usize; text.len() + 1];
132    {
133        let mut last_byte = 0usize;
134        let mut last_char = 0usize;
135        for (byte_idx, _) in text.char_indices() {
136            for slot in last_byte..=byte_idx {
137                byte_to_char[slot] = last_char;
138            }
139            last_byte = byte_idx + 1;
140            last_char += 1;
141        }
142        for slot in last_byte..byte_to_char.len() {
143            byte_to_char[slot] = total_chars;
144        }
145    }
146    let to_char = |byte: usize| -> usize {
147        if byte >= byte_to_char.len() {
148            total_chars
149        } else {
150            byte_to_char[byte]
151        }
152    };
153
154    let mut match_spans: Vec<(usize, usize)> = Vec::new();
155    for m in word_regex()?.find_iter(text) {
156        let token = m.as_str();
157        let hit = match analyzer {
158            Some(a) => {
159                let toks = a.analyze(token)?;
160                !toks.is_empty() && toks.iter().any(|t| analyzed.contains(t))
161            }
162            None => analyzed.contains(&token.to_lowercase()),
163        };
164        if hit {
165            match_spans.push((to_char(m.start()), to_char(m.end())));
166        }
167    }
168
169    if match_spans.is_empty() {
170        if opts.max_fragments > 0 {
171            return Ok(ellipsis_prefix(text, opts.fragment_size));
172        }
173        return Ok(text.to_string());
174    }
175
176    let highlighted = if opts.max_fragments > 0 {
177        build_fragments(text, &match_spans, opts)
178    } else {
179        wrap_full(text, &match_spans, &opts.start_tag, &opts.end_tag)
180    };
181    Ok(highlighted)
182}
183
184fn ellipsis_prefix(text: &str, fragment_size: usize) -> String {
185    let total = text.chars().count();
186    let take = fragment_size.min(total);
187    let mut out = String::new();
188    out.extend(text.chars().take(take));
189    if take < total {
190        out.push_str("...");
191    }
192    out
193}
194
195/// Splice `start_tag` / `end_tag` into `text` around every char-offset
196/// span in `match_spans`. Spans are assumed to be in left-to-right
197/// order.
198fn wrap_full(text: &str, match_spans: &[(usize, usize)], start_tag: &str, end_tag: &str) -> String {
199    // Convert char offsets back to byte boundaries via a single
200    // pass over the source.
201    let char_to_byte: Vec<usize> = {
202        let mut v: Vec<usize> = text.char_indices().map(|(b, _)| b).collect();
203        v.push(text.len());
204        v
205    };
206    let to_byte = |c: usize| -> usize {
207        if c >= char_to_byte.len() {
208            text.len()
209        } else {
210            char_to_byte[c]
211        }
212    };
213    let mut out = String::with_capacity(text.len());
214    let mut prev_byte = 0usize;
215    for (cs, ce) in match_spans {
216        let bs = to_byte(*cs);
217        let be = to_byte(*ce);
218        out.push_str(&text[prev_byte..bs]);
219        out.push_str(start_tag);
220        out.push_str(&text[bs..be]);
221        out.push_str(end_tag);
222        prev_byte = be;
223    }
224    out.push_str(&text[prev_byte..]);
225    out
226}
227
228fn build_fragments(text: &str, match_spans: &[(usize, usize)], opts: &HighlightOptions) -> String {
229    let half = (opts.fragment_size / 2).max(1);
230    let total_chars = text.chars().count();
231
232    // Group nearby matches into clusters: a span joins the current
233    // cluster if it starts within `half` characters of the previous
234    // cluster's right edge.
235    let mut clusters: Vec<Vec<(usize, usize)>> = Vec::new();
236    let mut current: Vec<(usize, usize)> = Vec::new();
237    for &span in match_spans {
238        if current.is_empty() {
239            current.push(span);
240            continue;
241        }
242        let last_end = current.last().map_or(span.0, |previous| previous.1);
243        if span.0.saturating_sub(last_end) > half {
244            clusters.push(std::mem::take(&mut current));
245            current.push(span);
246        } else {
247            current.push(span);
248        }
249    }
250    if !current.is_empty() {
251        clusters.push(current);
252    }
253
254    // Pick the densest clusters, then put the survivors back in
255    // textual order so the resulting string reads left-to-right.
256    clusters.sort_by_key(|c| std::cmp::Reverse(c.len()));
257    let mut selected: Vec<Vec<(usize, usize)>> =
258        clusters.into_iter().take(opts.max_fragments).collect();
259    selected.sort_by_key(|c| c[0].0);
260
261    // Convert the picked clusters into bounded text windows.
262    let chars: Vec<(usize, char)> = text.char_indices().collect();
263    let chars_len = chars.len();
264    let char_at = |idx: usize| -> usize {
265        if idx >= chars_len {
266            text.len()
267        } else {
268            chars[idx].0
269        }
270    };
271    let char_range_to_string = |start: usize, end: usize| -> String {
272        let bs = char_at(start);
273        let be = if end >= chars_len {
274            text.len()
275        } else {
276            chars[end].0
277        };
278        text[bs..be].to_string()
279    };
280
281    let mut fragments: Vec<String> = Vec::new();
282    for cluster in selected {
283        let (Some(first), Some(last)) = (cluster.first(), cluster.last()) else {
284            continue;
285        };
286        let centre = first.0 + last.1.saturating_sub(first.0) / 2;
287        let mut frag_start = centre.saturating_sub(half);
288        let mut frag_end = (centre + half).min(total_chars);
289
290        // Snap to nearest space boundary so we do not bisect a word.
291        if frag_start > 0 {
292            let mut probe = frag_start;
293            let limit = (frag_start + 30).min(total_chars);
294            while probe < limit {
295                if chars
296                    .get(probe)
297                    .map(|(_, c)| c.is_whitespace())
298                    .unwrap_or(false)
299                {
300                    frag_start = probe + 1;
301                    break;
302                }
303                probe += 1;
304            }
305        }
306        if frag_end < total_chars {
307            let lower = frag_end.saturating_sub(30);
308            let mut probe = frag_end;
309            while probe > lower {
310                if chars
311                    .get(probe - 1)
312                    .map(|(_, c)| c.is_whitespace())
313                    .unwrap_or(false)
314                {
315                    frag_end = probe - 1;
316                    break;
317                }
318                probe -= 1;
319            }
320        }
321
322        let frag_text = char_range_to_string(frag_start, frag_end);
323        let local_spans: Vec<(usize, usize)> = cluster
324            .iter()
325            .filter(|(s, e)| *s >= frag_start && *e <= frag_end)
326            .map(|(s, e)| (s - frag_start, e - frag_start))
327            .collect();
328        let highlighted = wrap_full(&frag_text, &local_spans, &opts.start_tag, &opts.end_tag);
329
330        let prefix = if frag_start > 0 { "..." } else { "" };
331        let suffix = if frag_end < total_chars { "..." } else { "" };
332        fragments.push(format!("{prefix}{highlighted}{suffix}"));
333    }
334    fragments.join(" ")
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn wraps_matched_terms_with_default_tags() {
343        let out = highlight(
344            "the quick brown fox",
345            &["fox".into(), "quick".into()],
346            None,
347            &HighlightOptions::default(),
348        )
349        .unwrap();
350        assert_eq!(out, "the <b>quick</b> brown <b>fox</b>");
351    }
352
353    #[test]
354    fn returns_text_unchanged_when_no_query_terms() {
355        let out = highlight("untouched", &[], None, &HighlightOptions::default()).unwrap();
356        assert_eq!(out, "untouched");
357    }
358
359    #[test]
360    fn returns_text_unchanged_when_no_matches() {
361        let out = highlight(
362            "no hits here",
363            &["banana".into()],
364            None,
365            &HighlightOptions::default(),
366        )
367        .unwrap();
368        assert_eq!(out, "no hits here");
369    }
370
371    #[test]
372    fn fragment_view_emits_ellipsis_around_match() {
373        let text = "abcdefghij ".repeat(40); // 440 chars, no matches
374        let mut text = text;
375        text.push_str("the quick brown fox jumps over a thing ");
376        text.push_str(&"abcdefghij ".repeat(40));
377
378        let opts = HighlightOptions {
379            max_fragments: 1,
380            fragment_size: 60,
381            ..Default::default()
382        };
383        let out = highlight(&text, &["fox".into()], None, &opts).unwrap();
384        assert!(out.contains("<b>fox</b>"));
385        assert!(out.starts_with("..."));
386        assert!(out.ends_with("..."));
387    }
388
389    #[test]
390    fn fragment_view_emits_ellipsis_when_no_match_found() {
391        let text = "a".repeat(500);
392        let opts = HighlightOptions {
393            max_fragments: 1,
394            fragment_size: 30,
395            ..Default::default()
396        };
397        let out = highlight(&text, &["zzz".into()], None, &opts).unwrap();
398        assert!(out.ends_with("..."));
399        assert_eq!(out.chars().take_while(|c| *c == 'a').count(), 30);
400    }
401
402    #[test]
403    fn analyzer_pipeline_matches_stemmed_form() {
404        // Standard analyzer lower-cases and stems through Porter.
405        let an = crate::analyzer::standard_analyzer("english");
406        let out = highlight(
407            "running quickly",
408            &["runs".into()],
409            Some(&an),
410            &HighlightOptions::default(),
411        )
412        .unwrap();
413        assert!(out.contains("<b>running</b>"), "got: {out}");
414    }
415
416    #[test]
417    fn cjk_character_offsets_round_trip() {
418        // A multi-byte text with the matched token in the middle.
419        let text = "안녕 hello 세계";
420        let out = highlight(text, &["hello".into()], None, &HighlightOptions::default()).unwrap();
421        assert_eq!(out, "안녕 <b>hello</b> 세계");
422    }
423
424    #[test]
425    fn analyzer_failure_is_returned_to_highlight_caller() {
426        let analyzer = Analyzer::new(
427            crate::Tokenizer::Pattern {
428                pattern: "[".into(),
429            },
430            Vec::new(),
431            Vec::new(),
432        );
433        let error = highlight(
434            "searchable text",
435            &["searchable".into()],
436            Some(&analyzer),
437            &HighlightOptions::default(),
438        )
439        .unwrap_err();
440        assert!(matches!(
441            error,
442            crate::AnalysisError::InvalidRegex {
443                component: "pattern tokenizer",
444                ..
445            }
446        ));
447    }
448}