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 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
33pub 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
48pub 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
71pub 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#[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
164fn 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}