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