Skip to main content

mant_engine/output/
text.rs

1//! Renders query, outline, and excerpt contracts as unstyled semantic text.
2
3use 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/// Render a complete query without Markdown or terminal escape sequences.
12#[must_use]
13pub fn render_query_text(query: &ResolvedContent) -> String {
14    render_query_body(query, true)
15}
16
17/// Render the manual as `man(1)`-faithful plain text.
18///
19/// Identical to [`render_query_text`] except the prepended tldr block is
20/// omitted, so the output stays a faithful, noise-free subset of the manual
21/// page (no page furniture, overstrike, or hyphenation — those never enter
22/// the document model because the source is parsed directly).
23#[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/// Render a complete query outline as a copyable Unicode tree.
48#[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/// Render selected query nodes as unstyled text with outline context.
66#[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/// Render one compact semantic-entry summary suffix shared by terminal transports.
107#[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(&section.blocks, depth.saturating_mul(2));
266    if !blocks.is_empty() {
267        parts.push(blocks);
268    }
269    let children = render_sections(&section.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    // Blocks are separated by a single blank line by default. An explicit
278    // vertical-space node *sets* the gap before the next block rather than
279    // adding to it, so `.sp` and blank input lines are not double-counted
280    // against the default paragraph separation (which previously turned one
281    // requested blank line into several). Leading and trailing gaps are
282    // dropped so a section never opens or closes with blank lines.
283    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        // Vertical space is handled as an inter-block separator in
355        // `render_blocks`, never as a standalone rendered block.
356        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        // text keeps the tldr block; man drops it entirely.
671        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        // man still renders the manual body verbatim, without markup.
677        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        // One vertical-space line yields exactly one blank line, not several.
746        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        // A larger explicit gap is preserved rather than collapsed.
755        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        // Leading and trailing vertical space never adds blank lines at the edges.
763        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        // Tight: exactly one space between term and description, no leaked indent.
835        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        // No double-space gap between term and description.
844        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        // inline_term=true in --format man: tight single-space.
915        assert!(man.contains("&& Logical AND."), "got: {man:?}");
916        // inline_term=false in --format man: term on its own line.
917        assert!(
918            man.contains("--long-option-name\n  A lengthy flag."),
919            "got: {man:?}"
920        );
921    }
922}