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