1use 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#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum SearchError {
34 EmptyPattern,
36 PatternTooLong,
38 InvalidLimit,
40 ContextTooLarge,
42 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
69pub 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
169pub 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
469fn 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}