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