1use std::{error::Error, fmt, ops::Range};
8
9use grep_matcher::Matcher;
10use grep_regex::RegexMatcherBuilder;
11use mant_protocol::{
12 MarkdownSchema, QuerySearch, SearchCase, SearchContextLine, SearchMarkdownRange, SearchMatch,
13 SearchQuery, SearchRender, SearchRenderFormat, SearchRenderScope, SearchSchema, SearchScope,
14 SearchSyntax,
15};
16use pulldown_cmark::{Event, Parser, TagEnd};
17
18use crate::{ResolvedContent, output::render_addressable_markdown};
19
20mod owners;
21
22use owners::{Owner, OwnerIndex};
23
24const MAX_PATTERN_BYTES: usize = 4096;
25const MAX_CONTEXT_LINES: u16 = 100;
26const MAX_SEARCH_LIMIT: u32 = 10_000;
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum SearchError {
31 EmptyPattern,
33 PatternTooLong,
35 InvalidLimit,
37 ContextTooLarge,
39 InvalidPattern(String),
41}
42
43impl fmt::Display for SearchError {
44 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
45 match self {
46 Self::EmptyPattern => formatter.write_str("search pattern must not be empty"),
47 Self::PatternTooLong => write!(
48 formatter,
49 "search pattern exceeds the {MAX_PATTERN_BYTES}-byte limit"
50 ),
51 Self::InvalidLimit => write!(
52 formatter,
53 "search limit must be between 1 and {MAX_SEARCH_LIMIT}"
54 ),
55 Self::ContextTooLarge => write!(
56 formatter,
57 "search context must not exceed {MAX_CONTEXT_LINES} lines"
58 ),
59 Self::InvalidPattern(message) => write!(formatter, "invalid search pattern: {message}"),
60 }
61 }
62}
63
64impl Error for SearchError {}
65
66pub fn search_query(
73 query: &ResolvedContent,
74 request: &SearchQuery,
75) -> Result<QuerySearch, SearchError> {
76 validate_request(request)?;
77 let artifact = render_addressable_markdown(query);
78 let markdown = &artifact.text;
79 let lines = LineIndex::new(markdown);
80 let owners = OwnerIndex::new(&artifact);
81 let searchable = SearchableText::new(markdown, request.scope);
82 let matcher = build_matcher(request)?;
83 let mut raw_matches = Vec::new();
84
85 matcher
86 .find_iter(searchable.text.as_bytes(), |found| {
87 let markdown_start = searchable.markdown_start(found.start());
88 let markdown_end = searchable.markdown_end(found.end());
89 if let Some(owner) = owners.owner(markdown_start) {
90 raw_matches.push(RawMatch {
91 searchable: found.start()..found.end(),
92 markdown: markdown_start..markdown_end,
93 owner: owner.clone(),
94 });
95 }
96 true
97 })
98 .map_err(|error| SearchError::InvalidPattern(error.to_string()))?;
99
100 let total = u32::try_from(raw_matches.len()).unwrap_or(u32::MAX);
101 let offset = usize::try_from(request.offset).unwrap_or(usize::MAX);
102 let limit = usize::try_from(request.limit).unwrap_or(usize::MAX);
103 let selected = raw_matches
104 .iter()
105 .enumerate()
106 .skip(offset)
107 .take(limit)
108 .map(|(index, found)| {
109 build_match(
110 index,
111 found,
112 &searchable.text,
113 markdown,
114 &lines,
115 request.context_lines,
116 )
117 })
118 .collect::<Vec<_>>();
119 let returned = u32::try_from(selected.len()).unwrap_or(u32::MAX);
120 let consumed = request.offset.saturating_add(returned);
121 let truncated = consumed < total;
122
123 Ok(QuerySearch {
124 schema: SearchSchema::V7,
125 label: query.label.clone(),
126 source: query
127 .document
128 .as_ref()
129 .map(|document| document.source.clone()),
130 meta: query
131 .document
132 .as_ref()
133 .map(|document| document.meta.clone()),
134 query: request.clone(),
135 render: SearchRender {
136 schema: MarkdownSchema::V1,
137 format: SearchRenderFormat::Markdown,
138 scope: SearchRenderScope::Full,
139 line_base: 1,
140 column_base: 1,
141 line_count: u32::try_from(lines.count()).unwrap_or(u32::MAX),
142 },
143 total,
144 returned,
145 offset: request.offset,
146 truncated,
147 next_offset: truncated.then_some(consumed),
148 matches: selected,
149 })
150}
151
152pub fn validate_search_query(request: &SearchQuery) -> Result<(), SearchError> {
158 validate_request(request)?;
159 build_matcher(request).map(|_| ())
160}
161
162fn validate_request(request: &SearchQuery) -> Result<(), SearchError> {
163 if request.pattern.is_empty() {
164 return Err(SearchError::EmptyPattern);
165 }
166 if request.pattern.len() > MAX_PATTERN_BYTES {
167 return Err(SearchError::PatternTooLong);
168 }
169 if request.limit == 0 || request.limit > MAX_SEARCH_LIMIT {
170 return Err(SearchError::InvalidLimit);
171 }
172 if request.context_lines > MAX_CONTEXT_LINES {
173 return Err(SearchError::ContextTooLarge);
174 }
175 Ok(())
176}
177
178fn build_matcher(request: &SearchQuery) -> Result<grep_regex::RegexMatcher, SearchError> {
179 let mut builder = RegexMatcherBuilder::new();
180 builder
181 .fixed_strings(request.syntax == SearchSyntax::Literal)
182 .word(request.word);
183 match request.case {
184 SearchCase::Insensitive => {
185 builder.case_insensitive(true);
186 }
187 SearchCase::Sensitive => {
188 builder.case_insensitive(false);
189 }
190 SearchCase::Smart => {
191 builder.case_smart(true);
192 }
193 }
194 let matcher = builder
195 .build(&request.pattern)
196 .map_err(|error| SearchError::InvalidPattern(error.to_string()))?;
197 if matcher
198 .is_match(b"")
199 .map_err(|error| SearchError::InvalidPattern(error.to_string()))?
200 {
201 return Err(SearchError::InvalidPattern(
202 "pattern must not match empty text".to_owned(),
203 ));
204 }
205 Ok(matcher)
206}
207
208#[derive(Clone)]
209struct RawMatch {
210 searchable: Range<usize>,
211 markdown: Range<usize>,
212 owner: Owner,
213}
214
215fn build_match(
216 index: usize,
217 found: &RawMatch,
218 searchable: &str,
219 markdown: &str,
220 lines: &LineIndex,
221 context_lines: u16,
222) -> SearchMatch {
223 let start = lines.position(markdown, found.markdown.start);
224 let end = lines.position(markdown, found.markdown.end);
225 let preview = display_markdown_line(lines.line(markdown, start.line_index));
226 let context_start = start.line_index.saturating_sub(usize::from(context_lines));
227 let context_end = end
228 .line_index
229 .saturating_add(usize::from(context_lines))
230 .min(lines.count().saturating_sub(1));
231 let context = if context_lines == 0 {
232 Vec::new()
233 } else {
234 (context_start..=context_end)
235 .map(|line_index| SearchContextLine {
236 line: u32::try_from(line_index.saturating_add(1)).unwrap_or(u32::MAX),
237 text: display_markdown_line(lines.line(markdown, line_index)),
238 matched: (start.line_index..=end.line_index).contains(&line_index),
239 })
240 .collect()
241 };
242
243 SearchMatch {
244 ordinal: u32::try_from(index.saturating_add(1)).unwrap_or(u32::MAX),
245 node: found.owner.node.clone(),
246 section: found.owner.section.clone(),
247 matched_text: searchable[found.searchable.clone()].to_owned(),
248 markdown: SearchMarkdownRange {
249 start_byte: u64::try_from(found.markdown.start).unwrap_or(u64::MAX),
250 end_byte: u64::try_from(found.markdown.end).unwrap_or(u64::MAX),
251 start_line: u32::try_from(start.line_index.saturating_add(1)).unwrap_or(u32::MAX),
252 start_column: u32::try_from(start.column).unwrap_or(u32::MAX),
253 end_line: u32::try_from(end.line_index.saturating_add(1)).unwrap_or(u32::MAX),
254 end_column: u32::try_from(end.column).unwrap_or(u32::MAX),
255 },
256 source: found.owner.source,
257 preview,
258 context,
259 }
260}
261
262fn display_markdown_line(line: &str) -> String {
264 let mut output = String::with_capacity(line.len());
265 let mut remaining = line.trim_end();
266 while let Some(start) = remaining.find("<a id=\"") {
267 output.push_str(&remaining[..start]);
268 let anchor = &remaining[start..];
269 let Some(end) = anchor.find("</a>") else {
270 output.push_str(anchor);
271 return output;
272 };
273 remaining = &anchor[end + "</a>".len()..];
274 }
275 output.push_str(remaining);
276 output
277}
278
279struct TextPosition {
280 line_index: usize,
281 column: usize,
282}
283
284struct LineIndex {
285 starts: Vec<usize>,
286}
287
288impl LineIndex {
289 fn new(text: &str) -> Self {
290 let mut starts = vec![0];
291 starts.extend(
292 text.bytes()
293 .enumerate()
294 .filter_map(|(index, byte)| (byte == b'\n').then_some(index + 1)),
295 );
296 Self { starts }
297 }
298
299 fn count(&self) -> usize {
300 self.starts.len()
301 }
302
303 fn position(&self, text: &str, offset: usize) -> TextPosition {
304 let offset = offset.min(text.len());
305 let line_index = self.starts.partition_point(|start| *start <= offset) - 1;
306 let line_start = self.starts[line_index];
307 TextPosition {
308 line_index,
309 column: text[line_start..offset].chars().count().saturating_add(1),
310 }
311 }
312
313 fn line<'a>(&self, text: &'a str, line_index: usize) -> &'a str {
314 let start = self.starts[line_index];
315 let end = self
316 .starts
317 .get(line_index + 1)
318 .copied()
319 .unwrap_or(text.len());
320 text[start..end]
321 .strip_suffix('\n')
322 .unwrap_or(&text[start..end])
323 }
324}
325
326struct SearchableText {
327 text: String,
328 segments: Vec<OffsetSegment>,
329 direct_markdown: bool,
330}
331
332#[derive(Debug)]
333struct OffsetSegment {
334 visible: Range<usize>,
335 markdown: Range<usize>,
336}
337
338impl SearchableText {
339 fn new(markdown: &str, scope: SearchScope) -> Self {
340 if scope == SearchScope::Markdown {
341 return Self {
342 text: markdown.to_owned(),
343 segments: Vec::new(),
344 direct_markdown: true,
345 };
346 }
347
348 let mut visible = VisibleBuilder::new(markdown);
349 for (event, source) in Parser::new(markdown).into_offset_iter() {
350 match event {
351 Event::Text(value)
352 | Event::Code(value)
353 | Event::InlineMath(value)
354 | Event::DisplayMath(value) => visible.push_aligned(&value, source),
355 Event::SoftBreak | Event::HardBreak => visible.push_break(source.start),
356 Event::End(
357 TagEnd::Paragraph
358 | TagEnd::Heading(_)
359 | TagEnd::Item
360 | TagEnd::CodeBlock
361 | TagEnd::TableRow,
362 )
363 | Event::Rule => visible.push_break(source.end),
364 Event::Start(_)
365 | Event::End(_)
366 | Event::Html(_)
367 | Event::InlineHtml(_)
368 | Event::FootnoteReference(_)
369 | Event::TaskListMarker(_) => {}
370 }
371 }
372 visible.finish()
373 }
374
375 fn markdown_start(&self, offset: usize) -> usize {
376 if self.direct_markdown {
377 return offset;
378 }
379 self.segment_at(offset).map_or(0, |segment| {
380 if segment.visible.len() == segment.markdown.len() {
381 segment.markdown.start + offset.saturating_sub(segment.visible.start)
382 } else {
383 segment.markdown.start
384 }
385 })
386 }
387
388 fn markdown_end(&self, offset: usize) -> usize {
389 if self.direct_markdown {
390 return offset;
391 }
392 if offset == 0 {
393 return 0;
394 }
395 self.segment_at(offset - 1).map_or(0, |segment| {
396 if segment.visible.len() == segment.markdown.len() {
397 segment.markdown.start + offset.saturating_sub(segment.visible.start)
398 } else {
399 segment.markdown.end
400 }
401 })
402 }
403
404 fn segment_at(&self, offset: usize) -> Option<&OffsetSegment> {
405 let index = self
406 .segments
407 .partition_point(|segment| segment.visible.end <= offset);
408 self.segments
409 .get(index)
410 .filter(|segment| segment.visible.contains(&offset))
411 }
412}
413
414fn floor_char_boundary(text: &str, offset: usize) -> usize {
417 let mut offset = offset.min(text.len());
418 while offset > 0 && !text.is_char_boundary(offset) {
419 offset -= 1;
420 }
421 offset
422}
423
424struct VisibleBuilder<'a> {
425 markdown: &'a str,
426 text: String,
427 segments: Vec<OffsetSegment>,
428}
429
430impl<'a> VisibleBuilder<'a> {
431 fn new(markdown: &'a str) -> Self {
432 Self {
433 markdown,
434 text: String::new(),
435 segments: Vec::new(),
436 }
437 }
438
439 fn push_aligned(&mut self, value: &str, source: Range<usize>) {
440 let mut markdown_cursor = source.start.min(self.markdown.len());
441 let markdown_end = floor_char_boundary(self.markdown, source.end);
442 for character in value.chars() {
443 let search_start = floor_char_boundary(self.markdown, markdown_cursor);
444 let search_end = markdown_end.max(search_start);
445 let found = self.markdown[search_start..search_end]
446 .find(character)
447 .map_or(search_start, |relative| search_start + relative);
448 let visible_start = self.text.len();
449 self.text.push(character);
450 let visible_end = self.text.len();
451 let source_end = floor_char_boundary(
452 self.markdown,
453 found.saturating_add(character.len_utf8()).min(search_end),
454 );
455 self.push_segment(OffsetSegment {
456 visible: visible_start..visible_end,
457 markdown: found..source_end,
458 });
459 markdown_cursor = source_end;
460 }
461 }
462
463 fn push_break(&mut self, markdown_offset: usize) {
464 if self.text.ends_with('\n') || self.text.is_empty() {
465 return;
466 }
467 let start = self.text.len();
468 self.text.push('\n');
469 self.push_segment(OffsetSegment {
470 visible: start..self.text.len(),
471 markdown: markdown_offset..markdown_offset,
472 });
473 }
474
475 fn push_segment(&mut self, segment: OffsetSegment) {
476 if let Some(previous) = self.segments.last_mut() {
477 let contiguous = previous.visible.end == segment.visible.start
478 && previous.markdown.end == segment.markdown.start
479 && previous.visible.len() == previous.markdown.len()
480 && segment.visible.len() == segment.markdown.len();
481 if contiguous {
482 previous.visible.end = segment.visible.end;
483 previous.markdown.end = segment.markdown.end;
484 return;
485 }
486 }
487 self.segments.push(segment);
488 }
489
490 fn finish(self) -> SearchableText {
491 SearchableText {
492 text: self.text,
493 segments: self.segments,
494 direct_markdown: false,
495 }
496 }
497}
498
499#[cfg(test)]
500mod tests {
501 use crate::ResolvedContent;
502 use mant_ir::{
503 Block, DefinitionCase, DefinitionIdentity, DefinitionItem, DefinitionRole, Document,
504 DocumentMeta, DocumentSource, Inline, LayoutHint, Section, SourceFormat,
505 };
506 use mant_protocol::{SearchCase, SearchQuery, SearchScope, SearchSyntax};
507
508 use super::search_query;
509
510 fn query() -> ResolvedContent {
511 ResolvedContent {
512 address: None,
513 label: "demo".to_owned(),
514 document: Some(Document {
515 parser: None,
516 source: DocumentSource {
517 format: SourceFormat::Man,
518 path: None,
519 },
520 meta: DocumentMeta {
521 manual_section: Some("1".to_owned()),
522 ..DocumentMeta::default()
523 },
524 diagnostics: Vec::new(),
525 blocks: Vec::new(),
526 sections: vec![Section {
527 id: "options-1".to_owned().into(),
528 title: "OPTIONS".to_owned(),
529 spacing_before_lines: 0,
530 blocks: vec![Block::DefinitionList {
531 items: vec![DefinitionItem {
532 inline_term: false,
533 identity: Some(DefinitionIdentity {
534 id: "option-acls".to_owned().into(),
535 role: DefinitionRole::Option,
536 case: DefinitionCase::Sensitive,
537 names: vec!["--acls".to_owned()],
538 }),
539 terms: vec![vec![
540 Inline::Anchor {
541 id: "option-acls".to_owned().into(),
542 },
543 Inline::Code {
544 value: "--acls".to_owned(),
545 },
546 ]],
547 description: vec![Block::Paragraph {
548 children: vec![
549 Inline::Text {
550 value: "Preserve ".to_owned(),
551 },
552 Inline::Strong {
553 children: vec![Inline::Text {
554 value: "access control".to_owned(),
555 }],
556 },
557 Inline::Text {
558 value: " lists".to_owned(),
559 },
560 ],
561 layout: LayoutHint::default(),
562 source: None,
563 }],
564 spacing_before_lines: None,
565 }],
566 compact: true,
567 layout: LayoutHint::default(),
568 source: None,
569 }],
570 children: Vec::new(),
571 source: None,
572 }],
573 }),
574 tldr: None,
575 }
576 }
577
578 fn request(pattern: &str) -> SearchQuery {
579 SearchQuery {
580 pattern: pattern.to_owned(),
581 syntax: SearchSyntax::Literal,
582 case: SearchCase::Insensitive,
583 scope: SearchScope::Visible,
584 word: false,
585 context_lines: 1,
586 limit: 100,
587 offset: 0,
588 }
589 }
590
591 #[test]
592 fn visible_search_maps_inline_formatting_to_markdown_and_option_nodes() {
593 let result = search_query(&query(), &request("access control")).expect("search");
594
595 assert_eq!(result.total, 1);
596 assert_eq!(result.matches[0].node.path(), "1/e1");
597 assert_eq!(result.matches[0].matched_text, "access control");
598 assert!(result.matches[0].markdown.start_line > 1);
599 assert!(result.matches[0].preview.contains("**access control**"));
600 assert!(!result.matches[0].preview.contains("<a id="));
601 assert!(!result.matches[0].context.is_empty());
602 }
603
604 #[test]
605 fn semantic_entry_ownership_ends_before_a_following_section_paragraph() {
606 let mut query = query();
607 query.document.as_mut().expect("manual").sections[0]
608 .blocks
609 .push(Block::Paragraph {
610 children: vec![Inline::Text {
611 value: "General section tail".to_owned(),
612 }],
613 layout: LayoutHint::default(),
614 source: None,
615 });
616
617 let result = search_query(&query, &request("section tail")).expect("search");
618 assert!(matches!(
619 &result.matches[0].node,
620 mant_protocol::SearchNode::DocumentSection { path, .. } if path == "1"
621 ));
622 }
623
624 #[test]
625 fn root_content_search_resolves_to_an_addressable_document_root() {
626 let mut query = query();
627 let document = query.document.as_mut().expect("document");
628 document.source.format = SourceFormat::Markdown;
629 document.blocks.push(Block::Paragraph {
630 children: vec![Inline::Text {
631 value: "Read the preface needle first.".to_owned(),
632 }],
633 layout: LayoutHint::default(),
634 source: None,
635 });
636
637 let result = search_query(&query, &request("preface needle")).expect("root search");
638
639 assert_eq!(result.total, 1);
640 assert!(matches!(
641 &result.matches[0].node,
642 mant_protocol::SearchNode::DocumentRoot { path, id, .. }
643 if path == "root" && id == "document-overview"
644 ));
645 assert!(result.matches[0].section.is_none());
646 assert!(result.matches[0].preview.contains("preface needle"));
647 }
648
649 #[test]
650 fn embedded_tldr_and_markdown_body_keep_distinct_search_owners() {
651 let query = crate::query_markdown_text(
652 "\
653<!-- mant:tldr:start -->
654# demo
655
656> Quick needle.
657
658- Run:
659
660`demo quick-command`
661<!-- mant:tldr:end -->
662
663# Demo
664
665Read the overview needle.
666
667## Synopsis
668
669Manual needle.
670",
671 Some("demo.md".to_owned()),
672 )
673 .expect("Markdown query");
674
675 let quick = search_query(&query, &request("quick needle")).expect("tldr search");
676 assert!(matches!(
677 &quick.matches[0].node,
678 mant_protocol::SearchNode::Tldr { path, id, .. }
679 if path == "0" && id == "tldr"
680 ));
681
682 let overview = search_query(&query, &request("overview needle")).expect("root search");
683 assert!(matches!(
684 &overview.matches[0].node,
685 mant_protocol::SearchNode::DocumentRoot { path, .. } if path == "root"
686 ));
687
688 let manual = search_query(&query, &request("manual needle")).expect("section search");
689 assert!(matches!(
690 &manual.matches[0].node,
691 mant_protocol::SearchNode::DocumentSection { path, id, .. }
692 if path == "1" && id == "synopsis"
693 ));
694 }
695
696 #[test]
697 fn regex_case_and_pagination_are_reported_without_losing_global_ordinals() {
698 let mut request = request("ACLS|control");
699 request.syntax = SearchSyntax::Regex;
700 request.case = SearchCase::Insensitive;
701 request.limit = 1;
702 request.offset = 1;
703 let result = search_query(&query(), &request).expect("search");
704
705 assert_eq!(result.total, 2);
706 assert_eq!(result.returned, 1);
707 assert_eq!(result.matches[0].ordinal, 2);
708 assert!(!result.truncated);
709 }
710
711 #[test]
712 fn regexes_that_match_empty_text_are_rejected() {
713 let mut request = request("$");
714 request.syntax = SearchSyntax::Regex;
715 let error = search_query(&query(), &request).expect_err("empty regex match");
716 assert!(error.to_string().contains("must not match empty text"));
717 }
718}