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 roff_escape;
10mod source;
11
12use std::path::Path;
13
14use libmandoc_rs::{
15    Compression, Document as MandocDocument, IncludePolicy, MacroSet, Node, ParseOptions,
16    ParseReport, Parser,
17};
18use mant_ir::{
19    Diagnostic, DiagnosticLevel, Document, DocumentMeta, DocumentSource, ParserInfo, SourceFormat,
20    SourceSpan, validate_document,
21};
22
23use self::{
24    roff_escape::visible_text,
25    source::{load_manual_source, redirect_target, resolve_manual_redirects},
26};
27use crate::ManualPage;
28use crate::text_safety::mask_terminal_control_bytes;
29
30pub use error::{ManualError, ManualErrorKind};
31pub use source::MAX_MANUAL_BYTES;
32
33/// Parse and normalize one standalone man or mdoc source file.
34///
35/// This safe convenience entry point does not expand `.so` redirects because
36/// no caller-approved manual hierarchy accompanies a bare path. `ManT`'s indexed
37/// query path uses [`parse_manual_page`] instead.
38///
39/// # Errors
40///
41/// Returns [`ManualError`] when the source cannot be opened, decoded, or parsed.
42pub fn parse_manual_source(path: &Path) -> Result<Document, ManualError> {
43    let loaded = load_manual_source(path)?;
44    reject_standalone_redirect(path, &loaded.source)?;
45    parse_plain_manual(path, &loaded.source, None)
46}
47
48/// Parse one already bounded, uncompressed standalone roff input.
49///
50/// This is the standard-input counterpart of [`parse_manual_source`]. It does
51/// not expand `.so` redirects and never reads another file.
52///
53/// # Errors
54///
55/// Returns [`ManualError`] when libmandoc rejects the input.
56pub fn parse_manual_bytes(path: &Path, source: &[u8]) -> Result<Document, ManualError> {
57    reject_standalone_redirect(path, source)?;
58    parse_plain_manual(path, source, None)
59}
60
61fn reject_standalone_redirect(path: &Path, source: &[u8]) -> Result<(), ManualError> {
62    if redirect_target(path, source)?.is_some() {
63        return Err(ManualError::redirect(
64            path,
65            "standalone .so redirects require MANPATH discovery and cannot be followed by --input",
66        ));
67    }
68    Ok(())
69}
70
71/// Parse an indexed manual, resolving `.so` redirects against its discovered
72/// manual hierarchy without falling back to the process working directory.
73///
74/// # Errors
75///
76/// Returns [`ManualError`] when the source cannot be opened, decoded, or parsed.
77pub fn parse_manual_page(page: &ManualPage) -> Result<Document, ManualError> {
78    let resolved = resolve_manual_redirects(page)?;
79    parse_plain_manual(
80        &page.path,
81        &resolved.source,
82        resolved.alias_target.as_deref(),
83    )
84}
85
86fn parse_plain_manual(
87    path: &Path,
88    source: &[u8],
89    alias_target: Option<&str>,
90) -> Result<Document, ManualError> {
91    let (source, masked_controls) = mask_terminal_control_bytes(source);
92    let report = Parser::new(ParseOptions {
93        includes: IncludePolicy::Deny,
94        compression: Compression::Plain,
95    })
96    .parse_bytes(path, source.as_ref())
97    .map_err(ManualError::from)?;
98    let mut document = lower_mandoc_document(path, &report);
99    if masked_controls > 0 {
100        document.diagnostics.insert(
101            0,
102            Diagnostic {
103                level: DiagnosticLevel::Warning,
104                code: Some("manual.control-characters".to_owned()),
105                message: format!("masked {masked_controls} terminal-unsafe control character(s)"),
106                source: None,
107            },
108        );
109    }
110    if let Some(alias_target) = alias_target {
111        document.meta.alias_target = Some(alias_target.to_owned());
112    }
113    Ok(document)
114}
115
116/// Convert a completed low-level parse into the stable document contract.
117#[must_use]
118pub fn lower_mandoc_document(path: &Path, report: &ParseReport) -> Document {
119    let parsed: &MandocDocument = &report.document;
120    let mut context = LoweringContext::new(parsed.metadata.name.as_deref());
121    let mut diagnostics = diagnostics::lower_diagnostics(&report.diagnostics);
122    let mut sections = blocks::lower_sections(&parsed.root, &mut context);
123    let explicit_targets = navigation::explicit_targets(&parsed.root);
124    let mut retained_targets = explicit_targets.clone();
125    let mut root_blocks = Vec::new();
126    retained_targets.extend(crate::definitions::identify_definitions(
127        &mut root_blocks,
128        &mut sections,
129        &explicit_targets,
130    ));
131    navigation::resolve_navigation(&mut sections, &retained_targets, &mut diagnostics);
132    let mut document = Document {
133        parser: Some(ParserInfo {
134            name: "libmandoc".to_owned(),
135            version: libmandoc_rs::LIBMANDOC_VERSION.to_owned(),
136        }),
137        source: DocumentSource {
138            format: match parsed.macro_set {
139                MacroSet::Mdoc => SourceFormat::Mdoc,
140                MacroSet::Man | MacroSet::None => SourceFormat::Man,
141            },
142            path: Some(path.to_string_lossy().into_owned()),
143        },
144        meta: DocumentMeta {
145            title: normalize_metadata(parsed.metadata.title.as_deref()),
146            manual_section: normalize_metadata(parsed.metadata.section.as_deref()),
147            date: normalize_metadata(parsed.metadata.date.as_deref()),
148            volume: normalize_metadata(parsed.metadata.volume.as_deref()),
149            os: normalize_metadata(parsed.metadata.os.as_deref()),
150            arch: normalize_metadata(parsed.metadata.arch.as_deref()),
151            names: normalize_metadata(parsed.metadata.name.as_deref())
152                .into_iter()
153                .collect(),
154            alias_target: parsed.metadata.alias_target.clone(),
155        },
156        diagnostics,
157        blocks: root_blocks,
158        sections,
159    };
160    document.diagnostics.extend(validate_document(&document));
161    document
162}
163
164/// Metadata strings come from roff macro arguments rather than visible text
165/// nodes, so libmandoc can legitimately retain zero-width escapes such as
166/// `\&`. Normalize them through the same inline decoder used for document
167/// content before exposing the renderer-neutral contract.
168fn normalize_metadata(value: Option<&str>) -> Option<String> {
169    value.map(visible_text)
170}
171
172struct LoweringContext<'a> {
173    default_name: Option<&'a str>,
174    next_section_id: usize,
175}
176
177impl<'a> LoweringContext<'a> {
178    const fn new(default_name: Option<&'a str>) -> Self {
179        Self {
180            default_name,
181            next_section_id: 1,
182        }
183    }
184
185    fn section_id(&mut self, title: &str) -> String {
186        let sequence = self.next_section_id;
187        self.next_section_id += 1;
188        let slug: String = title
189            .chars()
190            .flat_map(char::to_lowercase)
191            .map(|character| {
192                if character.is_alphanumeric() {
193                    character
194                } else {
195                    '-'
196                }
197            })
198            .collect::<String>()
199            .split('-')
200            .filter(|part| !part.is_empty())
201            .collect::<Vec<_>>()
202            .join("-");
203        if slug.is_empty() {
204            format!("section-{sequence}")
205        } else {
206            format!("{slug}-{sequence}")
207        }
208    }
209}
210
211fn source_span(node: &Node) -> Option<SourceSpan> {
212    (node.line > 0).then_some(SourceSpan {
213        byte_range: None,
214        line: node.line,
215        column: node.column.max(1),
216        end_line: None,
217        end_column: None,
218    })
219}
220
221fn part_children(node: &Node, kind: libmandoc_rs::NodeKind) -> &[Node] {
222    node.children
223        .iter()
224        .find(|child| child.kind == kind)
225        .map_or(&[], |child| child.children.as_slice())
226}
227
228#[cfg(test)]
229mod tests {
230    use std::{fs, process};
231
232    use mant_ir::{Block, DiagnosticLevel, Inline, SourceFormat};
233
234    use super::{parse_manual_bytes, parse_manual_source};
235
236    fn temporary_source(label: &str, source: &str) -> std::path::PathBuf {
237        let path = std::env::temp_dir().join(format!("mant-lower-{label}-{}.1", process::id()));
238        fs::write(&path, source).expect("write temporary roff fixture");
239        path
240    }
241
242    #[test]
243    fn standalone_inputs_reject_redirect_only_so_pages() {
244        let error = parse_manual_bytes(std::path::Path::new("stdin"), b".so man1/target.1\n")
245            .expect_err("standalone input must not follow another file");
246        assert!(error.to_string().contains("require MANPATH discovery"));
247    }
248
249    #[test]
250    fn lowers_man_sections_fonts_definitions_and_literal_blocks() {
251        let path = temporary_source(
252            "man",
253            ".TH MANT 1 \"July 2026\"\n\
254             .SH NAME\n\
255             mant \\- a viewer\n\
256             .SH OPTIONS\n\
257             .TP\n\
258             \\fB\\-h\\fR\n\
259             Show help.\n\
260             .nf\n\
261             mant --help\n\
262             mant git\n\
263             .fi\n",
264        );
265
266        let document = parse_manual_source(&path).expect("lower man source");
267        fs::remove_file(path).expect("remove temporary roff fixture");
268
269        assert_eq!(document.source.format, SourceFormat::Man);
270        assert_eq!(
271            document
272                .sections
273                .iter()
274                .map(|section| section.title.as_str())
275                .collect::<Vec<_>>(),
276            vec!["NAME", "OPTIONS"]
277        );
278        assert!(
279            document.sections[1]
280                .blocks
281                .iter()
282                .any(|block| matches!(block, Block::DefinitionList { .. }))
283        );
284        assert!(document.sections[1].blocks.iter().any(|block| matches!(
285            block,
286            Block::DefinitionList { items, .. }
287                if items.iter().any(|item| item.description.iter().any(
288                    |description| matches!(description, Block::Preformatted { .. })
289                ))
290        )));
291    }
292
293    #[test]
294    fn separates_definition_layout_arguments_from_visible_terms() {
295        let path = temporary_source(
296            "definition-head-roles",
297            ".TH HEAD-ROLES 1\n\
298             .SH EXAMPLES\n\
299             .TP \\w'man\\ 'u\n\
300             .BI man \\ ls\n\
301             Display ls.\n\
302             .TP 4\n\
303             4\n\
304             A numeric term remains visible.\n\
305             .IP \"1\" 8n\n\
306             An IP width remains layout-only.\n",
307        );
308
309        let document = parse_manual_source(&path).expect("lower definition head roles");
310        fs::remove_file(path).expect("remove temporary roff fixture");
311
312        let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
313            panic!("expected one definition list");
314        };
315        assert_eq!(
316            items
317                .iter()
318                .flat_map(|item| item.terms.iter())
319                .map(|term| inline_text(term))
320                .collect::<Vec<_>>(),
321            ["man ls", "4", "1"]
322        );
323        assert!(matches!(
324            items[0].terms[0].as_slice(),
325            [Inline::Strong { .. }, Inline::Emphasis { .. }]
326        ));
327        assert!(
328            items
329                .iter()
330                .flat_map(|item| item.terms.iter())
331                .all(|term| !inline_text(term).contains("96u"))
332        );
333    }
334
335    #[test]
336    fn preserves_man_synopsis_flow_and_alternating_fonts() {
337        let path = temporary_source(
338            "man-synopsis-flow",
339            ".TH MAN 1\n\
340             .SH SYNOPSIS\n\
341             .B man\n\
342             .RI [\\| \"man options\" \\|]\n\
343             .RI [\\|[\\| section \\|]\n\
344             .IR page \\ \\|.\\|.\\|.\\|]\\ \\.\\|.\\|.\\&\n\
345             .br\n\
346             .B man\n\
347             .B \\-k\n\
348             .RI [\\| \"apropos options\" \\|]\n\
349             .I regexp\n\
350             \\&.\\|.\\|.\\&\n\
351             .br\n\
352             .B man\n\
353             .BR \\-w \\||\\| \\-W\n\
354             .RI [\\| \"man options\" \\|]\n\
355             .I page\n\
356             \\&.\\|.\\|.\\&\n",
357        );
358
359        let document = parse_manual_source(&path).expect("lower man synopsis");
360        fs::remove_file(path).expect("remove temporary roff fixture");
361
362        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
363            panic!("expected one synopsis paragraph");
364        };
365        assert_eq!(
366            inline_text(children),
367            "man [man options] [[section] page ...] ...\n\
368             man -k [apropos options] regexp ...\n\
369             man -w|-W [man options] page ..."
370        );
371        assert_eq!(
372            children
373                .iter()
374                .filter(|node| matches!(node, Inline::LineBreak))
375                .count(),
376            2
377        );
378        assert!(children.iter().any(
379            |node| matches!(node, Inline::Emphasis { children } if inline_text(children) == "man options")
380        ));
381        assert!(children.iter().any(
382            |node| matches!(node, Inline::Strong { children } if inline_text(children) == "-w")
383        ));
384        assert!(children.iter().any(
385            |node| matches!(node, Inline::Strong { children } if inline_text(children) == "-W")
386        ));
387    }
388
389    #[test]
390    fn distinguishes_filled_source_wrapping_from_indented_output_lines() {
391        let path = temporary_source(
392            "filled-line-boundaries",
393            concat!(
394                ".TH TOOL 1\n",
395                ".SH SYNOPSIS\n",
396                "tool [first]\n",
397                "    [second]\n",
398                "    [third]\n",
399                ".PP\n",
400                "Ordinary source wrapping\n",
401                "remains one filled paragraph.\n",
402            ),
403        );
404
405        let document = parse_manual_source(&path).expect("lower filled line boundaries");
406        fs::remove_file(path).expect("remove temporary roff fixture");
407
408        let [
409            Block::Paragraph {
410                children: synopsis, ..
411            },
412            Block::Paragraph {
413                children: prose, ..
414            },
415        ] = document.sections[0].blocks.as_slice()
416        else {
417            panic!("expected synopsis and prose paragraphs");
418        };
419        assert_eq!(
420            inline_text(synopsis),
421            "tool [first]\n    [second]\n    [third]"
422        );
423        assert_eq!(
424            synopsis
425                .iter()
426                .filter(|inline| matches!(inline, Inline::LineBreak))
427                .count(),
428            2
429        );
430        assert_eq!(
431            inline_text(prose),
432            "Ordinary source wrapping remains one filled paragraph."
433        );
434    }
435
436    #[test]
437    fn lets_explicit_fonts_override_an_alternating_macro_default() {
438        let path = temporary_source(
439            "alternating-font-reset",
440            ".TH MAN 1\n\
441             .SH OPTIONS\n\
442             .TP\n\
443             .BI \\-r\\  prompt \\fR,\\ \\fB\\-\\-prompt= prompt\n\
444             Set the pager prompt.\n",
445        );
446
447        let document = parse_manual_source(&path).expect("lower alternating font reset");
448        fs::remove_file(path).expect("remove temporary roff fixture");
449
450        let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
451            panic!("expected one definition list");
452        };
453        let term = items[0]
454            .terms
455            .first()
456            .expect("first definition term")
457            .iter()
458            .filter(|inline| !matches!(inline, Inline::Anchor { .. }))
459            .collect::<Vec<_>>();
460
461        assert_eq!(term.len(), 5);
462        assert!(matches!(term[0], Inline::Strong { children } if inline_text(children) == "-r "));
463        assert!(
464            matches!(term[1], Inline::Emphasis { children } if inline_text(children) == "prompt")
465        );
466        assert!(matches!(term[2], Inline::Text { value } if value == ", "));
467        assert!(
468            matches!(term[3], Inline::Strong { children } if inline_text(children) == "--prompt=")
469        );
470        assert!(
471            matches!(term[4], Inline::Emphasis { children } if inline_text(children) == "prompt")
472        );
473    }
474
475    #[test]
476    fn suppresses_pod_font_requests_around_verbatim_blocks() {
477        let path = temporary_source(
478            "pod-verbatim-fonts",
479            ".de Vb\n\
480             .ft CW\n\
481             .nf\n\
482             ..\n\
483             .de Ve\n\
484             .ft R\n\
485             .fi\n\
486             ..\n\
487             .TH POD 1\n\
488             .SH EXAMPLES\n\
489             .Vb 2\n\
490             \\&struct A { int a; };\n\
491             \\&struct B : A {};\n\
492             .Ve\n",
493        );
494
495        let document = parse_manual_source(&path).expect("lower Pod::Man verbatim source");
496        fs::remove_file(path).expect("remove temporary roff fixture");
497
498        assert_eq!(document.sections[0].blocks.len(), 1);
499        let Block::Preformatted { children, .. } = &document.sections[0].blocks[0] else {
500            panic!("expected one preformatted block");
501        };
502        assert_eq!(
503            inline_text(children),
504            "struct A { int a; };\nstruct B : A {};"
505        );
506    }
507
508    #[test]
509    fn lowers_indented_aliases_without_roff_layout_arguments() {
510        let path = temporary_source(
511            "indented-aliases",
512            ".TH CONTROL 1\n\
513             .SH OPTIONS\n\
514             .PD 0\n\
515             .IP \"\\fB-a\\fR\" 4\n\
516             .IP \"\\fB--all\\fR\" 4\n\
517             Show all entries.\n\
518             .PD\n\
519             .in 168u\n",
520        );
521
522        let document = parse_manual_source(&path).expect("lower indented aliases");
523        fs::remove_file(path).expect("remove temporary roff fixture");
524
525        let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
526            panic!("expected one definition list");
527        };
528        assert_eq!(items.len(), 1);
529        assert_eq!(
530            items[0]
531                .terms
532                .iter()
533                .map(|term| inline_text(term))
534                .collect::<Vec<_>>(),
535            ["-a", "--all"]
536        );
537        assert_eq!(items[0].description.len(), 1);
538        let Block::Paragraph { children, .. } = &items[0].description[0] else {
539            panic!("expected alias description paragraph");
540        };
541        assert_eq!(inline_text(children), "Show all entries.");
542    }
543
544    #[test]
545    fn preserves_man_paragraph_distance_between_indented_paragraphs() {
546        let path = temporary_source(
547            "paragraph-distance",
548            ".TH SPACING 1\n\
549             .SH OPTIONS\n\
550             .IP \"\\fB-a\\fR\" 4\n\
551             First.\n\
552             .IP \"\\fB-b\\fR\" 4\n\
553             Second.\n\
554             .PD 0\n\
555             .IP \"\\fB-c\\fR\" 4\n\
556             Third.\n\
557             .IP \"\\fB-d\\fR\" 4\n\
558             Fourth.\n\
559             .PD\n\
560             .IP \"\\fB-e\\fR\" 4\n\
561             Fifth.\n",
562        );
563
564        let document = parse_manual_source(&path).expect("lower paragraph distance");
565        fs::remove_file(path).expect("remove temporary roff fixture");
566
567        let [Block::DefinitionList { items, compact, .. }] = document.sections[0].blocks.as_slice()
568        else {
569            panic!("expected one definition list");
570        };
571        assert!(!compact);
572        assert_eq!(items.len(), 5);
573        assert_eq!(
574            items
575                .iter()
576                .map(|item| item.spacing_before_lines)
577                .collect::<Vec<_>>(),
578            [Some(0), Some(1), Some(0), Some(0), Some(1)]
579        );
580    }
581
582    #[test]
583    fn preserves_man_paragraph_and_heading_distance_as_one_layout_model() {
584        let path = temporary_source(
585            "vertical-layout",
586            ".TH SPACING 1\n\
587             .SH FIRST\n\
588             First paragraph.\n\
589             .PP\n\
590             Second paragraph.\n\
591             .SS CHILD\n\
592             Child body.\n\
593             .PD 0\n\
594             .SS COMPACT\n\
595             Compact child.\n\
596             .SH NEXT\n\
597             Next body.\n\
598             .PD\n\
599             .SH FINAL\n\
600             Final body.\n",
601        );
602
603        let document = parse_manual_source(&path).expect("lower vertical layout");
604        fs::remove_file(path).expect("remove temporary roff fixture");
605
606        let [first, next, final_section] = document.sections.as_slice() else {
607            panic!("expected three top-level sections");
608        };
609        assert_eq!(first.spacing_before_lines, 0);
610        let [Block::Paragraph { .. }, Block::Paragraph { layout, .. }] = first.blocks.as_slice()
611        else {
612            panic!("expected two semantic paragraphs");
613        };
614        assert_eq!(layout.spacing_before_lines, 1);
615
616        let [child, compact] = first.children.as_slice() else {
617            panic!("expected two subsections");
618        };
619        assert_eq!(child.spacing_before_lines, 1);
620        assert_eq!(compact.spacing_before_lines, 0);
621        assert_eq!(next.spacing_before_lines, 0);
622        assert_eq!(final_section.spacing_before_lines, 1);
623    }
624
625    #[test]
626    fn does_not_duplicate_explicit_space_before_a_transparent_indent() {
627        let path = temporary_source(
628            "explicit-space-before-indent",
629            ".TH SPACING 1\n\
630             .SH CONTENT\n\
631             Before.\n\
632             .sp\n\
633             .RS 4\n\
634             After.\n\
635             .RE\n",
636        );
637
638        let document = parse_manual_source(&path).expect("lower explicit indented spacing");
639        fs::remove_file(path).expect("remove temporary roff fixture");
640
641        let [
642            Block::Paragraph { .. },
643            Block::VerticalSpace { lines: 1, .. },
644            Block::Paragraph { layout, .. },
645        ] = document.sections[0].blocks.as_slice()
646        else {
647            panic!("expected prose, one explicit gap, and indented prose");
648        };
649        assert_eq!(layout.indent_columns, 4);
650        assert_eq!(
651            layout.spacing_before_lines, 0,
652            "the explicit gap must not be repeated as wrapper boundary spacing",
653        );
654    }
655
656    #[test]
657    fn preserves_mdoc_paragraph_and_heading_distance() {
658        let path = temporary_source(
659            "mdoc-vertical-layout",
660            ".Dd July 19, 2026\n\
661             .Dt SPACING 1\n\
662             .Os\n\
663             .Sh FIRST\n\
664             First paragraph.\n\
665             .Pp\n\
666             Second paragraph.\n\
667             .Ss CHILD\n\
668             Child body.\n",
669        );
670
671        let document = parse_manual_source(&path).expect("lower mdoc vertical layout");
672        fs::remove_file(path).expect("remove temporary roff fixture");
673
674        let [first] = document.sections.as_slice() else {
675            panic!("expected one top-level section");
676        };
677        assert_eq!(first.spacing_before_lines, 1);
678        assert!(matches!(
679            first.blocks.get(1),
680            Some(Block::VerticalSpace { lines: 1, .. })
681        ));
682        assert_eq!(first.children[0].spacing_before_lines, 1);
683    }
684
685    #[test]
686    fn lowers_mdoc_semantic_inline_nodes_and_nested_sections() {
687        let path = temporary_source(
688            "mdoc",
689            ".Dd July 19, 2026\n\
690             .Dt MANT 1\n\
691             .Os\n\
692             .Sh DESCRIPTION\n\
693             Use\n\
694             .Nm mant\n\
695             with\n\
696             .Xr man 1\n\
697             Read\n\
698             .Lk https://example.test/docs \"the documentation\"\n\
699             or contact\n\
700             .Mt docs@example.test\n\
701             .Ss Details\n\
702             .Fl h\n",
703        );
704
705        let document = parse_manual_source(&path).expect("lower mdoc source");
706        fs::remove_file(path).expect("remove temporary roff fixture");
707
708        assert_eq!(document.source.format, SourceFormat::Mdoc);
709        assert_eq!(document.sections[0].children[0].title, "Details");
710        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
711            panic!("expected description paragraph");
712        };
713        assert!(
714            children
715                .iter()
716                .any(|inline| matches!(inline, Inline::Strong { .. }))
717        );
718        assert!(
719            children.iter().any(
720                |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::Manual { name, .. }, .. } if name == "man")
721            )
722        );
723        assert!(children.iter().any(
724            |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::External { uri }, .. } if uri == "https://example.test/docs")
725        ));
726        assert!(children.iter().any(
727            |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::Email { address }, .. } if address == "docs@example.test")
728        ));
729    }
730
731    #[test]
732    fn lowers_documented_mdoc_delimiters_and_common_roff_characters() {
733        let path = temporary_source(
734            "mdoc-delimiters",
735            ".Dd July 19, 2026\n\
736             .Dt DELIMITERS 7\n\
737             .Os\n\
738             .Sh DESCRIPTION\n\
739             .Op optional\n\
740             .Bq bracket\n\
741             .Dq double\n\
742             .Sq single\n\
743             .Pq parenthesized\n\
744             .Brq braced\n\
745             .Aq angled\n\
746             .Oo multi Ar value\n\
747             .Oc\n\
748             .Sh CHARACTERS\n\
749             \\(en \\(em \\(aq \\(dq \\(co \\(rg \\(tm \\(bu \\(ha \\(ti \\(rs\n",
750        );
751
752        let document = parse_manual_source(&path).expect("lower delimiter and character source");
753        fs::remove_file(path).expect("remove temporary roff fixture");
754
755        let description = document.sections[0]
756            .blocks
757            .iter()
758            .map(|block| match block {
759                Block::Paragraph { children, .. } => inline_text(children),
760                _ => String::new(),
761            })
762            .collect::<Vec<_>>()
763            .join(" ");
764        for expected in [
765            "[optional]",
766            "[bracket]",
767            "“double”",
768            "‘single’",
769            "(parenthesized)",
770            "{braced}",
771            "<angled>",
772            "[multi value]",
773        ] {
774            assert!(
775                description.contains(expected),
776                "missing {expected:?} in {description:?}"
777            );
778        }
779
780        let [Block::Paragraph { children, .. }] = document.sections[1].blocks.as_slice() else {
781            panic!("expected one special-character paragraph");
782        };
783        assert_eq!(inline_text(children), "– — ' \" © ® ™ • ^ ~ \\");
784    }
785
786    #[test]
787    fn recognizes_explicitly_styled_traditional_man_references_in_any_section() {
788        let path = temporary_source(
789            "man-see-also",
790            ".TH TOOL 1\n\
791             .SH DESCRIPTION\n\
792             The styled reference \\fBprintf\\fP(3) is usable here.\n\
793             .SH SEE ALSO\n\
794             .BR printf (3),\n\
795             .BR man (1)\n",
796        );
797
798        let document = parse_manual_source(&path).expect("lower man references");
799        fs::remove_file(path).expect("remove temporary roff fixture");
800
801        let see_also = document
802            .sections
803            .iter()
804            .find(|section| section.title == "SEE ALSO")
805            .expect("SEE ALSO");
806        let Block::Paragraph { children, .. } = &see_also.blocks[0] else {
807            panic!("references are a paragraph");
808        };
809        assert!(children.iter().any(|inline| matches!(
810            inline,
811            Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
812                if name == "printf" && manual_section == "3"
813        )));
814        assert!(children.iter().any(|inline| matches!(
815            inline,
816            Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
817                if name == "man" && manual_section == "1"
818        )));
819
820        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
821            panic!("description is a paragraph");
822        };
823        assert!(children.iter().any(|inline| matches!(
824            inline,
825            Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
826                if name == "printf" && manual_section == "3"
827        )));
828    }
829
830    #[test]
831    fn lowers_modern_groff_manual_uri_and_mail_macros() {
832        let path = temporary_source(
833            "man-modern-links",
834            ".TH TOOL 1\n\
835             .SH DESCRIPTION\n\
836             .MR git-add 1 ,\n\
837             .UR https://example.test/docs\n\
838             Documentation\n\
839             .UE .\n\
840             .MT docs@example.test\n\
841             Mail us\n\
842             .ME .\n",
843        );
844
845        let document = parse_manual_source(&path).expect("lower modern man links");
846        fs::remove_file(path).expect("remove temporary roff fixture");
847        let section = &document.sections[0];
848        let mut manual = false;
849        let mut web = false;
850        let mut mail = false;
851        for children in section.blocks.iter().filter_map(|block| match block {
852            Block::Paragraph { children, .. } => Some(children),
853            _ => None,
854        }) {
855            for inline in children {
856                match inline {
857                    Inline::Link {
858                        target:
859                            mant_ir::LinkTarget::Manual {
860                                name,
861                                manual_section: Some(manual_section),
862                            },
863                        ..
864                    } if name == "git-add" && manual_section == "1" => manual = true,
865                    Inline::Link {
866                        target: mant_ir::LinkTarget::External { uri },
867                        ..
868                    } if uri == "https://example.test/docs" => {
869                        web = true;
870                    }
871                    Inline::Link {
872                        target: mant_ir::LinkTarget::Email { address },
873                        ..
874                    } if address == "docs@example.test" => {
875                        mail = true;
876                    }
877                    _ => {}
878                }
879            }
880        }
881
882        assert!(manual && web && mail);
883        assert!(section.blocks.iter().any(|block| match block {
884            Block::Paragraph { children, .. } => inline_text(children).contains("git-add(1),"),
885            _ => false,
886        }));
887        let linked_paragraphs = section
888            .blocks
889            .iter()
890            .filter_map(|block| match block {
891                Block::Paragraph { children, .. }
892                    if children.iter().any(|inline| {
893                        matches!(
894                            inline,
895                            Inline::Link {
896                                target: mant_ir::LinkTarget::External { .. },
897                                ..
898                            } | Inline::Link {
899                                target: mant_ir::LinkTarget::Email { .. },
900                                ..
901                            }
902                        )
903                    }) =>
904                {
905                    Some(inline_text(children))
906                }
907                _ => None,
908            })
909            .collect::<Vec<_>>();
910        assert_eq!(linked_paragraphs, ["Documentation.", "Mail us."]);
911    }
912
913    #[test]
914    fn resolves_mdoc_section_references_and_explicit_targets() {
915        let path = temporary_source(
916            "mdoc-navigation",
917            ".Dd July 19, 2026\n\
918             .Dt NAVIGATION 1\n\
919             .Os\n\
920             .Sh DESCRIPTION\n\
921             Continue with\n\
922             .Sx DETAILS\n\
923             .Tg explicit-option\n\
924             .Fl x\n\
925             .Sh DETAILS\n\
926             Target content.\n",
927        );
928
929        let document = parse_manual_source(&path).expect("lower navigation mdoc source");
930        fs::remove_file(path).expect("remove temporary roff fixture");
931
932        assert_eq!(document.sections[0].id, "description-1");
933        assert_eq!(document.sections[1].id, "details-2");
934        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
935            panic!("expected navigation paragraph");
936        };
937        assert!(children.iter().any(|inline| matches!(
938            inline,
939            Inline::Link {
940                target: mant_ir::LinkTarget::Section { id },
941                children,
942                ..
943            } if id == "details-2" && inline_text(children) == "DETAILS"
944        )));
945        assert!(children.iter().any(|inline| matches!(
946            inline,
947            Inline::Anchor { id } if id == "explicit-option"
948        )));
949    }
950
951    #[test]
952    fn degrades_unresolved_mdoc_section_references_to_text() {
953        let path = temporary_source(
954            "mdoc-missing-section",
955            ".Dd July 19, 2026\n.Dt NAVIGATION 1\n.Os\n.Sh DESCRIPTION\n.Sx MISSING\n",
956        );
957
958        let document = parse_manual_source(&path).expect("lower unresolved navigation source");
959        fs::remove_file(path).expect("remove temporary roff fixture");
960
961        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
962            panic!("expected reference paragraph");
963        };
964        assert_eq!(inline_text(children), "MISSING");
965        assert!(children.iter().all(|inline| !matches!(
966            inline,
967            Inline::Link {
968                target: mant_ir::LinkTarget::Section { .. },
969                ..
970            }
971        )));
972        assert!(document.diagnostics.iter().any(|diagnostic| {
973            diagnostic.code.as_deref() == Some("unresolved-section-reference")
974        }));
975    }
976
977    #[test]
978    fn turns_captured_parser_findings_into_structured_diagnostics() {
979        let path = temporary_source(
980            "unsupported",
981            ".Dd July 19, 2026\n.Dt BAD 1\n.Os\n.Sh NAME\n.Nm bad\n.ab\n",
982        );
983
984        let document = parse_manual_source(&path).expect("best-effort parse");
985        fs::remove_file(path).expect("remove temporary roff fixture");
986
987        assert!(
988            document
989                .diagnostics
990                .iter()
991                .any(|diagnostic| diagnostic.level == DiagnosticLevel::Unsupported)
992        );
993    }
994
995    #[test]
996    fn masks_terminal_controls_before_native_parsing() {
997        let path = temporary_source("controls", ".TH SAFE 1\n.SH NAME\nsafe \x1b[2J text\n");
998
999        let document = parse_manual_source(&path).expect("parse sanitized manual");
1000        fs::remove_file(path).expect("remove temporary roff fixture");
1001
1002        assert!(
1003            document.diagnostics.iter().any(|diagnostic| {
1004                diagnostic.code.as_deref() == Some("manual.control-characters")
1005            })
1006        );
1007    }
1008
1009    #[test]
1010    fn lowers_normalized_ordered_lists_and_literal_displays() {
1011        let path = temporary_source(
1012            "normalized",
1013            ".Dd July 19, 2026\n.Dt NORMALIZED 1\n.Os\n.Sh CONTENT\n\
1014             .Bl -enum -compact\n.It\nfirst\n.It\nsecond\n.El\n\
1015             .Bd -literal -offset 6n\nline one\nline two\n.Ed\n",
1016        );
1017
1018        let document = parse_manual_source(&path).expect("lower normalized mdoc");
1019        fs::remove_file(path).expect("remove temporary roff fixture");
1020
1021        assert!(matches!(
1022            document.sections[0].blocks[0],
1023            Block::List {
1024                kind: mant_ir::ListKind::Ordered,
1025                compact: true,
1026                ..
1027            }
1028        ));
1029        assert!(matches!(
1030            document.sections[0].blocks[1],
1031            Block::Preformatted { layout, .. } if layout.indent_columns == 6
1032        ));
1033    }
1034
1035    #[test]
1036    fn lowers_normalized_mdoc_font_and_author_layout() {
1037        let path = temporary_source(
1038            "normalized-mdoc-modes",
1039            ".Dd July 19, 2026\n\
1040             .Dt NORMALIZED-MODES 1\n\
1041             .Os\n\
1042             .Sh AUTHORS\n\
1043             .An -split\n\
1044             .An Alice Example\n\
1045             .An Bob Example\n\
1046             .An -nosplit\n\
1047             .An Carol Example\n\
1048             .An Dave Example\n\
1049             .Sh DESCRIPTION\n\
1050             .Bf -literal\n\
1051             literal text\n\
1052             .Ef\n",
1053        );
1054
1055        let document = parse_manual_source(&path).expect("lower normalized mdoc modes");
1056        fs::remove_file(path).expect("remove temporary roff fixture");
1057
1058        let authors = &document.sections[0];
1059        let Block::Paragraph { children, .. } = &authors.blocks[0] else {
1060            panic!("authors are one paragraph");
1061        };
1062        assert_eq!(
1063            inline_text(children),
1064            "Alice Example\nBob Example Carol Example Dave Example"
1065        );
1066
1067        let description = &document.sections[1];
1068        let Block::Paragraph { children, .. } = &description.blocks[0] else {
1069            panic!("font block is a paragraph");
1070        };
1071        assert!(matches!(
1072            children.as_slice(),
1073            [Inline::Code { value }] if value == "literal text"
1074        ));
1075    }
1076
1077    #[test]
1078    fn mdoc_definition_layout_uses_the_normalized_list_width() {
1079        let path = temporary_source(
1080            "mdoc-definition-widths",
1081            ".Dd July 23, 2026\n.Dt WIDTHS 1\n.Os\n.Sh ITEMS\n\
1082             .Bl -tag -width 20n\n.It tenletters\nwide description\n.El\n\
1083             .Bl -tag -width 3n\n.It short\nnarrow description\n.El\n",
1084        );
1085
1086        let document = parse_manual_source(&path).expect("lower mdoc definition widths");
1087        fs::remove_file(path).expect("remove temporary roff fixture");
1088
1089        let lists = document.sections[0]
1090            .blocks
1091            .iter()
1092            .filter_map(|block| match block {
1093                Block::DefinitionList { items, .. } => Some(items),
1094                _ => None,
1095            })
1096            .collect::<Vec<_>>();
1097        assert_eq!(lists.len(), 2);
1098        assert!(lists[0][0].inline_term);
1099        assert!(!lists[1][0].inline_term);
1100    }
1101
1102    #[test]
1103    fn lowers_the_pinned_large_mdoc_fixture_without_empty_sections() {
1104        let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1105            .join("../libmandoc-rs/vendor/mandoc-1.14.6/mandoc.1");
1106
1107        let document = parse_manual_source(&source).expect("lower vendored mandoc manual");
1108
1109        assert!(document.sections.len() > 5);
1110        assert!(
1111            document
1112                .sections
1113                .iter()
1114                .any(|section| section.title == "DESCRIPTION")
1115        );
1116        assert!(
1117            document
1118                .sections
1119                .iter()
1120                .all(|section| !section.blocks.is_empty() || !section.children.is_empty())
1121        );
1122    }
1123
1124    #[test]
1125    fn lowers_tbl_and_eqn_payloads_into_structured_blocks() {
1126        let path = temporary_source(
1127            "table-equation",
1128            ".TH PAYLOAD 1\n.SH TABLE\n.TS\ntab(|);\nl r.\nleft|right\n.TE\n\
1129             .SH EQUATION\n.EQ\nx sup 2\n.EN\n",
1130        );
1131
1132        let document = parse_manual_source(&path).expect("lower table and equation");
1133        fs::remove_file(path).expect("remove temporary roff fixture");
1134
1135        assert!(matches!(
1136            document.sections[0].blocks[0],
1137            Block::Table { ref rows, .. } if rows.len() == 1 && rows[0].cells.len() == 2
1138        ));
1139        assert!(matches!(
1140            document.sections[1].blocks[0],
1141            Block::Equation { ref value, .. } if value.contains('x')
1142        ));
1143    }
1144
1145    fn inline_text(children: &[Inline]) -> String {
1146        children
1147            .iter()
1148            .map(|child| match child {
1149                Inline::Text { value } | Inline::Code { value } => value.clone(),
1150                Inline::Strong { children }
1151                | Inline::Emphasis { children }
1152                | Inline::Link { children, .. } => inline_text(children),
1153                Inline::Anchor { .. } => String::new(),
1154                Inline::LineBreak => "\n".to_owned(),
1155            })
1156            .collect()
1157    }
1158}