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;