1mod blocks;
4mod diagnostics;
5mod error;
6pub(crate) mod inline;
7mod layout;
8mod navigation;
9mod reference;
10mod roff_escape;
11mod source;
12
13use std::{cell::RefCell, path::Path};
14
15use libmandoc_rs::{
16 Compression, Document as MandocDocument, IncludePolicy, MacroSet, Node, ParseOptions,
17 ParseReport, Parser,
18};
19use mant_ir::{
20 Diagnostic, DiagnosticLevel, Document, DocumentMeta, DocumentSource, ParserInfo, SourceFormat,
21 SourceSpan, validate_document,
22};
23
24use self::{
25 roff_escape::visible_text,
26 source::{load_manual_source, redirect_target, resolve_manual_redirects},
27};
28use crate::ManualPage;
29use crate::text_safety::mask_terminal_control_bytes;
30
31pub use error::{ManualError, ManualErrorKind};
32pub use source::MAX_MANUAL_BYTES;
33
34pub fn parse_manual_source(path: &Path) -> Result<Document, ManualError> {
44 let loaded = load_manual_source(path)?;
45 reject_standalone_redirect(path, &loaded.source)?;
46 parse_plain_manual(path, &loaded.source, None)
47}
48
49pub fn parse_manual_bytes(path: &Path, source: &[u8]) -> Result<Document, ManualError> {
58 reject_standalone_redirect(path, source)?;
59 parse_plain_manual(path, source, None)
60}
61
62fn reject_standalone_redirect(path: &Path, source: &[u8]) -> Result<(), ManualError> {
63 if redirect_target(path, source)?.is_some() {
64 return Err(ManualError::redirect(
65 path,
66 "standalone .so redirects require MANPATH discovery and cannot be followed by --input",
67 ));
68 }
69 Ok(())
70}
71
72pub fn parse_manual_page(page: &ManualPage) -> Result<Document, ManualError> {
79 let resolved = resolve_manual_redirects(page)?;
80 parse_plain_manual(
81 &page.path,
82 &resolved.source,
83 resolved.alias_target.as_deref(),
84 )
85}
86
87fn parse_plain_manual(
88 path: &Path,
89 source: &[u8],
90 alias_target: Option<&str>,
91) -> Result<Document, ManualError> {
92 let (source, masked_controls) = mask_terminal_control_bytes(source);
93 let report = Parser::new(ParseOptions {
94 includes: IncludePolicy::Deny,
95 compression: Compression::Plain,
96 })
97 .parse_bytes(path, source.as_ref())
98 .map_err(ManualError::from)?;
99 let mut document = lower_mandoc_document(path, &report);
100 if masked_controls > 0 {
101 document.diagnostics.insert(
102 0,
103 Diagnostic {
104 level: DiagnosticLevel::Warning,
105 code: Some("manual.control-characters".to_owned()),
106 message: format!("masked {masked_controls} terminal-unsafe control character(s)"),
107 source: None,
108 },
109 );
110 }
111 if let Some(alias_target) = alias_target {
112 document.meta.alias_target = Some(alias_target.to_owned());
113 }
114 Ok(document)
115}
116
117#[must_use]
119pub fn lower_mandoc_document(path: &Path, report: &ParseReport) -> Document {
120 let parsed: &MandocDocument = &report.document;
121 let mut context = LoweringContext::new(parsed.metadata.name.as_deref());
122 let mut diagnostics = diagnostics::lower_diagnostics(&report.diagnostics);
123 let mut sections = blocks::lower_sections(&parsed.root, &mut context);
124 diagnostics.extend(context.take_diagnostics());
125 let explicit_targets = navigation::explicit_targets(&parsed.root);
126 let mut retained_targets = explicit_targets.clone();
127 let mut root_blocks = Vec::new();
128 retained_targets.extend(crate::definitions::identify_definitions(
129 &mut root_blocks,
130 &mut sections,
131 &explicit_targets,
132 ));
133 navigation::resolve_navigation(&mut sections, &retained_targets, &mut diagnostics);
134 let mut document = Document {
135 parser: Some(ParserInfo {
136 name: "libmandoc".to_owned(),
137 version: libmandoc_rs::LIBMANDOC_VERSION.to_owned(),
138 }),
139 source: DocumentSource {
140 format: match parsed.macro_set {
141 MacroSet::Mdoc => SourceFormat::Mdoc,
142 MacroSet::Man | MacroSet::None => SourceFormat::Man,
143 },
144 path: Some(path.to_string_lossy().into_owned()),
145 },
146 meta: DocumentMeta {
147 title: normalize_metadata(parsed.metadata.title.as_deref()),
148 manual_section: normalize_metadata(parsed.metadata.section.as_deref()),
149 date: normalize_metadata(parsed.metadata.date.as_deref()),
150 volume: normalize_metadata(parsed.metadata.volume.as_deref()),
151 os: normalize_metadata(parsed.metadata.os.as_deref()),
152 arch: normalize_metadata(parsed.metadata.arch.as_deref()),
153 names: normalize_metadata(parsed.metadata.name.as_deref())
154 .into_iter()
155 .collect(),
156 alias_target: parsed.metadata.alias_target.clone(),
157 },
158 diagnostics,
159 blocks: root_blocks,
160 sections,
161 };
162 document.diagnostics.extend(validate_document(&document));
163 document
164}
165
166fn normalize_metadata(value: Option<&str>) -> Option<String> {
171 value.map(visible_text)
172}
173
174struct LoweringContext<'a> {
175 default_name: Option<&'a str>,
176 next_section_id: usize,
177 diagnostics: RefCell<Vec<Diagnostic>>,
178}
179
180impl<'a> LoweringContext<'a> {
181 const fn new(default_name: Option<&'a str>) -> Self {
182 Self {
183 default_name,
184 next_section_id: 1,
185 diagnostics: RefCell::new(Vec::new()),
186 }
187 }
188
189 fn section_id(&mut self, title: &str) -> String {
190 let sequence = self.next_section_id;
191 self.next_section_id += 1;
192 let slug: String = title
193 .chars()
194 .flat_map(char::to_lowercase)
195 .map(|character| {
196 if character.is_alphanumeric() {
197 character
198 } else {
199 '-'
200 }
201 })
202 .collect::<String>()
203 .split('-')
204 .filter(|part| !part.is_empty())
205 .collect::<Vec<_>>()
206 .join("-");
207 if slug.is_empty() {
208 format!("section-{sequence}")
209 } else {
210 format!("{slug}-{sequence}")
211 }
212 }
213
214 fn warn_unhandled_structural_parts(&self, node: &Node) {
215 let macro_name = node.macro_name.as_deref().unwrap_or("unknown");
216 self.diagnostics.borrow_mut().push(Diagnostic {
217 level: DiagnosticLevel::Warning,
218 code: Some("manual.unhandled-structural-parts".to_owned()),
219 message: format!(
220 "structural macro '{macro_name}' contains visible head or tail content without a lowering policy"
221 ),
222 source: source_span(node),
223 });
224 }
225
226 fn take_diagnostics(&self) -> Vec<Diagnostic> {
227 self.diagnostics.take()
228 }
229}
230
231fn source_span(node: &Node) -> Option<SourceSpan> {
232 (node.line > 0).then_some(SourceSpan {
233 byte_range: None,
234 line: node.line,
235 column: node.column.max(1),
236 end_line: None,
237 end_column: None,
238 })
239}
240
241fn part_children(node: &Node, kind: libmandoc_rs::NodeKind) -> &[Node] {
242 node.children
243 .iter()
244 .find(|child| child.kind == kind)
245 .map_or(&[], |child| child.children.as_slice())
246}
247
248#[cfg(test)]
249mod tests {
250 use std::{fs, process};
251
252 use mant_ir::{Block, DiagnosticLevel, Inline, SourceFormat};
253
254 use super::{Parser, lower_mandoc_document, parse_manual_bytes, parse_manual_source};
255
256 fn temporary_source(label: &str, source: &str) -> std::path::PathBuf {
257 let path = std::env::temp_dir().join(format!("mant-lower-{label}-{}.1", process::id()));
258 fs::write(&path, source).expect("write temporary roff fixture");
259 path
260 }
261
262 fn find_macro_mut<'a>(
263 node: &'a mut libmandoc_rs::Node,
264 name: &str,
265 ) -> Option<&'a mut libmandoc_rs::Node> {
266 if node.macro_name.as_deref() == Some(name) {
267 return Some(node);
268 }
269 node.children
270 .iter_mut()
271 .find_map(|child| find_macro_mut(child, name))
272 }
273
274 #[test]
275 fn standalone_inputs_reject_redirect_only_so_pages() {
276 let error = parse_manual_bytes(std::path::Path::new("stdin"), b".so man1/target.1\n")
277 .expect_err("standalone input must not follow another file");
278 assert!(error.to_string().contains("require MANPATH discovery"));
279 }
280
281 #[test]
282 fn lowers_man_sections_fonts_definitions_and_literal_blocks() {
283 let path = temporary_source(
284 "man",
285 ".TH MANT 1 \"July 2026\"\n\
286 .SH NAME\n\
287 mant \\- a viewer\n\
288 .SH OPTIONS\n\
289 .TP\n\
290 \\fB\\-h\\fR\n\
291 Show help.\n\
292 .nf\n\
293 mant --help\n\
294 mant git\n\
295 .fi\n",
296 );
297
298 let document = parse_manual_source(&path).expect("lower man source");
299 fs::remove_file(path).expect("remove temporary roff fixture");
300
301 assert_eq!(document.source.format, SourceFormat::Man);
302 assert_eq!(
303 document
304 .sections
305 .iter()
306 .map(|section| section.title.as_str())
307 .collect::<Vec<_>>(),
308 vec!["NAME", "OPTIONS"]
309 );
310 assert!(
311 document.sections[1]
312 .blocks
313 .iter()
314 .any(|block| matches!(block, Block::DefinitionList { .. }))
315 );
316 assert!(document.sections[1].blocks.iter().any(|block| matches!(
317 block,
318 Block::DefinitionList { items, .. }
319 if items.iter().any(|item| item.description.iter().any(
320 |description| matches!(description, Block::Preformatted { .. })
321 ))
322 )));
323 }
324
325 #[test]
326 fn separates_definition_layout_arguments_from_visible_terms() {
327 let path = temporary_source(
328 "definition-head-roles",
329 ".TH HEAD-ROLES 1\n\
330 .SH EXAMPLES\n\
331 .TP \\w'man\\ 'u\n\
332 .BI man \\ ls\n\
333 Display ls.\n\
334 .TP 4\n\
335 4\n\
336 A numeric term remains visible.\n\
337 .IP \"1\" 8n\n\
338 An IP width remains layout-only.\n",
339 );
340
341 let document = parse_manual_source(&path).expect("lower definition head roles");
342 fs::remove_file(path).expect("remove temporary roff fixture");
343
344 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
345 panic!("expected one definition list");
346 };
347 assert_eq!(
348 items
349 .iter()
350 .flat_map(|item| item.terms.iter())
351 .map(|term| inline_text(term))
352 .collect::<Vec<_>>(),
353 ["man ls", "4", "1"]
354 );
355 assert!(matches!(
356 items[0].terms[0].as_slice(),
357 [Inline::Strong { .. }, Inline::Emphasis { .. }]
358 ));
359 assert!(
360 items
361 .iter()
362 .flat_map(|item| item.terms.iter())
363 .all(|term| !inline_text(term).contains("96u"))
364 );
365 }
366
367 #[test]
368 fn preserves_man_synopsis_flow_and_alternating_fonts() {
369 let path = temporary_source(
370 "man-synopsis-flow",
371 ".TH MAN 1\n\
372 .SH SYNOPSIS\n\
373 .B man\n\
374 .RI [\\| \"man options\" \\|]\n\
375 .RI [\\|[\\| section \\|]\n\
376 .IR page \\ \\|.\\|.\\|.\\|]\\ \\.\\|.\\|.\\&\n\
377 .br\n\
378 .B man\n\
379 .B \\-k\n\
380 .RI [\\| \"apropos options\" \\|]\n\
381 .I regexp\n\
382 \\&.\\|.\\|.\\&\n\
383 .br\n\
384 .B man\n\
385 .BR \\-w \\||\\| \\-W\n\
386 .RI [\\| \"man options\" \\|]\n\
387 .I page\n\
388 \\&.\\|.\\|.\\&\n",
389 );
390
391 let document = parse_manual_source(&path).expect("lower man synopsis");
392 fs::remove_file(path).expect("remove temporary roff fixture");
393
394 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
395 panic!("expected one synopsis paragraph");
396 };
397 assert_eq!(
398 inline_text(children),
399 "man [man options] [[section] page ...] ...\n\
400 man -k [apropos options] regexp ...\n\
401 man -w|-W [man options] page ..."
402 );
403 assert_eq!(
404 children
405 .iter()
406 .filter(|node| matches!(node, Inline::LineBreak))
407 .count(),
408 2
409 );
410 assert!(children.iter().any(
411 |node| matches!(node, Inline::Emphasis { children } if inline_text(children) == "man options")
412 ));
413 assert!(children.iter().any(
414 |node| matches!(node, Inline::Strong { children } if inline_text(children) == "-w")
415 ));
416 assert!(children.iter().any(
417 |node| matches!(node, Inline::Strong { children } if inline_text(children) == "-W")
418 ));
419 }
420
421 #[test]
422 fn preserves_man_sy_heads_with_body_content_and_inline_fonts() {
423 let document = parse_manual_bytes(
424 std::path::Path::new("sy-heads.1"),
425 b".TH SY-HEADS 1 \"August 17, 2026\"\n\
426.SH SYNOPSIS\n\
427.SY getent\n\
428.RI [ option ]\n\
429.I database\n\
430.YS\n\
431.SH DESCRIPTION\n\
432.SY #!\\f[I]interpreter\\f[]\n\
433.RI [ optional-arg ]\n\
434.YS\n",
435 )
436 .expect("lower SY heads");
437
438 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
439 panic!("expected one synopsis paragraph");
440 };
441 assert_eq!(inline_text(children), "getent [option] database");
442 assert!(matches!(
443 children.first(),
444 Some(Inline::Strong { children }) if inline_text(children) == "getent"
445 ));
446
447 let [Block::Paragraph { children, .. }] = document.sections[1].blocks.as_slice() else {
448 panic!("expected one description paragraph");
449 };
450 assert_eq!(inline_text(children), "#!interpreter [optional-arg]");
451 assert!(matches!(
452 children.first(),
453 Some(Inline::Strong { children })
454 if children.iter().any(|inline| matches!(
455 inline,
456 Inline::Emphasis { children } if inline_text(children) == "interpreter"
457 ))
458 ));
459 assert!(
460 document.diagnostics.is_empty(),
461 "{:?}",
462 document.diagnostics
463 );
464 }
465
466 #[test]
467 fn keeps_man_synopsis_lines_together_inside_no_fill_examples() {
468 let document = parse_manual_bytes(
469 std::path::Path::new("no-fill-synopsis.2"),
470 b".TH NO-FILL-SYNOPSIS 2\n\
471.SH DESCRIPTION\n\
472.EX\n\
473.SY #!\\f[I]interpreter\\f[]\n\
474.RI [ optional-arg ]\n\
475.YS\n\
476.EE\n",
477 )
478 .expect("lower synopsis inside example");
479
480 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
481 panic!(
482 "no-fill synopsis must remain one preformatted block: {:?}",
483 document.sections[0].blocks
484 );
485 };
486 assert_eq!(inline_text(children), "#!interpreter\n[optional-arg]");
487 assert_eq!(
488 children
489 .iter()
490 .filter(|inline| matches!(inline, Inline::LineBreak))
491 .count(),
492 1
493 );
494 }
495
496 #[test]
497 fn distinguishes_filled_source_wrapping_from_indented_output_lines() {
498 let path = temporary_source(
499 "filled-line-boundaries",
500 concat!(
501 ".TH TOOL 1\n",
502 ".SH SYNOPSIS\n",
503 "tool [first]\n",
504 " [second]\n",
505 " [third]\n",
506 ".PP\n",
507 "Ordinary source wrapping\n",
508 "remains one filled paragraph.\n",
509 ),
510 );
511
512 let document = parse_manual_source(&path).expect("lower filled line boundaries");
513 fs::remove_file(path).expect("remove temporary roff fixture");
514
515 let [
516 Block::Paragraph {
517 children: synopsis, ..
518 },
519 Block::Paragraph {
520 children: prose, ..
521 },
522 ] = document.sections[0].blocks.as_slice()
523 else {
524 panic!("expected synopsis and prose paragraphs");
525 };
526 assert_eq!(
527 inline_text(synopsis),
528 "tool [first]\n [second]\n [third]"
529 );
530 assert_eq!(
531 synopsis
532 .iter()
533 .filter(|inline| matches!(inline, Inline::LineBreak))
534 .count(),
535 2
536 );
537 assert_eq!(
538 inline_text(prose),
539 "Ordinary source wrapping remains one filled paragraph."
540 );
541 }
542
543 #[test]
544 fn honours_roff_no_space_line_continuations() {
545 let document = parse_manual_bytes(
546 std::path::Path::new("line-continuation.1"),
547 b".TH LINE-CONTINUATION 1\n\
548.SH DESCRIPTION\n\
549extsize=\\c\n\
550nnnn; multi-\\c\n\
551block; (\\c\n\
552.BR read (2)\n\
553.EX\n\
554literal-\\c\n\
555continuation\n\
556.EE\n",
557 )
558 .expect("lower no-space line continuations");
559
560 let [
561 Block::Paragraph {
562 children: prose, ..
563 },
564 Block::Preformatted {
565 children: literal, ..
566 },
567 ] = document.sections[0].blocks.as_slice()
568 else {
569 panic!(
570 "expected one filled and one no-fill block: {:?}",
571 document.sections[0].blocks
572 );
573 };
574 assert_eq!(inline_text(prose), "extsize=nnnn; multi-block; (read(2)");
575 assert_eq!(inline_text(literal), "literal-continuation");
576 }
577
578 #[test]
579 fn keeps_explicit_horizontal_separation_at_a_tight_line_join() {
580 let document = parse_manual_bytes(
581 std::path::Path::new("motion-continuation.1"),
582 b".TH MOTION-CONTINUATION 1\n\
583.SH DESCRIPTION\n\
584\\h'-04' 1.\\h'+01'\\c\n\
585The next line.\n",
586 )
587 .expect("lower a horizontally spaced continued line");
588
589 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
590 panic!("expected one paragraph: {:?}", document.sections[0].blocks);
591 };
592 assert_eq!(inline_text(children), " 1. The next line.");
593 }
594
595 #[test]
596 fn lets_explicit_fonts_override_an_alternating_macro_default() {
597 let path = temporary_source(
598 "alternating-font-reset",
599 ".TH MAN 1\n\
600 .SH OPTIONS\n\
601 .TP\n\
602 .BI \\-r\\ prompt \\fR,\\ \\fB\\-\\-prompt= prompt\n\
603 Set the pager prompt.\n",
604 );
605
606 let document = parse_manual_source(&path).expect("lower alternating font reset");
607 fs::remove_file(path).expect("remove temporary roff fixture");
608
609 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
610 panic!("expected one definition list");
611 };
612 let term = items[0]
613 .terms
614 .first()
615 .expect("first definition term")
616 .iter()
617 .filter(|inline| !matches!(inline, Inline::Anchor { .. }))
618 .collect::<Vec<_>>();
619
620 assert_eq!(term.len(), 5);
621 assert!(matches!(term[0], Inline::Strong { children } if inline_text(children) == "-r "));
622 assert!(
623 matches!(term[1], Inline::Emphasis { children } if inline_text(children) == "prompt")
624 );
625 assert!(matches!(term[2], Inline::Text { value } if value == ", "));
626 assert!(
627 matches!(term[3], Inline::Strong { children } if inline_text(children) == "--prompt=")
628 );
629 assert!(
630 matches!(term[4], Inline::Emphasis { children } if inline_text(children) == "prompt")
631 );
632 }
633
634 #[test]
635 fn suppresses_pod_font_requests_around_verbatim_blocks() {
636 let path = temporary_source(
637 "pod-verbatim-fonts",
638 ".de Vb\n\
639 .ft CW\n\
640 .nf\n\
641 ..\n\
642 .de Ve\n\
643 .ft R\n\
644 .fi\n\
645 ..\n\
646 .TH POD 1\n\
647 .SH EXAMPLES\n\
648 .Vb 2\n\
649 \\&struct A { int a; };\n\
650 \\&struct B : A {};\n\
651 .Ve\n",
652 );
653
654 let document = parse_manual_source(&path).expect("lower Pod::Man verbatim source");
655 fs::remove_file(path).expect("remove temporary roff fixture");
656
657 assert_eq!(document.sections[0].blocks.len(), 1);
658 let Block::Preformatted { children, .. } = &document.sections[0].blocks[0] else {
659 panic!("expected one preformatted block");
660 };
661 assert_eq!(
662 inline_text(children),
663 "struct A { int a; };\nstruct B : A {};"
664 );
665 }
666
667 #[test]
668 fn lowers_indented_aliases_without_roff_layout_arguments() {
669 let path = temporary_source(
670 "indented-aliases",
671 ".TH CONTROL 1\n\
672 .SH OPTIONS\n\
673 .PD 0\n\
674 .IP \"\\fB-a\\fR\" 4\n\
675 .IP \"\\fB--all\\fR\" 4\n\
676 Show all entries.\n\
677 .PD\n\
678 .in 168u\n",
679 );
680
681 let document = parse_manual_source(&path).expect("lower indented aliases");
682 fs::remove_file(path).expect("remove temporary roff fixture");
683
684 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
685 panic!("expected one definition list");
686 };
687 assert_eq!(items.len(), 1);
688 assert_eq!(
689 items[0]
690 .terms
691 .iter()
692 .map(|term| inline_text(term))
693 .collect::<Vec<_>>(),
694 ["-a", "--all"]
695 );
696 assert_eq!(items[0].description.len(), 1);
697 let Block::Paragraph { children, .. } = &items[0].description[0] else {
698 panic!("expected alias description paragraph");
699 };
700 assert_eq!(inline_text(children), "Show all entries.");
701 }
702
703 #[test]
704 fn preserves_man_paragraph_distance_between_indented_paragraphs() {
705 let path = temporary_source(
706 "paragraph-distance",
707 ".TH SPACING 1\n\
708 .SH OPTIONS\n\
709 .IP \"\\fB-a\\fR\" 4\n\
710 First.\n\
711 .IP \"\\fB-b\\fR\" 4\n\
712 Second.\n\
713 .PD 0\n\
714 .IP \"\\fB-c\\fR\" 4\n\
715 Third.\n\
716 .IP \"\\fB-d\\fR\" 4\n\
717 Fourth.\n\
718 .PD\n\
719 .IP \"\\fB-e\\fR\" 4\n\
720 Fifth.\n",
721 );
722
723 let document = parse_manual_source(&path).expect("lower paragraph distance");
724 fs::remove_file(path).expect("remove temporary roff fixture");
725
726 let [Block::DefinitionList { items, compact, .. }] = document.sections[0].blocks.as_slice()
727 else {
728 panic!("expected one definition list");
729 };
730 assert!(!compact);
731 assert_eq!(items.len(), 5);
732 assert_eq!(
733 items
734 .iter()
735 .map(|item| item.spacing_before_lines)
736 .collect::<Vec<_>>(),
737 [Some(0), Some(1), Some(0), Some(0), Some(1)]
738 );
739 }
740
741 #[test]
742 fn preserves_man_paragraph_and_heading_distance_as_one_layout_model() {
743 let path = temporary_source(
744 "vertical-layout",
745 ".TH SPACING 1\n\
746 .SH FIRST\n\
747 First paragraph.\n\
748 .PP\n\
749 Second paragraph.\n\
750 .SS CHILD\n\
751 Child body.\n\
752 .PD 0\n\
753 .SS COMPACT\n\
754 Compact child.\n\
755 .SH NEXT\n\
756 Next body.\n\
757 .PD\n\
758 .SH FINAL\n\
759 Final body.\n",
760 );
761
762 let document = parse_manual_source(&path).expect("lower vertical layout");
763 fs::remove_file(path).expect("remove temporary roff fixture");
764
765 let [first, next, final_section] = document.sections.as_slice() else {
766 panic!("expected three top-level sections");
767 };
768 assert_eq!(first.spacing_before_lines, 0);
769 let [Block::Paragraph { .. }, Block::Paragraph { layout, .. }] = first.blocks.as_slice()
770 else {
771 panic!("expected two semantic paragraphs");
772 };
773 assert_eq!(layout.spacing_before_lines, 1);
774
775 let [child, compact] = first.children.as_slice() else {
776 panic!("expected two subsections");
777 };
778 assert_eq!(child.spacing_before_lines, 1);
779 assert_eq!(compact.spacing_before_lines, 0);
780 assert_eq!(next.spacing_before_lines, 0);
781 assert_eq!(final_section.spacing_before_lines, 1);
782 }
783
784 #[test]
785 fn does_not_duplicate_explicit_space_before_a_transparent_indent() {
786 let path = temporary_source(
787 "explicit-space-before-indent",
788 ".TH SPACING 1\n\
789 .SH CONTENT\n\
790 Before.\n\
791 .sp\n\
792 .RS 4\n\
793 After.\n\
794 .RE\n",
795 );
796
797 let document = parse_manual_source(&path).expect("lower explicit indented spacing");
798 fs::remove_file(path).expect("remove temporary roff fixture");
799
800 let [
801 Block::Paragraph { .. },
802 Block::VerticalSpace { lines: 1, .. },
803 Block::Paragraph { layout, .. },
804 ] = document.sections[0].blocks.as_slice()
805 else {
806 panic!("expected prose, one explicit gap, and indented prose");
807 };
808 assert_eq!(layout.indent_columns, 4);
809 assert_eq!(
810 layout.spacing_before_lines, 0,
811 "the explicit gap must not be repeated as wrapper boundary spacing",
812 );
813 }
814
815 #[test]
816 fn preserves_mdoc_paragraph_and_heading_distance() {
817 let path = temporary_source(
818 "mdoc-vertical-layout",
819 ".Dd July 19, 2026\n\
820 .Dt SPACING 1\n\
821 .Os\n\
822 .Sh FIRST\n\
823 First paragraph.\n\
824 .Pp\n\
825 Second paragraph.\n\
826 .Ss CHILD\n\
827 Child body.\n",
828 );
829
830 let document = parse_manual_source(&path).expect("lower mdoc vertical layout");
831 fs::remove_file(path).expect("remove temporary roff fixture");
832
833 let [first] = document.sections.as_slice() else {
834 panic!("expected one top-level section");
835 };
836 assert_eq!(first.spacing_before_lines, 1);
837 assert!(matches!(
838 first.blocks.get(1),
839 Some(Block::VerticalSpace { lines: 1, .. })
840 ));
841 assert_eq!(first.children[0].spacing_before_lines, 1);
842 }
843
844 #[test]
845 fn lowers_mdoc_semantic_inline_nodes_and_nested_sections() {
846 let path = temporary_source(
847 "mdoc",
848 ".Dd July 19, 2026\n\
849 .Dt MANT 1\n\
850 .Os\n\
851 .Sh DESCRIPTION\n\
852 Use\n\
853 .Nm mant\n\
854 with\n\
855 .Xr man 1\n\
856 Read\n\
857 .Lk https://example.test/docs \"the documentation\"\n\
858 or contact\n\
859 .Mt docs@example.test\n\
860 .Ss Details\n\
861 .Fl h\n",
862 );
863
864 let document = parse_manual_source(&path).expect("lower mdoc source");
865 fs::remove_file(path).expect("remove temporary roff fixture");
866
867 assert_eq!(document.source.format, SourceFormat::Mdoc);
868 assert_eq!(document.sections[0].children[0].title, "Details");
869 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
870 panic!("expected description paragraph");
871 };
872 assert!(
873 children
874 .iter()
875 .any(|inline| matches!(inline, Inline::Strong { .. }))
876 );
877 assert!(
878 children.iter().any(
879 |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::Manual { name, .. }, .. } if name == "man")
880 )
881 );
882 assert!(children.iter().any(
883 |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::External { uri }, .. } if uri == "https://example.test/docs")
884 ));
885 assert!(children.iter().any(
886 |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::Email { address }, .. } if address == "docs@example.test")
887 ));
888 }
889
890 #[test]
891 fn lowers_documented_mdoc_delimiters_and_common_roff_characters() {
892 let path = temporary_source(
893 "mdoc-delimiters",
894 ".Dd July 19, 2026\n\
895 .Dt DELIMITERS 7\n\
896 .Os\n\
897 .Sh DESCRIPTION\n\
898 .Op optional\n\
899 .Bq bracket\n\
900 .Dq double\n\
901 .Sq single\n\
902 .Pq parenthesized\n\
903 .Brq braced\n\
904 .Aq angled\n\
905 .Oo multi Ar value\n\
906 .Oc\n\
907 .Sh CHARACTERS\n\
908 \\(en \\(em \\(aq \\(dq \\(co \\(rg \\(tm \\(bu \\(ha \\(ti \\(rs\n",
909 );
910
911 let document = parse_manual_source(&path).expect("lower delimiter and character source");
912 fs::remove_file(path).expect("remove temporary roff fixture");
913
914 let description = document.sections[0]
915 .blocks
916 .iter()
917 .map(|block| match block {
918 Block::Paragraph { children, .. } => inline_text(children),
919 _ => String::new(),
920 })
921 .collect::<Vec<_>>()
922 .join(" ");
923 for expected in [
924 "[optional]",
925 "[bracket]",
926 "“double”",
927 "‘single’",
928 "(parenthesized)",
929 "{braced}",
930 "<angled>",
931 "[multi value]",
932 ] {
933 assert!(
934 description.contains(expected),
935 "missing {expected:?} in {description:?}"
936 );
937 }
938
939 let [Block::Paragraph { children, .. }] = document.sections[1].blocks.as_slice() else {
940 panic!("expected one special-character paragraph");
941 };
942 assert_eq!(inline_text(children), "– — ' \" © ® ™ • ^ ~ \\");
943 }
944
945 #[test]
946 fn lowers_the_pinned_named_character_catalog_without_silent_deletion() {
947 let document = parse_manual_bytes(
948 std::path::Path::new("named-characters.7"),
949 b".TH NAMED-CHARACTERS 7\n\
950.SH TEST\n\
951at=\\(at ga=\\(ga oq=\\(oq arrow=\\(-> larrow=\\(<- mu=\\(mu\n\
952de=\\(de pl=\\(pl dg=\\(dg ua=\\(ua da=\\(da lB=\\(lB rB=\\(rB\n\
953unknown=\\[future-glyph]\n",
954 )
955 .expect("lower named characters");
956
957 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
958 panic!("expected one character paragraph");
959 };
960 assert_eq!(
961 inline_text(children),
962 "at=@ ga=` oq=' arrow=→ larrow=← mu=× de=° pl=+ dg=† ua=↑ da=↓ lB=[ rB=] unknown=\\[future-glyph]"
963 );
964 }
965
966 #[test]
967 fn preserves_explicit_mdoc_function_and_enclosure_structure() {
968 let document = parse_manual_bytes(
969 std::path::Path::new("explicit-mdoc.1"),
970 b".Dd August 17, 2026\n\
971.Dt EXPLICIT-MDOC 1\n\
972.Os\n\
973.Sh NAME\n\
974.Nm explicit-mdoc\n\
975.Nd exercise explicit blocks\n\
976.Sh FUNCTION\n\
977.Ft int\n\
978.Fo audit_open\n\
979.Fa const char *path\n\
980.Fa int flags\n\
981.Fc\n\
982.Sh ENCLOSURES\n\
983.Ao\nangle\n.Ac\n\
984.Bo\nbracket\n.Bc\n\
985.Do\ndouble\n.Dc\n\
986.Po\nparenthesized\n.Pc\n\
987.Qo\nquoted\n.Qc\n\
988.So\nsingle\n.Sc\n\
989.Bro\nbraced\n.Brc\n\
990.Oo\noptional\n.Oc\n\
991.Eo <<\ngeneric\n.Ec >>\n\
992.Es [[ ]]\n\
993.En custom\n",
994 )
995 .expect("lower explicit mdoc blocks");
996
997 let function = &document.sections[1];
998 let [
999 Block::Paragraph {
1000 children: return_type,
1001 ..
1002 },
1003 Block::Paragraph {
1004 children: declaration,
1005 ..
1006 },
1007 ] = function.blocks.as_slice()
1008 else {
1009 panic!("expected return type and function declaration paragraphs");
1010 };
1011 assert_eq!(inline_text(return_type), "int");
1012 assert_eq!(
1013 inline_text(declaration),
1014 "audit_open(const char *path, int flags)"
1015 );
1016 assert!(matches!(
1017 declaration.first(),
1018 Some(Inline::Strong { children }) if inline_text(children) == "audit_open"
1019 ));
1020
1021 let [Block::Paragraph { children, .. }] = document.sections[2].blocks.as_slice() else {
1022 panic!("expected one enclosure paragraph");
1023 };
1024 assert_eq!(
1025 inline_text(children),
1026 "<angle> [bracket] “double” (parenthesized) “quoted” ‘single’ {braced} \
1027 [optional] <<generic>> [[custom]]"
1028 );
1029 assert_eq!(document.diagnostics.len(), 2);
1030 assert!(
1031 document
1032 .diagnostics
1033 .iter()
1034 .all(|diagnostic| diagnostic.message.starts_with("obsolete macro:")),
1035 "{:?}",
1036 document.diagnostics
1037 );
1038 }
1039
1040 #[test]
1041 fn diagnoses_future_structural_macros_before_discarding_visible_parts() {
1042 let mut report = Parser::default()
1043 .parse_bytes(
1044 "future-structure.1",
1045 b".Dd August 17, 2026\n.Dt FUTURE 1\n.Os\n.Sh SYNOPSIS\n\
1046.Fo future_call\n.Fa argument\n.Fc\n",
1047 )
1048 .expect("parse structural fixture");
1049 let block = find_macro_mut(&mut report.document.root, "Fo").expect("Fo block");
1050 block.macro_name = Some("FutureBlock".to_owned());
1051
1052 let document = lower_mandoc_document(std::path::Path::new("future-structure.1"), &report);
1053
1054 assert!(document.diagnostics.iter().any(|diagnostic| {
1055 diagnostic.code.as_deref() == Some("manual.unhandled-structural-parts")
1056 && diagnostic.message.contains("FutureBlock")
1057 }));
1058 }
1059
1060 #[test]
1061 fn recognizes_explicitly_styled_traditional_man_references_in_any_section() {
1062 let path = temporary_source(
1063 "man-see-also",
1064 ".TH TOOL 1\n\
1065 .SH DESCRIPTION\n\
1066 The styled reference \\fBprintf\\fP(3) is usable here.\n\
1067 .SH SEE ALSO\n\
1068 .BR printf (3),\n\
1069 .BR man (1)\n",
1070 );
1071
1072 let document = parse_manual_source(&path).expect("lower man references");
1073 fs::remove_file(path).expect("remove temporary roff fixture");
1074
1075 let see_also = document
1076 .sections
1077 .iter()
1078 .find(|section| section.title == "SEE ALSO")
1079 .expect("SEE ALSO");
1080 let Block::Paragraph { children, .. } = &see_also.blocks[0] else {
1081 panic!("references are a paragraph");
1082 };
1083 assert!(children.iter().any(|inline| matches!(
1084 inline,
1085 Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
1086 if name == "printf" && manual_section == "3"
1087 )));
1088 assert!(children.iter().any(|inline| matches!(
1089 inline,
1090 Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
1091 if name == "man" && manual_section == "1"
1092 )));
1093
1094 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
1095 panic!("description is a paragraph");
1096 };
1097 assert!(children.iter().any(|inline| matches!(
1098 inline,
1099 Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
1100 if name == "printf" && manual_section == "3"
1101 )));
1102 }
1103
1104 #[test]
1105 fn recognizes_legacy_sphinx_manual_links_in_roff_inputs() {
1106 let path = temporary_source(
1107 "sphinx-manual-links",
1108 ".TH BTRFS 8\n\
1109 .SH COMMANDS\n\
1110 See btrfs\\-subvolume(8) \\%<> and btrfs(5) \\%<> for details.\n\
1111 .EX\n\
1112 btrfs-subvolume(8) \\%<>\n\
1113 .EE\n",
1114 );
1115
1116 let document = parse_manual_source(&path).expect("lower legacy Sphinx references");
1117 fs::remove_file(path).expect("remove temporary roff fixture");
1118 let section = &document.sections[0];
1119 let paragraph = section
1120 .blocks
1121 .iter()
1122 .find_map(|block| match block {
1123 Block::Paragraph { children, .. } => Some(children),
1124 _ => None,
1125 })
1126 .expect("commands paragraph");
1127 assert_eq!(
1128 inline_text(paragraph),
1129 "See btrfs-subvolume(8) and btrfs(5) for details."
1130 );
1131 let references = paragraph
1132 .iter()
1133 .filter_map(|inline| match inline {
1134 Inline::Link {
1135 target:
1136 mant_ir::LinkTarget::Manual {
1137 name,
1138 manual_section: Some(manual_section),
1139 },
1140 ..
1141 } => Some((name.as_str(), manual_section.as_str())),
1142 _ => None,
1143 })
1144 .collect::<Vec<_>>();
1145 assert_eq!(references, [("btrfs-subvolume", "8"), ("btrfs", "5")]);
1146
1147 let literal = section
1148 .blocks
1149 .iter()
1150 .find_map(|block| match block {
1151 Block::Preformatted { children, .. } => Some(children),
1152 _ => None,
1153 })
1154 .expect("literal display");
1155 assert_eq!(inline_text(literal), "btrfs-subvolume(8) <>");
1156 assert!(!literal.iter().any(|inline| matches!(
1157 inline,
1158 Inline::Link {
1159 target: mant_ir::LinkTarget::Manual { .. },
1160 ..
1161 }
1162 )));
1163 }
1164
1165 #[test]
1166 fn lowers_modern_groff_manual_uri_and_mail_macros() {
1167 let path = temporary_source(
1168 "man-modern-links",
1169 ".TH TOOL 1\n\
1170 .SH DESCRIPTION\n\
1171 .MR git-add 1 ,\n\
1172 .UR https://example.test/docs\n\
1173 Documentation\n\
1174 .UE .\n\
1175 .MT docs@example.test\n\
1176 Mail us\n\
1177 .ME .\n",
1178 );
1179
1180 let document = parse_manual_source(&path).expect("lower modern man links");
1181 fs::remove_file(path).expect("remove temporary roff fixture");
1182 let section = &document.sections[0];
1183 let mut manual = false;
1184 let mut web = false;
1185 let mut mail = false;
1186 for children in section.blocks.iter().filter_map(|block| match block {
1187 Block::Paragraph { children, .. } => Some(children),
1188 _ => None,
1189 }) {
1190 for inline in children {
1191 match inline {
1192 Inline::Link {
1193 target:
1194 mant_ir::LinkTarget::Manual {
1195 name,
1196 manual_section: Some(manual_section),
1197 },
1198 ..
1199 } if name == "git-add" && manual_section == "1" => manual = true,
1200 Inline::Link {
1201 target: mant_ir::LinkTarget::External { uri },
1202 ..
1203 } if uri == "https://example.test/docs" => {
1204 web = true;
1205 }
1206 Inline::Link {
1207 target: mant_ir::LinkTarget::Email { address },
1208 ..
1209 } if address == "docs@example.test" => {
1210 mail = true;
1211 }
1212 _ => {}
1213 }
1214 }
1215 }
1216
1217 assert!(manual && web && mail);
1218 assert!(section.blocks.iter().any(|block| match block {
1219 Block::Paragraph { children, .. } => inline_text(children).contains("git-add(1),"),
1220 _ => false,
1221 }));
1222 let linked_paragraphs = section
1223 .blocks
1224 .iter()
1225 .filter_map(|block| match block {
1226 Block::Paragraph { children, .. }
1227 if children.iter().any(|inline| {
1228 matches!(
1229 inline,
1230 Inline::Link {
1231 target: mant_ir::LinkTarget::External { .. },
1232 ..
1233 } | Inline::Link {
1234 target: mant_ir::LinkTarget::Email { .. },
1235 ..
1236 }
1237 )
1238 }) =>
1239 {
1240 Some(inline_text(children))
1241 }
1242 _ => None,
1243 })
1244 .collect::<Vec<_>>();
1245 assert_eq!(linked_paragraphs, ["Documentation.", "Mail us."]);
1246 }
1247
1248 #[test]
1249 fn resolves_mdoc_section_references_and_explicit_targets() {
1250 let path = temporary_source(
1251 "mdoc-navigation",
1252 ".Dd July 19, 2026\n\
1253 .Dt NAVIGATION 1\n\
1254 .Os\n\
1255 .Sh DESCRIPTION\n\
1256 Continue with\n\
1257 .Sx DETAILS\n\
1258 .Tg explicit-option\n\
1259 .Fl x\n\
1260 .Sh DETAILS\n\
1261 Target content.\n",
1262 );
1263
1264 let document = parse_manual_source(&path).expect("lower navigation mdoc source");
1265 fs::remove_file(path).expect("remove temporary roff fixture");
1266
1267 assert_eq!(document.sections[0].id, "description-1");
1268 assert_eq!(document.sections[1].id, "details-2");
1269 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
1270 panic!("expected navigation paragraph");
1271 };
1272 assert!(children.iter().any(|inline| matches!(
1273 inline,
1274 Inline::Link {
1275 target: mant_ir::LinkTarget::Section { id },
1276 children,
1277 ..
1278 } if id == "details-2" && inline_text(children) == "DETAILS"
1279 )));
1280 assert!(children.iter().any(|inline| matches!(
1281 inline,
1282 Inline::Anchor { id } if id == "explicit-option"
1283 )));
1284 }
1285
1286 #[test]
1287 fn degrades_unresolved_mdoc_section_references_to_text() {
1288 let path = temporary_source(
1289 "mdoc-missing-section",
1290 ".Dd July 19, 2026\n.Dt NAVIGATION 1\n.Os\n.Sh DESCRIPTION\n.Sx MISSING\n",
1291 );
1292
1293 let document = parse_manual_source(&path).expect("lower unresolved navigation source");
1294 fs::remove_file(path).expect("remove temporary roff fixture");
1295
1296 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
1297 panic!("expected reference paragraph");
1298 };
1299 assert_eq!(inline_text(children), "MISSING");
1300 assert!(children.iter().all(|inline| !matches!(
1301 inline,
1302 Inline::Link {
1303 target: mant_ir::LinkTarget::Section { .. },
1304 ..
1305 }
1306 )));
1307 assert!(document.diagnostics.iter().any(|diagnostic| {
1308 diagnostic.code.as_deref() == Some("unresolved-section-reference")
1309 }));
1310 }
1311
1312 #[test]
1313 fn turns_captured_parser_findings_into_structured_diagnostics() {
1314 let path = temporary_source(
1315 "unsupported",
1316 ".Dd July 19, 2026\n.Dt BAD 1\n.Os\n.Sh NAME\n.Nm bad\n.ab\n",
1317 );
1318
1319 let document = parse_manual_source(&path).expect("best-effort parse");
1320 fs::remove_file(path).expect("remove temporary roff fixture");
1321
1322 assert!(
1323 document
1324 .diagnostics
1325 .iter()
1326 .any(|diagnostic| diagnostic.level == DiagnosticLevel::Unsupported)
1327 );
1328 }
1329
1330 #[test]
1331 fn masks_terminal_controls_before_native_parsing() {
1332 let path = temporary_source("controls", ".TH SAFE 1\n.SH NAME\nsafe \x1b[2J text\n");
1333
1334 let document = parse_manual_source(&path).expect("parse sanitized manual");
1335 fs::remove_file(path).expect("remove temporary roff fixture");
1336
1337 assert!(
1338 document.diagnostics.iter().any(|diagnostic| {
1339 diagnostic.code.as_deref() == Some("manual.control-characters")
1340 })
1341 );
1342 }
1343
1344 #[test]
1345 fn lowers_normalized_ordered_lists_and_literal_displays() {
1346 let path = temporary_source(
1347 "normalized",
1348 ".Dd July 19, 2026\n.Dt NORMALIZED 1\n.Os\n.Sh CONTENT\n\
1349 .Bl -enum -compact\n.It\nfirst\n.It\nsecond\n.El\n\
1350 .Bd -literal -offset 6n\nline one\nline two\n.Ed\n",
1351 );
1352
1353 let document = parse_manual_source(&path).expect("lower normalized mdoc");
1354 fs::remove_file(path).expect("remove temporary roff fixture");
1355
1356 assert!(matches!(
1357 document.sections[0].blocks[0],
1358 Block::List {
1359 kind: mant_ir::ListKind::Ordered,
1360 compact: true,
1361 ..
1362 }
1363 ));
1364 assert!(matches!(
1365 document.sections[0].blocks[1],
1366 Block::Preformatted { layout, .. } if layout.indent_columns == 6
1367 ));
1368 }
1369
1370 #[test]
1371 fn lowers_normalized_mdoc_font_and_author_layout() {
1372 let path = temporary_source(
1373 "normalized-mdoc-modes",
1374 ".Dd July 19, 2026\n\
1375 .Dt NORMALIZED-MODES 1\n\
1376 .Os\n\
1377 .Sh AUTHORS\n\
1378 .An -split\n\
1379 .An Alice Example\n\
1380 .An Bob Example\n\
1381 .An -nosplit\n\
1382 .An Carol Example\n\
1383 .An Dave Example\n\
1384 .Sh DESCRIPTION\n\
1385 .Bf -literal\n\
1386 literal text\n\
1387 .Ef\n",
1388 );
1389
1390 let document = parse_manual_source(&path).expect("lower normalized mdoc modes");
1391 fs::remove_file(path).expect("remove temporary roff fixture");
1392
1393 let authors = &document.sections[0];
1394 let Block::Paragraph { children, .. } = &authors.blocks[0] else {
1395 panic!("authors are one paragraph");
1396 };
1397 assert_eq!(
1398 inline_text(children),
1399 "Alice Example\nBob Example Carol Example Dave Example"
1400 );
1401
1402 let description = &document.sections[1];
1403 let Block::Paragraph { children, .. } = &description.blocks[0] else {
1404 panic!("font block is a paragraph");
1405 };
1406 assert!(matches!(
1407 children.as_slice(),
1408 [Inline::Code { value }] if value == "literal text"
1409 ));
1410 }
1411
1412 #[test]
1413 fn mdoc_definition_layout_uses_the_normalized_list_width() {
1414 let path = temporary_source(
1415 "mdoc-definition-widths",
1416 ".Dd July 23, 2026\n.Dt WIDTHS 1\n.Os\n.Sh ITEMS\n\
1417 .Bl -tag -width 20n\n.It tenletters\nwide description\n.El\n\
1418 .Bl -tag -width 3n\n.It short\nnarrow description\n.El\n",
1419 );
1420
1421 let document = parse_manual_source(&path).expect("lower mdoc definition widths");
1422 fs::remove_file(path).expect("remove temporary roff fixture");
1423
1424 let lists = document.sections[0]
1425 .blocks
1426 .iter()
1427 .filter_map(|block| match block {
1428 Block::DefinitionList { items, .. } => Some(items),
1429 _ => None,
1430 })
1431 .collect::<Vec<_>>();
1432 assert_eq!(lists.len(), 2);
1433 assert!(lists[0][0].inline_term);
1434 assert!(!lists[1][0].inline_term);
1435 }
1436
1437 #[test]
1438 fn lowers_the_pinned_large_mdoc_fixture_without_empty_sections() {
1439 let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1440 .join("../libmandoc-rs/vendor/mandoc-1.14.6/mandoc.1");
1441
1442 let document = parse_manual_source(&source).expect("lower vendored mandoc manual");
1443
1444 assert!(document.sections.len() > 5);
1445 assert!(
1446 document
1447 .sections
1448 .iter()
1449 .any(|section| section.title == "DESCRIPTION")
1450 );
1451 assert!(
1452 document
1453 .sections
1454 .iter()
1455 .all(|section| !section.blocks.is_empty() || !section.children.is_empty())
1456 );
1457 }
1458
1459 #[test]
1460 fn lowers_tbl_and_eqn_payloads_into_structured_blocks() {
1461 let path = temporary_source(
1462 "table-equation",
1463 ".TH PAYLOAD 1\n.SH TABLE\n.TS\ntab(|);\nl r.\nleft|right\n.TE\n\
1464 .SH EQUATION\n.EQ\nx sup 2\n.EN\n",
1465 );
1466
1467 let document = parse_manual_source(&path).expect("lower table and equation");
1468 fs::remove_file(path).expect("remove temporary roff fixture");
1469
1470 assert!(matches!(
1471 document.sections[0].blocks[0],
1472 Block::Table { ref rows, .. } if rows.len() == 1 && rows[0].cells.len() == 2
1473 ));
1474 assert!(matches!(
1475 document.sections[1].blocks[0],
1476 Block::Equation { ref value, .. } if value.contains('x')
1477 ));
1478 }
1479
1480 fn inline_text(children: &[Inline]) -> String {
1481 children
1482 .iter()
1483 .map(|child| match child {
1484 Inline::Text { value } | Inline::Code { value } => value.clone(),
1485 Inline::Strong { children }
1486 | Inline::Emphasis { children }
1487 | Inline::Link { children, .. } => inline_text(children),
1488 Inline::Anchor { .. } => String::new(),
1489 Inline::LineBreak => "\n".to_owned(),
1490 })
1491 .collect()
1492 }
1493}