Skip to main content

mant_engine/output/
search.rs

1//! Presents structure-aware search results for terminals and language models.
2
3use 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/// Semantic roles in the grep-like search presentation.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum SearchTextRole {
14    /// Ordinary prose and punctuation.
15    Plain,
16    /// The logical document label.
17    Document,
18    /// A rendered Markdown coordinate or result count.
19    Coordinate,
20    /// A stable semantic-node path.
21    Path,
22    /// A document, section, or tldr node title.
23    Heading,
24    /// A semantic entry title.
25    Definition(DefinitionRole),
26    /// Text that matched the search query.
27    Match,
28    /// Secondary guides and context markers.
29    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/// Render grep-like results with stable Markdown coordinates and node paths.
118#[must_use]
119pub fn render_search_text(search: &QuerySearch) -> String {
120    render_search_text_with(search, |_, value| value.to_owned())
121}
122
123/// Render grep-like search text through a semantic span decorator.
124///
125/// The callback may add terminal styling around a span, but must preserve its
126/// visible text. This keeps layout, Markdown projection, and match boundaries
127/// identical between coloured and uncoloured frontends.
128#[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/// Render a readable Markdown report whose coordinates target the full page.
479#[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}