Skip to main content

mant_core/mandoc/
mod.rs

1//! Lowers the owned libmandoc syntax tree into `ManT`'s stable document model.
2
3mod blocks;
4mod diagnostics;
5pub(crate) mod inline;
6mod layout;
7mod navigation;
8
9use std::path::Path;
10
11use libmandoc_rs::{Document, IncludePolicy, MacroSet, Node, ParseOptions, ParseReport, Parser};
12use mant_ast::{
13    DocumentMeta, DocumentSchema, DocumentSource, Engine, MantDocument, Producer, SourceFormat,
14    SourceSpan,
15};
16
17use self::inline::{parse_roff_text, plain_text};
18
19pub use libmandoc_rs::ParseError;
20
21/// Parse and normalize one located man or mdoc source file.
22///
23/// # Errors
24///
25/// Returns [`ParseError`] when the source cannot be opened or parsed.
26pub fn parse_manual_source(path: &Path) -> Result<MantDocument, ParseError> {
27    let report = Parser::new(ParseOptions {
28        includes: IncludePolicy::SourceTree,
29        ..ParseOptions::default()
30    })
31    .parse_file(path)?;
32    Ok(lower_mandoc_document(path, &report))
33}
34
35/// Convert a completed low-level parse into the stable document contract.
36#[must_use]
37pub fn lower_mandoc_document(path: &Path, report: &ParseReport) -> MantDocument {
38    let parsed: &Document = &report.document;
39    let mut context = LoweringContext::new(parsed.metadata.name.as_deref());
40    let mut diagnostics = diagnostics::lower_diagnostics(&report.diagnostics);
41    let mut sections = blocks::lower_sections(&parsed.root, &mut context);
42    let explicit_targets = navigation::explicit_targets(&parsed.root);
43    let mut retained_targets = explicit_targets.clone();
44    retained_targets.extend(crate::definitions::identify_definitions(
45        &mut sections,
46        &explicit_targets,
47    ));
48    navigation::resolve_navigation(&mut sections, &retained_targets, &mut diagnostics);
49    MantDocument {
50        schema: DocumentSchema::V3,
51        producer: Producer {
52            name: "mant".to_owned(),
53            version: env!("CARGO_PKG_VERSION").to_owned(),
54            engine: Some(Engine {
55                name: "libmandoc".to_owned(),
56                version: libmandoc_rs::LIBMANDOC_VERSION.to_owned(),
57            }),
58        },
59        source: DocumentSource {
60            format: match parsed.macro_set {
61                MacroSet::Mdoc => SourceFormat::Mdoc,
62                MacroSet::Man | MacroSet::None => SourceFormat::Man,
63            },
64            path: Some(path.to_string_lossy().into_owned()),
65            renderer: None,
66        },
67        meta: DocumentMeta {
68            title: normalize_metadata(parsed.metadata.title.as_deref()),
69            section: normalize_metadata(parsed.metadata.section.as_deref()),
70            date: normalize_metadata(parsed.metadata.date.as_deref()),
71            volume: normalize_metadata(parsed.metadata.volume.as_deref()),
72            os: normalize_metadata(parsed.metadata.os.as_deref()),
73            arch: normalize_metadata(parsed.metadata.arch.as_deref()),
74            names: normalize_metadata(parsed.metadata.name.as_deref())
75                .into_iter()
76                .collect(),
77            alias_target: parsed.metadata.alias_target.clone(),
78        },
79        diagnostics,
80        blocks: Vec::new(),
81        sections,
82    }
83}
84
85/// Metadata strings come from roff macro arguments rather than visible text
86/// nodes, so libmandoc can legitimately retain zero-width escapes such as
87/// `\&`. Normalize them through the same inline decoder used for document
88/// content before exposing the renderer-neutral contract.
89fn normalize_metadata(value: Option<&str>) -> Option<String> {
90    value.map(|value| plain_text(&parse_roff_text(value)))
91}
92
93struct LoweringContext<'a> {
94    default_name: Option<&'a str>,
95    next_section_id: usize,
96}
97
98impl<'a> LoweringContext<'a> {
99    const fn new(default_name: Option<&'a str>) -> Self {
100        Self {
101            default_name,
102            next_section_id: 1,
103        }
104    }
105
106    fn section_id(&mut self, title: &str) -> String {
107        let sequence = self.next_section_id;
108        self.next_section_id += 1;
109        let slug: String = title
110            .chars()
111            .flat_map(char::to_lowercase)
112            .map(|character| {
113                if character.is_alphanumeric() {
114                    character
115                } else {
116                    '-'
117                }
118            })
119            .collect::<String>()
120            .split('-')
121            .filter(|part| !part.is_empty())
122            .collect::<Vec<_>>()
123            .join("-");
124        if slug.is_empty() {
125            format!("section-{sequence}")
126        } else {
127            format!("{slug}-{sequence}")
128        }
129    }
130}
131
132fn source_span(node: &Node) -> Option<SourceSpan> {
133    (node.line > 0).then_some(SourceSpan {
134        line: node.line,
135        column: node.column.max(1),
136        end_line: None,
137        end_column: None,
138    })
139}
140
141fn part_children(node: &Node, kind: libmandoc_rs::NodeKind) -> &[Node] {
142    node.children
143        .iter()
144        .find(|child| child.kind == kind)
145        .map_or(&[], |child| child.children.as_slice())
146}
147
148#[cfg(test)]
149mod tests {
150    use std::{fs, process};
151
152    use mant_ast::{Block, DiagnosticLevel, Inline, SourceFormat};
153
154    use super::parse_manual_source;
155
156    fn temporary_source(label: &str, source: &str) -> std::path::PathBuf {
157        let path = std::env::temp_dir().join(format!("mant-lower-{label}-{}.1", process::id()));
158        fs::write(&path, source).expect("write temporary roff fixture");
159        path
160    }
161
162    #[test]
163    fn lowers_man_sections_fonts_definitions_and_literal_blocks() {
164        let path = temporary_source(
165            "man",
166            ".TH MANT 1 \"July 2026\"\n\
167             .SH NAME\n\
168             mant \\- a viewer\n\
169             .SH OPTIONS\n\
170             .TP\n\
171             \\fB\\-h\\fR\n\
172             Show help.\n\
173             .nf\n\
174             mant --help\n\
175             mant git\n\
176             .fi\n",
177        );
178
179        let document = parse_manual_source(&path).expect("lower man source");
180        fs::remove_file(path).expect("remove temporary roff fixture");
181
182        assert_eq!(document.source.format, SourceFormat::Man);
183        assert_eq!(
184            document
185                .sections
186                .iter()
187                .map(|section| section.title.as_str())
188                .collect::<Vec<_>>(),
189            vec!["NAME", "OPTIONS"]
190        );
191        assert!(
192            document.sections[1]
193                .blocks
194                .iter()
195                .any(|block| matches!(block, Block::DefinitionList { .. }))
196        );
197        assert!(document.sections[1].blocks.iter().any(|block| matches!(
198            block,
199            Block::DefinitionList { items, .. }
200                if items.iter().any(|item| item.description.iter().any(
201                    |description| matches!(description, Block::Preformatted { .. })
202                ))
203        )));
204    }
205
206    #[test]
207    fn separates_definition_layout_arguments_from_visible_terms() {
208        let path = temporary_source(
209            "definition-head-roles",
210            ".TH HEAD-ROLES 1\n\
211             .SH EXAMPLES\n\
212             .TP \\w'man\\ 'u\n\
213             .BI man \\ ls\n\
214             Display ls.\n\
215             .TP 4\n\
216             4\n\
217             A numeric term remains visible.\n\
218             .IP \"1\" 8n\n\
219             An IP width remains layout-only.\n",
220        );
221
222        let document = parse_manual_source(&path).expect("lower definition head roles");
223        fs::remove_file(path).expect("remove temporary roff fixture");
224
225        let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
226            panic!("expected one definition list");
227        };
228        assert_eq!(
229            items
230                .iter()
231                .flat_map(|item| item.terms.iter())
232                .map(|term| inline_text(term))
233                .collect::<Vec<_>>(),
234            ["man ls", "4", "1"]
235        );
236        assert!(matches!(
237            items[0].terms[0].as_slice(),
238            [Inline::Strong { .. }, Inline::Emphasis { .. }]
239        ));
240        assert!(
241            items
242                .iter()
243                .flat_map(|item| item.terms.iter())
244                .all(|term| !inline_text(term).contains("96u"))
245        );
246    }
247
248    #[test]
249    fn preserves_man_synopsis_flow_and_alternating_fonts() {
250        let path = temporary_source(
251            "man-synopsis-flow",
252            ".TH MAN 1\n\
253             .SH SYNOPSIS\n\
254             .B man\n\
255             .RI [\\| \"man options\" \\|]\n\
256             .RI [\\|[\\| section \\|]\n\
257             .IR page \\ \\|.\\|.\\|.\\|]\\ \\.\\|.\\|.\\&\n\
258             .br\n\
259             .B man\n\
260             .B \\-k\n\
261             .RI [\\| \"apropos options\" \\|]\n\
262             .I regexp\n\
263             \\&.\\|.\\|.\\&\n\
264             .br\n\
265             .B man\n\
266             .BR \\-w \\||\\| \\-W\n\
267             .RI [\\| \"man options\" \\|]\n\
268             .I page\n\
269             \\&.\\|.\\|.\\&\n",
270        );
271
272        let document = parse_manual_source(&path).expect("lower man synopsis");
273        fs::remove_file(path).expect("remove temporary roff fixture");
274
275        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
276            panic!("expected one synopsis paragraph");
277        };
278        assert_eq!(
279            inline_text(children),
280            "man [man options] [[section] page ...] ...\n\
281             man -k [apropos options] regexp ...\n\
282             man -w|-W [man options] page ..."
283        );
284        assert_eq!(
285            children
286                .iter()
287                .filter(|node| matches!(node, Inline::LineBreak))
288                .count(),
289            2
290        );
291        assert!(children.iter().any(
292            |node| matches!(node, Inline::Emphasis { children } if inline_text(children) == "man options")
293        ));
294        assert!(children.iter().any(
295            |node| matches!(node, Inline::Strong { children } if inline_text(children) == "-w")
296        ));
297        assert!(children.iter().any(
298            |node| matches!(node, Inline::Strong { children } if inline_text(children) == "-W")
299        ));
300    }
301
302    #[test]
303    fn lets_explicit_fonts_override_an_alternating_macro_default() {
304        let path = temporary_source(
305            "alternating-font-reset",
306            ".TH MAN 1\n\
307             .SH OPTIONS\n\
308             .TP\n\
309             .BI \\-r\\  prompt \\fR,\\ \\fB\\-\\-prompt= prompt\n\
310             Set the pager prompt.\n",
311        );
312
313        let document = parse_manual_source(&path).expect("lower alternating font reset");
314        fs::remove_file(path).expect("remove temporary roff fixture");
315
316        let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
317            panic!("expected one definition list");
318        };
319        let term = items[0]
320            .terms
321            .first()
322            .expect("first definition term")
323            .iter()
324            .filter(|inline| !matches!(inline, Inline::Anchor { .. }))
325            .collect::<Vec<_>>();
326
327        assert_eq!(term.len(), 5);
328        assert!(matches!(term[0], Inline::Strong { children } if inline_text(children) == "-r "));
329        assert!(
330            matches!(term[1], Inline::Emphasis { children } if inline_text(children) == "prompt")
331        );
332        assert!(matches!(term[2], Inline::Text { value } if value == ", "));
333        assert!(
334            matches!(term[3], Inline::Strong { children } if inline_text(children) == "--prompt=")
335        );
336        assert!(
337            matches!(term[4], Inline::Emphasis { children } if inline_text(children) == "prompt")
338        );
339    }
340
341    #[test]
342    fn suppresses_pod_font_requests_around_verbatim_blocks() {
343        let path = temporary_source(
344            "pod-verbatim-fonts",
345            ".de Vb\n\
346             .ft CW\n\
347             .nf\n\
348             ..\n\
349             .de Ve\n\
350             .ft R\n\
351             .fi\n\
352             ..\n\
353             .TH POD 1\n\
354             .SH EXAMPLES\n\
355             .Vb 2\n\
356             \\&struct A { int a; };\n\
357             \\&struct B : A {};\n\
358             .Ve\n",
359        );
360
361        let document = parse_manual_source(&path).expect("lower Pod::Man verbatim source");
362        fs::remove_file(path).expect("remove temporary roff fixture");
363
364        assert_eq!(document.sections[0].blocks.len(), 1);
365        let Block::Preformatted { children, .. } = &document.sections[0].blocks[0] else {
366            panic!("expected one preformatted block");
367        };
368        assert_eq!(
369            inline_text(children),
370            "struct A { int a; };\nstruct B : A {};"
371        );
372    }
373
374    #[test]
375    fn lowers_indented_aliases_without_roff_layout_arguments() {
376        let path = temporary_source(
377            "indented-aliases",
378            ".TH CONTROL 1\n\
379             .SH OPTIONS\n\
380             .PD 0\n\
381             .IP \"\\fB-a\\fR\" 4\n\
382             .IP \"\\fB--all\\fR\" 4\n\
383             Show all entries.\n\
384             .PD\n\
385             .in 168u\n",
386        );
387
388        let document = parse_manual_source(&path).expect("lower indented aliases");
389        fs::remove_file(path).expect("remove temporary roff fixture");
390
391        let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
392            panic!("expected one definition list");
393        };
394        assert_eq!(items.len(), 1);
395        assert_eq!(
396            items[0]
397                .terms
398                .iter()
399                .map(|term| inline_text(term))
400                .collect::<Vec<_>>(),
401            ["-a", "--all"]
402        );
403        assert_eq!(items[0].description.len(), 1);
404        let Block::Paragraph { children, .. } = &items[0].description[0] else {
405            panic!("expected alias description paragraph");
406        };
407        assert_eq!(inline_text(children), "Show all entries.");
408    }
409
410    #[test]
411    fn preserves_man_paragraph_distance_between_indented_paragraphs() {
412        let path = temporary_source(
413            "paragraph-distance",
414            ".TH SPACING 1\n\
415             .SH OPTIONS\n\
416             .IP \"\\fB-a\\fR\" 4\n\
417             First.\n\
418             .IP \"\\fB-b\\fR\" 4\n\
419             Second.\n\
420             .PD 0\n\
421             .IP \"\\fB-c\\fR\" 4\n\
422             Third.\n\
423             .IP \"\\fB-d\\fR\" 4\n\
424             Fourth.\n\
425             .PD\n\
426             .IP \"\\fB-e\\fR\" 4\n\
427             Fifth.\n",
428        );
429
430        let document = parse_manual_source(&path).expect("lower paragraph distance");
431        fs::remove_file(path).expect("remove temporary roff fixture");
432
433        let [Block::DefinitionList { items, compact, .. }] = document.sections[0].blocks.as_slice()
434        else {
435            panic!("expected one definition list");
436        };
437        assert!(!compact);
438        assert_eq!(items.len(), 5);
439        assert_eq!(
440            items
441                .iter()
442                .map(|item| item.spacing_before_lines)
443                .collect::<Vec<_>>(),
444            [Some(0), Some(1), Some(0), Some(0), Some(1)]
445        );
446    }
447
448    #[test]
449    fn preserves_man_paragraph_and_heading_distance_as_one_layout_model() {
450        let path = temporary_source(
451            "vertical-layout",
452            ".TH SPACING 1\n\
453             .SH FIRST\n\
454             First paragraph.\n\
455             .PP\n\
456             Second paragraph.\n\
457             .SS CHILD\n\
458             Child body.\n\
459             .PD 0\n\
460             .SS COMPACT\n\
461             Compact child.\n\
462             .SH NEXT\n\
463             Next body.\n\
464             .PD\n\
465             .SH FINAL\n\
466             Final body.\n",
467        );
468
469        let document = parse_manual_source(&path).expect("lower vertical layout");
470        fs::remove_file(path).expect("remove temporary roff fixture");
471
472        let [first, next, final_section] = document.sections.as_slice() else {
473            panic!("expected three top-level sections");
474        };
475        assert_eq!(first.spacing_before_lines, 0);
476        let [Block::Paragraph { .. }, Block::Paragraph { layout, .. }] = first.blocks.as_slice()
477        else {
478            panic!("expected two semantic paragraphs");
479        };
480        assert_eq!(layout.spacing_before_lines, 1);
481
482        let [child, compact] = first.children.as_slice() else {
483            panic!("expected two subsections");
484        };
485        assert_eq!(child.spacing_before_lines, 1);
486        assert_eq!(compact.spacing_before_lines, 0);
487        assert_eq!(next.spacing_before_lines, 0);
488        assert_eq!(final_section.spacing_before_lines, 1);
489    }
490
491    #[test]
492    fn does_not_duplicate_explicit_space_before_a_transparent_indent() {
493        let path = temporary_source(
494            "explicit-space-before-indent",
495            ".TH SPACING 1\n\
496             .SH CONTENT\n\
497             Before.\n\
498             .sp\n\
499             .RS 4\n\
500             After.\n\
501             .RE\n",
502        );
503
504        let document = parse_manual_source(&path).expect("lower explicit indented spacing");
505        fs::remove_file(path).expect("remove temporary roff fixture");
506
507        let [
508            Block::Paragraph { .. },
509            Block::VerticalSpace { lines: 1, .. },
510            Block::Paragraph { layout, .. },
511        ] = document.sections[0].blocks.as_slice()
512        else {
513            panic!("expected prose, one explicit gap, and indented prose");
514        };
515        assert_eq!(layout.indent_columns, 4);
516        assert_eq!(
517            layout.spacing_before_lines, 0,
518            "the explicit gap must not be repeated as wrapper boundary spacing",
519        );
520    }
521
522    #[test]
523    fn preserves_mdoc_paragraph_and_heading_distance() {
524        let path = temporary_source(
525            "mdoc-vertical-layout",
526            ".Dd July 19, 2026\n\
527             .Dt SPACING 1\n\
528             .Os\n\
529             .Sh FIRST\n\
530             First paragraph.\n\
531             .Pp\n\
532             Second paragraph.\n\
533             .Ss CHILD\n\
534             Child body.\n",
535        );
536
537        let document = parse_manual_source(&path).expect("lower mdoc vertical layout");
538        fs::remove_file(path).expect("remove temporary roff fixture");
539
540        let [first] = document.sections.as_slice() else {
541            panic!("expected one top-level section");
542        };
543        assert_eq!(first.spacing_before_lines, 1);
544        assert!(matches!(
545            first.blocks.get(1),
546            Some(Block::VerticalSpace { lines: 1, .. })
547        ));
548        assert_eq!(first.children[0].spacing_before_lines, 1);
549    }
550
551    #[test]
552    fn lowers_mdoc_semantic_inline_nodes_and_nested_sections() {
553        let path = temporary_source(
554            "mdoc",
555            ".Dd July 19, 2026\n\
556             .Dt MANT 1\n\
557             .Os\n\
558             .Sh DESCRIPTION\n\
559             Use\n\
560             .Nm mant\n\
561             with\n\
562             .Xr man 1\n\
563             Read\n\
564             .Lk https://example.test/docs \"the documentation\"\n\
565             or contact\n\
566             .Mt docs@example.test\n\
567             .Ss Details\n\
568             .Fl h\n",
569        );
570
571        let document = parse_manual_source(&path).expect("lower mdoc source");
572        fs::remove_file(path).expect("remove temporary roff fixture");
573
574        assert_eq!(document.source.format, SourceFormat::Mdoc);
575        assert_eq!(document.sections[0].children[0].title, "Details");
576        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
577            panic!("expected description paragraph");
578        };
579        assert!(
580            children
581                .iter()
582                .any(|inline| matches!(inline, Inline::Strong { .. }))
583        );
584        assert!(
585            children.iter().any(
586                |inline| matches!(inline, Inline::ManualReference { name, .. } if name == "man")
587            )
588        );
589        assert!(children.iter().any(
590            |inline| matches!(inline, Inline::ExternalLink { uri, .. } if uri == "https://example.test/docs")
591        ));
592        assert!(children.iter().any(
593            |inline| matches!(inline, Inline::EmailLink { address, .. } if address == "docs@example.test")
594        ));
595    }
596
597    #[test]
598    fn resolves_mdoc_section_references_and_explicit_targets() {
599        let path = temporary_source(
600            "mdoc-navigation",
601            ".Dd July 19, 2026\n\
602             .Dt NAVIGATION 1\n\
603             .Os\n\
604             .Sh DESCRIPTION\n\
605             Continue with\n\
606             .Sx DETAILS\n\
607             .Tg explicit-option\n\
608             .Fl x\n\
609             .Sh DETAILS\n\
610             Target content.\n",
611        );
612
613        let document = parse_manual_source(&path).expect("lower navigation mdoc source");
614        fs::remove_file(path).expect("remove temporary roff fixture");
615
616        assert_eq!(document.sections[0].id, "description-1");
617        assert_eq!(document.sections[1].id, "details-2");
618        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
619            panic!("expected navigation paragraph");
620        };
621        assert!(children.iter().any(|inline| matches!(
622            inline,
623            Inline::SectionReference { target, children }
624                if target == "details-2" && inline_text(children) == "DETAILS"
625        )));
626        assert!(children.iter().any(|inline| matches!(
627            inline,
628            Inline::Anchor { id } if id == "explicit-option"
629        )));
630    }
631
632    #[test]
633    fn degrades_unresolved_mdoc_section_references_to_text() {
634        let path = temporary_source(
635            "mdoc-missing-section",
636            ".Dd July 19, 2026\n.Dt NAVIGATION 1\n.Os\n.Sh DESCRIPTION\n.Sx MISSING\n",
637        );
638
639        let document = parse_manual_source(&path).expect("lower unresolved navigation source");
640        fs::remove_file(path).expect("remove temporary roff fixture");
641
642        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
643            panic!("expected reference paragraph");
644        };
645        assert_eq!(inline_text(children), "MISSING");
646        assert!(
647            children
648                .iter()
649                .all(|inline| !matches!(inline, Inline::SectionReference { .. }))
650        );
651        assert!(document.diagnostics.iter().any(|diagnostic| {
652            diagnostic.code.as_deref() == Some("unresolved-section-reference")
653        }));
654    }
655
656    #[test]
657    fn turns_captured_parser_findings_into_structured_diagnostics() {
658        let path = temporary_source(
659            "unsupported",
660            ".Dd July 19, 2026\n.Dt BAD 1\n.Os\n.Sh NAME\n.Nm bad\n.ab\n",
661        );
662
663        let document = parse_manual_source(&path).expect("best-effort parse");
664        fs::remove_file(path).expect("remove temporary roff fixture");
665
666        assert!(
667            document
668                .diagnostics
669                .iter()
670                .any(|diagnostic| diagnostic.level == DiagnosticLevel::Unsupported)
671        );
672    }
673
674    #[test]
675    fn lowers_normalized_ordered_lists_and_literal_displays() {
676        let path = temporary_source(
677            "normalized",
678            ".Dd July 19, 2026\n.Dt NORMALIZED 1\n.Os\n.Sh CONTENT\n\
679             .Bl -enum -compact\n.It\nfirst\n.It\nsecond\n.El\n\
680             .Bd -literal -offset 6n\nline one\nline two\n.Ed\n",
681        );
682
683        let document = parse_manual_source(&path).expect("lower normalized mdoc");
684        fs::remove_file(path).expect("remove temporary roff fixture");
685
686        assert!(matches!(
687            document.sections[0].blocks[0],
688            Block::List {
689                kind: mant_ast::ListKind::Ordered,
690                compact: true,
691                ..
692            }
693        ));
694        assert!(matches!(
695            document.sections[0].blocks[1],
696            Block::Preformatted { layout, .. } if layout.indent_columns == 6
697        ));
698    }
699
700    #[test]
701    fn mdoc_definition_layout_uses_the_normalized_list_width() {
702        let path = temporary_source(
703            "mdoc-definition-widths",
704            ".Dd July 23, 2026\n.Dt WIDTHS 1\n.Os\n.Sh ITEMS\n\
705             .Bl -tag -width 20n\n.It tenletters\nwide description\n.El\n\
706             .Bl -tag -width 3n\n.It short\nnarrow description\n.El\n",
707        );
708
709        let document = parse_manual_source(&path).expect("lower mdoc definition widths");
710        fs::remove_file(path).expect("remove temporary roff fixture");
711
712        let lists = document.sections[0]
713            .blocks
714            .iter()
715            .filter_map(|block| match block {
716                Block::DefinitionList { items, .. } => Some(items),
717                _ => None,
718            })
719            .collect::<Vec<_>>();
720        assert_eq!(lists.len(), 2);
721        assert!(lists[0][0].inline_term);
722        assert!(!lists[1][0].inline_term);
723    }
724
725    #[test]
726    fn lowers_the_pinned_large_mdoc_fixture_without_empty_sections() {
727        let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
728            .join("../libmandoc-rs/vendor/mandoc-1.14.6/mandoc.1");
729
730        let document = parse_manual_source(&source).expect("lower vendored mandoc manual");
731
732        assert!(document.sections.len() > 5);
733        assert!(
734            document
735                .sections
736                .iter()
737                .any(|section| section.title == "DESCRIPTION")
738        );
739        assert!(
740            document
741                .sections
742                .iter()
743                .all(|section| !section.blocks.is_empty() || !section.children.is_empty())
744        );
745    }
746
747    #[test]
748    fn lowers_tbl_and_eqn_payloads_into_structured_blocks() {
749        let path = temporary_source(
750            "table-equation",
751            ".TH PAYLOAD 1\n.SH TABLE\n.TS\ntab(|);\nl r.\nleft|right\n.TE\n\
752             .SH EQUATION\n.EQ\nx sup 2\n.EN\n",
753        );
754
755        let document = parse_manual_source(&path).expect("lower table and equation");
756        fs::remove_file(path).expect("remove temporary roff fixture");
757
758        assert!(matches!(
759            document.sections[0].blocks[0],
760            Block::Table { ref rows, .. } if rows.len() == 1 && rows[0].cells.len() == 2
761        ));
762        assert!(matches!(
763            document.sections[1].blocks[0],
764            Block::Equation { ref value, .. } if value.contains('x')
765        ));
766    }
767
768    fn inline_text(children: &[Inline]) -> String {
769        children
770            .iter()
771            .map(|child| match child {
772                Inline::Text { value } | Inline::Code { value } => value.clone(),
773                Inline::Strong { children }
774                | Inline::Emphasis { children }
775                | Inline::ExternalLink { children, .. }
776                | Inline::EmailLink { children, .. }
777                | Inline::ManualReference { children, .. }
778                | Inline::SectionReference { children, .. } => inline_text(children),
779                Inline::Anchor { .. } => String::new(),
780                Inline::LineBreak => "\n".to_owned(),
781            })
782            .collect()
783    }
784}