1mod 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
32pub 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
46pub 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#[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
142fn 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}