Skip to main content

uqa_analysis/highlight/
rich.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Match complete-source token spans under one retained compiled revision.
8
9use super::{
10    render::{merge_spans, render, Span},
11    terms::Terms,
12    HighlightOptions,
13};
14use crate::{AnalysisError, AnalysisResult, CompiledAnalyzer};
15use uqa_core::memory::{Budgeted, BudgetedVec, MemoryBudget};
16
17/// Highlight original source spans using one immutable analyzer for the source and each complete query input.
18///
19/// Matching preserves raw UTF-16 term identity. Markers use scalar-covering UTF-8 offsets, and overlapping spans merge only for presentation. Fragment sizes are targets; a selected match is always kept whole.
20pub fn highlight_compiled(
21    text: &str,
22    query_inputs: &[String],
23    analyzer: &CompiledAnalyzer,
24    opts: &HighlightOptions,
25) -> AnalysisResult<String> {
26    Ok(highlight_compiled_budgeted(
27        text,
28        query_inputs,
29        analyzer,
30        opts,
31        &MemoryBudget::new(usize::MAX),
32        || Ok(()),
33    )?
34    .into_parts()
35    .0)
36}
37
38/// Retain one runtime allowance through complete-source analysis and source-span rendering.
39///
40/// Query terms keep their moved token buffers while unused morphology/source state is released. Matching preserves raw UTF-16 identity, density ties preserve source order, and a selected match remains complete inside its fragment. Errors release partial output without releasing another allocation owner's reservations.
41pub fn highlight_compiled_budgeted(
42    text: &str,
43    query_inputs: &[String],
44    analyzer: &CompiledAnalyzer,
45    opts: &HighlightOptions,
46    budget: &MemoryBudget,
47    mut poll: impl FnMut() -> AnalysisResult<()>,
48) -> AnalysisResult<Budgeted<String>> {
49    poll()?;
50    if text.is_empty() || query_inputs.is_empty() {
51        return crate::allocation::copy_text(text, budget, &mut poll);
52    }
53    let mut terms = Terms::new(budget);
54    for query in query_inputs {
55        poll()?;
56        terms.append(
57            analyzer.analyze_tokens_budgeted(query, budget, &mut poll)?,
58            false,
59            &mut poll,
60        )?;
61    }
62    if terms.is_empty() {
63        return crate::allocation::copy_text(text, budget, &mut poll);
64    }
65    terms.sort_unique(&mut poll)?;
66    let source = analyzer.analyze_tokens_budgeted(text, budget, &mut poll)?;
67    let mut spans = BudgetedVec::new(budget);
68    for token in source.tokens() {
69        poll()?;
70        if !terms.contains(token.term(), &mut poll)? {
71            continue;
72        }
73        let range = &token
74            .offsets()
75            .ok_or(AnalysisError::MissingTokenOffsets)?
76            .utf8;
77        if range.start > range.end {
78            return Err(AnalysisError::InvalidTextSpan {
79                start: range.start,
80                end: range.end,
81            });
82        }
83        for offset in [range.start, range.end] {
84            if !text.is_char_boundary(offset) {
85                return Err(AnalysisError::InvalidTextOffset {
86                    coordinate: "UTF-8",
87                    offset,
88                    length: text.len(),
89                });
90            }
91        }
92        if !range.is_empty() {
93            spans.push(Span::new(range.start, range.end))?;
94        }
95    }
96    drop(source);
97    drop(terms);
98    merge_spans(&mut spans, &mut poll)?;
99    render(text, spans, opts, budget, &mut poll)
100}