1use mant_ir::{
4 Block, DefinitionItem, Inline, ListItem, ListKind, Section, TableCell, TldrCommandPart,
5 TldrDocument,
6};
7use mant_protocol::{ExcerptSelection, OutlineNode, QueryExcerpt, QueryOutline};
8
9use crate::ResolvedContent;
10
11#[must_use]
13pub fn render_query_text(query: &ResolvedContent) -> String {
14 render_query_body(query, true)
15}
16
17#[must_use]
24pub fn render_query_man(query: &ResolvedContent) -> String {
25 if query.document.is_none() {
26 return String::new();
27 }
28 render_query_body(query, false)
29}
30
31fn render_query_body(query: &ResolvedContent, include_tldr: bool) -> String {
32 let section = query
33 .document
34 .as_ref()
35 .and_then(|document| document.meta.manual_section.as_deref());
36 let mut parts = vec![document_label(&query.label, section)];
37 if include_tldr && let Some(tldr) = &query.tldr {
38 parts.push(render_tldr_text(tldr));
39 }
40 if let Some(document) = &query.document {
41 parts.push(render_blocks(&document.blocks, 0));
42 parts.push(render_sections(&document.sections, 0));
43 }
44 join_parts(parts)
45}
46
47#[must_use]
49pub fn render_outline_text(outline: &QueryOutline) -> String {
50 let mut lines = vec![document_label(
51 &outline.label,
52 outline
53 .meta
54 .as_ref()
55 .and_then(|meta| meta.manual_section.as_deref()),
56 )];
57 render_outline_nodes(&outline.nodes, "", &mut lines);
58 lines.join("\n").trim_end().to_owned()
59}
60
61#[must_use]
63pub fn render_excerpt_text(excerpt: &QueryExcerpt) -> String {
64 let mut parts = vec![document_label(
65 &excerpt.label,
66 excerpt
67 .meta
68 .as_ref()
69 .and_then(|meta| meta.manual_section.as_deref()),
70 )];
71 for selection in &excerpt.selections {
72 parts.push(render_selection(selection));
73 }
74 join_parts(parts)
75}
76
77fn render_outline_nodes(nodes: &[OutlineNode], prefix: &str, output: &mut Vec<String>) {
78 for (index, node) in nodes.iter().enumerate() {
79 let last = index + 1 == nodes.len();
80 let connector = if last { "└─" } else { "├─" };
81 output.push(format!(
82 "{prefix}{connector} {} [{}] {}",
83 node.path(),
84 node.id(),
85 node.title()
86 ));
87 let child_prefix = format!("{prefix}{}", if last { " " } else { "│ " });
88 render_outline_nodes(node.children(), &child_prefix, output);
89 }
90}
91
92fn render_selection(selection: &ExcerptSelection) -> String {
93 match selection {
94 ExcerptSelection::Tldr { document, .. } => render_tldr_text(document),
95 ExcerptSelection::DocumentRoot {
96 path,
97 title,
98 blocks,
99 ..
100 } => join_parts(vec![
101 format!("Outline {path}: {title}"),
102 render_blocks(blocks, 0),
103 ]),
104 ExcerptSelection::DocumentSection {
105 path,
106 title,
107 breadcrumbs,
108 section,
109 ..
110 } => {
111 let mut parts = Vec::new();
112 if !breadcrumbs.is_empty() {
113 let breadcrumb = breadcrumbs
114 .iter()
115 .map(|ancestor| ancestor.title.as_str())
116 .chain(std::iter::once(title.as_str()))
117 .collect::<Vec<_>>()
118 .join(" > ");
119 parts.push(format!("Outline {path}: {breadcrumb}"));
120 }
121 parts.push(render_section(section, 0));
122 join_parts(parts)
123 }
124 ExcerptSelection::DocumentEntry {
125 path,
126 title,
127 breadcrumbs,
128 entry,
129 ..
130 } => {
131 let breadcrumb = breadcrumbs
132 .iter()
133 .map(|ancestor| ancestor.title.as_str())
134 .chain(std::iter::once(title.as_str()))
135 .collect::<Vec<_>>()
136 .join(" > ");
137 join_parts(vec![
138 format!("Outline {path}: {breadcrumb}"),
139 render_definitions(std::slice::from_ref(entry), true, 0),
140 ])
141 }
142 }
143}
144
145fn render_tldr_text(tldr: &TldrDocument) -> String {
146 let mut lines = vec!["TLDR".to_owned()];
147 lines.extend(tldr.description.iter().map(|line| line.trim().to_owned()));
148 if let Some(information) = &tldr.more_information {
149 lines.push(format!("More information: {}", information.trim()));
150 }
151 for example in &tldr.examples {
152 if !example.description.trim().is_empty() {
153 lines.push(example.description.trim().to_owned());
154 }
155 let command = example
156 .command_parts
157 .iter()
158 .map(|part| match part {
159 TldrCommandPart::Text { value } | TldrCommandPart::Placeholder { value } => {
160 value.as_str()
161 }
162 })
163 .collect::<String>();
164 lines.push(if command.is_empty() {
165 example.command.clone()
166 } else {
167 command
168 });
169 }
170 lines.join("\n\n")
171}
172
173fn render_sections(sections: &[Section], depth: usize) -> String {
174 sections
175 .iter()
176 .map(|section| render_section(section, depth))
177 .filter(|section| !section.is_empty())
178 .collect::<Vec<_>>()
179 .join("\n\n")
180}
181
182fn render_section(section: &Section, depth: usize) -> String {
183 let heading_indent = " ".repeat(depth);
184 let mut parts = vec![format!("{heading_indent}{}", section.title)];
185 let blocks = render_blocks(§ion.blocks, depth.saturating_mul(2));
186 if !blocks.is_empty() {
187 parts.push(blocks);
188 }
189 let children = render_sections(§ion.children, depth + 1);
190 if !children.is_empty() {
191 parts.push(children);
192 }
193 join_parts(parts)
194}
195
196fn render_blocks(blocks: &[Block], base_indent: usize) -> String {
197 let mut output = String::new();
204 let mut has_content = false;
205 let mut pending_blank_lines: Option<usize> = None;
206 for block in blocks {
207 if let Block::VerticalSpace { lines, .. } = block {
208 if has_content {
209 let requested = usize::from(*lines);
210 pending_blank_lines = Some(pending_blank_lines.unwrap_or(0).max(requested));
211 }
212 continue;
213 }
214 let Some(text) = render_block(block, base_indent) else {
215 continue;
216 };
217 if has_content {
218 let blank_lines = pending_blank_lines.unwrap_or(1);
219 output.push_str(&"\n".repeat(blank_lines + 1));
220 }
221 output.push_str(&text);
222 has_content = true;
223 pending_blank_lines = None;
224 }
225 output
226}
227
228fn render_block(block: &Block, base_indent: usize) -> Option<String> {
229 let (value, layout_indent) = match block {
230 Block::Paragraph {
231 children, layout, ..
232 }
233 | Block::Preformatted {
234 children, layout, ..
235 } => (inline_text(children), usize::from(layout.indent_columns)),
236 Block::List {
237 kind,
238 start,
239 items,
240 layout,
241 ..
242 } => (
243 render_list(*kind, *start, items, base_indent),
244 usize::from(layout.indent_columns),
245 ),
246 Block::DefinitionList {
247 items,
248 compact,
249 layout,
250 ..
251 } => (
252 render_definitions(items, *compact, base_indent),
253 usize::from(layout.indent_columns),
254 ),
255 Block::Table { rows, layout, .. } => (
256 rows.iter()
257 .map(|row| {
258 row.cells
259 .iter()
260 .map(cell_text)
261 .collect::<Vec<_>>()
262 .join(" | ")
263 })
264 .collect::<Vec<_>>()
265 .join("\n"),
266 usize::from(layout.indent_columns),
267 ),
268 Block::Equation { value, layout, .. }
269 | Block::Unsupported {
270 text: value,
271 layout,
272 ..
273 } => (value.clone(), usize::from(layout.indent_columns)),
274 Block::VerticalSpace { .. } => return None,
277 Block::ThematicBreak { .. } => ("---".to_owned(), 0),
278 };
279 let value = value.trim_matches('\n');
280 (!value.trim().is_empty()).then(|| indent_lines(value, base_indent + layout_indent))
281}
282
283fn render_list(
284 kind: ListKind,
285 start: Option<u64>,
286 items: &[ListItem],
287 base_indent: usize,
288) -> String {
289 items
290 .iter()
291 .enumerate()
292 .filter_map(|(index, item)| {
293 let marker = match kind {
294 ListKind::Ordered => format!(
295 "{}. ",
296 start
297 .unwrap_or(1)
298 .saturating_add(u64::try_from(index).unwrap_or(u64::MAX))
299 ),
300 ListKind::Bullet => "- ".to_owned(),
301 ListKind::Plain => String::new(),
302 };
303 prefix_text_item(&render_blocks(&item.blocks, base_indent), &marker)
304 })
305 .collect::<Vec<_>>()
306 .join("\n")
307}
308
309fn render_definitions(items: &[DefinitionItem], compact: bool, base_indent: usize) -> String {
310 let rendered = items
311 .iter()
312 .filter_map(|item| {
313 let terms = item
314 .terms
315 .iter()
316 .map(|term| inline_text(term))
317 .filter(|term| !term.trim().is_empty())
318 .collect::<Vec<_>>()
319 .join(", ");
320 let description = render_blocks(&item.description, base_indent);
321 let value = match (terms.is_empty(), description.is_empty()) {
322 (false, false) => {
323 if item.inline_term {
324 Some(format!("{terms} {}", description.trim_start()))
325 } else {
326 Some(format!("{terms}\n{}", indent_lines(&description, 2)))
327 }
328 }
329 (false, true) => Some(terms),
330 (true, false) => Some(description),
331 (true, true) => None,
332 }?;
333 Some((value, item.spacing_before_lines))
334 })
335 .collect::<Vec<_>>();
336
337 let Some((first, rest)) = rendered.split_first() else {
338 return String::new();
339 };
340 let mut output = first.0.clone();
341 for (item, spacing_before_lines) in rest {
342 let blank_lines = spacing_before_lines.unwrap_or(u16::from(!compact));
343 output.push_str(&"\n".repeat(usize::from(blank_lines) + 1));
344 output.push_str(item);
345 }
346 output
347}
348
349fn cell_text(cell: &TableCell) -> String {
350 render_blocks(&cell.blocks, 0).replace('\n', " ")
351}
352
353fn inline_text(children: &[Inline]) -> String {
354 let mut output = String::new();
355 for child in children {
356 match child {
357 Inline::Text { value } | Inline::Code { value } => output.push_str(value),
358 Inline::Strong { children }
359 | Inline::Emphasis { children }
360 | Inline::Link { children, .. } => output.push_str(&inline_text(children)),
361 Inline::Anchor { .. } => {}
362 Inline::LineBreak => output.push('\n'),
363 }
364 }
365 output
366}
367
368fn prefix_text_item(content: &str, marker: &str) -> Option<String> {
369 if content.trim().is_empty() {
370 return None;
371 }
372 let continuation = " ".repeat(marker.chars().count());
373 let mut lines = content.lines();
374 let mut output = format!("{marker}{}", lines.next()?);
375 for line in lines {
376 output.push('\n');
377 output.push_str(&continuation);
378 output.push_str(line);
379 }
380 Some(output)
381}
382
383fn indent_lines(value: &str, columns: usize) -> String {
384 if columns == 0 {
385 return value.to_owned();
386 }
387 let prefix = " ".repeat(columns);
388 value
389 .lines()
390 .map(|line| {
391 if line.is_empty() {
392 String::new()
393 } else {
394 format!("{prefix}{line}")
395 }
396 })
397 .collect::<Vec<_>>()
398 .join("\n")
399}
400
401fn document_label(document: &str, section: Option<&str>) -> String {
402 section.map_or_else(
403 || document.to_owned(),
404 |section| format!("{document}({section})"),
405 )
406}
407
408fn join_parts(parts: Vec<String>) -> String {
409 parts
410 .into_iter()
411 .filter(|part| !part.trim().is_empty())
412 .collect::<Vec<_>>()
413 .join("\n\n")
414 .trim_end()
415 .to_owned()
416}
417
418#[cfg(test)]
419mod tests {
420 use crate::ResolvedContent;
421 use mant_ir::{
422 Block, DefinitionItem, Document, DocumentMeta, DocumentSource, Inline, LayoutHint, Section,
423 SourceFormat, TldrDocument, TldrOrigin,
424 };
425
426 use super::{render_excerpt_text, render_outline_text, render_query_man, render_query_text};
427 use crate::{build_outline, select_excerpt};
428
429 fn query() -> ResolvedContent {
430 ResolvedContent {
431 address: None,
432 label: "demo".to_owned(),
433 document: Some(Document {
434 parser: None,
435 source: DocumentSource {
436 format: SourceFormat::Man,
437 path: None,
438 },
439 meta: DocumentMeta {
440 manual_section: Some("1".to_owned()),
441 ..DocumentMeta::default()
442 },
443 diagnostics: Vec::new(),
444 blocks: Vec::new(),
445 sections: vec![Section {
446 id: "options-1".to_owned().into(),
447 title: "OPTIONS".to_owned(),
448 spacing_before_lines: 0,
449 blocks: vec![paragraph("parent details", true)],
450 children: vec![Section {
451 id: "common-2".to_owned().into(),
452 title: "Common options".to_owned(),
453 spacing_before_lines: 1,
454 blocks: vec![paragraph("child details", false)],
455 children: Vec::new(),
456 source: None,
457 }],
458 source: None,
459 }],
460 }),
461 tldr: None,
462 }
463 }
464
465 fn paragraph(value: &str, strong: bool) -> Block {
466 let text = vec![Inline::Text {
467 value: value.to_owned(),
468 }];
469 Block::Paragraph {
470 children: if strong {
471 vec![Inline::Strong { children: text }]
472 } else {
473 text
474 },
475 layout: LayoutHint::default(),
476 source: None,
477 }
478 }
479
480 #[test]
481 fn renders_plain_queries_without_markup_and_uses_resolved_manual_sections() {
482 let output = render_query_text(&query());
483
484 assert!(output.starts_with("demo(1)\n\nOPTIONS"));
485 assert!(output.contains("parent details"));
486 assert!(output.contains("Common options"));
487 assert!(!output.contains("**"));
488 }
489
490 #[test]
491 fn renders_copyable_outline_trees_and_contextual_excerpts() {
492 let query = query();
493 let outline = build_outline(&query).expect("outline");
494 assert_eq!(
495 render_outline_text(&outline),
496 "demo(1)\n└─ 1 [options-1] OPTIONS\n └─ 1.1 [common-2] Common options"
497 );
498
499 let excerpt = select_excerpt(&query, &["1.1".to_owned()]).expect("excerpt");
500 let output = render_excerpt_text(&excerpt);
501 assert!(output.contains("Outline 1.1: OPTIONS > Common options"));
502 assert!(output.contains("child details"));
503 assert!(!output.contains("parent details"));
504 }
505
506 #[test]
507 fn renders_tldr_as_zero_in_outlines_and_standalone_excerpts() {
508 let mut query = query();
509 query.tldr = Some(TldrDocument {
510 title: "demo".to_owned(),
511 description: vec!["A small demonstration.".to_owned()],
512 more_information: None,
513 examples: Vec::new(),
514 platform: "common".to_owned(),
515 language: "en".to_owned(),
516 source_path: "/cache/tldr/demo.md".to_owned(),
517 origin: TldrOrigin::TldrPages,
518 });
519
520 let outline = render_outline_text(&build_outline(&query).expect("combined outline"));
521 assert!(outline.contains("├─ 0 [tldr] TLDR QUICK REFERENCE"));
522 assert!(outline.contains("└─ 1 [options-1] OPTIONS"));
523
524 let excerpt = select_excerpt(&query, &["tldr".to_owned()]).expect("tldr excerpt");
525 assert_eq!(
526 render_excerpt_text(&excerpt),
527 "demo\n\nTLDR\n\nA small demonstration."
528 );
529 }
530
531 #[test]
532 fn man_format_renders_the_manual_but_omits_the_prepended_tldr() {
533 let mut query = query();
534 query.tldr = Some(TldrDocument {
535 title: "demo".to_owned(),
536 description: vec!["A small demonstration.".to_owned()],
537 more_information: None,
538 examples: Vec::new(),
539 platform: "common".to_owned(),
540 language: "en".to_owned(),
541 source_path: "/cache/tldr/demo.md".to_owned(),
542 origin: TldrOrigin::TldrPages,
543 });
544
545 let text = render_query_text(&query);
546 let man = render_query_man(&query);
547
548 assert!(text.contains("TLDR"));
550 assert!(text.contains("A small demonstration."));
551 assert!(!man.contains("TLDR"));
552 assert!(!man.contains("A small demonstration."));
553
554 assert!(man.starts_with("demo(1)\n\nOPTIONS"));
556 assert!(man.contains("parent details"));
557 assert!(man.contains("Common options"));
558 assert!(!man.contains("**"));
559 }
560
561 #[test]
562 fn man_format_does_not_invent_a_document_for_tldr_only_queries() {
563 let mut query = query();
564 query.document = None;
565 query.tldr = Some(TldrDocument {
566 title: "demo".to_owned(),
567 description: vec!["A small demonstration.".to_owned()],
568 more_information: None,
569 examples: Vec::new(),
570 platform: "common".to_owned(),
571 language: "en".to_owned(),
572 source_path: "/cache/tldr/demo.md".to_owned(),
573 origin: TldrOrigin::TldrPages,
574 });
575
576 assert!(render_query_man(&query).is_empty());
577 }
578
579 #[test]
580 fn vertical_space_sets_the_gap_instead_of_stacking_blank_lines() {
581 fn document_with(blocks: Vec<Block>) -> ResolvedContent {
582 ResolvedContent {
583 address: None,
584 label: "demo".to_owned(),
585 document: Some(Document {
586 parser: None,
587 source: DocumentSource {
588 format: SourceFormat::Man,
589 path: None,
590 },
591 meta: DocumentMeta {
592 manual_section: Some("1".to_owned()),
593 ..DocumentMeta::default()
594 },
595 diagnostics: Vec::new(),
596 blocks: Vec::new(),
597 sections: vec![Section {
598 id: "s-1".to_owned().into(),
599 title: "S".to_owned(),
600 spacing_before_lines: 0,
601 blocks,
602 children: Vec::new(),
603 source: None,
604 }],
605 }),
606 tldr: None,
607 }
608 }
609 fn para(value: &str) -> Block {
610 Block::Paragraph {
611 children: vec![Inline::Text {
612 value: value.to_owned(),
613 }],
614 layout: LayoutHint::default(),
615 source: None,
616 }
617 }
618 let vspace = |lines: u16| Block::VerticalSpace {
619 lines,
620 source: None,
621 };
622
623 let one = render_query_text(&document_with(vec![
625 para("first"),
626 vspace(1),
627 para("second"),
628 ]));
629 assert!(one.contains("first\n\nsecond"), "got: {one:?}");
630 assert!(!one.contains("first\n\n\nsecond"), "got: {one:?}");
631
632 let wide = render_query_text(&document_with(vec![
634 para("first"),
635 vspace(2),
636 para("second"),
637 ]));
638 assert!(wide.contains("first\n\n\nsecond"), "got: {wide:?}");
639
640 let edges = render_query_text(&document_with(vec![vspace(2), para("only"), vspace(3)]));
642 assert!(edges.ends_with("only"), "got: {edges:?}");
643 assert!(edges.contains("S\n\nonly"), "got: {edges:?}");
644 }
645
646 #[test]
647 fn inline_definition_descriptions_are_tight_against_their_terms() {
648 let bundle = ResolvedContent {
649 address: None,
650 label: "demo".to_owned(),
651 document: Some(Document {
652 parser: None,
653 source: DocumentSource {
654 format: SourceFormat::Man,
655 path: None,
656 },
657 meta: DocumentMeta {
658 manual_section: Some("1".to_owned()),
659 ..DocumentMeta::default()
660 },
661 diagnostics: Vec::new(),
662 blocks: Vec::new(),
663 sections: vec![Section {
664 id: "ops".to_owned().into(),
665 title: "OPERATORS".to_owned(),
666 spacing_before_lines: 0,
667 blocks: vec![Block::DefinitionList {
668 compact: false,
669 layout: LayoutHint::default(),
670 source: None,
671 items: vec![
672 DefinitionItem {
673 identity: None,
674 inline_term: true,
675 terms: vec![vec![Inline::Text {
676 value: "* / %".to_owned(),
677 }]],
678 description: vec![Block::Paragraph {
679 children: vec![Inline::Text {
680 value: "Multiplication, division, and modulus.".to_owned(),
681 }],
682 layout: LayoutHint::default(),
683 source: None,
684 }],
685 spacing_before_lines: Some(1),
686 },
687 DefinitionItem {
688 identity: None,
689 inline_term: true,
690 terms: vec![vec![Inline::Text {
691 value: "space".to_owned(),
692 }]],
693 description: vec![Block::Paragraph {
694 children: vec![Inline::Text {
695 value: "String concatenation.".to_owned(),
696 }],
697 layout: LayoutHint::default(),
698 source: None,
699 }],
700 spacing_before_lines: Some(1),
701 },
702 ],
703 }],
704 children: Vec::new(),
705 source: None,
706 }],
707 }),
708 tldr: None,
709 };
710
711 let output = render_query_text(&bundle);
712 assert!(
714 output.contains("* / % Multiplication, division, and modulus."),
715 "got: {output:?}"
716 );
717 assert!(
718 output.contains("space String concatenation."),
719 "got: {output:?}"
720 );
721 assert!(!output.contains("* / % "), "got: {output:?}");
723 assert!(!output.contains("space "), "got: {output:?}");
724 }
725
726 #[test]
727 fn man_format_keeps_inline_definitions_tight() {
728 let bundle = ResolvedContent {
729 address: None,
730 label: "demo".to_owned(),
731 document: Some(Document {
732 parser: None,
733 source: DocumentSource {
734 format: SourceFormat::Man,
735 path: None,
736 },
737 meta: DocumentMeta {
738 manual_section: Some("1".to_owned()),
739 ..DocumentMeta::default()
740 },
741 diagnostics: Vec::new(),
742 blocks: Vec::new(),
743 sections: vec![Section {
744 id: "ops".to_owned().into(),
745 title: "OPERATORS".to_owned(),
746 spacing_before_lines: 0,
747 blocks: vec![Block::DefinitionList {
748 compact: false,
749 layout: LayoutHint::default(),
750 source: None,
751 items: vec![
752 DefinitionItem {
753 identity: None,
754 inline_term: true,
755 terms: vec![vec![Inline::Text {
756 value: "&&".to_owned(),
757 }]],
758 description: vec![Block::Paragraph {
759 children: vec![Inline::Text {
760 value: "Logical AND.".to_owned(),
761 }],
762 layout: LayoutHint::default(),
763 source: None,
764 }],
765 spacing_before_lines: Some(1),
766 },
767 DefinitionItem {
768 identity: None,
769 inline_term: false,
770 terms: vec![vec![Inline::Text {
771 value: "--long-option-name".to_owned(),
772 }]],
773 description: vec![Block::Paragraph {
774 children: vec![Inline::Text {
775 value: "A lengthy flag.".to_owned(),
776 }],
777 layout: LayoutHint::default(),
778 source: None,
779 }],
780 spacing_before_lines: Some(1),
781 },
782 ],
783 }],
784 children: Vec::new(),
785 source: None,
786 }],
787 }),
788 tldr: None,
789 };
790
791 let man = render_query_man(&bundle);
792 assert!(man.contains("&& Logical AND."), "got: {man:?}");
794 assert!(
796 man.contains("--long-option-name\n A lengthy flag."),
797 "got: {man:?}"
798 );
799 }
800}