Skip to main content

mant_core/output/
text.rs

1//! Renders query, outline, and excerpt contracts as unstyled semantic text.
2
3use mant_ast::{
4    Block, DefinitionItem, ExcerptSelection, Inline, ListItem, ListKind, OutlineNode, QueryBundle,
5    QueryExcerpt, QueryOutline, Section, TableCell, TldrCommandPart, TldrDocument,
6};
7
8/// Render a complete query without Markdown or terminal escape sequences.
9#[must_use]
10pub fn render_query_text(query: &QueryBundle) -> String {
11    render_query_body(query, true)
12}
13
14/// Render the manual as `man(1)`-faithful plain text.
15///
16/// Identical to [`render_query_text`] except the prepended tldr block is
17/// omitted, so the output stays a faithful, noise-free subset of the manual
18/// page (no page furniture, overstrike, or hyphenation — those never enter
19/// the document model because the source is parsed directly).
20#[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/// Render a complete query outline as a copyable Unicode tree.
45#[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/// Render selected query nodes as unstyled text with outline context.
59#[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(&section.blocks, depth.saturating_mul(2));
183    if !blocks.is_empty() {
184        parts.push(blocks);
185    }
186    let children = render_sections(&section.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    // Blocks are separated by a single blank line by default. An explicit
195    // vertical-space node *sets* the gap before the next block rather than
196    // adding to it, so `.sp` and blank input lines are not double-counted
197    // against the default paragraph separation (which previously turned one
198    // requested blank line into several). Leading and trailing gaps are
199    // dropped so a section never opens or closes with blank lines.
200    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        // Vertical space is handled as an inter-block separator in
272        // `render_blocks`, never as a standalone rendered block.
273        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        // text keeps the tldr block; man drops it entirely.
552        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        // man still renders the manual body verbatim, without markup.
558        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        // One vertical-space line yields exactly one blank line, not several.
633        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        // A larger explicit gap is preserved rather than collapsed.
642        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        // Leading and trailing vertical space never adds blank lines at the edges.
650        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        // Tight: exactly one space between term and description, no leaked indent.
728        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        // No double-space gap between term and description.
737        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        // inline_term=true in --format man: tight single-space.
814        assert!(man.contains("&& Logical AND."), "got: {man:?}");
815        // inline_term=false in --format man: term on its own line.
816        assert!(
817            man.contains("--long-option-name\n  A lengthy flag."),
818            "got: {man:?}"
819        );
820    }
821}