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(topic: &str, section: Option<&str>) -> String {
402 section.map_or_else(|| topic.to_owned(), |section| format!("{topic}({section})"))
403}
404
405fn join_parts(parts: Vec<String>) -> String {
406 parts
407 .into_iter()
408 .filter(|part| !part.trim().is_empty())
409 .collect::<Vec<_>>()
410 .join("\n\n")
411 .trim_end()
412 .to_owned()
413}
414
415#[cfg(test)]
416mod tests {
417 use mant_ast::{
418 Block, DefinitionItem, DocumentMeta, DocumentSchema, DocumentSource, Inline, LayoutHint,
419 MantDocument, Producer, QueryBundle, QuerySchema, Section, SourceFormat, TldrDocument,
420 TldrOrigin,
421 };
422
423 use super::{render_excerpt_text, render_outline_text, render_query_man, render_query_text};
424 use crate::{build_outline, select_excerpt};
425
426 fn query() -> QueryBundle {
427 QueryBundle {
428 schema: QuerySchema::V3,
429 label: "demo".to_owned(),
430 document: Some(MantDocument {
431 schema: DocumentSchema::V3,
432 producer: Producer {
433 name: "test".to_owned(),
434 version: "1".to_owned(),
435 engine: None,
436 },
437 source: DocumentSource {
438 format: SourceFormat::Man,
439 path: None,
440 renderer: None,
441 },
442 meta: DocumentMeta {
443 section: Some("1".to_owned()),
444 ..DocumentMeta::default()
445 },
446 diagnostics: Vec::new(),
447 blocks: Vec::new(),
448 sections: vec![Section {
449 id: "options-1".to_owned(),
450 title: "OPTIONS".to_owned(),
451 spacing_before_lines: 0,
452 blocks: vec![paragraph("parent details", true)],
453 children: vec![Section {
454 id: "common-2".to_owned(),
455 title: "Common options".to_owned(),
456 spacing_before_lines: 1,
457 blocks: vec![paragraph("child details", false)],
458 children: Vec::new(),
459 source: None,
460 }],
461 source: None,
462 }],
463 }),
464 tldr: None,
465 }
466 }
467
468 fn paragraph(value: &str, strong: bool) -> Block {
469 let text = vec![Inline::Text {
470 value: value.to_owned(),
471 }];
472 Block::Paragraph {
473 children: if strong {
474 vec![Inline::Strong { children: text }]
475 } else {
476 text
477 },
478 layout: LayoutHint::default(),
479 source: None,
480 }
481 }
482
483 #[test]
484 fn renders_plain_queries_without_markup_and_uses_resolved_manual_sections() {
485 let output = render_query_text(&query());
486
487 assert!(output.starts_with("demo(1)\n\nOPTIONS"));
488 assert!(output.contains("parent details"));
489 assert!(output.contains("Common options"));
490 assert!(!output.contains("**"));
491 }
492
493 #[test]
494 fn renders_copyable_outline_trees_and_contextual_excerpts() {
495 let query = query();
496 let outline = build_outline(&query).expect("outline");
497 assert_eq!(
498 render_outline_text(&outline),
499 "demo(1)\n└─ 1 [options-1] OPTIONS\n └─ 1.1 [common-2] Common options"
500 );
501
502 let excerpt = select_excerpt(&query, &["1.1".to_owned()]).expect("excerpt");
503 let output = render_excerpt_text(&excerpt);
504 assert!(output.contains("Outline 1.1: OPTIONS > Common options"));
505 assert!(output.contains("child details"));
506 assert!(!output.contains("parent details"));
507 }
508
509 #[test]
510 fn renders_tldr_as_zero_in_outlines_and_standalone_excerpts() {
511 let mut query = query();
512 query.tldr = Some(TldrDocument {
513 title: "demo".to_owned(),
514 description: vec!["A small demonstration.".to_owned()],
515 more_information: None,
516 examples: Vec::new(),
517 platform: "common".to_owned(),
518 language: "en".to_owned(),
519 source_path: "/cache/tldr/demo.md".to_owned(),
520 origin: TldrOrigin::TldrPages,
521 });
522
523 let outline = render_outline_text(&build_outline(&query).expect("combined outline"));
524 assert!(outline.contains("├─ 0 [tldr] TLDR QUICK REFERENCE"));
525 assert!(outline.contains("└─ 1 [options-1] OPTIONS"));
526
527 let excerpt = select_excerpt(&query, &["tldr".to_owned()]).expect("tldr excerpt");
528 assert_eq!(
529 render_excerpt_text(&excerpt),
530 "demo\n\nTLDR\n\nA small demonstration."
531 );
532 }
533
534 #[test]
535 fn man_format_renders_the_manual_but_omits_the_prepended_tldr() {
536 let mut query = query();
537 query.tldr = Some(TldrDocument {
538 title: "demo".to_owned(),
539 description: vec!["A small demonstration.".to_owned()],
540 more_information: None,
541 examples: Vec::new(),
542 platform: "common".to_owned(),
543 language: "en".to_owned(),
544 source_path: "/cache/tldr/demo.md".to_owned(),
545 origin: TldrOrigin::TldrPages,
546 });
547
548 let text = render_query_text(&query);
549 let man = render_query_man(&query);
550
551 assert!(text.contains("TLDR"));
553 assert!(text.contains("A small demonstration."));
554 assert!(!man.contains("TLDR"));
555 assert!(!man.contains("A small demonstration."));
556
557 assert!(man.starts_with("demo(1)\n\nOPTIONS"));
559 assert!(man.contains("parent details"));
560 assert!(man.contains("Common options"));
561 assert!(!man.contains("**"));
562 }
563
564 #[test]
565 fn man_format_does_not_invent_a_document_for_tldr_only_queries() {
566 let mut query = query();
567 query.document = None;
568 query.tldr = Some(TldrDocument {
569 title: "demo".to_owned(),
570 description: vec!["A small demonstration.".to_owned()],
571 more_information: None,
572 examples: Vec::new(),
573 platform: "common".to_owned(),
574 language: "en".to_owned(),
575 source_path: "/cache/tldr/demo.md".to_owned(),
576 origin: TldrOrigin::TldrPages,
577 });
578
579 assert!(render_query_man(&query).is_empty());
580 }
581
582 #[test]
583 fn vertical_space_sets_the_gap_instead_of_stacking_blank_lines() {
584 fn document_with(blocks: Vec<Block>) -> QueryBundle {
585 QueryBundle {
586 schema: QuerySchema::V3,
587 label: "demo".to_owned(),
588 document: Some(MantDocument {
589 schema: DocumentSchema::V3,
590 producer: Producer {
591 name: "test".to_owned(),
592 version: "1".to_owned(),
593 engine: None,
594 },
595 source: DocumentSource {
596 format: SourceFormat::Man,
597 path: None,
598 renderer: None,
599 },
600 meta: DocumentMeta {
601 section: Some("1".to_owned()),
602 ..DocumentMeta::default()
603 },
604 diagnostics: Vec::new(),
605 blocks: Vec::new(),
606 sections: vec![Section {
607 id: "s-1".to_owned(),
608 title: "S".to_owned(),
609 spacing_before_lines: 0,
610 blocks,
611 children: Vec::new(),
612 source: None,
613 }],
614 }),
615 tldr: None,
616 }
617 }
618 fn para(value: &str) -> Block {
619 Block::Paragraph {
620 children: vec![Inline::Text {
621 value: value.to_owned(),
622 }],
623 layout: LayoutHint::default(),
624 source: None,
625 }
626 }
627 let vspace = |lines: u16| Block::VerticalSpace {
628 lines,
629 source: None,
630 };
631
632 let one = render_query_text(&document_with(vec![
634 para("first"),
635 vspace(1),
636 para("second"),
637 ]));
638 assert!(one.contains("first\n\nsecond"), "got: {one:?}");
639 assert!(!one.contains("first\n\n\nsecond"), "got: {one:?}");
640
641 let wide = render_query_text(&document_with(vec![
643 para("first"),
644 vspace(2),
645 para("second"),
646 ]));
647 assert!(wide.contains("first\n\n\nsecond"), "got: {wide:?}");
648
649 let edges = render_query_text(&document_with(vec![vspace(2), para("only"), vspace(3)]));
651 assert!(edges.ends_with("only"), "got: {edges:?}");
652 assert!(edges.contains("S\n\nonly"), "got: {edges:?}");
653 }
654
655 #[test]
656 fn inline_definition_descriptions_are_tight_against_their_terms() {
657 let bundle = QueryBundle {
658 schema: QuerySchema::V3,
659 label: "demo".to_owned(),
660 document: Some(MantDocument {
661 schema: DocumentSchema::V3,
662 producer: Producer {
663 name: "test".to_owned(),
664 version: "1".to_owned(),
665 engine: None,
666 },
667 source: DocumentSource {
668 format: SourceFormat::Man,
669 path: None,
670 renderer: 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::V3,
745 label: "demo".to_owned(),
746 document: Some(MantDocument {
747 schema: DocumentSchema::V3,
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 renderer: None,
757 },
758 meta: DocumentMeta {
759 section: Some("1".to_owned()),
760 ..DocumentMeta::default()
761 },
762 diagnostics: Vec::new(),
763 blocks: Vec::new(),
764 sections: vec![Section {
765 id: "ops".to_owned(),
766 title: "OPERATORS".to_owned(),
767 spacing_before_lines: 0,
768 blocks: vec![Block::DefinitionList {
769 compact: false,
770 layout: LayoutHint::default(),
771 source: None,
772 items: vec![
773 DefinitionItem {
774 identity: None,
775 inline_term: true,
776 terms: vec![vec![Inline::Text {
777 value: "&&".to_owned(),
778 }]],
779 description: vec![Block::Paragraph {
780 children: vec![Inline::Text {
781 value: "Logical AND.".to_owned(),
782 }],
783 layout: LayoutHint::default(),
784 source: None,
785 }],
786 spacing_before_lines: Some(1),
787 },
788 DefinitionItem {
789 identity: None,
790 inline_term: false,
791 terms: vec![vec![Inline::Text {
792 value: "--long-option-name".to_owned(),
793 }]],
794 description: vec![Block::Paragraph {
795 children: vec![Inline::Text {
796 value: "A lengthy flag.".to_owned(),
797 }],
798 layout: LayoutHint::default(),
799 source: None,
800 }],
801 spacing_before_lines: Some(1),
802 },
803 ],
804 }],
805 children: Vec::new(),
806 source: None,
807 }],
808 }),
809 tldr: None,
810 };
811
812 let man = render_query_man(&bundle);
813 assert!(man.contains("&& Logical AND."), "got: {man:?}");
815 assert!(
817 man.contains("--long-option-name\n A lengthy flag."),
818 "got: {man:?}"
819 );
820 }
821}