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