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