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