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    MAX_SEARCH_PATTERN_CHARS, MarkdownSchema, QuerySearch, SearchCase, SearchContextLine,
13    SearchHit, SearchLineRange, SearchMarkdownRange, SearchOccurrence, SearchQuery, SearchRender,
14    SearchRenderFormat, 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_CONTEXT_LINES: u16 = 100;
27const MAX_SEARCH_LIMIT: u32 = 10_000;
28const MAX_OCCURRENCES_PER_MATCH: usize = 256;
29const MAX_REGEX_COMPILED_BYTES: usize = 4 * 1024 * 1024;
30const MAX_REGEX_DFA_CACHE_BYTES: usize = 8 * 1024 * 1024;
31
32/// Invalid search input or matcher construction.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum SearchError {
35    /// Search pattern contained no bytes.
36    EmptyPattern,
37    /// Search pattern exceeded the request bound.
38    PatternTooLong,
39    /// Result limit was zero or exceeded the protocol maximum.
40    InvalidLimit,
41    /// Requested context exceeded the protocol maximum.
42    ContextTooLarge,
43    /// Regular-expression compilation or execution failed.
44    InvalidPattern(String),
45}
46
47impl fmt::Display for SearchError {
48    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            Self::EmptyPattern => formatter.write_str("search pattern must not be empty"),
51            Self::PatternTooLong => write!(
52                formatter,
53                "search pattern exceeds the {MAX_SEARCH_PATTERN_CHARS}-character limit"
54            ),
55            Self::InvalidLimit => write!(
56                formatter,
57                "search limit must be between 1 and {MAX_SEARCH_LIMIT}"
58            ),
59            Self::ContextTooLarge => write!(
60                formatter,
61                "search context must not exceed {MAX_CONTEXT_LINES} lines"
62            ),
63            Self::InvalidPattern(message) => write!(formatter, "invalid search pattern: {message}"),
64        }
65    }
66}
67
68impl Error for SearchError {}
69
70/// Search one complete query and report coordinates in its canonical Markdown.
71///
72/// # Errors
73///
74/// Returns [`SearchError`] for empty or excessive inputs and invalid regular
75/// expressions. A valid search with no matches is a successful empty result.
76pub fn search_query(
77    query: &ResolvedContent,
78    request: &SearchQuery,
79) -> Result<QuerySearch, SearchError> {
80    validate_request(request)?;
81    let artifact = render_addressable_markdown(query);
82    let markdown = &artifact.text;
83    let lines = LineIndex::new(markdown);
84    let owners = OwnerIndex::new(&artifact);
85    let searchable = SearchableText::new(markdown, request.scope);
86    let matcher = build_matcher(request)?;
87    let offset = usize::try_from(request.offset).unwrap_or(usize::MAX);
88    let limit = usize::try_from(request.limit).unwrap_or(usize::MAX);
89    let mut collector = SearchCollector::new(markdown, &lines, offset, limit);
90    collect_occurrences(
91        &matcher,
92        &searchable,
93        markdown,
94        &lines,
95        &owners,
96        &mut collector,
97    )?;
98
99    let (raw_groups, total) = collector.finish();
100    let selected = raw_groups
101        .iter()
102        .map(|found| build_match(found, &searchable, markdown, &lines, request.context_lines))
103        .collect::<Vec<_>>();
104    let returned = u32::try_from(selected.len()).unwrap_or(u32::MAX);
105    let consumed = request.offset.saturating_add(returned);
106    let truncated = consumed < total;
107
108    Ok(QuerySearch {
109        schema: SearchSchema::V0Dot10,
110        label: query.label.clone(),
111        source: query
112            .document
113            .as_ref()
114            .map(|document| document.source.clone()),
115        meta: query
116            .document
117            .as_ref()
118            .map(|document| document.meta.clone()),
119        query: request.clone(),
120        render: SearchRender {
121            schema: MarkdownSchema::V1,
122            format: SearchRenderFormat::Markdown,
123            scope: SearchRenderScope::Full,
124            line_base: 1,
125            column_base: 1,
126            line_count: u32::try_from(lines.count()).unwrap_or(u32::MAX),
127        },
128        total,
129        returned,
130        offset: request.offset,
131        truncated,
132        next_offset: truncated.then_some(consumed),
133        matches: selected,
134    })
135}
136
137fn collect_occurrences(
138    matcher: &grep_regex::RegexMatcher,
139    searchable: &SearchableText,
140    markdown: &str,
141    lines: &LineIndex,
142    owners: &OwnerIndex,
143    collector: &mut SearchCollector<'_>,
144) -> Result<(), SearchError> {
145    let mut invalid_utf8_match = false;
146    let mut invalid_zero_width_match = false;
147    matcher
148        .find_iter(searchable.text.as_bytes(), |found| {
149            if found.start() == found.end() {
150                invalid_zero_width_match = true;
151                return false;
152            }
153            if !searchable.text.is_char_boundary(found.start())
154                || !searchable.text.is_char_boundary(found.end())
155            {
156                invalid_utf8_match = true;
157                return false;
158            }
159            let markdown_start = searchable.markdown_start(found.start());
160            let markdown_end = searchable.markdown_end(found.end());
161            if !markdown.is_char_boundary(markdown_start)
162                || !markdown.is_char_boundary(markdown_end)
163            {
164                invalid_utf8_match = true;
165                return false;
166            }
167            if markdown_start >= markdown_end {
168                // Visible text contains synthetic separators between block
169                // events. They make whitespace searchable but have no
170                // canonical Markdown bytes of their own, so this occurrence
171                // is intentionally absent rather than invalidating the query.
172                return true;
173            }
174            let line_ranges = occurrence_line_ranges(markdown_start..markdown_end, markdown, lines);
175            if line_ranges.is_empty() {
176                // A Markdown-scope matcher can land wholly inside one of the
177                // zero-width source-map anchors. Such internal matches have
178                // no anchor-free presentation and must not become phantom
179                // result rows.
180                return true;
181            }
182            let owner = owners.owner(markdown_start);
183            let end_owner = owners.owner(markdown_end - 1);
184            if let (Some(owner), Some(end_owner)) = (owner, end_owner)
185                && owner.key == end_owner.key
186            {
187                collector.push(
188                    RawOccurrence {
189                        searchable: found.start()..found.end(),
190                        markdown: markdown_start..markdown_end,
191                        line_ranges,
192                    },
193                    owner,
194                );
195            }
196            true
197        })
198        .map_err(matcher_error)?;
199    if invalid_utf8_match {
200        Err(non_utf8_pattern_error())
201    } else if invalid_zero_width_match {
202        Err(empty_match_error())
203    } else {
204        Ok(())
205    }
206}
207
208/// Validate search limits and compile its matcher without loading a manual.
209///
210/// # Errors
211///
212/// Returns the same [`SearchError`] variants as [`search_query`].
213pub fn validate_search_query(request: &SearchQuery) -> Result<(), SearchError> {
214    validate_request(request)?;
215    build_matcher(request).map(|_| ())
216}
217
218fn validate_request(request: &SearchQuery) -> Result<(), SearchError> {
219    if request.pattern.is_empty() {
220        return Err(SearchError::EmptyPattern);
221    }
222    if request.pattern.chars().count() > MAX_SEARCH_PATTERN_CHARS {
223        return Err(SearchError::PatternTooLong);
224    }
225    if request.limit == 0 || request.limit > MAX_SEARCH_LIMIT {
226        return Err(SearchError::InvalidLimit);
227    }
228    if request.context_lines > MAX_CONTEXT_LINES {
229        return Err(SearchError::ContextTooLarge);
230    }
231    Ok(())
232}
233
234fn build_matcher(request: &SearchQuery) -> Result<grep_regex::RegexMatcher, SearchError> {
235    validate_pattern_semantics(request)?;
236    let mut builder = RegexMatcherBuilder::new();
237    builder
238        .fixed_strings(request.syntax == SearchSyntax::Literal)
239        .multi_line(true)
240        .size_limit(MAX_REGEX_COMPILED_BYTES)
241        .dfa_size_limit(MAX_REGEX_DFA_CACHE_BYTES)
242        .word(request.word);
243    match request.case {
244        SearchCase::Insensitive => {
245            builder.case_insensitive(true);
246        }
247        SearchCase::Sensitive => {
248            builder.case_insensitive(false);
249        }
250        SearchCase::Smart => {
251            builder.case_smart(true);
252        }
253    }
254    let matcher = builder.build(&request.pattern).map_err(matcher_error)?;
255    if matcher.is_match(b"").map_err(matcher_error)? {
256        return Err(empty_match_error());
257    }
258    Ok(matcher)
259}
260
261fn validate_pattern_semantics(request: &SearchQuery) -> Result<(), SearchError> {
262    if request.syntax == SearchSyntax::Literal {
263        return Ok(());
264    }
265    let hir = ParserBuilder::new()
266        .utf8(true)
267        .unicode(true)
268        .build()
269        .parse(&request.pattern)
270        .map_err(|error| {
271            let message = error.to_string();
272            if message.contains("pattern can match invalid UTF-8") {
273                non_utf8_pattern_error()
274            } else {
275                SearchError::InvalidPattern(message)
276            }
277        })?;
278    if hir.properties().minimum_len() == Some(0) {
279        return Err(empty_match_error());
280    }
281    Ok(())
282}
283
284fn empty_match_error() -> SearchError {
285    SearchError::InvalidPattern("pattern must not match empty text".to_owned())
286}
287
288fn non_utf8_pattern_error() -> SearchError {
289    SearchError::InvalidPattern(
290        "regular expressions must preserve UTF-8 character boundaries; Unicode mode cannot be disabled"
291            .to_owned(),
292    )
293}
294
295fn matcher_error(error: impl fmt::Display) -> SearchError {
296    let message = error.to_string();
297    if message.contains("compiled regex exceeds size limit") {
298        SearchError::InvalidPattern(
299            "regular expression exceeds ManT's compiled-size limit".to_owned(),
300        )
301    } else {
302        SearchError::InvalidPattern(message)
303    }
304}
305
306struct RawOccurrence {
307    searchable: Range<usize>,
308    markdown: Range<usize>,
309    line_ranges: Vec<SearchLineRange>,
310}
311
312struct RawMatchGroup {
313    ordinal: u32,
314    occurrences: Vec<RawOccurrence>,
315    occurrence_count: u32,
316    owner: Owner,
317    start_line_index: usize,
318    end_line_index: usize,
319}
320
321struct PendingRawMatchGroup {
322    ordinal: u32,
323    occurrences: Vec<RawOccurrence>,
324    occurrence_count: u32,
325    owner: PendingOwner,
326    start_line_index: usize,
327    end_line_index: usize,
328}
329
330enum PendingOwner {
331    Retained(Owner),
332    CountOnly(usize),
333}
334
335impl PendingOwner {
336    const fn key(&self) -> usize {
337        match self {
338            Self::Retained(owner) => owner.key,
339            Self::CountOnly(key) => *key,
340        }
341    }
342}
343
344struct SearchCollector<'a> {
345    markdown: &'a str,
346    lines: &'a LineIndex,
347    offset: usize,
348    limit: usize,
349    total: usize,
350    selected: Vec<RawMatchGroup>,
351    current: Option<PendingRawMatchGroup>,
352}
353
354impl<'a> SearchCollector<'a> {
355    fn new(markdown: &'a str, lines: &'a LineIndex, offset: usize, limit: usize) -> Self {
356        Self {
357            markdown,
358            lines,
359            offset,
360            limit,
361            total: 0,
362            selected: Vec::with_capacity(limit.min(256)),
363            current: None,
364        }
365    }
366
367    fn push(&mut self, occurrence: RawOccurrence, owner: &Owner) {
368        let start_line_index = self
369            .lines
370            .position(self.markdown, occurrence.markdown.start)
371            .line_index;
372        let end_line_index = self
373            .lines
374            .line_index_at_byte(occurrence.markdown.end.saturating_sub(1));
375        if let Some(group) = self.current.as_mut().filter(|group| {
376            group.start_line_index == start_line_index
377                && group.end_line_index == end_line_index
378                && group.owner.key() == owner.key
379        }) {
380            group.occurrence_count = group.occurrence_count.saturating_add(1);
381            if matches!(group.owner, PendingOwner::Retained(_))
382                && group.occurrences.len() < MAX_OCCURRENCES_PER_MATCH
383            {
384                group.occurrences.push(occurrence);
385            }
386            return;
387        }
388
389        self.flush();
390        let retained = self.total >= self.offset && self.selected.len() < self.limit;
391        let occurrences = retained.then_some(occurrence).into_iter().collect();
392        self.current = Some(PendingRawMatchGroup {
393            ordinal: u32::try_from(self.total.saturating_add(1)).unwrap_or(u32::MAX),
394            occurrences,
395            occurrence_count: 1,
396            owner: if retained {
397                PendingOwner::Retained(owner.clone())
398            } else {
399                PendingOwner::CountOnly(owner.key)
400            },
401            start_line_index,
402            end_line_index,
403        });
404    }
405
406    fn flush(&mut self) {
407        let Some(group) = self.current.take() else {
408            return;
409        };
410        self.total = self.total.saturating_add(1);
411        if let PendingOwner::Retained(owner) = group.owner {
412            self.selected.push(RawMatchGroup {
413                ordinal: group.ordinal,
414                occurrences: group.occurrences,
415                occurrence_count: group.occurrence_count,
416                owner,
417                start_line_index: group.start_line_index,
418                end_line_index: group.end_line_index,
419            });
420        }
421    }
422
423    fn finish(mut self) -> (Vec<RawMatchGroup>, u32) {
424        self.flush();
425        (self.selected, u32::try_from(self.total).unwrap_or(u32::MAX))
426    }
427}
428
429impl RawMatchGroup {
430    fn occurrences_truncated(&self) -> bool {
431        usize::try_from(self.occurrence_count).map_or(true, |count| count > self.occurrences.len())
432    }
433}
434
435fn build_match(
436    found: &RawMatchGroup,
437    searchable: &SearchableText,
438    markdown: &str,
439    lines: &LineIndex,
440    context_lines: u16,
441) -> SearchHit {
442    let first = &found.occurrences[0];
443    let start = lines.position(markdown, first.markdown.start);
444    let preview = display_markdown_line(lines.line(markdown, start.line_index));
445    let context_start = found
446        .start_line_index
447        .saturating_sub(usize::from(context_lines));
448    let context_end = found
449        .end_line_index
450        .saturating_add(usize::from(context_lines))
451        .min(lines.count().saturating_sub(1));
452    let context = if context_lines == 0 {
453        Vec::new()
454    } else {
455        (context_start..=context_end)
456            .map(|line_index| SearchContextLine {
457                line: u32::try_from(line_index.saturating_add(1)).unwrap_or(u32::MAX),
458                text: display_markdown_line(lines.line(markdown, line_index)),
459                matched: (found.start_line_index..=found.end_line_index).contains(&line_index),
460            })
461            .collect()
462    };
463
464    SearchHit {
465        ordinal: found.ordinal,
466        outline: found.owner.outline.clone(),
467        occurrences: found
468            .occurrences
469            .iter()
470            .map(|occurrence| {
471                let start = lines.position(markdown, occurrence.markdown.start);
472                let end = lines.position(markdown, occurrence.markdown.end);
473                SearchOccurrence {
474                    matched_text: if searchable.direct_markdown {
475                        presented_matched_text(occurrence, markdown, lines)
476                    } else {
477                        searchable.text[occurrence.searchable.clone()].to_owned()
478                    },
479                    markdown: SearchMarkdownRange {
480                        start_byte: u64::try_from(occurrence.markdown.start).unwrap_or(u64::MAX),
481                        end_byte: u64::try_from(occurrence.markdown.end).unwrap_or(u64::MAX),
482                        start_line: u32::try_from(start.line_index.saturating_add(1))
483                            .unwrap_or(u32::MAX),
484                        start_column: u32::try_from(start.column).unwrap_or(u32::MAX),
485                        end_line: u32::try_from(end.line_index.saturating_add(1))
486                            .unwrap_or(u32::MAX),
487                        end_column: u32::try_from(end.column).unwrap_or(u32::MAX),
488                    },
489                    line_ranges: occurrence.line_ranges.clone(),
490                }
491            })
492            .collect(),
493        occurrence_count: found.occurrence_count,
494        occurrences_truncated: found.occurrences_truncated(),
495        node_source: found.owner.source,
496        preview,
497        context,
498    }
499}
500
501fn occurrence_line_ranges(
502    markdown_range: Range<usize>,
503    markdown: &str,
504    lines: &LineIndex,
505) -> Vec<SearchLineRange> {
506    let start = lines.position(markdown, markdown_range.start).line_index;
507    let end = lines.line_index_at_byte(markdown_range.end.saturating_sub(1));
508    (start..=end)
509        .flat_map(|line_index| {
510            let line_start = lines.start(line_index);
511            let line = lines.line(markdown, line_index).trim_end();
512            let line_end = line_start.saturating_add(line.len());
513            let intersection =
514                markdown_range.start.max(line_start)..markdown_range.end.min(line_end);
515            (intersection.start < intersection.end)
516                .then(|| AnchorStrippedLine::new(line))
517                .into_iter()
518                .flat_map(move |visible| {
519                    visible.map_range(
520                        intersection.start.saturating_sub(line_start)
521                            ..intersection.end.saturating_sub(line_start),
522                    )
523                })
524                .map(move |range| SearchLineRange {
525                    line: u32::try_from(line_index.saturating_add(1)).unwrap_or(u32::MAX),
526                    start_byte: u32::try_from(range.start).unwrap_or(u32::MAX),
527                    end_byte: u32::try_from(range.end).unwrap_or(u32::MAX),
528                })
529        })
530        .collect()
531}
532
533fn presented_matched_text(occurrence: &RawOccurrence, markdown: &str, lines: &LineIndex) -> String {
534    let mut text = String::new();
535    let mut previous_line = None;
536    for range in &occurrence.line_ranges {
537        let line_index = usize::try_from(range.line.saturating_sub(1)).unwrap_or(usize::MAX);
538        if previous_line.is_some_and(|previous| previous != line_index) {
539            text.push('\n');
540        }
541        let line = display_markdown_line(lines.line(markdown, line_index));
542        let start = usize::try_from(range.start_byte).unwrap_or(usize::MAX);
543        let end = usize::try_from(range.end_byte).unwrap_or(usize::MAX);
544        if let Some(fragment) = line.get(start..end) {
545            text.push_str(fragment);
546        }
547        previous_line = Some(line_index);
548    }
549    text
550}
551
552/// Hide `ManT`'s zero-width source-map anchors from human-facing snippets.
553fn display_markdown_line(line: &str) -> String {
554    AnchorStrippedLine::new(line.trim_end()).text
555}
556
557struct AnchorStrippedLine {
558    text: String,
559    segments: Vec<OffsetSegment>,
560}
561
562impl AnchorStrippedLine {
563    fn new(line: &str) -> Self {
564        let mut text = String::with_capacity(line.len());
565        let mut segments = Vec::new();
566        let mut cursor = 0;
567        while let Some(relative_start) = line[cursor..].find("<a id=\"") {
568            let anchor_start = cursor + relative_start;
569            push_retained_line_segment(line, cursor..anchor_start, &mut text, &mut segments);
570            let anchor = &line[anchor_start..];
571            let Some(relative_end) = anchor.find("\"></a>") else {
572                push_retained_line_segment(
573                    line,
574                    anchor_start..line.len(),
575                    &mut text,
576                    &mut segments,
577                );
578                return Self { text, segments };
579            };
580            cursor = anchor_start + relative_end + "\"></a>".len();
581        }
582        push_retained_line_segment(line, cursor..line.len(), &mut text, &mut segments);
583        Self { text, segments }
584    }
585
586    fn map_range(&self, source: Range<usize>) -> Vec<Range<usize>> {
587        self.segments
588            .iter()
589            .filter_map(|segment| {
590                let start = source.start.max(segment.markdown.start);
591                let end = source.end.min(segment.markdown.end);
592                (start < end).then(|| {
593                    segment.visible.start + start.saturating_sub(segment.markdown.start)
594                        ..segment.visible.start + end.saturating_sub(segment.markdown.start)
595                })
596            })
597            .collect()
598    }
599}
600
601fn push_retained_line_segment(
602    line: &str,
603    source: Range<usize>,
604    text: &mut String,
605    segments: &mut Vec<OffsetSegment>,
606) {
607    if source.is_empty() {
608        return;
609    }
610    let visible_start = text.len();
611    text.push_str(&line[source.clone()]);
612    segments.push(OffsetSegment {
613        visible: visible_start..text.len(),
614        markdown: source,
615        linear: true,
616    });
617}
618
619struct TextPosition {
620    line_index: usize,
621    column: usize,
622}
623
624struct LineIndex {
625    starts: Vec<usize>,
626}
627
628impl LineIndex {
629    fn new(text: &str) -> Self {
630        let mut starts = vec![0];
631        starts.extend(
632            text.bytes()
633                .enumerate()
634                .filter_map(|(index, byte)| (byte == b'\n').then_some(index + 1)),
635        );
636        Self { starts }
637    }
638
639    fn count(&self) -> usize {
640        self.starts.len()
641    }
642
643    fn position(&self, text: &str, offset: usize) -> TextPosition {
644        let offset = offset.min(text.len());
645        let line_index = self.starts.partition_point(|start| *start <= offset) - 1;
646        let line_start = self.starts[line_index];
647        TextPosition {
648            line_index,
649            column: text[line_start..offset].chars().count().saturating_add(1),
650        }
651    }
652
653    fn line_index_at_byte(&self, offset: usize) -> usize {
654        self.starts.partition_point(|start| *start <= offset) - 1
655    }
656
657    fn line<'a>(&self, text: &'a str, line_index: usize) -> &'a str {
658        let start = self.starts[line_index];
659        let end = self
660            .starts
661            .get(line_index + 1)
662            .copied()
663            .unwrap_or(text.len());
664        text[start..end]
665            .strip_suffix('\n')
666            .unwrap_or(&text[start..end])
667    }
668
669    fn start(&self, line_index: usize) -> usize {
670        self.starts[line_index]
671    }
672}
673
674struct SearchableText {
675    text: String,
676    segments: Vec<OffsetSegment>,
677    direct_markdown: bool,
678}
679
680#[derive(Debug)]
681struct OffsetSegment {
682    visible: Range<usize>,
683    markdown: Range<usize>,
684    linear: bool,
685}
686
687impl SearchableText {
688    fn new(markdown: &str, scope: SearchScope) -> Self {
689        if scope == SearchScope::Markdown {
690            return Self {
691                text: markdown.to_owned(),
692                segments: Vec::new(),
693                direct_markdown: true,
694            };
695        }
696
697        let mut visible = VisibleBuilder::new(markdown);
698        for (event, source) in Parser::new(markdown).into_offset_iter() {
699            match event {
700                Event::Text(value) | Event::InlineMath(value) | Event::DisplayMath(value) => {
701                    visible.push_mapped(&value, source, InlineMappingKind::Text);
702                }
703                Event::Code(value) => {
704                    visible.push_mapped(&value, source, InlineMappingKind::Code);
705                }
706                Event::SoftBreak | Event::HardBreak | Event::Rule => visible.push_break(source),
707                Event::End(
708                    TagEnd::Paragraph
709                    | TagEnd::Heading(_)
710                    | TagEnd::Item
711                    | TagEnd::CodeBlock
712                    | TagEnd::TableRow,
713                ) => visible.push_break(source.end..source.end),
714                Event::Start(_)
715                | Event::End(_)
716                | Event::Html(_)
717                | Event::InlineHtml(_)
718                | Event::FootnoteReference(_)
719                | Event::TaskListMarker(_) => {}
720            }
721        }
722        visible.finish()
723    }
724
725    fn markdown_start(&self, offset: usize) -> usize {
726        if self.direct_markdown {
727            return offset;
728        }
729        self.segment_at(offset).map_or(0, |segment| {
730            if segment.linear {
731                segment.markdown.start + offset.saturating_sub(segment.visible.start)
732            } else {
733                segment.markdown.start
734            }
735        })
736    }
737
738    fn markdown_end(&self, offset: usize) -> usize {
739        if self.direct_markdown {
740            return offset;
741        }
742        if offset == 0 {
743            return 0;
744        }
745        self.segment_at(offset - 1).map_or(0, |segment| {
746            if segment.linear {
747                segment.markdown.start + offset.saturating_sub(segment.visible.start)
748            } else {
749                segment.markdown.end
750            }
751        })
752    }
753
754    fn segment_at(&self, offset: usize) -> Option<&OffsetSegment> {
755        let index = self
756            .segments
757            .partition_point(|segment| segment.visible.end <= offset);
758        self.segments
759            .get(index)
760            .filter(|segment| segment.visible.contains(&offset))
761    }
762}
763
764struct VisibleBuilder<'a> {
765    markdown: &'a str,
766    text: String,
767    segments: Vec<OffsetSegment>,
768}
769
770impl<'a> VisibleBuilder<'a> {
771    fn new(markdown: &'a str) -> Self {
772        Self {
773            markdown,
774            text: String::new(),
775            segments: Vec::new(),
776        }
777    }
778
779    fn push_mapped(&mut self, value: &str, source: Range<usize>, kind: InlineMappingKind) {
780        for mapped in map_inline_characters(self.markdown, value, source, kind) {
781            let visible_start = self.text.len();
782            self.text.push(mapped.value);
783            let visible_end = self.text.len();
784            self.push_segment(OffsetSegment {
785                visible: visible_start..visible_end,
786                markdown: mapped.source,
787                linear: mapped.linear,
788            });
789        }
790    }
791
792    fn push_break(&mut self, markdown: Range<usize>) {
793        if self.text.ends_with('\n') || self.text.is_empty() {
794            return;
795        }
796        let start = self.text.len();
797        self.text.push('\n');
798        self.push_segment(OffsetSegment {
799            visible: start..self.text.len(),
800            markdown,
801            linear: false,
802        });
803    }
804
805    fn push_segment(&mut self, segment: OffsetSegment) {
806        if let Some(previous) = self.segments.last_mut() {
807            let contiguous = previous.visible.end == segment.visible.start
808                && previous.markdown.end == segment.markdown.start
809                && previous.linear
810                && segment.linear;
811            if contiguous {
812                previous.visible.end = segment.visible.end;
813                previous.markdown.end = segment.markdown.end;
814                return;
815            }
816        }
817        self.segments.push(segment);
818    }
819
820    fn finish(self) -> SearchableText {
821        SearchableText {
822            text: self.text,
823            segments: self.segments,
824            direct_markdown: false,
825        }
826    }
827}
828
829#[cfg(test)]
830mod tests {
831    use crate::ResolvedContent;
832    use mant_ir::{
833        Block, DefinitionCase, DefinitionIdentity, DefinitionItem, DefinitionRole, Document,
834        DocumentMeta, DocumentSource, Inline, LayoutHint, Section, SourceFormat,
835    };
836    use mant_protocol::{
837        MAX_SEARCH_PATTERN_CHARS, SearchCase, SearchQuery, SearchScope, SearchSyntax,
838    };
839
840    use super::{
841        LineIndex, MAX_OCCURRENCES_PER_MATCH, SearchError, display_markdown_line,
842        occurrence_line_ranges, render_addressable_markdown, search_query, validate_search_query,
843    };
844
845    fn query() -> ResolvedContent {
846        ResolvedContent {
847            address: None,
848            label: "demo".to_owned(),
849            document: Some(Document {
850                parser: None,
851                source: DocumentSource {
852                    format: SourceFormat::Man,
853                    path: None,
854                },
855                meta: DocumentMeta {
856                    manual_section: Some("1".to_owned()),
857                    ..DocumentMeta::default()
858                },
859                diagnostics: Vec::new(),
860                blocks: Vec::new(),
861                sections: vec![Section {
862                    id: "options-1".to_owned().into(),
863                    title: "OPTIONS".to_owned(),
864                    spacing_before_lines: 0,
865                    blocks: vec![Block::DefinitionList {
866                        items: vec![DefinitionItem {
867                            inline_term: false,
868                            identity: Some(DefinitionIdentity {
869                                id: "option-acls".to_owned().into(),
870                                role: DefinitionRole::Option,
871                                case: DefinitionCase::Sensitive,
872                                names: vec!["--acls".to_owned()],
873                            }),
874                            terms: vec![vec![
875                                Inline::Anchor {
876                                    id: "option-acls".to_owned().into(),
877                                },
878                                Inline::Code {
879                                    value: "--acls".to_owned(),
880                                },
881                            ]],
882                            description: vec![Block::Paragraph {
883                                children: vec![
884                                    Inline::Text {
885                                        value: "Preserve ".to_owned(),
886                                    },
887                                    Inline::Strong {
888                                        children: vec![Inline::Text {
889                                            value: "access control".to_owned(),
890                                        }],
891                                    },
892                                    Inline::Text {
893                                        value: " lists".to_owned(),
894                                    },
895                                ],
896                                layout: LayoutHint::default(),
897                                source: None,
898                            }],
899                            spacing_before_lines: None,
900                        }],
901                        compact: true,
902                        layout: LayoutHint::default(),
903                        source: None,
904                    }],
905                    children: Vec::new(),
906                    source: None,
907                }],
908            }),
909            tldr: None,
910        }
911    }
912
913    fn request(pattern: &str) -> SearchQuery {
914        SearchQuery {
915            pattern: pattern.to_owned(),
916            syntax: SearchSyntax::Literal,
917            case: SearchCase::Insensitive,
918            scope: SearchScope::Visible,
919            word: false,
920            context_lines: 1,
921            limit: 100,
922            offset: 0,
923        }
924    }
925
926    #[test]
927    fn visible_search_maps_inline_formatting_to_markdown_and_option_nodes() {
928        let result = search_query(&query(), &request("access control")).expect("search");
929
930        assert_eq!(result.total, 1);
931        assert_eq!(result.matches[0].outline.node.path(), "1/e1");
932        assert_eq!(
933            result.matches[0].occurrences[0].matched_text,
934            "access control"
935        );
936        assert_eq!(result.matches[0].occurrences[0].line_ranges.len(), 1);
937        assert!(result.matches[0].occurrences[0].markdown.start_line > 1);
938        assert!(result.matches[0].preview.contains("**access control**"));
939        assert!(!result.matches[0].preview.contains("<a id="));
940        assert!(!result.matches[0].context.is_empty());
941    }
942
943    #[test]
944    fn presented_line_ranges_exclude_trimmed_unicode_trailing_space() {
945        let markdown = "zz 日本語   \nnext";
946        let lines = LineIndex::new(markdown);
947        let ranges = occurrence_line_ranges(3..15, markdown, &lines);
948
949        assert_eq!(display_markdown_line(lines.line(markdown, 0)), "zz 日本語");
950        assert_eq!(ranges.len(), 1);
951        assert_eq!(ranges[0].line, 1);
952        assert_eq!(ranges[0].start_byte, 3);
953        assert_eq!(ranges[0].end_byte, 12);
954    }
955
956    #[test]
957    fn searches_contiguous_text_across_an_unsafe_style_boundary() {
958        let mut query = query();
959        query.document.as_mut().expect("fixture document").sections[0]
960            .blocks
961            .push(Block::Paragraph {
962                children: vec![
963                    Inline::Text {
964                        value: "disabled with --".to_owned(),
965                    },
966                    Inline::Strong {
967                        children: vec![Inline::Text {
968                            value: "no-".to_owned(),
969                        }],
970                    },
971                    Inline::Text {
972                        value: "option".to_owned(),
973                    },
974                ],
975                layout: LayoutHint::default(),
976                source: None,
977            });
978
979        let visible = search_query(&query, &request("no-option")).expect("visible search");
980        assert_eq!(visible.total, 1);
981        assert_eq!(visible.matches[0].occurrences[0].matched_text, "no-option");
982        assert!(visible.matches[0].preview.contains("--no-option"));
983        assert!(!visible.matches[0].preview.contains("**no-**"));
984
985        let markdown = search_query(
986            &query,
987            &SearchQuery {
988                scope: SearchScope::Markdown,
989                ..request("no-option")
990            },
991        )
992        .expect("Markdown search");
993        assert_eq!(markdown.total, 1);
994        assert_eq!(markdown.matches[0].occurrences[0].matched_text, "no-option");
995    }
996
997    #[test]
998    fn source_map_stripping_accepts_only_complete_empty_anchors() {
999        assert_eq!(
1000            display_markdown_line("before<a id=\"node\"></a>after"),
1001            "beforeafter"
1002        );
1003        assert_eq!(
1004            display_markdown_line("before<a id=\"node\">payload</a>after"),
1005            "before<a id=\"node\">payload</a>after"
1006        );
1007        assert_eq!(
1008            display_markdown_line("before<a id=\"node\"after"),
1009            "before<a id=\"node\"after"
1010        );
1011    }
1012
1013    #[test]
1014    fn visible_regex_anchors_apply_to_rendered_lines_not_the_whole_document() {
1015        for pattern in [r"^--acls", r"lists$"] {
1016            let mut request = request(pattern);
1017            request.syntax = SearchSyntax::Regex;
1018            request.case = SearchCase::Sensitive;
1019
1020            let result = search_query(&query(), &request).expect("search");
1021
1022            assert_eq!(result.total, 1, "pattern {pattern:?}");
1023            assert_eq!(result.matches[0].outline.node.path(), "1/e1");
1024        }
1025    }
1026
1027    #[test]
1028    fn synthetic_visible_whitespace_occurrences_are_skipped_without_failing_the_query() {
1029        for pattern in [r"\n", r"\s", r"\s+", "[[:space:]]"] {
1030            let mut request = request(pattern);
1031            request.syntax = SearchSyntax::Regex;
1032            request.case = SearchCase::Sensitive;
1033
1034            let result = search_query(&query(), &request).expect("valid whitespace search");
1035            assert!(
1036                result
1037                    .matches
1038                    .iter()
1039                    .flat_map(|hit| &hit.occurrences)
1040                    .all(|occurrence| !occurrence.line_ranges.is_empty()),
1041                "pattern {pattern:?} emitted an unpresentable occurrence"
1042            );
1043        }
1044    }
1045
1046    #[test]
1047    fn markdown_anchor_only_matches_do_not_become_phantom_results() {
1048        let mut request = request("a id=");
1049        request.scope = SearchScope::Markdown;
1050        request.case = SearchCase::Sensitive;
1051
1052        let result = search_query(&query(), &request).expect("search internal anchor text");
1053
1054        assert_eq!(result.total, 0);
1055        assert!(result.matches.is_empty());
1056    }
1057
1058    #[test]
1059    fn markdown_matches_crossing_an_anchor_expose_only_presented_text() {
1060        let markdown = render_addressable_markdown(&query()).text;
1061        let marker = "\"></a>`--acls";
1062        assert!(
1063            markdown.contains(marker),
1064            "fixture anchor shape changed:\n{markdown}"
1065        );
1066        let mut request = request(marker);
1067        request.scope = SearchScope::Markdown;
1068        request.case = SearchCase::Sensitive;
1069
1070        let result = search_query(&query(), &request).expect("search across source-map anchor");
1071        let occurrence = &result.matches[0].occurrences[0];
1072
1073        assert_eq!(occurrence.matched_text, "`--acls");
1074        assert!(!occurrence.line_ranges.is_empty());
1075        assert!(!result.matches[0].preview.contains("<a id="));
1076    }
1077
1078    #[test]
1079    fn visible_search_maps_padded_code_span_content_not_its_delimiters() {
1080        for value in ["`x", "x`", " x", "x ", "`x`"] {
1081            let mut query = query();
1082            let Block::DefinitionList { items, .. } =
1083                &mut query.document.as_mut().expect("manual").sections[0].blocks[0]
1084            else {
1085                panic!("fixture contains a definition list");
1086            };
1087            items[0].description = vec![Block::Paragraph {
1088                children: vec![Inline::Code {
1089                    value: value.to_owned(),
1090                }],
1091                layout: LayoutHint::default(),
1092                source: None,
1093            }];
1094            let markdown = render_addressable_markdown(&query).text;
1095
1096            let result = search_query(&query, &request(value)).expect("search");
1097            let occurrence = &result.matches[0].occurrences[0];
1098            let start = usize::try_from(occurrence.markdown.start_byte).expect("small fixture");
1099            let end = usize::try_from(occurrence.markdown.end_byte).expect("small fixture");
1100
1101            assert_eq!(&markdown[start..end], value, "code value {value:?}");
1102            assert_eq!(occurrence.line_ranges.len(), 1, "code value {value:?}");
1103            let line = &occurrence.line_ranges[0];
1104            let line_start = usize::try_from(line.start_byte).expect("small fixture");
1105            let line_end = usize::try_from(line.end_byte).expect("small fixture");
1106            assert_eq!(
1107                &result.matches[0].preview[line_start..line_end],
1108                value,
1109                "code value {value:?}"
1110            );
1111        }
1112    }
1113
1114    #[test]
1115    fn visible_search_maps_an_explicit_line_break_to_its_markdown_byte() {
1116        let mut query = query();
1117        let Block::DefinitionList { items, .. } =
1118            &mut query.document.as_mut().expect("manual").sections[0].blocks[0]
1119        else {
1120            panic!("fixture contains a definition list");
1121        };
1122        items[0].description = vec![Block::Paragraph {
1123            children: vec![
1124                Inline::Text {
1125                    value: "alpha".to_owned(),
1126                },
1127                Inline::LineBreak,
1128                Inline::Text {
1129                    value: "beta".to_owned(),
1130                },
1131            ],
1132            layout: LayoutHint::default(),
1133            source: None,
1134        }];
1135        let markdown = render_addressable_markdown(&query).text;
1136
1137        let result = search_query(&query, &request("alpha\n")).expect("search");
1138        let occurrence = &result.matches[0].occurrences[0];
1139        let start = usize::try_from(occurrence.markdown.start_byte).expect("small fixture");
1140        let end = usize::try_from(occurrence.markdown.end_byte).expect("small fixture");
1141
1142        assert_eq!(&markdown[start..end], "alpha  \n");
1143    }
1144
1145    #[test]
1146    fn same_line_occurrences_form_one_paginated_search_result() {
1147        let mut query = query();
1148        let Block::DefinitionList { items, .. } =
1149            &mut query.document.as_mut().expect("manual").sections[0].blocks[0]
1150        else {
1151            panic!("fixture contains a definition list");
1152        };
1153        items[0].description = vec![Block::Paragraph {
1154            children: vec![Inline::Text {
1155                value: "needle, then another needle on one line".to_owned(),
1156            }],
1157            layout: LayoutHint::default(),
1158            source: None,
1159        }];
1160
1161        let mut request = request("needle");
1162        request.limit = 1;
1163        let result = search_query(&query, &request).expect("search");
1164
1165        assert_eq!(result.total, 1);
1166        assert_eq!(result.returned, 1);
1167        assert_eq!(result.matches[0].occurrences.len(), 2);
1168        assert_eq!(
1169            result.matches[0].occurrences[0].markdown.start_line,
1170            result.matches[0].occurrences[1].markdown.start_line
1171        );
1172        assert!(!result.truncated);
1173    }
1174
1175    #[test]
1176    fn one_repetitive_line_has_bounded_occurrence_details() {
1177        let mut query = query();
1178        let Block::DefinitionList { items, .. } =
1179            &mut query.document.as_mut().expect("manual").sections[0].blocks[0]
1180        else {
1181            panic!("fixture contains a definition list");
1182        };
1183        let occurrence_count = MAX_OCCURRENCES_PER_MATCH + 7;
1184        items[0].description = vec![Block::Paragraph {
1185            children: vec![Inline::Text {
1186                value: vec!["needle"; occurrence_count].join(" "),
1187            }],
1188            layout: LayoutHint::default(),
1189            source: None,
1190        }];
1191
1192        let result = search_query(&query, &request("needle")).expect("search");
1193
1194        assert_eq!(result.total, 1);
1195        assert_eq!(
1196            result.matches[0].occurrence_count,
1197            u32::try_from(occurrence_count).expect("small fixture")
1198        );
1199        assert_eq!(
1200            result.matches[0].occurrences.len(),
1201            MAX_OCCURRENCES_PER_MATCH
1202        );
1203        assert!(result.matches[0].occurrences_truncated);
1204    }
1205
1206    #[test]
1207    fn semantic_entry_ownership_ends_before_a_following_section_paragraph() {
1208        let mut query = query();
1209        query.document.as_mut().expect("manual").sections[0]
1210            .blocks
1211            .push(Block::Paragraph {
1212                children: vec![Inline::Text {
1213                    value: "General section tail".to_owned(),
1214                }],
1215                layout: LayoutHint::default(),
1216                source: None,
1217            });
1218
1219        let result = search_query(&query, &request("section tail")).expect("search");
1220        assert!(matches!(
1221            &result.matches[0].outline.node,
1222            mant_protocol::OutlineNodeReference::DocumentSection { path, .. } if path == "1"
1223        ));
1224    }
1225
1226    #[test]
1227    fn root_content_search_resolves_to_an_addressable_document_root() {
1228        let mut query = query();
1229        let document = query.document.as_mut().expect("document");
1230        document.source.format = SourceFormat::Markdown;
1231        document.blocks.push(Block::Paragraph {
1232            children: vec![Inline::Text {
1233                value: "Read the preface needle first.".to_owned(),
1234            }],
1235            layout: LayoutHint::default(),
1236            source: None,
1237        });
1238
1239        let result = search_query(&query, &request("preface needle")).expect("root search");
1240
1241        assert_eq!(result.total, 1);
1242        assert!(matches!(
1243            &result.matches[0].outline.node,
1244            mant_protocol::OutlineNodeReference::DocumentRoot { path, id, .. }
1245                if path == "root" && id == "document-overview"
1246        ));
1247        assert!(result.matches[0].outline.ancestors.is_empty());
1248        assert!(result.matches[0].preview.contains("preface needle"));
1249    }
1250
1251    #[test]
1252    fn embedded_tldr_and_markdown_body_keep_distinct_search_owners() {
1253        let query = crate::query_markdown_text(
1254            "\
1255<!-- mant:tldr:start -->
1256# demo
1257
1258> Quick needle.
1259
1260- Run:
1261
1262`demo quick-command`
1263<!-- mant:tldr:end -->
1264
1265# Demo
1266
1267Read the overview needle.
1268
1269## Synopsis
1270
1271Manual needle.
1272",
1273            Some("demo.md".to_owned()),
1274        )
1275        .expect("Markdown query");
1276
1277        let quick = search_query(&query, &request("quick needle")).expect("tldr search");
1278        assert!(matches!(
1279            &quick.matches[0].outline.node,
1280            mant_protocol::OutlineNodeReference::Tldr { path, id, .. }
1281                if path == "0" && id == "tldr"
1282        ));
1283
1284        let overview = search_query(&query, &request("overview needle")).expect("root search");
1285        assert!(matches!(
1286            &overview.matches[0].outline.node,
1287            mant_protocol::OutlineNodeReference::DocumentRoot { path, .. } if path == "root"
1288        ));
1289
1290        let manual = search_query(&query, &request("manual needle")).expect("section search");
1291        assert!(matches!(
1292            &manual.matches[0].outline.node,
1293            mant_protocol::OutlineNodeReference::DocumentSection { path, id, .. }
1294                if path == "1" && id == "synopsis"
1295        ));
1296    }
1297
1298    #[test]
1299    fn regex_case_and_pagination_are_reported_without_losing_global_ordinals() {
1300        let mut request = request("ACLS|control");
1301        request.syntax = SearchSyntax::Regex;
1302        request.case = SearchCase::Insensitive;
1303        request.limit = 1;
1304        request.offset = 1;
1305        let result = search_query(&query(), &request).expect("search");
1306
1307        assert_eq!(result.total, 2);
1308        assert_eq!(result.returned, 1);
1309        assert_eq!(result.matches[0].ordinal, 2);
1310        assert!(!result.truncated);
1311    }
1312
1313    #[test]
1314    fn regexes_that_match_empty_text_are_rejected() {
1315        for pattern in ["$", r"\b", r"\B", "a*"] {
1316            let mut request = request(pattern);
1317            request.syntax = SearchSyntax::Regex;
1318            let error = search_query(&query(), &request).expect_err("empty regex match");
1319            assert!(
1320                error.to_string().contains("must not match empty text"),
1321                "pattern {pattern:?}: {error}"
1322            );
1323        }
1324    }
1325
1326    #[test]
1327    fn search_results_never_cross_addressable_owner_boundaries() {
1328        let mut query = query();
1329        query
1330            .document
1331            .as_mut()
1332            .expect("document")
1333            .sections
1334            .push(Section {
1335                id: "next".into(),
1336                title: "NEXT".to_owned(),
1337                spacing_before_lines: 0,
1338                blocks: vec![Block::Paragraph {
1339                    children: vec![Inline::Text {
1340                        value: "Following owner".to_owned(),
1341                    }],
1342                    layout: LayoutHint::default(),
1343                    source: None,
1344                }],
1345                children: Vec::new(),
1346                source: None,
1347            });
1348        let mut request = request(r"lists(?s:.*?)NEXT");
1349        request.syntax = SearchSyntax::Regex;
1350        request.scope = SearchScope::Markdown;
1351        request.case = SearchCase::Sensitive;
1352
1353        let result = search_query(&query, &request).expect("bounded owner search");
1354
1355        assert_eq!(result.total, 0);
1356    }
1357
1358    #[test]
1359    fn exclusive_newline_end_does_not_mark_the_following_context_line() {
1360        let mut query = query();
1361        query.document.as_mut().expect("document").sections[0]
1362            .blocks
1363            .push(Block::Paragraph {
1364                children: vec![
1365                    Inline::Text {
1366                        value: "alpha".to_owned(),
1367                    },
1368                    Inline::LineBreak,
1369                    Inline::Text {
1370                        value: "beta".to_owned(),
1371                    },
1372                ],
1373                layout: LayoutHint::default(),
1374                source: None,
1375            });
1376        let mut request = request("alpha  \n");
1377        request.scope = SearchScope::Markdown;
1378        request.case = SearchCase::Sensitive;
1379        let result = search_query(&query, &request).expect("newline search");
1380        let hit = &result.matches[0];
1381        let occurrence = &hit.occurrences[0];
1382
1383        assert_eq!(occurrence.line_ranges.len(), 1);
1384        let following = hit
1385            .context
1386            .iter()
1387            .find(|line| line.line == occurrence.markdown.end_line)
1388            .expect("following context line");
1389        assert!(!following.matched);
1390    }
1391
1392    #[test]
1393    fn multibyte_match_ends_remain_valid_coordinate_boundaries() {
1394        let mut query = query();
1395        query.document.as_mut().expect("document").sections[0]
1396            .blocks
1397            .push(Block::Paragraph {
1398                children: vec![Inline::Text {
1399                    value: "café — 日本".to_owned(),
1400                }],
1401                layout: LayoutHint::default(),
1402                source: None,
1403            });
1404        let mut request = request("—");
1405        request.case = SearchCase::Sensitive;
1406
1407        let result = search_query(&query, &request).expect("Unicode search");
1408        let occurrence = &result.matches[0].occurrences[0];
1409
1410        assert_eq!(occurrence.matched_text, "—");
1411        assert_eq!(
1412            occurrence.markdown.end_column,
1413            occurrence.markdown.start_column + 1
1414        );
1415    }
1416
1417    #[test]
1418    fn byte_mode_regexes_are_rejected_before_matching_unicode_text() {
1419        let mut request = request("(?-u:.)");
1420        request.syntax = SearchSyntax::Regex;
1421        let error = search_query(&query(), &request).expect_err("byte-oriented regex");
1422
1423        assert!(error.to_string().contains("UTF-8 character boundaries"));
1424    }
1425
1426    #[test]
1427    fn regex_syntax_errors_retain_their_actual_cause() {
1428        for (pattern, expected) in [
1429            ("(", "unclosed group"),
1430            ("a{2,1}", "invalid repetition count range"),
1431            ("[z-a]", "invalid character class range"),
1432        ] {
1433            let mut request = request(pattern);
1434            request.syntax = SearchSyntax::Regex;
1435            let error = validate_search_query(&request).expect_err("invalid regex");
1436            let message = error.to_string();
1437            assert!(message.contains(expected), "{pattern}: {message}");
1438            assert!(!message.contains("Unicode mode cannot be disabled"));
1439        }
1440    }
1441
1442    #[test]
1443    fn compiled_regex_programs_use_the_project_resource_budget() {
1444        let mut oversized = request("((a{100}){100}){100}");
1445        oversized.syntax = SearchSyntax::Regex;
1446
1447        let error = validate_search_query(&oversized).expect_err("reject oversized regex program");
1448
1449        assert_eq!(
1450            error,
1451            SearchError::InvalidPattern(
1452                "regular expression exceeds ManT's compiled-size limit".to_owned()
1453            )
1454        );
1455        let mut ordinary = request("needle|ordinary");
1456        ordinary.syntax = SearchSyntax::Regex;
1457        assert_eq!(validate_search_query(&ordinary), Ok(()));
1458    }
1459
1460    #[test]
1461    fn search_pattern_limit_counts_unicode_scalars() {
1462        let valid = request(&"界".repeat(MAX_SEARCH_PATTERN_CHARS));
1463        assert_eq!(validate_search_query(&valid), Ok(()));
1464
1465        let request = request(&"界".repeat(MAX_SEARCH_PATTERN_CHARS + 1));
1466        assert_eq!(
1467            validate_search_query(&request),
1468            Err(SearchError::PatternTooLong)
1469        );
1470    }
1471}