1use std::fmt::Write as _;
4
5use mant_protocol::QuerySearch;
6
7#[must_use]
9pub fn render_search_text(search: &QuerySearch) -> String {
10 let label = document_label(search);
11 if search.total == 0 {
12 return format!("No matches for \"{}\" in {label}.", search.query.pattern);
13 }
14 if search.matches.is_empty() {
15 return format!(
16 "No matches returned at offset {} for \"{}\" in {label} ({} total).",
17 search.offset, search.query.pattern, search.total
18 );
19 }
20
21 let mut rendered = search
22 .matches
23 .iter()
24 .map(|found| {
25 let mut lines = vec![format!(
26 "{label}:{}:{} [{}] {}",
27 found.markdown.start_line,
28 found.markdown.start_column,
29 found.node.path(),
30 found.node.title()
31 )];
32 if found.context.is_empty() {
33 lines.push(format!(" {}", found.preview));
34 } else {
35 lines.extend(found.context.iter().map(|line| {
36 format!(
37 " {} {} {}",
38 if line.matched { ">" } else { " " },
39 line.line,
40 line.text
41 )
42 }));
43 }
44 lines.join("\n")
45 })
46 .collect::<Vec<_>>()
47 .join("\n\n");
48 if let Some(next_offset) = search.next_offset {
49 let _ = write!(
50 rendered,
51 "\n\n{} total matches; continue with --offset {next_offset}.",
52 search.total
53 );
54 }
55 rendered
56}
57
58#[must_use]
60pub fn render_search_markdown(search: &QuerySearch) -> String {
61 let label = document_label(search);
62 let mut blocks = vec![format!(
63 "# Search results for {} in {}",
64 code_span(&search.query.pattern),
65 escape_text(&label)
66 )];
67 blocks.push(format!(
68 "{} {} in the full Markdown document.",
69 search.total,
70 if search.total == 1 {
71 "match"
72 } else {
73 "matches"
74 }
75 ));
76 if search.returned < search.total {
77 if search.returned == 0 {
78 blocks.push(format!(
79 "No matches were returned at offset {}.",
80 search.offset
81 ));
82 } else {
83 let range_start = search.offset.saturating_add(1);
84 let range_end = search.offset.saturating_add(search.returned);
85 let continuation = search
86 .next_offset
87 .map_or(String::new(), |offset| format!(" Next offset: `{offset}`."));
88 blocks.push(format!(
89 "Showing matches {range_start}–{range_end}.{continuation}"
90 ));
91 }
92 }
93
94 for found in &search.matches {
95 blocks.push(format!(
96 "## {}. {}",
97 found.ordinal,
98 code_span(found.node.title())
99 ));
100 let mut details = vec![
101 format!("- Node: {}", code_span(found.node.path())),
102 format!(
103 "- Markdown: line {}, column {}",
104 found.markdown.start_line, found.markdown.start_column
105 ),
106 ];
107 if let Some(section) = &found.section {
108 details.push(format!(
109 "- Section: {} ({})",
110 code_span(§ion.title),
111 code_span(§ion.path)
112 ));
113 }
114 if let Some(source) = found.source {
115 details.push(format!(
116 "- Source: line {}, column {}",
117 source.line, source.column
118 ));
119 }
120 blocks.push(details.join("\n"));
121 blocks.push(format!("> {}", found.preview.replace('\n', "\n> ")));
122 }
123 blocks.join("\n\n").trim_end().to_owned()
124}
125
126fn document_label(search: &QuerySearch) -> String {
127 search
128 .meta
129 .as_ref()
130 .and_then(|meta| meta.manual_section.as_deref())
131 .map_or_else(
132 || search.label.clone(),
133 |section| format!("{}({section})", search.label),
134 )
135}
136
137fn code_span(value: &str) -> String {
138 let width = value
139 .split(|character| character != '`')
140 .map(str::len)
141 .max()
142 .unwrap_or(0)
143 .saturating_add(1)
144 .max(1);
145 let delimiter = "`".repeat(width);
146 format!("{delimiter}{value}{delimiter}")
147}
148
149fn escape_text(value: &str) -> String {
150 value
151 .replace('\\', "\\\\")
152 .replace('*', "\\*")
153 .replace('_', "\\_")
154 .replace('[', "\\[")
155 .replace(']', "\\]")
156}
157
158#[cfg(test)]
159mod tests {
160 use mant_protocol::{
161 MarkdownSchema, QuerySearch, SearchCase, SearchMarkdownRange, SearchMatch, SearchNode,
162 SearchQuery, SearchRender, SearchRenderFormat, SearchRenderScope, SearchSchema,
163 SearchScope, SearchSyntax,
164 };
165
166 use super::{render_search_markdown, render_search_text};
167
168 fn result() -> QuerySearch {
169 QuerySearch {
170 schema: SearchSchema::V7,
171 label: "tar".to_owned(),
172 source: None,
173 meta: Some(mant_ir::DocumentMeta {
174 manual_section: Some("1".to_owned()),
175 ..mant_ir::DocumentMeta::default()
176 }),
177 query: SearchQuery {
178 pattern: "--acls".to_owned(),
179 syntax: SearchSyntax::Literal,
180 case: SearchCase::Insensitive,
181 scope: SearchScope::Visible,
182 word: false,
183 context_lines: 0,
184 limit: 100,
185 offset: 0,
186 },
187 render: SearchRender {
188 schema: MarkdownSchema::V1,
189 format: SearchRenderFormat::Markdown,
190 scope: SearchRenderScope::Full,
191 line_base: 1,
192 column_base: 1,
193 line_count: 900,
194 },
195 total: 1,
196 returned: 1,
197 offset: 0,
198 truncated: false,
199 next_offset: None,
200 matches: vec![SearchMatch {
201 ordinal: 1,
202 node: SearchNode::DocumentEntry {
203 path: "5.3/e17".to_owned().into(),
204 id: "acls-option".to_owned().into(),
205 title: "--acls".to_owned(),
206 role: mant_ir::DefinitionRole::Option,
207 case: mant_ir::DefinitionCase::Sensitive,
208 names: vec!["--acls".to_owned()],
209 },
210 section: None,
211 matched_text: "--acls".to_owned(),
212 markdown: SearchMarkdownRange {
213 start_byte: 10,
214 end_byte: 16,
215 start_line: 824,
216 start_column: 3,
217 end_line: 824,
218 end_column: 9,
219 },
220 source: None,
221 preview: "- `--acls`".to_owned(),
222 context: Vec::new(),
223 }],
224 }
225 }
226
227 #[test]
228 fn search_reports_are_human_readable_but_keep_machine_node_paths() {
229 let result = result();
230 assert!(render_search_text(&result).contains("tar(1):824:3 [5.3/e17] --acls"));
231 let markdown = render_search_markdown(&result);
232 assert!(markdown.contains("# Search results for `--acls` in tar(1)"));
233 assert!(markdown.contains("- Node: `5.3/e17`"));
234 }
235}