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