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_consecutive_tp_aliases_ending_in_line_continuations() {
743 let path = temporary_source(
744 "continued-definition-aliases",
745 ".TH ALIASES 1\n\
746 .SH OPTIONS\n\
747 .TP\n\
748 .BI \"\\-symbols=\" \"file\"\\c\n\
749 .TP\n\
750 .BI \"\\-s \" \"file\"\\c\n\
751 \\&\n\
752 Read symbols.\n",
753 );
754
755 let document = parse_manual_source(&path).expect("lower consecutive TP aliases");
756 fs::remove_file(path).expect("remove temporary roff fixture");
757
758 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
759 panic!("expected one definition list");
760 };
761 assert_eq!(items.len(), 1);
762 assert_eq!(
763 items[0]
764 .terms
765 .iter()
766 .map(|term| inline_text(term))
767 .collect::<Vec<_>>(),
768 ["-symbols=file", "-s file"]
769 );
770 let Block::Paragraph { children, .. } = &items[0].description[0] else {
771 panic!("expected alias description paragraph");
772 };
773 assert_eq!(inline_text(children), "Read symbols.");
774 }
775
776 #[test]
777 fn preserves_man_synopsis_flow_and_alternating_fonts() {
778 let path = temporary_source(
779 "man-synopsis-flow",
780 ".TH MAN 1\n\
781 .SH SYNOPSIS\n\
782 .B man\n\
783 .RI [\\| \"man options\" \\|]\n\
784 .RI [\\|[\\| section \\|]\n\
785 .IR page \\ \\|.\\|.\\|.\\|]\\ \\.\\|.\\|.\\&\n\
786 .br\n\
787 .B man\n\
788 .B \\-k\n\
789 .RI [\\| \"apropos options\" \\|]\n\
790 .I regexp\n\
791 \\&.\\|.\\|.\\&\n\
792 .br\n\
793 .B man\n\
794 .BR \\-w \\||\\| \\-W\n\
795 .RI [\\| \"man options\" \\|]\n\
796 .I page\n\
797 \\&.\\|.\\|.\\&\n",
798 );
799
800 let document = parse_manual_source(&path).expect("lower man synopsis");
801 fs::remove_file(path).expect("remove temporary roff fixture");
802
803 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
804 panic!("expected one synopsis paragraph");
805 };
806 assert_eq!(
807 inline_text(children),
808 "man [man options] [[section] page ...] ...\n\
809 man -k [apropos options] regexp ...\n\
810 man -w|-W [man options] page ..."
811 );
812 assert_eq!(
813 children
814 .iter()
815 .filter(|node| matches!(node, Inline::LineBreak))
816 .count(),
817 2
818 );
819 assert!(children.iter().any(
820 |node| matches!(node, Inline::Emphasis { children } if inline_text(children) == "man options")
821 ));
822 assert!(children.iter().any(
823 |node| matches!(node, Inline::Strong { children } if inline_text(children) == "-w")
824 ));
825 assert!(children.iter().any(
826 |node| matches!(node, Inline::Strong { children } if inline_text(children) == "-W")
827 ));
828 }
829
830 #[test]
831 fn preserves_man_sy_heads_with_body_content_and_inline_fonts() {
832 let document = parse_manual_bytes(
833 std::path::Path::new("sy-heads.1"),
834 b".TH SY-HEADS 1 \"August 17, 2026\"\n\
835.SH SYNOPSIS\n\
836.SY getent\n\
837.RI [ option ]\n\
838.I database\n\
839.YS\n\
840.SH DESCRIPTION\n\
841.SY #!\\f[I]interpreter\\f[]\n\
842.RI [ optional-arg ]\n\
843.YS\n",
844 )
845 .expect("lower SY heads");
846
847 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
848 panic!("expected one synopsis paragraph");
849 };
850 assert_eq!(inline_text(children), "getent [option] database");
851 assert!(matches!(
852 children.first(),
853 Some(Inline::Strong { children }) if inline_text(children) == "getent"
854 ));
855
856 let [Block::Paragraph { children, .. }] = document.sections[1].blocks.as_slice() else {
857 panic!("expected one description paragraph");
858 };
859 assert_eq!(inline_text(children), "#!interpreter [optional-arg]");
860 assert!(matches!(
861 children.first(),
862 Some(Inline::Strong { children })
863 if children.iter().any(|inline| matches!(
864 inline,
865 Inline::Emphasis { children } if inline_text(children) == "interpreter"
866 ))
867 ));
868 assert!(
869 document.diagnostics.is_empty(),
870 "{:?}",
871 document.diagnostics
872 );
873 }
874
875 #[test]
876 fn keeps_man_synopsis_lines_together_inside_no_fill_examples() {
877 let document = parse_manual_bytes(
878 std::path::Path::new("no-fill-synopsis.2"),
879 b".TH NO-FILL-SYNOPSIS 2\n\
880.SH DESCRIPTION\n\
881.EX\n\
882.SY #!\\f[I]interpreter\\f[]\n\
883.RI [ optional-arg ]\n\
884.YS\n\
885.EE\n",
886 )
887 .expect("lower synopsis inside example");
888
889 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
890 panic!(
891 "no-fill synopsis must remain one preformatted block: {:?}",
892 document.sections[0].blocks
893 );
894 };
895 assert_eq!(inline_text(children), "#!interpreter\n[optional-arg]");
896 assert_eq!(
897 children
898 .iter()
899 .filter(|inline| matches!(inline, Inline::LineBreak))
900 .count(),
901 1
902 );
903 }
904
905 #[test]
906 fn preserves_explicit_blank_rows_inside_no_fill_displays() {
907 let document = parse_manual_bytes(
908 std::path::Path::new("no-fill-blank-row.7"),
909 b".TH NO-FILL-BLANK-ROW 7\n\
910.SH EXAMPLE\n\
911.EX\n\
912first line\n\
913\n\
914second line\n\
915.EE\n",
916 )
917 .expect("lower no-fill blank row");
918
919 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
920 panic!(
921 "no-fill display must remain preformatted: {:?}",
922 document.sections[0].blocks
923 );
924 };
925 assert_eq!(inline_text(children), "first line\n\nsecond line");
926 assert_eq!(
927 children
928 .iter()
929 .filter(|inline| matches!(inline, Inline::LineBreak))
930 .count(),
931 2
932 );
933 }
934
935 #[test]
936 fn preserves_zero_width_guard_rows_inside_no_fill_displays() {
937 let document = parse_manual_bytes(
938 std::path::Path::new("no-fill-zero-width-row.7"),
939 b".TH NO-FILL-ZERO-WIDTH-ROW 7\n\
940.SH EXAMPLE\n\
941.EX\n\
942first line\n\
943\\&\n\
944second line\n\
945.EE\n",
946 )
947 .expect("lower no-fill zero-width row");
948
949 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
950 panic!(
951 "no-fill display must remain preformatted: {:?}",
952 document.sections[0].blocks
953 );
954 };
955 assert_eq!(inline_text(children), "first line\n\nsecond line");
956 assert_eq!(
957 children
958 .iter()
959 .filter(|inline| matches!(inline, Inline::LineBreak))
960 .count(),
961 2
962 );
963 }
964
965 #[test]
966 fn preserves_lines_inside_font_blocks_nested_in_literal_displays() {
967 let document = parse_manual_bytes(
968 std::path::Path::new("literal-font-block.7"),
969 b".Dd August 20, 2026\n\
970.Dt LITERAL-FONT-BLOCK 7\n\
971.Os\n\
972.Sh EXAMPLE\n\
973.Bd -literal\n\
974.Bf Sy\n\
975first line\n\
976second line\n\
977.Ef\n\
978.Ed\n",
979 )
980 .expect("lower font block inside literal display");
981
982 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
983 panic!(
984 "literal display must remain one preformatted block: {:?}",
985 document.sections[0].blocks
986 );
987 };
988 assert_eq!(inline_text(children), "first line\nsecond line");
989 assert_eq!(
990 children
991 .iter()
992 .filter(|inline| matches!(inline, Inline::LineBreak))
993 .count(),
994 1
995 );
996 }
997
998 #[test]
999 fn preserves_literal_display_lines_inside_literal_font_blocks() {
1000 let document = parse_manual_bytes(
1001 std::path::Path::new("literal-display-inside-font-block.7"),
1002 b".Dd August 21, 2026\n\
1003.Dt LITERAL-DISPLAY-INSIDE-FONT-BLOCK 7\n\
1004.Os\n\
1005.Sh EXAMPLE\n\
1006.Bf Li\n\
1007.Bd -literal\n\
1008first line\n\
1009second line\n\
1010.Ed\n\
1011.Ef\n",
1012 )
1013 .expect("lower literal display inside literal font block");
1014
1015 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
1016 panic!(
1017 "fonted literal display must remain preformatted: {:?}",
1018 document.sections[0].blocks
1019 );
1020 };
1021 assert_eq!(inline_text(children), "first line\nsecond line");
1022 assert_eq!(
1023 children
1024 .iter()
1025 .filter(|inline| matches!(inline, Inline::LineBreak))
1026 .count(),
1027 1
1028 );
1029 }
1030
1031 #[test]
1032 fn preserves_lines_inside_nested_literal_displays() {
1033 let document = parse_manual_bytes(
1034 std::path::Path::new("nested-literal-display.7"),
1035 b".Dd August 21, 2026\n\
1036.Dt NESTED-LITERAL-DISPLAY 7\n\
1037.Os\n\
1038.Sh EXAMPLE\n\
1039.Bd -literal\n\
1040first line\n\
1041.Bd -literal\n\
1042second line\n\
1043third line\n\
1044.Ed\n",
1045 )
1046 .expect("lower nested literal display");
1047
1048 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
1049 panic!(
1050 "nested literal displays must remain one preformatted block: {:?}",
1051 document.sections[0].blocks
1052 );
1053 };
1054 assert_eq!(inline_text(children), "first line\nsecond line\nthird line");
1055 assert_eq!(
1056 children
1057 .iter()
1058 .filter(|inline| matches!(inline, Inline::LineBreak))
1059 .count(),
1060 2
1061 );
1062 }
1063
1064 #[test]
1065 fn collapses_a_no_fill_blank_line_run_to_one_visual_separator() {
1066 let document = parse_manual_bytes(
1067 std::path::Path::new("no-fill-blank-run.7"),
1068 b".TH NO-FILL-BLANK-RUN 7\n\
1069.SH EXAMPLE\n\
1070.EX\n\
1071first line\n\
1072\n\
1073\n\
1074second line\n\
1075.EE\n",
1076 )
1077 .expect("lower no-fill blank run");
1078
1079 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
1080 panic!(
1081 "no-fill display must remain preformatted: {:?}",
1082 document.sections[0].blocks
1083 );
1084 };
1085 assert_eq!(inline_text(children), "first line\n\nsecond line");
1086 assert_eq!(
1087 children
1088 .iter()
1089 .filter(|inline| matches!(inline, Inline::LineBreak))
1090 .count(),
1091 2
1092 );
1093 }
1094
1095 #[test]
1096 fn adjacent_no_fill_regions_scale_without_changing_their_topology() {
1097 const REGION_COUNT: usize = 2_048;
1098 let mut source = String::from(".TH NO-FILL-SCALE 7\n.SH EXAMPLE\n");
1099 for index in 0..REGION_COUNT {
1100 writeln!(source, ".nf\nline {index}\n.fi").expect("append no-fill region");
1101 }
1102
1103 let document =
1104 parse_manual_bytes(std::path::Path::new("no-fill-scale.7"), source.as_bytes())
1105 .expect("lower adjacent no-fill regions");
1106
1107 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
1108 panic!(
1109 "adjacent regions must remain one preformatted block: {:?}",
1110 document.sections[0].blocks
1111 );
1112 };
1113 assert_eq!(
1114 children
1115 .iter()
1116 .filter(|inline| matches!(inline, Inline::LineBreak))
1117 .count(),
1118 REGION_COUNT - 1
1119 );
1120 assert!(inline_text(children).starts_with("line 0\nline 1\n"));
1121 assert!(
1122 inline_text(children).ends_with(&format!("line {}", REGION_COUNT - 1)),
1123 "last no-fill region must remain visible"
1124 );
1125 }
1126
1127 #[test]
1128 fn distinguishes_filled_source_wrapping_from_indented_output_lines() {
1129 let path = temporary_source(
1130 "filled-line-boundaries",
1131 concat!(
1132 ".TH TOOL 1\n",
1133 ".SH SYNOPSIS\n",
1134 "tool [first]\n",
1135 " [second]\n",
1136 " [third]\n",
1137 ".PP\n",
1138 "Ordinary source wrapping\n",
1139 "remains one filled paragraph.\n",
1140 ),
1141 );
1142
1143 let document = parse_manual_source(&path).expect("lower filled line boundaries");
1144 fs::remove_file(path).expect("remove temporary roff fixture");
1145
1146 let [
1147 Block::Paragraph {
1148 children: synopsis, ..
1149 },
1150 Block::Paragraph {
1151 children: prose, ..
1152 },
1153 ] = document.sections[0].blocks.as_slice()
1154 else {
1155 panic!("expected synopsis and prose paragraphs");
1156 };
1157 assert_eq!(
1158 inline_text(synopsis),
1159 "tool [first]\n [second]\n [third]"
1160 );
1161 assert_eq!(
1162 synopsis
1163 .iter()
1164 .filter(|inline| matches!(inline, Inline::LineBreak))
1165 .count(),
1166 2
1167 );
1168 assert_eq!(
1169 inline_text(prose),
1170 "Ordinary source wrapping remains one filled paragraph."
1171 );
1172 }
1173
1174 #[test]
1175 fn honours_roff_no_space_line_continuations() {
1176 let document = parse_manual_bytes(
1177 std::path::Path::new("line-continuation.1"),
1178 b".TH LINE-CONTINUATION 1\n\
1179.SH DESCRIPTION\n\
1180extsize=\\c\n\
1181nnnn; multi-\\c\n\
1182block; (\\c\n\
1183.BR read (2)\n\
1184.EX\n\
1185literal-\\c\n\
1186continuation\n\
1187.EE\n",
1188 )
1189 .expect("lower no-space line continuations");
1190
1191 let [
1192 Block::Paragraph {
1193 children: prose, ..
1194 },
1195 Block::Preformatted {
1196 children: literal, ..
1197 },
1198 ] = document.sections[0].blocks.as_slice()
1199 else {
1200 panic!(
1201 "expected one filled and one no-fill block: {:?}",
1202 document.sections[0].blocks
1203 );
1204 };
1205 assert_eq!(inline_text(prose), "extsize=nnnn; multi-block; (read(2)");
1206 assert_eq!(inline_text(literal), "literal-continuation");
1207 }
1208
1209 #[test]
1210 fn keeps_explicit_horizontal_separation_at_a_tight_line_join() {
1211 let document = parse_manual_bytes(
1212 std::path::Path::new("motion-continuation.1"),
1213 b".TH MOTION-CONTINUATION 1\n\
1214.SH DESCRIPTION\n\
1215\\h'-04' 1.\\h'+01'\\c\n\
1216The next line.\n",
1217 )
1218 .expect("lower a horizontally spaced continued line");
1219
1220 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1221 panic!("expected one paragraph: {:?}", document.sections[0].blocks);
1222 };
1223 assert_eq!(inline_text(children), " 1. The next line.");
1224 }
1225
1226 #[test]
1227 fn lets_explicit_fonts_override_an_alternating_macro_default() {
1228 let path = temporary_source(
1229 "alternating-font-reset",
1230 ".TH MAN 1\n\
1231 .SH OPTIONS\n\
1232 .TP\n\
1233 .BI \\-r\\ prompt \\fR,\\ \\fB\\-\\-prompt= prompt\n\
1234 Set the pager prompt.\n",
1235 );
1236
1237 let document = parse_manual_source(&path).expect("lower alternating font reset");
1238 fs::remove_file(path).expect("remove temporary roff fixture");
1239
1240 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
1241 panic!("expected one definition list");
1242 };
1243 let term = items[0]
1244 .terms
1245 .first()
1246 .expect("first definition term")
1247 .iter()
1248 .filter(|inline| !matches!(inline, Inline::Anchor { .. }))
1249 .collect::<Vec<_>>();
1250
1251 assert_eq!(term.len(), 5);
1252 assert!(matches!(term[0], Inline::Strong { children } if inline_text(children) == "-r "));
1253 assert!(
1254 matches!(term[1], Inline::Emphasis { children } if inline_text(children) == "prompt")
1255 );
1256 assert!(matches!(term[2], Inline::Text { value } if value == ", "));
1257 assert!(
1258 matches!(term[3], Inline::Strong { children } if inline_text(children) == "--prompt=")
1259 );
1260 assert!(
1261 matches!(term[4], Inline::Emphasis { children } if inline_text(children) == "prompt")
1262 );
1263 }
1264
1265 #[test]
1266 fn suppresses_pod_font_requests_around_verbatim_blocks() {
1267 let path = temporary_source(
1268 "pod-verbatim-fonts",
1269 ".de Vb\n\
1270 .ft CW\n\
1271 .nf\n\
1272 ..\n\
1273 .de Ve\n\
1274 .ft R\n\
1275 .fi\n\
1276 ..\n\
1277 .TH POD 1\n\
1278 .SH EXAMPLES\n\
1279 .Vb 2\n\
1280 \\&struct A { int a; };\n\
1281 \\&struct B : A {};\n\
1282 .Ve\n",
1283 );
1284
1285 let document = parse_manual_source(&path).expect("lower Pod::Man verbatim source");
1286 fs::remove_file(path).expect("remove temporary roff fixture");
1287
1288 assert_eq!(document.sections[0].blocks.len(), 1);
1289 let Block::Preformatted { children, .. } = &document.sections[0].blocks[0] else {
1290 panic!("expected one preformatted block");
1291 };
1292 assert_eq!(
1293 inline_text(children),
1294 "struct A { int a; };\nstruct B : A {};"
1295 );
1296 }
1297
1298 #[test]
1299 fn lowers_indented_aliases_without_roff_layout_arguments() {
1300 let path = temporary_source(
1301 "indented-aliases",
1302 ".TH CONTROL 1\n\
1303 .SH OPTIONS\n\
1304 .PD 0\n\
1305 .IP \"\\fB-a\\fR\" 4\n\
1306 .IP \"\\fB--all\\fR\" 4\n\
1307 Show all entries.\n\
1308 .PD\n\
1309 .in 168u\n",
1310 );
1311
1312 let document = parse_manual_source(&path).expect("lower indented aliases");
1313 fs::remove_file(path).expect("remove temporary roff fixture");
1314
1315 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
1316 panic!("expected one definition list");
1317 };
1318 assert_eq!(items.len(), 1);
1319 assert_eq!(
1320 items[0]
1321 .terms
1322 .iter()
1323 .map(|term| inline_text(term))
1324 .collect::<Vec<_>>(),
1325 ["-a", "--all"]
1326 );
1327 assert_eq!(items[0].description.len(), 1);
1328 let Block::Paragraph { children, .. } = &items[0].description[0] else {
1329 panic!("expected alias description paragraph");
1330 };
1331 assert_eq!(inline_text(children), "Show all entries.");
1332 }
1333
1334 #[test]
1335 fn tq_terms_share_one_semantic_option_identity() {
1336 let path = temporary_source(
1337 "tq-aliases",
1338 ".TH TQ-ALIASES 7\n\
1339 .SH OPTIONS\n\
1340 .TP\n\
1341 .B \\-\\-alpha\n\
1342 .TQ\n\
1343 .B \\-a\n\
1344 .TQ\n\
1345 .B \\-\\-ALPHA\n\
1346 Enable alpha mode.\n",
1347 );
1348
1349 let document = parse_manual_source(&path).expect("lower TQ aliases");
1350 fs::remove_file(path).expect("remove temporary roff fixture");
1351 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
1352 panic!("expected one definition list");
1353 };
1354 assert_eq!(items.len(), 1);
1355 assert_eq!(
1356 items[0]
1357 .terms
1358 .iter()
1359 .map(|term| inline_text(term))
1360 .collect::<Vec<_>>(),
1361 ["-a", "--alpha", "--ALPHA"]
1362 );
1363 assert_eq!(
1364 items[0].identity.as_ref().expect("option identity").names,
1365 ["-a", "--alpha", "--ALPHA"]
1366 );
1367 }
1368
1369 #[test]
1370 fn preserves_man_paragraph_distance_between_indented_paragraphs() {
1371 let path = temporary_source(
1372 "paragraph-distance",
1373 ".TH SPACING 1\n\
1374 .SH OPTIONS\n\
1375 .IP \"\\fB-a\\fR\" 4\n\
1376 First.\n\
1377 .IP \"\\fB-b\\fR\" 4\n\
1378 Second.\n\
1379 .PD 0\n\
1380 .IP \"\\fB-c\\fR\" 4\n\
1381 Third.\n\
1382 .IP \"\\fB-d\\fR\" 4\n\
1383 Fourth.\n\
1384 .PD\n\
1385 .IP \"\\fB-e\\fR\" 4\n\
1386 Fifth.\n",
1387 );
1388
1389 let document = parse_manual_source(&path).expect("lower paragraph distance");
1390 fs::remove_file(path).expect("remove temporary roff fixture");
1391
1392 let [Block::DefinitionList { items, compact, .. }] = document.sections[0].blocks.as_slice()
1393 else {
1394 panic!("expected one definition list");
1395 };
1396 assert!(!compact);
1397 assert_eq!(items.len(), 5);
1398 assert_eq!(
1399 items
1400 .iter()
1401 .map(|item| item.spacing_before_lines)
1402 .collect::<Vec<_>>(),
1403 [Some(0), Some(1), Some(0), Some(0), Some(1)]
1404 );
1405 }
1406
1407 #[test]
1408 fn preserves_man_paragraph_and_heading_distance_as_one_layout_model() {
1409 let path = temporary_source(
1410 "vertical-layout",
1411 ".TH SPACING 1\n\
1412 .SH FIRST\n\
1413 First paragraph.\n\
1414 .PP\n\
1415 Second paragraph.\n\
1416 .SS CHILD\n\
1417 Child body.\n\
1418 .PD 0\n\
1419 .SS COMPACT\n\
1420 Compact child.\n\
1421 .SH NEXT\n\
1422 Next body.\n\
1423 .PD\n\
1424 .SH FINAL\n\
1425 Final body.\n",
1426 );
1427
1428 let document = parse_manual_source(&path).expect("lower vertical layout");
1429 fs::remove_file(path).expect("remove temporary roff fixture");
1430
1431 let [first, next, final_section] = document.sections.as_slice() else {
1432 panic!("expected three top-level sections");
1433 };
1434 assert_eq!(first.spacing_before_lines, 0);
1435 let [Block::Paragraph { .. }, Block::Paragraph { layout, .. }] = first.blocks.as_slice()
1436 else {
1437 panic!("expected two semantic paragraphs");
1438 };
1439 assert_eq!(layout.spacing_before_lines, 1);
1440
1441 let [child, compact] = first.children.as_slice() else {
1442 panic!("expected two subsections");
1443 };
1444 assert_eq!(child.spacing_before_lines, 1);
1445 assert_eq!(compact.spacing_before_lines, 0);
1446 assert_eq!(next.spacing_before_lines, 0);
1447 assert_eq!(final_section.spacing_before_lines, 1);
1448 }
1449
1450 #[test]
1451 fn does_not_duplicate_explicit_space_before_a_transparent_indent() {
1452 let path = temporary_source(
1453 "explicit-space-before-indent",
1454 ".TH SPACING 1\n\
1455 .SH CONTENT\n\
1456 Before.\n\
1457 .sp\n\
1458 .RS 4\n\
1459 After.\n\
1460 .RE\n",
1461 );
1462
1463 let document = parse_manual_source(&path).expect("lower explicit indented spacing");
1464 fs::remove_file(path).expect("remove temporary roff fixture");
1465
1466 let [
1467 Block::Paragraph { .. },
1468 Block::VerticalSpace { lines: 1, .. },
1469 Block::Paragraph { layout, .. },
1470 ] = document.sections[0].blocks.as_slice()
1471 else {
1472 panic!("expected prose, one explicit gap, and indented prose");
1473 };
1474 assert_eq!(layout.indent_columns, 4);
1475 assert_eq!(
1476 layout.spacing_before_lines, 0,
1477 "the explicit gap must not be repeated as wrapper boundary spacing",
1478 );
1479 }
1480
1481 #[test]
1482 fn preserves_mdoc_paragraph_and_heading_distance() {
1483 let path = temporary_source(
1484 "mdoc-vertical-layout",
1485 ".Dd July 19, 2026\n\
1486 .Dt SPACING 1\n\
1487 .Os\n\
1488 .Sh FIRST\n\
1489 First paragraph.\n\
1490 .Pp\n\
1491 Second paragraph.\n\
1492 .Ss CHILD\n\
1493 Child body.\n",
1494 );
1495
1496 let document = parse_manual_source(&path).expect("lower mdoc vertical layout");
1497 fs::remove_file(path).expect("remove temporary roff fixture");
1498
1499 let [first] = document.sections.as_slice() else {
1500 panic!("expected one top-level section");
1501 };
1502 assert_eq!(first.spacing_before_lines, 1);
1503 assert!(matches!(
1504 first.blocks.get(1),
1505 Some(Block::VerticalSpace { lines: 1, .. })
1506 ));
1507 assert_eq!(first.children[0].spacing_before_lines, 1);
1508 }
1509
1510 #[test]
1511 fn lowers_mdoc_semantic_inline_nodes_and_nested_sections() {
1512 let path = temporary_source(
1513 "mdoc",
1514 ".Dd July 19, 2026\n\
1515 .Dt MANT 1\n\
1516 .Os\n\
1517 .Sh DESCRIPTION\n\
1518 Use\n\
1519 .Nm mant\n\
1520 with\n\
1521 .Xr man 1\n\
1522 Read\n\
1523 .Lk https://example.test/docs \"the documentation\"\n\
1524 or contact\n\
1525 .Mt docs@example.test\n\
1526 .Ss Details\n\
1527 .Fl h\n",
1528 );
1529
1530 let document = parse_manual_source(&path).expect("lower mdoc source");
1531 fs::remove_file(path).expect("remove temporary roff fixture");
1532
1533 assert_eq!(document.source.format, SourceFormat::Mdoc);
1534 assert_eq!(document.sections[0].children[0].title, "Details");
1535 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
1536 panic!("expected description paragraph");
1537 };
1538 assert!(
1539 children
1540 .iter()
1541 .any(|inline| matches!(inline, Inline::Strong { .. }))
1542 );
1543 assert!(
1544 children.iter().any(
1545 |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::Manual { name, .. }, .. } if name == "man")
1546 )
1547 );
1548 assert!(children.iter().any(
1549 |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::External { uri }, .. } if uri == "https://example.test/docs")
1550 ));
1551 assert!(children.iter().any(
1552 |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::Email { address }, .. } if address == "docs@example.test")
1553 ));
1554 }
1555
1556 #[test]
1557 fn retains_unlabelled_mdoc_link_targets_before_trailing_punctuation() {
1558 let document = parse_manual_bytes(
1559 std::path::Path::new("external-link.9"),
1560 b".Dd August 19, 2026\n.Dt EXTERNAL-LINK 9\n.Os\n.Sh DESCRIPTION\n.Lk https://example.test/books .\n",
1561 )
1562 .expect("lower an unlabelled mdoc external link");
1563
1564 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1565 panic!("expected one external-link paragraph");
1566 };
1567 assert_eq!(inline_text(children), "https://example.test/books.");
1568 assert!(matches!(
1569 children.as_slice(),
1570 [
1571 Inline::Link {
1572 target: mant_ir::LinkTarget::External { uri },
1573 children: link_children,
1574 ..
1575 },
1576 Inline::Text { value },
1577 ] if uri == "https://example.test/books"
1578 && inline_text(link_children) == "https://example.test/books"
1579 && value == "."
1580 ));
1581 }
1582
1583 #[test]
1584 fn expands_mdoc_bsd_lifecycle_and_release_forms() {
1585 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";
1586 let document = parse_manual_bytes(std::path::Path::new("bsd-lifecycle.7"), source)
1587 .expect("lower mdoc BSD lifecycle forms");
1588
1589 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1590 panic!("expected one BSD lifecycle paragraph");
1591 };
1592 assert_eq!(
1593 inline_text(children),
1594 "BSD BSD (currently in alpha test) BSD (currently in beta test) BSD (currently under development). 4.3BSD. 4.3BSD Net/2. 386BSD 0.1."
1595 );
1596 }
1597
1598 #[test]
1599 fn preserves_complete_mdoc_include_directives() {
1600 let document = parse_manual_bytes(
1601 std::path::Path::new("include.3"),
1602 b".Dd August 19, 2026\n.Dt INCLUDE 3\n.Os\n.Sh SYNOPSIS\n.In fido.h\n",
1603 )
1604 .expect("lower mdoc include");
1605
1606 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1607 panic!("expected one include paragraph");
1608 };
1609 assert_eq!(inline_text(children), "#include <fido.h>");
1610 assert!(matches!(
1611 children.as_slice(),
1612 [Inline::Code { value }] if value == "#include <fido.h>"
1613 ));
1614 }
1615
1616 #[test]
1617 fn propagates_nested_no_space_and_preserves_prefix_content() {
1618 let document = parse_manual_bytes(
1619 std::path::Path::new("no-space.7"),
1620 b".Dd August 19, 2026\n.Dt NO-SPACE 7\n.Os\n.Sh DESCRIPTION\n\
1621.Em Bell Labs Ns -derived\n\
1622.Ar job Ns s :\n\
1623.Sm off\n\
1624.Pf [\\-]ddd Cm \\&. No ddd\n\
1625.Sm on\n",
1626 )
1627 .expect("lower nested no-space macros");
1628
1629 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1630 panic!("expected one no-space paragraph");
1631 };
1632 assert_eq!(inline_text(children), "Bell Labs-derived jobs: [-]ddd.ddd");
1633 }
1634
1635 #[test]
1636 fn lowers_documented_mdoc_delimiters_and_common_roff_characters() {
1637 let path = temporary_source(
1638 "mdoc-delimiters",
1639 ".Dd July 19, 2026\n\
1640 .Dt DELIMITERS 7\n\
1641 .Os\n\
1642 .Sh DESCRIPTION\n\
1643 .Op optional\n\
1644 .Bq bracket\n\
1645 .Dq double\n\
1646 .Sq single\n\
1647 .Pq parenthesized\n\
1648 .Brq braced\n\
1649 .Aq angled\n\
1650 .Oo multi Ar value\n\
1651 .Oc\n\
1652 .Sh CHARACTERS\n\
1653 \\(en \\(em \\(aq \\(dq \\(co \\(rg \\(tm \\(bu \\(ha \\(ti \\(rs\n",
1654 );
1655
1656 let document = parse_manual_source(&path).expect("lower delimiter and character source");
1657 fs::remove_file(path).expect("remove temporary roff fixture");
1658
1659 let description = document.sections[0]
1660 .blocks
1661 .iter()
1662 .map(|block| match block {
1663 Block::Paragraph { children, .. } => inline_text(children),
1664 _ => String::new(),
1665 })
1666 .collect::<Vec<_>>()
1667 .join(" ");
1668 for expected in [
1669 "[optional]",
1670 "[bracket]",
1671 "“double”",
1672 "‘single’",
1673 "(parenthesized)",
1674 "{braced}",
1675 "<angled>",
1676 "[multi value]",
1677 ] {
1678 assert!(
1679 description.contains(expected),
1680 "missing {expected:?} in {description:?}"
1681 );
1682 }
1683
1684 let [Block::Paragraph { children, .. }] = document.sections[1].blocks.as_slice() else {
1685 panic!("expected one special-character paragraph");
1686 };
1687 assert_eq!(inline_text(children), "– — ' \" © ® ™ • ^ ~ \\");
1688 }
1689
1690 #[test]
1691 fn retains_punctuation_after_implicit_mdoc_enclosures() {
1692 let document = parse_manual_bytes(
1693 std::path::Path::new("implicit-enclosure-punctuation.7"),
1694 b".Dd August 19, 2026\n.Dt IMPLICIT-ENCLOSURE-PUNCTUATION 7\n.Os\n\
1695.Sh DESCRIPTION\nWhen disabled\n.Pq all features remain readable ;\ncontinue safely.\n",
1696 )
1697 .expect("lower punctuation after an implicit enclosure");
1698
1699 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
1700 panic!("expected one paragraph");
1701 };
1702 assert_eq!(
1703 inline_text(children),
1704 "When disabled (all features remain readable); continue safely."
1705 );
1706 }
1707
1708 #[test]
1709 fn lowers_the_pinned_named_character_catalog_without_silent_deletion() {
1710 let document = parse_manual_bytes(
1711 std::path::Path::new("named-characters.7"),
1712 b".TH NAMED-CHARACTERS 7\n\
1713.SH TEST\n\
1714at=\\(at ga=\\(ga oq=\\(oq arrow=\\(-> larrow=\\(<- mu=\\(mu\n\
1715de=\\(de pl=\\(pl dg=\\(dg ua=\\(ua da=\\(da lB=\\(lB rB=\\(rB\n\
1716unknown=\\[future-glyph]\n",
1717 )
1718 .expect("lower named characters");
1719
1720 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1721 panic!("expected one character paragraph");
1722 };
1723 assert_eq!(
1724 inline_text(children),
1725 "at=@ ga=` oq=' arrow=→ larrow=← mu=× de=° pl=+ dg=† ua=↑ da=↓ lB=[ rB=] unknown=\\[future-glyph]"
1726 );
1727 }
1728
1729 #[test]
1730 fn round_trips_raw_and_bracketed_unicode_manual_text() {
1731 let source = ".TH UNICODE 7\n\
1732.SH TEST\n\
1733Raw UTF-8: Mašláňová café — naïve.\n\
1734Escaped: Ma\\[u0161]l\\[u00E1] and \\[u2014] dash.\n";
1735 let document = parse_manual_bytes(std::path::Path::new("unicode.7"), source.as_bytes())
1736 .expect("lower raw and escaped Unicode");
1737
1738 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1739 panic!("expected one Unicode paragraph");
1740 };
1741 let rendered = inline_text(children);
1742 assert!(rendered.contains("Raw UTF-8: Mašláňová café — naïve."));
1743 assert!(rendered.contains("Escaped: Mašlá and — dash."));
1744 assert!(!rendered.contains(r"\[u"));
1745 }
1746
1747 #[test]
1748 fn preserves_explicit_mdoc_function_and_enclosure_structure() {
1749 let document = parse_manual_bytes(
1750 std::path::Path::new("explicit-mdoc.1"),
1751 b".Dd August 17, 2026\n\
1752.Dt EXPLICIT-MDOC 1\n\
1753.Os\n\
1754.Sh NAME\n\
1755.Nm explicit-mdoc\n\
1756.Nd exercise explicit blocks\n\
1757.Sh FUNCTION\n\
1758.Ft int\n\
1759.Fo audit_open\n\
1760.Fa const char *path\n\
1761.Fa int flags\n\
1762.Fc\n\
1763.Sh ENCLOSURES\n\
1764.Ao\nangle\n.Ac\n\
1765.Bo\nbracket\n.Bc\n\
1766.Do\ndouble\n.Dc\n\
1767.Po\nparenthesized\n.Pc\n\
1768.Qo\nquoted\n.Qc\n\
1769.So\nsingle\n.Sc\n\
1770.Bro\nbraced\n.Brc\n\
1771.Oo\noptional\n.Oc\n\
1772.Eo <<\ngeneric\n.Ec >>\n\
1773.Es [[ ]]\n\
1774.En custom\n",
1775 )
1776 .expect("lower explicit mdoc blocks");
1777
1778 let function = &document.sections[1];
1779 let [
1780 Block::Paragraph {
1781 children: return_type,
1782 ..
1783 },
1784 Block::Paragraph {
1785 children: declaration,
1786 ..
1787 },
1788 ] = function.blocks.as_slice()
1789 else {
1790 panic!("expected return type and function declaration paragraphs");
1791 };
1792 assert_eq!(inline_text(return_type), "int");
1793 assert_eq!(
1794 inline_text(declaration),
1795 "audit_open(const char *path, int flags)"
1796 );
1797 assert!(matches!(
1798 declaration.first(),
1799 Some(Inline::Strong { children }) if inline_text(children) == "audit_open"
1800 ));
1801
1802 let [Block::Paragraph { children, .. }] = document.sections[2].blocks.as_slice() else {
1803 panic!("expected one enclosure paragraph");
1804 };
1805 assert_eq!(
1806 inline_text(children),
1807 "<angle> [bracket] “double” (parenthesized) “quoted” ‘single’ {braced} \
1808 [optional] <<generic>> [[custom]]"
1809 );
1810 assert_eq!(document.diagnostics.len(), 2);
1811 assert!(
1812 document
1813 .diagnostics
1814 .iter()
1815 .all(|diagnostic| diagnostic.message.starts_with("obsolete macro:")),
1816 "{:?}",
1817 document.diagnostics
1818 );
1819 }
1820
1821 #[test]
1822 fn preserves_the_complete_libbsd_library_identity() {
1823 let document = parse_manual_bytes(
1824 std::path::Path::new("libbsd.3bsd"),
1825 b".Dd August 19, 2026\n.Dt LIBBSD 3bsd\n.Os\n.Sh LIBRARY\n.Lb libbsd\n",
1826 )
1827 .expect("lower libbsd library declaration");
1828 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1829 panic!("expected one library paragraph");
1830 };
1831
1832 assert_eq!(
1833 inline_text(children),
1834 "Utility functions from BSD systems (libbsd, -lbsd)"
1835 );
1836 }
1837
1838 #[test]
1839 fn joins_the_final_mdoc_bibliography_authors() {
1840 let document = parse_manual_bytes(
1841 std::path::Path::new("bibliography.3"),
1842 b".Dd August 19, 2026\n.Dt BIBLIOGRAPHY 3\n.Os\n.Sh SEE ALSO\n\
1843.Rs\n.%A Bentley, J.L.\n.%A McIlroy, M.D.\n.%T Engineering a Sort Function\n.Re\n",
1844 )
1845 .expect("lower mdoc bibliography");
1846 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1847 panic!("expected one bibliography paragraph");
1848 };
1849
1850 assert_eq!(
1851 inline_text(children),
1852 "Bentley, J.L. and McIlroy, M.D. Engineering a Sort Function."
1853 );
1854 }
1855
1856 #[test]
1857 fn preserves_mdoc_command_names_in_each_synopsis_form() {
1858 let document = parse_manual_bytes(
1859 std::path::Path::new("fido2-cred.1"),
1860 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",
1861 )
1862 .expect("lower mdoc synopsis names");
1863 let synopsis = &document.sections[1];
1864 let rendered = synopsis
1865 .blocks
1866 .iter()
1867 .map(|block| match block {
1868 Block::Paragraph { children, .. } => inline_text(children),
1869 block => panic!("expected synopsis paragraph, got {block:?}"),
1870 })
1871 .collect::<Vec<_>>();
1872
1873 assert_eq!(
1874 rendered,
1875 [
1876 "fido2-cred -M [-i input_file]",
1877 "fido2-cred -V",
1878 "helper [-q]",
1879 ]
1880 );
1881 }
1882
1883 #[test]
1884 fn preserves_mdoc_name_and_function_punctuation_by_context() {
1885 let document = parse_manual_bytes(
1886 std::path::Path::new("function-punctuation.3"),
1887 b".Dd August 19, 2026\n.Dt FUNCTION-PUNCTUATION 3\n.Os\n\
1888.Sh NAME\n.Nm function-punctuation\n.Nd test generated punctuation\n\
1889.Sh SYNOPSIS\n.Fn compact_call \"int value\"\n\
1890.Fo explicit_call\n.Fa \"int value\" \"const char *label\"\n.Fc\n\
1891.Sh DESCRIPTION\nThe\n.Fn prose_call \"int value\"\nfunction.\n",
1892 )
1893 .expect("lower mdoc generated punctuation");
1894
1895 let [Block::Paragraph { children: name, .. }] = document.sections[0].blocks.as_slice()
1896 else {
1897 panic!("expected one NAME paragraph");
1898 };
1899 assert_eq!(
1900 inline_text(name),
1901 "function-punctuation — test generated punctuation"
1902 );
1903
1904 let synopsis = document.sections[1]
1905 .blocks
1906 .iter()
1907 .map(|block| match block {
1908 Block::Paragraph { children, .. } => inline_text(children),
1909 block => panic!("expected synopsis paragraph, got {block:?}"),
1910 })
1911 .collect::<Vec<_>>();
1912 assert_eq!(
1913 synopsis,
1914 [
1915 "compact_call(int value);",
1916 "explicit_call(int value, const char *label);"
1917 ]
1918 );
1919
1920 let [
1921 Block::Paragraph {
1922 children: description,
1923 ..
1924 },
1925 ] = document.sections[2].blocks.as_slice()
1926 else {
1927 panic!("expected one DESCRIPTION paragraph");
1928 };
1929 assert_eq!(
1930 inline_text(description),
1931 "The prose_call(int value) function."
1932 );
1933 }
1934
1935 #[test]
1936 fn preserves_mdoc_synopsis_declaration_units() {
1937 let document = parse_manual_bytes(
1938 std::path::Path::new("synopsis-declarations.3"),
1939 b".Dd August 19, 2026\n.Dt SYNOPSIS-DECLARATIONS 3\n.Os\n\
1940.Sh SYNOPSIS\n.In synprobe.h\n.Ft const struct stat *\n\
1941.Fn synprobe_first \"struct thing *a\"\n.Ft void\n\
1942.Fo synprobe_second\n.Fa \"struct thing *a\"\n.Fa \"int n\"\n.Fc\n\
1943.Fn synprobe_third \"int n\"\n",
1944 )
1945 .expect("lower mdoc synopsis declarations");
1946
1947 let rendered = document.sections[0]
1948 .blocks
1949 .iter()
1950 .map(|block| match block {
1951 Block::Paragraph { children, .. } => inline_text(children),
1952 block => panic!("expected synopsis declaration paragraph, got {block:?}"),
1953 })
1954 .collect::<Vec<_>>();
1955
1956 assert_eq!(
1957 rendered,
1958 [
1959 "#include <synprobe.h>",
1960 "const struct stat * synprobe_first(struct thing *a);",
1961 "void synprobe_second(struct thing *a, int n);",
1962 "synprobe_third(int n);",
1963 ]
1964 );
1965 }
1966
1967 #[test]
1968 fn preserves_printable_roff_content_outside_formal_sections() {
1969 let document = parse_manual_bytes(
1970 std::path::Path::new("manweb.1"),
1971 b".TH MANWEB 1\n .SH NAME\nmanweb - browse generated documentation\n.SH SYNOPSIS\n.B manweb\n",
1972 )
1973 .expect("lower root prose");
1974 let [Block::Paragraph { children, .. }] = document.blocks.as_slice() else {
1975 panic!("expected one root paragraph, got {:?}", document.blocks);
1976 };
1977
1978 assert_eq!(
1979 inline_text(children),
1980 " .SH NAME manweb - browse generated documentation"
1981 );
1982 assert_eq!(document.sections[0].title, "SYNOPSIS");
1983 }
1984
1985 #[test]
1986 fn discards_temporary_indent_arguments_without_hiding_the_next_line() {
1987 let document = parse_manual_bytes(
1988 std::path::Path::new("temporary-indent.8"),
1989 b".TH TEMPORARY-INDENT 8\n.SH EXAMPLES\n.ti +8n\nexample% command\n.ti\nexample% other\n",
1990 )
1991 .expect("lower temporary indentation requests");
1992
1993 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1994 panic!("expected one examples paragraph");
1995 };
1996 assert_eq!(inline_text(children), "example% command example% other");
1997 }
1998
1999 #[test]
2000 fn diagnoses_future_structural_macros_before_discarding_visible_parts() {
2001 let mut report = Parser::default()
2002 .parse_bytes(
2003 "future-structure.1",
2004 b".Dd August 17, 2026\n.Dt FUTURE 1\n.Os\n.Sh SYNOPSIS\n\
2005.Fo future_call\n.Fa argument\n.Fc\n",
2006 )
2007 .expect("parse structural fixture");
2008 let block = find_macro_mut(&mut report.document.root, "Fo").expect("Fo block");
2009 block.macro_name = Some("FutureBlock".to_owned());
2010 let mut second_body = block
2011 .children
2012 .iter()
2013 .find(|child| child.kind == libmandoc_rs::NodeKind::Body)
2014 .cloned()
2015 .expect("function body");
2016 assert!(replace_first_text(&mut second_body, "second_argument"));
2017 block.children.push(second_body);
2018
2019 let document = lower_mandoc_document(std::path::Path::new("future-structure.1"), &report);
2020
2021 assert!(document.diagnostics.iter().any(|diagnostic| {
2022 diagnostic.code.as_deref() == Some("manual.unhandled-structural-parts")
2023 && diagnostic.message.contains("FutureBlock")
2024 }));
2025 let rendered = document.sections[0]
2026 .blocks
2027 .iter()
2028 .map(|block| match block {
2029 Block::Paragraph { children, .. } => inline_text(children),
2030 block => panic!("expected fallback paragraph, got {block:?}"),
2031 })
2032 .collect::<Vec<_>>();
2033 assert_eq!(rendered, ["argument", "second_argument"]);
2034 }
2035
2036 #[test]
2037 fn recognizes_explicitly_styled_traditional_man_references_in_any_section() {
2038 let path = temporary_source(
2039 "man-see-also",
2040 ".TH TOOL 1\n\
2041 .SH DESCRIPTION\n\
2042 The styled reference \\fBprintf\\fP(3) is usable here.\n\
2043 .SH SEE ALSO\n\
2044 .BR printf (3),\n\
2045 .BR man (1)\n",
2046 );
2047
2048 let document = parse_manual_source(&path).expect("lower man references");
2049 fs::remove_file(path).expect("remove temporary roff fixture");
2050
2051 let see_also = document
2052 .sections
2053 .iter()
2054 .find(|section| section.title == "SEE ALSO")
2055 .expect("SEE ALSO");
2056 let Block::Paragraph { children, .. } = &see_also.blocks[0] else {
2057 panic!("references are a paragraph");
2058 };
2059 assert!(children.iter().any(|inline| matches!(
2060 inline,
2061 Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
2062 if name == "printf" && manual_section == "3"
2063 )));
2064 assert!(children.iter().any(|inline| matches!(
2065 inline,
2066 Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
2067 if name == "man" && manual_section == "1"
2068 )));
2069
2070 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2071 panic!("description is a paragraph");
2072 };
2073 assert!(children.iter().any(|inline| matches!(
2074 inline,
2075 Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
2076 if name == "printf" && manual_section == "3"
2077 )));
2078 }
2079
2080 #[test]
2081 fn recognizes_legacy_sphinx_manual_links_in_roff_inputs() {
2082 let path = temporary_source(
2083 "sphinx-manual-links",
2084 ".TH BTRFS 8\n\
2085 .SH COMMANDS\n\
2086 See btrfs\\-subvolume(8) \\%<> and btrfs(5) \\%<> for details.\n\
2087 .EX\n\
2088 btrfs-subvolume(8) \\%<>\n\
2089 .EE\n",
2090 );
2091
2092 let document = parse_manual_source(&path).expect("lower legacy Sphinx references");
2093 fs::remove_file(path).expect("remove temporary roff fixture");
2094 let section = &document.sections[0];
2095 let paragraph = section
2096 .blocks
2097 .iter()
2098 .find_map(|block| match block {
2099 Block::Paragraph { children, .. } => Some(children),
2100 _ => None,
2101 })
2102 .expect("commands paragraph");
2103 assert_eq!(
2104 inline_text(paragraph),
2105 "See btrfs-subvolume(8) and btrfs(5) for details."
2106 );
2107 let references = paragraph
2108 .iter()
2109 .filter_map(|inline| match inline {
2110 Inline::Link {
2111 target:
2112 mant_ir::LinkTarget::Manual {
2113 name,
2114 manual_section: Some(manual_section),
2115 },
2116 ..
2117 } => Some((name.as_str(), manual_section.as_str())),
2118 _ => None,
2119 })
2120 .collect::<Vec<_>>();
2121 assert_eq!(references, [("btrfs-subvolume", "8"), ("btrfs", "5")]);
2122
2123 let literal = section
2124 .blocks
2125 .iter()
2126 .find_map(|block| match block {
2127 Block::Preformatted { children, .. } => Some(children),
2128 _ => None,
2129 })
2130 .expect("literal display");
2131 assert_eq!(inline_text(literal), "btrfs-subvolume(8) <>");
2132 assert!(!literal.iter().any(|inline| matches!(
2133 inline,
2134 Inline::Link {
2135 target: mant_ir::LinkTarget::Manual { .. },
2136 ..
2137 }
2138 )));
2139 }
2140
2141 #[test]
2142 fn lowers_modern_groff_manual_uri_and_mail_macros() {
2143 let path = temporary_source(
2144 "man-modern-links",
2145 ".TH TOOL 1\n\
2146 .SH DESCRIPTION\n\
2147 .MR git-add 1 ,\n\
2148 .PP\n\
2149 Read\n\
2150 .UR https://example.test/docs\n\
2151 Documentation\n\
2152 .UE\n\
2153 now.\n\
2154 .PP\n\
2155 Mail comments, suggestions and bug reports to\n\
2156 .MT docs@example.test\n\
2157 Sean\n\
2158 .ME .\n",
2159 );
2160
2161 let document = parse_manual_source(&path).expect("lower modern man links");
2162 fs::remove_file(path).expect("remove temporary roff fixture");
2163 let section = &document.sections[0];
2164 let mut manual = false;
2165 let mut web = false;
2166 let mut mail = false;
2167 for children in section.blocks.iter().filter_map(|block| match block {
2168 Block::Paragraph { children, .. } => Some(children),
2169 _ => None,
2170 }) {
2171 for inline in children {
2172 match inline {
2173 Inline::Link {
2174 target:
2175 mant_ir::LinkTarget::Manual {
2176 name,
2177 manual_section: Some(manual_section),
2178 },
2179 ..
2180 } if name == "git-add" && manual_section == "1" => manual = true,
2181 Inline::Link {
2182 target: mant_ir::LinkTarget::External { uri },
2183 ..
2184 } if uri == "https://example.test/docs" => {
2185 web = true;
2186 }
2187 Inline::Link {
2188 target: mant_ir::LinkTarget::Email { address },
2189 ..
2190 } if address == "docs@example.test" => {
2191 mail = true;
2192 }
2193 _ => {}
2194 }
2195 }
2196 }
2197
2198 assert!(manual && web && mail);
2199 assert!(section.blocks.iter().any(|block| match block {
2200 Block::Paragraph { children, .. } => inline_text(children).contains("git-add(1),"),
2201 _ => false,
2202 }));
2203 let linked_paragraphs = section
2204 .blocks
2205 .iter()
2206 .filter_map(|block| match block {
2207 Block::Paragraph { children, .. }
2208 if children.iter().any(|inline| {
2209 matches!(
2210 inline,
2211 Inline::Link {
2212 target: mant_ir::LinkTarget::External { .. },
2213 ..
2214 } | Inline::Link {
2215 target: mant_ir::LinkTarget::Email { .. },
2216 ..
2217 }
2218 )
2219 }) =>
2220 {
2221 Some(inline_text(children))
2222 }
2223 _ => None,
2224 })
2225 .collect::<Vec<_>>();
2226 assert_eq!(
2227 linked_paragraphs,
2228 [
2229 "Read Documentation ⟨https://example.test/docs⟩ now.",
2230 "Mail comments, suggestions and bug reports to Sean ⟨docs@example.test⟩."
2231 ]
2232 );
2233 }
2234
2235 #[test]
2236 fn searches_across_man_link_labels_and_visible_targets() {
2237 let source = b".TH LINK-SEARCH 1\n\
2238.SH REPORTING BUGS\n\
2239Mail comments, suggestions and bug reports to\n\
2240.MT docs@example.test\n\
2241Sean\n\
2242.ME .\n";
2243
2244 for pattern in ["bug reports to Sean", "docs@example.test"] {
2245 let query = crate::query_roff_bytes(source).expect("query link fixture");
2246 let result = crate::project_query_view(
2247 query,
2248 &mant_protocol::QueryView::Search {
2249 pattern: pattern.to_owned(),
2250 syntax: mant_protocol::SearchSyntax::Literal,
2251 case: mant_protocol::SearchCase::Sensitive,
2252 scope: mant_protocol::SearchScope::Visible,
2253 word: false,
2254 context_lines: 0,
2255 limit: 100,
2256 offset: 0,
2257 },
2258 )
2259 .expect("search link fixture");
2260 let crate::QueryViewResult::Search(search) = result else {
2261 panic!("expected search result");
2262 };
2263 assert_eq!(search.total, 1, "pattern={pattern:?}");
2264 }
2265 }
2266
2267 #[test]
2268 fn resolves_mdoc_section_references_and_explicit_targets() {
2269 let path = temporary_source(
2270 "mdoc-navigation",
2271 ".Dd July 19, 2026\n\
2272 .Dt NAVIGATION 1\n\
2273 .Os\n\
2274 .Sh DESCRIPTION\n\
2275 Continue with\n\
2276 .Sx DETAILS\n\
2277 .Tg explicit-option\n\
2278 .Fl x\n\
2279 .Sh DETAILS\n\
2280 Target content.\n",
2281 );
2282
2283 let document = parse_manual_source(&path).expect("lower navigation mdoc source");
2284 fs::remove_file(path).expect("remove temporary roff fixture");
2285
2286 assert_eq!(document.sections[0].id, "description-1");
2287 assert_eq!(document.sections[1].id, "details-2");
2288 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2289 panic!("expected navigation paragraph");
2290 };
2291 assert!(children.iter().any(|inline| matches!(
2292 inline,
2293 Inline::Link {
2294 target: mant_ir::LinkTarget::Section { id },
2295 children,
2296 ..
2297 } if id == "details-2" && inline_text(children) == "DETAILS"
2298 )));
2299 assert!(children.iter().any(|inline| matches!(
2300 inline,
2301 Inline::Anchor { id } if id == "explicit-option"
2302 )));
2303 }
2304
2305 #[test]
2306 fn resolves_a_unique_parenthetically_qualified_mdoc_section_reference() {
2307 let path = temporary_source(
2308 "mdoc-qualified-navigation",
2309 ".Dd July 19, 2026\n\
2310 .Dt NAVIGATION 1\n\
2311 .Os\n\
2312 .Sh DESCRIPTION\n\
2313 See\n\
2314 .Sx White Space Splitting\n\
2315 .Sh \"White Space Splitting (Field Splitting)\"\n\
2316 Target content.\n",
2317 );
2318
2319 let document = parse_manual_source(&path).expect("lower qualified navigation source");
2320 fs::remove_file(path).expect("remove temporary roff fixture");
2321
2322 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2323 panic!("expected navigation paragraph");
2324 };
2325 assert!(children.iter().any(|inline| matches!(
2326 inline,
2327 Inline::Link {
2328 target: mant_ir::LinkTarget::Section { id },
2329 children,
2330 ..
2331 } if id == "white-space-splitting-field-splitting-2"
2332 && inline_text(children) == "White Space Splitting"
2333 )));
2334 assert!(document.diagnostics.iter().all(|diagnostic| {
2335 diagnostic.code.as_deref() != Some("unresolved-section-reference")
2336 }));
2337 }
2338
2339 #[test]
2340 fn degrades_unresolved_mdoc_section_references_to_text() {
2341 let path = temporary_source(
2342 "mdoc-missing-section",
2343 ".Dd July 19, 2026\n.Dt NAVIGATION 1\n.Os\n.Sh DESCRIPTION\n.Sx MISSING\n",
2344 );
2345
2346 let document = parse_manual_source(&path).expect("lower unresolved navigation source");
2347 fs::remove_file(path).expect("remove temporary roff fixture");
2348
2349 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2350 panic!("expected reference paragraph");
2351 };
2352 assert_eq!(inline_text(children), "MISSING");
2353 assert!(children.iter().all(|inline| !matches!(
2354 inline,
2355 Inline::Link {
2356 target: mant_ir::LinkTarget::Section { .. },
2357 ..
2358 }
2359 )));
2360 assert!(document.diagnostics.iter().any(|diagnostic| {
2361 diagnostic.code.as_deref() == Some("unresolved-section-reference")
2362 }));
2363 }
2364
2365 #[test]
2366 fn turns_captured_parser_findings_into_structured_diagnostics() {
2367 let path = temporary_source(
2368 "unsupported",
2369 ".Dd July 19, 2026\n.Dt BAD 1\n.Os\n.Sh NAME\n.Nm bad\n.ab\n",
2370 );
2371
2372 let document = parse_manual_source(&path).expect("best-effort parse");
2373 fs::remove_file(path).expect("remove temporary roff fixture");
2374
2375 assert!(
2376 document
2377 .diagnostics
2378 .iter()
2379 .any(|diagnostic| diagnostic.level == DiagnosticLevel::Unsupported)
2380 );
2381 }
2382
2383 #[test]
2384 fn masks_terminal_controls_before_native_parsing() {
2385 let path = temporary_source("controls", ".TH SAFE 1\n.SH NAME\nsafe \x1b[2J text\n");
2386
2387 let document = parse_manual_source(&path).expect("parse sanitized manual");
2388 fs::remove_file(path).expect("remove temporary roff fixture");
2389
2390 assert!(
2391 document.diagnostics.iter().any(|diagnostic| {
2392 diagnostic.code.as_deref() == Some("manual.control-characters")
2393 })
2394 );
2395 }
2396
2397 #[test]
2398 fn lowers_normalized_ordered_lists_and_literal_displays() {
2399 let path = temporary_source(
2400 "normalized",
2401 ".Dd July 19, 2026\n.Dt NORMALIZED 1\n.Os\n.Sh CONTENT\n\
2402 .Bl -enum -compact\n.It\nfirst\n.It\nsecond\n.El\n\
2403 .Bd -literal -offset 6n\nline one\nline two\n.Ed\n",
2404 );
2405
2406 let document = parse_manual_source(&path).expect("lower normalized mdoc");
2407 fs::remove_file(path).expect("remove temporary roff fixture");
2408
2409 assert!(matches!(
2410 document.sections[0].blocks[0],
2411 Block::List {
2412 kind: mant_ir::ListKind::Ordered,
2413 compact: true,
2414 ..
2415 }
2416 ));
2417 assert!(matches!(
2418 document.sections[0].blocks[1],
2419 Block::Preformatted { layout, .. } if layout.indent_columns == 6
2420 ));
2421 }
2422
2423 #[test]
2424 fn lowers_normalized_mdoc_font_and_author_layout() {
2425 let path = temporary_source(
2426 "normalized-mdoc-modes",
2427 ".Dd July 19, 2026\n\
2428 .Dt NORMALIZED-MODES 1\n\
2429 .Os\n\
2430 .Sh AUTHORS\n\
2431 .An -split\n\
2432 .An Alice Example\n\
2433 .An Bob Example\n\
2434 .An -nosplit\n\
2435 .An Carol Example\n\
2436 .An Dave Example\n\
2437 .Sh DESCRIPTION\n\
2438 .Bf -literal\n\
2439 literal text\n\
2440 .Ef\n",
2441 );
2442
2443 let document = parse_manual_source(&path).expect("lower normalized mdoc modes");
2444 fs::remove_file(path).expect("remove temporary roff fixture");
2445
2446 let authors = &document.sections[0];
2447 let Block::Paragraph { children, .. } = &authors.blocks[0] else {
2448 panic!("authors are one paragraph");
2449 };
2450 assert_eq!(
2451 inline_text(children),
2452 "Alice Example\nBob Example Carol Example Dave Example"
2453 );
2454
2455 let description = &document.sections[1];
2456 let Block::Paragraph { children, .. } = &description.blocks[0] else {
2457 panic!("font block is a paragraph");
2458 };
2459 assert!(matches!(
2460 children.as_slice(),
2461 [Inline::Code { value }] if value == "literal text"
2462 ));
2463 }
2464
2465 #[test]
2466 fn mdoc_definition_layout_uses_the_normalized_list_width() {
2467 let path = temporary_source(
2468 "mdoc-definition-widths",
2469 ".Dd July 23, 2026\n.Dt WIDTHS 1\n.Os\n.Sh ITEMS\n\
2470 .Bl -tag -width 20n\n.It tenletters\nwide description\n.El\n\
2471 .Bl -tag -width 3n\n.It short\nnarrow description\n.El\n",
2472 );
2473
2474 let document = parse_manual_source(&path).expect("lower mdoc definition widths");
2475 fs::remove_file(path).expect("remove temporary roff fixture");
2476
2477 let lists = document.sections[0]
2478 .blocks
2479 .iter()
2480 .filter_map(|block| match block {
2481 Block::DefinitionList { items, .. } => Some(items),
2482 _ => None,
2483 })
2484 .collect::<Vec<_>>();
2485 assert_eq!(lists.len(), 2);
2486 assert!(lists[0][0].inline_term);
2487 assert!(!lists[1][0].inline_term);
2488 }
2489
2490 #[test]
2491 fn lowers_the_pinned_large_mdoc_fixture_without_empty_sections() {
2492 let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2493 .join("../libmandoc-rs/vendor/mandoc-1.14.6/mandoc.1");
2494 if !source.exists() {
2495 return;
2498 }
2499
2500 let document = parse_manual_source(&source).expect("lower vendored mandoc manual");
2501
2502 assert!(document.sections.len() > 5);
2503 assert!(
2504 document
2505 .sections
2506 .iter()
2507 .any(|section| section.title == "DESCRIPTION")
2508 );
2509 assert!(
2510 document
2511 .sections
2512 .iter()
2513 .all(|section| !section.blocks.is_empty() || !section.children.is_empty())
2514 );
2515 }
2516
2517 #[test]
2518 fn lowers_tbl_and_eqn_payloads_into_structured_blocks() {
2519 let path = temporary_source(
2520 "table-equation",
2521 ".TH PAYLOAD 1\n.SH TABLE\n.TS\ntab(|);\nl r.\nleft|right\n.TE\n\
2522 .SH EQUATION\n.EQ\nx + {width over 2}\n.EN\n",
2523 );
2524
2525 let document = parse_manual_source(&path).expect("lower table and equation");
2526 fs::remove_file(path).expect("remove temporary roff fixture");
2527
2528 assert!(matches!(
2529 document.sections[0].blocks[0],
2530 Block::Table { ref rows, .. } if rows.len() == 1 && rows[0].cells.len() == 2
2531 ));
2532 assert!(matches!(
2533 document.sections[1].blocks[0],
2534 Block::Equation { ref value, .. } if value == "x + width / 2"
2535 ));
2536 }
2537
2538 #[test]
2539 fn large_tbl_rows_scale_without_changing_their_topology() {
2540 const ROW_COUNT: usize = 2_048;
2541 let mut source = String::from(".TH TABLE-SCALE 7\n.SH TABLE\n.TS\nl l.\n");
2542 for index in 0..ROW_COUNT {
2543 writeln!(source, "left {index}\tright {index}").expect("append table row");
2544 }
2545 source.push_str(".TE\n");
2546
2547 let document = parse_manual_bytes(std::path::Path::new("table-scale.7"), source.as_bytes())
2548 .expect("lower large table");
2549
2550 let [Block::Table { rows, .. }] = document.sections[0].blocks.as_slice() else {
2551 panic!("large tbl input must remain one table");
2552 };
2553 assert_eq!(rows.len(), ROW_COUNT);
2554 assert!(matches!(
2555 rows.first().and_then(|row| row.cells.first()),
2556 Some(mant_ir::TableCell { blocks, .. })
2557 if matches!(blocks.as_slice(), [Block::Paragraph { children, .. }]
2558 if inline_text(children) == "left 0")
2559 ));
2560 assert!(matches!(
2561 rows.last().and_then(|row| row.cells.get(1)),
2562 Some(mant_ir::TableCell { blocks, .. })
2563 if matches!(blocks.as_slice(), [Block::Paragraph { children, .. }]
2564 if inline_text(children) == format!("right {}", ROW_COUNT - 1))
2565 ));
2566 }
2567
2568 #[test]
2569 fn keeps_inline_equations_in_macro_arguments_and_filled_prose() {
2570 let document = parse_manual_bytes(
2571 std::path::Path::new("inline-equation.7"),
2572 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",
2573 )
2574 .expect("lower inline equations");
2575
2576 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
2577 panic!(
2578 "expected one definition list: {:?}",
2579 document.sections[0].blocks
2580 );
2581 };
2582 let [item] = items.as_slice() else {
2583 panic!("expected one equation definition");
2584 };
2585 assert_eq!(inline_text(&item.terms[0]), "Dp dx _ 1 ... dx _ n");
2586 let [Block::Paragraph { children, .. }] = item.description.as_slice() else {
2587 panic!("expected one filled description: {:?}", item.description);
2588 };
2589 assert_eq!(
2590 inline_text(children),
2591 "Draw a polygon with, for i = 1 , ... , n + 1, its vertex."
2592 );
2593 assert!(children.iter().any(
2594 |child| matches!(child, Inline::Code { value } if value == "i = 1 , ... , n + 1")
2595 ));
2596 }
2597
2598 #[test]
2599 fn normalizes_inline_equations_retained_as_tbl_cell_text() {
2600 let document = parse_manual_bytes(
2601 std::path::Path::new("table-inline-equation.3"),
2602 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",
2603 )
2604 .expect("lower table equations");
2605
2606 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2607 panic!("expected equation table");
2608 };
2609 let [left, right] = rows[0].cells.as_slice() else {
2610 panic!("expected two cells");
2611 };
2612 let [Block::Paragraph { children: left, .. }] = left.blocks.as_slice() else {
2613 panic!("expected left paragraph");
2614 };
2615 let [
2616 Block::Paragraph {
2617 children: right, ..
2618 },
2619 ] = right.blocks.as_slice()
2620 else {
2621 panic!("expected right paragraph");
2622 };
2623 assert!(matches!(left.as_slice(), [Inline::Code { value }] if value == "0"));
2624 assert_eq!(inline_text(right), "for values in [ 0 , π / 2 ]");
2625 assert!(
2626 right
2627 .iter()
2628 .any(|child| matches!(child, Inline::Code { .. }))
2629 );
2630 }
2631
2632 #[test]
2633 fn bounds_distinct_tbl_equation_normalization_work() {
2634 let mut source =
2635 String::from(".TH TABLE-EQN-BUDGET 3\n.SH DESCRIPTION\n.EQ\ndelim %%\n.EN\n.TS\nl.\n");
2636 for index in 0..=MAX_INLINE_EQUATION_NORMALIZATIONS {
2637 writeln!(source, "%x{index}%").expect("write fixture row");
2638 }
2639 source.push_str(".TE\n");
2640
2641 let document = parse_manual_bytes(
2642 std::path::Path::new("table-inline-equation-budget.3"),
2643 source.as_bytes(),
2644 )
2645 .expect("lower a bounded number of table equations");
2646
2647 assert!(document.diagnostics.iter().any(|diagnostic| {
2648 diagnostic.code.as_deref() == Some("manual.inline-equation-budget")
2649 }));
2650 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2651 panic!("expected equation table");
2652 };
2653 assert_eq!(rows.len(), MAX_INLINE_EQUATION_NORMALIZATIONS + 1);
2654 }
2655
2656 #[test]
2657 fn preserves_tbl_rows_across_interleaved_comments_and_text_blocks() {
2658 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";
2659 let document = parse_manual_bytes(std::path::Path::new("commented-table.1"), source)
2660 .expect("lower commented table");
2661
2662 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2663 panic!("expected a table");
2664 };
2665 assert_eq!(rows.len(), 5);
2666 let first_cells = rows
2667 .iter()
2668 .map(|row| match row.cells[0].blocks.as_slice() {
2669 [Block::Paragraph { children, .. }] => inline_text(children),
2670 cells => panic!("expected one paragraph per table cell: {cells:?}"),
2671 })
2672 .collect::<Vec<_>>();
2673 assert_eq!(first_cells, ["a", "b", "c", "d(1)", "e"]);
2674 }
2675
2676 #[test]
2677 fn keeps_multiline_cells_aligned_after_an_empty_text_block() {
2678 let source = b".TH EMPTY-TABLE-CELL 7\n.SH TABLE\n.TS\ntab(@);\nl l l.\n\
2679T{\nT}@T{\nCore\nT}@T{\nProduction-grade, first-class\nT}\n.TE\n";
2680 let document = parse_manual_bytes(std::path::Path::new("empty-table-cell.7"), source)
2681 .expect("lower a row beginning with an empty text block");
2682
2683 let [Block::Table { rows, .. }] = document.sections[0].blocks.as_slice() else {
2684 panic!("expected one table");
2685 };
2686 let [row] = rows.as_slice() else {
2687 panic!("expected one table row");
2688 };
2689 let values = row
2690 .cells
2691 .iter()
2692 .map(|cell| match cell.blocks.as_slice() {
2693 [Block::Paragraph { children, .. }] => inline_text(children),
2694 [] => String::new(),
2695 blocks => panic!("unexpected table cell blocks: {blocks:?}"),
2696 })
2697 .collect::<Vec<_>>();
2698 assert_eq!(values, ["", "Core", "Production-grade, first-class"]);
2699 }
2700
2701 #[test]
2702 fn keeps_tbl_vertical_span_markers_out_of_visible_cells() {
2703 let document = parse_manual_bytes(
2704 std::path::Path::new("vertical-table-span.1"),
2705 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",
2706 )
2707 .expect("lower vertical table span");
2708
2709 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2710 panic!("expected a table");
2711 };
2712 assert_eq!(rows.len(), 4);
2713 assert_eq!(rows[1].cells[0].row_span, 3);
2714 assert!(rows[2].cells[0].blocks.is_empty());
2715 assert!(rows[3].cells[0].blocks.is_empty());
2716 }
2717
2718 #[test]
2719 fn preserves_tbl_rows_nested_in_unfilled_mdoc_displays() {
2720 let document = parse_manual_bytes(
2721 std::path::Path::new("unfilled-table.7"),
2722 b".Dd August 19, 2026\n.Dt UNFILLED-TABLE 7\n.Os\n.Sh DESCRIPTION\n\
2723.Bd -unfilled -offset indent\n.TS\ntab(@);\nl l.\nleft@right\nnext@value\n.TE\n.Ed\n",
2724 )
2725 .expect("lower table nested in an unfilled display");
2726
2727 let table = document.sections[0]
2728 .blocks
2729 .iter()
2730 .find_map(|block| match block {
2731 Block::Table { rows, .. } => Some(rows),
2732 _ => None,
2733 })
2734 .expect("nested table must remain structured");
2735 assert_eq!(table.len(), 2);
2736 assert_eq!(table[0].cells.len(), 2);
2737 assert!(
2738 document.sections[0]
2739 .blocks
2740 .iter()
2741 .all(|block| !matches!(block, Block::Preformatted { children, .. } if children.is_empty())),
2742 "the surrounding display must not leave an empty placeholder"
2743 );
2744 }
2745
2746 #[test]
2747 fn keeps_unexpanded_tabular_cells_visible_with_a_diagnostic() {
2748 let document = parse_manual_bytes(
2749 std::path::Path::new("unexpanded-table-cell.7"),
2750 b".TH UNEXPANDED-TABLE-CELL 7\n.SH DESCRIPTION\n.TS\nl l.\n1\t\\*[unknown-label]\n.TE\n",
2751 )
2752 .expect("lower unresolved formatter string in a table cell");
2753
2754 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2755 panic!("expected a structured table");
2756 };
2757 assert_eq!(rows[0].cells.len(), 2);
2758 let [Block::Paragraph { children, .. }] = rows[0].cells[1].blocks.as_slice() else {
2759 panic!("expected one recovered table-cell paragraph");
2760 };
2761 assert_eq!(inline_text(children), r"\*[unknown-label]");
2762 assert!(document.diagnostics.iter().any(|diagnostic| {
2763 diagnostic.level == DiagnosticLevel::Unsupported
2764 && diagnostic.code.as_deref() == Some("manual.unexpanded-table-cell")
2765 }));
2766 }
2767
2768 #[test]
2769 fn restores_mdoc_names_inside_tbl_text_blocks() {
2770 let document = parse_manual_bytes(
2771 std::path::Path::new("table-text-block.3"),
2772 b".Dd August 19, 2026\n.Dt TABLE-TEXT-BLOCK 3\n.Os\n\
2773.Sh NAME\n.Nm table-text-block\n.Nd test tbl text blocks\n\
2774.Sh ATTRIBUTES\n.TS\nallbox;\nl l.\nInterface\tValue\n\
2775T{\n.Nm\nT}\tMT-Safe\n.TE\n",
2776 )
2777 .expect("lower tbl text blocks");
2778
2779 let Block::Table { rows, .. } = &document.sections[1].blocks[0] else {
2780 panic!("expected attributes table");
2781 };
2782 let [Block::Paragraph { children, .. }] = rows[1].cells[0].blocks.as_slice() else {
2783 panic!("expected recovered name cell");
2784 };
2785 assert_eq!(inline_text(children), "table-text-block");
2786 assert!(matches!(children.as_slice(), [Inline::Strong { .. }]));
2787 }
2788
2789 #[test]
2790 fn keeps_semantic_links_inside_tbl_text_blocks() {
2791 let document = parse_manual_bytes(
2792 std::path::Path::new("table-text-link.1"),
2793 b".TH TABLE-TEXT-LINK 1\n\
2794.nr do-fallback 0\n\
2795.if !\\n(.f .nr do-fallback 1\n\
2796.if \\n[do-fallback] \\{\\\n\
2797. de MR\n\
2798. ie \\\\n(.$=1 \\\n\
2799. I \\%\\\\$1\n\
2800. el \\\n\
2801. IR \\%\\\\$1 (\\\\$2)\\\\$3\n\
2802. .\n\
2803.\\}\n\
2804.rr do-fallback\n\
2805.SH DESCRIPTION\n\
2806.TS\ntab($);\nl l.\ngrn$T{\nrenders\n.MR gremlin 1\ndiagrams;\nT}\n\
2807gperl$T{\npopulates\n.I groff\nregisters using\n.MR perl 1 ;\nT}\n.TE\n",
2808 )
2809 .expect("lower semantic tbl text block");
2810
2811 let [Block::Table { rows, .. }] = document.sections[0].blocks.as_slice() else {
2812 panic!("semantic table content must not escape into a separate paragraph");
2813 };
2814 let [Block::Paragraph { children, .. }] = rows[0].cells[1].blocks.as_slice() else {
2815 panic!("expected semantic table cell paragraph");
2816 };
2817 assert_eq!(inline_text(children), "renders gremlin(1) diagrams;");
2818 assert!(children.iter().any(|child| matches!(
2819 child,
2820 Inline::Link {
2821 target: mant_ir::LinkTarget::Manual { name, manual_section },
2822 ..
2823 } if name == "gremlin" && manual_section.as_deref() == Some("1")
2824 )));
2825 let [Block::Paragraph { children, .. }] = rows[1].cells[1].blocks.as_slice() else {
2826 panic!("expected styled semantic table cell paragraph");
2827 };
2828 assert_eq!(
2829 inline_text(children),
2830 "populates groff registers using perl(1);"
2831 );
2832 assert!(
2833 children
2834 .iter()
2835 .any(|child| matches!(child, Inline::Emphasis { .. }))
2836 );
2837 }
2838
2839 #[test]
2840 fn restores_alternating_font_arguments_inside_tbl_text_blocks() {
2841 let document = parse_manual_bytes(
2842 std::path::Path::new("table-text-alternation.7"),
2843 b".TH TABLE-TEXT-ALTERNATION 7\n.SH DESCRIPTION\n.TS\nl l.\nT{\n\
2844.BI \\[aq] s1 \\[aq] s2 \\[aq]\nT}\tT{\n\
2845.I s1\nproduces the same formatted output as\n.IR s2 .\nT}\n.TE\n",
2846 )
2847 .expect("lower alternating man macros inside a tbl text block");
2848
2849 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2850 panic!("expected a structured table");
2851 };
2852 let [left, right] = rows[0].cells.as_slice() else {
2853 panic!("expected both reconstructed table cells");
2854 };
2855 let [Block::Paragraph { children: left, .. }] = left.blocks.as_slice() else {
2856 panic!("expected a reconstructed left table-cell paragraph");
2857 };
2858 let [
2859 Block::Paragraph {
2860 children: right, ..
2861 },
2862 ] = right.blocks.as_slice()
2863 else {
2864 panic!("expected a reconstructed right table-cell paragraph");
2865 };
2866 assert_eq!(inline_text(left), "'s1's2'");
2867 assert_eq!(
2868 inline_text(right),
2869 "s1 produces the same formatted output as s2."
2870 );
2871 assert!(
2872 right
2873 .iter()
2874 .any(|inline| matches!(inline, Inline::Emphasis { .. }))
2875 );
2876 }
2877
2878 #[test]
2879 fn restores_nested_mdoc_requests_inside_tbl_text_blocks() {
2880 let document = parse_manual_bytes(
2881 std::path::Path::new("table-mdoc-requests.8"),
2882 b".Dd August 19, 2026\n.Dt TABLE-MDOC-REQUESTS 8\n.Os\n.Sh DESCRIPTION\n\
2883.TS\ntab(@);\nl l.\nT{\n.Cm sip Ar addr Ns Op / Ns Ar mask\nT}@T{\n\
2884bitwise and of the address with\n.Ar mask\nequals\n.Ar addr .\n.Ar addr\n\
2885can be an IPv4 or IPv6 address.\nT}\n.TE\n",
2886 )
2887 .expect("lower nested mdoc requests in table text blocks");
2888
2889 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2890 panic!("expected a structured table");
2891 };
2892 let [left, right] = rows[0].cells.as_slice() else {
2893 panic!("expected two reconstructed table cells");
2894 };
2895 let [Block::Paragraph { children: left, .. }] = left.blocks.as_slice() else {
2896 panic!("expected reconstructed selector cell");
2897 };
2898 let [
2899 Block::Paragraph {
2900 children: right, ..
2901 },
2902 ] = right.blocks.as_slice()
2903 else {
2904 panic!("expected reconstructed description cell");
2905 };
2906 assert_eq!(inline_text(left), "sip addr[/mask]");
2907 assert_eq!(
2908 inline_text(right),
2909 "bitwise and of the address with mask equals addr. addr can be an IPv4 or IPv6 address."
2910 );
2911 assert!(
2912 left.iter()
2913 .any(|inline| matches!(inline, Inline::Strong { .. }))
2914 );
2915 assert!(
2916 right
2917 .iter()
2918 .any(|inline| matches!(inline, Inline::Emphasis { .. }))
2919 );
2920 }
2921
2922 #[test]
2923 fn keeps_command_names_in_extended_mdoc_synopsis_terms() {
2924 let document = parse_manual_bytes(
2925 std::path::Path::new("extended-synopsis.8"),
2926 b".Dd August 19, 2026\n.Dt EXTENDED-SYNOPSIS 8\n.Os\n.Sh NAME\n\
2927.Nm zinject\n.Nd inject faults\n.Sh SYNOPSIS\n.Bl -tag -width Ds\n\
2928.It Xo\n.Nm zinject\n.Xc\nList injections.\n\
2929.It Xo\n.Nm zinject\n.Fl b Ar bookmark\n.Xc\nInject a bookmark.\n.El\n",
2930 )
2931 .expect("lower extended mdoc synopsis terms");
2932
2933 let Block::DefinitionList { items, .. } = &document.sections[1].blocks[0] else {
2934 panic!("expected synopsis definition list");
2935 };
2936 assert_eq!(inline_text(&items[0].terms[0]), "zinject");
2937 assert_eq!(inline_text(&items[1].terms[0]), "zinject -b bookmark");
2938 assert!(matches!(
2939 items[0].terms[0].as_slice(),
2940 [Inline::Strong { .. }]
2941 ));
2942 assert!(
2943 items[1].terms[0]
2944 .iter()
2945 .any(|inline| matches!(inline, Inline::Strong { .. }))
2946 );
2947 }
2948
2949 #[test]
2950 fn decodes_named_characters_inside_equations() {
2951 let document = parse_manual_bytes(
2952 std::path::Path::new("equation-characters.1"),
2953 b".TH EQUATION-CHARACTERS 1\n.SH EQUATION\n.EQ\n\\[*p] \\[mi] x\n.EN\n",
2954 )
2955 .expect("lower equation characters");
2956
2957 assert!(matches!(
2958 document.sections[0].blocks[0],
2959 Block::Equation { ref value, .. } if value == "\u{03c0} \u{2212} x"
2960 ));
2961 }
2962
2963 #[test]
2964 fn lowers_every_mdoc_column_list_cell() {
2965 let document = parse_manual_bytes(
2966 std::path::Path::new("columns.3"),
2967 b".Dd August 19, 2026\n.Dt COLUMNS 3\n.Os\n.Sh DESCRIPTION\n\
2968.Bl -column name type description\n.It Dv CLSET_TIMEOUT Ta \"struct timeval *\" Ta \"set total timeout\"\n.El\n",
2969 )
2970 .expect("lower mdoc column list");
2971
2972 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2973 panic!("expected column list to lower as a table");
2974 };
2975 assert_eq!(rows.len(), 1);
2976 assert_eq!(rows[0].cells.len(), 3);
2977 let rendered = rows[0]
2978 .cells
2979 .iter()
2980 .map(|cell| match cell.blocks.as_slice() {
2981 [Block::Paragraph { children, .. }] => inline_text(children),
2982 blocks => panic!("expected one paragraph per cell, got {blocks:?}"),
2983 })
2984 .collect::<Vec<_>>();
2985 assert_eq!(
2986 rendered,
2987 ["CLSET_TIMEOUT", "struct timeval *", "set total timeout"]
2988 );
2989 }
2990
2991 #[test]
2992 fn preserves_nested_mdoc_spacing_state_in_definition_terms() {
2993 let document = parse_manual_bytes(
2994 std::path::Path::new("nested-spacing.1"),
2995 b".Dd August 19, 2026\n.Dt NESTED-SPACING 1\n.Os\n.Sh OPTIONS\n\
2996.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",
2997 )
2998 .expect("lower nested mdoc spacing controls");
2999
3000 let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
3001 panic!("expected an option definition list");
3002 };
3003 assert_eq!(
3004 inline_text(&items[0].terms[0]),
3005 "-L local_socket:host:hostport"
3006 );
3007 }
3008
3009 #[test]
3010 fn carries_mdoc_spacing_state_into_display_lines() {
3011 let document = parse_manual_bytes(
3012 std::path::Path::new("display-spacing.8"),
3013 b".Dd August 24, 2026\n.Dt DISPLAY-SPACING 8\n.Os\n.Sh FORMAT\n\
3014.Sm off\n.D1 Ar name : uid : gid\n.Sm on\n",
3015 )
3016 .expect("lower display-scoped mdoc spacing controls");
3017
3018 let Block::Preformatted { children, .. } = &document.sections[0].blocks[0] else {
3019 panic!("expected one display line");
3020 };
3021 assert_eq!(inline_text(children), "name:uid:gid");
3022 }
3023
3024 #[test]
3025 fn carries_mdoc_spacing_state_across_list_item_boundaries() {
3026 let document = parse_manual_bytes(
3027 std::path::Path::new("list-spacing.8"),
3028 b".Dd August 19, 2026\n.Dt LIST-SPACING 8\n.Os\n.Sh COMMANDS\n\
3029.Bl -tag -width Ds\n.Sm off\n.It Ic O Ar device\n.Sm on\n.It Ic done\nFinished.\n.El\n",
3030 )
3031 .expect("lower list-scoped mdoc spacing controls");
3032
3033 let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
3034 panic!("expected a command definition list");
3035 };
3036 assert_eq!(inline_text(&items[0].terms[0]), "Odevice");
3037 assert_eq!(inline_text(&items[1].terms[0]), "done");
3038 }
3039
3040 #[test]
3041 fn carries_mdoc_spacing_state_out_of_nested_synopsis_enclosures() {
3042 let document = parse_manual_bytes(
3043 std::path::Path::new("nested-synopsis-spacing.8"),
3044 b".Dd August 19, 2026\n.Dt NESTED-SYNOPSIS-SPACING 8\n.Os\n.Sh SYNOPSIS\n\
3045.Nm demo\n.Sm off\n.Oo Fl m\\~\n.Ar memory\n.Sm on\n.Oc\n\
3046.Op Fl o Ar variable Ns Cm = Ns Ar value\n.Ar name\n",
3047 )
3048 .expect("lower nested synopsis spacing transitions");
3049
3050 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
3051 panic!("expected synopsis paragraph");
3052 };
3053 assert_eq!(
3054 inline_text(children),
3055 "demo [-m memory] [-o variable=value] name"
3056 );
3057 }
3058
3059 #[test]
3060 fn preserves_the_boundary_that_enters_a_compact_mdoc_term() {
3061 let document = parse_manual_bytes(
3062 std::path::Path::new("spacing-transition.5"),
3063 b".Dd August 19, 2026\n.Dt SPACING-TRANSITION 5\n.Os\n.Sh KEYWORDS\n\
3064.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",
3065 )
3066 .expect("lower an mdoc spacing transition inside a term");
3067
3068 let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
3069 panic!("expected a keyword definition list");
3070 };
3071 assert_eq!(inline_text(&items[0].terms[0]), "@newuser name:uid:gid");
3072 }
3073
3074 #[test]
3075 fn separates_alternative_terms_in_an_extended_mdoc_definition_head() {
3076 let document = parse_manual_bytes(
3077 std::path::Path::new("extended-term-alternatives.8"),
3078 b".Dd August 19, 2026\n.Dt EXTENDED-TERM-ALTERNATIVES 8\n.Os\n.Sh OPTIONS\n\
3079.Bl -tag -width Ds\n.It Xo\n.Sm off\n.Ar ipaddr\n.Op / Ar masklen\n.Pp\n\
3080.Ar ipaddr\n.Op / Ar prefixlen\n.Sm on\n.Xc\nAccept this peer.\n.El\n",
3081 )
3082 .expect("lower alternative extended definition terms");
3083
3084 let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
3085 panic!("expected a definition list");
3086 };
3087 assert_eq!(items.len(), 1);
3088 assert_eq!(items[0].terms.len(), 2);
3089 assert_eq!(inline_text(&items[0].terms[0]), "ipaddr[/masklen]");
3090 assert_eq!(inline_text(&items[0].terms[1]), "ipaddr[/prefixlen]");
3091 }
3092
3093 fn inline_text(children: &[Inline]) -> String {
3094 children
3095 .iter()
3096 .map(|child| match child {
3097 Inline::Text { value } | Inline::Code { value } => value.clone(),
3098 Inline::Strong { children }
3099 | Inline::Emphasis { children }
3100 | Inline::Link { children, .. } => inline_text(children),
3101 Inline::Anchor { .. } => String::new(),
3102 Inline::LineBreak => "\n".to_owned(),
3103 })
3104 .collect()
3105 }
3106}