1use mant_ir::{
4 Block, DefinitionItem, EntryKind, EntrySummary, Inline, ListItem, ListKind, ParameterKind,
5 Section, TableCell, TldrCommandPart, 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 if let Some(message) = super::outline_empty_message(outline) {
58 lines.push(message);
59 } else {
60 render_outline_nodes(&outline.nodes, "", &mut lines);
61 }
62 lines.join("\n").trim_end().to_owned()
63}
64
65#[must_use]
67pub fn render_excerpt_text(excerpt: &QueryExcerpt) -> String {
68 let mut parts = vec![document_label(
69 &excerpt.label,
70 excerpt
71 .meta
72 .as_ref()
73 .and_then(|meta| meta.manual_section.as_deref()),
74 )];
75 for selection in &excerpt.selections {
76 parts.push(render_selection(selection));
77 }
78 join_parts(parts)
79}
80
81fn render_outline_nodes(nodes: &[OutlineNode], prefix: &str, output: &mut Vec<String>) {
82 for (index, node) in nodes.iter().enumerate() {
83 let last = index + 1 == nodes.len();
84 let connector = if last { "└─" } else { "├─" };
85 let summary = outline_summary(node).map_or_else(String::new, render_outline_entry_summary);
86 output.push(format!(
87 "{prefix}{connector} {} [{}] {}{summary}",
88 node.path(),
89 node.id(),
90 node.title()
91 ));
92 let child_prefix = format!("{prefix}{}", if last { " " } else { "│ " });
93 render_outline_nodes(node.children(), &child_prefix, output);
94 }
95}
96
97fn outline_summary(node: &OutlineNode) -> Option<&EntrySummary> {
98 match node {
99 OutlineNode::DocumentRoot { entry_summary, .. }
100 | OutlineNode::DocumentSection { entry_summary, .. }
101 | OutlineNode::DocumentEntry { entry_summary, .. } => entry_summary.as_ref(),
102 OutlineNode::Tldr { .. } => None,
103 }
104}
105
106#[must_use]
108pub fn render_outline_entry_summary(summary: &EntrySummary) -> String {
109 if summary.is_empty() {
110 return String::new();
111 }
112 let mut counts = summary
113 .by_kind
114 .iter()
115 .map(|count| {
116 format!(
117 "{} {}",
118 count.count,
119 entry_kind_label(count.kind, count.count == 1)
120 )
121 })
122 .collect::<Vec<_>>();
123 counts.push(format!(
124 "{} {}",
125 summary.forms,
126 if summary.forms == 1 { "form" } else { "forms" }
127 ));
128 format!(
129 " — {} direct, {} nested ({})",
130 summary.direct,
131 summary.descendants,
132 counts.join(", ")
133 )
134}
135
136pub(super) const fn entry_kind_label(kind: EntryKind, singular: bool) -> &'static str {
137 match (kind, singular) {
138 (EntryKind::Command, true) => "command",
139 (EntryKind::Command, false) => "commands",
140 (
141 EntryKind::Parameter {
142 parameter_kind: ParameterKind::Option,
143 },
144 true,
145 ) => "option",
146 (
147 EntryKind::Parameter {
148 parameter_kind: ParameterKind::Option,
149 },
150 false,
151 ) => "options",
152 (
153 EntryKind::Parameter {
154 parameter_kind: ParameterKind::Marker,
155 },
156 true,
157 ) => "marker",
158 (
159 EntryKind::Parameter {
160 parameter_kind: ParameterKind::Marker,
161 },
162 false,
163 ) => "markers",
164 (
165 EntryKind::Parameter {
166 parameter_kind: ParameterKind::Operand,
167 },
168 true,
169 ) => "operand",
170 (
171 EntryKind::Parameter {
172 parameter_kind: ParameterKind::Operand,
173 },
174 false,
175 ) => "operands",
176 (EntryKind::ConfigurationKey, true) => "configuration key",
177 (EntryKind::ConfigurationKey, false) => "configuration keys",
178 (EntryKind::EnvironmentVariable, true) => "environment variable",
179 (EntryKind::EnvironmentVariable, false) => "environment variables",
180 (EntryKind::Variable, true) => "variable",
181 (EntryKind::Variable, false) => "variables",
182 (EntryKind::Value, true) => "value",
183 (EntryKind::Value, false) => "values",
184 (EntryKind::Term, true) => "term",
185 (EntryKind::Term, false) => "terms",
186 }
187}
188
189fn render_selection(selection: &ExcerptSelection) -> String {
190 let context = render_outline_trail(selection.outline());
191 match selection {
192 ExcerptSelection::Tldr { document, .. } => {
193 join_parts(vec![context, render_tldr_text(document)])
194 }
195 ExcerptSelection::DocumentRoot { blocks, .. } => {
196 join_parts(vec![context, render_blocks(blocks, 0)])
197 }
198 ExcerptSelection::DocumentSection { section, .. } => {
199 join_parts(vec![context, render_section(section, 0)])
200 }
201 ExcerptSelection::DocumentEntry { entry, .. } => join_parts(vec![
202 context,
203 render_definitions(std::slice::from_ref(entry), true, 0),
204 ]),
205 }
206}
207
208fn render_outline_trail(trail: &mant_protocol::OutlineTrail) -> String {
209 let breadcrumb = trail
210 .ancestors
211 .iter()
212 .map(|ancestor| ancestor.title.as_str())
213 .chain(std::iter::once(trail.title()))
214 .collect::<Vec<_>>()
215 .join(" > ");
216 format!("Outline {}: {breadcrumb}", trail.path())
217}
218
219fn render_tldr_text(tldr: &TldrDocument) -> String {
220 let mut lines = vec!["TLDR".to_owned()];
221 lines.extend(tldr.description.iter().map(|line| line.trim().to_owned()));
222 if let Some(information) = &tldr.more_information {
223 lines.push(format!("More information: {}", information.trim()));
224 }
225 for example in &tldr.examples {
226 if !example.description.trim().is_empty() {
227 lines.push(example.description.trim().to_owned());
228 }
229 let command = example
230 .command_parts
231 .iter()
232 .map(|part| match part {
233 TldrCommandPart::Text { value } | TldrCommandPart::Placeholder { value } => {
234 value.as_str()
235 }
236 })
237 .collect::<String>();
238 lines.push(if command.is_empty() {
239 example.command.clone()
240 } else {
241 command
242 });
243 }
244 if tldr.origin == TldrOrigin::TldrPages {
245 lines.push(format!(
246 "tldr-pages · CC BY 4.0 · {} · {}",
247 tldr.platform, tldr.language
248 ));
249 }
250 lines.join("\n\n")
251}
252
253fn render_sections(sections: &[Section], depth: usize) -> String {
254 sections
255 .iter()
256 .map(|section| render_section(section, depth))
257 .filter(|section| !section.is_empty())
258 .collect::<Vec<_>>()
259 .join("\n\n")
260}
261
262fn render_section(section: &Section, depth: usize) -> String {
263 let heading_indent = " ".repeat(depth);
264 let mut parts = vec![format!("{heading_indent}{}", section.title)];
265 let blocks = render_blocks(§ion.blocks, depth.saturating_mul(2));
266 if !blocks.is_empty() {
267 parts.push(blocks);
268 }
269 let children = render_sections(§ion.children, depth + 1);
270 if !children.is_empty() {
271 parts.push(children);
272 }
273 join_parts(parts)
274}
275
276fn render_blocks(blocks: &[Block], base_indent: usize) -> String {
277 let mut output = String::new();
284 let mut has_content = false;
285 let mut pending_blank_lines: Option<usize> = None;
286 for block in blocks {
287 if let Block::VerticalSpace { lines, .. } = block {
288 if has_content {
289 let requested = usize::from(*lines);
290 pending_blank_lines = Some(pending_blank_lines.unwrap_or(0).max(requested));
291 }
292 continue;
293 }
294 let Some(text) = render_block(block, base_indent) else {
295 continue;
296 };
297 if has_content {
298 let blank_lines = pending_blank_lines.unwrap_or(1);
299 output.push_str(&"\n".repeat(blank_lines + 1));
300 }
301 output.push_str(&text);
302 has_content = true;
303 pending_blank_lines = None;
304 }
305 output
306}
307
308fn render_block(block: &Block, base_indent: usize) -> Option<String> {
309 let (value, layout_indent) = match block {
310 Block::Paragraph {
311 children, layout, ..
312 }
313 | Block::Preformatted {
314 children, layout, ..
315 } => (inline_text(children), usize::from(layout.indent_columns)),
316 Block::List {
317 kind,
318 start,
319 items,
320 layout,
321 ..
322 } => (
323 render_list(*kind, *start, items, base_indent),
324 usize::from(layout.indent_columns),
325 ),
326 Block::DefinitionList {
327 items,
328 compact,
329 layout,
330 ..
331 } => (
332 render_definitions(items, *compact, base_indent),
333 usize::from(layout.indent_columns),
334 ),
335 Block::Table { rows, layout, .. } => (
336 rows.iter()
337 .map(|row| {
338 row.cells
339 .iter()
340 .map(cell_text)
341 .collect::<Vec<_>>()
342 .join(" | ")
343 })
344 .collect::<Vec<_>>()
345 .join("\n"),
346 usize::from(layout.indent_columns),
347 ),
348 Block::Equation { value, layout, .. }
349 | Block::Unsupported {
350 text: value,
351 layout,
352 ..
353 } => (value.clone(), usize::from(layout.indent_columns)),
354 Block::VerticalSpace { .. } => return None,
357 Block::ThematicBreak { .. } => ("---".to_owned(), 0),
358 };
359 let value = value.trim_matches('\n');
360 (!value.trim().is_empty()).then(|| indent_lines(value, base_indent + layout_indent))
361}
362
363fn render_list(
364 kind: ListKind,
365 start: Option<u64>,
366 items: &[ListItem],
367 base_indent: usize,
368) -> String {
369 items
370 .iter()
371 .enumerate()
372 .filter_map(|(index, item)| {
373 let marker = match kind {
374 ListKind::Ordered => format!(
375 "{}. ",
376 start
377 .unwrap_or(1)
378 .saturating_add(u64::try_from(index).unwrap_or(u64::MAX))
379 ),
380 ListKind::Bullet => "- ".to_owned(),
381 ListKind::Plain => String::new(),
382 };
383 prefix_text_item(&render_blocks(&item.blocks, base_indent), &marker)
384 })
385 .collect::<Vec<_>>()
386 .join("\n")
387}
388
389fn render_definitions(items: &[DefinitionItem], compact: bool, base_indent: usize) -> String {
390 let rendered = items
391 .iter()
392 .filter_map(|item| {
393 let terms = item
394 .terms
395 .iter()
396 .map(|term| inline_text(term))
397 .filter(|term| !term.trim().is_empty())
398 .collect::<Vec<_>>()
399 .join(", ");
400 let description = render_blocks(&item.description, base_indent);
401 let value = match (terms.is_empty(), description.is_empty()) {
402 (false, false) => {
403 if item.inline_term {
404 Some(format!("{terms} {}", description.trim_start()))
405 } else {
406 Some(format!("{terms}\n{}", indent_lines(&description, 2)))
407 }
408 }
409 (false, true) => Some(terms),
410 (true, false) => Some(description),
411 (true, true) => None,
412 }?;
413 Some((value, item.spacing_before_lines))
414 })
415 .collect::<Vec<_>>();
416
417 let Some((first, rest)) = rendered.split_first() else {
418 return String::new();
419 };
420 let mut output = first.0.clone();
421 for (item, spacing_before_lines) in rest {
422 let blank_lines = spacing_before_lines.unwrap_or(u16::from(!compact));
423 output.push_str(&"\n".repeat(usize::from(blank_lines) + 1));
424 output.push_str(item);
425 }
426 output
427}
428
429fn cell_text(cell: &TableCell) -> String {
430 render_blocks(&cell.blocks, 0).replace('\n', " ")
431}
432
433fn inline_text(children: &[Inline]) -> String {
434 let mut output = String::new();
435 for child in children {
436 match child {
437 Inline::Text { value } | Inline::Code { value } => output.push_str(value),
438 Inline::Strong { children }
439 | Inline::Emphasis { children }
440 | Inline::Link { children, .. } => output.push_str(&inline_text(children)),
441 Inline::Anchor { .. } => {}
442 Inline::LineBreak => output.push('\n'),
443 }
444 }
445 output
446}
447
448fn prefix_text_item(content: &str, marker: &str) -> Option<String> {
449 if content.trim().is_empty() {
450 return None;
451 }
452 let continuation = " ".repeat(marker.chars().count());
453 let mut lines = content.lines();
454 let mut output = format!("{marker}{}", lines.next()?);
455 for line in lines {
456 output.push('\n');
457 output.push_str(&continuation);
458 output.push_str(line);
459 }
460 Some(output)
461}
462
463fn indent_lines(value: &str, columns: usize) -> String {
464 if columns == 0 {
465 return value.to_owned();
466 }
467 let prefix = " ".repeat(columns);
468 value
469 .lines()
470 .map(|line| {
471 if line.is_empty() {
472 String::new()
473 } else {
474 format!("{prefix}{line}")
475 }
476 })
477 .collect::<Vec<_>>()
478 .join("\n")
479}
480
481fn document_label(document: &str, section: Option<&str>) -> String {
482 section.map_or_else(
483 || document.to_owned(),
484 |section| format!("{document}({section})"),
485 )
486}
487
488fn join_parts(parts: Vec<String>) -> String {
489 parts
490 .into_iter()
491 .filter(|part| !part.trim().is_empty())
492 .collect::<Vec<_>>()
493 .join("\n\n")
494 .trim_end()
495 .to_owned()
496}
497
498#[cfg(test)]
499mod tests {
500 use crate::ResolvedContent;
501 use mant_ir::{
502 Block, DefinitionItem, Document, DocumentMeta, DocumentSource, EntryKind, Inline,
503 LayoutHint, Section, SourceFormat, TldrDocument, TldrOrigin,
504 };
505 use mant_protocol::EntryProjection;
506
507 use super::{render_excerpt_text, render_outline_text, render_query_man, render_query_text};
508 use crate::{build_outline, build_outline_projection, render_outline_markdown, select_excerpt};
509
510 fn query() -> ResolvedContent {
511 ResolvedContent {
512 address: None,
513 label: "demo".to_owned(),
514 document: Some(Document {
515 parser: None,
516 source: DocumentSource {
517 format: SourceFormat::Man,
518 path: None,
519 },
520 meta: DocumentMeta {
521 manual_section: Some("1".to_owned()),
522 ..DocumentMeta::default()
523 },
524 diagnostics: Vec::new(),
525 blocks: Vec::new(),
526 sections: vec![Section {
527 id: "options-1".to_owned().into(),
528 title: "OPTIONS".to_owned(),
529 spacing_before_lines: 0,
530 blocks: vec![paragraph("parent details", true)],
531 children: vec![Section {
532 id: "common-2".to_owned().into(),
533 title: "Common options".to_owned(),
534 spacing_before_lines: 1,
535 blocks: vec![paragraph("child details", false)],
536 children: Vec::new(),
537 source: None,
538 }],
539 source: None,
540 }],
541 }),
542 tldr: None,
543 }
544 }
545
546 fn paragraph(value: &str, strong: bool) -> Block {
547 let text = vec![Inline::Text {
548 value: value.to_owned(),
549 }];
550 Block::Paragraph {
551 children: if strong {
552 vec![Inline::Strong { children: text }]
553 } else {
554 text
555 },
556 layout: LayoutHint::default(),
557 source: None,
558 }
559 }
560
561 #[test]
562 fn renders_plain_queries_without_markup_and_uses_resolved_manual_sections() {
563 let output = render_query_text(&query());
564
565 assert!(output.starts_with("demo(1)\n\nOPTIONS"));
566 assert!(output.contains("parent details"));
567 assert!(output.contains("Common options"));
568 assert!(!output.contains("**"));
569 }
570
571 #[test]
572 fn renders_copyable_outline_trees_and_contextual_excerpts() {
573 let query = query();
574 let outline = build_outline(&query).expect("outline");
575 assert_eq!(
576 render_outline_text(&outline),
577 "demo(1)\n└─ 1 [options-1] OPTIONS\n └─ 1.1 [common-2] Common options"
578 );
579
580 let excerpt = select_excerpt(&query, &["1.1".to_owned()]).expect("excerpt");
581 let output = render_excerpt_text(&excerpt);
582 assert!(output.contains("Outline 1.1: OPTIONS > Common options"));
583 assert!(output.contains("child details"));
584 assert!(!output.contains("parent details"));
585 }
586
587 #[test]
588 fn renders_an_explicit_zero_for_an_empty_kind_projection() {
589 let outline = build_outline_projection(
590 &query(),
591 EntryProjection::Kinds {
592 kinds: vec![EntryKind::EnvironmentVariable],
593 },
594 None,
595 )
596 .expect("empty kind projection");
597
598 assert_eq!(
599 render_outline_text(&outline),
600 "demo(1)\n0 matching semantic entries for: environment variables"
601 );
602 assert!(
603 render_outline_markdown(&outline)
604 .contains("0 matching semantic entries for: environment variables")
605 );
606 }
607
608 #[test]
609 fn renders_tldr_as_zero_in_outlines_and_standalone_excerpts() {
610 let mut query = query();
611 query.tldr = Some(TldrDocument {
612 title: "demo".to_owned(),
613 description: vec!["A small demonstration.".to_owned()],
614 more_information: None,
615 examples: Vec::new(),
616 platform: "common".to_owned(),
617 language: "en".to_owned(),
618 source_path: "/cache/tldr/demo.md".to_owned(),
619 origin: TldrOrigin::TldrPages,
620 });
621
622 let outline = render_outline_text(&build_outline(&query).expect("combined outline"));
623 assert!(outline.contains("├─ 0 [tldr] TLDR QUICK REFERENCE"));
624 assert!(outline.contains("└─ 1 [options-1] OPTIONS"));
625
626 let excerpt = select_excerpt(&query, &["tldr".to_owned()]).expect("tldr excerpt");
627 assert_eq!(
628 render_excerpt_text(&excerpt),
629 "demo\n\nOutline 0: TLDR QUICK REFERENCE\n\nTLDR\n\nA small demonstration.\n\ntldr-pages · CC BY 4.0 · common · en"
630 );
631 }
632
633 #[test]
634 fn attributes_only_community_tldr_in_plain_text() {
635 let mut community = query();
636 community.tldr = Some(TldrDocument {
637 title: "demo".to_owned(),
638 description: vec!["A small demonstration.".to_owned()],
639 more_information: None,
640 examples: Vec::new(),
641 platform: "common".to_owned(),
642 language: "en".to_owned(),
643 source_path: "/cache/tldr/demo.md".to_owned(),
644 origin: TldrOrigin::TldrPages,
645 });
646 assert!(render_query_text(&community).contains("tldr-pages · CC BY 4.0 · common · en"));
647
648 let mut embedded = community;
649 embedded.tldr.as_mut().expect("tldr").origin = TldrOrigin::Embedded;
650 assert!(!render_query_text(&embedded).contains("CC BY 4.0"));
651 }
652
653 #[test]
654 fn man_format_renders_the_manual_but_omits_the_prepended_tldr() {
655 let mut query = query();
656 query.tldr = Some(TldrDocument {
657 title: "demo".to_owned(),
658 description: vec!["A small demonstration.".to_owned()],
659 more_information: None,
660 examples: Vec::new(),
661 platform: "common".to_owned(),
662 language: "en".to_owned(),
663 source_path: "/cache/tldr/demo.md".to_owned(),
664 origin: TldrOrigin::TldrPages,
665 });
666
667 let text = render_query_text(&query);
668 let man = render_query_man(&query);
669
670 assert!(text.contains("TLDR"));
672 assert!(text.contains("A small demonstration."));
673 assert!(!man.contains("TLDR"));
674 assert!(!man.contains("A small demonstration."));
675
676 assert!(man.starts_with("demo(1)\n\nOPTIONS"));
678 assert!(man.contains("parent details"));
679 assert!(man.contains("Common options"));
680 assert!(!man.contains("**"));
681 }
682
683 #[test]
684 fn man_format_does_not_invent_a_document_for_tldr_only_queries() {
685 let mut query = query();
686 query.document = None;
687 query.tldr = Some(TldrDocument {
688 title: "demo".to_owned(),
689 description: vec!["A small demonstration.".to_owned()],
690 more_information: None,
691 examples: Vec::new(),
692 platform: "common".to_owned(),
693 language: "en".to_owned(),
694 source_path: "/cache/tldr/demo.md".to_owned(),
695 origin: TldrOrigin::TldrPages,
696 });
697
698 assert!(render_query_man(&query).is_empty());
699 }
700
701 #[test]
702 fn vertical_space_sets_the_gap_instead_of_stacking_blank_lines() {
703 fn document_with(blocks: Vec<Block>) -> ResolvedContent {
704 ResolvedContent {
705 address: None,
706 label: "demo".to_owned(),
707 document: Some(Document {
708 parser: None,
709 source: DocumentSource {
710 format: SourceFormat::Man,
711 path: None,
712 },
713 meta: DocumentMeta {
714 manual_section: Some("1".to_owned()),
715 ..DocumentMeta::default()
716 },
717 diagnostics: Vec::new(),
718 blocks: Vec::new(),
719 sections: vec![Section {
720 id: "s-1".to_owned().into(),
721 title: "S".to_owned(),
722 spacing_before_lines: 0,
723 blocks,
724 children: Vec::new(),
725 source: None,
726 }],
727 }),
728 tldr: None,
729 }
730 }
731 fn para(value: &str) -> Block {
732 Block::Paragraph {
733 children: vec![Inline::Text {
734 value: value.to_owned(),
735 }],
736 layout: LayoutHint::default(),
737 source: None,
738 }
739 }
740 let vspace = |lines: u16| Block::VerticalSpace {
741 lines,
742 source: None,
743 };
744
745 let one = render_query_text(&document_with(vec![
747 para("first"),
748 vspace(1),
749 para("second"),
750 ]));
751 assert!(one.contains("first\n\nsecond"), "got: {one:?}");
752 assert!(!one.contains("first\n\n\nsecond"), "got: {one:?}");
753
754 let wide = render_query_text(&document_with(vec![
756 para("first"),
757 vspace(2),
758 para("second"),
759 ]));
760 assert!(wide.contains("first\n\n\nsecond"), "got: {wide:?}");
761
762 let edges = render_query_text(&document_with(vec![vspace(2), para("only"), vspace(3)]));
764 assert!(edges.ends_with("only"), "got: {edges:?}");
765 assert!(edges.contains("S\n\nonly"), "got: {edges:?}");
766 }
767
768 #[test]
769 fn inline_definition_descriptions_are_tight_against_their_terms() {
770 let bundle = ResolvedContent {
771 address: None,
772 label: "demo".to_owned(),
773 document: Some(Document {
774 parser: None,
775 source: DocumentSource {
776 format: SourceFormat::Man,
777 path: None,
778 },
779 meta: DocumentMeta {
780 manual_section: Some("1".to_owned()),
781 ..DocumentMeta::default()
782 },
783 diagnostics: Vec::new(),
784 blocks: Vec::new(),
785 sections: vec![Section {
786 id: "ops".to_owned().into(),
787 title: "OPERATORS".to_owned(),
788 spacing_before_lines: 0,
789 blocks: vec![Block::DefinitionList {
790 compact: false,
791 layout: LayoutHint::default(),
792 source: None,
793 items: vec![
794 DefinitionItem {
795 identity: None,
796 inline_term: true,
797 terms: vec![vec![Inline::Text {
798 value: "* / %".to_owned(),
799 }]],
800 description: vec![Block::Paragraph {
801 children: vec![Inline::Text {
802 value: "Multiplication, division, and modulus.".to_owned(),
803 }],
804 layout: LayoutHint::default(),
805 source: None,
806 }],
807 spacing_before_lines: Some(1),
808 },
809 DefinitionItem {
810 identity: None,
811 inline_term: true,
812 terms: vec![vec![Inline::Text {
813 value: "space".to_owned(),
814 }]],
815 description: vec![Block::Paragraph {
816 children: vec![Inline::Text {
817 value: "String concatenation.".to_owned(),
818 }],
819 layout: LayoutHint::default(),
820 source: None,
821 }],
822 spacing_before_lines: Some(1),
823 },
824 ],
825 }],
826 children: Vec::new(),
827 source: None,
828 }],
829 }),
830 tldr: None,
831 };
832
833 let output = render_query_text(&bundle);
834 assert!(
836 output.contains("* / % Multiplication, division, and modulus."),
837 "got: {output:?}"
838 );
839 assert!(
840 output.contains("space String concatenation."),
841 "got: {output:?}"
842 );
843 assert!(!output.contains("* / % "), "got: {output:?}");
845 assert!(!output.contains("space "), "got: {output:?}");
846 }
847
848 #[test]
849 fn man_format_keeps_inline_definitions_tight() {
850 let bundle = ResolvedContent {
851 address: None,
852 label: "demo".to_owned(),
853 document: Some(Document {
854 parser: None,
855 source: DocumentSource {
856 format: SourceFormat::Man,
857 path: None,
858 },
859 meta: DocumentMeta {
860 manual_section: Some("1".to_owned()),
861 ..DocumentMeta::default()
862 },
863 diagnostics: Vec::new(),
864 blocks: Vec::new(),
865 sections: vec![Section {
866 id: "ops".to_owned().into(),
867 title: "OPERATORS".to_owned(),
868 spacing_before_lines: 0,
869 blocks: vec![Block::DefinitionList {
870 compact: false,
871 layout: LayoutHint::default(),
872 source: None,
873 items: vec![
874 DefinitionItem {
875 identity: None,
876 inline_term: true,
877 terms: vec![vec![Inline::Text {
878 value: "&&".to_owned(),
879 }]],
880 description: vec![Block::Paragraph {
881 children: vec![Inline::Text {
882 value: "Logical AND.".to_owned(),
883 }],
884 layout: LayoutHint::default(),
885 source: None,
886 }],
887 spacing_before_lines: Some(1),
888 },
889 DefinitionItem {
890 identity: None,
891 inline_term: false,
892 terms: vec![vec![Inline::Text {
893 value: "--long-option-name".to_owned(),
894 }]],
895 description: vec![Block::Paragraph {
896 children: vec![Inline::Text {
897 value: "A lengthy flag.".to_owned(),
898 }],
899 layout: LayoutHint::default(),
900 source: None,
901 }],
902 spacing_before_lines: Some(1),
903 },
904 ],
905 }],
906 children: Vec::new(),
907 source: None,
908 }],
909 }),
910 tldr: None,
911 };
912
913 let man = render_query_man(&bundle);
914 assert!(man.contains("&& Logical AND."), "got: {man:?}");
916 assert!(
918 man.contains("--long-option-name\n A lengthy flag."),
919 "got: {man:?}"
920 );
921 }
922}