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//! Explicit analyzers process the complete source and query inputs, then highlight matching lossless terms at their original source offsets. Overlapping source spans are merged when rendering, so compound alternatives and rewritten terms do not produce nested markers.
10//!
11//! Without an analyzer, the helper scans Unicode words and compares their lower-case forms. The word-scanning entry point also preserves the existing SQL highlighting behavior.
12//!
13//! ```rust
14//! use uqa_analysis::{highlight, HighlightOptions};
15//!
16//! let out = highlight(
17//!     "the quick brown fox jumps over the lazy dog",
18//!     &["fox".into(), "dog".into()],
19//!     None,
20//!     &HighlightOptions::default(),
21//! ).unwrap();
22//! assert!(out.contains("<b>fox</b>"));
23//! assert!(out.contains("<b>dog</b>"));
24//! ```
25
26use crate::{AnalysisResult, Analyzer};
27use uqa_core::memory::{Budgeted, MemoryBudget};
28
29/// Per-call configuration. Defaults use `<b>` / `</b>` tags, a full-text
30/// highlight with no fragment cap, and
31/// 150-char fragments when `max_fragments > 0`.
32#[derive(Debug, Clone)]
33pub struct HighlightOptions {
34    pub start_tag: String,
35    pub end_tag: String,
36    /// `0` keeps the whole text and just wraps matches; `> 0`
37    /// extracts that many fragments centred on the densest match
38    /// clusters.
39    pub max_fragments: usize,
40    pub fragment_size: usize,
41}
42
43impl Default for HighlightOptions {
44    fn default() -> Self {
45        Self {
46            start_tag: "<b>".into(),
47            end_tag: "</b>".into(),
48            max_fragments: 0,
49            fragment_size: 150,
50        }
51    }
52}
53
54mod render;
55mod rich;
56mod terms;
57mod words;
58pub use rich::{highlight_compiled, highlight_compiled_budgeted};
59pub use words::highlight_words_budgeted;
60
61/// Highlight complete source spans with an explicit analyzer, or lowercase word matches when omitted.
62pub fn highlight(
63    text: &str,
64    query_terms: &[String],
65    analyzer: Option<&Analyzer>,
66    opts: &HighlightOptions,
67) -> AnalysisResult<String> {
68    Ok(highlight_budgeted(
69        text,
70        query_terms,
71        analyzer,
72        opts,
73        &MemoryBudget::new(usize::MAX),
74        || Ok(()),
75    )?
76    .into_parts()
77    .0)
78}
79
80/// Highlight with one allowance for analysis, matching, fragment selection, and returned text.
81///
82/// The caller owns input text, query strings, options, and immutable preparation resources. Owned runtime buffers retain their reservations until destruction. Allocation-limit and callback failures return no partial string. An explicit analyzer is compiled once; configured library searches execute between callback checks.
83///
84/// ```
85/// use uqa_analysis::{highlight_budgeted, HighlightOptions};
86/// use uqa_core::memory::MemoryBudget;
87/// let budget = MemoryBudget::new(64 * 1024);
88/// let result = highlight_budgeted("the quick fox", &["FOX".into()], None, &HighlightOptions::default(), &budget, || Ok(()))?;
89/// assert_eq!(&**result, "the quick <b>fox</b>");
90/// assert_eq!(budget.used(), result.reserved_bytes());
91/// drop(result);
92/// assert_eq!(budget.used(), 0);
93/// # Ok::<(), uqa_analysis::AnalysisError>(())
94/// ```
95pub fn highlight_budgeted(
96    text: &str,
97    query_terms: &[String],
98    analyzer: Option<&Analyzer>,
99    opts: &HighlightOptions,
100    budget: &MemoryBudget,
101    mut poll: impl FnMut() -> AnalysisResult<()>,
102) -> AnalysisResult<Budgeted<String>> {
103    poll()?;
104    if text.is_empty() || query_terms.is_empty() {
105        return crate::allocation::copy_text(text, budget, &mut poll);
106    }
107    if let Some(analyzer) = analyzer {
108        let compiled = analyzer.compile()?;
109        highlight_compiled_budgeted(text, query_terms, &compiled, opts, budget, poll)
110    } else {
111        highlight_words_budgeted(
112            text,
113            query_terms.iter().map(String::as_str),
114            None,
115            opts,
116            budget,
117            poll,
118        )
119    }
120}
121
122/// Highlight independently analyzed source words, retaining the legacy word-scanning contract.
123pub fn highlight_words(
124    text: &str,
125    query_terms: &[String],
126    analyzer: Option<&Analyzer>,
127    opts: &HighlightOptions,
128) -> AnalysisResult<String> {
129    Ok(highlight_words_budgeted(
130        text,
131        query_terms.iter().map(String::as_str),
132        analyzer,
133        opts,
134        &MemoryBudget::new(usize::MAX),
135        || Ok(()),
136    )?
137    .into_parts()
138    .0)
139}
140
141#[cfg(test)]
142mod tests;