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;
8mod roff_escape;
9
10use std::path::Path;
11
12use libmandoc_rs::{Document, IncludePolicy, MacroSet, Node, ParseOptions, ParseReport, Parser};
13use mant_ast::{
14    DocumentMeta, DocumentSchema, DocumentSource, Engine, MantDocument, Producer, SourceFormat,
15    SourceSpan,
16};
17
18use self::roff_escape::visible_text;
19
20pub use libmandoc_rs::ParseError;
21
22/// Parse and normalize one located man or mdoc source file.
23///
24/// # Errors
25///
26/// Returns [`ParseError`] when the source cannot be opened or parsed.
27pub fn parse_manual_source(path: &Path) -> Result<MantDocument, ParseError> {
28    let report = Parser::new(ParseOptions {
29        includes: IncludePolicy::SourceTree,
30        ..ParseOptions::default()
31    })
32    .parse_file(path)?;
33    Ok(lower_mandoc_document(path, &report))
34}
35
36/// Convert a completed low-level parse into the stable document contract.
37#[must_use]
38pub fn lower_mandoc_document(path: &Path, report: &ParseReport) -> MantDocument {
39    let parsed: &Document = &report.document;
40    let mut context = LoweringContext::new(parsed.metadata.name.as_deref());
41    let mut diagnostics = diagnostics::lower_diagnostics(&report.diagnostics);
42    let mut sections = blocks::lower_sections(&parsed.root, &mut context);
43    let explicit_targets = navigation::explicit_targets(&parsed.root);
44    let mut retained_targets = explicit_targets.clone();
45    retained_targets.extend(crate::definitions::identify_definitions(
46        &mut sections,
47        &explicit_targets,
48    ));
49    navigation::resolve_navigation(&mut sections, &retained_targets, &mut diagnostics);
50    MantDocument {
51        schema: DocumentSchema::V4,
52        producer: Producer {
53            name: "mant".to_owned(),
54            version: env!("CARGO_PKG_VERSION").to_owned(),
55            engine: Some(Engine {
56                name: "libmandoc".to_owned(),
57                version: libmandoc_rs::LIBMANDOC_VERSION.to_owned(),
58            }),
59        },
60        source: DocumentSource {
61            format: match parsed.macro_set {
62                MacroSet::Mdoc => SourceFormat::Mdoc,
63                MacroSet::Man | MacroSet::None => SourceFormat::Man,
64            },
65            path: Some(path.to_string_lossy().into_owned()),
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(visible_text)
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 distinguishes_filled_source_wrapping_from_indented_output_lines() {
304        let path = temporary_source(
305            "filled-line-boundaries",
306            concat!(
307                ".TH TOOL 1\n",
308                ".SH SYNOPSIS\n",
309                "tool [first]\n",
310                "    [second]\n",
311                "    [third]\n",
312                ".PP\n",
313                "Ordinary source wrapping\n",
314                "remains one filled paragraph.\n",
315            ),
316        );
317
318        let document = parse_manual_source(&path).expect("lower filled line boundaries");
319        fs::remove_file(path).expect("remove temporary roff fixture");
320
321        let [
322            Block::Paragraph {
323                children: synopsis, ..
324            },
325            Block::Paragraph {
326                children: prose, ..
327            },
328        ] = document.sections[0].blocks.as_slice()
329        else {
330            panic!("expected synopsis and prose paragraphs");
331        };
332        assert_eq!(
333            inline_text(synopsis),
334            "tool [first]\n    [second]\n    [third]"
335        );
336        assert_eq!(
337            synopsis
338                .iter()
339                .filter(|inline| matches!(inline, Inline::LineBreak))
340                .count(),
341            2
342        );
343        assert_eq!(
344            inline_text(prose),
345            "Ordinary source wrapping remains one filled paragraph."
346        );
347    }
348
349    #[test]
350    fn lets_explicit_fonts_override_an_alternating_macro_default() {
351        let path = temporary_source(
352            "alternating-font-reset",
353            ".TH MAN 1\n\
354             .SH OPTIONS\n\
355             .TP\n\
356             .BI \\-r\\  prompt \\fR,\\ \\fB\\-\\-prompt= prompt\n\
357             Set the pager prompt.\n",
358        );
359
360        let document = parse_manual_source(&path).expect("lower alternating font reset");
361        fs::remove_file(path).expect("remove temporary roff fixture");
362
363        let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
364            panic!("expected one definition list");
365        };
366        let term = items[0]
367            .terms
368            .first()
369            .expect("first definition term")
370            .iter()
371            .filter(|inline| !matches!(inline, Inline::Anchor { .. }))
372            .collect::<Vec<_>>();
373
374        assert_eq!(term.len(), 5);
375        assert!(matches!(term[0], Inline::Strong { children } if inline_text(children) == "-r "));
376        assert!(
377            matches!(term[1], Inline::Emphasis { children } if inline_text(children) == "prompt")
378        );
379        assert!(matches!(term[2], Inline::Text { value } if value == ", "));
380        assert!(
381            matches!(term[3], Inline::Strong { children } if inline_text(children) == "--prompt=")
382        );
383        assert!(
384            matches!(term[4], Inline::Emphasis { children } if inline_text(children) == "prompt")
385        );
386    }
387
388    #[test]
389    fn suppresses_pod_font_requests_around_verbatim_blocks() {
390        let path = temporary_source(
391            "pod-verbatim-fonts",
392            ".de Vb\n\
393             .ft CW\n\
394             .nf\n\
395             ..\n\
396             .de Ve\n\
397             .ft R\n\
398             .fi\n\
399             ..\n\
400             .TH POD 1\n\
401             .SH EXAMPLES\n\
402             .Vb 2\n\
403             \\&struct A { int a; };\n\
404             \\&struct B : A {};\n\
405             .Ve\n",
406        );
407
408        let document = parse_manual_source(&path).expect("lower Pod::Man verbatim source");
409        fs::remove_file(path).expect("remove temporary roff fixture");
410
411        assert_eq!(document.sections[0].blocks.len(), 1);
412        let Block::Preformatted { children, .. } = &document.sections[0].blocks[0] else {
413            panic!("expected one preformatted block");
414        };
415        assert_eq!(
416            inline_text(children),
417            "struct A { int a; };\nstruct B : A {};"
418        );
419    }
420
421    #[test]
422    fn lowers_indented_aliases_without_roff_layout_arguments() {
423        let path = temporary_source(
424            "indented-aliases",
425            ".TH CONTROL 1\n\
426             .SH OPTIONS\n\
427             .PD 0\n\
428             .IP \"\\fB-a\\fR\" 4\n\
429             .IP \"\\fB--all\\fR\" 4\n\
430             Show all entries.\n\
431             .PD\n\
432             .in 168u\n",
433        );
434
435        let document = parse_manual_source(&path).expect("lower indented aliases");
436        fs::remove_file(path).expect("remove temporary roff fixture");
437
438        let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
439            panic!("expected one definition list");
440        };
441        assert_eq!(items.len(), 1);
442        assert_eq!(
443            items[0]
444                .terms
445                .iter()
446                .map(|term| inline_text(term))
447                .collect::<Vec<_>>(),
448            ["-a", "--all"]
449        );
450        assert_eq!(items[0].description.len(), 1);
451        let Block::Paragraph { children, .. } = &items[0].description[0] else {
452            panic!("expected alias description paragraph");
453        };
454        assert_eq!(inline_text(children), "Show all entries.");
455    }
456
457    #[test]
458    fn preserves_man_paragraph_distance_between_indented_paragraphs() {
459        let path = temporary_source(
460            "paragraph-distance",
461            ".TH SPACING 1\n\
462             .SH OPTIONS\n\
463             .IP \"\\fB-a\\fR\" 4\n\
464             First.\n\
465             .IP \"\\fB-b\\fR\" 4\n\
466             Second.\n\
467             .PD 0\n\
468             .IP \"\\fB-c\\fR\" 4\n\
469             Third.\n\
470             .IP \"\\fB-d\\fR\" 4\n\
471             Fourth.\n\
472             .PD\n\
473             .IP \"\\fB-e\\fR\" 4\n\
474             Fifth.\n",
475        );
476
477        let document = parse_manual_source(&path).expect("lower paragraph distance");
478        fs::remove_file(path).expect("remove temporary roff fixture");
479
480        let [Block::DefinitionList { items, compact, .. }] = document.sections[0].blocks.as_slice()
481        else {
482            panic!("expected one definition list");
483        };
484        assert!(!compact);
485        assert_eq!(items.len(), 5);
486        assert_eq!(
487            items
488                .iter()
489                .map(|item| item.spacing_before_lines)
490                .collect::<Vec<_>>(),
491            [Some(0), Some(1), Some(0), Some(0), Some(1)]
492        );
493    }
494
495    #[test]
496    fn preserves_man_paragraph_and_heading_distance_as_one_layout_model() {
497        let path = temporary_source(
498            "vertical-layout",
499            ".TH SPACING 1\n\
500             .SH FIRST\n\
501             First paragraph.\n\
502             .PP\n\
503             Second paragraph.\n\
504             .SS CHILD\n\
505             Child body.\n\
506             .PD 0\n\
507             .SS COMPACT\n\
508             Compact child.\n\
509             .SH NEXT\n\
510             Next body.\n\
511             .PD\n\
512             .SH FINAL\n\
513             Final body.\n",
514        );
515
516        let document = parse_manual_source(&path).expect("lower vertical layout");
517        fs::remove_file(path).expect("remove temporary roff fixture");
518
519        let [first, next, final_section] = document.sections.as_slice() else {
520            panic!("expected three top-level sections");
521        };
522        assert_eq!(first.spacing_before_lines, 0);
523        let [Block::Paragraph { .. }, Block::Paragraph { layout, .. }] = first.blocks.as_slice()
524        else {
525            panic!("expected two semantic paragraphs");
526        };
527        assert_eq!(layout.spacing_before_lines, 1);
528
529        let [child, compact] = first.children.as_slice() else {
530            panic!("expected two subsections");
531        };
532        assert_eq!(child.spacing_before_lines, 1);
533        assert_eq!(compact.spacing_before_lines, 0);
534        assert_eq!(next.spacing_before_lines, 0);
535        assert_eq!(final_section.spacing_before_lines, 1);
536    }
537
538    #[test]
539    fn does_not_duplicate_explicit_space_before_a_transparent_indent() {
540        let path = temporary_source(
541            "explicit-space-before-indent",
542            ".TH SPACING 1\n\
543             .SH CONTENT\n\
544             Before.\n\
545             .sp\n\
546             .RS 4\n\
547             After.\n\
548             .RE\n",
549        );
550
551        let document = parse_manual_source(&path).expect("lower explicit indented spacing");
552        fs::remove_file(path).expect("remove temporary roff fixture");
553
554        let [
555            Block::Paragraph { .. },
556            Block::VerticalSpace { lines: 1, .. },
557            Block::Paragraph { layout, .. },
558        ] = document.sections[0].blocks.as_slice()
559        else {
560            panic!("expected prose, one explicit gap, and indented prose");
561        };
562        assert_eq!(layout.indent_columns, 4);
563        assert_eq!(
564            layout.spacing_before_lines, 0,
565            "the explicit gap must not be repeated as wrapper boundary spacing",
566        );
567    }
568
569    #[test]
570    fn preserves_mdoc_paragraph_and_heading_distance() {
571        let path = temporary_source(
572            "mdoc-vertical-layout",
573            ".Dd July 19, 2026\n\
574             .Dt SPACING 1\n\
575             .Os\n\
576             .Sh FIRST\n\
577             First paragraph.\n\
578             .Pp\n\
579             Second paragraph.\n\
580             .Ss CHILD\n\
581             Child body.\n",
582        );
583
584        let document = parse_manual_source(&path).expect("lower mdoc vertical layout");
585        fs::remove_file(path).expect("remove temporary roff fixture");
586
587        let [first] = document.sections.as_slice() else {
588            panic!("expected one top-level section");
589        };
590        assert_eq!(first.spacing_before_lines, 1);
591        assert!(matches!(
592            first.blocks.get(1),
593            Some(Block::VerticalSpace { lines: 1, .. })
594        ));
595        assert_eq!(first.children[0].spacing_before_lines, 1);
596    }
597
598    #[test]
599    fn lowers_mdoc_semantic_inline_nodes_and_nested_sections() {
600        let path = temporary_source(
601            "mdoc",
602            ".Dd July 19, 2026\n\
603             .Dt MANT 1\n\
604             .Os\n\
605             .Sh DESCRIPTION\n\
606             Use\n\
607             .Nm mant\n\
608             with\n\
609             .Xr man 1\n\
610             Read\n\
611             .Lk https://example.test/docs \"the documentation\"\n\
612             or contact\n\
613             .Mt docs@example.test\n\
614             .Ss Details\n\
615             .Fl h\n",
616        );
617
618        let document = parse_manual_source(&path).expect("lower mdoc source");
619        fs::remove_file(path).expect("remove temporary roff fixture");
620
621        assert_eq!(document.source.format, SourceFormat::Mdoc);
622        assert_eq!(document.sections[0].children[0].title, "Details");
623        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
624            panic!("expected description paragraph");
625        };
626        assert!(
627            children
628                .iter()
629                .any(|inline| matches!(inline, Inline::Strong { .. }))
630        );
631        assert!(
632            children.iter().any(
633                |inline| matches!(inline, Inline::ManualReference { name, .. } if name == "man")
634            )
635        );
636        assert!(children.iter().any(
637            |inline| matches!(inline, Inline::ExternalLink { uri, .. } if uri == "https://example.test/docs")
638        ));
639        assert!(children.iter().any(
640            |inline| matches!(inline, Inline::EmailLink { address, .. } if address == "docs@example.test")
641        ));
642    }
643
644    #[test]
645    fn resolves_mdoc_section_references_and_explicit_targets() {
646        let path = temporary_source(
647            "mdoc-navigation",
648            ".Dd July 19, 2026\n\
649             .Dt NAVIGATION 1\n\
650             .Os\n\
651             .Sh DESCRIPTION\n\
652             Continue with\n\
653             .Sx DETAILS\n\
654             .Tg explicit-option\n\
655             .Fl x\n\
656             .Sh DETAILS\n\
657             Target content.\n",
658        );
659
660        let document = parse_manual_source(&path).expect("lower navigation mdoc source");
661        fs::remove_file(path).expect("remove temporary roff fixture");
662
663        assert_eq!(document.sections[0].id, "description-1");
664        assert_eq!(document.sections[1].id, "details-2");
665        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
666            panic!("expected navigation paragraph");
667        };
668        assert!(children.iter().any(|inline| matches!(
669            inline,
670            Inline::SectionReference { target, children }
671                if target == "details-2" && inline_text(children) == "DETAILS"
672        )));
673        assert!(children.iter().any(|inline| matches!(
674            inline,
675            Inline::Anchor { id } if id == "explicit-option"
676        )));
677    }
678
679    #[test]
680    fn degrades_unresolved_mdoc_section_references_to_text() {
681        let path = temporary_source(
682            "mdoc-missing-section",
683            ".Dd July 19, 2026\n.Dt NAVIGATION 1\n.Os\n.Sh DESCRIPTION\n.Sx MISSING\n",
684        );
685
686        let document = parse_manual_source(&path).expect("lower unresolved navigation source");
687        fs::remove_file(path).expect("remove temporary roff fixture");
688
689        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
690            panic!("expected reference paragraph");
691        };
692        assert_eq!(inline_text(children), "MISSING");
693        assert!(
694            children
695                .iter()
696                .all(|inline| !matches!(inline, Inline::SectionReference { .. }))
697        );
698        assert!(document.diagnostics.iter().any(|diagnostic| {
699            diagnostic.code.as_deref() == Some("unresolved-section-reference")
700        }));
701    }
702
703    #[test]
704    fn turns_captured_parser_findings_into_structured_diagnostics() {
705        let path = temporary_source(
706            "unsupported",
707            ".Dd July 19, 2026\n.Dt BAD 1\n.Os\n.Sh NAME\n.Nm bad\n.ab\n",
708        );
709
710        let document = parse_manual_source(&path).expect("best-effort parse");
711        fs::remove_file(path).expect("remove temporary roff fixture");
712
713        assert!(
714            document
715                .diagnostics
716                .iter()
717                .any(|diagnostic| diagnostic.level == DiagnosticLevel::Unsupported)
718        );
719    }
720
721    #[test]
722    fn lowers_normalized_ordered_lists_and_literal_displays() {
723        let path = temporary_source(
724            "normalized",
725            ".Dd July 19, 2026\n.Dt NORMALIZED 1\n.Os\n.Sh CONTENT\n\
726             .Bl -enum -compact\n.It\nfirst\n.It\nsecond\n.El\n\
727             .Bd -literal -offset 6n\nline one\nline two\n.Ed\n",
728        );
729
730        let document = parse_manual_source(&path).expect("lower normalized mdoc");
731        fs::remove_file(path).expect("remove temporary roff fixture");
732
733        assert!(matches!(
734            document.sections[0].blocks[0],
735            Block::List {
736                kind: mant_ast::ListKind::Ordered,
737                compact: true,
738                ..
739            }
740        ));
741        assert!(matches!(
742            document.sections[0].blocks[1],
743            Block::Preformatted { layout, .. } if layout.indent_columns == 6
744        ));
745    }
746
747    #[test]
748    fn mdoc_definition_layout_uses_the_normalized_list_width() {
749        let path = temporary_source(
750            "mdoc-definition-widths",
751            ".Dd July 23, 2026\n.Dt WIDTHS 1\n.Os\n.Sh ITEMS\n\
752             .Bl -tag -width 20n\n.It tenletters\nwide description\n.El\n\
753             .Bl -tag -width 3n\n.It short\nnarrow description\n.El\n",
754        );
755
756        let document = parse_manual_source(&path).expect("lower mdoc definition widths");
757        fs::remove_file(path).expect("remove temporary roff fixture");
758
759        let lists = document.sections[0]
760            .blocks
761            .iter()
762            .filter_map(|block| match block {
763                Block::DefinitionList { items, .. } => Some(items),
764                _ => None,
765            })
766            .collect::<Vec<_>>();
767        assert_eq!(lists.len(), 2);
768        assert!(lists[0][0].inline_term);
769        assert!(!lists[1][0].inline_term);
770    }
771
772    #[test]
773    fn lowers_the_pinned_large_mdoc_fixture_without_empty_sections() {
774        let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
775            .join("../libmandoc-rs/vendor/mandoc-1.14.6/mandoc.1");
776
777        let document = parse_manual_source(&source).expect("lower vendored mandoc manual");
778
779        assert!(document.sections.len() > 5);
780        assert!(
781            document
782                .sections
783                .iter()
784                .any(|section| section.title == "DESCRIPTION")
785        );
786        assert!(
787            document
788                .sections
789                .iter()
790                .all(|section| !section.blocks.is_empty() || !section.children.is_empty())
791        );
792    }
793
794    #[test]
795    fn lowers_tbl_and_eqn_payloads_into_structured_blocks() {
796        let path = temporary_source(
797            "table-equation",
798            ".TH PAYLOAD 1\n.SH TABLE\n.TS\ntab(|);\nl r.\nleft|right\n.TE\n\
799             .SH EQUATION\n.EQ\nx sup 2\n.EN\n",
800        );
801
802        let document = parse_manual_source(&path).expect("lower table and equation");
803        fs::remove_file(path).expect("remove temporary roff fixture");
804
805        assert!(matches!(
806            document.sections[0].blocks[0],
807            Block::Table { ref rows, .. } if rows.len() == 1 && rows[0].cells.len() == 2
808        ));
809        assert!(matches!(
810            document.sections[1].blocks[0],
811            Block::Equation { ref value, .. } if value.contains('x')
812        ));
813    }
814
815    fn inline_text(children: &[Inline]) -> String {
816        children
817            .iter()
818            .map(|child| match child {
819                Inline::Text { value } | Inline::Code { value } => value.clone(),
820                Inline::Strong { children }
821                | Inline::Emphasis { children }
822                | Inline::ExternalLink { children, .. }
823                | Inline::EmailLink { children, .. }
824                | Inline::ManualReference { children, .. }
825                | Inline::SectionReference { children, .. } => inline_text(children),
826                Inline::Anchor { .. } => String::new(),
827                Inline::LineBreak => "\n".to_owned(),
828            })
829            .collect()
830    }
831}