1#![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#[derive(Debug, Clone)]
58pub struct HighlightOptions {
59 pub start_tag: String,
60 pub end_tag: String,
61 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
90pub 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 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
195fn wrap_full(text: &str, match_spans: &[(usize, usize)], start_tag: &str, end_tag: &str) -> String {
199 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 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 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 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 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); 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 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 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}