Skip to main content

mant_engine/mandoc/
mod.rs

1//! Lowers the owned libmandoc syntax tree into `ManT`'s stable document model.
2
3mod blocks;
4mod diagnostics;
5mod error;
6pub(crate) mod inline;
7mod layout;
8mod navigation;
9mod reference;
10mod roff_escape;
11mod source;
12
13use std::{cell::RefCell, path::Path};
14
15use libmandoc_rs::{
16    Compression, Document as MandocDocument, IncludePolicy, MacroSet, Node, ParseOptions,
17    ParseReport, Parser,
18};
19use mant_ir::{
20    Diagnostic, DiagnosticLevel, Document, DocumentMeta, DocumentSource, ParserInfo, SourceFormat,
21    SourceSpan, validate_document,
22};
23
24use self::{
25    roff_escape::visible_text,
26    source::{load_manual_source, redirect_target, resolve_manual_redirects},
27};
28use crate::ManualPage;
29use crate::text_safety::mask_terminal_control_bytes;
30
31pub use error::{ManualError, ManualErrorKind};
32pub use source::MAX_MANUAL_BYTES;
33
34/// Parse and normalize one standalone man or mdoc source file.
35///
36/// This safe convenience entry point does not expand `.so` redirects because
37/// no caller-approved manual hierarchy accompanies a bare path. `ManT`'s indexed
38/// query path uses [`parse_manual_page`] instead.
39///
40/// # Errors
41///
42/// Returns [`ManualError`] when the source cannot be opened, decoded, or parsed.
43pub fn parse_manual_source(path: &Path) -> Result<Document, ManualError> {
44    let loaded = load_manual_source(path)?;
45    reject_standalone_redirect(path, &loaded.source)?;
46    parse_plain_manual(path, &loaded.source, None)
47}
48
49/// Parse one already bounded, uncompressed standalone roff input.
50///
51/// This is the standard-input counterpart of [`parse_manual_source`]. It does
52/// not expand `.so` redirects and never reads another file.
53///
54/// # Errors
55///
56/// Returns [`ManualError`] when libmandoc rejects the input.
57pub fn parse_manual_bytes(path: &Path, source: &[u8]) -> Result<Document, ManualError> {
58    reject_standalone_redirect(path, source)?;
59    parse_plain_manual(path, source, None)
60}
61
62fn reject_standalone_redirect(path: &Path, source: &[u8]) -> Result<(), ManualError> {
63    if redirect_target(path, source)?.is_some() {
64        return Err(ManualError::redirect(
65            path,
66            "standalone .so redirects require MANPATH discovery and cannot be followed by --input",
67        ));
68    }
69    Ok(())
70}
71
72/// Parse an indexed manual, resolving `.so` redirects against its discovered
73/// manual hierarchy without falling back to the process working directory.
74///
75/// # Errors
76///
77/// Returns [`ManualError`] when the source cannot be opened, decoded, or parsed.
78pub fn parse_manual_page(page: &ManualPage) -> Result<Document, ManualError> {
79    let resolved = resolve_manual_redirects(page)?;
80    parse_plain_manual(
81        &page.path,
82        &resolved.source,
83        resolved.alias_target.as_deref(),
84    )
85}
86
87fn parse_plain_manual(
88    path: &Path,
89    source: &[u8],
90    alias_target: Option<&str>,
91) -> Result<Document, ManualError> {
92    let (source, masked_controls) = mask_terminal_control_bytes(source);
93    let report = Parser::new(ParseOptions {
94        includes: IncludePolicy::Deny,
95        compression: Compression::Plain,
96    })
97    .parse_bytes(path, source.as_ref())
98    .map_err(ManualError::from)?;
99    let mut document = lower_mandoc_document(path, &report);
100    if masked_controls > 0 {
101        document.diagnostics.insert(
102            0,
103            Diagnostic {
104                level: DiagnosticLevel::Warning,
105                code: Some("manual.control-characters".to_owned()),
106                message: format!("masked {masked_controls} terminal-unsafe control character(s)"),
107                source: None,
108            },
109        );
110    }
111    if let Some(alias_target) = alias_target {
112        document.meta.alias_target = Some(alias_target.to_owned());
113    }
114    Ok(document)
115}
116
117/// Convert a completed low-level parse into the stable document contract.
118#[must_use]
119pub fn lower_mandoc_document(path: &Path, report: &ParseReport) -> Document {
120    let parsed: &MandocDocument = &report.document;
121    let mut context = LoweringContext::new(parsed.metadata.name.as_deref());
122    let mut diagnostics = diagnostics::lower_diagnostics(&report.diagnostics);
123    let mut sections = blocks::lower_sections(&parsed.root, &mut context);
124    diagnostics.extend(context.take_diagnostics());
125    let explicit_targets = navigation::explicit_targets(&parsed.root);
126    let mut retained_targets = explicit_targets.clone();
127    let mut root_blocks = Vec::new();
128    retained_targets.extend(crate::definitions::identify_definitions(
129        &mut root_blocks,
130        &mut sections,
131        &explicit_targets,
132    ));
133    navigation::resolve_navigation(&mut sections, &retained_targets, &mut diagnostics);
134    let mut document = Document {
135        parser: Some(ParserInfo {
136            name: "libmandoc".to_owned(),
137            version: libmandoc_rs::LIBMANDOC_VERSION.to_owned(),
138        }),
139        source: DocumentSource {
140            format: match parsed.macro_set {
141                MacroSet::Mdoc => SourceFormat::Mdoc,
142                MacroSet::Man | MacroSet::None => SourceFormat::Man,
143            },
144            path: Some(path.to_string_lossy().into_owned()),
145        },
146        meta: DocumentMeta {
147            title: normalize_metadata(parsed.metadata.title.as_deref()),
148            manual_section: normalize_metadata(parsed.metadata.section.as_deref()),
149            date: normalize_metadata(parsed.metadata.date.as_deref()),
150            volume: normalize_metadata(parsed.metadata.volume.as_deref()),
151            os: normalize_metadata(parsed.metadata.os.as_deref()),
152            arch: normalize_metadata(parsed.metadata.arch.as_deref()),
153            names: normalize_metadata(parsed.metadata.name.as_deref())
154                .into_iter()
155                .collect(),
156            alias_target: parsed.metadata.alias_target.clone(),
157        },
158        diagnostics,
159        blocks: root_blocks,
160        sections,
161    };
162    document.diagnostics.extend(validate_document(&document));
163    document
164}
165
166/// Metadata strings come from roff macro arguments rather than visible text
167/// nodes, so libmandoc can legitimately retain zero-width escapes such as
168/// `\&`. Normalize them through the same inline decoder used for document
169/// content before exposing the renderer-neutral contract.
170fn normalize_metadata(value: Option<&str>) -> Option<String> {
171    value.map(visible_text)
172}
173
174struct LoweringContext<'a> {
175    default_name: Option<&'a str>,
176    next_section_id: usize,
177    diagnostics: RefCell<Vec<Diagnostic>>,
178}
179
180impl<'a> LoweringContext<'a> {
181    const fn new(default_name: Option<&'a str>) -> Self {
182        Self {
183            default_name,
184            next_section_id: 1,
185            diagnostics: RefCell::new(Vec::new()),
186        }
187    }
188
189    fn section_id(&mut self, title: &str) -> String {
190        let sequence = self.next_section_id;
191        self.next_section_id += 1;
192        let slug: String = title
193            .chars()
194            .flat_map(char::to_lowercase)
195            .map(|character| {
196                if character.is_alphanumeric() {
197                    character
198                } else {
199                    '-'
200                }
201            })
202            .collect::<String>()
203            .split('-')
204            .filter(|part| !part.is_empty())
205            .collect::<Vec<_>>()
206            .join("-");
207        if slug.is_empty() {
208            format!("section-{sequence}")
209        } else {
210            format!("{slug}-{sequence}")
211        }
212    }
213
214    fn warn_unhandled_structural_parts(&self, node: &Node) {
215        let macro_name = node.macro_name.as_deref().unwrap_or("unknown");
216        self.diagnostics.borrow_mut().push(Diagnostic {
217            level: DiagnosticLevel::Warning,
218            code: Some("manual.unhandled-structural-parts".to_owned()),
219            message: format!(
220                "structural macro '{macro_name}' contains visible head or tail content without a lowering policy"
221            ),
222            source: source_span(node),
223        });
224    }
225
226    fn take_diagnostics(&self) -> Vec<Diagnostic> {
227        self.diagnostics.take()
228    }
229}
230
231fn source_span(node: &Node) -> Option<SourceSpan> {
232    (node.line > 0).then_some(SourceSpan {
233        byte_range: None,
234        line: node.line,
235        column: node.column.max(1),
236        end_line: None,
237        end_column: None,
238    })
239}
240
241fn part_children(node: &Node, kind: libmandoc_rs::NodeKind) -> &[Node] {
242    node.children
243        .iter()
244        .find(|child| child.kind == kind)
245        .map_or(&[], |child| child.children.as_slice())
246}
247
248#[cfg(test)]
249mod tests {
250    use std::{fs, process};
251
252    use mant_ir::{Block, DiagnosticLevel, Inline, SourceFormat};
253
254    use super::{Parser, lower_mandoc_document, parse_manual_bytes, parse_manual_source};
255
256    fn temporary_source(label: &str, source: &str) -> std::path::PathBuf {
257        let path = std::env::temp_dir().join(format!("mant-lower-{label}-{}.1", process::id()));
258        fs::write(&path, source).expect("write temporary roff fixture");
259        path
260    }
261
262    fn find_macro_mut<'a>(
263        node: &'a mut libmandoc_rs::Node,
264        name: &str,
265    ) -> Option<&'a mut libmandoc_rs::Node> {
266        if node.macro_name.as_deref() == Some(name) {
267            return Some(node);
268        }
269        node.children
270            .iter_mut()
271            .find_map(|child| find_macro_mut(child, name))
272    }
273
274    #[test]
275    fn standalone_inputs_reject_redirect_only_so_pages() {
276        let error = parse_manual_bytes(std::path::Path::new("stdin"), b".so man1/target.1\n")
277            .expect_err("standalone input must not follow another file");
278        assert!(error.to_string().contains("require MANPATH discovery"));
279    }
280
281    #[test]
282    fn lowers_man_sections_fonts_definitions_and_literal_blocks() {
283        let path = temporary_source(
284            "man",
285            ".TH MANT 1 \"July 2026\"\n\
286             .SH NAME\n\
287             mant \\- a viewer\n\
288             .SH OPTIONS\n\
289             .TP\n\
290             \\fB\\-h\\fR\n\
291             Show help.\n\
292             .nf\n\
293             mant --help\n\
294             mant git\n\
295             .fi\n",
296        );
297
298        let document = parse_manual_source(&path).expect("lower man source");
299        fs::remove_file(path).expect("remove temporary roff fixture");
300
301        assert_eq!(document.source.format, SourceFormat::Man);
302        assert_eq!(
303            document
304                .sections
305                .iter()
306                .map(|section| section.title.as_str())
307                .collect::<Vec<_>>(),
308            vec!["NAME", "OPTIONS"]
309        );
310        assert!(
311            document.sections[1]
312                .blocks
313                .iter()
314                .any(|block| matches!(block, Block::DefinitionList { .. }))
315        );
316        assert!(document.sections[1].blocks.iter().any(|block| matches!(
317            block,
318            Block::DefinitionList { items, .. }
319                if items.iter().any(|item| item.description.iter().any(
320                    |description| matches!(description, Block::Preformatted { .. })
321                ))
322        )));
323    }
324
325    #[test]
326    fn separates_definition_layout_arguments_from_visible_terms() {
327        let path = temporary_source(
328            "definition-head-roles",
329            ".TH HEAD-ROLES 1\n\
330             .SH EXAMPLES\n\
331             .TP \\w'man\\ 'u\n\
332             .BI man \\ ls\n\
333             Display ls.\n\
334             .TP 4\n\
335             4\n\
336             A numeric term remains visible.\n\
337             .IP \"1\" 8n\n\
338             An IP width remains layout-only.\n",
339        );
340
341        let document = parse_manual_source(&path).expect("lower definition head roles");
342        fs::remove_file(path).expect("remove temporary roff fixture");
343
344        let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
345            panic!("expected one definition list");
346        };
347        assert_eq!(
348            items
349                .iter()
350                .flat_map(|item| item.terms.iter())
351                .map(|term| inline_text(term))
352                .collect::<Vec<_>>(),
353            ["man ls", "4", "1"]
354        );
355        assert!(matches!(
356            items[0].terms[0].as_slice(),
357            [Inline::Strong { .. }, Inline::Emphasis { .. }]
358        ));
359        assert!(
360            items
361                .iter()
362                .flat_map(|item| item.terms.iter())
363                .all(|term| !inline_text(term).contains("96u"))
364        );
365    }
366
367    #[test]
368    fn preserves_man_synopsis_flow_and_alternating_fonts() {
369        let path = temporary_source(
370            "man-synopsis-flow",
371            ".TH MAN 1\n\
372             .SH SYNOPSIS\n\
373             .B man\n\
374             .RI [\\| \"man options\" \\|]\n\
375             .RI [\\|[\\| section \\|]\n\
376             .IR page \\ \\|.\\|.\\|.\\|]\\ \\.\\|.\\|.\\&\n\
377             .br\n\
378             .B man\n\
379             .B \\-k\n\
380             .RI [\\| \"apropos options\" \\|]\n\
381             .I regexp\n\
382             \\&.\\|.\\|.\\&\n\
383             .br\n\
384             .B man\n\
385             .BR \\-w \\||\\| \\-W\n\
386             .RI [\\| \"man options\" \\|]\n\
387             .I page\n\
388             \\&.\\|.\\|.\\&\n",
389        );
390
391        let document = parse_manual_source(&path).expect("lower man synopsis");
392        fs::remove_file(path).expect("remove temporary roff fixture");
393
394        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
395            panic!("expected one synopsis paragraph");
396        };
397        assert_eq!(
398            inline_text(children),
399            "man [man options] [[section] page ...] ...\n\
400             man -k [apropos options] regexp ...\n\
401             man -w|-W [man options] page ..."
402        );
403        assert_eq!(
404            children
405                .iter()
406                .filter(|node| matches!(node, Inline::LineBreak))
407                .count(),
408            2
409        );
410        assert!(children.iter().any(
411            |node| matches!(node, Inline::Emphasis { children } if inline_text(children) == "man options")
412        ));
413        assert!(children.iter().any(
414            |node| matches!(node, Inline::Strong { children } if inline_text(children) == "-w")
415        ));
416        assert!(children.iter().any(
417            |node| matches!(node, Inline::Strong { children } if inline_text(children) == "-W")
418        ));
419    }
420
421    #[test]
422    fn preserves_man_sy_heads_with_body_content_and_inline_fonts() {
423        let document = parse_manual_bytes(
424            std::path::Path::new("sy-heads.1"),
425            b".TH SY-HEADS 1 \"August 17, 2026\"\n\
426.SH SYNOPSIS\n\
427.SY getent\n\
428.RI [ option ]\n\
429.I database\n\
430.YS\n\
431.SH DESCRIPTION\n\
432.SY #!\\f[I]interpreter\\f[]\n\
433.RI [ optional-arg ]\n\
434.YS\n",
435        )
436        .expect("lower SY heads");
437
438        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
439            panic!("expected one synopsis paragraph");
440        };
441        assert_eq!(inline_text(children), "getent [option] database");
442        assert!(matches!(
443            children.first(),
444            Some(Inline::Strong { children }) if inline_text(children) == "getent"
445        ));
446
447        let [Block::Paragraph { children, .. }] = document.sections[1].blocks.as_slice() else {
448            panic!("expected one description paragraph");
449        };
450        assert_eq!(inline_text(children), "#!interpreter [optional-arg]");
451        assert!(matches!(
452            children.first(),
453            Some(Inline::Strong { children })
454                if children.iter().any(|inline| matches!(
455                    inline,
456                    Inline::Emphasis { children } if inline_text(children) == "interpreter"
457                ))
458        ));
459        assert!(
460            document.diagnostics.is_empty(),
461            "{:?}",
462            document.diagnostics
463        );
464    }
465
466    #[test]
467    fn keeps_man_synopsis_lines_together_inside_no_fill_examples() {
468        let document = parse_manual_bytes(
469            std::path::Path::new("no-fill-synopsis.2"),
470            b".TH NO-FILL-SYNOPSIS 2\n\
471.SH DESCRIPTION\n\
472.EX\n\
473.SY #!\\f[I]interpreter\\f[]\n\
474.RI [ optional-arg ]\n\
475.YS\n\
476.EE\n",
477        )
478        .expect("lower synopsis inside example");
479
480        let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
481            panic!(
482                "no-fill synopsis must remain one preformatted block: {:?}",
483                document.sections[0].blocks
484            );
485        };
486        assert_eq!(inline_text(children), "#!interpreter\n[optional-arg]");
487        assert_eq!(
488            children
489                .iter()
490                .filter(|inline| matches!(inline, Inline::LineBreak))
491                .count(),
492            1
493        );
494    }
495
496    #[test]
497    fn distinguishes_filled_source_wrapping_from_indented_output_lines() {
498        let path = temporary_source(
499            "filled-line-boundaries",
500            concat!(
501                ".TH TOOL 1\n",
502                ".SH SYNOPSIS\n",
503                "tool [first]\n",
504                "    [second]\n",
505                "    [third]\n",
506                ".PP\n",
507                "Ordinary source wrapping\n",
508                "remains one filled paragraph.\n",
509            ),
510        );
511
512        let document = parse_manual_source(&path).expect("lower filled line boundaries");
513        fs::remove_file(path).expect("remove temporary roff fixture");
514
515        let [
516            Block::Paragraph {
517                children: synopsis, ..
518            },
519            Block::Paragraph {
520                children: prose, ..
521            },
522        ] = document.sections[0].blocks.as_slice()
523        else {
524            panic!("expected synopsis and prose paragraphs");
525        };
526        assert_eq!(
527            inline_text(synopsis),
528            "tool [first]\n    [second]\n    [third]"
529        );
530        assert_eq!(
531            synopsis
532                .iter()
533                .filter(|inline| matches!(inline, Inline::LineBreak))
534                .count(),
535            2
536        );
537        assert_eq!(
538            inline_text(prose),
539            "Ordinary source wrapping remains one filled paragraph."
540        );
541    }
542
543    #[test]
544    fn honours_roff_no_space_line_continuations() {
545        let document = parse_manual_bytes(
546            std::path::Path::new("line-continuation.1"),
547            b".TH LINE-CONTINUATION 1\n\
548.SH DESCRIPTION\n\
549extsize=\\c\n\
550nnnn; multi-\\c\n\
551block; (\\c\n\
552.BR read (2)\n\
553.EX\n\
554literal-\\c\n\
555continuation\n\
556.EE\n",
557        )
558        .expect("lower no-space line continuations");
559
560        let [
561            Block::Paragraph {
562                children: prose, ..
563            },
564            Block::Preformatted {
565                children: literal, ..
566            },
567        ] = document.sections[0].blocks.as_slice()
568        else {
569            panic!(
570                "expected one filled and one no-fill block: {:?}",
571                document.sections[0].blocks
572            );
573        };
574        assert_eq!(inline_text(prose), "extsize=nnnn; multi-block; (read(2)");
575        assert_eq!(inline_text(literal), "literal-continuation");
576    }
577
578    #[test]
579    fn keeps_explicit_horizontal_separation_at_a_tight_line_join() {
580        let document = parse_manual_bytes(
581            std::path::Path::new("motion-continuation.1"),
582            b".TH MOTION-CONTINUATION 1\n\
583.SH DESCRIPTION\n\
584\\h'-04' 1.\\h'+01'\\c\n\
585The next line.\n",
586        )
587        .expect("lower a horizontally spaced continued line");
588
589        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
590            panic!("expected one paragraph: {:?}", document.sections[0].blocks);
591        };
592        assert_eq!(inline_text(children), " 1. The next line.");
593    }
594
595    #[test]
596    fn lets_explicit_fonts_override_an_alternating_macro_default() {
597        let path = temporary_source(
598            "alternating-font-reset",
599            ".TH MAN 1\n\
600             .SH OPTIONS\n\
601             .TP\n\
602             .BI \\-r\\  prompt \\fR,\\ \\fB\\-\\-prompt= prompt\n\
603             Set the pager prompt.\n",
604        );
605
606        let document = parse_manual_source(&path).expect("lower alternating font reset");
607        fs::remove_file(path).expect("remove temporary roff fixture");
608
609        let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
610            panic!("expected one definition list");
611        };
612        let term = items[0]
613            .terms
614            .first()
615            .expect("first definition term")
616            .iter()
617            .filter(|inline| !matches!(inline, Inline::Anchor { .. }))
618            .collect::<Vec<_>>();
619
620        assert_eq!(term.len(), 5);
621        assert!(matches!(term[0], Inline::Strong { children } if inline_text(children) == "-r "));
622        assert!(
623            matches!(term[1], Inline::Emphasis { children } if inline_text(children) == "prompt")
624        );
625        assert!(matches!(term[2], Inline::Text { value } if value == ", "));
626        assert!(
627            matches!(term[3], Inline::Strong { children } if inline_text(children) == "--prompt=")
628        );
629        assert!(
630            matches!(term[4], Inline::Emphasis { children } if inline_text(children) == "prompt")
631        );
632    }
633
634    #[test]
635    fn suppresses_pod_font_requests_around_verbatim_blocks() {
636        let path = temporary_source(
637            "pod-verbatim-fonts",
638            ".de Vb\n\
639             .ft CW\n\
640             .nf\n\
641             ..\n\
642             .de Ve\n\
643             .ft R\n\
644             .fi\n\
645             ..\n\
646             .TH POD 1\n\
647             .SH EXAMPLES\n\
648             .Vb 2\n\
649             \\&struct A { int a; };\n\
650             \\&struct B : A {};\n\
651             .Ve\n",
652        );
653
654        let document = parse_manual_source(&path).expect("lower Pod::Man verbatim source");
655        fs::remove_file(path).expect("remove temporary roff fixture");
656
657        assert_eq!(document.sections[0].blocks.len(), 1);
658        let Block::Preformatted { children, .. } = &document.sections[0].blocks[0] else {
659            panic!("expected one preformatted block");
660        };
661        assert_eq!(
662            inline_text(children),
663            "struct A { int a; };\nstruct B : A {};"
664        );
665    }
666
667    #[test]
668    fn lowers_indented_aliases_without_roff_layout_arguments() {
669        let path = temporary_source(
670            "indented-aliases",
671            ".TH CONTROL 1\n\
672             .SH OPTIONS\n\
673             .PD 0\n\
674             .IP \"\\fB-a\\fR\" 4\n\
675             .IP \"\\fB--all\\fR\" 4\n\
676             Show all entries.\n\
677             .PD\n\
678             .in 168u\n",
679        );
680
681        let document = parse_manual_source(&path).expect("lower indented aliases");
682        fs::remove_file(path).expect("remove temporary roff fixture");
683
684        let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
685            panic!("expected one definition list");
686        };
687        assert_eq!(items.len(), 1);
688        assert_eq!(
689            items[0]
690                .terms
691                .iter()
692                .map(|term| inline_text(term))
693                .collect::<Vec<_>>(),
694            ["-a", "--all"]
695        );
696        assert_eq!(items[0].description.len(), 1);
697        let Block::Paragraph { children, .. } = &items[0].description[0] else {
698            panic!("expected alias description paragraph");
699        };
700        assert_eq!(inline_text(children), "Show all entries.");
701    }
702
703    #[test]
704    fn preserves_man_paragraph_distance_between_indented_paragraphs() {
705        let path = temporary_source(
706            "paragraph-distance",
707            ".TH SPACING 1\n\
708             .SH OPTIONS\n\
709             .IP \"\\fB-a\\fR\" 4\n\
710             First.\n\
711             .IP \"\\fB-b\\fR\" 4\n\
712             Second.\n\
713             .PD 0\n\
714             .IP \"\\fB-c\\fR\" 4\n\
715             Third.\n\
716             .IP \"\\fB-d\\fR\" 4\n\
717             Fourth.\n\
718             .PD\n\
719             .IP \"\\fB-e\\fR\" 4\n\
720             Fifth.\n",
721        );
722
723        let document = parse_manual_source(&path).expect("lower paragraph distance");
724        fs::remove_file(path).expect("remove temporary roff fixture");
725
726        let [Block::DefinitionList { items, compact, .. }] = document.sections[0].blocks.as_slice()
727        else {
728            panic!("expected one definition list");
729        };
730        assert!(!compact);
731        assert_eq!(items.len(), 5);
732        assert_eq!(
733            items
734                .iter()
735                .map(|item| item.spacing_before_lines)
736                .collect::<Vec<_>>(),
737            [Some(0), Some(1), Some(0), Some(0), Some(1)]
738        );
739    }
740
741    #[test]
742    fn preserves_man_paragraph_and_heading_distance_as_one_layout_model() {
743        let path = temporary_source(
744            "vertical-layout",
745            ".TH SPACING 1\n\
746             .SH FIRST\n\
747             First paragraph.\n\
748             .PP\n\
749             Second paragraph.\n\
750             .SS CHILD\n\
751             Child body.\n\
752             .PD 0\n\
753             .SS COMPACT\n\
754             Compact child.\n\
755             .SH NEXT\n\
756             Next body.\n\
757             .PD\n\
758             .SH FINAL\n\
759             Final body.\n",
760        );
761
762        let document = parse_manual_source(&path).expect("lower vertical layout");
763        fs::remove_file(path).expect("remove temporary roff fixture");
764
765        let [first, next, final_section] = document.sections.as_slice() else {
766            panic!("expected three top-level sections");
767        };
768        assert_eq!(first.spacing_before_lines, 0);
769        let [Block::Paragraph { .. }, Block::Paragraph { layout, .. }] = first.blocks.as_slice()
770        else {
771            panic!("expected two semantic paragraphs");
772        };
773        assert_eq!(layout.spacing_before_lines, 1);
774
775        let [child, compact] = first.children.as_slice() else {
776            panic!("expected two subsections");
777        };
778        assert_eq!(child.spacing_before_lines, 1);
779        assert_eq!(compact.spacing_before_lines, 0);
780        assert_eq!(next.spacing_before_lines, 0);
781        assert_eq!(final_section.spacing_before_lines, 1);
782    }
783
784    #[test]
785    fn does_not_duplicate_explicit_space_before_a_transparent_indent() {
786        let path = temporary_source(
787            "explicit-space-before-indent",
788            ".TH SPACING 1\n\
789             .SH CONTENT\n\
790             Before.\n\
791             .sp\n\
792             .RS 4\n\
793             After.\n\
794             .RE\n",
795        );
796
797        let document = parse_manual_source(&path).expect("lower explicit indented spacing");
798        fs::remove_file(path).expect("remove temporary roff fixture");
799
800        let [
801            Block::Paragraph { .. },
802            Block::VerticalSpace { lines: 1, .. },
803            Block::Paragraph { layout, .. },
804        ] = document.sections[0].blocks.as_slice()
805        else {
806            panic!("expected prose, one explicit gap, and indented prose");
807        };
808        assert_eq!(layout.indent_columns, 4);
809        assert_eq!(
810            layout.spacing_before_lines, 0,
811            "the explicit gap must not be repeated as wrapper boundary spacing",
812        );
813    }
814
815    #[test]
816    fn preserves_mdoc_paragraph_and_heading_distance() {
817        let path = temporary_source(
818            "mdoc-vertical-layout",
819            ".Dd July 19, 2026\n\
820             .Dt SPACING 1\n\
821             .Os\n\
822             .Sh FIRST\n\
823             First paragraph.\n\
824             .Pp\n\
825             Second paragraph.\n\
826             .Ss CHILD\n\
827             Child body.\n",
828        );
829
830        let document = parse_manual_source(&path).expect("lower mdoc vertical layout");
831        fs::remove_file(path).expect("remove temporary roff fixture");
832
833        let [first] = document.sections.as_slice() else {
834            panic!("expected one top-level section");
835        };
836        assert_eq!(first.spacing_before_lines, 1);
837        assert!(matches!(
838            first.blocks.get(1),
839            Some(Block::VerticalSpace { lines: 1, .. })
840        ));
841        assert_eq!(first.children[0].spacing_before_lines, 1);
842    }
843
844    #[test]
845    fn lowers_mdoc_semantic_inline_nodes_and_nested_sections() {
846        let path = temporary_source(
847            "mdoc",
848            ".Dd July 19, 2026\n\
849             .Dt MANT 1\n\
850             .Os\n\
851             .Sh DESCRIPTION\n\
852             Use\n\
853             .Nm mant\n\
854             with\n\
855             .Xr man 1\n\
856             Read\n\
857             .Lk https://example.test/docs \"the documentation\"\n\
858             or contact\n\
859             .Mt docs@example.test\n\
860             .Ss Details\n\
861             .Fl h\n",
862        );
863
864        let document = parse_manual_source(&path).expect("lower mdoc source");
865        fs::remove_file(path).expect("remove temporary roff fixture");
866
867        assert_eq!(document.source.format, SourceFormat::Mdoc);
868        assert_eq!(document.sections[0].children[0].title, "Details");
869        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
870            panic!("expected description paragraph");
871        };
872        assert!(
873            children
874                .iter()
875                .any(|inline| matches!(inline, Inline::Strong { .. }))
876        );
877        assert!(
878            children.iter().any(
879                |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::Manual { name, .. }, .. } if name == "man")
880            )
881        );
882        assert!(children.iter().any(
883            |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::External { uri }, .. } if uri == "https://example.test/docs")
884        ));
885        assert!(children.iter().any(
886            |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::Email { address }, .. } if address == "docs@example.test")
887        ));
888    }
889
890    #[test]
891    fn lowers_documented_mdoc_delimiters_and_common_roff_characters() {
892        let path = temporary_source(
893            "mdoc-delimiters",
894            ".Dd July 19, 2026\n\
895             .Dt DELIMITERS 7\n\
896             .Os\n\
897             .Sh DESCRIPTION\n\
898             .Op optional\n\
899             .Bq bracket\n\
900             .Dq double\n\
901             .Sq single\n\
902             .Pq parenthesized\n\
903             .Brq braced\n\
904             .Aq angled\n\
905             .Oo multi Ar value\n\
906             .Oc\n\
907             .Sh CHARACTERS\n\
908             \\(en \\(em \\(aq \\(dq \\(co \\(rg \\(tm \\(bu \\(ha \\(ti \\(rs\n",
909        );
910
911        let document = parse_manual_source(&path).expect("lower delimiter and character source");
912        fs::remove_file(path).expect("remove temporary roff fixture");
913
914        let description = document.sections[0]
915            .blocks
916            .iter()
917            .map(|block| match block {
918                Block::Paragraph { children, .. } => inline_text(children),
919                _ => String::new(),
920            })
921            .collect::<Vec<_>>()
922            .join(" ");
923        for expected in [
924            "[optional]",
925            "[bracket]",
926            "“double”",
927            "‘single’",
928            "(parenthesized)",
929            "{braced}",
930            "<angled>",
931            "[multi value]",
932        ] {
933            assert!(
934                description.contains(expected),
935                "missing {expected:?} in {description:?}"
936            );
937        }
938
939        let [Block::Paragraph { children, .. }] = document.sections[1].blocks.as_slice() else {
940            panic!("expected one special-character paragraph");
941        };
942        assert_eq!(inline_text(children), "– — ' \" © ® ™ • ^ ~ \\");
943    }
944
945    #[test]
946    fn lowers_the_pinned_named_character_catalog_without_silent_deletion() {
947        let document = parse_manual_bytes(
948            std::path::Path::new("named-characters.7"),
949            b".TH NAMED-CHARACTERS 7\n\
950.SH TEST\n\
951at=\\(at ga=\\(ga oq=\\(oq arrow=\\(-> larrow=\\(<- mu=\\(mu\n\
952de=\\(de pl=\\(pl dg=\\(dg ua=\\(ua da=\\(da lB=\\(lB rB=\\(rB\n\
953unknown=\\[future-glyph]\n",
954        )
955        .expect("lower named characters");
956
957        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
958            panic!("expected one character paragraph");
959        };
960        assert_eq!(
961            inline_text(children),
962            "at=@ ga=` oq=' arrow=→ larrow=← mu=× de=° pl=+ dg=† ua=↑ da=↓ lB=[ rB=] unknown=\\[future-glyph]"
963        );
964    }
965
966    #[test]
967    fn preserves_explicit_mdoc_function_and_enclosure_structure() {
968        let document = parse_manual_bytes(
969            std::path::Path::new("explicit-mdoc.1"),
970            b".Dd August 17, 2026\n\
971.Dt EXPLICIT-MDOC 1\n\
972.Os\n\
973.Sh NAME\n\
974.Nm explicit-mdoc\n\
975.Nd exercise explicit blocks\n\
976.Sh FUNCTION\n\
977.Ft int\n\
978.Fo audit_open\n\
979.Fa const char *path\n\
980.Fa int flags\n\
981.Fc\n\
982.Sh ENCLOSURES\n\
983.Ao\nangle\n.Ac\n\
984.Bo\nbracket\n.Bc\n\
985.Do\ndouble\n.Dc\n\
986.Po\nparenthesized\n.Pc\n\
987.Qo\nquoted\n.Qc\n\
988.So\nsingle\n.Sc\n\
989.Bro\nbraced\n.Brc\n\
990.Oo\noptional\n.Oc\n\
991.Eo <<\ngeneric\n.Ec >>\n\
992.Es [[ ]]\n\
993.En custom\n",
994        )
995        .expect("lower explicit mdoc blocks");
996
997        let function = &document.sections[1];
998        let [
999            Block::Paragraph {
1000                children: return_type,
1001                ..
1002            },
1003            Block::Paragraph {
1004                children: declaration,
1005                ..
1006            },
1007        ] = function.blocks.as_slice()
1008        else {
1009            panic!("expected return type and function declaration paragraphs");
1010        };
1011        assert_eq!(inline_text(return_type), "int");
1012        assert_eq!(
1013            inline_text(declaration),
1014            "audit_open(const char *path, int flags)"
1015        );
1016        assert!(matches!(
1017            declaration.first(),
1018            Some(Inline::Strong { children }) if inline_text(children) == "audit_open"
1019        ));
1020
1021        let [Block::Paragraph { children, .. }] = document.sections[2].blocks.as_slice() else {
1022            panic!("expected one enclosure paragraph");
1023        };
1024        assert_eq!(
1025            inline_text(children),
1026            "<angle> [bracket] “double” (parenthesized) “quoted” ‘single’ {braced} \
1027             [optional] <<generic>> [[custom]]"
1028        );
1029        assert_eq!(document.diagnostics.len(), 2);
1030        assert!(
1031            document
1032                .diagnostics
1033                .iter()
1034                .all(|diagnostic| diagnostic.message.starts_with("obsolete macro:")),
1035            "{:?}",
1036            document.diagnostics
1037        );
1038    }
1039
1040    #[test]
1041    fn diagnoses_future_structural_macros_before_discarding_visible_parts() {
1042        let mut report = Parser::default()
1043            .parse_bytes(
1044                "future-structure.1",
1045                b".Dd August 17, 2026\n.Dt FUTURE 1\n.Os\n.Sh SYNOPSIS\n\
1046.Fo future_call\n.Fa argument\n.Fc\n",
1047            )
1048            .expect("parse structural fixture");
1049        let block = find_macro_mut(&mut report.document.root, "Fo").expect("Fo block");
1050        block.macro_name = Some("FutureBlock".to_owned());
1051
1052        let document = lower_mandoc_document(std::path::Path::new("future-structure.1"), &report);
1053
1054        assert!(document.diagnostics.iter().any(|diagnostic| {
1055            diagnostic.code.as_deref() == Some("manual.unhandled-structural-parts")
1056                && diagnostic.message.contains("FutureBlock")
1057        }));
1058    }
1059
1060    #[test]
1061    fn recognizes_explicitly_styled_traditional_man_references_in_any_section() {
1062        let path = temporary_source(
1063            "man-see-also",
1064            ".TH TOOL 1\n\
1065             .SH DESCRIPTION\n\
1066             The styled reference \\fBprintf\\fP(3) is usable here.\n\
1067             .SH SEE ALSO\n\
1068             .BR printf (3),\n\
1069             .BR man (1)\n",
1070        );
1071
1072        let document = parse_manual_source(&path).expect("lower man references");
1073        fs::remove_file(path).expect("remove temporary roff fixture");
1074
1075        let see_also = document
1076            .sections
1077            .iter()
1078            .find(|section| section.title == "SEE ALSO")
1079            .expect("SEE ALSO");
1080        let Block::Paragraph { children, .. } = &see_also.blocks[0] else {
1081            panic!("references are a paragraph");
1082        };
1083        assert!(children.iter().any(|inline| matches!(
1084            inline,
1085            Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
1086                if name == "printf" && manual_section == "3"
1087        )));
1088        assert!(children.iter().any(|inline| matches!(
1089            inline,
1090            Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
1091                if name == "man" && manual_section == "1"
1092        )));
1093
1094        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
1095            panic!("description is a paragraph");
1096        };
1097        assert!(children.iter().any(|inline| matches!(
1098            inline,
1099            Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
1100                if name == "printf" && manual_section == "3"
1101        )));
1102    }
1103
1104    #[test]
1105    fn recognizes_legacy_sphinx_manual_links_in_roff_inputs() {
1106        let path = temporary_source(
1107            "sphinx-manual-links",
1108            ".TH BTRFS 8\n\
1109             .SH COMMANDS\n\
1110             See btrfs\\-subvolume(8) \\%<> and btrfs(5) \\%<> for details.\n\
1111             .EX\n\
1112             btrfs-subvolume(8) \\%<>\n\
1113             .EE\n",
1114        );
1115
1116        let document = parse_manual_source(&path).expect("lower legacy Sphinx references");
1117        fs::remove_file(path).expect("remove temporary roff fixture");
1118        let section = &document.sections[0];
1119        let paragraph = section
1120            .blocks
1121            .iter()
1122            .find_map(|block| match block {
1123                Block::Paragraph { children, .. } => Some(children),
1124                _ => None,
1125            })
1126            .expect("commands paragraph");
1127        assert_eq!(
1128            inline_text(paragraph),
1129            "See btrfs-subvolume(8) and btrfs(5) for details."
1130        );
1131        let references = paragraph
1132            .iter()
1133            .filter_map(|inline| match inline {
1134                Inline::Link {
1135                    target:
1136                        mant_ir::LinkTarget::Manual {
1137                            name,
1138                            manual_section: Some(manual_section),
1139                        },
1140                    ..
1141                } => Some((name.as_str(), manual_section.as_str())),
1142                _ => None,
1143            })
1144            .collect::<Vec<_>>();
1145        assert_eq!(references, [("btrfs-subvolume", "8"), ("btrfs", "5")]);
1146
1147        let literal = section
1148            .blocks
1149            .iter()
1150            .find_map(|block| match block {
1151                Block::Preformatted { children, .. } => Some(children),
1152                _ => None,
1153            })
1154            .expect("literal display");
1155        assert_eq!(inline_text(literal), "btrfs-subvolume(8) <>");
1156        assert!(!literal.iter().any(|inline| matches!(
1157            inline,
1158            Inline::Link {
1159                target: mant_ir::LinkTarget::Manual { .. },
1160                ..
1161            }
1162        )));
1163    }
1164
1165    #[test]
1166    fn lowers_modern_groff_manual_uri_and_mail_macros() {
1167        let path = temporary_source(
1168            "man-modern-links",
1169            ".TH TOOL 1\n\
1170             .SH DESCRIPTION\n\
1171             .MR git-add 1 ,\n\
1172             .UR https://example.test/docs\n\
1173             Documentation\n\
1174             .UE .\n\
1175             .MT docs@example.test\n\
1176             Mail us\n\
1177             .ME .\n",
1178        );
1179
1180        let document = parse_manual_source(&path).expect("lower modern man links");
1181        fs::remove_file(path).expect("remove temporary roff fixture");
1182        let section = &document.sections[0];
1183        let mut manual = false;
1184        let mut web = false;
1185        let mut mail = false;
1186        for children in section.blocks.iter().filter_map(|block| match block {
1187            Block::Paragraph { children, .. } => Some(children),
1188            _ => None,
1189        }) {
1190            for inline in children {
1191                match inline {
1192                    Inline::Link {
1193                        target:
1194                            mant_ir::LinkTarget::Manual {
1195                                name,
1196                                manual_section: Some(manual_section),
1197                            },
1198                        ..
1199                    } if name == "git-add" && manual_section == "1" => manual = true,
1200                    Inline::Link {
1201                        target: mant_ir::LinkTarget::External { uri },
1202                        ..
1203                    } if uri == "https://example.test/docs" => {
1204                        web = true;
1205                    }
1206                    Inline::Link {
1207                        target: mant_ir::LinkTarget::Email { address },
1208                        ..
1209                    } if address == "docs@example.test" => {
1210                        mail = true;
1211                    }
1212                    _ => {}
1213                }
1214            }
1215        }
1216
1217        assert!(manual && web && mail);
1218        assert!(section.blocks.iter().any(|block| match block {
1219            Block::Paragraph { children, .. } => inline_text(children).contains("git-add(1),"),
1220            _ => false,
1221        }));
1222        let linked_paragraphs = section
1223            .blocks
1224            .iter()
1225            .filter_map(|block| match block {
1226                Block::Paragraph { children, .. }
1227                    if children.iter().any(|inline| {
1228                        matches!(
1229                            inline,
1230                            Inline::Link {
1231                                target: mant_ir::LinkTarget::External { .. },
1232                                ..
1233                            } | Inline::Link {
1234                                target: mant_ir::LinkTarget::Email { .. },
1235                                ..
1236                            }
1237                        )
1238                    }) =>
1239                {
1240                    Some(inline_text(children))
1241                }
1242                _ => None,
1243            })
1244            .collect::<Vec<_>>();
1245        assert_eq!(linked_paragraphs, ["Documentation.", "Mail us."]);
1246    }
1247
1248    #[test]
1249    fn resolves_mdoc_section_references_and_explicit_targets() {
1250        let path = temporary_source(
1251            "mdoc-navigation",
1252            ".Dd July 19, 2026\n\
1253             .Dt NAVIGATION 1\n\
1254             .Os\n\
1255             .Sh DESCRIPTION\n\
1256             Continue with\n\
1257             .Sx DETAILS\n\
1258             .Tg explicit-option\n\
1259             .Fl x\n\
1260             .Sh DETAILS\n\
1261             Target content.\n",
1262        );
1263
1264        let document = parse_manual_source(&path).expect("lower navigation mdoc source");
1265        fs::remove_file(path).expect("remove temporary roff fixture");
1266
1267        assert_eq!(document.sections[0].id, "description-1");
1268        assert_eq!(document.sections[1].id, "details-2");
1269        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
1270            panic!("expected navigation paragraph");
1271        };
1272        assert!(children.iter().any(|inline| matches!(
1273            inline,
1274            Inline::Link {
1275                target: mant_ir::LinkTarget::Section { id },
1276                children,
1277                ..
1278            } if id == "details-2" && inline_text(children) == "DETAILS"
1279        )));
1280        assert!(children.iter().any(|inline| matches!(
1281            inline,
1282            Inline::Anchor { id } if id == "explicit-option"
1283        )));
1284    }
1285
1286    #[test]
1287    fn degrades_unresolved_mdoc_section_references_to_text() {
1288        let path = temporary_source(
1289            "mdoc-missing-section",
1290            ".Dd July 19, 2026\n.Dt NAVIGATION 1\n.Os\n.Sh DESCRIPTION\n.Sx MISSING\n",
1291        );
1292
1293        let document = parse_manual_source(&path).expect("lower unresolved navigation source");
1294        fs::remove_file(path).expect("remove temporary roff fixture");
1295
1296        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
1297            panic!("expected reference paragraph");
1298        };
1299        assert_eq!(inline_text(children), "MISSING");
1300        assert!(children.iter().all(|inline| !matches!(
1301            inline,
1302            Inline::Link {
1303                target: mant_ir::LinkTarget::Section { .. },
1304                ..
1305            }
1306        )));
1307        assert!(document.diagnostics.iter().any(|diagnostic| {
1308            diagnostic.code.as_deref() == Some("unresolved-section-reference")
1309        }));
1310    }
1311
1312    #[test]
1313    fn turns_captured_parser_findings_into_structured_diagnostics() {
1314        let path = temporary_source(
1315            "unsupported",
1316            ".Dd July 19, 2026\n.Dt BAD 1\n.Os\n.Sh NAME\n.Nm bad\n.ab\n",
1317        );
1318
1319        let document = parse_manual_source(&path).expect("best-effort parse");
1320        fs::remove_file(path).expect("remove temporary roff fixture");
1321
1322        assert!(
1323            document
1324                .diagnostics
1325                .iter()
1326                .any(|diagnostic| diagnostic.level == DiagnosticLevel::Unsupported)
1327        );
1328    }
1329
1330    #[test]
1331    fn masks_terminal_controls_before_native_parsing() {
1332        let path = temporary_source("controls", ".TH SAFE 1\n.SH NAME\nsafe \x1b[2J text\n");
1333
1334        let document = parse_manual_source(&path).expect("parse sanitized manual");
1335        fs::remove_file(path).expect("remove temporary roff fixture");
1336
1337        assert!(
1338            document.diagnostics.iter().any(|diagnostic| {
1339                diagnostic.code.as_deref() == Some("manual.control-characters")
1340            })
1341        );
1342    }
1343
1344    #[test]
1345    fn lowers_normalized_ordered_lists_and_literal_displays() {
1346        let path = temporary_source(
1347            "normalized",
1348            ".Dd July 19, 2026\n.Dt NORMALIZED 1\n.Os\n.Sh CONTENT\n\
1349             .Bl -enum -compact\n.It\nfirst\n.It\nsecond\n.El\n\
1350             .Bd -literal -offset 6n\nline one\nline two\n.Ed\n",
1351        );
1352
1353        let document = parse_manual_source(&path).expect("lower normalized mdoc");
1354        fs::remove_file(path).expect("remove temporary roff fixture");
1355
1356        assert!(matches!(
1357            document.sections[0].blocks[0],
1358            Block::List {
1359                kind: mant_ir::ListKind::Ordered,
1360                compact: true,
1361                ..
1362            }
1363        ));
1364        assert!(matches!(
1365            document.sections[0].blocks[1],
1366            Block::Preformatted { layout, .. } if layout.indent_columns == 6
1367        ));
1368    }
1369
1370    #[test]
1371    fn lowers_normalized_mdoc_font_and_author_layout() {
1372        let path = temporary_source(
1373            "normalized-mdoc-modes",
1374            ".Dd July 19, 2026\n\
1375             .Dt NORMALIZED-MODES 1\n\
1376             .Os\n\
1377             .Sh AUTHORS\n\
1378             .An -split\n\
1379             .An Alice Example\n\
1380             .An Bob Example\n\
1381             .An -nosplit\n\
1382             .An Carol Example\n\
1383             .An Dave Example\n\
1384             .Sh DESCRIPTION\n\
1385             .Bf -literal\n\
1386             literal text\n\
1387             .Ef\n",
1388        );
1389
1390        let document = parse_manual_source(&path).expect("lower normalized mdoc modes");
1391        fs::remove_file(path).expect("remove temporary roff fixture");
1392
1393        let authors = &document.sections[0];
1394        let Block::Paragraph { children, .. } = &authors.blocks[0] else {
1395            panic!("authors are one paragraph");
1396        };
1397        assert_eq!(
1398            inline_text(children),
1399            "Alice Example\nBob Example Carol Example Dave Example"
1400        );
1401
1402        let description = &document.sections[1];
1403        let Block::Paragraph { children, .. } = &description.blocks[0] else {
1404            panic!("font block is a paragraph");
1405        };
1406        assert!(matches!(
1407            children.as_slice(),
1408            [Inline::Code { value }] if value == "literal text"
1409        ));
1410    }
1411
1412    #[test]
1413    fn mdoc_definition_layout_uses_the_normalized_list_width() {
1414        let path = temporary_source(
1415            "mdoc-definition-widths",
1416            ".Dd July 23, 2026\n.Dt WIDTHS 1\n.Os\n.Sh ITEMS\n\
1417             .Bl -tag -width 20n\n.It tenletters\nwide description\n.El\n\
1418             .Bl -tag -width 3n\n.It short\nnarrow description\n.El\n",
1419        );
1420
1421        let document = parse_manual_source(&path).expect("lower mdoc definition widths");
1422        fs::remove_file(path).expect("remove temporary roff fixture");
1423
1424        let lists = document.sections[0]
1425            .blocks
1426            .iter()
1427            .filter_map(|block| match block {
1428                Block::DefinitionList { items, .. } => Some(items),
1429                _ => None,
1430            })
1431            .collect::<Vec<_>>();
1432        assert_eq!(lists.len(), 2);
1433        assert!(lists[0][0].inline_term);
1434        assert!(!lists[1][0].inline_term);
1435    }
1436
1437    #[test]
1438    fn lowers_the_pinned_large_mdoc_fixture_without_empty_sections() {
1439        let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1440            .join("../libmandoc-rs/vendor/mandoc-1.14.6/mandoc.1");
1441
1442        let document = parse_manual_source(&source).expect("lower vendored mandoc manual");
1443
1444        assert!(document.sections.len() > 5);
1445        assert!(
1446            document
1447                .sections
1448                .iter()
1449                .any(|section| section.title == "DESCRIPTION")
1450        );
1451        assert!(
1452            document
1453                .sections
1454                .iter()
1455                .all(|section| !section.blocks.is_empty() || !section.children.is_empty())
1456        );
1457    }
1458
1459    #[test]
1460    fn lowers_tbl_and_eqn_payloads_into_structured_blocks() {
1461        let path = temporary_source(
1462            "table-equation",
1463            ".TH PAYLOAD 1\n.SH TABLE\n.TS\ntab(|);\nl r.\nleft|right\n.TE\n\
1464             .SH EQUATION\n.EQ\nx sup 2\n.EN\n",
1465        );
1466
1467        let document = parse_manual_source(&path).expect("lower table and equation");
1468        fs::remove_file(path).expect("remove temporary roff fixture");
1469
1470        assert!(matches!(
1471            document.sections[0].blocks[0],
1472            Block::Table { ref rows, .. } if rows.len() == 1 && rows[0].cells.len() == 2
1473        ));
1474        assert!(matches!(
1475            document.sections[1].blocks[0],
1476            Block::Equation { ref value, .. } if value.contains('x')
1477        ));
1478    }
1479
1480    fn inline_text(children: &[Inline]) -> String {
1481        children
1482            .iter()
1483            .map(|child| match child {
1484                Inline::Text { value } | Inline::Code { value } => value.clone(),
1485                Inline::Strong { children }
1486                | Inline::Emphasis { children }
1487                | Inline::Link { children, .. } => inline_text(children),
1488                Inline::Anchor { .. } => String::new(),
1489                Inline::LineBreak => "\n".to_owned(),
1490            })
1491            .collect()
1492    }
1493}