Skip to main content

mant_engine/
search.rs

1//! Searches deterministic Markdown while retaining addressable manual nodes.
2//!
3//! Section and semantic-entry anchors emitted by the Markdown renderer form
4//! an internal source map. pulldown-cmark supplies a visible-text projection
5//! whose byte ranges map back into that exact Markdown document.
6
7use std::{error::Error, fmt, ops::Range};
8
9use grep_matcher::Matcher;
10use grep_regex::RegexMatcherBuilder;
11use mant_protocol::{
12    MarkdownSchema, QuerySearch, SearchCase, SearchContextLine, SearchHit, SearchLineRange,
13    SearchMarkdownRange, SearchOccurrence, SearchQuery, SearchRender, SearchRenderFormat,
14    SearchRenderScope, SearchSchema, SearchScope, SearchSyntax,
15};
16use pulldown_cmark::{Event, Parser, TagEnd};
17use regex_syntax::ParserBuilder;
18
19use crate::markdown_mapping::{InlineMappingKind, map_inline_characters};
20use crate::{ResolvedContent, output::render_addressable_markdown};
21
22mod owners;
23
24use owners::{Owner, OwnerIndex};
25
26const MAX_PATTERN_BYTES: usize = 4096;
27const MAX_CONTEXT_LINES: u16 = 100;
28const MAX_SEARCH_LIMIT: u32 = 10_000;
29const MAX_OCCURRENCES_PER_MATCH: usize = 256;
30
31/// Invalid search input or matcher construction.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum SearchError {
34    /// Search pattern contained no bytes.
35    EmptyPattern,
36    /// Search pattern exceeded the request bound.
37    PatternTooLong,
38    /// Result limit was zero or exceeded the protocol maximum.
39    InvalidLimit,
40    /// Requested context exceeded the protocol maximum.
41    ContextTooLarge,
42    /// Regular-expression compilation or execution failed.
43    InvalidPattern(String),
44}
45
46impl fmt::Display for SearchError {
47    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48        match self {
49            Self::EmptyPattern => formatter.write_str("search pattern must not be empty"),
50            Self::PatternTooLong => write!(
51                formatter,
52                "search pattern exceeds the {MAX_PATTERN_BYTES}-byte limit"
53            ),
54            Self::InvalidLimit => write!(
55                formatter,
56                "search limit must be between 1 and {MAX_SEARCH_LIMIT}"
57            ),
58            Self::ContextTooLarge => write!(
59                formatter,
60                "search context must not exceed {MAX_CONTEXT_LINES} lines"
61            ),
62            Self::InvalidPattern(message) => write!(formatter, "invalid search pattern: {message}"),
63        }
64    }
65}
66
67impl Error for SearchError {}
68
69/// Search one complete query and report coordinates in its canonical Markdown.
70///
71/// # Errors
72///
73/// Returns [`SearchError`] for empty or excessive inputs and invalid regular
74/// expressions. A valid search with no matches is a successful empty result.
75pub fn search_query(
76    query: &ResolvedContent,
77    request: &SearchQuery,
78) -> Result<QuerySearch, SearchError> {
79    validate_request(request)?;
80    let artifact = render_addressable_markdown(query);
81    let markdown = &artifact.text;
82    let lines = LineIndex::new(markdown);
83    let owners = OwnerIndex::new(&artifact);
84    let searchable = SearchableText::new(markdown, request.scope);
85    let matcher = build_matcher(request)?;
86    let offset = usize::try_from(request.offset).unwrap_or(usize::MAX);
87    let limit = usize::try_from(request.limit).unwrap_or(usize::MAX);
88    let mut collector = SearchCollector::new(markdown, &lines, offset, limit);
89    let mut invalid_utf8_match = false;
90
91    matcher
92        .find_iter(searchable.text.as_bytes(), |found| {
93            if !searchable.text.is_char_boundary(found.start())
94                || !searchable.text.is_char_boundary(found.end())
95            {
96                invalid_utf8_match = true;
97                return false;
98            }
99            let markdown_start = searchable.markdown_start(found.start());
100            let markdown_end = searchable.markdown_end(found.end());
101            if !markdown.is_char_boundary(markdown_start)
102                || !markdown.is_char_boundary(markdown_end)
103            {
104                invalid_utf8_match = true;
105                return false;
106            }
107            if let Some(owner) = owners.owner(markdown_start) {
108                collector.push(
109                    RawOccurrence {
110                        searchable: found.start()..found.end(),
111                        markdown: markdown_start..markdown_end,
112                    },
113                    owner,
114                );
115            }
116            true
117        })
118        .map_err(|error| SearchError::InvalidPattern(error.to_string()))?;
119    if invalid_utf8_match {
120        return Err(non_utf8_pattern_error());
121    }
122
123    let (raw_groups, total) = collector.finish();
124    let selected = raw_groups
125        .iter()
126        .map(|found| {
127            build_match(
128                found,
129                &searchable.text,
130                markdown,
131                &lines,
132                request.context_lines,
133            )
134        })
135        .collect::<Vec<_>>();
136    let returned = u32::try_from(selected.len()).unwrap_or(u32::MAX);
137    let consumed = request.offset.saturating_add(returned);
138    let truncated = consumed < total;
139
140    Ok(QuerySearch {
141        schema: SearchSchema::V0Dot8,
142        label: query.label.clone(),
143        source: query
144            .document
145            .as_ref()
146            .map(|document| document.source.clone()),
147        meta: query
148            .document
149            .as_ref()
150            .map(|document| document.meta.clone()),
151        query: request.clone(),
152        render: SearchRender {
153            schema: MarkdownSchema::V1,
154            format: SearchRenderFormat::Markdown,
155            scope: SearchRenderScope::Full,
156            line_base: 1,
157            column_base: 1,
158            line_count: u32::try_from(lines.count()).unwrap_or(u32::MAX),
159        },
160        total,
161        returned,
162        offset: request.offset,
163        truncated,
164        next_offset: truncated.then_some(consumed),
165        matches: selected,
166    })
167}
168
169/// Validate search limits and compile its matcher without loading a manual.
170///
171/// # Errors
172///
173/// Returns the same [`SearchError`] variants as [`search_query`].
174pub fn validate_search_query(request: &SearchQuery) -> Result<(), SearchError> {
175    validate_request(request)?;
176    build_matcher(request).map(|_| ())
177}
178
179fn validate_request(request: &SearchQuery) -> Result<(), SearchError> {
180    if request.pattern.is_empty() {
181        return Err(SearchError::EmptyPattern);
182    }
183    if request.pattern.len() > MAX_PATTERN_BYTES {
184        return Err(SearchError::PatternTooLong);
185    }
186    if request.limit == 0 || request.limit > MAX_SEARCH_LIMIT {
187        return Err(SearchError::InvalidLimit);
188    }
189    if request.context_lines > MAX_CONTEXT_LINES {
190        return Err(SearchError::ContextTooLarge);
191    }
192    Ok(())
193}
194
195fn build_matcher(request: &SearchQuery) -> Result<grep_regex::RegexMatcher, SearchError> {
196    validate_utf8_pattern(request)?;
197    let mut builder = RegexMatcherBuilder::new();
198    builder
199        .fixed_strings(request.syntax == SearchSyntax::Literal)
200        .multi_line(true)
201        .word(request.word);
202    match request.case {
203        SearchCase::Insensitive => {
204            builder.case_insensitive(true);
205        }
206        SearchCase::Sensitive => {
207            builder.case_insensitive(false);
208        }
209        SearchCase::Smart => {
210            builder.case_smart(true);
211        }
212    }
213    let matcher = builder
214        .build(&request.pattern)
215        .map_err(|error| SearchError::InvalidPattern(error.to_string()))?;
216    if matcher
217        .is_match(b"")
218        .map_err(|error| SearchError::InvalidPattern(error.to_string()))?
219    {
220        return Err(SearchError::InvalidPattern(
221            "pattern must not match empty text".to_owned(),
222        ));
223    }
224    Ok(matcher)
225}
226
227fn validate_utf8_pattern(request: &SearchQuery) -> Result<(), SearchError> {
228    if request.syntax == SearchSyntax::Literal {
229        return Ok(());
230    }
231    ParserBuilder::new()
232        .utf8(true)
233        .unicode(true)
234        .build()
235        .parse(&request.pattern)
236        .map(|_| ())
237        .map_err(|_| non_utf8_pattern_error())
238}
239
240fn non_utf8_pattern_error() -> SearchError {
241    SearchError::InvalidPattern(
242        "regular expressions must preserve UTF-8 character boundaries; Unicode mode cannot be disabled"
243            .to_owned(),
244    )
245}
246
247struct RawOccurrence {
248    searchable: Range<usize>,
249    markdown: Range<usize>,
250}
251
252struct RawMatchGroup {
253    ordinal: u32,
254    occurrences: Vec<RawOccurrence>,
255    occurrence_count: u32,
256    owner: Owner,
257    start_line_index: usize,
258    end_line_index: usize,
259}
260
261struct PendingRawMatchGroup {
262    ordinal: u32,
263    occurrences: Vec<RawOccurrence>,
264    occurrence_count: u32,
265    owner: PendingOwner,
266    start_line_index: usize,
267    end_line_index: usize,
268}
269
270enum PendingOwner {
271    Retained(Owner),
272    CountOnly(usize),
273}
274
275impl PendingOwner {
276    const fn key(&self) -> usize {
277        match self {
278            Self::Retained(owner) => owner.key,
279            Self::CountOnly(key) => *key,
280        }
281    }
282}
283
284struct SearchCollector<'a> {
285    markdown: &'a str,
286    lines: &'a LineIndex,
287    offset: usize,
288    limit: usize,
289    total: usize,
290    selected: Vec<RawMatchGroup>,
291    current: Option<PendingRawMatchGroup>,
292}
293
294impl<'a> SearchCollector<'a> {
295    fn new(markdown: &'a str, lines: &'a LineIndex, offset: usize, limit: usize) -> Self {
296        Self {
297            markdown,
298            lines,
299            offset,
300            limit,
301            total: 0,
302            selected: Vec::with_capacity(limit.min(256)),
303            current: None,
304        }
305    }
306
307    fn push(&mut self, occurrence: RawOccurrence, owner: &Owner) {
308        let start_line_index = self
309            .lines
310            .position(self.markdown, occurrence.markdown.start)
311            .line_index;
312        let end_line_index = self
313            .lines
314            .position(self.markdown, occurrence.markdown.end)
315            .line_index;
316        if let Some(group) = self.current.as_mut().filter(|group| {
317            group.start_line_index == start_line_index
318                && group.end_line_index == end_line_index
319                && group.owner.key() == owner.key
320        }) {
321            group.occurrence_count = group.occurrence_count.saturating_add(1);
322            if matches!(group.owner, PendingOwner::Retained(_))
323                && group.occurrences.len() < MAX_OCCURRENCES_PER_MATCH
324            {
325                group.occurrences.push(occurrence);
326            }
327            return;
328        }
329
330        self.flush();
331        let retained = self.total >= self.offset && self.selected.len() < self.limit;
332        let occurrences = retained.then_some(occurrence).into_iter().collect();
333        self.current = Some(PendingRawMatchGroup {
334            ordinal: u32::try_from(self.total.saturating_add(1)).unwrap_or(u32::MAX),
335            occurrences,
336            occurrence_count: 1,
337            owner: if retained {
338                PendingOwner::Retained(owner.clone())
339            } else {
340                PendingOwner::CountOnly(owner.key)
341            },
342            start_line_index,
343            end_line_index,
344        });
345    }
346
347    fn flush(&mut self) {
348        let Some(group) = self.current.take() else {
349            return;
350        };
351        self.total = self.total.saturating_add(1);
352        if let PendingOwner::Retained(owner) = group.owner {
353            self.selected.push(RawMatchGroup {
354                ordinal: group.ordinal,
355                occurrences: group.occurrences,
356                occurrence_count: group.occurrence_count,
357                owner,
358                start_line_index: group.start_line_index,
359                end_line_index: group.end_line_index,
360            });
361        }
362    }
363
364    fn finish(mut self) -> (Vec<RawMatchGroup>, u32) {
365        self.flush();
366        (self.selected, u32::try_from(self.total).unwrap_or(u32::MAX))
367    }
368}
369
370impl RawMatchGroup {
371    fn occurrences_truncated(&self) -> bool {
372        usize::try_from(self.occurrence_count).map_or(true, |count| count > self.occurrences.len())
373    }
374}
375
376fn build_match(
377    found: &RawMatchGroup,
378    searchable: &str,
379    markdown: &str,
380    lines: &LineIndex,
381    context_lines: u16,
382) -> SearchHit {
383    let first = &found.occurrences[0];
384    let start = lines.position(markdown, first.markdown.start);
385    let preview = display_markdown_line(lines.line(markdown, start.line_index));
386    let context_start = found
387        .start_line_index
388        .saturating_sub(usize::from(context_lines));
389    let context_end = found
390        .end_line_index
391        .saturating_add(usize::from(context_lines))
392        .min(lines.count().saturating_sub(1));
393    let context = if context_lines == 0 {
394        Vec::new()
395    } else {
396        (context_start..=context_end)
397            .map(|line_index| SearchContextLine {
398                line: u32::try_from(line_index.saturating_add(1)).unwrap_or(u32::MAX),
399                text: display_markdown_line(lines.line(markdown, line_index)),
400                matched: (found.start_line_index..=found.end_line_index).contains(&line_index),
401            })
402            .collect()
403    };
404
405    SearchHit {
406        ordinal: found.ordinal,
407        outline: found.owner.outline.clone(),
408        occurrences: found
409            .occurrences
410            .iter()
411            .map(|occurrence| {
412                let start = lines.position(markdown, occurrence.markdown.start);
413                let end = lines.position(markdown, occurrence.markdown.end);
414                SearchOccurrence {
415                    matched_text: searchable[occurrence.searchable.clone()].to_owned(),
416                    markdown: SearchMarkdownRange {
417                        start_byte: u64::try_from(occurrence.markdown.start).unwrap_or(u64::MAX),
418                        end_byte: u64::try_from(occurrence.markdown.end).unwrap_or(u64::MAX),
419                        start_line: u32::try_from(start.line_index.saturating_add(1))
420                            .unwrap_or(u32::MAX),
421                        start_column: u32::try_from(start.column).unwrap_or(u32::MAX),
422                        end_line: u32::try_from(end.line_index.saturating_add(1))
423                            .unwrap_or(u32::MAX),
424                        end_column: u32::try_from(end.column).unwrap_or(u32::MAX),
425                    },
426                    line_ranges: occurrence_line_ranges(occurrence, markdown, lines),
427                }
428            })
429            .collect(),
430        occurrence_count: found.occurrence_count,
431        occurrences_truncated: found.occurrences_truncated(),
432        node_source: found.owner.source,
433        preview,
434        context,
435    }
436}
437
438fn occurrence_line_ranges(
439    occurrence: &RawOccurrence,
440    markdown: &str,
441    lines: &LineIndex,
442) -> Vec<SearchLineRange> {
443    let start = lines
444        .position(markdown, occurrence.markdown.start)
445        .line_index;
446    let end = lines.position(markdown, occurrence.markdown.end).line_index;
447    (start..=end)
448        .flat_map(|line_index| {
449            let line_start = lines.start(line_index);
450            let line = lines.line(markdown, line_index);
451            let line_end = line_start.saturating_add(line.len());
452            let intersection =
453                occurrence.markdown.start.max(line_start)..occurrence.markdown.end.min(line_end);
454            AnchorStrippedLine::new(line)
455                .map_range(
456                    intersection.start.saturating_sub(line_start)
457                        ..intersection.end.saturating_sub(line_start),
458                )
459                .into_iter()
460                .map(move |range| SearchLineRange {
461                    line: u32::try_from(line_index.saturating_add(1)).unwrap_or(u32::MAX),
462                    start_byte: u32::try_from(range.start).unwrap_or(u32::MAX),
463                    end_byte: u32::try_from(range.end).unwrap_or(u32::MAX),
464                })
465        })
466        .collect()
467}
468
469/// Hide `ManT`'s zero-width source-map anchors from human-facing snippets.
470fn display_markdown_line(line: &str) -> String {
471    AnchorStrippedLine::new(line.trim_end()).text
472}
473
474struct AnchorStrippedLine {
475    text: String,
476    segments: Vec<OffsetSegment>,
477}
478
479impl AnchorStrippedLine {
480    fn new(line: &str) -> Self {
481        let mut text = String::with_capacity(line.len());
482        let mut segments = Vec::new();
483        let mut cursor = 0;
484        while let Some(relative_start) = line[cursor..].find("<a id=\"") {
485            let anchor_start = cursor + relative_start;
486            push_retained_line_segment(line, cursor..anchor_start, &mut text, &mut segments);
487            let anchor = &line[anchor_start..];
488            let Some(relative_end) = anchor.find("\"></a>") else {
489                push_retained_line_segment(
490                    line,
491                    anchor_start..line.len(),
492                    &mut text,
493                    &mut segments,
494                );
495                return Self { text, segments };
496            };
497            cursor = anchor_start + relative_end + "\"></a>".len();
498        }
499        push_retained_line_segment(line, cursor..line.len(), &mut text, &mut segments);
500        Self { text, segments }
501    }
502
503    fn map_range(&self, source: Range<usize>) -> Vec<Range<usize>> {
504        self.segments
505            .iter()
506            .filter_map(|segment| {
507                let start = source.start.max(segment.markdown.start);
508                let end = source.end.min(segment.markdown.end);
509                (start < end).then(|| {
510                    segment.visible.start + start.saturating_sub(segment.markdown.start)
511                        ..segment.visible.start + end.saturating_sub(segment.markdown.start)
512                })
513            })
514            .collect()
515    }
516}
517
518fn push_retained_line_segment(
519    line: &str,
520    source: Range<usize>,
521    text: &mut String,
522    segments: &mut Vec<OffsetSegment>,
523) {
524    if source.is_empty() {
525        return;
526    }
527    let visible_start = text.len();
528    text.push_str(&line[source.clone()]);
529    segments.push(OffsetSegment {
530        visible: visible_start..text.len(),
531        markdown: source,
532        linear: true,
533    });
534}
535
536struct TextPosition {
537    line_index: usize,
538    column: usize,
539}
540
541struct LineIndex {
542    starts: Vec<usize>,
543}
544
545impl LineIndex {
546    fn new(text: &str) -> Self {
547        let mut starts = vec![0];
548        starts.extend(
549            text.bytes()
550                .enumerate()
551                .filter_map(|(index, byte)| (byte == b'\n').then_some(index + 1)),
552        );
553        Self { starts }
554    }
555
556    fn count(&self) -> usize {
557        self.starts.len()
558    }
559
560    fn position(&self, text: &str, offset: usize) -> TextPosition {
561        let offset = offset.min(text.len());
562        let line_index = self.starts.partition_point(|start| *start <= offset) - 1;
563        let line_start = self.starts[line_index];
564        TextPosition {
565            line_index,
566            column: text[line_start..offset].chars().count().saturating_add(1),
567        }
568    }
569
570    fn line<'a>(&self, text: &'a str, line_index: usize) -> &'a str {
571        let start = self.starts[line_index];
572        let end = self
573            .starts
574            .get(line_index + 1)
575            .copied()
576            .unwrap_or(text.len());
577        text[start..end]
578            .strip_suffix('\n')
579            .unwrap_or(&text[start..end])
580    }
581
582    fn start(&self, line_index: usize) -> usize {
583        self.starts[line_index]
584    }
585}
586
587struct SearchableText {
588    text: String,
589    segments: Vec<OffsetSegment>,
590    direct_markdown: bool,
591}
592
593#[derive(Debug)]
594struct OffsetSegment {
595    visible: Range<usize>,
596    markdown: Range<usize>,
597    linear: bool,
598}
599
600impl SearchableText {
601    fn new(markdown: &str, scope: SearchScope) -> Self {
602        if scope == SearchScope::Markdown {
603            return Self {
604                text: markdown.to_owned(),
605                segments: Vec::new(),
606                direct_markdown: true,
607            };
608        }
609
610        let mut visible = VisibleBuilder::new(markdown);
611        for (event, source) in Parser::new(markdown).into_offset_iter() {
612            match event {
613                Event::Text(value) | Event::InlineMath(value) | Event::DisplayMath(value) => {
614                    visible.push_mapped(&value, source, InlineMappingKind::Text);
615                }
616                Event::Code(value) => {
617                    visible.push_mapped(&value, source, InlineMappingKind::Code);
618                }
619                Event::SoftBreak | Event::HardBreak | Event::Rule => visible.push_break(source),
620                Event::End(
621                    TagEnd::Paragraph
622                    | TagEnd::Heading(_)
623                    | TagEnd::Item
624                    | TagEnd::CodeBlock
625                    | TagEnd::TableRow,
626                ) => visible.push_break(source.end..source.end),
627                Event::Start(_)
628                | Event::End(_)
629                | Event::Html(_)
630                | Event::InlineHtml(_)
631                | Event::FootnoteReference(_)
632                | Event::TaskListMarker(_) => {}
633            }
634        }
635        visible.finish()
636    }
637
638    fn markdown_start(&self, offset: usize) -> usize {
639        if self.direct_markdown {
640            return offset;
641        }
642        self.segment_at(offset).map_or(0, |segment| {
643            if segment.linear {
644                segment.markdown.start + offset.saturating_sub(segment.visible.start)
645            } else {
646                segment.markdown.start
647            }
648        })
649    }
650
651    fn markdown_end(&self, offset: usize) -> usize {
652        if self.direct_markdown {
653            return offset;
654        }
655        if offset == 0 {
656            return 0;
657        }
658        self.segment_at(offset - 1).map_or(0, |segment| {
659            if segment.linear {
660                segment.markdown.start + offset.saturating_sub(segment.visible.start)
661            } else {
662                segment.markdown.end
663            }
664        })
665    }
666
667    fn segment_at(&self, offset: usize) -> Option<&OffsetSegment> {
668        let index = self
669            .segments
670            .partition_point(|segment| segment.visible.end <= offset);
671        self.segments
672            .get(index)
673            .filter(|segment| segment.visible.contains(&offset))
674    }
675}
676
677struct VisibleBuilder<'a> {
678    markdown: &'a str,
679    text: String,
680    segments: Vec<OffsetSegment>,
681}
682
683impl<'a> VisibleBuilder<'a> {
684    fn new(markdown: &'a str) -> Self {
685        Self {
686            markdown,
687            text: String::new(),
688            segments: Vec::new(),
689        }
690    }
691
692    fn push_mapped(&mut self, value: &str, source: Range<usize>, kind: InlineMappingKind) {
693        for mapped in map_inline_characters(self.markdown, value, source, kind) {
694            let visible_start = self.text.len();
695            self.text.push(mapped.value);
696            let visible_end = self.text.len();
697            self.push_segment(OffsetSegment {
698                visible: visible_start..visible_end,
699                markdown: mapped.source,
700                linear: mapped.linear,
701            });
702        }
703    }
704
705    fn push_break(&mut self, markdown: Range<usize>) {
706        if self.text.ends_with('\n') || self.text.is_empty() {
707            return;
708        }
709        let start = self.text.len();
710        self.text.push('\n');
711        self.push_segment(OffsetSegment {
712            visible: start..self.text.len(),
713            markdown,
714            linear: false,
715        });
716    }
717
718    fn push_segment(&mut self, segment: OffsetSegment) {
719        if let Some(previous) = self.segments.last_mut() {
720            let contiguous = previous.visible.end == segment.visible.start
721                && previous.markdown.end == segment.markdown.start
722                && previous.linear
723                && segment.linear;
724            if contiguous {
725                previous.visible.end = segment.visible.end;
726                previous.markdown.end = segment.markdown.end;
727                return;
728            }
729        }
730        self.segments.push(segment);
731    }
732
733    fn finish(self) -> SearchableText {
734        SearchableText {
735            text: self.text,
736            segments: self.segments,
737            direct_markdown: false,
738        }
739    }
740}
741
742#[cfg(test)]
743mod tests {
744    use crate::ResolvedContent;
745    use mant_ir::{
746        Block, DefinitionCase, DefinitionIdentity, DefinitionItem, DefinitionRole, Document,
747        DocumentMeta, DocumentSource, Inline, LayoutHint, Section, SourceFormat,
748    };
749    use mant_protocol::{SearchCase, SearchQuery, SearchScope, SearchSyntax};
750
751    use super::{
752        MAX_OCCURRENCES_PER_MATCH, display_markdown_line, render_addressable_markdown, search_query,
753    };
754
755    fn query() -> ResolvedContent {
756        ResolvedContent {
757            address: None,
758            label: "demo".to_owned(),
759            document: Some(Document {
760                parser: None,
761                source: DocumentSource {
762                    format: SourceFormat::Man,
763                    path: None,
764                },
765                meta: DocumentMeta {
766                    manual_section: Some("1".to_owned()),
767                    ..DocumentMeta::default()
768                },
769                diagnostics: Vec::new(),
770                blocks: Vec::new(),
771                sections: vec![Section {
772                    id: "options-1".to_owned().into(),
773                    title: "OPTIONS".to_owned(),
774                    spacing_before_lines: 0,
775                    blocks: vec![Block::DefinitionList {
776                        items: vec![DefinitionItem {
777                            inline_term: false,
778                            identity: Some(DefinitionIdentity {
779                                id: "option-acls".to_owned().into(),
780                                role: DefinitionRole::Option,
781                                case: DefinitionCase::Sensitive,
782                                names: vec!["--acls".to_owned()],
783                            }),
784                            terms: vec![vec![
785                                Inline::Anchor {
786                                    id: "option-acls".to_owned().into(),
787                                },
788                                Inline::Code {
789                                    value: "--acls".to_owned(),
790                                },
791                            ]],
792                            description: vec![Block::Paragraph {
793                                children: vec![
794                                    Inline::Text {
795                                        value: "Preserve ".to_owned(),
796                                    },
797                                    Inline::Strong {
798                                        children: vec![Inline::Text {
799                                            value: "access control".to_owned(),
800                                        }],
801                                    },
802                                    Inline::Text {
803                                        value: " lists".to_owned(),
804                                    },
805                                ],
806                                layout: LayoutHint::default(),
807                                source: None,
808                            }],
809                            spacing_before_lines: None,
810                        }],
811                        compact: true,
812                        layout: LayoutHint::default(),
813                        source: None,
814                    }],
815                    children: Vec::new(),
816                    source: None,
817                }],
818            }),
819            tldr: None,
820        }
821    }
822
823    fn request(pattern: &str) -> SearchQuery {
824        SearchQuery {
825            pattern: pattern.to_owned(),
826            syntax: SearchSyntax::Literal,
827            case: SearchCase::Insensitive,
828            scope: SearchScope::Visible,
829            word: false,
830            context_lines: 1,
831            limit: 100,
832            offset: 0,
833        }
834    }
835
836    #[test]
837    fn visible_search_maps_inline_formatting_to_markdown_and_option_nodes() {
838        let result = search_query(&query(), &request("access control")).expect("search");
839
840        assert_eq!(result.total, 1);
841        assert_eq!(result.matches[0].outline.node.path(), "1/e1");
842        assert_eq!(
843            result.matches[0].occurrences[0].matched_text,
844            "access control"
845        );
846        assert_eq!(result.matches[0].occurrences[0].line_ranges.len(), 1);
847        assert!(result.matches[0].occurrences[0].markdown.start_line > 1);
848        assert!(result.matches[0].preview.contains("**access control**"));
849        assert!(!result.matches[0].preview.contains("<a id="));
850        assert!(!result.matches[0].context.is_empty());
851    }
852
853    #[test]
854    fn source_map_stripping_accepts_only_complete_empty_anchors() {
855        assert_eq!(
856            display_markdown_line("before<a id=\"node\"></a>after"),
857            "beforeafter"
858        );
859        assert_eq!(
860            display_markdown_line("before<a id=\"node\">payload</a>after"),
861            "before<a id=\"node\">payload</a>after"
862        );
863        assert_eq!(
864            display_markdown_line("before<a id=\"node\"after"),
865            "before<a id=\"node\"after"
866        );
867    }
868
869    #[test]
870    fn visible_regex_anchors_apply_to_rendered_lines_not_the_whole_document() {
871        for pattern in [r"^--acls", r"lists$"] {
872            let mut request = request(pattern);
873            request.syntax = SearchSyntax::Regex;
874            request.case = SearchCase::Sensitive;
875
876            let result = search_query(&query(), &request).expect("search");
877
878            assert_eq!(result.total, 1, "pattern {pattern:?}");
879            assert_eq!(result.matches[0].outline.node.path(), "1/e1");
880        }
881    }
882
883    #[test]
884    fn visible_search_maps_padded_code_span_content_not_its_delimiters() {
885        for value in ["`x", "x`", " x", "x ", "`x`"] {
886            let mut query = query();
887            let Block::DefinitionList { items, .. } =
888                &mut query.document.as_mut().expect("manual").sections[0].blocks[0]
889            else {
890                panic!("fixture contains a definition list");
891            };
892            items[0].description = vec![Block::Paragraph {
893                children: vec![Inline::Code {
894                    value: value.to_owned(),
895                }],
896                layout: LayoutHint::default(),
897                source: None,
898            }];
899            let markdown = render_addressable_markdown(&query).text;
900
901            let result = search_query(&query, &request(value)).expect("search");
902            let occurrence = &result.matches[0].occurrences[0];
903            let start = usize::try_from(occurrence.markdown.start_byte).expect("small fixture");
904            let end = usize::try_from(occurrence.markdown.end_byte).expect("small fixture");
905
906            assert_eq!(&markdown[start..end], value, "code value {value:?}");
907            assert_eq!(occurrence.line_ranges.len(), 1, "code value {value:?}");
908            let line = &occurrence.line_ranges[0];
909            let line_start = usize::try_from(line.start_byte).expect("small fixture");
910            let line_end = usize::try_from(line.end_byte).expect("small fixture");
911            assert_eq!(
912                &result.matches[0].preview[line_start..line_end],
913                value,
914                "code value {value:?}"
915            );
916        }
917    }
918
919    #[test]
920    fn visible_search_maps_an_explicit_line_break_to_its_markdown_byte() {
921        let mut query = query();
922        let Block::DefinitionList { items, .. } =
923            &mut query.document.as_mut().expect("manual").sections[0].blocks[0]
924        else {
925            panic!("fixture contains a definition list");
926        };
927        items[0].description = vec![Block::Paragraph {
928            children: vec![
929                Inline::Text {
930                    value: "alpha".to_owned(),
931                },
932                Inline::LineBreak,
933                Inline::Text {
934                    value: "beta".to_owned(),
935                },
936            ],
937            layout: LayoutHint::default(),
938            source: None,
939        }];
940        let markdown = render_addressable_markdown(&query).text;
941
942        let result = search_query(&query, &request("alpha\n")).expect("search");
943        let occurrence = &result.matches[0].occurrences[0];
944        let start = usize::try_from(occurrence.markdown.start_byte).expect("small fixture");
945        let end = usize::try_from(occurrence.markdown.end_byte).expect("small fixture");
946
947        assert_eq!(&markdown[start..end], "alpha  \n");
948    }
949
950    #[test]
951    fn same_line_occurrences_form_one_paginated_search_result() {
952        let mut query = query();
953        let Block::DefinitionList { items, .. } =
954            &mut query.document.as_mut().expect("manual").sections[0].blocks[0]
955        else {
956            panic!("fixture contains a definition list");
957        };
958        items[0].description = vec![Block::Paragraph {
959            children: vec![Inline::Text {
960                value: "needle, then another needle on one line".to_owned(),
961            }],
962            layout: LayoutHint::default(),
963            source: None,
964        }];
965
966        let mut request = request("needle");
967        request.limit = 1;
968        let result = search_query(&query, &request).expect("search");
969
970        assert_eq!(result.total, 1);
971        assert_eq!(result.returned, 1);
972        assert_eq!(result.matches[0].occurrences.len(), 2);
973        assert_eq!(
974            result.matches[0].occurrences[0].markdown.start_line,
975            result.matches[0].occurrences[1].markdown.start_line
976        );
977        assert!(!result.truncated);
978    }
979
980    #[test]
981    fn one_repetitive_line_has_bounded_occurrence_details() {
982        let mut query = query();
983        let Block::DefinitionList { items, .. } =
984            &mut query.document.as_mut().expect("manual").sections[0].blocks[0]
985        else {
986            panic!("fixture contains a definition list");
987        };
988        let occurrence_count = MAX_OCCURRENCES_PER_MATCH + 7;
989        items[0].description = vec![Block::Paragraph {
990            children: vec![Inline::Text {
991                value: vec!["needle"; occurrence_count].join(" "),
992            }],
993            layout: LayoutHint::default(),
994            source: None,
995        }];
996
997        let result = search_query(&query, &request("needle")).expect("search");
998
999        assert_eq!(result.total, 1);
1000        assert_eq!(
1001            result.matches[0].occurrence_count,
1002            u32::try_from(occurrence_count).expect("small fixture")
1003        );
1004        assert_eq!(
1005            result.matches[0].occurrences.len(),
1006            MAX_OCCURRENCES_PER_MATCH
1007        );
1008        assert!(result.matches[0].occurrences_truncated);
1009    }
1010
1011    #[test]
1012    fn semantic_entry_ownership_ends_before_a_following_section_paragraph() {
1013        let mut query = query();
1014        query.document.as_mut().expect("manual").sections[0]
1015            .blocks
1016            .push(Block::Paragraph {
1017                children: vec![Inline::Text {
1018                    value: "General section tail".to_owned(),
1019                }],
1020                layout: LayoutHint::default(),
1021                source: None,
1022            });
1023
1024        let result = search_query(&query, &request("section tail")).expect("search");
1025        assert!(matches!(
1026            &result.matches[0].outline.node,
1027            mant_protocol::OutlineNodeReference::DocumentSection { path, .. } if path == "1"
1028        ));
1029    }
1030
1031    #[test]
1032    fn root_content_search_resolves_to_an_addressable_document_root() {
1033        let mut query = query();
1034        let document = query.document.as_mut().expect("document");
1035        document.source.format = SourceFormat::Markdown;
1036        document.blocks.push(Block::Paragraph {
1037            children: vec![Inline::Text {
1038                value: "Read the preface needle first.".to_owned(),
1039            }],
1040            layout: LayoutHint::default(),
1041            source: None,
1042        });
1043
1044        let result = search_query(&query, &request("preface needle")).expect("root search");
1045
1046        assert_eq!(result.total, 1);
1047        assert!(matches!(
1048            &result.matches[0].outline.node,
1049            mant_protocol::OutlineNodeReference::DocumentRoot { path, id, .. }
1050                if path == "root" && id == "document-overview"
1051        ));
1052        assert!(result.matches[0].outline.ancestors.is_empty());
1053        assert!(result.matches[0].preview.contains("preface needle"));
1054    }
1055
1056    #[test]
1057    fn embedded_tldr_and_markdown_body_keep_distinct_search_owners() {
1058        let query = crate::query_markdown_text(
1059            "\
1060<!-- mant:tldr:start -->
1061# demo
1062
1063> Quick needle.
1064
1065- Run:
1066
1067`demo quick-command`
1068<!-- mant:tldr:end -->
1069
1070# Demo
1071
1072Read the overview needle.
1073
1074## Synopsis
1075
1076Manual needle.
1077",
1078            Some("demo.md".to_owned()),
1079        )
1080        .expect("Markdown query");
1081
1082        let quick = search_query(&query, &request("quick needle")).expect("tldr search");
1083        assert!(matches!(
1084            &quick.matches[0].outline.node,
1085            mant_protocol::OutlineNodeReference::Tldr { path, id, .. }
1086                if path == "0" && id == "tldr"
1087        ));
1088
1089        let overview = search_query(&query, &request("overview needle")).expect("root search");
1090        assert!(matches!(
1091            &overview.matches[0].outline.node,
1092            mant_protocol::OutlineNodeReference::DocumentRoot { path, .. } if path == "root"
1093        ));
1094
1095        let manual = search_query(&query, &request("manual needle")).expect("section search");
1096        assert!(matches!(
1097            &manual.matches[0].outline.node,
1098            mant_protocol::OutlineNodeReference::DocumentSection { path, id, .. }
1099                if path == "1" && id == "synopsis"
1100        ));
1101    }
1102
1103    #[test]
1104    fn regex_case_and_pagination_are_reported_without_losing_global_ordinals() {
1105        let mut request = request("ACLS|control");
1106        request.syntax = SearchSyntax::Regex;
1107        request.case = SearchCase::Insensitive;
1108        request.limit = 1;
1109        request.offset = 1;
1110        let result = search_query(&query(), &request).expect("search");
1111
1112        assert_eq!(result.total, 2);
1113        assert_eq!(result.returned, 1);
1114        assert_eq!(result.matches[0].ordinal, 2);
1115        assert!(!result.truncated);
1116    }
1117
1118    #[test]
1119    fn regexes_that_match_empty_text_are_rejected() {
1120        let mut request = request("$");
1121        request.syntax = SearchSyntax::Regex;
1122        let error = search_query(&query(), &request).expect_err("empty regex match");
1123        assert!(error.to_string().contains("must not match empty text"));
1124    }
1125
1126    #[test]
1127    fn byte_mode_regexes_are_rejected_before_matching_unicode_text() {
1128        let mut request = request("(?-u:.)");
1129        request.syntax = SearchSyntax::Regex;
1130        let error = search_query(&query(), &request).expect_err("byte-oriented regex");
1131
1132        assert!(error.to_string().contains("UTF-8 character boundaries"));
1133    }
1134}