1use 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#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum SearchError {
35 EmptyPattern,
37 PatternTooLong,
39 InvalidLimit,
41 ContextTooLarge,
43 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
70pub 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 return true;
173 }
174 let line_ranges = occurrence_line_ranges(markdown_start..markdown_end, markdown, lines);
175 if line_ranges.is_empty() {
176 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
208pub 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
552fn 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}