1mod blocks;
4mod diagnostics;
5mod error;
6pub(crate) mod inline;
7mod layout;
8mod navigation;
9mod reference;
10mod roff_escape;
11mod source;
12mod source_lines;
13
14use std::{cell::RefCell, collections::BTreeMap, path::Path};
15
16use libmandoc_rs::{
17 Compression, Document as MandocDocument, IncludePolicy, MacroSet, Node, ParseOptions,
18 ParseReport, Parser,
19};
20use mant_ir::{
21 Diagnostic, DiagnosticLevel, Document, DocumentMeta, DocumentSource, ParserInfo, SourceFormat,
22 SourceSpan, validate_document,
23};
24
25use self::{
26 roff_escape::visible_text,
27 source::{load_manual_source, redirect_target, resolve_manual_redirects},
28 source_lines::SourceLineIndex,
29};
30use crate::ManualPage;
31use crate::text_safety::mask_terminal_control_bytes;
32
33pub use error::{ManualError, ManualErrorKind};
34pub use source::MAX_MANUAL_BYTES;
35
36const MAX_INLINE_EQUATION_NORMALIZATIONS: usize = 256;
37
38pub fn parse_manual_source(path: &Path) -> Result<Document, ManualError> {
48 let loaded = load_manual_source(path)?;
49 reject_standalone_redirect(path, &loaded.source)?;
50 parse_plain_manual(path, &loaded.source, None)
51}
52
53pub fn parse_manual_bytes(path: &Path, source: &[u8]) -> Result<Document, ManualError> {
62 reject_standalone_redirect(path, source)?;
63 parse_plain_manual(path, source, None)
64}
65
66fn reject_standalone_redirect(path: &Path, source: &[u8]) -> Result<(), ManualError> {
67 if redirect_target(path, source)?.is_some() {
68 return Err(ManualError::redirect(
69 path,
70 "standalone .so redirects require MANPATH discovery and cannot be followed by --input",
71 ));
72 }
73 Ok(())
74}
75
76pub fn parse_manual_page(page: &ManualPage) -> Result<Document, ManualError> {
83 let resolved = resolve_manual_redirects(page)?;
84 parse_plain_manual(
85 &page.path,
86 &resolved.source,
87 resolved.alias_target.as_deref(),
88 )
89}
90
91fn parse_plain_manual(
92 path: &Path,
93 source: &[u8],
94 alias_target: Option<&str>,
95) -> Result<Document, ManualError> {
96 let (source, masked_controls) = mask_terminal_control_bytes(source);
97 let report = Parser::new(ParseOptions {
98 includes: IncludePolicy::Deny,
99 compression: Compression::Plain,
100 })
101 .parse_bytes(path, source.as_ref())
102 .map_err(ManualError::from)?;
103 let source_text = String::from_utf8_lossy(source.as_ref());
104 let mut document = lower_mandoc_document_with_source(path, &report, Some(&source_text));
105 if masked_controls > 0 {
106 document.diagnostics.insert(
107 0,
108 Diagnostic {
109 level: DiagnosticLevel::Warning,
110 code: Some("manual.control-characters".to_owned()),
111 message: format!("masked {masked_controls} terminal-unsafe control character(s)"),
112 source: None,
113 },
114 );
115 }
116 if let Some(alias_target) = alias_target {
117 document.meta.alias_target = Some(alias_target.to_owned());
118 }
119 Ok(document)
120}
121
122#[must_use]
124pub fn lower_mandoc_document(path: &Path, report: &ParseReport) -> Document {
125 lower_mandoc_document_with_source(path, report, None)
126}
127
128fn lower_mandoc_document_with_source(
129 path: &Path,
130 report: &ParseReport,
131 source: Option<&str>,
132) -> Document {
133 let parsed: &MandocDocument = &report.document;
134 let mut context = LoweringContext::new(parsed.metadata.name.as_deref(), source);
135 let mut diagnostics = diagnostics::lower_diagnostics(&report.diagnostics);
136 let mut sections = blocks::lower_sections(&parsed.root, &mut context);
137 let mut root_blocks = blocks::lower_root_blocks(&parsed.root, &context);
138 diagnostics.extend(context.take_diagnostics());
139 let explicit_targets = navigation::explicit_targets(&parsed.root);
140 let mut retained_targets = explicit_targets.clone();
141 retained_targets.extend(crate::definitions::identify_definitions(
142 &mut root_blocks,
143 &mut sections,
144 &explicit_targets,
145 ));
146 navigation::resolve_navigation(&mut sections, &retained_targets, &mut diagnostics);
147 let mut document = Document {
148 parser: Some(ParserInfo {
149 name: "libmandoc".to_owned(),
150 version: libmandoc_rs::LIBMANDOC_VERSION.to_owned(),
151 }),
152 source: DocumentSource {
153 format: match parsed.macro_set {
154 MacroSet::Mdoc => SourceFormat::Mdoc,
155 MacroSet::Man | MacroSet::None => SourceFormat::Man,
156 },
157 path: Some(path.to_string_lossy().into_owned()),
158 },
159 meta: DocumentMeta {
160 title: normalize_metadata(parsed.metadata.title.as_deref()),
161 manual_section: normalize_metadata(parsed.metadata.section.as_deref()),
162 date: normalize_metadata(parsed.metadata.date.as_deref()),
163 volume: normalize_metadata(parsed.metadata.volume.as_deref()),
164 os: normalize_metadata(parsed.metadata.os.as_deref()),
165 arch: normalize_metadata(parsed.metadata.arch.as_deref()),
166 names: normalize_metadata(parsed.metadata.name.as_deref())
167 .into_iter()
168 .collect(),
169 alias_target: parsed.metadata.alias_target.clone(),
170 },
171 diagnostics,
172 blocks: root_blocks,
173 sections,
174 };
175 document.diagnostics.extend(validate_document(&document));
176 document
177}
178
179fn normalize_metadata(value: Option<&str>) -> Option<String> {
184 value.map(visible_text)
185}
186
187struct LoweringContext<'a> {
188 default_name: Option<&'a str>,
189 source_lines: Option<SourceLineIndex<'a>>,
190 equation_delimiters: Vec<EquationDelimiterChange>,
191 normalized_equations: RefCell<BTreeMap<String, String>>,
192 next_section_id: usize,
193 diagnostics: RefCell<Vec<Diagnostic>>,
194}
195
196#[derive(Clone, Copy, Debug)]
197struct EquationDelimiterChange {
198 line: u32,
199 delimiters: Option<(char, char)>,
200}
201
202#[derive(Clone, Copy, Debug)]
203enum EquationDelimiterDirective {
204 Enable(char, char),
205 Disable,
206}
207
208impl EquationDelimiterDirective {
209 const fn delimiters(self) -> Option<(char, char)> {
210 match self {
211 Self::Enable(opening, closing) => Some((opening, closing)),
212 Self::Disable => None,
213 }
214 }
215}
216
217#[derive(Debug)]
218struct TableTextBlock {
219 source: String,
220 start_line: u32,
221 end_line: u32,
222}
223
224impl TableTextBlock {
225 const fn contains_line(&self, line: u32) -> bool {
226 line >= self.start_line && line <= self.end_line
227 }
228}
229
230impl<'a> LoweringContext<'a> {
231 fn new(default_name: Option<&'a str>, source: Option<&'a str>) -> Self {
232 Self {
233 default_name,
234 source_lines: source.map(SourceLineIndex::new),
235 equation_delimiters: source.map_or_else(Vec::new, equation_delimiter_changes),
236 normalized_equations: RefCell::new(BTreeMap::new()),
237 next_section_id: 1,
238 diagnostics: RefCell::new(Vec::new()),
239 }
240 }
241
242 fn equation_delimiters_at(&self, line: u32) -> Option<(char, char)> {
243 self.equation_delimiters
244 .iter()
245 .rev()
246 .find(|change| change.line <= line)
247 .and_then(|change| change.delimiters)
248 }
249
250 fn normalize_equation(&self, source: &str, line: u32) -> String {
255 {
256 let normalized = self.normalized_equations.borrow();
257 if let Some(value) = normalized.get(source) {
258 return value.clone();
259 }
260 if normalized.len() >= MAX_INLINE_EQUATION_NORMALIZATIONS {
261 drop(normalized);
262 self.warn_inline_equation_budget(line);
263 return visible_text(source);
264 }
265 }
266 let synthetic = format!(".TH MANT-EQN 7\n.EQ\n{source}\n.EN\n");
267 let normalized = Parser::default()
268 .parse_bytes(Path::new("mant-inline-eqn.7"), synthetic.as_bytes())
269 .ok()
270 .and_then(|report| first_equation(&report.document.root).map(visible_text))
271 .filter(|value| !value.trim().is_empty())
272 .unwrap_or_else(|| visible_text(source));
273 self.normalized_equations
274 .borrow_mut()
275 .insert(source.to_owned(), normalized.clone());
276 normalized
277 }
278
279 fn table_text_blocks(&self, line: u32, maximum: usize) -> Vec<TableTextBlock> {
280 if maximum == 0 {
284 return Vec::new();
285 }
286 let Some(source_lines) = self.source_lines.as_ref() else {
287 return Vec::new();
288 };
289 let mut blocks = Vec::new();
290 let mut current = None::<(String, u32)>;
291 for (line_number, line) in source_lines.lines_from(line) {
292 let trimmed = line.trim_start();
293 if trimmed.starts_with(".\\\"") || trimmed.starts_with("'\\\"") {
298 continue;
299 }
300 if let Some((content, start_line)) = current.as_mut() {
301 if let Some(remainder) = trimmed.strip_prefix("T}") {
302 blocks.push(TableTextBlock {
303 source: std::mem::take(content),
304 start_line: *start_line,
305 end_line: line_number.saturating_sub(1),
306 });
307 current = None;
308 if blocks.len() == maximum {
309 break;
310 }
311 if remainder.trim_end().ends_with("T{") {
315 current = Some((String::new(), line_number.saturating_add(1)));
316 }
317 } else {
318 if !content.is_empty() {
319 content.push('\n');
320 }
321 content.push_str(line);
322 }
323 } else if trimmed.trim_end().ends_with("T{") {
324 current = Some((String::new(), line_number.saturating_add(1)));
325 }
326 }
327 blocks
328 }
329
330 fn tab_separated_table_cells(&self, line: u32) -> Option<Vec<&'a str>> {
331 let source_line = self.source_lines.as_ref()?.line(line)?;
332 source_line
333 .contains('\t')
334 .then(|| source_line.split('\t').collect())
335 }
336
337 pub(super) fn no_fill_blank_rows_between(
347 &self,
348 previous_line: Option<u32>,
349 current_line: Option<u32>,
350 ) -> u16 {
351 let Some((previous, current)) = previous_line.zip(current_line) else {
352 return 0;
353 };
354 if current <= previous.saturating_add(1) {
355 return 0;
356 }
357 let Some(source_lines) = self.source_lines.as_ref() else {
358 return 0;
359 };
360 source_lines
361 .lines_between(previous, current)
362 .map(no_fill_vertical_rows)
363 .max()
364 .unwrap_or(0)
365 }
366
367 fn section_id(&mut self, title: &str) -> String {
368 let sequence = self.next_section_id;
369 self.next_section_id += 1;
370 let slug: String = title
371 .chars()
372 .flat_map(char::to_lowercase)
373 .map(|character| {
374 if character.is_alphanumeric() {
375 character
376 } else {
377 '-'
378 }
379 })
380 .collect::<String>()
381 .split('-')
382 .filter(|part| !part.is_empty())
383 .collect::<Vec<_>>()
384 .join("-");
385 if slug.is_empty() {
386 format!("section-{sequence}")
387 } else {
388 format!("{slug}-{sequence}")
389 }
390 }
391
392 fn warn_unhandled_structural_parts(&self, node: &Node) {
393 let macro_name = node.macro_name.as_deref().unwrap_or("unknown");
394 self.diagnostics.borrow_mut().push(Diagnostic {
395 level: DiagnosticLevel::Warning,
396 code: Some("manual.unhandled-structural-parts".to_owned()),
397 message: format!(
398 "structural macro '{macro_name}' contains parts without a complete lowering policy"
399 ),
400 source: source_span(node),
401 });
402 }
403
404 fn warn_unhandled_table_text_block(&self, node: &Node) {
405 self.diagnostics.borrow_mut().push(Diagnostic {
406 level: DiagnosticLevel::Warning,
407 code: Some("manual.unhandled-table-text-block".to_owned()),
408 message: "tbl text block contains semantic roff that could not be retained".to_owned(),
409 source: source_span(node),
410 });
411 }
412
413 fn warn_unhandled_table_text_block_line(&self, line: u32) {
414 self.diagnostics.borrow_mut().push(Diagnostic {
415 level: DiagnosticLevel::Warning,
416 code: Some("manual.unhandled-table-text-block".to_owned()),
417 message: "tbl text block contains semantic roff that could not be retained".to_owned(),
418 source: Some(SourceSpan {
419 byte_range: None,
420 line,
421 column: 1,
422 end_line: None,
423 end_column: None,
424 }),
425 });
426 }
427
428 fn warn_unexpanded_table_cell(&self, line: u32) {
429 let mut diagnostics = self.diagnostics.borrow_mut();
430 if diagnostics
431 .iter()
432 .any(|diagnostic| diagnostic.code.as_deref() == Some("manual.unexpanded-table-cell"))
433 {
434 return;
435 }
436 diagnostics.push(Diagnostic {
437 level: DiagnosticLevel::Unsupported,
438 code: Some("manual.unexpanded-table-cell".to_owned()),
439 message: "one or more tbl cells contain formatter strings that could not be expanded; their source spellings were preserved".to_owned(),
440 source: Some(SourceSpan {
441 byte_range: None,
442 line,
443 column: 1,
444 end_line: None,
445 end_column: None,
446 }),
447 });
448 }
449
450 fn warn_inline_equation_budget(&self, line: u32) {
451 let mut diagnostics = self.diagnostics.borrow_mut();
452 if diagnostics
453 .iter()
454 .any(|diagnostic| diagnostic.code.as_deref() == Some("manual.inline-equation-budget"))
455 {
456 return;
457 }
458 diagnostics.push(Diagnostic {
459 level: DiagnosticLevel::Unsupported,
460 code: Some("manual.inline-equation-budget".to_owned()),
461 message: format!(
462 "more than {MAX_INLINE_EQUATION_NORMALIZATIONS} distinct inline table equations; later source spellings were retained without normalization"
463 ),
464 source: Some(SourceSpan {
465 byte_range: None,
466 line,
467 column: 1,
468 end_line: None,
469 end_column: None,
470 }),
471 });
472 }
473
474 fn take_diagnostics(&self) -> Vec<Diagnostic> {
475 self.diagnostics.take()
476 }
477}
478
479fn first_equation(node: &Node) -> Option<&str> {
480 node.equation
481 .as_deref()
482 .or_else(|| node.children.iter().find_map(first_equation))
483}
484
485fn equation_delimiter_changes(source: &str) -> Vec<EquationDelimiterChange> {
492 let mut changes = Vec::new();
493 let mut in_equation = false;
494 let mut pending = None;
495 for (index, source_line) in source.lines().enumerate() {
496 let line = u32::try_from(index + 1).unwrap_or(u32::MAX);
497 let trimmed = source_line.trim();
498 if trimmed.starts_with(".\\\"") || trimmed.starts_with("'\\\"") {
499 continue;
500 }
501 if let Some(rest) = trimmed
502 .strip_prefix(".EQ")
503 .or_else(|| trimmed.strip_prefix("'EQ"))
504 .filter(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace))
505 {
506 in_equation = true;
507 pending = parse_equation_delimiters(rest.trim()).or(pending);
508 continue;
509 }
510 if in_equation {
511 if trimmed == ".EN" || trimmed == "'EN" {
512 if let Some(delimiters) = pending.take() {
513 changes.push(EquationDelimiterChange {
514 line: line.saturating_add(1),
515 delimiters: delimiters.delimiters(),
516 });
517 }
518 in_equation = false;
519 } else if let Some(delimiters) = parse_equation_delimiters(trimmed) {
520 pending = Some(delimiters);
521 }
522 }
523 }
524 changes
525}
526
527fn parse_equation_delimiters(value: &str) -> Option<EquationDelimiterDirective> {
528 let value = value.strip_prefix("delim")?.trim_start();
529 if value == "off" {
530 return Some(EquationDelimiterDirective::Disable);
531 }
532 let mut delimiters = value.chars();
533 let opening = delimiters.next()?;
534 let closing = delimiters.next()?;
535 Some(EquationDelimiterDirective::Enable(opening, closing))
536}
537
538fn no_fill_vertical_rows(line: &str) -> u16 {
543 let trimmed = line.trim();
544 if trimmed.is_empty() || roff_zero_width_blank_line(trimmed) {
545 return 1;
546 }
547 let Some(request) = line.trim_start().strip_prefix(['.', '\'']) else {
548 return 0;
549 };
550 let (name, arguments) = request
551 .split_once(char::is_whitespace)
552 .unwrap_or((request, ""));
553 if name != "sp" {
554 return 0;
555 }
556 let Some(argument) = arguments.split_whitespace().next() else {
557 return 1;
558 };
559 argument.trim_end_matches('v').parse::<u16>().unwrap_or(1)
560}
561
562fn roff_zero_width_blank_line(line: &str) -> bool {
570 let mut remainder = line;
571 let mut found = false;
572 while let Some(rest) = remainder.strip_prefix(r"\&") {
573 found = true;
574 remainder = rest.trim();
575 }
576 found && remainder.is_empty()
577}
578
579fn source_span(node: &Node) -> Option<SourceSpan> {
580 (node.line > 0).then_some(SourceSpan {
581 byte_range: None,
582 line: node.line,
583 column: node.column.max(1),
584 end_line: None,
585 end_column: None,
586 })
587}
588
589fn first_part_children(node: &Node, kind: libmandoc_rs::NodeKind) -> &[Node] {
595 node.children
596 .iter()
597 .find(|child| child.kind == kind)
598 .map_or(&[], |child| child.children.as_slice())
599}
600
601fn part_child_groups(node: &Node, kind: libmandoc_rs::NodeKind) -> impl Iterator<Item = &[Node]> {
603 node.children
604 .iter()
605 .filter(move |child| child.kind == kind)
606 .map(|child| child.children.as_slice())
607}
608
609#[cfg(test)]
610mod tests {
611 use std::{fmt::Write as _, fs, process};
612
613 use mant_ir::{Block, DiagnosticLevel, Inline, SourceFormat};
614
615 use super::{
616 MAX_INLINE_EQUATION_NORMALIZATIONS, Parser, lower_mandoc_document, parse_manual_bytes,
617 parse_manual_source,
618 };
619
620 fn temporary_source(label: &str, source: &str) -> std::path::PathBuf {
621 let path = std::env::temp_dir().join(format!("mant-lower-{label}-{}.1", process::id()));
622 fs::write(&path, source).expect("write temporary roff fixture");
623 path
624 }
625
626 fn find_macro_mut<'a>(
627 node: &'a mut libmandoc_rs::Node,
628 name: &str,
629 ) -> Option<&'a mut libmandoc_rs::Node> {
630 if node.macro_name.as_deref() == Some(name) {
631 return Some(node);
632 }
633 node.children
634 .iter_mut()
635 .find_map(|child| find_macro_mut(child, name))
636 }
637
638 fn replace_first_text(node: &mut libmandoc_rs::Node, value: &str) -> bool {
639 if let Some(text) = node.text.as_mut() {
640 *text = value.to_owned();
641 return true;
642 }
643 node.children
644 .iter_mut()
645 .any(|child| replace_first_text(child, value))
646 }
647
648 #[test]
649 fn standalone_inputs_reject_redirect_only_so_pages() {
650 let error = parse_manual_bytes(std::path::Path::new("stdin"), b".so man1/target.1\n")
651 .expect_err("standalone input must not follow another file");
652 assert!(error.to_string().contains("require MANPATH discovery"));
653 }
654
655 #[test]
656 fn lowers_man_sections_fonts_definitions_and_literal_blocks() {
657 let path = temporary_source(
658 "man",
659 ".TH MANT 1 \"July 2026\"\n\
660 .SH NAME\n\
661 mant \\- a viewer\n\
662 .SH OPTIONS\n\
663 .TP\n\
664 \\fB\\-h\\fR\n\
665 Show help.\n\
666 .nf\n\
667 mant --help\n\
668 mant git\n\
669 .fi\n",
670 );
671
672 let document = parse_manual_source(&path).expect("lower man source");
673 fs::remove_file(path).expect("remove temporary roff fixture");
674
675 assert_eq!(document.source.format, SourceFormat::Man);
676 assert_eq!(
677 document
678 .sections
679 .iter()
680 .map(|section| section.title.as_str())
681 .collect::<Vec<_>>(),
682 vec!["NAME", "OPTIONS"]
683 );
684 assert!(
685 document.sections[1]
686 .blocks
687 .iter()
688 .any(|block| matches!(block, Block::DefinitionList { .. }))
689 );
690 assert!(document.sections[1].blocks.iter().any(|block| matches!(
691 block,
692 Block::DefinitionList { items, .. }
693 if items.iter().any(|item| item.description.iter().any(
694 |description| matches!(description, Block::Preformatted { .. })
695 ))
696 )));
697 }
698
699 #[test]
700 fn separates_definition_layout_arguments_from_visible_terms() {
701 let path = temporary_source(
702 "definition-head-roles",
703 ".TH HEAD-ROLES 1\n\
704 .SH EXAMPLES\n\
705 .TP \\w'man\\ 'u\n\
706 .BI man \\ ls\n\
707 Display ls.\n\
708 .TP 4\n\
709 4\n\
710 A numeric term remains visible.\n\
711 .IP \"1\" 8n\n\
712 An IP width remains layout-only.\n",
713 );
714
715 let document = parse_manual_source(&path).expect("lower definition head roles");
716 fs::remove_file(path).expect("remove temporary roff fixture");
717
718 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
719 panic!("expected one definition list");
720 };
721 assert_eq!(
722 items
723 .iter()
724 .flat_map(|item| item.terms.iter())
725 .map(|term| inline_text(term))
726 .collect::<Vec<_>>(),
727 ["man ls", "4", "1"]
728 );
729 assert!(matches!(
730 items[0].terms[0].as_slice(),
731 [Inline::Strong { .. }, Inline::Emphasis { .. }]
732 ));
733 assert!(
734 items
735 .iter()
736 .flat_map(|item| item.terms.iter())
737 .all(|term| !inline_text(term).contains("96u"))
738 );
739 }
740
741 #[test]
742 fn preserves_man_synopsis_flow_and_alternating_fonts() {
743 let path = temporary_source(
744 "man-synopsis-flow",
745 ".TH MAN 1\n\
746 .SH SYNOPSIS\n\
747 .B man\n\
748 .RI [\\| \"man options\" \\|]\n\
749 .RI [\\|[\\| section \\|]\n\
750 .IR page \\ \\|.\\|.\\|.\\|]\\ \\.\\|.\\|.\\&\n\
751 .br\n\
752 .B man\n\
753 .B \\-k\n\
754 .RI [\\| \"apropos options\" \\|]\n\
755 .I regexp\n\
756 \\&.\\|.\\|.\\&\n\
757 .br\n\
758 .B man\n\
759 .BR \\-w \\||\\| \\-W\n\
760 .RI [\\| \"man options\" \\|]\n\
761 .I page\n\
762 \\&.\\|.\\|.\\&\n",
763 );
764
765 let document = parse_manual_source(&path).expect("lower man synopsis");
766 fs::remove_file(path).expect("remove temporary roff fixture");
767
768 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
769 panic!("expected one synopsis paragraph");
770 };
771 assert_eq!(
772 inline_text(children),
773 "man [man options] [[section] page ...] ...\n\
774 man -k [apropos options] regexp ...\n\
775 man -w|-W [man options] page ..."
776 );
777 assert_eq!(
778 children
779 .iter()
780 .filter(|node| matches!(node, Inline::LineBreak))
781 .count(),
782 2
783 );
784 assert!(children.iter().any(
785 |node| matches!(node, Inline::Emphasis { children } if inline_text(children) == "man options")
786 ));
787 assert!(children.iter().any(
788 |node| matches!(node, Inline::Strong { children } if inline_text(children) == "-w")
789 ));
790 assert!(children.iter().any(
791 |node| matches!(node, Inline::Strong { children } if inline_text(children) == "-W")
792 ));
793 }
794
795 #[test]
796 fn preserves_man_sy_heads_with_body_content_and_inline_fonts() {
797 let document = parse_manual_bytes(
798 std::path::Path::new("sy-heads.1"),
799 b".TH SY-HEADS 1 \"August 17, 2026\"\n\
800.SH SYNOPSIS\n\
801.SY getent\n\
802.RI [ option ]\n\
803.I database\n\
804.YS\n\
805.SH DESCRIPTION\n\
806.SY #!\\f[I]interpreter\\f[]\n\
807.RI [ optional-arg ]\n\
808.YS\n",
809 )
810 .expect("lower SY heads");
811
812 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
813 panic!("expected one synopsis paragraph");
814 };
815 assert_eq!(inline_text(children), "getent [option] database");
816 assert!(matches!(
817 children.first(),
818 Some(Inline::Strong { children }) if inline_text(children) == "getent"
819 ));
820
821 let [Block::Paragraph { children, .. }] = document.sections[1].blocks.as_slice() else {
822 panic!("expected one description paragraph");
823 };
824 assert_eq!(inline_text(children), "#!interpreter [optional-arg]");
825 assert!(matches!(
826 children.first(),
827 Some(Inline::Strong { children })
828 if children.iter().any(|inline| matches!(
829 inline,
830 Inline::Emphasis { children } if inline_text(children) == "interpreter"
831 ))
832 ));
833 assert!(
834 document.diagnostics.is_empty(),
835 "{:?}",
836 document.diagnostics
837 );
838 }
839
840 #[test]
841 fn keeps_man_synopsis_lines_together_inside_no_fill_examples() {
842 let document = parse_manual_bytes(
843 std::path::Path::new("no-fill-synopsis.2"),
844 b".TH NO-FILL-SYNOPSIS 2\n\
845.SH DESCRIPTION\n\
846.EX\n\
847.SY #!\\f[I]interpreter\\f[]\n\
848.RI [ optional-arg ]\n\
849.YS\n\
850.EE\n",
851 )
852 .expect("lower synopsis inside example");
853
854 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
855 panic!(
856 "no-fill synopsis must remain one preformatted block: {:?}",
857 document.sections[0].blocks
858 );
859 };
860 assert_eq!(inline_text(children), "#!interpreter\n[optional-arg]");
861 assert_eq!(
862 children
863 .iter()
864 .filter(|inline| matches!(inline, Inline::LineBreak))
865 .count(),
866 1
867 );
868 }
869
870 #[test]
871 fn preserves_explicit_blank_rows_inside_no_fill_displays() {
872 let document = parse_manual_bytes(
873 std::path::Path::new("no-fill-blank-row.7"),
874 b".TH NO-FILL-BLANK-ROW 7\n\
875.SH EXAMPLE\n\
876.EX\n\
877first line\n\
878\n\
879second line\n\
880.EE\n",
881 )
882 .expect("lower no-fill blank row");
883
884 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
885 panic!(
886 "no-fill display must remain preformatted: {:?}",
887 document.sections[0].blocks
888 );
889 };
890 assert_eq!(inline_text(children), "first line\n\nsecond line");
891 assert_eq!(
892 children
893 .iter()
894 .filter(|inline| matches!(inline, Inline::LineBreak))
895 .count(),
896 2
897 );
898 }
899
900 #[test]
901 fn preserves_zero_width_guard_rows_inside_no_fill_displays() {
902 let document = parse_manual_bytes(
903 std::path::Path::new("no-fill-zero-width-row.7"),
904 b".TH NO-FILL-ZERO-WIDTH-ROW 7\n\
905.SH EXAMPLE\n\
906.EX\n\
907first line\n\
908\\&\n\
909second line\n\
910.EE\n",
911 )
912 .expect("lower no-fill zero-width row");
913
914 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
915 panic!(
916 "no-fill display must remain preformatted: {:?}",
917 document.sections[0].blocks
918 );
919 };
920 assert_eq!(inline_text(children), "first line\n\nsecond line");
921 assert_eq!(
922 children
923 .iter()
924 .filter(|inline| matches!(inline, Inline::LineBreak))
925 .count(),
926 2
927 );
928 }
929
930 #[test]
931 fn preserves_lines_inside_font_blocks_nested_in_literal_displays() {
932 let document = parse_manual_bytes(
933 std::path::Path::new("literal-font-block.7"),
934 b".Dd August 20, 2026\n\
935.Dt LITERAL-FONT-BLOCK 7\n\
936.Os\n\
937.Sh EXAMPLE\n\
938.Bd -literal\n\
939.Bf Sy\n\
940first line\n\
941second line\n\
942.Ef\n\
943.Ed\n",
944 )
945 .expect("lower font block inside literal display");
946
947 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
948 panic!(
949 "literal display must remain one preformatted block: {:?}",
950 document.sections[0].blocks
951 );
952 };
953 assert_eq!(inline_text(children), "first line\nsecond line");
954 assert_eq!(
955 children
956 .iter()
957 .filter(|inline| matches!(inline, Inline::LineBreak))
958 .count(),
959 1
960 );
961 }
962
963 #[test]
964 fn preserves_literal_display_lines_inside_literal_font_blocks() {
965 let document = parse_manual_bytes(
966 std::path::Path::new("literal-display-inside-font-block.7"),
967 b".Dd August 21, 2026\n\
968.Dt LITERAL-DISPLAY-INSIDE-FONT-BLOCK 7\n\
969.Os\n\
970.Sh EXAMPLE\n\
971.Bf Li\n\
972.Bd -literal\n\
973first line\n\
974second line\n\
975.Ed\n\
976.Ef\n",
977 )
978 .expect("lower literal display inside literal font block");
979
980 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
981 panic!(
982 "fonted literal display must remain preformatted: {:?}",
983 document.sections[0].blocks
984 );
985 };
986 assert_eq!(inline_text(children), "first line\nsecond line");
987 assert_eq!(
988 children
989 .iter()
990 .filter(|inline| matches!(inline, Inline::LineBreak))
991 .count(),
992 1
993 );
994 }
995
996 #[test]
997 fn preserves_lines_inside_nested_literal_displays() {
998 let document = parse_manual_bytes(
999 std::path::Path::new("nested-literal-display.7"),
1000 b".Dd August 21, 2026\n\
1001.Dt NESTED-LITERAL-DISPLAY 7\n\
1002.Os\n\
1003.Sh EXAMPLE\n\
1004.Bd -literal\n\
1005first line\n\
1006.Bd -literal\n\
1007second line\n\
1008third line\n\
1009.Ed\n",
1010 )
1011 .expect("lower nested literal display");
1012
1013 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
1014 panic!(
1015 "nested literal displays must remain one preformatted block: {:?}",
1016 document.sections[0].blocks
1017 );
1018 };
1019 assert_eq!(inline_text(children), "first line\nsecond line\nthird line");
1020 assert_eq!(
1021 children
1022 .iter()
1023 .filter(|inline| matches!(inline, Inline::LineBreak))
1024 .count(),
1025 2
1026 );
1027 }
1028
1029 #[test]
1030 fn collapses_a_no_fill_blank_line_run_to_one_visual_separator() {
1031 let document = parse_manual_bytes(
1032 std::path::Path::new("no-fill-blank-run.7"),
1033 b".TH NO-FILL-BLANK-RUN 7\n\
1034.SH EXAMPLE\n\
1035.EX\n\
1036first line\n\
1037\n\
1038\n\
1039second line\n\
1040.EE\n",
1041 )
1042 .expect("lower no-fill blank run");
1043
1044 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
1045 panic!(
1046 "no-fill display must remain preformatted: {:?}",
1047 document.sections[0].blocks
1048 );
1049 };
1050 assert_eq!(inline_text(children), "first line\n\nsecond line");
1051 assert_eq!(
1052 children
1053 .iter()
1054 .filter(|inline| matches!(inline, Inline::LineBreak))
1055 .count(),
1056 2
1057 );
1058 }
1059
1060 #[test]
1061 fn adjacent_no_fill_regions_scale_without_changing_their_topology() {
1062 const REGION_COUNT: usize = 2_048;
1063 let mut source = String::from(".TH NO-FILL-SCALE 7\n.SH EXAMPLE\n");
1064 for index in 0..REGION_COUNT {
1065 writeln!(source, ".nf\nline {index}\n.fi").expect("append no-fill region");
1066 }
1067
1068 let document =
1069 parse_manual_bytes(std::path::Path::new("no-fill-scale.7"), source.as_bytes())
1070 .expect("lower adjacent no-fill regions");
1071
1072 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
1073 panic!(
1074 "adjacent regions must remain one preformatted block: {:?}",
1075 document.sections[0].blocks
1076 );
1077 };
1078 assert_eq!(
1079 children
1080 .iter()
1081 .filter(|inline| matches!(inline, Inline::LineBreak))
1082 .count(),
1083 REGION_COUNT - 1
1084 );
1085 assert!(inline_text(children).starts_with("line 0\nline 1\n"));
1086 assert!(
1087 inline_text(children).ends_with(&format!("line {}", REGION_COUNT - 1)),
1088 "last no-fill region must remain visible"
1089 );
1090 }
1091
1092 #[test]
1093 fn distinguishes_filled_source_wrapping_from_indented_output_lines() {
1094 let path = temporary_source(
1095 "filled-line-boundaries",
1096 concat!(
1097 ".TH TOOL 1\n",
1098 ".SH SYNOPSIS\n",
1099 "tool [first]\n",
1100 " [second]\n",
1101 " [third]\n",
1102 ".PP\n",
1103 "Ordinary source wrapping\n",
1104 "remains one filled paragraph.\n",
1105 ),
1106 );
1107
1108 let document = parse_manual_source(&path).expect("lower filled line boundaries");
1109 fs::remove_file(path).expect("remove temporary roff fixture");
1110
1111 let [
1112 Block::Paragraph {
1113 children: synopsis, ..
1114 },
1115 Block::Paragraph {
1116 children: prose, ..
1117 },
1118 ] = document.sections[0].blocks.as_slice()
1119 else {
1120 panic!("expected synopsis and prose paragraphs");
1121 };
1122 assert_eq!(
1123 inline_text(synopsis),
1124 "tool [first]\n [second]\n [third]"
1125 );
1126 assert_eq!(
1127 synopsis
1128 .iter()
1129 .filter(|inline| matches!(inline, Inline::LineBreak))
1130 .count(),
1131 2
1132 );
1133 assert_eq!(
1134 inline_text(prose),
1135 "Ordinary source wrapping remains one filled paragraph."
1136 );
1137 }
1138
1139 #[test]
1140 fn honours_roff_no_space_line_continuations() {
1141 let document = parse_manual_bytes(
1142 std::path::Path::new("line-continuation.1"),
1143 b".TH LINE-CONTINUATION 1\n\
1144.SH DESCRIPTION\n\
1145extsize=\\c\n\
1146nnnn; multi-\\c\n\
1147block; (\\c\n\
1148.BR read (2)\n\
1149.EX\n\
1150literal-\\c\n\
1151continuation\n\
1152.EE\n",
1153 )
1154 .expect("lower no-space line continuations");
1155
1156 let [
1157 Block::Paragraph {
1158 children: prose, ..
1159 },
1160 Block::Preformatted {
1161 children: literal, ..
1162 },
1163 ] = document.sections[0].blocks.as_slice()
1164 else {
1165 panic!(
1166 "expected one filled and one no-fill block: {:?}",
1167 document.sections[0].blocks
1168 );
1169 };
1170 assert_eq!(inline_text(prose), "extsize=nnnn; multi-block; (read(2)");
1171 assert_eq!(inline_text(literal), "literal-continuation");
1172 }
1173
1174 #[test]
1175 fn keeps_explicit_horizontal_separation_at_a_tight_line_join() {
1176 let document = parse_manual_bytes(
1177 std::path::Path::new("motion-continuation.1"),
1178 b".TH MOTION-CONTINUATION 1\n\
1179.SH DESCRIPTION\n\
1180\\h'-04' 1.\\h'+01'\\c\n\
1181The next line.\n",
1182 )
1183 .expect("lower a horizontally spaced continued line");
1184
1185 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1186 panic!("expected one paragraph: {:?}", document.sections[0].blocks);
1187 };
1188 assert_eq!(inline_text(children), " 1. The next line.");
1189 }
1190
1191 #[test]
1192 fn lets_explicit_fonts_override_an_alternating_macro_default() {
1193 let path = temporary_source(
1194 "alternating-font-reset",
1195 ".TH MAN 1\n\
1196 .SH OPTIONS\n\
1197 .TP\n\
1198 .BI \\-r\\ prompt \\fR,\\ \\fB\\-\\-prompt= prompt\n\
1199 Set the pager prompt.\n",
1200 );
1201
1202 let document = parse_manual_source(&path).expect("lower alternating font reset");
1203 fs::remove_file(path).expect("remove temporary roff fixture");
1204
1205 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
1206 panic!("expected one definition list");
1207 };
1208 let term = items[0]
1209 .terms
1210 .first()
1211 .expect("first definition term")
1212 .iter()
1213 .filter(|inline| !matches!(inline, Inline::Anchor { .. }))
1214 .collect::<Vec<_>>();
1215
1216 assert_eq!(term.len(), 5);
1217 assert!(matches!(term[0], Inline::Strong { children } if inline_text(children) == "-r "));
1218 assert!(
1219 matches!(term[1], Inline::Emphasis { children } if inline_text(children) == "prompt")
1220 );
1221 assert!(matches!(term[2], Inline::Text { value } if value == ", "));
1222 assert!(
1223 matches!(term[3], Inline::Strong { children } if inline_text(children) == "--prompt=")
1224 );
1225 assert!(
1226 matches!(term[4], Inline::Emphasis { children } if inline_text(children) == "prompt")
1227 );
1228 }
1229
1230 #[test]
1231 fn suppresses_pod_font_requests_around_verbatim_blocks() {
1232 let path = temporary_source(
1233 "pod-verbatim-fonts",
1234 ".de Vb\n\
1235 .ft CW\n\
1236 .nf\n\
1237 ..\n\
1238 .de Ve\n\
1239 .ft R\n\
1240 .fi\n\
1241 ..\n\
1242 .TH POD 1\n\
1243 .SH EXAMPLES\n\
1244 .Vb 2\n\
1245 \\&struct A { int a; };\n\
1246 \\&struct B : A {};\n\
1247 .Ve\n",
1248 );
1249
1250 let document = parse_manual_source(&path).expect("lower Pod::Man verbatim source");
1251 fs::remove_file(path).expect("remove temporary roff fixture");
1252
1253 assert_eq!(document.sections[0].blocks.len(), 1);
1254 let Block::Preformatted { children, .. } = &document.sections[0].blocks[0] else {
1255 panic!("expected one preformatted block");
1256 };
1257 assert_eq!(
1258 inline_text(children),
1259 "struct A { int a; };\nstruct B : A {};"
1260 );
1261 }
1262
1263 #[test]
1264 fn lowers_indented_aliases_without_roff_layout_arguments() {
1265 let path = temporary_source(
1266 "indented-aliases",
1267 ".TH CONTROL 1\n\
1268 .SH OPTIONS\n\
1269 .PD 0\n\
1270 .IP \"\\fB-a\\fR\" 4\n\
1271 .IP \"\\fB--all\\fR\" 4\n\
1272 Show all entries.\n\
1273 .PD\n\
1274 .in 168u\n",
1275 );
1276
1277 let document = parse_manual_source(&path).expect("lower indented aliases");
1278 fs::remove_file(path).expect("remove temporary roff fixture");
1279
1280 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
1281 panic!("expected one definition list");
1282 };
1283 assert_eq!(items.len(), 1);
1284 assert_eq!(
1285 items[0]
1286 .terms
1287 .iter()
1288 .map(|term| inline_text(term))
1289 .collect::<Vec<_>>(),
1290 ["-a", "--all"]
1291 );
1292 assert_eq!(items[0].description.len(), 1);
1293 let Block::Paragraph { children, .. } = &items[0].description[0] else {
1294 panic!("expected alias description paragraph");
1295 };
1296 assert_eq!(inline_text(children), "Show all entries.");
1297 }
1298
1299 #[test]
1300 fn tq_terms_share_one_semantic_option_identity() {
1301 let path = temporary_source(
1302 "tq-aliases",
1303 ".TH TQ-ALIASES 7\n\
1304 .SH OPTIONS\n\
1305 .TP\n\
1306 .B \\-\\-alpha\n\
1307 .TQ\n\
1308 .B \\-a\n\
1309 .TQ\n\
1310 .B \\-\\-ALPHA\n\
1311 Enable alpha mode.\n",
1312 );
1313
1314 let document = parse_manual_source(&path).expect("lower TQ aliases");
1315 fs::remove_file(path).expect("remove temporary roff fixture");
1316 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
1317 panic!("expected one definition list");
1318 };
1319 assert_eq!(items.len(), 1);
1320 assert_eq!(
1321 items[0]
1322 .terms
1323 .iter()
1324 .map(|term| inline_text(term))
1325 .collect::<Vec<_>>(),
1326 ["-a", "--alpha", "--ALPHA"]
1327 );
1328 assert_eq!(
1329 items[0].identity.as_ref().expect("option identity").names,
1330 ["-a", "--alpha", "--ALPHA"]
1331 );
1332 }
1333
1334 #[test]
1335 fn preserves_man_paragraph_distance_between_indented_paragraphs() {
1336 let path = temporary_source(
1337 "paragraph-distance",
1338 ".TH SPACING 1\n\
1339 .SH OPTIONS\n\
1340 .IP \"\\fB-a\\fR\" 4\n\
1341 First.\n\
1342 .IP \"\\fB-b\\fR\" 4\n\
1343 Second.\n\
1344 .PD 0\n\
1345 .IP \"\\fB-c\\fR\" 4\n\
1346 Third.\n\
1347 .IP \"\\fB-d\\fR\" 4\n\
1348 Fourth.\n\
1349 .PD\n\
1350 .IP \"\\fB-e\\fR\" 4\n\
1351 Fifth.\n",
1352 );
1353
1354 let document = parse_manual_source(&path).expect("lower paragraph distance");
1355 fs::remove_file(path).expect("remove temporary roff fixture");
1356
1357 let [Block::DefinitionList { items, compact, .. }] = document.sections[0].blocks.as_slice()
1358 else {
1359 panic!("expected one definition list");
1360 };
1361 assert!(!compact);
1362 assert_eq!(items.len(), 5);
1363 assert_eq!(
1364 items
1365 .iter()
1366 .map(|item| item.spacing_before_lines)
1367 .collect::<Vec<_>>(),
1368 [Some(0), Some(1), Some(0), Some(0), Some(1)]
1369 );
1370 }
1371
1372 #[test]
1373 fn preserves_man_paragraph_and_heading_distance_as_one_layout_model() {
1374 let path = temporary_source(
1375 "vertical-layout",
1376 ".TH SPACING 1\n\
1377 .SH FIRST\n\
1378 First paragraph.\n\
1379 .PP\n\
1380 Second paragraph.\n\
1381 .SS CHILD\n\
1382 Child body.\n\
1383 .PD 0\n\
1384 .SS COMPACT\n\
1385 Compact child.\n\
1386 .SH NEXT\n\
1387 Next body.\n\
1388 .PD\n\
1389 .SH FINAL\n\
1390 Final body.\n",
1391 );
1392
1393 let document = parse_manual_source(&path).expect("lower vertical layout");
1394 fs::remove_file(path).expect("remove temporary roff fixture");
1395
1396 let [first, next, final_section] = document.sections.as_slice() else {
1397 panic!("expected three top-level sections");
1398 };
1399 assert_eq!(first.spacing_before_lines, 0);
1400 let [Block::Paragraph { .. }, Block::Paragraph { layout, .. }] = first.blocks.as_slice()
1401 else {
1402 panic!("expected two semantic paragraphs");
1403 };
1404 assert_eq!(layout.spacing_before_lines, 1);
1405
1406 let [child, compact] = first.children.as_slice() else {
1407 panic!("expected two subsections");
1408 };
1409 assert_eq!(child.spacing_before_lines, 1);
1410 assert_eq!(compact.spacing_before_lines, 0);
1411 assert_eq!(next.spacing_before_lines, 0);
1412 assert_eq!(final_section.spacing_before_lines, 1);
1413 }
1414
1415 #[test]
1416 fn does_not_duplicate_explicit_space_before_a_transparent_indent() {
1417 let path = temporary_source(
1418 "explicit-space-before-indent",
1419 ".TH SPACING 1\n\
1420 .SH CONTENT\n\
1421 Before.\n\
1422 .sp\n\
1423 .RS 4\n\
1424 After.\n\
1425 .RE\n",
1426 );
1427
1428 let document = parse_manual_source(&path).expect("lower explicit indented spacing");
1429 fs::remove_file(path).expect("remove temporary roff fixture");
1430
1431 let [
1432 Block::Paragraph { .. },
1433 Block::VerticalSpace { lines: 1, .. },
1434 Block::Paragraph { layout, .. },
1435 ] = document.sections[0].blocks.as_slice()
1436 else {
1437 panic!("expected prose, one explicit gap, and indented prose");
1438 };
1439 assert_eq!(layout.indent_columns, 4);
1440 assert_eq!(
1441 layout.spacing_before_lines, 0,
1442 "the explicit gap must not be repeated as wrapper boundary spacing",
1443 );
1444 }
1445
1446 #[test]
1447 fn preserves_mdoc_paragraph_and_heading_distance() {
1448 let path = temporary_source(
1449 "mdoc-vertical-layout",
1450 ".Dd July 19, 2026\n\
1451 .Dt SPACING 1\n\
1452 .Os\n\
1453 .Sh FIRST\n\
1454 First paragraph.\n\
1455 .Pp\n\
1456 Second paragraph.\n\
1457 .Ss CHILD\n\
1458 Child body.\n",
1459 );
1460
1461 let document = parse_manual_source(&path).expect("lower mdoc vertical layout");
1462 fs::remove_file(path).expect("remove temporary roff fixture");
1463
1464 let [first] = document.sections.as_slice() else {
1465 panic!("expected one top-level section");
1466 };
1467 assert_eq!(first.spacing_before_lines, 1);
1468 assert!(matches!(
1469 first.blocks.get(1),
1470 Some(Block::VerticalSpace { lines: 1, .. })
1471 ));
1472 assert_eq!(first.children[0].spacing_before_lines, 1);
1473 }
1474
1475 #[test]
1476 fn lowers_mdoc_semantic_inline_nodes_and_nested_sections() {
1477 let path = temporary_source(
1478 "mdoc",
1479 ".Dd July 19, 2026\n\
1480 .Dt MANT 1\n\
1481 .Os\n\
1482 .Sh DESCRIPTION\n\
1483 Use\n\
1484 .Nm mant\n\
1485 with\n\
1486 .Xr man 1\n\
1487 Read\n\
1488 .Lk https://example.test/docs \"the documentation\"\n\
1489 or contact\n\
1490 .Mt docs@example.test\n\
1491 .Ss Details\n\
1492 .Fl h\n",
1493 );
1494
1495 let document = parse_manual_source(&path).expect("lower mdoc source");
1496 fs::remove_file(path).expect("remove temporary roff fixture");
1497
1498 assert_eq!(document.source.format, SourceFormat::Mdoc);
1499 assert_eq!(document.sections[0].children[0].title, "Details");
1500 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
1501 panic!("expected description paragraph");
1502 };
1503 assert!(
1504 children
1505 .iter()
1506 .any(|inline| matches!(inline, Inline::Strong { .. }))
1507 );
1508 assert!(
1509 children.iter().any(
1510 |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::Manual { name, .. }, .. } if name == "man")
1511 )
1512 );
1513 assert!(children.iter().any(
1514 |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::External { uri }, .. } if uri == "https://example.test/docs")
1515 ));
1516 assert!(children.iter().any(
1517 |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::Email { address }, .. } if address == "docs@example.test")
1518 ));
1519 }
1520
1521 #[test]
1522 fn retains_unlabelled_mdoc_link_targets_before_trailing_punctuation() {
1523 let document = parse_manual_bytes(
1524 std::path::Path::new("external-link.9"),
1525 b".Dd August 19, 2026\n.Dt EXTERNAL-LINK 9\n.Os\n.Sh DESCRIPTION\n.Lk https://example.test/books .\n",
1526 )
1527 .expect("lower an unlabelled mdoc external link");
1528
1529 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1530 panic!("expected one external-link paragraph");
1531 };
1532 assert_eq!(inline_text(children), "https://example.test/books.");
1533 assert!(matches!(
1534 children.as_slice(),
1535 [
1536 Inline::Link {
1537 target: mant_ir::LinkTarget::External { uri },
1538 children: link_children,
1539 ..
1540 },
1541 Inline::Text { value },
1542 ] if uri == "https://example.test/books"
1543 && inline_text(link_children) == "https://example.test/books"
1544 && value == "."
1545 ));
1546 }
1547
1548 #[test]
1549 fn expands_mdoc_bsd_lifecycle_and_release_forms() {
1550 let source = b".Dd August 19, 2026\n.Dt BSD-LIFECYCLE 7\n.Os\n.Sh DESCRIPTION\n.Bx\n.Bx -alpha\n.Bx -beta\n.Bx -devel .\n.Bx 4.3 .\n.Bx 4.3 Net/2 .\n.Bx 386 0.1 .\n";
1551 let document = parse_manual_bytes(std::path::Path::new("bsd-lifecycle.7"), source)
1552 .expect("lower mdoc BSD lifecycle forms");
1553
1554 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1555 panic!("expected one BSD lifecycle paragraph");
1556 };
1557 assert_eq!(
1558 inline_text(children),
1559 "BSD BSD (currently in alpha test) BSD (currently in beta test) BSD (currently under development). 4.3BSD. 4.3BSD Net/2. 386BSD 0.1."
1560 );
1561 }
1562
1563 #[test]
1564 fn preserves_complete_mdoc_include_directives() {
1565 let document = parse_manual_bytes(
1566 std::path::Path::new("include.3"),
1567 b".Dd August 19, 2026\n.Dt INCLUDE 3\n.Os\n.Sh SYNOPSIS\n.In fido.h\n",
1568 )
1569 .expect("lower mdoc include");
1570
1571 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1572 panic!("expected one include paragraph");
1573 };
1574 assert_eq!(inline_text(children), "#include <fido.h>");
1575 assert!(matches!(
1576 children.as_slice(),
1577 [Inline::Code { value }] if value == "#include <fido.h>"
1578 ));
1579 }
1580
1581 #[test]
1582 fn propagates_nested_no_space_and_preserves_prefix_content() {
1583 let document = parse_manual_bytes(
1584 std::path::Path::new("no-space.7"),
1585 b".Dd August 19, 2026\n.Dt NO-SPACE 7\n.Os\n.Sh DESCRIPTION\n\
1586.Em Bell Labs Ns -derived\n\
1587.Ar job Ns s :\n\
1588.Sm off\n\
1589.Pf [\\-]ddd Cm \\&. No ddd\n\
1590.Sm on\n",
1591 )
1592 .expect("lower nested no-space macros");
1593
1594 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1595 panic!("expected one no-space paragraph");
1596 };
1597 assert_eq!(inline_text(children), "Bell Labs-derived jobs: [-]ddd.ddd");
1598 }
1599
1600 #[test]
1601 fn lowers_documented_mdoc_delimiters_and_common_roff_characters() {
1602 let path = temporary_source(
1603 "mdoc-delimiters",
1604 ".Dd July 19, 2026\n\
1605 .Dt DELIMITERS 7\n\
1606 .Os\n\
1607 .Sh DESCRIPTION\n\
1608 .Op optional\n\
1609 .Bq bracket\n\
1610 .Dq double\n\
1611 .Sq single\n\
1612 .Pq parenthesized\n\
1613 .Brq braced\n\
1614 .Aq angled\n\
1615 .Oo multi Ar value\n\
1616 .Oc\n\
1617 .Sh CHARACTERS\n\
1618 \\(en \\(em \\(aq \\(dq \\(co \\(rg \\(tm \\(bu \\(ha \\(ti \\(rs\n",
1619 );
1620
1621 let document = parse_manual_source(&path).expect("lower delimiter and character source");
1622 fs::remove_file(path).expect("remove temporary roff fixture");
1623
1624 let description = document.sections[0]
1625 .blocks
1626 .iter()
1627 .map(|block| match block {
1628 Block::Paragraph { children, .. } => inline_text(children),
1629 _ => String::new(),
1630 })
1631 .collect::<Vec<_>>()
1632 .join(" ");
1633 for expected in [
1634 "[optional]",
1635 "[bracket]",
1636 "“double”",
1637 "‘single’",
1638 "(parenthesized)",
1639 "{braced}",
1640 "<angled>",
1641 "[multi value]",
1642 ] {
1643 assert!(
1644 description.contains(expected),
1645 "missing {expected:?} in {description:?}"
1646 );
1647 }
1648
1649 let [Block::Paragraph { children, .. }] = document.sections[1].blocks.as_slice() else {
1650 panic!("expected one special-character paragraph");
1651 };
1652 assert_eq!(inline_text(children), "– — ' \" © ® ™ • ^ ~ \\");
1653 }
1654
1655 #[test]
1656 fn retains_punctuation_after_implicit_mdoc_enclosures() {
1657 let document = parse_manual_bytes(
1658 std::path::Path::new("implicit-enclosure-punctuation.7"),
1659 b".Dd August 19, 2026\n.Dt IMPLICIT-ENCLOSURE-PUNCTUATION 7\n.Os\n\
1660.Sh DESCRIPTION\nWhen disabled\n.Pq all features remain readable ;\ncontinue safely.\n",
1661 )
1662 .expect("lower punctuation after an implicit enclosure");
1663
1664 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
1665 panic!("expected one paragraph");
1666 };
1667 assert_eq!(
1668 inline_text(children),
1669 "When disabled (all features remain readable); continue safely."
1670 );
1671 }
1672
1673 #[test]
1674 fn lowers_the_pinned_named_character_catalog_without_silent_deletion() {
1675 let document = parse_manual_bytes(
1676 std::path::Path::new("named-characters.7"),
1677 b".TH NAMED-CHARACTERS 7\n\
1678.SH TEST\n\
1679at=\\(at ga=\\(ga oq=\\(oq arrow=\\(-> larrow=\\(<- mu=\\(mu\n\
1680de=\\(de pl=\\(pl dg=\\(dg ua=\\(ua da=\\(da lB=\\(lB rB=\\(rB\n\
1681unknown=\\[future-glyph]\n",
1682 )
1683 .expect("lower named characters");
1684
1685 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1686 panic!("expected one character paragraph");
1687 };
1688 assert_eq!(
1689 inline_text(children),
1690 "at=@ ga=` oq=' arrow=→ larrow=← mu=× de=° pl=+ dg=† ua=↑ da=↓ lB=[ rB=] unknown=\\[future-glyph]"
1691 );
1692 }
1693
1694 #[test]
1695 fn round_trips_raw_and_bracketed_unicode_manual_text() {
1696 let source = ".TH UNICODE 7\n\
1697.SH TEST\n\
1698Raw UTF-8: Mašláňová café — naïve.\n\
1699Escaped: Ma\\[u0161]l\\[u00E1] and \\[u2014] dash.\n";
1700 let document = parse_manual_bytes(std::path::Path::new("unicode.7"), source.as_bytes())
1701 .expect("lower raw and escaped Unicode");
1702
1703 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1704 panic!("expected one Unicode paragraph");
1705 };
1706 let rendered = inline_text(children);
1707 assert!(rendered.contains("Raw UTF-8: Mašláňová café — naïve."));
1708 assert!(rendered.contains("Escaped: Mašlá and — dash."));
1709 assert!(!rendered.contains(r"\[u"));
1710 }
1711
1712 #[test]
1713 fn preserves_explicit_mdoc_function_and_enclosure_structure() {
1714 let document = parse_manual_bytes(
1715 std::path::Path::new("explicit-mdoc.1"),
1716 b".Dd August 17, 2026\n\
1717.Dt EXPLICIT-MDOC 1\n\
1718.Os\n\
1719.Sh NAME\n\
1720.Nm explicit-mdoc\n\
1721.Nd exercise explicit blocks\n\
1722.Sh FUNCTION\n\
1723.Ft int\n\
1724.Fo audit_open\n\
1725.Fa const char *path\n\
1726.Fa int flags\n\
1727.Fc\n\
1728.Sh ENCLOSURES\n\
1729.Ao\nangle\n.Ac\n\
1730.Bo\nbracket\n.Bc\n\
1731.Do\ndouble\n.Dc\n\
1732.Po\nparenthesized\n.Pc\n\
1733.Qo\nquoted\n.Qc\n\
1734.So\nsingle\n.Sc\n\
1735.Bro\nbraced\n.Brc\n\
1736.Oo\noptional\n.Oc\n\
1737.Eo <<\ngeneric\n.Ec >>\n\
1738.Es [[ ]]\n\
1739.En custom\n",
1740 )
1741 .expect("lower explicit mdoc blocks");
1742
1743 let function = &document.sections[1];
1744 let [
1745 Block::Paragraph {
1746 children: return_type,
1747 ..
1748 },
1749 Block::Paragraph {
1750 children: declaration,
1751 ..
1752 },
1753 ] = function.blocks.as_slice()
1754 else {
1755 panic!("expected return type and function declaration paragraphs");
1756 };
1757 assert_eq!(inline_text(return_type), "int");
1758 assert_eq!(
1759 inline_text(declaration),
1760 "audit_open(const char *path, int flags)"
1761 );
1762 assert!(matches!(
1763 declaration.first(),
1764 Some(Inline::Strong { children }) if inline_text(children) == "audit_open"
1765 ));
1766
1767 let [Block::Paragraph { children, .. }] = document.sections[2].blocks.as_slice() else {
1768 panic!("expected one enclosure paragraph");
1769 };
1770 assert_eq!(
1771 inline_text(children),
1772 "<angle> [bracket] “double” (parenthesized) “quoted” ‘single’ {braced} \
1773 [optional] <<generic>> [[custom]]"
1774 );
1775 assert_eq!(document.diagnostics.len(), 2);
1776 assert!(
1777 document
1778 .diagnostics
1779 .iter()
1780 .all(|diagnostic| diagnostic.message.starts_with("obsolete macro:")),
1781 "{:?}",
1782 document.diagnostics
1783 );
1784 }
1785
1786 #[test]
1787 fn preserves_the_complete_libbsd_library_identity() {
1788 let document = parse_manual_bytes(
1789 std::path::Path::new("libbsd.3bsd"),
1790 b".Dd August 19, 2026\n.Dt LIBBSD 3bsd\n.Os\n.Sh LIBRARY\n.Lb libbsd\n",
1791 )
1792 .expect("lower libbsd library declaration");
1793 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1794 panic!("expected one library paragraph");
1795 };
1796
1797 assert_eq!(
1798 inline_text(children),
1799 "Utility functions from BSD systems (libbsd, -lbsd)"
1800 );
1801 }
1802
1803 #[test]
1804 fn joins_the_final_mdoc_bibliography_authors() {
1805 let document = parse_manual_bytes(
1806 std::path::Path::new("bibliography.3"),
1807 b".Dd August 19, 2026\n.Dt BIBLIOGRAPHY 3\n.Os\n.Sh SEE ALSO\n\
1808.Rs\n.%A Bentley, J.L.\n.%A McIlroy, M.D.\n.%T Engineering a Sort Function\n.Re\n",
1809 )
1810 .expect("lower mdoc bibliography");
1811 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1812 panic!("expected one bibliography paragraph");
1813 };
1814
1815 assert_eq!(
1816 inline_text(children),
1817 "Bentley, J.L. and McIlroy, M.D. Engineering a Sort Function."
1818 );
1819 }
1820
1821 #[test]
1822 fn preserves_mdoc_command_names_in_each_synopsis_form() {
1823 let document = parse_manual_bytes(
1824 std::path::Path::new("fido2-cred.1"),
1825 b".Dd August 19, 2026\n.Dt FIDO2-CRED 1\n.Os\n.Sh NAME\n.Nm fido2-cred\n.Nd make a credential\n.Sh SYNOPSIS\n.Nm\n.Fl M\n.Op Fl i Ar input_file\n.Nm fido2-cred\n.Fl V\n.Nm helper\n.Op Fl q\n",
1826 )
1827 .expect("lower mdoc synopsis names");
1828 let synopsis = &document.sections[1];
1829 let rendered = synopsis
1830 .blocks
1831 .iter()
1832 .map(|block| match block {
1833 Block::Paragraph { children, .. } => inline_text(children),
1834 block => panic!("expected synopsis paragraph, got {block:?}"),
1835 })
1836 .collect::<Vec<_>>();
1837
1838 assert_eq!(
1839 rendered,
1840 [
1841 "fido2-cred -M [-i input_file]",
1842 "fido2-cred -V",
1843 "helper [-q]",
1844 ]
1845 );
1846 }
1847
1848 #[test]
1849 fn preserves_mdoc_name_and_function_punctuation_by_context() {
1850 let document = parse_manual_bytes(
1851 std::path::Path::new("function-punctuation.3"),
1852 b".Dd August 19, 2026\n.Dt FUNCTION-PUNCTUATION 3\n.Os\n\
1853.Sh NAME\n.Nm function-punctuation\n.Nd test generated punctuation\n\
1854.Sh SYNOPSIS\n.Fn compact_call \"int value\"\n\
1855.Fo explicit_call\n.Fa \"int value\"\n.Fc\n\
1856.Sh DESCRIPTION\nThe\n.Fn prose_call \"int value\"\nfunction.\n",
1857 )
1858 .expect("lower mdoc generated punctuation");
1859
1860 let [Block::Paragraph { children: name, .. }] = document.sections[0].blocks.as_slice()
1861 else {
1862 panic!("expected one NAME paragraph");
1863 };
1864 assert_eq!(
1865 inline_text(name),
1866 "function-punctuation — test generated punctuation"
1867 );
1868
1869 let synopsis = document.sections[1]
1870 .blocks
1871 .iter()
1872 .map(|block| match block {
1873 Block::Paragraph { children, .. } => inline_text(children),
1874 block => panic!("expected synopsis paragraph, got {block:?}"),
1875 })
1876 .collect::<Vec<_>>();
1877 assert_eq!(
1878 synopsis,
1879 ["compact_call(int value);", "explicit_call(int value);"]
1880 );
1881
1882 let [
1883 Block::Paragraph {
1884 children: description,
1885 ..
1886 },
1887 ] = document.sections[2].blocks.as_slice()
1888 else {
1889 panic!("expected one DESCRIPTION paragraph");
1890 };
1891 assert_eq!(
1892 inline_text(description),
1893 "The prose_call(int value) function."
1894 );
1895 }
1896
1897 #[test]
1898 fn preserves_mdoc_synopsis_declaration_units() {
1899 let document = parse_manual_bytes(
1900 std::path::Path::new("synopsis-declarations.3"),
1901 b".Dd August 19, 2026\n.Dt SYNOPSIS-DECLARATIONS 3\n.Os\n\
1902.Sh SYNOPSIS\n.In synprobe.h\n.Ft const struct stat *\n\
1903.Fn synprobe_first \"struct thing *a\"\n.Ft void\n\
1904.Fo synprobe_second\n.Fa \"struct thing *a\"\n.Fa \"int n\"\n.Fc\n\
1905.Fn synprobe_third \"int n\"\n",
1906 )
1907 .expect("lower mdoc synopsis declarations");
1908
1909 let rendered = document.sections[0]
1910 .blocks
1911 .iter()
1912 .map(|block| match block {
1913 Block::Paragraph { children, .. } => inline_text(children),
1914 block => panic!("expected synopsis declaration paragraph, got {block:?}"),
1915 })
1916 .collect::<Vec<_>>();
1917
1918 assert_eq!(
1919 rendered,
1920 [
1921 "#include <synprobe.h>",
1922 "const struct stat * synprobe_first(struct thing *a);",
1923 "void synprobe_second(struct thing *a, int n);",
1924 "synprobe_third(int n);",
1925 ]
1926 );
1927 }
1928
1929 #[test]
1930 fn preserves_printable_roff_content_outside_formal_sections() {
1931 let document = parse_manual_bytes(
1932 std::path::Path::new("manweb.1"),
1933 b".TH MANWEB 1\n .SH NAME\nmanweb - browse generated documentation\n.SH SYNOPSIS\n.B manweb\n",
1934 )
1935 .expect("lower root prose");
1936 let [Block::Paragraph { children, .. }] = document.blocks.as_slice() else {
1937 panic!("expected one root paragraph, got {:?}", document.blocks);
1938 };
1939
1940 assert_eq!(
1941 inline_text(children),
1942 " .SH NAME manweb - browse generated documentation"
1943 );
1944 assert_eq!(document.sections[0].title, "SYNOPSIS");
1945 }
1946
1947 #[test]
1948 fn discards_temporary_indent_arguments_without_hiding_the_next_line() {
1949 let document = parse_manual_bytes(
1950 std::path::Path::new("temporary-indent.8"),
1951 b".TH TEMPORARY-INDENT 8\n.SH EXAMPLES\n.ti +8n\nexample% command\n.ti\nexample% other\n",
1952 )
1953 .expect("lower temporary indentation requests");
1954
1955 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1956 panic!("expected one examples paragraph");
1957 };
1958 assert_eq!(inline_text(children), "example% command example% other");
1959 }
1960
1961 #[test]
1962 fn diagnoses_future_structural_macros_before_discarding_visible_parts() {
1963 let mut report = Parser::default()
1964 .parse_bytes(
1965 "future-structure.1",
1966 b".Dd August 17, 2026\n.Dt FUTURE 1\n.Os\n.Sh SYNOPSIS\n\
1967.Fo future_call\n.Fa argument\n.Fc\n",
1968 )
1969 .expect("parse structural fixture");
1970 let block = find_macro_mut(&mut report.document.root, "Fo").expect("Fo block");
1971 block.macro_name = Some("FutureBlock".to_owned());
1972 let mut second_body = block
1973 .children
1974 .iter()
1975 .find(|child| child.kind == libmandoc_rs::NodeKind::Body)
1976 .cloned()
1977 .expect("function body");
1978 assert!(replace_first_text(&mut second_body, "second_argument"));
1979 block.children.push(second_body);
1980
1981 let document = lower_mandoc_document(std::path::Path::new("future-structure.1"), &report);
1982
1983 assert!(document.diagnostics.iter().any(|diagnostic| {
1984 diagnostic.code.as_deref() == Some("manual.unhandled-structural-parts")
1985 && diagnostic.message.contains("FutureBlock")
1986 }));
1987 let rendered = document.sections[0]
1988 .blocks
1989 .iter()
1990 .map(|block| match block {
1991 Block::Paragraph { children, .. } => inline_text(children),
1992 block => panic!("expected fallback paragraph, got {block:?}"),
1993 })
1994 .collect::<Vec<_>>();
1995 assert_eq!(rendered, ["argument", "second_argument"]);
1996 }
1997
1998 #[test]
1999 fn recognizes_explicitly_styled_traditional_man_references_in_any_section() {
2000 let path = temporary_source(
2001 "man-see-also",
2002 ".TH TOOL 1\n\
2003 .SH DESCRIPTION\n\
2004 The styled reference \\fBprintf\\fP(3) is usable here.\n\
2005 .SH SEE ALSO\n\
2006 .BR printf (3),\n\
2007 .BR man (1)\n",
2008 );
2009
2010 let document = parse_manual_source(&path).expect("lower man references");
2011 fs::remove_file(path).expect("remove temporary roff fixture");
2012
2013 let see_also = document
2014 .sections
2015 .iter()
2016 .find(|section| section.title == "SEE ALSO")
2017 .expect("SEE ALSO");
2018 let Block::Paragraph { children, .. } = &see_also.blocks[0] else {
2019 panic!("references are a paragraph");
2020 };
2021 assert!(children.iter().any(|inline| matches!(
2022 inline,
2023 Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
2024 if name == "printf" && manual_section == "3"
2025 )));
2026 assert!(children.iter().any(|inline| matches!(
2027 inline,
2028 Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
2029 if name == "man" && manual_section == "1"
2030 )));
2031
2032 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2033 panic!("description is a paragraph");
2034 };
2035 assert!(children.iter().any(|inline| matches!(
2036 inline,
2037 Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
2038 if name == "printf" && manual_section == "3"
2039 )));
2040 }
2041
2042 #[test]
2043 fn recognizes_legacy_sphinx_manual_links_in_roff_inputs() {
2044 let path = temporary_source(
2045 "sphinx-manual-links",
2046 ".TH BTRFS 8\n\
2047 .SH COMMANDS\n\
2048 See btrfs\\-subvolume(8) \\%<> and btrfs(5) \\%<> for details.\n\
2049 .EX\n\
2050 btrfs-subvolume(8) \\%<>\n\
2051 .EE\n",
2052 );
2053
2054 let document = parse_manual_source(&path).expect("lower legacy Sphinx references");
2055 fs::remove_file(path).expect("remove temporary roff fixture");
2056 let section = &document.sections[0];
2057 let paragraph = section
2058 .blocks
2059 .iter()
2060 .find_map(|block| match block {
2061 Block::Paragraph { children, .. } => Some(children),
2062 _ => None,
2063 })
2064 .expect("commands paragraph");
2065 assert_eq!(
2066 inline_text(paragraph),
2067 "See btrfs-subvolume(8) and btrfs(5) for details."
2068 );
2069 let references = paragraph
2070 .iter()
2071 .filter_map(|inline| match inline {
2072 Inline::Link {
2073 target:
2074 mant_ir::LinkTarget::Manual {
2075 name,
2076 manual_section: Some(manual_section),
2077 },
2078 ..
2079 } => Some((name.as_str(), manual_section.as_str())),
2080 _ => None,
2081 })
2082 .collect::<Vec<_>>();
2083 assert_eq!(references, [("btrfs-subvolume", "8"), ("btrfs", "5")]);
2084
2085 let literal = section
2086 .blocks
2087 .iter()
2088 .find_map(|block| match block {
2089 Block::Preformatted { children, .. } => Some(children),
2090 _ => None,
2091 })
2092 .expect("literal display");
2093 assert_eq!(inline_text(literal), "btrfs-subvolume(8) <>");
2094 assert!(!literal.iter().any(|inline| matches!(
2095 inline,
2096 Inline::Link {
2097 target: mant_ir::LinkTarget::Manual { .. },
2098 ..
2099 }
2100 )));
2101 }
2102
2103 #[test]
2104 fn lowers_modern_groff_manual_uri_and_mail_macros() {
2105 let path = temporary_source(
2106 "man-modern-links",
2107 ".TH TOOL 1\n\
2108 .SH DESCRIPTION\n\
2109 .MR git-add 1 ,\n\
2110 .PP\n\
2111 Read\n\
2112 .UR https://example.test/docs\n\
2113 Documentation\n\
2114 .UE\n\
2115 now.\n\
2116 .PP\n\
2117 Mail comments, suggestions and bug reports to\n\
2118 .MT docs@example.test\n\
2119 Sean\n\
2120 .ME .\n",
2121 );
2122
2123 let document = parse_manual_source(&path).expect("lower modern man links");
2124 fs::remove_file(path).expect("remove temporary roff fixture");
2125 let section = &document.sections[0];
2126 let mut manual = false;
2127 let mut web = false;
2128 let mut mail = false;
2129 for children in section.blocks.iter().filter_map(|block| match block {
2130 Block::Paragraph { children, .. } => Some(children),
2131 _ => None,
2132 }) {
2133 for inline in children {
2134 match inline {
2135 Inline::Link {
2136 target:
2137 mant_ir::LinkTarget::Manual {
2138 name,
2139 manual_section: Some(manual_section),
2140 },
2141 ..
2142 } if name == "git-add" && manual_section == "1" => manual = true,
2143 Inline::Link {
2144 target: mant_ir::LinkTarget::External { uri },
2145 ..
2146 } if uri == "https://example.test/docs" => {
2147 web = true;
2148 }
2149 Inline::Link {
2150 target: mant_ir::LinkTarget::Email { address },
2151 ..
2152 } if address == "docs@example.test" => {
2153 mail = true;
2154 }
2155 _ => {}
2156 }
2157 }
2158 }
2159
2160 assert!(manual && web && mail);
2161 assert!(section.blocks.iter().any(|block| match block {
2162 Block::Paragraph { children, .. } => inline_text(children).contains("git-add(1),"),
2163 _ => false,
2164 }));
2165 let linked_paragraphs = section
2166 .blocks
2167 .iter()
2168 .filter_map(|block| match block {
2169 Block::Paragraph { children, .. }
2170 if children.iter().any(|inline| {
2171 matches!(
2172 inline,
2173 Inline::Link {
2174 target: mant_ir::LinkTarget::External { .. },
2175 ..
2176 } | Inline::Link {
2177 target: mant_ir::LinkTarget::Email { .. },
2178 ..
2179 }
2180 )
2181 }) =>
2182 {
2183 Some(inline_text(children))
2184 }
2185 _ => None,
2186 })
2187 .collect::<Vec<_>>();
2188 assert_eq!(
2189 linked_paragraphs,
2190 [
2191 "Read Documentation ⟨https://example.test/docs⟩ now.",
2192 "Mail comments, suggestions and bug reports to Sean ⟨docs@example.test⟩."
2193 ]
2194 );
2195 }
2196
2197 #[test]
2198 fn searches_across_man_link_labels_and_visible_targets() {
2199 let source = b".TH LINK-SEARCH 1\n\
2200.SH REPORTING BUGS\n\
2201Mail comments, suggestions and bug reports to\n\
2202.MT docs@example.test\n\
2203Sean\n\
2204.ME .\n";
2205
2206 for pattern in ["bug reports to Sean", "docs@example.test"] {
2207 let query = crate::query_roff_bytes(source).expect("query link fixture");
2208 let result = crate::project_query_view(
2209 query,
2210 &mant_protocol::QueryView::Search {
2211 pattern: pattern.to_owned(),
2212 syntax: mant_protocol::SearchSyntax::Literal,
2213 case: mant_protocol::SearchCase::Sensitive,
2214 scope: mant_protocol::SearchScope::Visible,
2215 word: false,
2216 context_lines: 0,
2217 limit: 100,
2218 offset: 0,
2219 },
2220 )
2221 .expect("search link fixture");
2222 let crate::QueryViewResult::Search(search) = result else {
2223 panic!("expected search result");
2224 };
2225 assert_eq!(search.total, 1, "pattern={pattern:?}");
2226 }
2227 }
2228
2229 #[test]
2230 fn resolves_mdoc_section_references_and_explicit_targets() {
2231 let path = temporary_source(
2232 "mdoc-navigation",
2233 ".Dd July 19, 2026\n\
2234 .Dt NAVIGATION 1\n\
2235 .Os\n\
2236 .Sh DESCRIPTION\n\
2237 Continue with\n\
2238 .Sx DETAILS\n\
2239 .Tg explicit-option\n\
2240 .Fl x\n\
2241 .Sh DETAILS\n\
2242 Target content.\n",
2243 );
2244
2245 let document = parse_manual_source(&path).expect("lower navigation mdoc source");
2246 fs::remove_file(path).expect("remove temporary roff fixture");
2247
2248 assert_eq!(document.sections[0].id, "description-1");
2249 assert_eq!(document.sections[1].id, "details-2");
2250 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2251 panic!("expected navigation paragraph");
2252 };
2253 assert!(children.iter().any(|inline| matches!(
2254 inline,
2255 Inline::Link {
2256 target: mant_ir::LinkTarget::Section { id },
2257 children,
2258 ..
2259 } if id == "details-2" && inline_text(children) == "DETAILS"
2260 )));
2261 assert!(children.iter().any(|inline| matches!(
2262 inline,
2263 Inline::Anchor { id } if id == "explicit-option"
2264 )));
2265 }
2266
2267 #[test]
2268 fn resolves_a_unique_parenthetically_qualified_mdoc_section_reference() {
2269 let path = temporary_source(
2270 "mdoc-qualified-navigation",
2271 ".Dd July 19, 2026\n\
2272 .Dt NAVIGATION 1\n\
2273 .Os\n\
2274 .Sh DESCRIPTION\n\
2275 See\n\
2276 .Sx White Space Splitting\n\
2277 .Sh \"White Space Splitting (Field Splitting)\"\n\
2278 Target content.\n",
2279 );
2280
2281 let document = parse_manual_source(&path).expect("lower qualified navigation source");
2282 fs::remove_file(path).expect("remove temporary roff fixture");
2283
2284 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2285 panic!("expected navigation paragraph");
2286 };
2287 assert!(children.iter().any(|inline| matches!(
2288 inline,
2289 Inline::Link {
2290 target: mant_ir::LinkTarget::Section { id },
2291 children,
2292 ..
2293 } if id == "white-space-splitting-field-splitting-2"
2294 && inline_text(children) == "White Space Splitting"
2295 )));
2296 assert!(document.diagnostics.iter().all(|diagnostic| {
2297 diagnostic.code.as_deref() != Some("unresolved-section-reference")
2298 }));
2299 }
2300
2301 #[test]
2302 fn degrades_unresolved_mdoc_section_references_to_text() {
2303 let path = temporary_source(
2304 "mdoc-missing-section",
2305 ".Dd July 19, 2026\n.Dt NAVIGATION 1\n.Os\n.Sh DESCRIPTION\n.Sx MISSING\n",
2306 );
2307
2308 let document = parse_manual_source(&path).expect("lower unresolved navigation source");
2309 fs::remove_file(path).expect("remove temporary roff fixture");
2310
2311 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2312 panic!("expected reference paragraph");
2313 };
2314 assert_eq!(inline_text(children), "MISSING");
2315 assert!(children.iter().all(|inline| !matches!(
2316 inline,
2317 Inline::Link {
2318 target: mant_ir::LinkTarget::Section { .. },
2319 ..
2320 }
2321 )));
2322 assert!(document.diagnostics.iter().any(|diagnostic| {
2323 diagnostic.code.as_deref() == Some("unresolved-section-reference")
2324 }));
2325 }
2326
2327 #[test]
2328 fn turns_captured_parser_findings_into_structured_diagnostics() {
2329 let path = temporary_source(
2330 "unsupported",
2331 ".Dd July 19, 2026\n.Dt BAD 1\n.Os\n.Sh NAME\n.Nm bad\n.ab\n",
2332 );
2333
2334 let document = parse_manual_source(&path).expect("best-effort parse");
2335 fs::remove_file(path).expect("remove temporary roff fixture");
2336
2337 assert!(
2338 document
2339 .diagnostics
2340 .iter()
2341 .any(|diagnostic| diagnostic.level == DiagnosticLevel::Unsupported)
2342 );
2343 }
2344
2345 #[test]
2346 fn masks_terminal_controls_before_native_parsing() {
2347 let path = temporary_source("controls", ".TH SAFE 1\n.SH NAME\nsafe \x1b[2J text\n");
2348
2349 let document = parse_manual_source(&path).expect("parse sanitized manual");
2350 fs::remove_file(path).expect("remove temporary roff fixture");
2351
2352 assert!(
2353 document.diagnostics.iter().any(|diagnostic| {
2354 diagnostic.code.as_deref() == Some("manual.control-characters")
2355 })
2356 );
2357 }
2358
2359 #[test]
2360 fn lowers_normalized_ordered_lists_and_literal_displays() {
2361 let path = temporary_source(
2362 "normalized",
2363 ".Dd July 19, 2026\n.Dt NORMALIZED 1\n.Os\n.Sh CONTENT\n\
2364 .Bl -enum -compact\n.It\nfirst\n.It\nsecond\n.El\n\
2365 .Bd -literal -offset 6n\nline one\nline two\n.Ed\n",
2366 );
2367
2368 let document = parse_manual_source(&path).expect("lower normalized mdoc");
2369 fs::remove_file(path).expect("remove temporary roff fixture");
2370
2371 assert!(matches!(
2372 document.sections[0].blocks[0],
2373 Block::List {
2374 kind: mant_ir::ListKind::Ordered,
2375 compact: true,
2376 ..
2377 }
2378 ));
2379 assert!(matches!(
2380 document.sections[0].blocks[1],
2381 Block::Preformatted { layout, .. } if layout.indent_columns == 6
2382 ));
2383 }
2384
2385 #[test]
2386 fn lowers_normalized_mdoc_font_and_author_layout() {
2387 let path = temporary_source(
2388 "normalized-mdoc-modes",
2389 ".Dd July 19, 2026\n\
2390 .Dt NORMALIZED-MODES 1\n\
2391 .Os\n\
2392 .Sh AUTHORS\n\
2393 .An -split\n\
2394 .An Alice Example\n\
2395 .An Bob Example\n\
2396 .An -nosplit\n\
2397 .An Carol Example\n\
2398 .An Dave Example\n\
2399 .Sh DESCRIPTION\n\
2400 .Bf -literal\n\
2401 literal text\n\
2402 .Ef\n",
2403 );
2404
2405 let document = parse_manual_source(&path).expect("lower normalized mdoc modes");
2406 fs::remove_file(path).expect("remove temporary roff fixture");
2407
2408 let authors = &document.sections[0];
2409 let Block::Paragraph { children, .. } = &authors.blocks[0] else {
2410 panic!("authors are one paragraph");
2411 };
2412 assert_eq!(
2413 inline_text(children),
2414 "Alice Example\nBob Example Carol Example Dave Example"
2415 );
2416
2417 let description = &document.sections[1];
2418 let Block::Paragraph { children, .. } = &description.blocks[0] else {
2419 panic!("font block is a paragraph");
2420 };
2421 assert!(matches!(
2422 children.as_slice(),
2423 [Inline::Code { value }] if value == "literal text"
2424 ));
2425 }
2426
2427 #[test]
2428 fn mdoc_definition_layout_uses_the_normalized_list_width() {
2429 let path = temporary_source(
2430 "mdoc-definition-widths",
2431 ".Dd July 23, 2026\n.Dt WIDTHS 1\n.Os\n.Sh ITEMS\n\
2432 .Bl -tag -width 20n\n.It tenletters\nwide description\n.El\n\
2433 .Bl -tag -width 3n\n.It short\nnarrow description\n.El\n",
2434 );
2435
2436 let document = parse_manual_source(&path).expect("lower mdoc definition widths");
2437 fs::remove_file(path).expect("remove temporary roff fixture");
2438
2439 let lists = document.sections[0]
2440 .blocks
2441 .iter()
2442 .filter_map(|block| match block {
2443 Block::DefinitionList { items, .. } => Some(items),
2444 _ => None,
2445 })
2446 .collect::<Vec<_>>();
2447 assert_eq!(lists.len(), 2);
2448 assert!(lists[0][0].inline_term);
2449 assert!(!lists[1][0].inline_term);
2450 }
2451
2452 #[test]
2453 fn lowers_the_pinned_large_mdoc_fixture_without_empty_sections() {
2454 let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2455 .join("../libmandoc-rs/vendor/mandoc-1.14.6/mandoc.1");
2456
2457 let document = parse_manual_source(&source).expect("lower vendored mandoc manual");
2458
2459 assert!(document.sections.len() > 5);
2460 assert!(
2461 document
2462 .sections
2463 .iter()
2464 .any(|section| section.title == "DESCRIPTION")
2465 );
2466 assert!(
2467 document
2468 .sections
2469 .iter()
2470 .all(|section| !section.blocks.is_empty() || !section.children.is_empty())
2471 );
2472 }
2473
2474 #[test]
2475 fn lowers_tbl_and_eqn_payloads_into_structured_blocks() {
2476 let path = temporary_source(
2477 "table-equation",
2478 ".TH PAYLOAD 1\n.SH TABLE\n.TS\ntab(|);\nl r.\nleft|right\n.TE\n\
2479 .SH EQUATION\n.EQ\nx + {width over 2}\n.EN\n",
2480 );
2481
2482 let document = parse_manual_source(&path).expect("lower table and equation");
2483 fs::remove_file(path).expect("remove temporary roff fixture");
2484
2485 assert!(matches!(
2486 document.sections[0].blocks[0],
2487 Block::Table { ref rows, .. } if rows.len() == 1 && rows[0].cells.len() == 2
2488 ));
2489 assert!(matches!(
2490 document.sections[1].blocks[0],
2491 Block::Equation { ref value, .. } if value == "x + width / 2"
2492 ));
2493 }
2494
2495 #[test]
2496 fn large_tbl_rows_scale_without_changing_their_topology() {
2497 const ROW_COUNT: usize = 2_048;
2498 let mut source = String::from(".TH TABLE-SCALE 7\n.SH TABLE\n.TS\nl l.\n");
2499 for index in 0..ROW_COUNT {
2500 writeln!(source, "left {index}\tright {index}").expect("append table row");
2501 }
2502 source.push_str(".TE\n");
2503
2504 let document = parse_manual_bytes(std::path::Path::new("table-scale.7"), source.as_bytes())
2505 .expect("lower large table");
2506
2507 let [Block::Table { rows, .. }] = document.sections[0].blocks.as_slice() else {
2508 panic!("large tbl input must remain one table");
2509 };
2510 assert_eq!(rows.len(), ROW_COUNT);
2511 assert!(matches!(
2512 rows.first().and_then(|row| row.cells.first()),
2513 Some(mant_ir::TableCell { blocks, .. })
2514 if matches!(blocks.as_slice(), [Block::Paragraph { children, .. }]
2515 if inline_text(children) == "left 0")
2516 ));
2517 assert!(matches!(
2518 rows.last().and_then(|row| row.cells.get(1)),
2519 Some(mant_ir::TableCell { blocks, .. })
2520 if matches!(blocks.as_slice(), [Block::Paragraph { children, .. }]
2521 if inline_text(children) == format!("right {}", ROW_COUNT - 1))
2522 ));
2523 }
2524
2525 #[test]
2526 fn keeps_inline_equations_in_macro_arguments_and_filled_prose() {
2527 let document = parse_manual_bytes(
2528 std::path::Path::new("inline-equation.7"),
2529 b".TH EQNPROBE2 7\n.SH DESCRIPTION\n.EQ\ndelim $$\n.EN\n.TP\n.BR Dp\\~ \"$dx sub 1 ~ ldots ~ dx sub n$\"\nDraw a polygon with,\nfor $i = 1 , ldots , n + 1$,\nits vertex.\n",
2530 )
2531 .expect("lower inline equations");
2532
2533 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
2534 panic!(
2535 "expected one definition list: {:?}",
2536 document.sections[0].blocks
2537 );
2538 };
2539 let [item] = items.as_slice() else {
2540 panic!("expected one equation definition");
2541 };
2542 assert_eq!(inline_text(&item.terms[0]), "Dp dx _ 1 ... dx _ n");
2543 let [Block::Paragraph { children, .. }] = item.description.as_slice() else {
2544 panic!("expected one filled description: {:?}", item.description);
2545 };
2546 assert_eq!(
2547 inline_text(children),
2548 "Draw a polygon with, for i = 1 , ... , n + 1, its vertex."
2549 );
2550 assert!(children.iter().any(
2551 |child| matches!(child, Inline::Code { value } if value == "i = 1 , ... , n + 1")
2552 ));
2553 }
2554
2555 #[test]
2556 fn normalizes_inline_equations_retained_as_tbl_cell_text() {
2557 let document = parse_manual_bytes(
2558 std::path::Path::new("table-inline-equation.3"),
2559 b".TH TABLE-EQN 3\n.SH DESCRIPTION\n.EQ\ndelim %%\n.EN\n.TS\nl l.\n%0%\tfor values in % [ 0 , ~pi over 2 ]%\n.TE\n",
2560 )
2561 .expect("lower table equations");
2562
2563 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2564 panic!("expected equation table");
2565 };
2566 let [left, right] = rows[0].cells.as_slice() else {
2567 panic!("expected two cells");
2568 };
2569 let [Block::Paragraph { children: left, .. }] = left.blocks.as_slice() else {
2570 panic!("expected left paragraph");
2571 };
2572 let [
2573 Block::Paragraph {
2574 children: right, ..
2575 },
2576 ] = right.blocks.as_slice()
2577 else {
2578 panic!("expected right paragraph");
2579 };
2580 assert!(matches!(left.as_slice(), [Inline::Code { value }] if value == "0"));
2581 assert_eq!(inline_text(right), "for values in [ 0 , π / 2 ]");
2582 assert!(
2583 right
2584 .iter()
2585 .any(|child| matches!(child, Inline::Code { .. }))
2586 );
2587 }
2588
2589 #[test]
2590 fn bounds_distinct_tbl_equation_normalization_work() {
2591 let mut source =
2592 String::from(".TH TABLE-EQN-BUDGET 3\n.SH DESCRIPTION\n.EQ\ndelim %%\n.EN\n.TS\nl.\n");
2593 for index in 0..=MAX_INLINE_EQUATION_NORMALIZATIONS {
2594 writeln!(source, "%x{index}%").expect("write fixture row");
2595 }
2596 source.push_str(".TE\n");
2597
2598 let document = parse_manual_bytes(
2599 std::path::Path::new("table-inline-equation-budget.3"),
2600 source.as_bytes(),
2601 )
2602 .expect("lower a bounded number of table equations");
2603
2604 assert!(document.diagnostics.iter().any(|diagnostic| {
2605 diagnostic.code.as_deref() == Some("manual.inline-equation-budget")
2606 }));
2607 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2608 panic!("expected equation table");
2609 };
2610 assert_eq!(rows.len(), MAX_INLINE_EQUATION_NORMALIZATIONS + 1);
2611 }
2612
2613 #[test]
2614 fn preserves_tbl_rows_across_interleaved_comments_and_text_blocks() {
2615 let source = b".TH COMMENTED-TABLE 1\n.SH TABLE\n.TS\nl l.\na\t1\n.\\\" disabled text block T{\n.\\\" ignored\n.\\\" T}\nb\t2\nc\t3\nT{\n.BR d (1)\nT}\t4\ne\t5\n.TE\n";
2616 let document = parse_manual_bytes(std::path::Path::new("commented-table.1"), source)
2617 .expect("lower commented table");
2618
2619 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2620 panic!("expected a table");
2621 };
2622 assert_eq!(rows.len(), 5);
2623 let first_cells = rows
2624 .iter()
2625 .map(|row| match row.cells[0].blocks.as_slice() {
2626 [Block::Paragraph { children, .. }] => inline_text(children),
2627 cells => panic!("expected one paragraph per table cell: {cells:?}"),
2628 })
2629 .collect::<Vec<_>>();
2630 assert_eq!(first_cells, ["a", "b", "c", "d(1)", "e"]);
2631 }
2632
2633 #[test]
2634 fn keeps_tbl_vertical_span_markers_out_of_visible_cells() {
2635 let document = parse_manual_bytes(
2636 std::path::Path::new("vertical-table-span.1"),
2637 b".TH VERTICAL-TABLE-SPAN 1\n.SH ATTRIBUTES\n.TS\nl l l.\nInterface\tAttribute\tValue\nT{\n.BR demo (1)\nT}\tThread safety\tMT-Safe\n\\^\tAsync-signal safety\tAS-Unsafe\n\\^\tAsync-cancel safety\tAC-Unsafe\n.TE\n",
2638 )
2639 .expect("lower vertical table span");
2640
2641 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2642 panic!("expected a table");
2643 };
2644 assert_eq!(rows.len(), 4);
2645 assert_eq!(rows[1].cells[0].row_span, 3);
2646 assert!(rows[2].cells[0].blocks.is_empty());
2647 assert!(rows[3].cells[0].blocks.is_empty());
2648 }
2649
2650 #[test]
2651 fn preserves_tbl_rows_nested_in_unfilled_mdoc_displays() {
2652 let document = parse_manual_bytes(
2653 std::path::Path::new("unfilled-table.7"),
2654 b".Dd August 19, 2026\n.Dt UNFILLED-TABLE 7\n.Os\n.Sh DESCRIPTION\n\
2655.Bd -unfilled -offset indent\n.TS\ntab(@);\nl l.\nleft@right\nnext@value\n.TE\n.Ed\n",
2656 )
2657 .expect("lower table nested in an unfilled display");
2658
2659 let table = document.sections[0]
2660 .blocks
2661 .iter()
2662 .find_map(|block| match block {
2663 Block::Table { rows, .. } => Some(rows),
2664 _ => None,
2665 })
2666 .expect("nested table must remain structured");
2667 assert_eq!(table.len(), 2);
2668 assert_eq!(table[0].cells.len(), 2);
2669 assert!(
2670 document.sections[0]
2671 .blocks
2672 .iter()
2673 .all(|block| !matches!(block, Block::Preformatted { children, .. } if children.is_empty())),
2674 "the surrounding display must not leave an empty placeholder"
2675 );
2676 }
2677
2678 #[test]
2679 fn keeps_unexpanded_tabular_cells_visible_with_a_diagnostic() {
2680 let document = parse_manual_bytes(
2681 std::path::Path::new("unexpanded-table-cell.7"),
2682 b".TH UNEXPANDED-TABLE-CELL 7\n.SH DESCRIPTION\n.TS\nl l.\n1\t\\*[unknown-label]\n.TE\n",
2683 )
2684 .expect("lower unresolved formatter string in a table cell");
2685
2686 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2687 panic!("expected a structured table");
2688 };
2689 assert_eq!(rows[0].cells.len(), 2);
2690 let [Block::Paragraph { children, .. }] = rows[0].cells[1].blocks.as_slice() else {
2691 panic!("expected one recovered table-cell paragraph");
2692 };
2693 assert_eq!(inline_text(children), r"\*[unknown-label]");
2694 assert!(document.diagnostics.iter().any(|diagnostic| {
2695 diagnostic.level == DiagnosticLevel::Unsupported
2696 && diagnostic.code.as_deref() == Some("manual.unexpanded-table-cell")
2697 }));
2698 }
2699
2700 #[test]
2701 fn restores_mdoc_names_inside_tbl_text_blocks() {
2702 let document = parse_manual_bytes(
2703 std::path::Path::new("table-text-block.3"),
2704 b".Dd August 19, 2026\n.Dt TABLE-TEXT-BLOCK 3\n.Os\n\
2705.Sh NAME\n.Nm table-text-block\n.Nd test tbl text blocks\n\
2706.Sh ATTRIBUTES\n.TS\nallbox;\nl l.\nInterface\tValue\n\
2707T{\n.Nm\nT}\tMT-Safe\n.TE\n",
2708 )
2709 .expect("lower tbl text blocks");
2710
2711 let Block::Table { rows, .. } = &document.sections[1].blocks[0] else {
2712 panic!("expected attributes table");
2713 };
2714 let [Block::Paragraph { children, .. }] = rows[1].cells[0].blocks.as_slice() else {
2715 panic!("expected recovered name cell");
2716 };
2717 assert_eq!(inline_text(children), "table-text-block");
2718 assert!(matches!(children.as_slice(), [Inline::Strong { .. }]));
2719 }
2720
2721 #[test]
2722 fn keeps_semantic_links_inside_tbl_text_blocks() {
2723 let document = parse_manual_bytes(
2724 std::path::Path::new("table-text-link.1"),
2725 b".TH TABLE-TEXT-LINK 1\n\
2726.nr do-fallback 0\n\
2727.if !\\n(.f .nr do-fallback 1\n\
2728.if \\n[do-fallback] \\{\\\n\
2729. de MR\n\
2730. ie \\\\n(.$=1 \\\n\
2731. I \\%\\\\$1\n\
2732. el \\\n\
2733. IR \\%\\\\$1 (\\\\$2)\\\\$3\n\
2734. .\n\
2735.\\}\n\
2736.rr do-fallback\n\
2737.SH DESCRIPTION\n\
2738.TS\ntab($);\nl l.\ngrn$T{\nrenders\n.MR gremlin 1\ndiagrams;\nT}\n\
2739gperl$T{\npopulates\n.I groff\nregisters using\n.MR perl 1 ;\nT}\n.TE\n",
2740 )
2741 .expect("lower semantic tbl text block");
2742
2743 let [Block::Table { rows, .. }] = document.sections[0].blocks.as_slice() else {
2744 panic!("semantic table content must not escape into a separate paragraph");
2745 };
2746 let [Block::Paragraph { children, .. }] = rows[0].cells[1].blocks.as_slice() else {
2747 panic!("expected semantic table cell paragraph");
2748 };
2749 assert_eq!(inline_text(children), "renders gremlin(1) diagrams;");
2750 assert!(children.iter().any(|child| matches!(
2751 child,
2752 Inline::Link {
2753 target: mant_ir::LinkTarget::Manual { name, manual_section },
2754 ..
2755 } if name == "gremlin" && manual_section.as_deref() == Some("1")
2756 )));
2757 let [Block::Paragraph { children, .. }] = rows[1].cells[1].blocks.as_slice() else {
2758 panic!("expected styled semantic table cell paragraph");
2759 };
2760 assert_eq!(
2761 inline_text(children),
2762 "populates groff registers using perl(1);"
2763 );
2764 assert!(
2765 children
2766 .iter()
2767 .any(|child| matches!(child, Inline::Emphasis { .. }))
2768 );
2769 }
2770
2771 #[test]
2772 fn restores_alternating_font_arguments_inside_tbl_text_blocks() {
2773 let document = parse_manual_bytes(
2774 std::path::Path::new("table-text-alternation.7"),
2775 b".TH TABLE-TEXT-ALTERNATION 7\n.SH DESCRIPTION\n.TS\nl l.\nT{\n\
2776.BI \\[aq] s1 \\[aq] s2 \\[aq]\nT}\tT{\n\
2777.I s1\nproduces the same formatted output as\n.IR s2 .\nT}\n.TE\n",
2778 )
2779 .expect("lower alternating man macros inside a tbl text block");
2780
2781 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2782 panic!("expected a structured table");
2783 };
2784 let [left, right] = rows[0].cells.as_slice() else {
2785 panic!("expected both reconstructed table cells");
2786 };
2787 let [Block::Paragraph { children: left, .. }] = left.blocks.as_slice() else {
2788 panic!("expected a reconstructed left table-cell paragraph");
2789 };
2790 let [
2791 Block::Paragraph {
2792 children: right, ..
2793 },
2794 ] = right.blocks.as_slice()
2795 else {
2796 panic!("expected a reconstructed right table-cell paragraph");
2797 };
2798 assert_eq!(inline_text(left), "'s1's2'");
2799 assert_eq!(
2800 inline_text(right),
2801 "s1 produces the same formatted output as s2."
2802 );
2803 assert!(
2804 right
2805 .iter()
2806 .any(|inline| matches!(inline, Inline::Emphasis { .. }))
2807 );
2808 }
2809
2810 #[test]
2811 fn restores_nested_mdoc_requests_inside_tbl_text_blocks() {
2812 let document = parse_manual_bytes(
2813 std::path::Path::new("table-mdoc-requests.8"),
2814 b".Dd August 19, 2026\n.Dt TABLE-MDOC-REQUESTS 8\n.Os\n.Sh DESCRIPTION\n\
2815.TS\ntab(@);\nl l.\nT{\n.Cm sip Ar addr Ns Op / Ns Ar mask\nT}@T{\n\
2816bitwise and of the address with\n.Ar mask\nequals\n.Ar addr .\n.Ar addr\n\
2817can be an IPv4 or IPv6 address.\nT}\n.TE\n",
2818 )
2819 .expect("lower nested mdoc requests in table text blocks");
2820
2821 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2822 panic!("expected a structured table");
2823 };
2824 let [left, right] = rows[0].cells.as_slice() else {
2825 panic!("expected two reconstructed table cells");
2826 };
2827 let [Block::Paragraph { children: left, .. }] = left.blocks.as_slice() else {
2828 panic!("expected reconstructed selector cell");
2829 };
2830 let [
2831 Block::Paragraph {
2832 children: right, ..
2833 },
2834 ] = right.blocks.as_slice()
2835 else {
2836 panic!("expected reconstructed description cell");
2837 };
2838 assert_eq!(inline_text(left), "sip addr[/mask]");
2839 assert_eq!(
2840 inline_text(right),
2841 "bitwise and of the address with mask equals addr. addr can be an IPv4 or IPv6 address."
2842 );
2843 assert!(
2844 left.iter()
2845 .any(|inline| matches!(inline, Inline::Strong { .. }))
2846 );
2847 assert!(
2848 right
2849 .iter()
2850 .any(|inline| matches!(inline, Inline::Emphasis { .. }))
2851 );
2852 }
2853
2854 #[test]
2855 fn keeps_command_names_in_extended_mdoc_synopsis_terms() {
2856 let document = parse_manual_bytes(
2857 std::path::Path::new("extended-synopsis.8"),
2858 b".Dd August 19, 2026\n.Dt EXTENDED-SYNOPSIS 8\n.Os\n.Sh NAME\n\
2859.Nm zinject\n.Nd inject faults\n.Sh SYNOPSIS\n.Bl -tag -width Ds\n\
2860.It Xo\n.Nm zinject\n.Xc\nList injections.\n\
2861.It Xo\n.Nm zinject\n.Fl b Ar bookmark\n.Xc\nInject a bookmark.\n.El\n",
2862 )
2863 .expect("lower extended mdoc synopsis terms");
2864
2865 let Block::DefinitionList { items, .. } = &document.sections[1].blocks[0] else {
2866 panic!("expected synopsis definition list");
2867 };
2868 assert_eq!(inline_text(&items[0].terms[0]), "zinject");
2869 assert_eq!(inline_text(&items[1].terms[0]), "zinject -b bookmark");
2870 assert!(matches!(
2871 items[0].terms[0].as_slice(),
2872 [Inline::Strong { .. }]
2873 ));
2874 assert!(
2875 items[1].terms[0]
2876 .iter()
2877 .any(|inline| matches!(inline, Inline::Strong { .. }))
2878 );
2879 }
2880
2881 #[test]
2882 fn decodes_named_characters_inside_equations() {
2883 let document = parse_manual_bytes(
2884 std::path::Path::new("equation-characters.1"),
2885 b".TH EQUATION-CHARACTERS 1\n.SH EQUATION\n.EQ\n\\[*p] \\[mi] x\n.EN\n",
2886 )
2887 .expect("lower equation characters");
2888
2889 assert!(matches!(
2890 document.sections[0].blocks[0],
2891 Block::Equation { ref value, .. } if value == "\u{03c0} \u{2212} x"
2892 ));
2893 }
2894
2895 #[test]
2896 fn lowers_every_mdoc_column_list_cell() {
2897 let document = parse_manual_bytes(
2898 std::path::Path::new("columns.3"),
2899 b".Dd August 19, 2026\n.Dt COLUMNS 3\n.Os\n.Sh DESCRIPTION\n\
2900.Bl -column name type description\n.It Dv CLSET_TIMEOUT Ta \"struct timeval *\" Ta \"set total timeout\"\n.El\n",
2901 )
2902 .expect("lower mdoc column list");
2903
2904 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2905 panic!("expected column list to lower as a table");
2906 };
2907 assert_eq!(rows.len(), 1);
2908 assert_eq!(rows[0].cells.len(), 3);
2909 let rendered = rows[0]
2910 .cells
2911 .iter()
2912 .map(|cell| match cell.blocks.as_slice() {
2913 [Block::Paragraph { children, .. }] => inline_text(children),
2914 blocks => panic!("expected one paragraph per cell, got {blocks:?}"),
2915 })
2916 .collect::<Vec<_>>();
2917 assert_eq!(
2918 rendered,
2919 ["CLSET_TIMEOUT", "struct timeval *", "set total timeout"]
2920 );
2921 }
2922
2923 #[test]
2924 fn preserves_nested_mdoc_spacing_state_in_definition_terms() {
2925 let document = parse_manual_bytes(
2926 std::path::Path::new("nested-spacing.1"),
2927 b".Dd August 19, 2026\n.Dt NESTED-SPACING 1\n.Os\n.Sh OPTIONS\n\
2928.Bl -tag -width Ds\n.It Fl L Xo\n.Sm off\n.Ar local_socket : host : hostport\n.Sm on\n.Xc\nForward a socket.\n.El\n",
2929 )
2930 .expect("lower nested mdoc spacing controls");
2931
2932 let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
2933 panic!("expected an option definition list");
2934 };
2935 assert_eq!(
2936 inline_text(&items[0].terms[0]),
2937 "-L local_socket:host:hostport"
2938 );
2939 }
2940
2941 #[test]
2942 fn carries_mdoc_spacing_state_across_list_item_boundaries() {
2943 let document = parse_manual_bytes(
2944 std::path::Path::new("list-spacing.8"),
2945 b".Dd August 19, 2026\n.Dt LIST-SPACING 8\n.Os\n.Sh COMMANDS\n\
2946.Bl -tag -width Ds\n.Sm off\n.It Ic O Ar device\n.Sm on\n.It Ic done\nFinished.\n.El\n",
2947 )
2948 .expect("lower list-scoped mdoc spacing controls");
2949
2950 let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
2951 panic!("expected a command definition list");
2952 };
2953 assert_eq!(inline_text(&items[0].terms[0]), "Odevice");
2954 assert_eq!(inline_text(&items[1].terms[0]), "done");
2955 }
2956
2957 #[test]
2958 fn carries_mdoc_spacing_state_out_of_nested_synopsis_enclosures() {
2959 let document = parse_manual_bytes(
2960 std::path::Path::new("nested-synopsis-spacing.8"),
2961 b".Dd August 19, 2026\n.Dt NESTED-SYNOPSIS-SPACING 8\n.Os\n.Sh SYNOPSIS\n\
2962.Nm demo\n.Sm off\n.Oo Fl m\\~\n.Ar memory\n.Sm on\n.Oc\n\
2963.Op Fl o Ar variable Ns Cm = Ns Ar value\n.Ar name\n",
2964 )
2965 .expect("lower nested synopsis spacing transitions");
2966
2967 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2968 panic!("expected synopsis paragraph");
2969 };
2970 assert_eq!(
2971 inline_text(children),
2972 "demo [-m memory] [-o variable=value] name"
2973 );
2974 }
2975
2976 #[test]
2977 fn preserves_the_boundary_that_enters_a_compact_mdoc_term() {
2978 let document = parse_manual_bytes(
2979 std::path::Path::new("spacing-transition.5"),
2980 b".Dd August 19, 2026\n.Dt SPACING-TRANSITION 5\n.Os\n.Sh KEYWORDS\n\
2981.Bl -tag -width Ds\n.It Xo\n.Cm @newuser\n.Sm off\n.Ar name : uid : gid\n.Sm on\n.Xc\nCreate a user.\n.El\n",
2982 )
2983 .expect("lower an mdoc spacing transition inside a term");
2984
2985 let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
2986 panic!("expected a keyword definition list");
2987 };
2988 assert_eq!(inline_text(&items[0].terms[0]), "@newuser name:uid:gid");
2989 }
2990
2991 #[test]
2992 fn separates_alternative_terms_in_an_extended_mdoc_definition_head() {
2993 let document = parse_manual_bytes(
2994 std::path::Path::new("extended-term-alternatives.8"),
2995 b".Dd August 19, 2026\n.Dt EXTENDED-TERM-ALTERNATIVES 8\n.Os\n.Sh OPTIONS\n\
2996.Bl -tag -width Ds\n.It Xo\n.Sm off\n.Ar ipaddr\n.Op / Ar masklen\n.Pp\n\
2997.Ar ipaddr\n.Op / Ar prefixlen\n.Sm on\n.Xc\nAccept this peer.\n.El\n",
2998 )
2999 .expect("lower alternative extended definition terms");
3000
3001 let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
3002 panic!("expected a definition list");
3003 };
3004 assert_eq!(items.len(), 1);
3005 assert_eq!(items[0].terms.len(), 2);
3006 assert_eq!(inline_text(&items[0].terms[0]), "ipaddr[/masklen]");
3007 assert_eq!(inline_text(&items[0].terms[1]), "ipaddr[/prefixlen]");
3008 }
3009
3010 fn inline_text(children: &[Inline]) -> String {
3011 children
3012 .iter()
3013 .map(|child| match child {
3014 Inline::Text { value } | Inline::Code { value } => value.clone(),
3015 Inline::Strong { children }
3016 | Inline::Emphasis { children }
3017 | Inline::Link { children, .. } => inline_text(children),
3018 Inline::Anchor { .. } => String::new(),
3019 Inline::LineBreak => "\n".to_owned(),
3020 })
3021 .collect()
3022 }
3023}