1use mant_ir::DefinitionRole;
4use std::{collections::BTreeMap, ops::Range};
5
6use mant_protocol::{OutlineNodeReference, OutlineTrail, QuerySearch, SearchHit, SearchScope};
7use pulldown_cmark::{Event, Parser};
8
9use crate::markdown_mapping::{InlineMappingKind, map_inline_characters};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum SearchTextRole {
14 Plain,
16 Document,
18 Coordinate,
20 Path,
22 Heading,
24 Definition(DefinitionRole),
26 Match,
28 Muted,
30}
31
32#[cfg(test)]
33fn render_search_line_text(markdown: &str) -> String {
34 render_search_line(markdown, &[]).0
35}
36
37fn render_search_line(markdown: &str, highlights: &[Range<usize>]) -> (String, Vec<Range<usize>>) {
38 let mut rendered = String::with_capacity(markdown.len());
39 let mut rendered_highlights = Vec::new();
40 for (event, source) in Parser::new(markdown).into_offset_iter() {
41 match event {
42 Event::Text(value) | Event::InlineMath(value) | Event::DisplayMath(value) => {
43 append_mapped_text(
44 markdown,
45 &value,
46 source,
47 InlineMappingKind::Text,
48 highlights,
49 &mut rendered,
50 &mut rendered_highlights,
51 );
52 }
53 Event::Code(value) => append_mapped_text(
54 markdown,
55 &value,
56 source,
57 InlineMappingKind::Code,
58 highlights,
59 &mut rendered,
60 &mut rendered_highlights,
61 ),
62 Event::SoftBreak | Event::HardBreak => {
63 let start = rendered.len();
64 rendered.push(' ');
65 if highlights
66 .iter()
67 .any(|range| ranges_overlap(range, &source))
68 {
69 rendered_highlights.push(start..rendered.len());
70 }
71 }
72 Event::TaskListMarker(checked) => {
73 rendered.push_str(if checked { "[x] " } else { "[ ] " });
74 }
75 Event::Rule => rendered.push_str("---"),
76 Event::Start(_)
77 | Event::End(_)
78 | Event::Html(_)
79 | Event::InlineHtml(_)
80 | Event::FootnoteReference(_) => {}
81 }
82 }
83 let visible_end = rendered.trim_end().len();
84 rendered.truncate(visible_end);
85 rendered_highlights.retain(|range| range.start < visible_end);
86 for range in &mut rendered_highlights {
87 range.end = range.end.min(visible_end);
88 }
89 (rendered, rendered_highlights)
90}
91
92fn append_mapped_text(
93 markdown: &str,
94 value: &str,
95 source: Range<usize>,
96 kind: InlineMappingKind,
97 highlights: &[Range<usize>],
98 rendered: &mut String,
99 rendered_highlights: &mut Vec<Range<usize>>,
100) {
101 for character in map_inline_characters(markdown, value, source, kind) {
102 let visible_start = rendered.len();
103 rendered.push(character.value);
104 if highlights
105 .iter()
106 .any(|range| ranges_overlap(range, &character.source))
107 {
108 rendered_highlights.push(visible_start..rendered.len());
109 }
110 }
111}
112
113fn ranges_overlap(left: &Range<usize>, right: &Range<usize>) -> bool {
114 left.start < right.end && right.start < left.end
115}
116
117#[must_use]
119pub fn render_search_text(search: &QuerySearch) -> String {
120 render_search_text_with(search, |_, value| value.to_owned())
121}
122
123#[must_use]
129pub fn render_search_text_with(
130 search: &QuerySearch,
131 decorate: impl FnMut(SearchTextRole, &str) -> String,
132) -> String {
133 let label = document_label(search);
134 let mut output = SearchTextRenderer::new(decorate);
135 if search.total == 0 {
136 output.plain("No matches for \"");
137 output.push(SearchTextRole::Match, &search.query.pattern);
138 output.plain("\" in ");
139 output.push(SearchTextRole::Document, &label);
140 output.plain(".");
141 return output.finish();
142 }
143 if search.matches.is_empty() {
144 output.plain("No matching lines returned at offset ");
145 output.push(SearchTextRole::Coordinate, &search.offset.to_string());
146 output.plain(" for \"");
147 output.push(SearchTextRole::Match, &search.query.pattern);
148 output.plain("\" in ");
149 output.push(SearchTextRole::Document, &label);
150 output.plain(" (");
151 output.push(SearchTextRole::Coordinate, &search.total.to_string());
152 output.plain(" total).");
153 return output.finish();
154 }
155
156 let mut previous_outline = None;
157 let mut index = 0;
158 while index < search.matches.len() {
159 let found = &search.matches[index];
160 if previous_outline != Some(&found.outline) {
161 if index > 0 {
162 output.line();
163 output.line();
164 }
165 output.push(SearchTextRole::Document, &label);
166 output.plain(" ");
167 render_outline_trail(&mut output, &found.outline);
168 }
169 let end = context_group_end(&search.matches, index);
170 let group = &search.matches[index..end];
171 output.line();
172 output.plain(" ");
173 output.push(
174 SearchTextRole::Coordinate,
175 &text_group_coordinates(group, search.query.scope),
176 );
177 if let Some(summary) = truncated_occurrence_summary(group) {
178 output.plain(" [");
179 output.push(SearchTextRole::Muted, &summary);
180 output.plain("]");
181 }
182 if found.context.is_empty() {
183 output.plain(" ");
184 let line = found
185 .occurrences
186 .first()
187 .map_or(0, |occurrence| occurrence.markdown.start_line);
188 let ranges = occurrence_line_ranges(found, line);
189 let (visible, highlights) = render_search_line(&found.preview, &ranges);
190 output.matching_line(&visible, highlights);
191 } else {
192 for (line_number, (text, matched, source_ranges)) in merged_context(group) {
193 output.line();
194 output.plain(" ");
195 output.push(
196 if matched {
197 SearchTextRole::Match
198 } else {
199 SearchTextRole::Muted
200 },
201 if matched { ">" } else { " " },
202 );
203 output.plain(" ");
204 output.push(SearchTextRole::Coordinate, &line_number.to_string());
205 output.plain(" ");
206 let (visible, highlights) = render_search_line(text, &source_ranges);
207 if matched {
208 output.matching_line(&visible, highlights);
209 } else {
210 output.plain(&visible);
211 }
212 }
213 }
214 previous_outline = Some(&found.outline);
215 index = end;
216 }
217 if let Some(next_offset) = search.next_offset {
218 output.line();
219 output.line();
220 output.push(SearchTextRole::Coordinate, &search.total.to_string());
221 output.plain(" total matching lines; continue with ");
222 output.push(SearchTextRole::Heading, "--offset");
223 output.plain(" ");
224 output.push(SearchTextRole::Coordinate, &next_offset.to_string());
225 output.plain(".");
226 }
227 output.finish()
228}
229
230struct SearchTextRenderer<F> {
231 rendered: String,
232 decorate: F,
233}
234
235impl<F> SearchTextRenderer<F>
236where
237 F: FnMut(SearchTextRole, &str) -> String,
238{
239 fn new(decorate: F) -> Self {
240 Self {
241 rendered: String::new(),
242 decorate,
243 }
244 }
245
246 fn plain(&mut self, value: &str) {
247 self.push(SearchTextRole::Plain, value);
248 }
249
250 fn push(&mut self, role: SearchTextRole, value: &str) {
251 self.rendered.push_str(&(self.decorate)(role, value));
252 }
253
254 fn line(&mut self) {
255 self.rendered.push('\n');
256 }
257
258 fn matching_line(&mut self, line: &str, matched: impl IntoIterator<Item = Range<usize>>) {
259 let mut ranges = matched
260 .into_iter()
261 .filter(|range| {
262 range.start < range.end
263 && range.end <= line.len()
264 && line.is_char_boundary(range.start)
265 && line.is_char_boundary(range.end)
266 })
267 .map(|range| (range.start, range.end))
268 .collect::<Vec<_>>();
269 if ranges.is_empty() {
270 self.plain(line);
271 return;
272 }
273 ranges.sort_unstable();
274 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len());
275 for (start, end) in ranges {
276 if let Some((_, previous_end)) = merged.last_mut().filter(|(_, end)| start <= *end) {
277 *previous_end = (*previous_end).max(end);
278 } else {
279 merged.push((start, end));
280 }
281 }
282 let mut position = 0;
283 for (start, end) in merged {
284 self.plain(&line[position..start]);
285 self.push(SearchTextRole::Match, &line[start..end]);
286 position = end;
287 }
288 self.plain(&line[position..]);
289 }
290
291 fn finish(self) -> String {
292 self.rendered.trim_end().to_owned()
293 }
294}
295
296fn occurrence_line_ranges(found: &SearchHit, line: u32) -> Vec<Range<usize>> {
297 found
298 .occurrences
299 .iter()
300 .flat_map(|occurrence| occurrence.line_ranges.iter())
301 .filter(|range| range.line == line)
302 .filter_map(|range| {
303 Some(usize::try_from(range.start_byte).ok()?..usize::try_from(range.end_byte).ok()?)
304 })
305 .collect()
306}
307
308fn group_coordinates(matches: &[SearchHit]) -> String {
309 let mut lines: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
310 for occurrence in matches.iter().flat_map(|found| found.occurrences.iter()) {
311 lines
312 .entry(occurrence.markdown.start_line)
313 .or_default()
314 .push(occurrence.markdown.start_column);
315 }
316 format_coordinate_lines(lines)
317}
318
319fn text_group_coordinates(matches: &[SearchHit], scope: SearchScope) -> String {
320 if scope == SearchScope::Markdown {
321 return group_coordinates(matches);
322 }
323
324 let mut lines: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
325 for found in matches {
326 for occurrence in &found.occurrences {
327 let line = occurrence.markdown.start_line;
328 let visible_column = search_line_text(found, line)
329 .and_then(|text| {
330 let ranges = occurrence
331 .line_ranges
332 .iter()
333 .filter(|range| range.line == line)
334 .filter_map(|range| {
335 Some(
336 usize::try_from(range.start_byte).ok()?
337 ..usize::try_from(range.end_byte).ok()?,
338 )
339 })
340 .collect::<Vec<_>>();
341 let (rendered, highlights) = render_search_line(text, &ranges);
342 highlights
343 .iter()
344 .map(|range| range.start)
345 .min()
346 .map(|start| {
347 u32::try_from(rendered[..start].chars().count().saturating_add(1))
348 .unwrap_or(u32::MAX)
349 })
350 })
351 .unwrap_or(occurrence.markdown.start_column);
352 lines.entry(line).or_default().push(visible_column);
353 }
354 }
355 format_coordinate_lines(lines)
356}
357
358fn search_line_text(found: &SearchHit, line: u32) -> Option<&str> {
359 found
360 .context
361 .iter()
362 .find(|context| context.line == line)
363 .map(|context| context.text.as_str())
364 .or_else(|| {
365 found
366 .occurrences
367 .iter()
368 .any(|occurrence| occurrence.markdown.start_line == line)
369 .then_some(found.preview.as_str())
370 })
371}
372
373fn format_coordinate_lines(lines: BTreeMap<u32, Vec<u32>>) -> String {
374 lines
375 .into_iter()
376 .map(|(line, mut columns)| {
377 columns.sort_unstable();
378 columns.dedup();
379 format!(
380 "{line}:{}",
381 columns
382 .into_iter()
383 .map(|column| column.to_string())
384 .collect::<Vec<_>>()
385 .join(",")
386 )
387 })
388 .collect::<Vec<_>>()
389 .join("; ")
390}
391
392fn truncated_occurrence_summary(matches: &[SearchHit]) -> Option<String> {
393 matches
394 .iter()
395 .any(|found| found.occurrences_truncated)
396 .then(|| {
397 let total = matches
398 .iter()
399 .map(|found| u64::from(found.occurrence_count))
400 .sum::<u64>();
401 let shown = matches
402 .iter()
403 .map(|found| found.occurrences.len() as u64)
404 .sum::<u64>();
405 format!("{total} occurrences; {shown} exact coordinates shown")
406 })
407}
408
409fn context_group_end(matches: &[SearchHit], start: usize) -> usize {
410 let Some((_, mut last_line)) = context_bounds(&matches[start]) else {
411 return start + 1;
412 };
413 let outline = &matches[start].outline;
414 let mut end = start + 1;
415 while let Some(found) = matches.get(end) {
416 let Some((first_line, found_last_line)) = context_bounds(found) else {
417 break;
418 };
419 if &found.outline != outline || first_line > last_line.saturating_add(1) {
420 break;
421 }
422 last_line = last_line.max(found_last_line);
423 end += 1;
424 }
425 end
426}
427
428fn context_bounds(found: &SearchHit) -> Option<(u32, u32)> {
429 Some((found.context.first()?.line, found.context.last()?.line))
430}
431
432type MergedContext<'a> = BTreeMap<u32, (&'a str, bool, Vec<Range<usize>>)>;
433
434fn merged_context(matches: &[SearchHit]) -> MergedContext<'_> {
435 let mut merged: MergedContext<'_> = BTreeMap::new();
436 for found in matches {
437 for line in &found.context {
438 let entry = merged
439 .entry(line.line)
440 .or_insert_with(|| (line.text.as_str(), false, Vec::new()));
441 entry.1 |= line.matched;
442 if line.matched {
443 entry.2.extend(occurrence_line_ranges(found, line.line));
444 }
445 }
446 }
447 merged
448}
449
450fn render_outline_trail<F>(output: &mut SearchTextRenderer<F>, trail: &OutlineTrail)
451where
452 F: FnMut(SearchTextRole, &str) -> String,
453{
454 output.push(SearchTextRole::Muted, "Outline ");
455 output.push(SearchTextRole::Path, trail.path());
456 output.push(SearchTextRole::Muted, ": ");
457 for (index, ancestor) in trail.ancestors.iter().enumerate() {
458 if index > 0 {
459 output.push(SearchTextRole::Muted, " > ");
460 }
461 output.push(SearchTextRole::Heading, &ancestor.title);
462 }
463 if !trail.ancestors.is_empty() {
464 output.push(SearchTextRole::Muted, " > ");
465 }
466 output.push(search_node_role(&trail.node), trail.title());
467}
468
469const fn search_node_role(node: &OutlineNodeReference) -> SearchTextRole {
470 match node {
471 OutlineNodeReference::DocumentEntry { role, .. } => SearchTextRole::Definition(*role),
472 OutlineNodeReference::Tldr { .. }
473 | OutlineNodeReference::DocumentRoot { .. }
474 | OutlineNodeReference::DocumentSection { .. } => SearchTextRole::Heading,
475 }
476}
477
478#[must_use]
480pub fn render_search_markdown(search: &QuerySearch) -> String {
481 let label = document_label(search);
482 let mut blocks = vec![format!(
483 "# Search results for {} in {}",
484 code_span(&search.query.pattern),
485 escape_text(&label)
486 )];
487 blocks.push(format!(
488 "{} {} in the full Markdown document.",
489 search.total,
490 if search.total == 1 {
491 "matching line"
492 } else {
493 "matching lines"
494 }
495 ));
496 if search.returned < search.total {
497 if search.returned == 0 {
498 blocks.push(format!(
499 "No matching lines were returned at offset {}.",
500 search.offset
501 ));
502 } else {
503 let range_start = search.offset.saturating_add(1);
504 let range_end = search.offset.saturating_add(search.returned);
505 let continuation = search
506 .next_offset
507 .map_or(String::new(), |offset| format!(" Next offset: `{offset}`."));
508 blocks.push(format!(
509 "Showing matching lines {range_start}–{range_end}.{continuation}"
510 ));
511 }
512 }
513
514 for found in &search.matches {
515 blocks.push(format!(
516 "## {}. {}",
517 found.ordinal,
518 code_span(found.outline.title())
519 ));
520 let mut details = vec![
521 format!("- Outline: {}", code_span(found.outline.path())),
522 format!(
523 "- Trail: {}",
524 found
525 .outline
526 .ancestors
527 .iter()
528 .map(|ancestor| code_span(&ancestor.title))
529 .chain(std::iter::once(code_span(found.outline.title())))
530 .collect::<Vec<_>>()
531 .join(" → ")
532 ),
533 format!(
534 "- Markdown: {}",
535 group_coordinates(std::slice::from_ref(found))
536 ),
537 ];
538 if let Some(source) = found.node_source {
539 details.push(format!(
540 "- Source: line {}, column {}",
541 source.line, source.column
542 ));
543 }
544 if found.occurrences_truncated {
545 details.push(format!(
546 "- Occurrences: {} total; {} exact coordinates shown",
547 found.occurrence_count,
548 found.occurrences.len()
549 ));
550 }
551 blocks.push(details.join("\n"));
552 blocks.push(format!("> {}", found.preview.replace('\n', "\n> ")));
553 }
554 blocks.join("\n\n").trim_end().to_owned()
555}
556
557fn document_label(search: &QuerySearch) -> String {
558 search
559 .meta
560 .as_ref()
561 .and_then(|meta| meta.manual_section.as_deref())
562 .map_or_else(
563 || search.label.clone(),
564 |section| format!("{}({section})", search.label),
565 )
566}
567
568fn code_span(value: &str) -> String {
569 let width = value
570 .split(|character| character != '`')
571 .map(str::len)
572 .max()
573 .unwrap_or(0)
574 .saturating_add(1)
575 .max(1);
576 let delimiter = "`".repeat(width);
577 format!("{delimiter}{value}{delimiter}")
578}
579
580fn escape_text(value: &str) -> String {
581 value
582 .replace('\\', "\\\\")
583 .replace('*', "\\*")
584 .replace('_', "\\_")
585 .replace('[', "\\[")
586 .replace(']', "\\]")
587}
588
589#[cfg(test)]
590mod tests {
591 use mant_protocol::{
592 MarkdownSchema, OutlineNodeReference, OutlineReference, OutlineTrail, QuerySearch,
593 SearchCase, SearchContextLine, SearchHit, SearchLineRange, SearchMarkdownRange,
594 SearchOccurrence, SearchQuery, SearchRender, SearchRenderFormat, SearchRenderScope,
595 SearchSchema, SearchScope, SearchSyntax,
596 };
597
598 use super::{
599 SearchTextRenderer, SearchTextRole, render_search_line_text, render_search_markdown,
600 render_search_text, render_search_text_with,
601 };
602
603 fn result() -> QuerySearch {
604 QuerySearch {
605 schema: SearchSchema::V0Dot8,
606 label: "tar".to_owned(),
607 source: None,
608 meta: Some(mant_ir::DocumentMeta {
609 manual_section: Some("1".to_owned()),
610 ..mant_ir::DocumentMeta::default()
611 }),
612 query: SearchQuery {
613 pattern: "--acls".to_owned(),
614 syntax: SearchSyntax::Literal,
615 case: SearchCase::Insensitive,
616 scope: SearchScope::Visible,
617 word: false,
618 context_lines: 0,
619 limit: 100,
620 offset: 0,
621 },
622 render: SearchRender {
623 schema: MarkdownSchema::V1,
624 format: SearchRenderFormat::Markdown,
625 scope: SearchRenderScope::Full,
626 line_base: 1,
627 column_base: 1,
628 line_count: 900,
629 },
630 total: 1,
631 returned: 1,
632 offset: 0,
633 truncated: false,
634 next_offset: None,
635 matches: vec![SearchHit {
636 ordinal: 1,
637 outline: OutlineTrail {
638 ancestors: vec![OutlineReference {
639 path: "5.3".to_owned().into(),
640 id: "archive-options".to_owned().into(),
641 title: "Archive options".to_owned(),
642 }],
643 node: OutlineNodeReference::DocumentEntry {
644 path: "5.3/e17".to_owned().into(),
645 id: "acls-option".to_owned().into(),
646 title: "--acls".to_owned(),
647 role: mant_ir::DefinitionRole::Option,
648 case: mant_ir::DefinitionCase::Sensitive,
649 names: vec!["--acls".to_owned()],
650 },
651 },
652 occurrences: vec![SearchOccurrence {
653 matched_text: "--acls".to_owned(),
654 markdown: SearchMarkdownRange {
655 start_byte: 10,
656 end_byte: 16,
657 start_line: 824,
658 start_column: 3,
659 end_line: 824,
660 end_column: 9,
661 },
662 line_ranges: vec![SearchLineRange {
663 line: 824,
664 start_byte: 3,
665 end_byte: 9,
666 }],
667 }],
668 occurrence_count: 1,
669 occurrences_truncated: false,
670 node_source: None,
671 preview: "- `--acls`".to_owned(),
672 context: Vec::new(),
673 }],
674 }
675 }
676
677 #[test]
678 fn search_reports_are_human_readable_but_keep_machine_node_paths() {
679 let result = result();
680 assert!(
681 render_search_text(&result)
682 .contains("tar(1) Outline 5.3/e17: Archive options > --acls\n 824:1 --acls")
683 );
684 assert!(render_search_text(&result).contains(" --acls"));
685 assert!(!render_search_text(&result).contains("`--acls`"));
686 let markdown = render_search_markdown(&result);
687 assert!(markdown.contains("# Search results for `--acls` in tar(1)"));
688 assert!(markdown.contains("- Outline: `5.3/e17`"));
689 assert!(markdown.contains("- Trail: `Archive options` → `--acls`"));
690 }
691
692 #[test]
693 fn text_search_coordinates_follow_the_presented_scope() {
694 let mut visible = result();
695 visible.matches[0].occurrences[0].markdown.start_column = 35;
696 assert!(render_search_text(&visible).contains(" 824:1 --acls"));
697
698 visible.query.scope = SearchScope::Markdown;
699 assert!(render_search_text(&visible).contains(" 824:35 --acls"));
700 }
701
702 #[test]
703 fn search_text_lines_hide_markdown_presentation_syntax() {
704 assert_eq!(
705 render_search_line_text("- **Use** [`mant`](https://example.test) with `--color`."),
706 "Use mant with --color."
707 );
708 }
709
710 #[test]
711 fn semantic_search_text_marks_only_the_visible_match() {
712 let rendered = render_search_text_with(&result(), |role, value| {
713 if role == SearchTextRole::Match {
714 format!("<match>{value}</match>")
715 } else {
716 value.to_owned()
717 }
718 });
719
720 assert!(rendered.contains(" <match>--acls</match>"));
721 assert!(!rendered.contains("<match> --acls</match>"));
722 assert!(!rendered.contains('`'));
723 }
724
725 #[test]
726 fn semantic_search_text_does_not_rehighlight_nonmatching_substrings() {
727 let mut result = result();
728 result.query.pattern = "foo".to_owned();
729 result.matches[0].preview = "foobar foo".to_owned();
730 result.matches[0].occurrences[0].matched_text = "foo".to_owned();
731 result.matches[0].occurrences[0].markdown.start_line = 824;
732 result.matches[0].occurrences[0].markdown.end_line = 824;
733 result.matches[0].occurrences[0].line_ranges = vec![SearchLineRange {
734 line: 824,
735 start_byte: 7,
736 end_byte: 10,
737 }];
738
739 let rendered = render_search_text_with(&result, |role, value| {
740 if role == SearchTextRole::Match {
741 format!("<match>{value}</match>")
742 } else {
743 value.to_owned()
744 }
745 });
746
747 assert!(rendered.contains("foobar <match>foo</match>"));
748 assert!(!rendered.contains("<match>foo</match>bar"));
749 }
750
751 #[test]
752 fn matching_lines_ignore_ranges_inside_utf8_characters() {
753 let mut renderer = SearchTextRenderer::new(|_, value| value.to_owned());
754
755 renderer.matching_line("é", std::iter::once(1..2));
756
757 assert_eq!(renderer.finish(), "é");
758 }
759
760 #[test]
761 fn search_text_groups_adjacent_matches_by_exact_outline_node() {
762 let mut result = result();
763 result.matches[0].preview = "- `--acls` and `--acls`".to_owned();
764 let mut same_line = result.matches[0].occurrences[0].clone();
765 same_line.markdown.start_column = 14;
766 same_line.markdown.end_column = 20;
767 same_line.line_ranges[0].start_byte = 16;
768 same_line.line_ranges[0].end_byte = 22;
769 result.matches[0].occurrences.push(same_line);
770 let mut second = result.matches[0].clone();
771 second.ordinal = 2;
772 second.occurrences.truncate(1);
773 second.occurrences[0].markdown.start_line = 825;
774 second.occurrences[0].markdown.end_line = 825;
775 second.occurrences[0].markdown.start_column = 7;
776 second.occurrences[0].markdown.end_column = 13;
777 second.occurrences[0].line_ranges[0].line = 825;
778 second.occurrences[0].line_ranges[0].start_byte = 3;
779 second.occurrences[0].line_ranges[0].end_byte = 9;
780 second.preview = "- `--acls`".to_owned();
781
782 let mut third = second.clone();
783 third.ordinal = 3;
784 third.occurrences[0].markdown.start_line = 900;
785 third.occurrences[0].markdown.end_line = 900;
786 third.outline.node = OutlineNodeReference::DocumentSection {
787 path: "6".to_owned().into(),
788 id: "examples".to_owned().into(),
789 title: "Examples".to_owned(),
790 };
791 third.outline.ancestors.clear();
792
793 result.total = 3;
794 result.returned = 3;
795 result.matches.extend([second, third]);
796 let rendered = render_search_text(&result);
797
798 assert_eq!(rendered.matches("Outline 5.3/e17").count(), 1);
799 assert!(rendered.contains(" 824:1,12 --acls and --acls\n 825:1 --acls"));
800 assert_eq!(rendered.matches("Outline 6: Examples").count(), 1);
801 assert!(rendered.contains("\n\ntar(1) Outline 6: Examples\n 900:7 --acls"));
802 }
803
804 #[test]
805 fn search_text_merges_overlapping_context_windows() {
806 let mut result = result();
807 result.matches[0].context = vec![
808 context(823, "before", false),
809 context(824, "first --acls", true),
810 context(825, "between", false),
811 ];
812 result.matches[0].occurrences[0].line_ranges[0].start_byte = 6;
813 result.matches[0].occurrences[0].line_ranges[0].end_byte = 12;
814 let mut second = result.matches[0].clone();
815 second.ordinal = 2;
816 second.occurrences[0].markdown.start_line = 826;
817 second.occurrences[0].markdown.end_line = 826;
818 second.occurrences[0].markdown.start_column = 8;
819 second.occurrences[0].line_ranges[0].line = 826;
820 second.occurrences[0].line_ranges[0].start_byte = 7;
821 second.occurrences[0].line_ranges[0].end_byte = 13;
822 second.context = vec![
823 context(825, "between", false),
824 context(826, "second --acls", true),
825 context(827, "after", false),
826 ];
827 result.total = 2;
828 result.returned = 2;
829 result.matches.push(second);
830
831 let rendered = render_search_text(&result);
832
833 assert!(rendered.contains(" 824:7; 826:8"));
834 assert_eq!(rendered.matches(" 825 between").count(), 1);
835 assert_eq!(rendered.matches(" 824 first --acls").count(), 1);
836 assert_eq!(rendered.matches(" 826 second --acls").count(), 1);
837 }
838
839 fn context(line: u32, text: &str, matched: bool) -> SearchContextLine {
840 SearchContextLine {
841 line,
842 text: text.to_owned(),
843 matched,
844 }
845 }
846}