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