///|
/// Matching policy used while reconstructing highlights from analyzed text.
pub(all) enum HighlightMode {
Terms
Phrase
} derive(Eq, @debug.Debug)
///|
/// One match in source-text UTF-8 byte offsets.
pub(all) struct HighlightSpan {
start_offset : Int
end_offset : Int
} derive(Eq, @debug.Debug)
///|
/// One UTF-8-safe source fragment. Match offsets remain relative to the full
/// source value so callers can map them back without losing provenance.
pub(all) struct HighlightFragment {
text : String
start_offset : Int
end_offset : Int
matches : ReadOnlyArray[HighlightSpan]
prefix_omitted : Bool
suffix_omitted : Bool
} derive(Eq, @debug.Debug)
///|
/// Configuration for one highlight operation.
pub struct HighlightOptions {
mode : HighlightMode
max_chars : Int
}
///|
/// Creates term-oriented highlighting with a 160-Unicode-scalar fragment.
pub fn HighlightOptions::new() -> HighlightOptions {
{ mode: Terms, max_chars: 160 }
}
///|
pub fn HighlightOptions::with_mode(
self : HighlightOptions,
mode : HighlightMode,
) -> HighlightOptions {
{ mode, max_chars: self.max_chars }
}
///|
pub fn HighlightOptions::with_max_chars(
self : HighlightOptions,
max_chars : Int,
) -> HighlightOptions {
guard max_chars > 0 else { abort("highlight max_chars must be positive") }
{ mode: self.mode, max_chars }
}
///|
/// Analyzer-driven highlighter. Like QueryParser, it snapshots the tokenizer
/// registry so subsequent application mutations cannot change its behavior.
pub struct Highlighter {
parser : QueryParser
}
///|
pub fn Highlighter::new(
schema : Schema,
tokenizers : TokenizerManager,
) -> Highlighter {
{ parser: QueryParser::new(schema, tokenizers) }
}
///|
fn highlight_utf8_width(ch : Char) -> Int {
let code_point = ch.to_int()
if code_point <= 0x7F {
1
} else if code_point <= 0x7FF {
2
} else if code_point <= 0xFFFF {
3
} else {
4
}
}
///|
fn highlight_boundaries(text : String) -> Array[Int] {
let boundaries : Array[Int] = [0]
let mut byte_offset = 0
for ch in text {
byte_offset += highlight_utf8_width(ch)
boundaries.push(byte_offset)
}
boundaries
}
///|
fn highlight_boundary_index(boundaries : Array[Int], byte_offset : Int) -> Int {
match boundaries.search_by(boundary => boundary == byte_offset) {
Some(index) => index
None => abort("highlight offset is not on a UTF-8 boundary")
}
}
///|
fn validate_highlight_tokens(
text : String,
tokens : Array[Token],
) -> Unit raise AnalysisError {
ignore(@analysis.TokenGraph::from_tokens(tokens))
let boundaries = highlight_boundaries(text)
let byte_length = boundaries[boundaries.length() - 1]
for token in tokens {
guard token.end_offset <= byte_length else {
raise AnalysisError::InvalidTokenGraph(
"token offsets exceed the analyzed UTF-8 source",
)
}
guard boundaries.search_by(offset => offset == token.start_offset)
is Some(_) &&
boundaries.search_by(offset => offset == token.end_offset) is Some(_) else {
raise AnalysisError::InvalidTokenGraph(
"token offsets must be UTF-8 boundaries",
)
}
}
}
///|
fn highlight_slice(
text : String,
start_offset : Int,
end_offset : Int,
) -> String {
let bytes = @utf8.encode(text)
guard start_offset >= 0 &&
end_offset >= start_offset &&
end_offset <= bytes.length() else {
abort("invalid highlight UTF-8 range")
}
@utf8.decode(bytes.view(start=start_offset, end=end_offset)) catch {
_ => abort("highlight range split a UTF-8 sequence")
}
}
///|
fn compare_highlight_spans(left : HighlightSpan, right : HighlightSpan) -> Int {
if left.start_offset != right.start_offset {
left.start_offset.compare(right.start_offset)
} else {
left.end_offset.compare(right.end_offset)
}
}
///|
fn merged_highlight_spans(
raw_spans : Array[HighlightSpan],
) -> Array[HighlightSpan] {
raw_spans.sort_by(compare_highlight_spans)
let merged : Array[HighlightSpan] = []
for span in raw_spans {
guard span.start_offset >= 0 && span.end_offset >= span.start_offset else {
abort("invalid analyzed token offsets")
}
if merged.length() == 0 {
merged.push(span)
continue
}
let last_index = merged.length() - 1
let last = merged[last_index]
if span.start_offset <= last.end_offset {
merged[last_index] = {
start_offset: last.start_offset,
end_offset: if span.end_offset > last.end_offset {
span.end_offset
} else {
last.end_offset
},
}
} else {
merged.push(span)
}
}
merged
}
///|
fn term_highlight_spans(
query_tokens : Array[Token],
source_tokens : Array[Token],
) -> Array[HighlightSpan] {
let terms : Array[String] = []
for token in query_tokens {
if terms.search_by(text => text == token.text) is None {
terms.push(token.text)
}
}
let spans : Array[HighlightSpan] = []
for token in source_tokens {
if terms.search_by(text => text == token.text) is Some(_) {
spans.push({
start_offset: token.start_offset,
end_offset: token.end_offset,
})
}
}
merged_highlight_spans(spans)
}
///|
fn source_token_at(
tokens : ReadOnlyArray[Token],
text : String,
position : Int,
) -> Token? {
match
tokens.search_by(token => token.position == position && token.text == text) {
Some(index) => Some(tokens[index])
None => None
}
}
///|
fn phrase_highlight_spans(
query_tokens : Array[Token],
source_tokens : Array[Token],
) -> Array[HighlightSpan] raise AnalysisError {
let paths = @analysis.TokenGraph::from_tokens(query_tokens).finite_strings()
let source_paths = @analysis.TokenGraph::from_tokens(source_tokens).finite_strings()
let spans : Array[HighlightSpan] = []
for path in paths {
let path_tokens = path.tokens()
if path_tokens.length() == 0 {
continue
}
let query_base = path_tokens[0].position
for source_path in source_paths {
let source_path_tokens = source_path.tokens()
for source_start in source_path_tokens {
if source_start.text != path_tokens[0].text {
continue
}
let mut start_offset = source_start.start_offset
let mut end_offset = source_start.end_offset
let mut matched = true
for query_index in 1.. {
if source_token.start_offset < start_offset {
start_offset = source_token.start_offset
}
if source_token.end_offset > end_offset {
end_offset = source_token.end_offset
}
}
None => {
matched = false
break
}
}
}
if matched {
spans.push({ start_offset, end_offset })
}
}
}
}
merged_highlight_spans(spans)
}
///|
fn highlight_window_for_span(
span : HighlightSpan,
boundaries : Array[Int],
max_chars : Int,
) -> (Int, Int) {
let total_chars = boundaries.length() - 1
let span_start = highlight_boundary_index(boundaries, span.start_offset)
let span_end = highlight_boundary_index(boundaries, span.end_offset)
let span_chars = span_end - span_start
if span_chars >= max_chars {
return (span_start, span_end)
}
let mut start = span_start - (max_chars - span_chars) / 2
if start < 0 {
start = 0
}
let mut end = start + max_chars
if end < span_end {
end = span_end
start = end - max_chars
}
if end > total_chars {
end = total_chars
start = end - max_chars
if start < 0 {
start = 0
}
}
(start, end)
}
///|
fn build_highlight_fragment(
source_text : String,
spans : Array[HighlightSpan],
max_chars : Int,
) -> HighlightFragment? {
if spans.length() == 0 {
return None
}
let boundaries = highlight_boundaries(source_text)
let total_chars = boundaries.length() - 1
let mut best_start_char = 0
let mut best_end_char = total_chars
if total_chars > max_chars {
let mut best_count = -1
for anchor in spans {
let (candidate_start, candidate_end) = highlight_window_for_span(
anchor, boundaries, max_chars,
)
let candidate_start_offset = boundaries[candidate_start]
let candidate_end_offset = boundaries[candidate_end]
let mut count = 0
for span in spans {
if span.start_offset >= candidate_start_offset &&
span.end_offset <= candidate_end_offset {
count += 1
}
}
if count > best_count ||
(count == best_count && candidate_start < best_start_char) {
best_count = count
best_start_char = candidate_start
best_end_char = candidate_end
}
}
}
let start_offset = boundaries[best_start_char]
let end_offset = boundaries[best_end_char]
let selected : Array[HighlightSpan] = []
for span in spans {
if span.start_offset >= start_offset && span.end_offset <= end_offset {
selected.push(span)
}
}
Some({
text: highlight_slice(source_text, start_offset, end_offset),
start_offset,
end_offset,
matches: ReadOnlyArray::from_array(selected),
prefix_omitted: start_offset > 0,
suffix_omitted: end_offset < @utf8.encode(source_text).length(),
})
}
///|
/// Re-analyzes query and source text using the field's configured tokenizer,
/// then returns one deterministic best fragment when at least one match exists.
pub fn Highlighter::highlight(
self : Highlighter,
field_id : FieldId,
query_text : String,
source_text : String,
options : HighlightOptions,
) -> HighlightFragment? raise AnalysisError {
let query_tokens = self.parser.analyze(field_id, query_text)
guard query_tokens.length() > 0 else { raise AnalysisError::EmptyQuery }
let source_tokens = self.parser.analyze(field_id, source_text)
validate_highlight_tokens(query_text, query_tokens)
validate_highlight_tokens(source_text, source_tokens)
let spans = match options.mode {
Terms => term_highlight_spans(query_tokens, source_tokens)
Phrase => phrase_highlight_spans(query_tokens, source_tokens)
}
build_highlight_fragment(source_text, spans, options.max_chars)
}
///|
/// Builds a fragment directly from persisted posting offsets, avoiding source
/// re-analysis. `value_index` selects one value of a multi-valued text field.
pub fn Highlighter::highlight_postings(
_self : Highlighter,
segment : Segment,
doc_id : DocId,
field_id : FieldId,
terms : ReadOnlyArray[String],
source_text : String,
value_index? : Int = 0,
options? : HighlightOptions = HighlightOptions::new(),
) -> HighlightFragment? {
let spans : Array[HighlightSpan] = []
for text in terms {
let postings = segment.postings_for(Term::new(field_id, text))
match postings.search_by(posting => posting.doc_id == doc_id) {
Some(posting_index) =>
for occurrence in postings[posting_index].occurrences() {
if occurrence.value_index == value_index {
spans.push({
start_offset: occurrence.start_offset,
end_offset: occurrence.end_offset,
})
}
}
None => ()
}
}
let merged = merged_highlight_spans(spans)
// Persisted offsets are checked against the original stored value before
// fragment reconstruction, catching analyzer/offset incompatibilities.
let boundaries = highlight_boundaries(source_text)
for span in merged {
ignore(highlight_boundary_index(boundaries, span.start_offset))
ignore(highlight_boundary_index(boundaries, span.end_offset))
}
build_highlight_fragment(source_text, merged, options.max_chars)
}
///|
fn add_unique_highlight_term(terms : Array[String], text : String) -> Unit {
if terms.search_by(existing => existing == text) is None {
terms.push(text)
}
}
///|
fn QueryStringParser::collect_highlight_terms(
self : QueryStringParser,
ast : UserQueryAst,
node_index : Int,
target_field : FieldId,
inherited_field : FieldId?,
segment : Segment,
terms : Array[String],
) -> Unit {
let node = ast.nodes[node_index]
match node.kind {
Literal(text, mode) => {
if inherited_field is Some(field_id) && field_id != target_field {
return
}
match mode {
RegexMode => {
let regex = @string.Regex("^(?:" + text + ")$") catch { _ => return }
for term in segment.terms() {
if term.field_id == target_field &&
regex.execute(term.text) is Some(_) {
add_unique_highlight_term(terms, term.text)
if terms.length() >= 256 {
break
}
}
}
}
Plain => {
let (literal, has_wildcard, _) = query_pattern_details(text)
if has_wildcard {
for term in segment.terms() {
if term.field_id == target_field &&
wildcard_matches(text, term.text) {
add_unique_highlight_term(terms, term.text)
if terms.length() >= 256 {
break
}
}
}
} else {
let parser = QueryParser::new(self.schema, self.tokenizers)
let tokens = parser.analyze(target_field, literal) catch { _ => [] }
for token in tokens {
add_unique_highlight_term(terms, token.text)
}
}
}
PhraseMode => {
let parser = QueryParser::new(self.schema, self.tokenizers)
let tokens = parser.analyze(target_field, text) catch { _ => [] }
for token in tokens {
add_unique_highlight_term(terms, token.text)
}
}
}
}
Range(_, _, _, _) => ()
FieldScope(name, child, _, _) =>
match self.schema.field(name) {
Some(field_id) =>
self.collect_highlight_terms(
ast,
child,
target_field,
Some(field_id),
segment,
terms,
)
None => ()
}
Conjunction(children) | Disjunction(children) =>
for child in children {
self.collect_highlight_terms(
ast, child, target_field, inherited_field, segment, terms,
)
}
Required(child) | Boost(child, _) | Group(child) | Proximity(child, _) =>
self.collect_highlight_terms(
ast, child, target_field, inherited_field, segment, terms,
)
Prohibited(_) => ()
}
}
///|
/// Query-string-aware persisted-offset highlighting. The AST controls field
/// scope and excludes prohibited clauses; wildcard/regex literals are expanded
/// against the exact segment dictionary before offsets are read.
pub fn QueryStringParser::highlight_query(
self : QueryStringParser,
highlighter : Highlighter,
segment : Segment,
doc_id : DocId,
field_id : FieldId,
query_text : String,
source_text : String,
value_index? : Int = 0,
options? : HighlightOptions = HighlightOptions::new(),
) -> HighlightFragment? raise QueryStringError {
let ast = self.parse_ast(query_text)
let terms : Array[String] = []
self.collect_highlight_terms(ast, ast.root, field_id, None, segment, terms)
highlighter.highlight_postings(
segment,
doc_id,
field_id,
ReadOnlyArray::from_array(terms),
source_text,
value_index~,
options~,
)
}
///|
/// Inserts caller-provided markers around matches without applying an HTML or
/// terminal escaping policy. Callers remain responsible for output escaping.
pub fn HighlightFragment::render(
self : HighlightFragment,
before : String,
after : String,
) -> String {
let mut output = ""
let mut cursor = 0
for span in self.matches {
let start = span.start_offset - self.start_offset
let end = span.end_offset - self.start_offset
output += highlight_slice(self.text, cursor, start)
output += before
output += highlight_slice(self.text, start, end)
output += after
cursor = end
}
output += highlight_slice(self.text, cursor, @utf8.encode(self.text).length())
output
}