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::{
15 cell::RefCell,
16 collections::{BTreeMap, HashMap, HashSet},
17 path::Path,
18};
19
20use libmandoc_rs::{
21 Compression, Document as MandocDocument, IncludePolicy, MacroSet, Node, ParseOptions,
22 ParseReport, Parser,
23};
24use mant_ir::{
25 Diagnostic, DiagnosticLevel, Document, DocumentMeta, DocumentSource, ParserInfo, SourceFormat,
26 SourceSpan, validate_document,
27};
28
29use self::{
30 roff_escape::visible_text,
31 source::{load_manual_source, redirect_target, resolve_manual_redirects},
32 source_lines::SourceLineIndex,
33};
34use crate::ManualPage;
35use crate::text_safety::mask_terminal_control_bytes;
36
37pub use error::{ManualError, ManualErrorKind};
38pub use source::MAX_MANUAL_BYTES;
39
40const MAX_INLINE_EQUATION_NORMALIZATIONS: usize = 256;
41
42pub fn parse_manual_source(path: &Path) -> Result<Document, ManualError> {
52 let loaded = load_manual_source(path)?;
53 reject_standalone_redirect(path, &loaded.source)?;
54 parse_plain_manual(path, &loaded.source, None)
55}
56
57pub fn parse_manual_bytes(path: &Path, source: &[u8]) -> Result<Document, ManualError> {
66 reject_standalone_redirect(path, source)?;
67 parse_plain_manual(path, source, None)
68}
69
70fn reject_standalone_redirect(path: &Path, source: &[u8]) -> Result<(), ManualError> {
71 if redirect_target(path, source)?.is_some() {
72 return Err(ManualError::redirect(
73 path,
74 "standalone .so redirects require MANPATH discovery and cannot be followed by --input",
75 ));
76 }
77 Ok(())
78}
79
80pub fn parse_manual_page(page: &ManualPage) -> Result<Document, ManualError> {
87 let resolved = resolve_manual_redirects(page)?;
88 parse_plain_manual(
89 &page.path,
90 &resolved.source,
91 resolved.alias_target.as_deref(),
92 )
93}
94
95fn parse_plain_manual(
96 path: &Path,
97 source: &[u8],
98 alias_target: Option<&str>,
99) -> Result<Document, ManualError> {
100 let (source, masked_controls) = mask_terminal_control_bytes(source);
101 let report = Parser::new(ParseOptions {
102 includes: IncludePolicy::Deny,
103 compression: Compression::Plain,
104 })
105 .parse_bytes(path, source.as_ref())
106 .map_err(ManualError::from)?;
107 let source_text = String::from_utf8_lossy(source.as_ref());
108 let mut document = lower_mandoc_document_with_source(path, &report, Some(&source_text));
109 if masked_controls > 0 {
110 document.diagnostics.insert(
111 0,
112 Diagnostic {
113 level: DiagnosticLevel::Warning,
114 code: Some("manual.control-characters".to_owned()),
115 message: format!("masked {masked_controls} terminal-unsafe control character(s)"),
116 source: None,
117 },
118 );
119 }
120 if let Some(alias_target) = alias_target {
121 document.meta.alias_target = Some(alias_target.to_owned());
122 }
123 Ok(document)
124}
125
126#[must_use]
128pub fn lower_mandoc_document(path: &Path, report: &ParseReport) -> Document {
129 lower_mandoc_document_with_source(path, report, None)
130}
131
132fn lower_mandoc_document_with_source(
133 path: &Path,
134 report: &ParseReport,
135 source: Option<&str>,
136) -> Document {
137 let parsed: &MandocDocument = &report.document;
138 let explicit_targets = navigation::explicit_targets(&parsed.root);
139 let mut context = LoweringContext::new(parsed.metadata.name.as_deref(), source);
140 context.reserve_section_ids(&explicit_targets);
141 let mut diagnostics = diagnostics::lower_diagnostics(&report.diagnostics);
142 let mut sections = blocks::lower_sections(&parsed.root, &mut context);
143 let mut root_blocks = blocks::lower_root_blocks(&parsed.root, &context);
144 diagnostics.extend(context.take_diagnostics());
145 navigation::normalize_generated_anchors(&mut root_blocks, &mut sections, &explicit_targets);
146 let mut retained_targets = explicit_targets.clone();
147 retained_targets.extend(crate::definitions::identify_definitions(
148 &mut root_blocks,
149 &mut sections,
150 &explicit_targets,
151 parsed.metadata.name.as_deref(),
152 ));
153 diagnostics.extend(crate::projection::semantic_selector_diagnostics(
154 &root_blocks,
155 §ions,
156 "manual",
157 ));
158 diagnostics.extend(crate::definitions::manual_discovery_diagnostics(§ions));
159 navigation::resolve_navigation(&mut sections, &retained_targets, &mut diagnostics);
160 let mut document = Document {
161 parser: Some(ParserInfo {
162 name: "libmandoc".to_owned(),
163 version: libmandoc_rs::LIBMANDOC_VERSION.to_owned(),
164 }),
165 source: DocumentSource {
166 format: match parsed.macro_set {
167 MacroSet::Mdoc => SourceFormat::Mdoc,
168 MacroSet::Man | MacroSet::None => SourceFormat::Man,
169 },
170 path: Some(path.to_string_lossy().into_owned()),
171 },
172 meta: DocumentMeta {
173 title: normalize_metadata(parsed.metadata.title.as_deref()),
174 manual_section: normalize_metadata(parsed.metadata.section.as_deref()),
175 date: normalize_metadata(parsed.metadata.date.as_deref()),
176 volume: normalize_metadata(parsed.metadata.volume.as_deref()),
177 os: normalize_metadata(parsed.metadata.os.as_deref()),
178 arch: normalize_metadata(parsed.metadata.arch.as_deref()),
179 names: normalize_metadata(parsed.metadata.name.as_deref())
180 .into_iter()
181 .collect(),
182 alias_target: parsed.metadata.alias_target.clone(),
183 },
184 diagnostics,
185 blocks: root_blocks,
186 sections,
187 };
188 document.diagnostics.extend(validate_document(&document));
189 document
190}
191
192fn normalize_metadata(value: Option<&str>) -> Option<String> {
197 value.map(visible_text)
198}
199
200struct LoweringContext<'a> {
201 default_name: Option<&'a str>,
202 source_lines: Option<SourceLineIndex<'a>>,
203 equation_delimiters: Vec<EquationDelimiterChange>,
204 normalized_equations: RefCell<BTreeMap<String, String>>,
205 section_ids: HashMap<String, usize>,
206 assigned_section_ids: HashSet<String>,
207 diagnostics: RefCell<Vec<Diagnostic>>,
208}
209
210#[derive(Clone, Copy, Debug)]
211struct EquationDelimiterChange {
212 line: u32,
213 delimiters: Option<(char, char)>,
214}
215
216#[derive(Clone, Copy, Debug)]
217enum EquationDelimiterDirective {
218 Enable(char, char),
219 Disable,
220}
221
222impl EquationDelimiterDirective {
223 const fn delimiters(self) -> Option<(char, char)> {
224 match self {
225 Self::Enable(opening, closing) => Some((opening, closing)),
226 Self::Disable => None,
227 }
228 }
229}
230
231#[derive(Debug)]
232struct TableTextBlock {
233 source: String,
234 start_line: u32,
235 end_line: u32,
236}
237
238impl TableTextBlock {
239 const fn contains_line(&self, line: u32) -> bool {
240 line >= self.start_line && line <= self.end_line
241 }
242}
243
244impl<'a> LoweringContext<'a> {
245 fn new(default_name: Option<&'a str>, source: Option<&'a str>) -> Self {
246 Self {
247 default_name,
248 source_lines: source.map(SourceLineIndex::new),
249 equation_delimiters: source.map_or_else(Vec::new, equation_delimiter_changes),
250 normalized_equations: RefCell::new(BTreeMap::new()),
251 section_ids: HashMap::new(),
252 assigned_section_ids: HashSet::new(),
253 diagnostics: RefCell::new(Vec::new()),
254 }
255 }
256
257 fn equation_delimiters_at(&self, line: u32) -> Option<(char, char)> {
258 self.equation_delimiters
259 .iter()
260 .rev()
261 .find(|change| change.line <= line)
262 .and_then(|change| change.delimiters)
263 }
264
265 fn reserve_section_ids(&mut self, ids: &HashSet<String>) {
266 self.assigned_section_ids.extend(ids.iter().cloned());
267 }
268
269 fn normalize_equation(&self, source: &str, line: u32) -> String {
274 {
275 let normalized = self.normalized_equations.borrow();
276 if let Some(value) = normalized.get(source) {
277 return value.clone();
278 }
279 if normalized.len() >= MAX_INLINE_EQUATION_NORMALIZATIONS {
280 drop(normalized);
281 self.warn_inline_equation_budget(line);
282 return visible_text(source);
283 }
284 }
285 let synthetic = format!(".TH MANT-EQN 7\n.EQ\n{source}\n.EN\n");
286 let normalized = Parser::default()
287 .parse_bytes(Path::new("mant-inline-eqn.7"), synthetic.as_bytes())
288 .ok()
289 .and_then(|report| first_equation(&report.document.root).map(visible_text))
290 .filter(|value| !value.trim().is_empty())
291 .unwrap_or_else(|| visible_text(source));
292 self.normalized_equations
293 .borrow_mut()
294 .insert(source.to_owned(), normalized.clone());
295 normalized
296 }
297
298 fn table_text_blocks(&self, line: u32, maximum: usize) -> Vec<TableTextBlock> {
299 if maximum == 0 {
303 return Vec::new();
304 }
305 let Some(source_lines) = self.source_lines.as_ref() else {
306 return Vec::new();
307 };
308 let mut blocks = Vec::new();
309 let mut current = None::<(String, u32)>;
310 for (line_number, line) in source_lines.lines_from(line) {
311 let trimmed = line.trim_start();
312 if trimmed.starts_with(".\\\"") || trimmed.starts_with("'\\\"") {
317 continue;
318 }
319 if let Some((content, start_line)) = current.as_mut() {
320 if let Some(remainder) = trimmed.strip_prefix("T}") {
321 blocks.push(TableTextBlock {
322 source: std::mem::take(content),
323 start_line: *start_line,
324 end_line: line_number.saturating_sub(1),
325 });
326 current = None;
327 if blocks.len() == maximum {
328 break;
329 }
330 if remainder.trim_end().ends_with("T{") {
334 current = Some((String::new(), line_number.saturating_add(1)));
335 }
336 } else {
337 if !content.is_empty() {
338 content.push('\n');
339 }
340 content.push_str(line);
341 }
342 } else if trimmed.trim_end().ends_with("T{") {
343 current = Some((String::new(), line_number.saturating_add(1)));
344 }
345 }
346 blocks
347 }
348
349 fn tab_separated_table_cells(&self, line: u32) -> Option<Vec<&'a str>> {
350 let source_line = self.source_lines.as_ref()?.line(line)?;
351 source_line
352 .contains('\t')
353 .then(|| source_line.split('\t').collect())
354 }
355
356 pub(super) fn no_fill_blank_rows_between(
366 &self,
367 previous_line: Option<u32>,
368 current_line: Option<u32>,
369 ) -> u16 {
370 let Some((previous, current)) = previous_line.zip(current_line) else {
371 return 0;
372 };
373 if current <= previous.saturating_add(1) {
374 return 0;
375 }
376 let Some(source_lines) = self.source_lines.as_ref() else {
377 return 0;
378 };
379 source_lines
380 .lines_between(previous, current)
381 .map(no_fill_vertical_rows)
382 .max()
383 .unwrap_or(0)
384 }
385
386 fn section_id(&mut self, title: &str) -> String {
387 let slug: String = title
388 .chars()
389 .flat_map(char::to_lowercase)
390 .map(|character| {
391 if character.is_alphanumeric() {
392 character
393 } else {
394 '-'
395 }
396 })
397 .collect::<String>()
398 .split('-')
399 .filter(|part| !part.is_empty())
400 .collect::<Vec<_>>()
401 .join("-");
402 let base = if slug.is_empty() {
403 "section".to_owned()
404 } else if crate::projection::is_reserved_selector(&slug) {
405 format!("{slug}-section")
406 } else {
407 slug
408 };
409 let count = self.section_ids.entry(base.clone()).or_default();
410 loop {
411 *count += 1;
412 let candidate = if *count == 1 {
413 base.clone()
414 } else {
415 format!("{base}-{count}")
416 };
417 if self.assigned_section_ids.insert(candidate.clone()) {
418 return candidate;
419 }
420 }
421 }
422
423 fn warn_unhandled_structural_parts(&self, node: &Node) {
424 let macro_name = node.macro_name.as_deref().unwrap_or("unknown");
425 self.diagnostics.borrow_mut().push(Diagnostic {
426 level: DiagnosticLevel::Warning,
427 code: Some("manual.unhandled-structural-parts".to_owned()),
428 message: format!(
429 "structural macro '{macro_name}' contains parts without a complete lowering policy"
430 ),
431 source: source_span(node),
432 });
433 }
434
435 fn warn_definition_alias_boundary(&self, node: &Node) {
436 self.diagnostics.borrow_mut().push(Diagnostic {
437 level: DiagnosticLevel::Warning,
438 code: Some("manual.definition-alias-boundary".to_owned()),
439 message: "unlabelled definition heads were kept separate because this macro does not prove that they share one description".to_owned(),
440 source: source_span(node),
441 });
442 }
443
444 fn warn_unhandled_table_text_block(&self, node: &Node) {
445 self.diagnostics.borrow_mut().push(Diagnostic {
446 level: DiagnosticLevel::Warning,
447 code: Some("manual.unhandled-table-text-block".to_owned()),
448 message: "tbl text block contains semantic roff that could not be retained".to_owned(),
449 source: source_span(node),
450 });
451 }
452
453 fn warn_unhandled_table_text_block_line(&self, line: u32) {
454 self.diagnostics.borrow_mut().push(Diagnostic {
455 level: DiagnosticLevel::Warning,
456 code: Some("manual.unhandled-table-text-block".to_owned()),
457 message: "tbl text block contains semantic roff that could not be retained".to_owned(),
458 source: Some(SourceSpan {
459 byte_range: None,
460 line,
461 column: 1,
462 end_line: None,
463 end_column: None,
464 }),
465 });
466 }
467
468 fn warn_unexpanded_table_cell(&self, line: u32) {
469 let mut diagnostics = self.diagnostics.borrow_mut();
470 if diagnostics
471 .iter()
472 .any(|diagnostic| diagnostic.code.as_deref() == Some("manual.unexpanded-table-cell"))
473 {
474 return;
475 }
476 diagnostics.push(Diagnostic {
477 level: DiagnosticLevel::Unsupported,
478 code: Some("manual.unexpanded-table-cell".to_owned()),
479 message: "one or more tbl cells contain formatter strings that could not be expanded; their source spellings were preserved".to_owned(),
480 source: Some(SourceSpan {
481 byte_range: None,
482 line,
483 column: 1,
484 end_line: None,
485 end_column: None,
486 }),
487 });
488 }
489
490 fn warn_inline_equation_budget(&self, line: u32) {
491 let mut diagnostics = self.diagnostics.borrow_mut();
492 if diagnostics
493 .iter()
494 .any(|diagnostic| diagnostic.code.as_deref() == Some("manual.inline-equation-budget"))
495 {
496 return;
497 }
498 diagnostics.push(Diagnostic {
499 level: DiagnosticLevel::Unsupported,
500 code: Some("manual.inline-equation-budget".to_owned()),
501 message: format!(
502 "more than {MAX_INLINE_EQUATION_NORMALIZATIONS} distinct inline table equations; later source spellings were retained without normalization"
503 ),
504 source: Some(SourceSpan {
505 byte_range: None,
506 line,
507 column: 1,
508 end_line: None,
509 end_column: None,
510 }),
511 });
512 }
513
514 fn take_diagnostics(&self) -> Vec<Diagnostic> {
515 self.diagnostics.take()
516 }
517}
518
519fn first_equation(node: &Node) -> Option<&str> {
520 node.equation
521 .as_deref()
522 .or_else(|| node.children.iter().find_map(first_equation))
523}
524
525fn equation_delimiter_changes(source: &str) -> Vec<EquationDelimiterChange> {
532 let mut changes = Vec::new();
533 let mut in_equation = false;
534 let mut pending = None;
535 for (index, source_line) in source.lines().enumerate() {
536 let line = u32::try_from(index + 1).unwrap_or(u32::MAX);
537 let trimmed = source_line.trim();
538 if trimmed.starts_with(".\\\"") || trimmed.starts_with("'\\\"") {
539 continue;
540 }
541 if let Some(rest) = trimmed
542 .strip_prefix(".EQ")
543 .or_else(|| trimmed.strip_prefix("'EQ"))
544 .filter(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace))
545 {
546 in_equation = true;
547 pending = parse_equation_delimiters(rest.trim()).or(pending);
548 continue;
549 }
550 if in_equation {
551 if trimmed == ".EN" || trimmed == "'EN" {
552 if let Some(delimiters) = pending.take() {
553 changes.push(EquationDelimiterChange {
554 line: line.saturating_add(1),
555 delimiters: delimiters.delimiters(),
556 });
557 }
558 in_equation = false;
559 } else if let Some(delimiters) = parse_equation_delimiters(trimmed) {
560 pending = Some(delimiters);
561 }
562 }
563 }
564 changes
565}
566
567fn parse_equation_delimiters(value: &str) -> Option<EquationDelimiterDirective> {
568 let value = value.strip_prefix("delim")?.trim_start();
569 if value == "off" {
570 return Some(EquationDelimiterDirective::Disable);
571 }
572 let mut delimiters = value.chars();
573 let opening = delimiters.next()?;
574 let closing = delimiters.next()?;
575 Some(EquationDelimiterDirective::Enable(opening, closing))
576}
577
578fn no_fill_vertical_rows(line: &str) -> u16 {
583 let trimmed = line.trim();
584 if trimmed.is_empty() || roff_zero_width_blank_line(trimmed) {
585 return 1;
586 }
587 let Some(request) = line.trim_start().strip_prefix(['.', '\'']) else {
588 return 0;
589 };
590 let (name, arguments) = request
591 .split_once(char::is_whitespace)
592 .unwrap_or((request, ""));
593 if name != "sp" {
594 return 0;
595 }
596 let Some(argument) = arguments.split_whitespace().next() else {
597 return 1;
598 };
599 argument.trim_end_matches('v').parse::<u16>().unwrap_or(1)
600}
601
602fn roff_zero_width_blank_line(line: &str) -> bool {
610 let mut remainder = line;
611 let mut found = false;
612 while let Some(rest) = remainder.strip_prefix(r"\&") {
613 found = true;
614 remainder = rest.trim();
615 }
616 found && remainder.is_empty()
617}
618
619fn source_span(node: &Node) -> Option<SourceSpan> {
620 (node.line > 0).then_some(SourceSpan {
621 byte_range: None,
622 line: node.line,
623 column: node.column.max(1),
624 end_line: None,
625 end_column: None,
626 })
627}
628
629fn first_part_children(node: &Node, kind: libmandoc_rs::NodeKind) -> &[Node] {
635 node.children
636 .iter()
637 .find(|child| child.kind == kind)
638 .map_or(&[], |child| child.children.as_slice())
639}
640
641fn part_child_groups(node: &Node, kind: libmandoc_rs::NodeKind) -> impl Iterator<Item = &[Node]> {
643 node.children
644 .iter()
645 .filter(move |child| child.kind == kind)
646 .map(|child| child.children.as_slice())
647}
648
649#[cfg(test)]
650mod tests {
651 use std::{collections::HashSet, fmt::Write as _, fs, process};
652
653 use mant_ir::{
654 Block, DiagnosticLevel, Inline, SourceFormat,
655 visit::{self, Visit},
656 };
657
658 use super::{
659 LoweringContext, MAX_INLINE_EQUATION_NORMALIZATIONS, Parser, lower_mandoc_document,
660 parse_manual_bytes, parse_manual_source,
661 };
662
663 fn temporary_source(label: &str, source: &str) -> std::path::PathBuf {
664 let path = std::env::temp_dir().join(format!("mant-lower-{label}-{}.1", process::id()));
665 fs::write(&path, source).expect("write temporary roff fixture");
666 path
667 }
668
669 #[test]
670 fn native_section_ids_ignore_unrelated_section_insertions() {
671 let mut original = LoweringContext::new(None, None);
672 let original_name = original.section_id("NAME");
673 let original_options = original.section_id("OPTIONS");
674
675 let mut edited = LoweringContext::new(None, None);
676 assert_eq!(edited.section_id("NOTES"), "notes");
677 assert_eq!(edited.section_id("NAME"), original_name);
678 assert_eq!(edited.section_id("OPTIONS"), original_options);
679 assert_eq!(edited.section_id("OPTIONS"), "options-2");
680 }
681
682 #[test]
683 fn native_section_ids_disambiguate_final_slug_collisions() {
684 let mut context = LoweringContext::new(None, None);
685 assert_eq!(context.section_id("FOO"), "foo");
686 assert_eq!(context.section_id("FOO"), "foo-2");
687 assert_eq!(context.section_id("FOO 2"), "foo-2-2");
688 }
689
690 #[test]
691 fn native_generated_anchors_share_one_normalized_unique_namespace() {
692 struct AnchorCollector(Vec<String>);
693
694 impl<'ir> Visit<'ir> for AnchorCollector {
695 fn visit_inline(&mut self, inline: &'ir Inline) {
696 if let Inline::Anchor { id } = inline {
697 self.0.push(id.to_string());
698 }
699 visit::walk_inline(self, inline);
700 }
701 }
702
703 let document = parse_manual_bytes(
704 std::path::Path::new("anchors.1"),
705 b".TH ANCHORS 1\n.SH ALPHA\nProse.\n.SH OPTIONS\n.TP\n.B --ALPHA\nFirst.\n.TP\n.B --ALPHA\nSecond.\n",
706 )
707 .expect("lower repeated uppercase definition tags");
708 let mut anchors = AnchorCollector(Vec::new());
709 anchors.visit_document(&document);
710
711 assert!(anchors.0.iter().any(|id| id == "alpha-2"));
712 assert!(anchors.0.iter().any(|id| id == "alpha-3"));
713 assert_eq!(
714 anchors.0.len(),
715 anchors.0.iter().collect::<HashSet<_>>().len()
716 );
717 assert!(document.diagnostics.iter().all(|diagnostic| {
718 !matches!(
719 diagnostic.code.as_deref(),
720 Some("ir.invalid-identity" | "ir.duplicate-identity")
721 )
722 }));
723 }
724
725 fn find_macro_mut<'a>(
726 node: &'a mut libmandoc_rs::Node,
727 name: &str,
728 ) -> Option<&'a mut libmandoc_rs::Node> {
729 if node.macro_name.as_deref() == Some(name) {
730 return Some(node);
731 }
732 node.children
733 .iter_mut()
734 .find_map(|child| find_macro_mut(child, name))
735 }
736
737 fn replace_first_text(node: &mut libmandoc_rs::Node, value: &str) -> bool {
738 if let Some(text) = node.text.as_mut() {
739 *text = value.to_owned();
740 return true;
741 }
742 node.children
743 .iter_mut()
744 .any(|child| replace_first_text(child, value))
745 }
746
747 #[test]
748 fn standalone_inputs_reject_redirect_only_so_pages() {
749 let error = parse_manual_bytes(std::path::Path::new("stdin"), b".so man1/target.1\n")
750 .expect_err("standalone input must not follow another file");
751 assert!(error.to_string().contains("require MANPATH discovery"));
752 }
753
754 #[test]
755 fn lowers_man_sections_fonts_definitions_and_literal_blocks() {
756 let path = temporary_source(
757 "man",
758 ".TH MANT 1 \"July 2026\"\n\
759 .SH NAME\n\
760 mant \\- a viewer\n\
761 .SH OPTIONS\n\
762 .TP\n\
763 \\fB\\-h\\fR\n\
764 Show help.\n\
765 .nf\n\
766 mant --help\n\
767 mant git\n\
768 .fi\n",
769 );
770
771 let document = parse_manual_source(&path).expect("lower man source");
772 fs::remove_file(path).expect("remove temporary roff fixture");
773
774 assert_eq!(document.source.format, SourceFormat::Man);
775 assert_eq!(
776 document
777 .sections
778 .iter()
779 .map(|section| section.title.as_str())
780 .collect::<Vec<_>>(),
781 vec!["NAME", "OPTIONS"]
782 );
783 assert!(
784 document.sections[1]
785 .blocks
786 .iter()
787 .any(|block| matches!(block, Block::DefinitionList { .. }))
788 );
789 assert!(document.sections[1].blocks.iter().any(|block| matches!(
790 block,
791 Block::DefinitionList { items, .. }
792 if items.iter().any(|item| item.description.iter().any(
793 |description| matches!(description, Block::Preformatted { .. })
794 ))
795 )));
796 }
797
798 #[test]
799 fn composite_environment_options_do_not_promote_shell_labels() {
800 let path = temporary_source(
801 "environment-options",
802 ".TH DEMO 1\n\
803 .SH \"ENVIRONMENT OPTIONS\"\n\
804 .TP\n\
805 Unix Bourne shell:\n\
806 UNZIP=-qq; export UNZIP\n\
807 .TP\n\
808 \\-q\n\
809 Be quiet.\n",
810 );
811
812 let document = parse_manual_source(&path).expect("lower environment option fixture");
813 fs::remove_file(path).expect("remove fixture");
814 let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
815 panic!("definitions");
816 };
817 assert_eq!(
818 items[0].identity.as_ref().expect("term").role,
819 mant_ir::DefinitionRole::Term
820 );
821 assert_eq!(items[1].identity.as_ref().expect("option").names, ["-q"]);
822 assert!(document.diagnostics.iter().any(|diagnostic| {
823 diagnostic.code.as_deref() == Some("manual.semantic-entry.unclassified-definition")
824 && diagnostic.message.contains("Unix Bourne shell:")
825 }));
826 }
827
828 #[test]
829 fn separates_definition_layout_arguments_from_visible_terms() {
830 let path = temporary_source(
831 "definition-head-roles",
832 ".TH HEAD-ROLES 1\n\
833 .SH EXAMPLES\n\
834 .TP \\w'man\\ 'u\n\
835 .BI man \\ ls\n\
836 Display ls.\n\
837 .TP 4\n\
838 4\n\
839 A numeric term remains visible.\n\
840 .IP \"1\" 8n\n\
841 An IP width remains layout-only.\n",
842 );
843
844 let document = parse_manual_source(&path).expect("lower definition head roles");
845 fs::remove_file(path).expect("remove temporary roff fixture");
846
847 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
848 panic!("expected one definition list");
849 };
850 assert_eq!(
851 items
852 .iter()
853 .flat_map(|item| item.terms.iter())
854 .map(|term| inline_text(term))
855 .collect::<Vec<_>>(),
856 ["man ls", "4", "1"]
857 );
858 assert!(matches!(
859 items[0].terms[0].as_slice(),
860 [
861 Inline::Anchor { id },
862 Inline::Anchor { .. },
863 Inline::Strong { .. },
864 Inline::Emphasis { .. }
865 ]
866 if items[0].identity.as_ref().is_some_and(|identity| &identity.id == id)
867 ));
868 assert!(
869 items
870 .iter()
871 .flat_map(|item| item.terms.iter())
872 .all(|term| !inline_text(term).contains("96u"))
873 );
874 }
875
876 #[test]
877 fn preserves_consecutive_tp_aliases_ending_in_line_continuations() {
878 let path = temporary_source(
879 "continued-definition-aliases",
880 ".TH ALIASES 1\n\
881 .SH OPTIONS\n\
882 .TP\n\
883 .BI \"\\-symbols=\" \"file\"\\c\n\
884 .TP\n\
885 .BI \"\\-s \" \"file\"\\c\n\
886 \\&\n\
887 Read symbols.\n",
888 );
889
890 let document = parse_manual_source(&path).expect("lower consecutive TP aliases");
891 fs::remove_file(path).expect("remove temporary roff fixture");
892
893 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
894 panic!("expected one definition list");
895 };
896 assert_eq!(items.len(), 1);
897 assert_eq!(
898 items[0]
899 .terms
900 .iter()
901 .map(|term| inline_text(term))
902 .collect::<Vec<_>>(),
903 ["-symbols=file", "-s file"]
904 );
905 let Block::Paragraph { children, .. } = &items[0].description[0] else {
906 panic!("expected alias description paragraph");
907 };
908 assert_eq!(inline_text(children), "Read symbols.");
909 }
910
911 #[test]
912 fn keeps_unrelated_consecutive_tp_definitions_separate() {
913 let path = temporary_source(
914 "distinct-consecutive-definitions",
915 ".TH DISTINCT 1\n\
916 .SH OPTIONS\n\
917 .TP\n\
918 -a\n\
919 .TP\n\
920 -b\n\
921 Description only for b.\n",
922 );
923
924 let document = parse_manual_source(&path).expect("lower distinct tagged paragraphs");
925 fs::remove_file(path).expect("remove temporary roff fixture");
926 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
927 panic!("expected one definition list");
928 };
929 assert_eq!(items.len(), 2);
930 assert_eq!(inline_text(&items[0].terms[0]), "-a");
931 assert!(items[0].description.is_empty());
932 assert_eq!(inline_text(&items[1].terms[0]), "-b");
933 let Block::Paragraph { children, .. } = &items[1].description[0] else {
934 panic!("expected second tagged paragraph description");
935 };
936 assert_eq!(inline_text(children), "Description only for b.");
937 }
938
939 #[test]
940 fn paragraph_distance_zero_does_not_turn_tp_items_into_aliases() {
941 let path = temporary_source(
942 "distinct-zero-distance-definitions",
943 ".TH DISTINCT 1\n\
944 .SH OPTIONS\n\
945 .PD 0\n\
946 .TP\n\
947 -a\n\
948 .TP\n\
949 -b\n\
950 Description only for b.\n",
951 );
952
953 let document = parse_manual_source(&path).expect("lower compact tagged paragraphs");
954 fs::remove_file(path).expect("remove temporary roff fixture");
955 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
956 panic!("expected one definition list");
957 };
958 assert_eq!(items.len(), 2);
959 assert_eq!(inline_text(&items[0].terms[0]), "-a");
960 assert!(items[0].description.is_empty());
961 assert_eq!(inline_text(&items[1].terms[0]), "-b");
962 let Block::Paragraph { children, .. } = &items[1].description[0] else {
963 panic!("expected second tagged paragraph description");
964 };
965 assert_eq!(inline_text(children), "Description only for b.");
966 }
967
968 #[test]
969 fn restored_paragraph_distance_closes_a_compact_tp_alias_group() {
970 let path = temporary_source(
971 "compact-alias-group",
972 ".TH ALIASES 1\n\
973 .SH COMMANDS\n\
974 .TP\n\
975 bind first-form\n\
976 .PD 0\n\
977 .TP\n\
978 bind second-form\n\
979 .PD\n\
980 Shared description.\n",
981 );
982
983 let document = parse_manual_source(&path).expect("lower compact alias group");
984 fs::remove_file(path).expect("remove temporary roff fixture");
985 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
986 panic!("expected one definition list");
987 };
988 assert_eq!(items.len(), 1);
989 assert_eq!(items[0].terms.len(), 2);
990 assert_eq!(inline_text(&items[0].terms[0]), "bind first-form");
991 assert_eq!(inline_text(&items[0].terms[1]), "bind second-form");
992 let Block::Paragraph { children, .. } = &items[0].description[0] else {
993 panic!("expected shared description");
994 };
995 assert_eq!(inline_text(children), "Shared description.");
996 }
997
998 #[test]
999 fn paragraph_distance_in_the_first_tp_head_opens_a_compact_alias_group() {
1000 let path = temporary_source(
1001 "head-owned-compact-alias-group",
1002 ".TH ALIASES 1\n\
1003 .SH OPTIONS\n\
1004 .TP\n\
1005 .PD 0\n\
1006 --first\n\
1007 .TP\n\
1008 --second\n\
1009 .PD\n\
1010 Shared description.\n",
1011 );
1012
1013 let document = parse_manual_source(&path).expect("lower head-owned compact alias group");
1014 fs::remove_file(path).expect("remove temporary roff fixture");
1015 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
1016 panic!("expected one definition list");
1017 };
1018 assert_eq!(items.len(), 1);
1019 assert_eq!(items[0].terms.len(), 2);
1020 assert_eq!(inline_text(&items[0].terms[0]), "--first");
1021 assert_eq!(inline_text(&items[0].terms[1]), "--second");
1022 let Block::Paragraph { children, .. } = &items[0].description[0] else {
1023 panic!("expected shared description");
1024 };
1025 assert_eq!(inline_text(children), "Shared description.");
1026 }
1027
1028 #[test]
1029 fn compact_alias_group_does_not_absorb_a_preceding_orphan() {
1030 let path = temporary_source(
1031 "bounded-compact-alias-group",
1032 ".TH ALIASES 1\n\
1033 .SH OPTIONS\n\
1034 .TP\n\
1035 orphan\n\
1036 .TP\n\
1037 .PD 0\n\
1038 --first\n\
1039 .TP\n\
1040 --second\n\
1041 .PD\n\
1042 Shared description.\n",
1043 );
1044
1045 let document = parse_manual_source(&path).expect("lower bounded compact alias group");
1046 fs::remove_file(path).expect("remove temporary roff fixture");
1047 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
1048 panic!("expected one definition list");
1049 };
1050 assert_eq!(items.len(), 2);
1051 assert_eq!(inline_text(&items[0].terms[0]), "orphan");
1052 assert!(items[0].description.is_empty());
1053 assert_eq!(items[1].terms.len(), 2);
1054 assert_eq!(inline_text(&items[1].terms[0]), "--first");
1055 assert_eq!(inline_text(&items[1].terms[1]), "--second");
1056 }
1057
1058 #[test]
1059 fn adjacent_compact_alias_groups_keep_their_exact_boundaries() {
1060 let path = temporary_source(
1061 "adjacent-compact-alias-groups",
1062 ".TH ALIASES 1\n\
1063 .SH OPTIONS\n\
1064 .TP\n\
1065 .PD 0\n\
1066 --first\n\
1067 .TP\n\
1068 --second\n\
1069 .PD\n\
1070 First description.\n\
1071 .TP\n\
1072 .PD 0\n\
1073 --third\n\
1074 .TP\n\
1075 --fourth\n\
1076 .PD\n\
1077 Second description.\n",
1078 );
1079
1080 let document = parse_manual_source(&path).expect("lower adjacent compact alias groups");
1081 fs::remove_file(path).expect("remove temporary roff fixture");
1082 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
1083 panic!("expected one definition list");
1084 };
1085 assert_eq!(items.len(), 2);
1086 assert_eq!(inline_text(&items[0].terms[0]), "--first");
1087 assert_eq!(inline_text(&items[0].terms[1]), "--second");
1088 assert_eq!(inline_text(&items[1].terms[0]), "--third");
1089 assert_eq!(inline_text(&items[1].terms[1]), "--fourth");
1090 }
1091
1092 #[test]
1093 fn unclosed_compact_run_stays_separate_and_resets_at_indent_scope() {
1094 let path = temporary_source(
1095 "unclosed-compact-alias-group",
1096 ".TH ALIASES 1\n\
1097 .SH OPTIONS\n\
1098 .TP\n\
1099 .PD 0\n\
1100 --loose\n\
1101 .TP\n\
1102 --described\n\
1103 Own description.\n\
1104 .TP\n\
1105 .PD 0\n\
1106 outer\n\
1107 .RS\n\
1108 .TP\n\
1109 inner\n\
1110 .PD\n\
1111 Inner description.\n\
1112 .RE\n",
1113 );
1114
1115 let document = parse_manual_source(&path).expect("lower unclosed compact alias group");
1116 fs::remove_file(path).expect("remove temporary roff fixture");
1117 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
1118 panic!("expected one outer definition list");
1119 };
1120 assert_eq!(items.len(), 3);
1121 assert_eq!(inline_text(&items[0].terms[0]), "--loose");
1122 assert!(items[0].description.is_empty());
1123 assert_eq!(inline_text(&items[1].terms[0]), "--described");
1124 assert_eq!(inline_text(&items[2].terms[0]), "outer");
1125 let [
1126 Block::DefinitionList {
1127 items: inner_items, ..
1128 },
1129 ] = items[2].description.as_slice()
1130 else {
1131 panic!("expected one nested definition list");
1132 };
1133 assert_eq!(inner_items.len(), 1);
1134 assert_eq!(inline_text(&inner_items[0].terms[0]), "inner");
1135 assert!(!inner_items[0].description.is_empty());
1136 }
1137
1138 #[test]
1139 fn unclosed_compact_run_does_not_cross_a_section_boundary() {
1140 let path = temporary_source(
1141 "section-bounded-compact-alias-group",
1142 ".TH ALIASES 1\n\
1143 .SH FIRST\n\
1144 .TP\n\
1145 .PD 0\n\
1146 first\n\
1147 .SH SECOND\n\
1148 .TP\n\
1149 second\n\
1150 .PD\n\
1151 Second description.\n",
1152 );
1153
1154 let document = parse_manual_source(&path).expect("lower section-bounded compact run");
1155 fs::remove_file(path).expect("remove temporary roff fixture");
1156 let [first, second] = document.sections.as_slice() else {
1157 panic!("expected two sections");
1158 };
1159 let [
1160 Block::DefinitionList {
1161 items: first_items, ..
1162 },
1163 ] = first.blocks.as_slice()
1164 else {
1165 panic!("expected first definition list");
1166 };
1167 let [
1168 Block::DefinitionList {
1169 items: second_items,
1170 ..
1171 },
1172 ] = second.blocks.as_slice()
1173 else {
1174 panic!("expected second definition list");
1175 };
1176 assert_eq!(first_items.len(), 1);
1177 assert_eq!(inline_text(&first_items[0].terms[0]), "first");
1178 assert!(first_items[0].description.is_empty());
1179 assert_eq!(second_items.len(), 1);
1180 assert_eq!(inline_text(&second_items[0].terms[0]), "second");
1181 assert!(!second_items[0].description.is_empty());
1182 }
1183
1184 #[test]
1185 fn preserves_man_synopsis_flow_and_alternating_fonts() {
1186 let path = temporary_source(
1187 "man-synopsis-flow",
1188 ".TH MAN 1\n\
1189 .SH SYNOPSIS\n\
1190 .B man\n\
1191 .RI [\\| \"man options\" \\|]\n\
1192 .RI [\\|[\\| section \\|]\n\
1193 .IR page \\ \\|.\\|.\\|.\\|]\\ \\.\\|.\\|.\\&\n\
1194 .br\n\
1195 .B man\n\
1196 .B \\-k\n\
1197 .RI [\\| \"apropos options\" \\|]\n\
1198 .I regexp\n\
1199 \\&.\\|.\\|.\\&\n\
1200 .br\n\
1201 .B man\n\
1202 .BR \\-w \\||\\| \\-W\n\
1203 .RI [\\| \"man options\" \\|]\n\
1204 .I page\n\
1205 \\&.\\|.\\|.\\&\n",
1206 );
1207
1208 let document = parse_manual_source(&path).expect("lower man synopsis");
1209 fs::remove_file(path).expect("remove temporary roff fixture");
1210
1211 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1212 panic!("expected one synopsis paragraph");
1213 };
1214 assert_eq!(
1215 inline_text(children),
1216 "man [man options] [[section] page ...] ...\n\
1217 man -k [apropos options] regexp ...\n\
1218 man -w|-W [man options] page ..."
1219 );
1220 assert_eq!(
1221 children
1222 .iter()
1223 .filter(|node| matches!(node, Inline::LineBreak))
1224 .count(),
1225 2
1226 );
1227 assert!(children.iter().any(
1228 |node| matches!(node, Inline::Emphasis { children } if inline_text(children) == "man options")
1229 ));
1230 assert!(children.iter().any(
1231 |node| matches!(node, Inline::Strong { children } if inline_text(children) == "-w")
1232 ));
1233 assert!(children.iter().any(
1234 |node| matches!(node, Inline::Strong { children } if inline_text(children) == "-W")
1235 ));
1236 }
1237
1238 #[test]
1239 fn preserves_man_sy_heads_with_body_content_and_inline_fonts() {
1240 let document = parse_manual_bytes(
1241 std::path::Path::new("sy-heads.1"),
1242 b".TH SY-HEADS 1 \"August 17, 2026\"\n\
1243.SH SYNOPSIS\n\
1244.SY getent\n\
1245.RI [ option ]\n\
1246.I database\n\
1247.YS\n\
1248.SH DESCRIPTION\n\
1249.SY #!\\f[I]interpreter\\f[]\n\
1250.RI [ optional-arg ]\n\
1251.YS\n",
1252 )
1253 .expect("lower SY heads");
1254
1255 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1256 panic!("expected one synopsis paragraph");
1257 };
1258 assert_eq!(inline_text(children), "getent [option] database");
1259 assert!(matches!(
1260 children.first(),
1261 Some(Inline::Strong { children }) if inline_text(children) == "getent"
1262 ));
1263
1264 let [Block::Paragraph { children, .. }] = document.sections[1].blocks.as_slice() else {
1265 panic!("expected one description paragraph");
1266 };
1267 assert_eq!(inline_text(children), "#!interpreter [optional-arg]");
1268 assert!(matches!(
1269 children.first(),
1270 Some(Inline::Strong { children })
1271 if children.iter().any(|inline| matches!(
1272 inline,
1273 Inline::Emphasis { children } if inline_text(children) == "interpreter"
1274 ))
1275 ));
1276 assert!(
1277 document.diagnostics.is_empty(),
1278 "{:?}",
1279 document.diagnostics
1280 );
1281 }
1282
1283 #[test]
1284 fn keeps_man_synopsis_lines_together_inside_no_fill_examples() {
1285 let document = parse_manual_bytes(
1286 std::path::Path::new("no-fill-synopsis.2"),
1287 b".TH NO-FILL-SYNOPSIS 2\n\
1288.SH DESCRIPTION\n\
1289.EX\n\
1290.SY #!\\f[I]interpreter\\f[]\n\
1291.RI [ optional-arg ]\n\
1292.YS\n\
1293.EE\n",
1294 )
1295 .expect("lower synopsis inside example");
1296
1297 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
1298 panic!(
1299 "no-fill synopsis must remain one preformatted block: {:?}",
1300 document.sections[0].blocks
1301 );
1302 };
1303 assert_eq!(inline_text(children), "#!interpreter\n[optional-arg]");
1304 assert_eq!(
1305 children
1306 .iter()
1307 .filter(|inline| matches!(inline, Inline::LineBreak))
1308 .count(),
1309 1
1310 );
1311 }
1312
1313 #[test]
1314 fn preserves_explicit_blank_rows_inside_no_fill_displays() {
1315 let document = parse_manual_bytes(
1316 std::path::Path::new("no-fill-blank-row.7"),
1317 b".TH NO-FILL-BLANK-ROW 7\n\
1318.SH EXAMPLE\n\
1319.EX\n\
1320first line\n\
1321\n\
1322second line\n\
1323.EE\n",
1324 )
1325 .expect("lower no-fill blank row");
1326
1327 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
1328 panic!(
1329 "no-fill display must remain preformatted: {:?}",
1330 document.sections[0].blocks
1331 );
1332 };
1333 assert_eq!(inline_text(children), "first line\n\nsecond line");
1334 assert_eq!(
1335 children
1336 .iter()
1337 .filter(|inline| matches!(inline, Inline::LineBreak))
1338 .count(),
1339 2
1340 );
1341 }
1342
1343 #[test]
1344 fn preserves_zero_width_guard_rows_inside_no_fill_displays() {
1345 let document = parse_manual_bytes(
1346 std::path::Path::new("no-fill-zero-width-row.7"),
1347 b".TH NO-FILL-ZERO-WIDTH-ROW 7\n\
1348.SH EXAMPLE\n\
1349.EX\n\
1350first line\n\
1351\\&\n\
1352second line\n\
1353.EE\n",
1354 )
1355 .expect("lower no-fill zero-width row");
1356
1357 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
1358 panic!(
1359 "no-fill display must remain preformatted: {:?}",
1360 document.sections[0].blocks
1361 );
1362 };
1363 assert_eq!(inline_text(children), "first line\n\nsecond line");
1364 assert_eq!(
1365 children
1366 .iter()
1367 .filter(|inline| matches!(inline, Inline::LineBreak))
1368 .count(),
1369 2
1370 );
1371 }
1372
1373 #[test]
1374 fn preserves_lines_inside_font_blocks_nested_in_literal_displays() {
1375 let document = parse_manual_bytes(
1376 std::path::Path::new("literal-font-block.7"),
1377 b".Dd August 20, 2026\n\
1378.Dt LITERAL-FONT-BLOCK 7\n\
1379.Os\n\
1380.Sh EXAMPLE\n\
1381.Bd -literal\n\
1382.Bf Sy\n\
1383first line\n\
1384second line\n\
1385.Ef\n\
1386.Ed\n",
1387 )
1388 .expect("lower font block inside literal display");
1389
1390 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
1391 panic!(
1392 "literal display must remain one preformatted block: {:?}",
1393 document.sections[0].blocks
1394 );
1395 };
1396 assert_eq!(inline_text(children), "first line\nsecond line");
1397 assert_eq!(
1398 children
1399 .iter()
1400 .filter(|inline| matches!(inline, Inline::LineBreak))
1401 .count(),
1402 1
1403 );
1404 }
1405
1406 #[test]
1407 fn preserves_literal_display_lines_inside_literal_font_blocks() {
1408 let document = parse_manual_bytes(
1409 std::path::Path::new("literal-display-inside-font-block.7"),
1410 b".Dd August 21, 2026\n\
1411.Dt LITERAL-DISPLAY-INSIDE-FONT-BLOCK 7\n\
1412.Os\n\
1413.Sh EXAMPLE\n\
1414.Bf Li\n\
1415.Bd -literal\n\
1416first line\n\
1417second line\n\
1418.Ed\n\
1419.Ef\n",
1420 )
1421 .expect("lower literal display inside literal font block");
1422
1423 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
1424 panic!(
1425 "fonted literal display must remain preformatted: {:?}",
1426 document.sections[0].blocks
1427 );
1428 };
1429 assert_eq!(inline_text(children), "first line\nsecond line");
1430 assert_eq!(
1431 children
1432 .iter()
1433 .filter(|inline| matches!(inline, Inline::LineBreak))
1434 .count(),
1435 1
1436 );
1437 }
1438
1439 #[test]
1440 fn preserves_lines_inside_nested_literal_displays() {
1441 let document = parse_manual_bytes(
1442 std::path::Path::new("nested-literal-display.7"),
1443 b".Dd August 21, 2026\n\
1444.Dt NESTED-LITERAL-DISPLAY 7\n\
1445.Os\n\
1446.Sh EXAMPLE\n\
1447.Bd -literal\n\
1448first line\n\
1449.Bd -literal\n\
1450second line\n\
1451third line\n\
1452.Ed\n",
1453 )
1454 .expect("lower nested literal display");
1455
1456 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
1457 panic!(
1458 "nested literal displays must remain one preformatted block: {:?}",
1459 document.sections[0].blocks
1460 );
1461 };
1462 assert_eq!(inline_text(children), "first line\nsecond line\nthird line");
1463 assert_eq!(
1464 children
1465 .iter()
1466 .filter(|inline| matches!(inline, Inline::LineBreak))
1467 .count(),
1468 2
1469 );
1470 }
1471
1472 #[test]
1473 fn collapses_a_no_fill_blank_line_run_to_one_visual_separator() {
1474 let document = parse_manual_bytes(
1475 std::path::Path::new("no-fill-blank-run.7"),
1476 b".TH NO-FILL-BLANK-RUN 7\n\
1477.SH EXAMPLE\n\
1478.EX\n\
1479first line\n\
1480\n\
1481\n\
1482second line\n\
1483.EE\n",
1484 )
1485 .expect("lower no-fill blank run");
1486
1487 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
1488 panic!(
1489 "no-fill display must remain preformatted: {:?}",
1490 document.sections[0].blocks
1491 );
1492 };
1493 assert_eq!(inline_text(children), "first line\n\nsecond line");
1494 assert_eq!(
1495 children
1496 .iter()
1497 .filter(|inline| matches!(inline, Inline::LineBreak))
1498 .count(),
1499 2
1500 );
1501 }
1502
1503 #[test]
1504 fn adjacent_no_fill_regions_scale_without_changing_their_topology() {
1505 const REGION_COUNT: usize = 2_048;
1506 let mut source = String::from(".TH NO-FILL-SCALE 7\n.SH EXAMPLE\n");
1507 for index in 0..REGION_COUNT {
1508 writeln!(source, ".nf\nline {index}\n.fi").expect("append no-fill region");
1509 }
1510
1511 let document =
1512 parse_manual_bytes(std::path::Path::new("no-fill-scale.7"), source.as_bytes())
1513 .expect("lower adjacent no-fill regions");
1514
1515 let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
1516 panic!(
1517 "adjacent regions must remain one preformatted block: {:?}",
1518 document.sections[0].blocks
1519 );
1520 };
1521 assert_eq!(
1522 children
1523 .iter()
1524 .filter(|inline| matches!(inline, Inline::LineBreak))
1525 .count(),
1526 REGION_COUNT - 1
1527 );
1528 assert!(inline_text(children).starts_with("line 0\nline 1\n"));
1529 assert!(
1530 inline_text(children).ends_with(&format!("line {}", REGION_COUNT - 1)),
1531 "last no-fill region must remain visible"
1532 );
1533 }
1534
1535 #[test]
1536 fn distinguishes_filled_source_wrapping_from_indented_output_lines() {
1537 let path = temporary_source(
1538 "filled-line-boundaries",
1539 concat!(
1540 ".TH TOOL 1\n",
1541 ".SH SYNOPSIS\n",
1542 "tool [first]\n",
1543 " [second]\n",
1544 " [third]\n",
1545 ".PP\n",
1546 "Ordinary source wrapping\n",
1547 "remains one filled paragraph.\n",
1548 ),
1549 );
1550
1551 let document = parse_manual_source(&path).expect("lower filled line boundaries");
1552 fs::remove_file(path).expect("remove temporary roff fixture");
1553
1554 let [
1555 Block::Paragraph {
1556 children: synopsis, ..
1557 },
1558 Block::Paragraph {
1559 children: prose, ..
1560 },
1561 ] = document.sections[0].blocks.as_slice()
1562 else {
1563 panic!("expected synopsis and prose paragraphs");
1564 };
1565 assert_eq!(
1566 inline_text(synopsis),
1567 "tool [first]\n [second]\n [third]"
1568 );
1569 assert_eq!(
1570 synopsis
1571 .iter()
1572 .filter(|inline| matches!(inline, Inline::LineBreak))
1573 .count(),
1574 2
1575 );
1576 assert_eq!(
1577 inline_text(prose),
1578 "Ordinary source wrapping remains one filled paragraph."
1579 );
1580 }
1581
1582 #[test]
1583 fn honours_roff_no_space_line_continuations() {
1584 let document = parse_manual_bytes(
1585 std::path::Path::new("line-continuation.1"),
1586 b".TH LINE-CONTINUATION 1\n\
1587.SH DESCRIPTION\n\
1588extsize=\\c\n\
1589nnnn; multi-\\c\n\
1590block; (\\c\n\
1591.BR read (2)\n\
1592.EX\n\
1593literal-\\c\n\
1594continuation\n\
1595.EE\n",
1596 )
1597 .expect("lower no-space line continuations");
1598
1599 let [
1600 Block::Paragraph {
1601 children: prose, ..
1602 },
1603 Block::Preformatted {
1604 children: literal, ..
1605 },
1606 ] = document.sections[0].blocks.as_slice()
1607 else {
1608 panic!(
1609 "expected one filled and one no-fill block: {:?}",
1610 document.sections[0].blocks
1611 );
1612 };
1613 assert_eq!(inline_text(prose), "extsize=nnnn; multi-block; (read(2)");
1614 assert_eq!(inline_text(literal), "literal-continuation");
1615 }
1616
1617 #[test]
1618 fn keeps_explicit_horizontal_separation_at_a_tight_line_join() {
1619 let document = parse_manual_bytes(
1620 std::path::Path::new("motion-continuation.1"),
1621 b".TH MOTION-CONTINUATION 1\n\
1622.SH DESCRIPTION\n\
1623\\h'-04' 1.\\h'+01'\\c\n\
1624The next line.\n",
1625 )
1626 .expect("lower a horizontally spaced continued line");
1627
1628 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1629 panic!("expected one paragraph: {:?}", document.sections[0].blocks);
1630 };
1631 assert_eq!(inline_text(children), " 1. The next line.");
1632 }
1633
1634 #[test]
1635 fn lets_explicit_fonts_override_an_alternating_macro_default() {
1636 let path = temporary_source(
1637 "alternating-font-reset",
1638 ".TH MAN 1\n\
1639 .SH OPTIONS\n\
1640 .TP\n\
1641 .BI \\-r\\ prompt \\fR,\\ \\fB\\-\\-prompt= prompt\n\
1642 Set the pager prompt.\n",
1643 );
1644
1645 let document = parse_manual_source(&path).expect("lower alternating font reset");
1646 fs::remove_file(path).expect("remove temporary roff fixture");
1647
1648 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
1649 panic!("expected one definition list");
1650 };
1651 let term = items[0]
1652 .terms
1653 .first()
1654 .expect("first definition term")
1655 .iter()
1656 .filter(|inline| !matches!(inline, Inline::Anchor { .. }))
1657 .collect::<Vec<_>>();
1658
1659 assert_eq!(term.len(), 5);
1660 assert!(matches!(term[0], Inline::Strong { children } if inline_text(children) == "-r "));
1661 assert!(
1662 matches!(term[1], Inline::Emphasis { children } if inline_text(children) == "prompt")
1663 );
1664 assert!(matches!(term[2], Inline::Text { value } if value == ", "));
1665 assert!(
1666 matches!(term[3], Inline::Strong { children } if inline_text(children) == "--prompt=")
1667 );
1668 assert!(
1669 matches!(term[4], Inline::Emphasis { children } if inline_text(children) == "prompt")
1670 );
1671 }
1672
1673 #[test]
1674 fn suppresses_pod_font_requests_around_verbatim_blocks() {
1675 let path = temporary_source(
1676 "pod-verbatim-fonts",
1677 ".de Vb\n\
1678 .ft CW\n\
1679 .nf\n\
1680 ..\n\
1681 .de Ve\n\
1682 .ft R\n\
1683 .fi\n\
1684 ..\n\
1685 .TH POD 1\n\
1686 .SH EXAMPLES\n\
1687 .Vb 2\n\
1688 \\&struct A { int a; };\n\
1689 \\&struct B : A {};\n\
1690 .Ve\n",
1691 );
1692
1693 let document = parse_manual_source(&path).expect("lower Pod::Man verbatim source");
1694 fs::remove_file(path).expect("remove temporary roff fixture");
1695
1696 assert_eq!(document.sections[0].blocks.len(), 1);
1697 let Block::Preformatted { children, .. } = &document.sections[0].blocks[0] else {
1698 panic!("expected one preformatted block");
1699 };
1700 assert_eq!(
1701 inline_text(children),
1702 "struct A { int a; };\nstruct B : A {};"
1703 );
1704 }
1705
1706 #[test]
1707 fn lowers_indented_aliases_without_roff_layout_arguments() {
1708 let path = temporary_source(
1709 "indented-aliases",
1710 ".TH CONTROL 1\n\
1711 .SH OPTIONS\n\
1712 .PD 0\n\
1713 .IP \"\\fB-a\\fR\" 4\n\
1714 .IP \"\\fB--all\\fR\" 4\n\
1715 Show all entries.\n\
1716 .PD\n\
1717 .in 168u\n",
1718 );
1719
1720 let document = parse_manual_source(&path).expect("lower indented aliases");
1721 fs::remove_file(path).expect("remove temporary roff fixture");
1722
1723 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
1724 panic!("expected one definition list");
1725 };
1726 assert_eq!(items.len(), 1);
1727 assert_eq!(
1728 items[0]
1729 .terms
1730 .iter()
1731 .map(|term| inline_text(term))
1732 .collect::<Vec<_>>(),
1733 ["-a", "--all"]
1734 );
1735 assert_eq!(items[0].description.len(), 1);
1736 let Block::Paragraph { children, .. } = &items[0].description[0] else {
1737 panic!("expected alias description paragraph");
1738 };
1739 assert_eq!(inline_text(children), "Show all entries.");
1740 }
1741
1742 #[test]
1743 fn headless_ip_macros_continue_the_preceding_definition() {
1744 let path = temporary_source(
1745 "headless-ip-continuations",
1746 ".TH CONTINUATIONS 1\n\
1747 .SH DESCRIPTION\n\
1748 .IP foo\n\
1749 First paragraph.\n\
1750 .IP\n\
1751 Second paragraph.\n\
1752 .IP\n\
1753 Third paragraph.\n\
1754 .IP bar\n\
1755 Bar body.\n",
1756 );
1757
1758 let document = parse_manual_source(&path).expect("lower headless IP continuations");
1759 fs::remove_file(path).expect("remove temporary roff fixture");
1760 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
1761 panic!("expected one definition list");
1762 };
1763 assert_eq!(items.len(), 2);
1764 assert_eq!(inline_text(&items[0].terms[0]), "foo");
1765 assert_eq!(items[0].description.len(), 3);
1766 assert_eq!(
1767 items[0]
1768 .description
1769 .iter()
1770 .filter_map(|block| match block {
1771 Block::Paragraph { children, .. } => Some(inline_text(children)),
1772 _ => None,
1773 })
1774 .collect::<Vec<_>>(),
1775 ["First paragraph.", "Second paragraph.", "Third paragraph."]
1776 );
1777 assert_eq!(inline_text(&items[1].terms[0]), "bar");
1778 }
1779
1780 #[test]
1781 fn tq_terms_share_one_semantic_option_identity() {
1782 let path = temporary_source(
1783 "tq-aliases",
1784 ".TH TQ-ALIASES 7\n\
1785 .SH OPTIONS\n\
1786 .TP\n\
1787 .B \\-\\-alpha\n\
1788 .TQ\n\
1789 .B \\-a\n\
1790 .TQ\n\
1791 .B \\-\\-ALPHA\n\
1792 Enable alpha mode.\n",
1793 );
1794
1795 let document = parse_manual_source(&path).expect("lower TQ aliases");
1796 fs::remove_file(path).expect("remove temporary roff fixture");
1797 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
1798 panic!("expected one definition list");
1799 };
1800 assert_eq!(items.len(), 1);
1801 assert_eq!(
1802 items[0]
1803 .terms
1804 .iter()
1805 .map(|term| inline_text(term))
1806 .collect::<Vec<_>>(),
1807 ["--alpha", "-a", "--ALPHA"]
1808 );
1809 assert_eq!(
1810 items[0].identity.as_ref().expect("option identity").names,
1811 ["--alpha", "-a", "--ALPHA"]
1812 );
1813 }
1814
1815 #[test]
1816 fn ip_does_not_absorb_unproven_definition_heads() {
1817 let path = temporary_source(
1818 "bounded-ip-aliases",
1819 ".TH IP-BOUNDARY 7\n\
1820 .SH OPTIONS\n\
1821 .TP\n\
1822 -a\n\
1823 .TP\n\
1824 -b\n\
1825 .TP\n\
1826 -c\n\
1827 .IP -d\n\
1828 Description only for d.\n",
1829 );
1830
1831 let document = parse_manual_source(&path).expect("lower bounded IP definitions");
1832 fs::remove_file(path).expect("remove temporary roff fixture");
1833 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
1834 panic!("expected one definition list");
1835 };
1836 assert_eq!(items.len(), 4);
1837 assert_eq!(
1838 items
1839 .iter()
1840 .map(|item| inline_text(&item.terms[0]))
1841 .collect::<Vec<_>>(),
1842 ["-a", "-b", "-c", "-d"]
1843 );
1844 assert!(items[..3].iter().all(|item| item.description.is_empty()));
1845 assert!(!items[3].description.is_empty());
1846 assert!(document.diagnostics.iter().any(|diagnostic| {
1847 diagnostic.code.as_deref() == Some("manual.definition-alias-boundary")
1848 }));
1849 }
1850
1851 #[test]
1852 fn tq_continuation_starts_at_the_immediately_preceding_head() {
1853 let path = temporary_source(
1854 "bounded-tq-aliases",
1855 ".TH TQ-BOUNDARY 7\n\
1856 .SH OPTIONS\n\
1857 .TP\n\
1858 -a\n\
1859 .TP\n\
1860 -b\n\
1861 .TQ\n\
1862 --beta\n\
1863 Description only for beta.\n",
1864 );
1865
1866 let document = parse_manual_source(&path).expect("lower bounded TQ definitions");
1867 fs::remove_file(path).expect("remove temporary roff fixture");
1868 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
1869 panic!("expected one definition list");
1870 };
1871 assert_eq!(items.len(), 2);
1872 assert_eq!(inline_text(&items[0].terms[0]), "-a");
1873 assert!(items[0].description.is_empty());
1874 assert_eq!(
1875 items[1]
1876 .terms
1877 .iter()
1878 .map(|term| inline_text(term))
1879 .collect::<Vec<_>>(),
1880 ["-b", "--beta"]
1881 );
1882 assert!(document.diagnostics.iter().any(|diagnostic| {
1883 diagnostic.code.as_deref() == Some("manual.definition-alias-boundary")
1884 }));
1885 }
1886
1887 #[test]
1888 fn preserves_man_paragraph_distance_between_indented_paragraphs() {
1889 let path = temporary_source(
1890 "paragraph-distance",
1891 ".TH SPACING 1\n\
1892 .SH OPTIONS\n\
1893 .IP \"\\fB-a\\fR\" 4\n\
1894 First.\n\
1895 .IP \"\\fB-b\\fR\" 4\n\
1896 Second.\n\
1897 .PD 0\n\
1898 .IP \"\\fB-c\\fR\" 4\n\
1899 Third.\n\
1900 .IP \"\\fB-d\\fR\" 4\n\
1901 Fourth.\n\
1902 .PD\n\
1903 .IP \"\\fB-e\\fR\" 4\n\
1904 Fifth.\n",
1905 );
1906
1907 let document = parse_manual_source(&path).expect("lower paragraph distance");
1908 fs::remove_file(path).expect("remove temporary roff fixture");
1909
1910 let [Block::DefinitionList { items, compact, .. }] = document.sections[0].blocks.as_slice()
1911 else {
1912 panic!("expected one definition list");
1913 };
1914 assert!(!compact);
1915 assert_eq!(items.len(), 5);
1916 assert_eq!(
1917 items
1918 .iter()
1919 .map(|item| item.spacing_before_lines)
1920 .collect::<Vec<_>>(),
1921 [Some(0), Some(1), Some(0), Some(0), Some(1)]
1922 );
1923 }
1924
1925 #[test]
1926 fn preserves_man_paragraph_and_heading_distance_as_one_layout_model() {
1927 let path = temporary_source(
1928 "vertical-layout",
1929 ".TH SPACING 1\n\
1930 .SH FIRST\n\
1931 First paragraph.\n\
1932 .PP\n\
1933 Second paragraph.\n\
1934 .SS CHILD\n\
1935 Child body.\n\
1936 .PD 0\n\
1937 .SS COMPACT\n\
1938 Compact child.\n\
1939 .SH NEXT\n\
1940 Next body.\n\
1941 .PD\n\
1942 .SH FINAL\n\
1943 Final body.\n",
1944 );
1945
1946 let document = parse_manual_source(&path).expect("lower vertical layout");
1947 fs::remove_file(path).expect("remove temporary roff fixture");
1948
1949 let [first, next, final_section] = document.sections.as_slice() else {
1950 panic!("expected three top-level sections");
1951 };
1952 assert_eq!(first.spacing_before_lines, 0);
1953 let [Block::Paragraph { .. }, Block::Paragraph { layout, .. }] = first.blocks.as_slice()
1954 else {
1955 panic!("expected two semantic paragraphs");
1956 };
1957 assert_eq!(layout.spacing_before_lines, 1);
1958
1959 let [child, compact] = first.children.as_slice() else {
1960 panic!("expected two subsections");
1961 };
1962 assert_eq!(child.spacing_before_lines, 1);
1963 assert_eq!(compact.spacing_before_lines, 0);
1964 assert_eq!(next.spacing_before_lines, 0);
1965 assert_eq!(final_section.spacing_before_lines, 1);
1966 }
1967
1968 #[test]
1969 fn does_not_duplicate_explicit_space_before_a_transparent_indent() {
1970 let path = temporary_source(
1971 "explicit-space-before-indent",
1972 ".TH SPACING 1\n\
1973 .SH CONTENT\n\
1974 Before.\n\
1975 .sp\n\
1976 .RS 4\n\
1977 After.\n\
1978 .RE\n",
1979 );
1980
1981 let document = parse_manual_source(&path).expect("lower explicit indented spacing");
1982 fs::remove_file(path).expect("remove temporary roff fixture");
1983
1984 let [
1985 Block::Paragraph { .. },
1986 Block::VerticalSpace { lines: 1, .. },
1987 Block::Paragraph { layout, .. },
1988 ] = document.sections[0].blocks.as_slice()
1989 else {
1990 panic!("expected prose, one explicit gap, and indented prose");
1991 };
1992 assert_eq!(layout.indent_columns, 4);
1993 assert_eq!(
1994 layout.spacing_before_lines, 0,
1995 "the explicit gap must not be repeated as wrapper boundary spacing",
1996 );
1997 }
1998
1999 #[test]
2000 fn relative_indent_does_not_invent_paragraph_distance() {
2001 let path = temporary_source(
2002 "relative-indent-spacing",
2003 ".TH SPACING 7\n\
2004 .SH DESCRIPTION\n\
2005 .PP\n\
2006 first term\n\
2007 .RS 4\n\
2008 First description.\n\
2009 .RE\n\
2010 .PP\n\
2011 second term\n\
2012 .RS 4\n\
2013 Second description.\n\
2014 .RE\n",
2015 );
2016
2017 let document = parse_manual_source(&path).expect("lower relative-indent spacing");
2018 fs::remove_file(path).expect("remove temporary roff fixture");
2019
2020 let [
2021 Block::Paragraph {
2022 layout: first_term, ..
2023 },
2024 Block::Paragraph {
2025 layout: first_description,
2026 ..
2027 },
2028 Block::Paragraph {
2029 layout: second_term,
2030 ..
2031 },
2032 Block::Paragraph {
2033 layout: second_description,
2034 ..
2035 },
2036 ] = document.sections[0].blocks.as_slice()
2037 else {
2038 panic!("expected two terms followed by their indented descriptions");
2039 };
2040 assert_eq!(
2041 (first_term.indent_columns, first_term.spacing_before_lines),
2042 (0, 0)
2043 );
2044 assert_eq!(
2045 (
2046 first_description.indent_columns,
2047 first_description.spacing_before_lines,
2048 ),
2049 (4, 0),
2050 "RS changes indentation without adding paragraph distance",
2051 );
2052 assert_eq!(
2053 (second_term.indent_columns, second_term.spacing_before_lines),
2054 (0, 1),
2055 "the following PP still owns the distance between entries",
2056 );
2057 assert_eq!(
2058 (
2059 second_description.indent_columns,
2060 second_description.spacing_before_lines,
2061 ),
2062 (4, 0),
2063 );
2064 }
2065
2066 #[test]
2067 fn relative_indent_preserves_child_owned_paragraph_distance() {
2068 let path = temporary_source(
2069 "relative-indent-child-spacing",
2070 ".TH SPACING 7\n\
2071 .SH DESCRIPTION\n\
2072 Before.\n\
2073 .RS 4\n\
2074 .PP\n\
2075 Explicit nested paragraph.\n\
2076 .RE\n",
2077 );
2078
2079 let document = parse_manual_source(&path).expect("lower nested paragraph spacing");
2080 fs::remove_file(path).expect("remove temporary roff fixture");
2081
2082 let [Block::Paragraph { .. }, Block::Paragraph { layout, .. }] =
2083 document.sections[0].blocks.as_slice()
2084 else {
2085 panic!("expected outer prose and one explicitly separated nested paragraph");
2086 };
2087 assert_eq!(layout.indent_columns, 4);
2088 assert_eq!(
2089 layout.spacing_before_lines, 1,
2090 "PP inside RS must retain its own paragraph distance",
2091 );
2092 }
2093
2094 #[test]
2095 fn preserves_mdoc_paragraph_and_heading_distance() {
2096 let path = temporary_source(
2097 "mdoc-vertical-layout",
2098 ".Dd July 19, 2026\n\
2099 .Dt SPACING 1\n\
2100 .Os\n\
2101 .Sh FIRST\n\
2102 First paragraph.\n\
2103 .Pp\n\
2104 Second paragraph.\n\
2105 .Ss CHILD\n\
2106 Child body.\n",
2107 );
2108
2109 let document = parse_manual_source(&path).expect("lower mdoc vertical layout");
2110 fs::remove_file(path).expect("remove temporary roff fixture");
2111
2112 let [first] = document.sections.as_slice() else {
2113 panic!("expected one top-level section");
2114 };
2115 assert_eq!(first.spacing_before_lines, 1);
2116 assert!(matches!(
2117 first.blocks.get(1),
2118 Some(Block::VerticalSpace { lines: 1, .. })
2119 ));
2120 assert_eq!(first.children[0].spacing_before_lines, 1);
2121 }
2122
2123 #[test]
2124 fn lowers_mdoc_semantic_inline_nodes_and_nested_sections() {
2125 let path = temporary_source(
2126 "mdoc",
2127 ".Dd July 19, 2026\n\
2128 .Dt MANT 1\n\
2129 .Os\n\
2130 .Sh DESCRIPTION\n\
2131 Use\n\
2132 .Nm mant\n\
2133 with\n\
2134 .Xr man 1\n\
2135 Read\n\
2136 .Lk https://example.test/docs \"the documentation\"\n\
2137 or contact\n\
2138 .Mt docs@example.test\n\
2139 .Ss Details\n\
2140 .Fl h\n",
2141 );
2142
2143 let document = parse_manual_source(&path).expect("lower mdoc source");
2144 fs::remove_file(path).expect("remove temporary roff fixture");
2145
2146 assert_eq!(document.source.format, SourceFormat::Mdoc);
2147 assert_eq!(document.sections[0].children[0].title, "Details");
2148 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2149 panic!("expected description paragraph");
2150 };
2151 assert!(
2152 children
2153 .iter()
2154 .any(|inline| matches!(inline, Inline::Strong { .. }))
2155 );
2156 assert!(
2157 children.iter().any(
2158 |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::Manual { name, .. }, .. } if name == "man")
2159 )
2160 );
2161 assert!(children.iter().any(
2162 |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::External { uri }, .. } if uri == "https://example.test/docs")
2163 ));
2164 assert!(children.iter().any(
2165 |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::Email { address }, .. } if address == "docs@example.test")
2166 ));
2167 }
2168
2169 #[test]
2170 fn retains_unlabelled_mdoc_link_targets_before_trailing_punctuation() {
2171 let document = parse_manual_bytes(
2172 std::path::Path::new("external-link.9"),
2173 b".Dd August 19, 2026\n.Dt EXTERNAL-LINK 9\n.Os\n.Sh DESCRIPTION\n.Lk https://example.test/books .\n",
2174 )
2175 .expect("lower an unlabelled mdoc external link");
2176
2177 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
2178 panic!("expected one external-link paragraph");
2179 };
2180 assert_eq!(inline_text(children), "https://example.test/books.");
2181 assert!(matches!(
2182 children.as_slice(),
2183 [
2184 Inline::Link {
2185 target: mant_ir::LinkTarget::External { uri },
2186 children: link_children,
2187 ..
2188 },
2189 Inline::Text { value },
2190 ] if uri == "https://example.test/books"
2191 && inline_text(link_children) == "https://example.test/books"
2192 && value == "."
2193 ));
2194 }
2195
2196 #[test]
2197 fn expands_mdoc_bsd_lifecycle_and_release_forms() {
2198 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";
2199 let document = parse_manual_bytes(std::path::Path::new("bsd-lifecycle.7"), source)
2200 .expect("lower mdoc BSD lifecycle forms");
2201
2202 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
2203 panic!("expected one BSD lifecycle paragraph");
2204 };
2205 assert_eq!(
2206 inline_text(children),
2207 "BSD BSD (currently in alpha test) BSD (currently in beta test) BSD (currently under development). 4.3BSD. 4.3BSD Net/2. 386BSD 0.1."
2208 );
2209 }
2210
2211 #[test]
2212 fn preserves_complete_mdoc_include_directives() {
2213 let document = parse_manual_bytes(
2214 std::path::Path::new("include.3"),
2215 b".Dd August 19, 2026\n.Dt INCLUDE 3\n.Os\n.Sh SYNOPSIS\n.In fido.h\n",
2216 )
2217 .expect("lower mdoc include");
2218
2219 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
2220 panic!("expected one include paragraph");
2221 };
2222 assert_eq!(inline_text(children), "#include <fido.h>");
2223 assert!(matches!(
2224 children.as_slice(),
2225 [Inline::Code { value }] if value == "#include <fido.h>"
2226 ));
2227 }
2228
2229 #[test]
2230 fn propagates_nested_no_space_and_preserves_prefix_content() {
2231 let document = parse_manual_bytes(
2232 std::path::Path::new("no-space.7"),
2233 b".Dd August 19, 2026\n.Dt NO-SPACE 7\n.Os\n.Sh DESCRIPTION\n\
2234.Em Bell Labs Ns -derived\n\
2235.Ar job Ns s :\n\
2236.Sm off\n\
2237.Pf [\\-]ddd Cm \\&. No ddd\n\
2238.Sm on\n",
2239 )
2240 .expect("lower nested no-space macros");
2241
2242 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
2243 panic!("expected one no-space paragraph");
2244 };
2245 assert_eq!(inline_text(children), "Bell Labs-derived jobs: [-]ddd.ddd");
2246 }
2247
2248 #[test]
2249 fn lowers_documented_mdoc_delimiters_and_common_roff_characters() {
2250 let path = temporary_source(
2251 "mdoc-delimiters",
2252 ".Dd July 19, 2026\n\
2253 .Dt DELIMITERS 7\n\
2254 .Os\n\
2255 .Sh DESCRIPTION\n\
2256 .Op optional\n\
2257 .Bq bracket\n\
2258 .Dq double\n\
2259 .Sq single\n\
2260 .Pq parenthesized\n\
2261 .Brq braced\n\
2262 .Aq angled\n\
2263 .Oo multi Ar value\n\
2264 .Oc\n\
2265 .Sh CHARACTERS\n\
2266 \\(en \\(em \\(aq \\(dq \\(co \\(rg \\(tm \\(bu \\(ha \\(ti \\(rs\n",
2267 );
2268
2269 let document = parse_manual_source(&path).expect("lower delimiter and character source");
2270 fs::remove_file(path).expect("remove temporary roff fixture");
2271
2272 let description = document.sections[0]
2273 .blocks
2274 .iter()
2275 .map(|block| match block {
2276 Block::Paragraph { children, .. } => inline_text(children),
2277 _ => String::new(),
2278 })
2279 .collect::<Vec<_>>()
2280 .join(" ");
2281 for expected in [
2282 "[optional]",
2283 "[bracket]",
2284 "“double”",
2285 "‘single’",
2286 "(parenthesized)",
2287 "{braced}",
2288 "<angled>",
2289 "[multi value]",
2290 ] {
2291 assert!(
2292 description.contains(expected),
2293 "missing {expected:?} in {description:?}"
2294 );
2295 }
2296
2297 let [Block::Paragraph { children, .. }] = document.sections[1].blocks.as_slice() else {
2298 panic!("expected one special-character paragraph");
2299 };
2300 assert_eq!(inline_text(children), "– — ' \" © ® ™ • ^ ~ \\");
2301 }
2302
2303 #[test]
2304 fn retains_punctuation_after_implicit_mdoc_enclosures() {
2305 let document = parse_manual_bytes(
2306 std::path::Path::new("implicit-enclosure-punctuation.7"),
2307 b".Dd August 19, 2026\n.Dt IMPLICIT-ENCLOSURE-PUNCTUATION 7\n.Os\n\
2308.Sh DESCRIPTION\nWhen disabled\n.Pq all features remain readable ;\ncontinue safely.\n",
2309 )
2310 .expect("lower punctuation after an implicit enclosure");
2311
2312 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2313 panic!("expected one paragraph");
2314 };
2315 assert_eq!(
2316 inline_text(children),
2317 "When disabled (all features remain readable); continue safely."
2318 );
2319 }
2320
2321 #[test]
2322 fn lowers_the_pinned_named_character_catalog_without_silent_deletion() {
2323 let document = parse_manual_bytes(
2324 std::path::Path::new("named-characters.7"),
2325 b".TH NAMED-CHARACTERS 7\n\
2326.SH TEST\n\
2327at=\\(at ga=\\(ga oq=\\(oq arrow=\\(-> larrow=\\(<- mu=\\(mu\n\
2328de=\\(de pl=\\(pl dg=\\(dg ua=\\(ua da=\\(da lB=\\(lB rB=\\(rB\n\
2329unknown=\\[future-glyph]\n",
2330 )
2331 .expect("lower named characters");
2332
2333 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
2334 panic!("expected one character paragraph");
2335 };
2336 assert_eq!(
2337 inline_text(children),
2338 "at=@ ga=` oq=' arrow=→ larrow=← mu=× de=° pl=+ dg=† ua=↑ da=↓ lB=[ rB=] unknown=\\[future-glyph]"
2339 );
2340 }
2341
2342 #[test]
2343 fn round_trips_raw_and_bracketed_unicode_manual_text() {
2344 let source = ".TH UNICODE 7\n\
2345.SH TEST\n\
2346Raw UTF-8: Mašláňová café — naïve.\n\
2347Escaped: Ma\\[u0161]l\\[u00E1] and \\[u2014] dash.\n";
2348 let document = parse_manual_bytes(std::path::Path::new("unicode.7"), source.as_bytes())
2349 .expect("lower raw and escaped Unicode");
2350
2351 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
2352 panic!("expected one Unicode paragraph");
2353 };
2354 let rendered = inline_text(children);
2355 assert!(rendered.contains("Raw UTF-8: Mašláňová café — naïve."));
2356 assert!(rendered.contains("Escaped: Mašlá and — dash."));
2357 assert!(!rendered.contains(r"\[u"));
2358 }
2359
2360 #[test]
2361 fn preserves_explicit_mdoc_function_and_enclosure_structure() {
2362 let document = parse_manual_bytes(
2363 std::path::Path::new("explicit-mdoc.1"),
2364 b".Dd August 17, 2026\n\
2365.Dt EXPLICIT-MDOC 1\n\
2366.Os\n\
2367.Sh NAME\n\
2368.Nm explicit-mdoc\n\
2369.Nd exercise explicit blocks\n\
2370.Sh FUNCTION\n\
2371.Ft int\n\
2372.Fo audit_open\n\
2373.Fa const char *path\n\
2374.Fa int flags\n\
2375.Fc\n\
2376.Sh ENCLOSURES\n\
2377.Ao\nangle\n.Ac\n\
2378.Bo\nbracket\n.Bc\n\
2379.Do\ndouble\n.Dc\n\
2380.Po\nparenthesized\n.Pc\n\
2381.Qo\nquoted\n.Qc\n\
2382.So\nsingle\n.Sc\n\
2383.Bro\nbraced\n.Brc\n\
2384.Oo\noptional\n.Oc\n\
2385.Eo <<\ngeneric\n.Ec >>\n\
2386.Es [[ ]]\n\
2387.En custom\n",
2388 )
2389 .expect("lower explicit mdoc blocks");
2390
2391 let function = &document.sections[1];
2392 let [
2393 Block::Paragraph {
2394 children: return_type,
2395 ..
2396 },
2397 Block::Paragraph {
2398 children: declaration,
2399 ..
2400 },
2401 ] = function.blocks.as_slice()
2402 else {
2403 panic!("expected return type and function declaration paragraphs");
2404 };
2405 assert_eq!(inline_text(return_type), "int");
2406 assert_eq!(
2407 inline_text(declaration),
2408 "audit_open(const char *path, int flags)"
2409 );
2410 assert!(matches!(
2411 declaration.first(),
2412 Some(Inline::Strong { children }) if inline_text(children) == "audit_open"
2413 ));
2414
2415 let [Block::Paragraph { children, .. }] = document.sections[2].blocks.as_slice() else {
2416 panic!("expected one enclosure paragraph");
2417 };
2418 assert_eq!(
2419 inline_text(children),
2420 "<angle> [bracket] “double” (parenthesized) “quoted” ‘single’ {braced} \
2421 [optional] <<generic>> [[custom]]"
2422 );
2423 assert_eq!(document.diagnostics.len(), 2);
2424 assert!(
2425 document
2426 .diagnostics
2427 .iter()
2428 .all(|diagnostic| diagnostic.message.starts_with("obsolete macro:")),
2429 "{:?}",
2430 document.diagnostics
2431 );
2432 }
2433
2434 #[test]
2435 fn preserves_the_complete_libbsd_library_identity() {
2436 let document = parse_manual_bytes(
2437 std::path::Path::new("libbsd.3bsd"),
2438 b".Dd August 19, 2026\n.Dt LIBBSD 3bsd\n.Os\n.Sh LIBRARY\n.Lb libbsd\n",
2439 )
2440 .expect("lower libbsd library declaration");
2441 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
2442 panic!("expected one library paragraph");
2443 };
2444
2445 assert_eq!(
2446 inline_text(children),
2447 "Utility functions from BSD systems (libbsd, -lbsd)"
2448 );
2449 }
2450
2451 #[test]
2452 fn joins_the_final_mdoc_bibliography_authors() {
2453 let document = parse_manual_bytes(
2454 std::path::Path::new("bibliography.3"),
2455 b".Dd August 19, 2026\n.Dt BIBLIOGRAPHY 3\n.Os\n.Sh SEE ALSO\n\
2456.Rs\n.%A Bentley, J.L.\n.%A McIlroy, M.D.\n.%T Engineering a Sort Function\n.Re\n",
2457 )
2458 .expect("lower mdoc bibliography");
2459 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
2460 panic!("expected one bibliography paragraph");
2461 };
2462
2463 assert_eq!(
2464 inline_text(children),
2465 "Bentley, J.L. and McIlroy, M.D. Engineering a Sort Function."
2466 );
2467 }
2468
2469 #[test]
2470 fn preserves_mdoc_command_names_in_each_synopsis_form() {
2471 let document = parse_manual_bytes(
2472 std::path::Path::new("fido2-cred.1"),
2473 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",
2474 )
2475 .expect("lower mdoc synopsis names");
2476 let synopsis = &document.sections[1];
2477 let rendered = synopsis
2478 .blocks
2479 .iter()
2480 .map(|block| match block {
2481 Block::Paragraph { children, .. } => inline_text(children),
2482 block => panic!("expected synopsis paragraph, got {block:?}"),
2483 })
2484 .collect::<Vec<_>>();
2485
2486 assert_eq!(
2487 rendered,
2488 [
2489 "fido2-cred -M [-i input_file]",
2490 "fido2-cred -V",
2491 "helper [-q]",
2492 ]
2493 );
2494 }
2495
2496 #[test]
2497 fn preserves_mdoc_name_and_function_punctuation_by_context() {
2498 let document = parse_manual_bytes(
2499 std::path::Path::new("function-punctuation.3"),
2500 b".Dd August 19, 2026\n.Dt FUNCTION-PUNCTUATION 3\n.Os\n\
2501.Sh NAME\n.Nm function-punctuation\n.Nd test generated punctuation\n\
2502.Sh SYNOPSIS\n.Fn compact_call \"int value\"\n\
2503.Fo explicit_call\n.Fa \"int value\" \"const char *label\"\n.Fc\n\
2504.Sh DESCRIPTION\nThe\n.Fn prose_call \"int value\"\nfunction.\n",
2505 )
2506 .expect("lower mdoc generated punctuation");
2507
2508 let [Block::Paragraph { children: name, .. }] = document.sections[0].blocks.as_slice()
2509 else {
2510 panic!("expected one NAME paragraph");
2511 };
2512 assert_eq!(
2513 inline_text(name),
2514 "function-punctuation — test generated punctuation"
2515 );
2516
2517 let synopsis = document.sections[1]
2518 .blocks
2519 .iter()
2520 .map(|block| match block {
2521 Block::Paragraph { children, .. } => inline_text(children),
2522 block => panic!("expected synopsis paragraph, got {block:?}"),
2523 })
2524 .collect::<Vec<_>>();
2525 assert_eq!(
2526 synopsis,
2527 [
2528 "compact_call(int value);",
2529 "explicit_call(int value, const char *label);"
2530 ]
2531 );
2532
2533 let [
2534 Block::Paragraph {
2535 children: description,
2536 ..
2537 },
2538 ] = document.sections[2].blocks.as_slice()
2539 else {
2540 panic!("expected one DESCRIPTION paragraph");
2541 };
2542 assert_eq!(
2543 inline_text(description),
2544 "The prose_call(int value) function."
2545 );
2546 }
2547
2548 #[test]
2549 fn preserves_mdoc_synopsis_declaration_units() {
2550 let document = parse_manual_bytes(
2551 std::path::Path::new("synopsis-declarations.3"),
2552 b".Dd August 19, 2026\n.Dt SYNOPSIS-DECLARATIONS 3\n.Os\n\
2553.Sh SYNOPSIS\n.In synprobe.h\n.Ft const struct stat *\n\
2554.Fn synprobe_first \"struct thing *a\"\n.Ft void\n\
2555.Fo synprobe_second\n.Fa \"struct thing *a\"\n.Fa \"int n\"\n.Fc\n\
2556.Fn synprobe_third \"int n\"\n",
2557 )
2558 .expect("lower mdoc synopsis declarations");
2559
2560 let rendered = document.sections[0]
2561 .blocks
2562 .iter()
2563 .map(|block| match block {
2564 Block::Paragraph { children, .. } => inline_text(children),
2565 block => panic!("expected synopsis declaration paragraph, got {block:?}"),
2566 })
2567 .collect::<Vec<_>>();
2568
2569 assert_eq!(
2570 rendered,
2571 [
2572 "#include <synprobe.h>",
2573 "const struct stat * synprobe_first(struct thing *a);",
2574 "void synprobe_second(struct thing *a, int n);",
2575 "synprobe_third(int n);",
2576 ]
2577 );
2578 }
2579
2580 #[test]
2581 fn preserves_printable_roff_content_outside_formal_sections() {
2582 let document = parse_manual_bytes(
2583 std::path::Path::new("manweb.1"),
2584 b".TH MANWEB 1\n .SH NAME\nmanweb - browse generated documentation\n.SH SYNOPSIS\n.B manweb\n",
2585 )
2586 .expect("lower root prose");
2587 let [Block::Paragraph { children, .. }] = document.blocks.as_slice() else {
2588 panic!("expected one root paragraph, got {:?}", document.blocks);
2589 };
2590
2591 assert_eq!(
2592 inline_text(children),
2593 " .SH NAME manweb - browse generated documentation"
2594 );
2595 assert_eq!(document.sections[0].title, "SYNOPSIS");
2596 }
2597
2598 #[test]
2599 fn discards_temporary_indent_arguments_without_hiding_the_next_line() {
2600 let document = parse_manual_bytes(
2601 std::path::Path::new("temporary-indent.8"),
2602 b".TH TEMPORARY-INDENT 8\n.SH EXAMPLES\n.ti +8n\nexample% command\n.ti\nexample% other\n",
2603 )
2604 .expect("lower temporary indentation requests");
2605
2606 let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
2607 panic!("expected one examples paragraph");
2608 };
2609 assert_eq!(inline_text(children), "example% command example% other");
2610 }
2611
2612 #[test]
2613 fn diagnoses_future_structural_macros_before_discarding_visible_parts() {
2614 let mut report = Parser::default()
2615 .parse_bytes(
2616 "future-structure.1",
2617 b".Dd August 17, 2026\n.Dt FUTURE 1\n.Os\n.Sh SYNOPSIS\n\
2618.Fo future_call\n.Fa argument\n.Fc\n",
2619 )
2620 .expect("parse structural fixture");
2621 let block = find_macro_mut(&mut report.document.root, "Fo").expect("Fo block");
2622 block.macro_name = Some("FutureBlock".to_owned());
2623 let mut second_body = block
2624 .children
2625 .iter()
2626 .find(|child| child.kind == libmandoc_rs::NodeKind::Body)
2627 .cloned()
2628 .expect("function body");
2629 assert!(replace_first_text(&mut second_body, "second_argument"));
2630 block.children.push(second_body);
2631
2632 let document = lower_mandoc_document(std::path::Path::new("future-structure.1"), &report);
2633
2634 assert!(document.diagnostics.iter().any(|diagnostic| {
2635 diagnostic.code.as_deref() == Some("manual.unhandled-structural-parts")
2636 && diagnostic.message.contains("FutureBlock")
2637 }));
2638 let rendered = document.sections[0]
2639 .blocks
2640 .iter()
2641 .map(|block| match block {
2642 Block::Paragraph { children, .. } => inline_text(children),
2643 block => panic!("expected fallback paragraph, got {block:?}"),
2644 })
2645 .collect::<Vec<_>>();
2646 assert_eq!(rendered, ["argument", "second_argument"]);
2647 }
2648
2649 #[test]
2650 fn recognizes_explicitly_styled_traditional_man_references_in_any_section() {
2651 let path = temporary_source(
2652 "man-see-also",
2653 ".TH TOOL 1\n\
2654 .SH DESCRIPTION\n\
2655 The styled reference \\fBprintf\\fP(3) is usable here.\n\
2656 .SH SEE ALSO\n\
2657 .BR printf (3),\n\
2658 .BR man (1)\n",
2659 );
2660
2661 let document = parse_manual_source(&path).expect("lower man references");
2662 fs::remove_file(path).expect("remove temporary roff fixture");
2663
2664 let see_also = document
2665 .sections
2666 .iter()
2667 .find(|section| section.title == "SEE ALSO")
2668 .expect("SEE ALSO");
2669 let Block::Paragraph { children, .. } = &see_also.blocks[0] else {
2670 panic!("references are a paragraph");
2671 };
2672 assert!(children.iter().any(|inline| matches!(
2673 inline,
2674 Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
2675 if name == "printf" && manual_section == "3"
2676 )));
2677 assert!(children.iter().any(|inline| matches!(
2678 inline,
2679 Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
2680 if name == "man" && manual_section == "1"
2681 )));
2682
2683 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2684 panic!("description is a paragraph");
2685 };
2686 assert!(children.iter().any(|inline| matches!(
2687 inline,
2688 Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
2689 if name == "printf" && manual_section == "3"
2690 )));
2691 }
2692
2693 #[test]
2694 fn recognizes_legacy_sphinx_manual_links_in_roff_inputs() {
2695 let path = temporary_source(
2696 "sphinx-manual-links",
2697 ".TH BTRFS 8\n\
2698 .SH COMMANDS\n\
2699 See btrfs\\-subvolume(8) \\%<> and btrfs(5) \\%<> for details.\n\
2700 .EX\n\
2701 btrfs-subvolume(8) \\%<>\n\
2702 .EE\n",
2703 );
2704
2705 let document = parse_manual_source(&path).expect("lower legacy Sphinx references");
2706 fs::remove_file(path).expect("remove temporary roff fixture");
2707 let section = &document.sections[0];
2708 let paragraph = section
2709 .blocks
2710 .iter()
2711 .find_map(|block| match block {
2712 Block::Paragraph { children, .. } => Some(children),
2713 _ => None,
2714 })
2715 .expect("commands paragraph");
2716 assert_eq!(
2717 inline_text(paragraph),
2718 "See btrfs-subvolume(8) and btrfs(5) for details."
2719 );
2720 let references = paragraph
2721 .iter()
2722 .filter_map(|inline| match inline {
2723 Inline::Link {
2724 target:
2725 mant_ir::LinkTarget::Manual {
2726 name,
2727 manual_section: Some(manual_section),
2728 },
2729 ..
2730 } => Some((name.as_str(), manual_section.as_str())),
2731 _ => None,
2732 })
2733 .collect::<Vec<_>>();
2734 assert_eq!(references, [("btrfs-subvolume", "8"), ("btrfs", "5")]);
2735
2736 let literal = section
2737 .blocks
2738 .iter()
2739 .find_map(|block| match block {
2740 Block::Preformatted { children, .. } => Some(children),
2741 _ => None,
2742 })
2743 .expect("literal display");
2744 assert_eq!(inline_text(literal), "btrfs-subvolume(8) <>");
2745 assert!(!literal.iter().any(|inline| matches!(
2746 inline,
2747 Inline::Link {
2748 target: mant_ir::LinkTarget::Manual { .. },
2749 ..
2750 }
2751 )));
2752 }
2753
2754 #[test]
2755 fn lowers_modern_groff_manual_uri_and_mail_macros() {
2756 let path = temporary_source(
2757 "man-modern-links",
2758 ".TH TOOL 1\n\
2759 .SH DESCRIPTION\n\
2760 .MR git-add 1 ,\n\
2761 .PP\n\
2762 Read\n\
2763 .UR https://example.test/docs\n\
2764 Documentation\n\
2765 .UE\n\
2766 now.\n\
2767 .PP\n\
2768 Mail comments, suggestions and bug reports to\n\
2769 .MT docs@example.test\n\
2770 Sean\n\
2771 .ME .\n",
2772 );
2773
2774 let document = parse_manual_source(&path).expect("lower modern man links");
2775 fs::remove_file(path).expect("remove temporary roff fixture");
2776 let section = &document.sections[0];
2777 let mut manual = false;
2778 let mut web = false;
2779 let mut mail = false;
2780 for children in section.blocks.iter().filter_map(|block| match block {
2781 Block::Paragraph { children, .. } => Some(children),
2782 _ => None,
2783 }) {
2784 for inline in children {
2785 match inline {
2786 Inline::Link {
2787 target:
2788 mant_ir::LinkTarget::Manual {
2789 name,
2790 manual_section: Some(manual_section),
2791 },
2792 ..
2793 } if name == "git-add" && manual_section == "1" => manual = true,
2794 Inline::Link {
2795 target: mant_ir::LinkTarget::External { uri },
2796 ..
2797 } if uri == "https://example.test/docs" => {
2798 web = true;
2799 }
2800 Inline::Link {
2801 target: mant_ir::LinkTarget::Email { address },
2802 ..
2803 } if address == "docs@example.test" => {
2804 mail = true;
2805 }
2806 _ => {}
2807 }
2808 }
2809 }
2810
2811 assert!(manual && web && mail);
2812 assert!(section.blocks.iter().any(|block| match block {
2813 Block::Paragraph { children, .. } => inline_text(children).contains("git-add(1),"),
2814 _ => false,
2815 }));
2816 let linked_paragraphs = section
2817 .blocks
2818 .iter()
2819 .filter_map(|block| match block {
2820 Block::Paragraph { children, .. }
2821 if children.iter().any(|inline| {
2822 matches!(
2823 inline,
2824 Inline::Link {
2825 target: mant_ir::LinkTarget::External { .. },
2826 ..
2827 } | Inline::Link {
2828 target: mant_ir::LinkTarget::Email { .. },
2829 ..
2830 }
2831 )
2832 }) =>
2833 {
2834 Some(inline_text(children))
2835 }
2836 _ => None,
2837 })
2838 .collect::<Vec<_>>();
2839 assert_eq!(
2840 linked_paragraphs,
2841 [
2842 "Read Documentation ⟨https://example.test/docs⟩ now.",
2843 "Mail comments, suggestions and bug reports to Sean ⟨docs@example.test⟩."
2844 ]
2845 );
2846 }
2847
2848 #[test]
2849 fn searches_across_man_link_labels_and_visible_targets() {
2850 let source = b".TH LINK-SEARCH 1\n\
2851.SH REPORTING BUGS\n\
2852Mail comments, suggestions and bug reports to\n\
2853.MT docs@example.test\n\
2854Sean\n\
2855.ME .\n";
2856
2857 for pattern in ["bug reports to Sean", "docs@example.test"] {
2858 let query = crate::query_roff_bytes(source).expect("query link fixture");
2859 let result = crate::project_query_view(
2860 query,
2861 &mant_protocol::QueryView::Search {
2862 pattern: pattern.to_owned(),
2863 syntax: mant_protocol::SearchSyntax::Literal,
2864 case: mant_protocol::SearchCase::Sensitive,
2865 scope: mant_protocol::SearchScope::Visible,
2866 word: false,
2867 context_lines: 0,
2868 limit: 100,
2869 offset: 0,
2870 },
2871 )
2872 .expect("search link fixture");
2873 let crate::QueryViewResult::Search(search) = result else {
2874 panic!("expected search result");
2875 };
2876 assert_eq!(search.total, 1, "pattern={pattern:?}");
2877 }
2878 }
2879
2880 #[test]
2881 fn resolves_mdoc_section_references_and_explicit_targets() {
2882 let path = temporary_source(
2883 "mdoc-navigation",
2884 ".Dd July 19, 2026\n\
2885 .Dt NAVIGATION 1\n\
2886 .Os\n\
2887 .Sh DESCRIPTION\n\
2888 Continue with\n\
2889 .Sx DETAILS\n\
2890 .Tg explicit-option\n\
2891 .Fl x\n\
2892 .Sh DETAILS\n\
2893 Target content.\n",
2894 );
2895
2896 let document = parse_manual_source(&path).expect("lower navigation mdoc source");
2897 fs::remove_file(path).expect("remove temporary roff fixture");
2898
2899 assert_eq!(document.sections[0].id, "description");
2900 assert_eq!(document.sections[1].id, "details");
2901 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2902 panic!("expected navigation paragraph");
2903 };
2904 assert!(children.iter().any(|inline| matches!(
2905 inline,
2906 Inline::Link {
2907 target: mant_ir::LinkTarget::Section { id },
2908 children,
2909 ..
2910 } if id == "details" && inline_text(children) == "DETAILS"
2911 )));
2912 assert!(children.iter().any(|inline| matches!(
2913 inline,
2914 Inline::Anchor { id } if id == "explicit-option"
2915 )));
2916 }
2917
2918 #[test]
2919 fn explicit_targets_reserve_the_native_section_namespace() {
2920 let path = temporary_source(
2921 "mdoc-target-section-collision",
2922 ".Dd August 30, 2026\n\
2923 .Dt TARGET-COLLISION 1\n\
2924 .Os\n\
2925 .Sh FOO\n\
2926 .Tg bar\n\
2927 First.\n\
2928 .Sh BAR\n\
2929 Second.\n",
2930 );
2931
2932 let document = parse_manual_source(&path).expect("lower reserved explicit target");
2933 fs::remove_file(path).expect("remove temporary roff fixture");
2934
2935 assert_eq!(document.sections[0].id, "foo");
2936 assert_eq!(document.sections[1].id, "bar-2");
2937 assert!(document.sections[0].blocks.iter().any(|block| matches!(
2938 block,
2939 Block::Paragraph { children, .. }
2940 if children.iter().any(|inline| matches!(
2941 inline,
2942 Inline::Anchor { id } if id == "bar"
2943 ))
2944 )));
2945 assert!(document.diagnostics.iter().all(|diagnostic| {
2946 diagnostic.code.as_deref() != Some("ir.identity-role-collision")
2947 }));
2948 }
2949
2950 #[test]
2951 fn resolves_a_unique_parenthetically_qualified_mdoc_section_reference() {
2952 let path = temporary_source(
2953 "mdoc-qualified-navigation",
2954 ".Dd July 19, 2026\n\
2955 .Dt NAVIGATION 1\n\
2956 .Os\n\
2957 .Sh DESCRIPTION\n\
2958 See\n\
2959 .Sx White Space Splitting\n\
2960 .Sh \"White Space Splitting (Field Splitting)\"\n\
2961 Target content.\n",
2962 );
2963
2964 let document = parse_manual_source(&path).expect("lower qualified navigation source");
2965 fs::remove_file(path).expect("remove temporary roff fixture");
2966
2967 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2968 panic!("expected navigation paragraph");
2969 };
2970 assert!(children.iter().any(|inline| matches!(
2971 inline,
2972 Inline::Link {
2973 target: mant_ir::LinkTarget::Section { id },
2974 children,
2975 ..
2976 } if id == "white-space-splitting-field-splitting"
2977 && inline_text(children) == "White Space Splitting"
2978 )));
2979 assert!(document.diagnostics.iter().all(|diagnostic| {
2980 diagnostic.code.as_deref() != Some("unresolved-section-reference")
2981 }));
2982 }
2983
2984 #[test]
2985 fn degrades_unresolved_mdoc_section_references_to_text() {
2986 let path = temporary_source(
2987 "mdoc-missing-section",
2988 ".Dd July 19, 2026\n.Dt NAVIGATION 1\n.Os\n.Sh DESCRIPTION\n.Sx MISSING\n",
2989 );
2990
2991 let document = parse_manual_source(&path).expect("lower unresolved navigation source");
2992 fs::remove_file(path).expect("remove temporary roff fixture");
2993
2994 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2995 panic!("expected reference paragraph");
2996 };
2997 assert_eq!(inline_text(children), "MISSING");
2998 assert!(children.iter().all(|inline| !matches!(
2999 inline,
3000 Inline::Link {
3001 target: mant_ir::LinkTarget::Section { .. },
3002 ..
3003 }
3004 )));
3005 assert!(document.diagnostics.iter().any(|diagnostic| {
3006 diagnostic.code.as_deref() == Some("unresolved-section-reference")
3007 }));
3008 }
3009
3010 #[test]
3011 fn turns_captured_parser_findings_into_structured_diagnostics() {
3012 let path = temporary_source(
3013 "unsupported",
3014 ".Dd July 19, 2026\n.Dt BAD 1\n.Os\n.Sh NAME\n.Nm bad\n.ab\n",
3015 );
3016
3017 let document = parse_manual_source(&path).expect("best-effort parse");
3018 fs::remove_file(path).expect("remove temporary roff fixture");
3019
3020 assert!(
3021 document
3022 .diagnostics
3023 .iter()
3024 .any(|diagnostic| diagnostic.level == DiagnosticLevel::Unsupported)
3025 );
3026 }
3027
3028 #[test]
3029 fn masks_terminal_controls_before_native_parsing() {
3030 let path = temporary_source("controls", ".TH SAFE 1\n.SH NAME\nsafe \x1b[2J text\n");
3031
3032 let document = parse_manual_source(&path).expect("parse sanitized manual");
3033 fs::remove_file(path).expect("remove temporary roff fixture");
3034
3035 assert!(
3036 document.diagnostics.iter().any(|diagnostic| {
3037 diagnostic.code.as_deref() == Some("manual.control-characters")
3038 })
3039 );
3040 }
3041
3042 #[test]
3043 fn lowers_normalized_ordered_lists_and_literal_displays() {
3044 let path = temporary_source(
3045 "normalized",
3046 ".Dd July 19, 2026\n.Dt NORMALIZED 1\n.Os\n.Sh CONTENT\n\
3047 .Bl -enum -compact\n.It\nfirst\n.It\nsecond\n.El\n\
3048 .Bd -literal -offset 6n\nline one\nline two\n.Ed\n",
3049 );
3050
3051 let document = parse_manual_source(&path).expect("lower normalized mdoc");
3052 fs::remove_file(path).expect("remove temporary roff fixture");
3053
3054 assert!(matches!(
3055 document.sections[0].blocks[0],
3056 Block::List {
3057 kind: mant_ir::ListKind::Ordered,
3058 compact: true,
3059 ..
3060 }
3061 ));
3062 assert!(matches!(
3063 document.sections[0].blocks[1],
3064 Block::Preformatted { layout, .. } if layout.indent_columns == 6
3065 ));
3066 }
3067
3068 #[test]
3069 fn lowers_normalized_mdoc_font_and_author_layout() {
3070 let path = temporary_source(
3071 "normalized-mdoc-modes",
3072 ".Dd July 19, 2026\n\
3073 .Dt NORMALIZED-MODES 1\n\
3074 .Os\n\
3075 .Sh AUTHORS\n\
3076 .An -split\n\
3077 .An Alice Example\n\
3078 .An Bob Example\n\
3079 .An -nosplit\n\
3080 .An Carol Example\n\
3081 .An Dave Example\n\
3082 .Sh DESCRIPTION\n\
3083 .Bf -literal\n\
3084 literal text\n\
3085 .Ef\n",
3086 );
3087
3088 let document = parse_manual_source(&path).expect("lower normalized mdoc modes");
3089 fs::remove_file(path).expect("remove temporary roff fixture");
3090
3091 let authors = &document.sections[0];
3092 let Block::Paragraph { children, .. } = &authors.blocks[0] else {
3093 panic!("authors are one paragraph");
3094 };
3095 assert_eq!(
3096 inline_text(children),
3097 "Alice Example\nBob Example Carol Example Dave Example"
3098 );
3099
3100 let description = &document.sections[1];
3101 let Block::Paragraph { children, .. } = &description.blocks[0] else {
3102 panic!("font block is a paragraph");
3103 };
3104 assert!(matches!(
3105 children.as_slice(),
3106 [Inline::Code { value }] if value == "literal text"
3107 ));
3108 }
3109
3110 #[test]
3111 fn mdoc_definition_layout_uses_the_normalized_list_width() {
3112 let path = temporary_source(
3113 "mdoc-definition-widths",
3114 ".Dd July 23, 2026\n.Dt WIDTHS 1\n.Os\n.Sh ITEMS\n\
3115 .Bl -tag -width 20n\n.It tenletters\nwide description\n.El\n\
3116 .Bl -tag -width 3n\n.It short\nnarrow description\n.El\n",
3117 );
3118
3119 let document = parse_manual_source(&path).expect("lower mdoc definition widths");
3120 fs::remove_file(path).expect("remove temporary roff fixture");
3121
3122 let lists = document.sections[0]
3123 .blocks
3124 .iter()
3125 .filter_map(|block| match block {
3126 Block::DefinitionList { items, .. } => Some(items),
3127 _ => None,
3128 })
3129 .collect::<Vec<_>>();
3130 assert_eq!(lists.len(), 2);
3131 assert!(lists[0][0].inline_term);
3132 assert!(!lists[1][0].inline_term);
3133 }
3134
3135 #[test]
3136 fn lowers_the_pinned_large_mdoc_fixture_without_empty_sections() {
3137 let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3138 .join("../libmandoc-rs/vendor/mandoc-1.14.6/mandoc.1");
3139 if !source.exists() {
3140 return;
3143 }
3144
3145 let document = parse_manual_source(&source).expect("lower vendored mandoc manual");
3146
3147 assert!(document.sections.len() > 5);
3148 assert!(
3149 document
3150 .sections
3151 .iter()
3152 .any(|section| section.title == "DESCRIPTION")
3153 );
3154 assert!(
3155 document
3156 .sections
3157 .iter()
3158 .all(|section| !section.blocks.is_empty() || !section.children.is_empty())
3159 );
3160 }
3161
3162 #[test]
3163 fn lowers_tbl_and_eqn_payloads_into_structured_blocks() {
3164 let path = temporary_source(
3165 "table-equation",
3166 ".TH PAYLOAD 1\n.SH TABLE\n.TS\ntab(|);\nl r.\nleft|right\n.TE\n\
3167 .SH EQUATION\n.EQ\nx + {width over 2}\n.EN\n",
3168 );
3169
3170 let document = parse_manual_source(&path).expect("lower table and equation");
3171 fs::remove_file(path).expect("remove temporary roff fixture");
3172
3173 assert!(matches!(
3174 document.sections[0].blocks[0],
3175 Block::Table { ref rows, .. } if rows.len() == 1 && rows[0].cells.len() == 2
3176 ));
3177 assert!(matches!(
3178 document.sections[1].blocks[0],
3179 Block::Equation { ref value, .. } if value == "x + width / 2"
3180 ));
3181 }
3182
3183 #[test]
3184 fn large_tbl_rows_scale_without_changing_their_topology() {
3185 const ROW_COUNT: usize = 2_048;
3186 let mut source = String::from(".TH TABLE-SCALE 7\n.SH TABLE\n.TS\nl l.\n");
3187 for index in 0..ROW_COUNT {
3188 writeln!(source, "left {index}\tright {index}").expect("append table row");
3189 }
3190 source.push_str(".TE\n");
3191
3192 let document = parse_manual_bytes(std::path::Path::new("table-scale.7"), source.as_bytes())
3193 .expect("lower large table");
3194
3195 let [Block::Table { rows, .. }] = document.sections[0].blocks.as_slice() else {
3196 panic!("large tbl input must remain one table");
3197 };
3198 assert_eq!(rows.len(), ROW_COUNT);
3199 assert!(matches!(
3200 rows.first().and_then(|row| row.cells.first()),
3201 Some(mant_ir::TableCell { blocks, .. })
3202 if matches!(blocks.as_slice(), [Block::Paragraph { children, .. }]
3203 if inline_text(children) == "left 0")
3204 ));
3205 assert!(matches!(
3206 rows.last().and_then(|row| row.cells.get(1)),
3207 Some(mant_ir::TableCell { blocks, .. })
3208 if matches!(blocks.as_slice(), [Block::Paragraph { children, .. }]
3209 if inline_text(children) == format!("right {}", ROW_COUNT - 1))
3210 ));
3211 }
3212
3213 #[test]
3214 fn keeps_inline_equations_in_macro_arguments_and_filled_prose() {
3215 let document = parse_manual_bytes(
3216 std::path::Path::new("inline-equation.7"),
3217 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",
3218 )
3219 .expect("lower inline equations");
3220
3221 let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
3222 panic!(
3223 "expected one definition list: {:?}",
3224 document.sections[0].blocks
3225 );
3226 };
3227 let [item] = items.as_slice() else {
3228 panic!("expected one equation definition");
3229 };
3230 assert_eq!(inline_text(&item.terms[0]), "Dp dx _ 1 ... dx _ n");
3231 let [Block::Paragraph { children, .. }] = item.description.as_slice() else {
3232 panic!("expected one filled description: {:?}", item.description);
3233 };
3234 assert_eq!(
3235 inline_text(children),
3236 "Draw a polygon with, for i = 1 , ... , n + 1, its vertex."
3237 );
3238 assert!(children.iter().any(
3239 |child| matches!(child, Inline::Code { value } if value == "i = 1 , ... , n + 1")
3240 ));
3241 }
3242
3243 #[test]
3244 fn normalizes_inline_equations_retained_as_tbl_cell_text() {
3245 let document = parse_manual_bytes(
3246 std::path::Path::new("table-inline-equation.3"),
3247 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",
3248 )
3249 .expect("lower table equations");
3250
3251 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
3252 panic!("expected equation table");
3253 };
3254 let [left, right] = rows[0].cells.as_slice() else {
3255 panic!("expected two cells");
3256 };
3257 let [Block::Paragraph { children: left, .. }] = left.blocks.as_slice() else {
3258 panic!("expected left paragraph");
3259 };
3260 let [
3261 Block::Paragraph {
3262 children: right, ..
3263 },
3264 ] = right.blocks.as_slice()
3265 else {
3266 panic!("expected right paragraph");
3267 };
3268 assert!(matches!(left.as_slice(), [Inline::Code { value }] if value == "0"));
3269 assert_eq!(inline_text(right), "for values in [ 0 , π / 2 ]");
3270 assert!(
3271 right
3272 .iter()
3273 .any(|child| matches!(child, Inline::Code { .. }))
3274 );
3275 }
3276
3277 #[test]
3278 fn bounds_distinct_tbl_equation_normalization_work() {
3279 let mut source =
3280 String::from(".TH TABLE-EQN-BUDGET 3\n.SH DESCRIPTION\n.EQ\ndelim %%\n.EN\n.TS\nl.\n");
3281 for index in 0..=MAX_INLINE_EQUATION_NORMALIZATIONS {
3282 writeln!(source, "%x{index}%").expect("write fixture row");
3283 }
3284 source.push_str(".TE\n");
3285
3286 let document = parse_manual_bytes(
3287 std::path::Path::new("table-inline-equation-budget.3"),
3288 source.as_bytes(),
3289 )
3290 .expect("lower a bounded number of table equations");
3291
3292 assert!(document.diagnostics.iter().any(|diagnostic| {
3293 diagnostic.code.as_deref() == Some("manual.inline-equation-budget")
3294 }));
3295 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
3296 panic!("expected equation table");
3297 };
3298 assert_eq!(rows.len(), MAX_INLINE_EQUATION_NORMALIZATIONS + 1);
3299 }
3300
3301 #[test]
3302 fn preserves_tbl_rows_across_interleaved_comments_and_text_blocks() {
3303 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";
3304 let document = parse_manual_bytes(std::path::Path::new("commented-table.1"), source)
3305 .expect("lower commented table");
3306
3307 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
3308 panic!("expected a table");
3309 };
3310 assert_eq!(rows.len(), 5);
3311 let first_cells = rows
3312 .iter()
3313 .map(|row| match row.cells[0].blocks.as_slice() {
3314 [Block::Paragraph { children, .. }] => inline_text(children),
3315 cells => panic!("expected one paragraph per table cell: {cells:?}"),
3316 })
3317 .collect::<Vec<_>>();
3318 assert_eq!(first_cells, ["a", "b", "c", "d(1)", "e"]);
3319 }
3320
3321 #[test]
3322 fn keeps_multiline_cells_aligned_after_an_empty_text_block() {
3323 let source = b".TH EMPTY-TABLE-CELL 7\n.SH TABLE\n.TS\ntab(@);\nl l l.\n\
3324T{\nT}@T{\nCore\nT}@T{\nProduction-grade, first-class\nT}\n.TE\n";
3325 let document = parse_manual_bytes(std::path::Path::new("empty-table-cell.7"), source)
3326 .expect("lower a row beginning with an empty text block");
3327
3328 let [Block::Table { rows, .. }] = document.sections[0].blocks.as_slice() else {
3329 panic!("expected one table");
3330 };
3331 let [row] = rows.as_slice() else {
3332 panic!("expected one table row");
3333 };
3334 let values = row
3335 .cells
3336 .iter()
3337 .map(|cell| match cell.blocks.as_slice() {
3338 [Block::Paragraph { children, .. }] => inline_text(children),
3339 [] => String::new(),
3340 blocks => panic!("unexpected table cell blocks: {blocks:?}"),
3341 })
3342 .collect::<Vec<_>>();
3343 assert_eq!(values, ["", "Core", "Production-grade, first-class"]);
3344 }
3345
3346 #[test]
3347 fn keeps_tbl_vertical_span_markers_out_of_visible_cells() {
3348 let document = parse_manual_bytes(
3349 std::path::Path::new("vertical-table-span.1"),
3350 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",
3351 )
3352 .expect("lower vertical table span");
3353
3354 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
3355 panic!("expected a table");
3356 };
3357 assert_eq!(rows.len(), 4);
3358 assert_eq!(rows[1].cells[0].row_span, 3);
3359 assert!(rows[2].cells[0].blocks.is_empty());
3360 assert!(rows[3].cells[0].blocks.is_empty());
3361 }
3362
3363 #[test]
3364 fn preserves_tbl_rows_nested_in_unfilled_mdoc_displays() {
3365 let document = parse_manual_bytes(
3366 std::path::Path::new("unfilled-table.7"),
3367 b".Dd August 19, 2026\n.Dt UNFILLED-TABLE 7\n.Os\n.Sh DESCRIPTION\n\
3368.Bd -unfilled -offset indent\n.TS\ntab(@);\nl l.\nleft@right\nnext@value\n.TE\n.Ed\n",
3369 )
3370 .expect("lower table nested in an unfilled display");
3371
3372 let table = document.sections[0]
3373 .blocks
3374 .iter()
3375 .find_map(|block| match block {
3376 Block::Table { rows, .. } => Some(rows),
3377 _ => None,
3378 })
3379 .expect("nested table must remain structured");
3380 assert_eq!(table.len(), 2);
3381 assert_eq!(table[0].cells.len(), 2);
3382 assert!(
3383 document.sections[0]
3384 .blocks
3385 .iter()
3386 .all(|block| !matches!(block, Block::Preformatted { children, .. } if children.is_empty())),
3387 "the surrounding display must not leave an empty placeholder"
3388 );
3389 }
3390
3391 #[test]
3392 fn keeps_unexpanded_tabular_cells_visible_with_a_diagnostic() {
3393 let document = parse_manual_bytes(
3394 std::path::Path::new("unexpanded-table-cell.7"),
3395 b".TH UNEXPANDED-TABLE-CELL 7\n.SH DESCRIPTION\n.TS\nl l.\n1\t\\*[unknown-label]\n.TE\n",
3396 )
3397 .expect("lower unresolved formatter string in a table cell");
3398
3399 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
3400 panic!("expected a structured table");
3401 };
3402 assert_eq!(rows[0].cells.len(), 2);
3403 let [Block::Paragraph { children, .. }] = rows[0].cells[1].blocks.as_slice() else {
3404 panic!("expected one recovered table-cell paragraph");
3405 };
3406 assert_eq!(inline_text(children), r"\*[unknown-label]");
3407 assert!(document.diagnostics.iter().any(|diagnostic| {
3408 diagnostic.level == DiagnosticLevel::Unsupported
3409 && diagnostic.code.as_deref() == Some("manual.unexpanded-table-cell")
3410 }));
3411 }
3412
3413 #[test]
3414 fn restores_mdoc_names_inside_tbl_text_blocks() {
3415 let document = parse_manual_bytes(
3416 std::path::Path::new("table-text-block.3"),
3417 b".Dd August 19, 2026\n.Dt TABLE-TEXT-BLOCK 3\n.Os\n\
3418.Sh NAME\n.Nm table-text-block\n.Nd test tbl text blocks\n\
3419.Sh ATTRIBUTES\n.TS\nallbox;\nl l.\nInterface\tValue\n\
3420T{\n.Nm\nT}\tMT-Safe\n.TE\n",
3421 )
3422 .expect("lower tbl text blocks");
3423
3424 let Block::Table { rows, .. } = &document.sections[1].blocks[0] else {
3425 panic!("expected attributes table");
3426 };
3427 let [Block::Paragraph { children, .. }] = rows[1].cells[0].blocks.as_slice() else {
3428 panic!("expected recovered name cell");
3429 };
3430 assert_eq!(inline_text(children), "table-text-block");
3431 assert!(matches!(children.as_slice(), [Inline::Strong { .. }]));
3432 }
3433
3434 #[test]
3435 fn keeps_semantic_links_inside_tbl_text_blocks() {
3436 let document = parse_manual_bytes(
3437 std::path::Path::new("table-text-link.1"),
3438 b".TH TABLE-TEXT-LINK 1\n\
3439.nr do-fallback 0\n\
3440.if !\\n(.f .nr do-fallback 1\n\
3441.if \\n[do-fallback] \\{\\\n\
3442. de MR\n\
3443. ie \\\\n(.$=1 \\\n\
3444. I \\%\\\\$1\n\
3445. el \\\n\
3446. IR \\%\\\\$1 (\\\\$2)\\\\$3\n\
3447. .\n\
3448.\\}\n\
3449.rr do-fallback\n\
3450.SH DESCRIPTION\n\
3451.TS\ntab($);\nl l.\ngrn$T{\nrenders\n.MR gremlin 1\ndiagrams;\nT}\n\
3452gperl$T{\npopulates\n.I groff\nregisters using\n.MR perl 1 ;\nT}\n.TE\n",
3453 )
3454 .expect("lower semantic tbl text block");
3455
3456 let [Block::Table { rows, .. }] = document.sections[0].blocks.as_slice() else {
3457 panic!("semantic table content must not escape into a separate paragraph");
3458 };
3459 let [Block::Paragraph { children, .. }] = rows[0].cells[1].blocks.as_slice() else {
3460 panic!("expected semantic table cell paragraph");
3461 };
3462 assert_eq!(inline_text(children), "renders gremlin(1) diagrams;");
3463 assert!(children.iter().any(|child| matches!(
3464 child,
3465 Inline::Link {
3466 target: mant_ir::LinkTarget::Manual { name, manual_section },
3467 ..
3468 } if name == "gremlin" && manual_section.as_deref() == Some("1")
3469 )));
3470 let [Block::Paragraph { children, .. }] = rows[1].cells[1].blocks.as_slice() else {
3471 panic!("expected styled semantic table cell paragraph");
3472 };
3473 assert_eq!(
3474 inline_text(children),
3475 "populates groff registers using perl(1);"
3476 );
3477 assert!(
3478 children
3479 .iter()
3480 .any(|child| matches!(child, Inline::Emphasis { .. }))
3481 );
3482 }
3483
3484 #[test]
3485 fn restores_alternating_font_arguments_inside_tbl_text_blocks() {
3486 let document = parse_manual_bytes(
3487 std::path::Path::new("table-text-alternation.7"),
3488 b".TH TABLE-TEXT-ALTERNATION 7\n.SH DESCRIPTION\n.TS\nl l.\nT{\n\
3489.BI \\[aq] s1 \\[aq] s2 \\[aq]\nT}\tT{\n\
3490.I s1\nproduces the same formatted output as\n.IR s2 .\nT}\n.TE\n",
3491 )
3492 .expect("lower alternating man macros inside a tbl text block");
3493
3494 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
3495 panic!("expected a structured table");
3496 };
3497 let [left, right] = rows[0].cells.as_slice() else {
3498 panic!("expected both reconstructed table cells");
3499 };
3500 let [Block::Paragraph { children: left, .. }] = left.blocks.as_slice() else {
3501 panic!("expected a reconstructed left table-cell paragraph");
3502 };
3503 let [
3504 Block::Paragraph {
3505 children: right, ..
3506 },
3507 ] = right.blocks.as_slice()
3508 else {
3509 panic!("expected a reconstructed right table-cell paragraph");
3510 };
3511 assert_eq!(inline_text(left), "'s1's2'");
3512 assert_eq!(
3513 inline_text(right),
3514 "s1 produces the same formatted output as s2."
3515 );
3516 assert!(
3517 right
3518 .iter()
3519 .any(|inline| matches!(inline, Inline::Emphasis { .. }))
3520 );
3521 }
3522
3523 #[test]
3524 fn restores_nested_mdoc_requests_inside_tbl_text_blocks() {
3525 let document = parse_manual_bytes(
3526 std::path::Path::new("table-mdoc-requests.8"),
3527 b".Dd August 19, 2026\n.Dt TABLE-MDOC-REQUESTS 8\n.Os\n.Sh DESCRIPTION\n\
3528.TS\ntab(@);\nl l.\nT{\n.Cm sip Ar addr Ns Op / Ns Ar mask\nT}@T{\n\
3529bitwise and of the address with\n.Ar mask\nequals\n.Ar addr .\n.Ar addr\n\
3530can be an IPv4 or IPv6 address.\nT}\n.TE\n",
3531 )
3532 .expect("lower nested mdoc requests in table text blocks");
3533
3534 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
3535 panic!("expected a structured table");
3536 };
3537 let [left, right] = rows[0].cells.as_slice() else {
3538 panic!("expected two reconstructed table cells");
3539 };
3540 let [Block::Paragraph { children: left, .. }] = left.blocks.as_slice() else {
3541 panic!("expected reconstructed selector cell");
3542 };
3543 let [
3544 Block::Paragraph {
3545 children: right, ..
3546 },
3547 ] = right.blocks.as_slice()
3548 else {
3549 panic!("expected reconstructed description cell");
3550 };
3551 assert_eq!(inline_text(left), "sip addr[/mask]");
3552 assert_eq!(
3553 inline_text(right),
3554 "bitwise and of the address with mask equals addr. addr can be an IPv4 or IPv6 address."
3555 );
3556 assert!(
3557 left.iter()
3558 .any(|inline| matches!(inline, Inline::Strong { .. }))
3559 );
3560 assert!(
3561 right
3562 .iter()
3563 .any(|inline| matches!(inline, Inline::Emphasis { .. }))
3564 );
3565 }
3566
3567 #[test]
3568 fn keeps_command_names_in_extended_mdoc_synopsis_terms() {
3569 let document = parse_manual_bytes(
3570 std::path::Path::new("extended-synopsis.8"),
3571 b".Dd August 19, 2026\n.Dt EXTENDED-SYNOPSIS 8\n.Os\n.Sh NAME\n\
3572.Nm zinject\n.Nd inject faults\n.Sh SYNOPSIS\n.Bl -tag -width Ds\n\
3573.It Xo\n.Nm zinject\n.Xc\nList injections.\n\
3574.It Xo\n.Nm zinject\n.Fl b Ar bookmark\n.Xc\nInject a bookmark.\n.El\n",
3575 )
3576 .expect("lower extended mdoc synopsis terms");
3577
3578 let Block::DefinitionList { items, .. } = &document.sections[1].blocks[0] else {
3579 panic!("expected synopsis definition list");
3580 };
3581 assert_eq!(inline_text(&items[0].terms[0]), "zinject");
3582 assert_eq!(inline_text(&items[1].terms[0]), "zinject -b bookmark");
3583 assert!(matches!(
3584 items[0].terms[0].as_slice(),
3585 [Inline::Anchor { id }, Inline::Strong { .. }]
3586 if items[0].identity.as_ref().is_some_and(|identity| &identity.id == id)
3587 ));
3588 assert!(
3589 items[1].terms[0]
3590 .iter()
3591 .any(|inline| matches!(inline, Inline::Strong { .. }))
3592 );
3593 }
3594
3595 #[test]
3596 fn decodes_named_characters_inside_equations() {
3597 let document = parse_manual_bytes(
3598 std::path::Path::new("equation-characters.1"),
3599 b".TH EQUATION-CHARACTERS 1\n.SH EQUATION\n.EQ\n\\[*p] \\[mi] x\n.EN\n",
3600 )
3601 .expect("lower equation characters");
3602
3603 assert!(matches!(
3604 document.sections[0].blocks[0],
3605 Block::Equation { ref value, .. } if value == "\u{03c0} \u{2212} x"
3606 ));
3607 }
3608
3609 #[test]
3610 fn lowers_every_mdoc_column_list_cell() {
3611 let document = parse_manual_bytes(
3612 std::path::Path::new("columns.3"),
3613 b".Dd August 19, 2026\n.Dt COLUMNS 3\n.Os\n.Sh DESCRIPTION\n\
3614.Bl -column name type description\n.It Dv CLSET_TIMEOUT Ta \"struct timeval *\" Ta \"set total timeout\"\n.El\n",
3615 )
3616 .expect("lower mdoc column list");
3617
3618 let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
3619 panic!("expected column list to lower as a table");
3620 };
3621 assert_eq!(rows.len(), 1);
3622 assert_eq!(rows[0].cells.len(), 3);
3623 let rendered = rows[0]
3624 .cells
3625 .iter()
3626 .map(|cell| match cell.blocks.as_slice() {
3627 [Block::Paragraph { children, .. }] => inline_text(children),
3628 blocks => panic!("expected one paragraph per cell, got {blocks:?}"),
3629 })
3630 .collect::<Vec<_>>();
3631 assert_eq!(
3632 rendered,
3633 ["CLSET_TIMEOUT", "struct timeval *", "set total timeout"]
3634 );
3635 }
3636
3637 #[test]
3638 fn preserves_nested_mdoc_spacing_state_in_definition_terms() {
3639 let document = parse_manual_bytes(
3640 std::path::Path::new("nested-spacing.1"),
3641 b".Dd August 19, 2026\n.Dt NESTED-SPACING 1\n.Os\n.Sh OPTIONS\n\
3642.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",
3643 )
3644 .expect("lower nested mdoc spacing controls");
3645
3646 let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
3647 panic!("expected an option definition list");
3648 };
3649 assert_eq!(
3650 inline_text(&items[0].terms[0]),
3651 "-L local_socket:host:hostport"
3652 );
3653 }
3654
3655 #[test]
3656 fn groups_mdoc_option_forms_that_share_one_description() {
3657 let document = parse_manual_bytes(
3658 std::path::Path::new("shared-option-forms.1"),
3659 b".Dd August 27, 2026\n.Dt SHARED-OPTION-FORMS 1\n.Os\n.Sh OPTIONS\n\
3660.Bl -tag -width Ds\n\
3661.It Fl L Xo\n.Sm off\n.Oo Ar bind_address : Oc\n.Ar port : host : hostport\n.Sm on\n.Xc\n\
3662.It Fl L Xo\n.Sm off\n.Oo Ar bind_address : Oc\n.Ar port : remote_socket\n.Sm on\n.Xc\n\
3663.It Fl L Xo\n.Sm off\n.Ar local_socket : host : hostport\n.Sm on\n.Xc\n\
3664.It Fl L Xo\n.Sm off\n.Ar local_socket : remote_socket\n.Sm on\n.Xc\n\
3665Forward a local socket.\n.El\n",
3666 )
3667 .expect("lower option forms with a shared description");
3668
3669 let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
3670 panic!("expected an option definition list");
3671 };
3672 assert_eq!(items.len(), 1);
3673 assert_eq!(
3674 items[0]
3675 .terms
3676 .iter()
3677 .map(|term| inline_text(term))
3678 .collect::<Vec<_>>(),
3679 [
3680 "-L [bind_address:]port:host:hostport",
3681 "-L [bind_address:]port:remote_socket",
3682 "-L local_socket:host:hostport",
3683 "-L local_socket:remote_socket",
3684 ]
3685 );
3686 assert_eq!(items[0].identity.as_ref().unwrap().names, ["-L"]);
3687 assert!(document.sections[0].blocks.iter().any(|block| {
3688 matches!(block, Block::DefinitionList { items, .. }
3689 if items[0].description.iter().any(|description| {
3690 matches!(description, Block::Paragraph { children, .. }
3691 if inline_text(children).contains("Forward a local socket"))
3692 }))
3693 }));
3694 }
3695
3696 #[test]
3697 fn groups_distinct_mdoc_options_that_share_one_description() {
3698 let document = parse_manual_bytes(
3699 std::path::Path::new("shared-option-description.1"),
3700 b".Dd August 29, 2026\n.Dt SHARED-OPTION-DESCRIPTION 1\n.Os\n.Sh OPTIONS\n\
3701.Bl -tag -width Ds\n\
3702.It Fl I Ar encoding\n\
3703.It Fl O Ar encoding\n\
3704Convert filenames from the specified encoding.\n\
3705.El\n",
3706 )
3707 .expect("lower distinct options with a shared description");
3708
3709 let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
3710 panic!("expected an option definition list");
3711 };
3712 assert_eq!(items.len(), 1);
3713 assert_eq!(
3714 items[0]
3715 .terms
3716 .iter()
3717 .map(|term| inline_text(term))
3718 .collect::<Vec<_>>(),
3719 ["-I encoding", "-O encoding"]
3720 );
3721 assert_eq!(items[0].identity.as_ref().unwrap().names, ["-I", "-O"]);
3722 assert!(items[0].description.iter().any(|description| {
3723 matches!(description, Block::Paragraph { children, .. }
3724 if inline_text(children) == "Convert filenames from the specified encoding.")
3725 }));
3726 }
3727
3728 #[test]
3729 fn preserves_a_single_mdoc_option_argument_and_its_description() {
3730 let document = parse_manual_bytes(
3731 std::path::Path::new("option-with-argument.1"),
3732 b".Dd August 29, 2026\n.Dt OPTION-WITH-ARGUMENT 1\n.Os\n.Sh OPTIONS\n\
3733.Bl -tag -width Ds\n\
3734.It Fl Z Ar mode\n\
3735Select the archive mode without losing this description.\n\
3736.El\n",
3737 )
3738 .expect("lower an mdoc option with one argument");
3739
3740 let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
3741 panic!("expected an option definition list");
3742 };
3743 assert_eq!(items.len(), 1);
3744 assert_eq!(inline_text(&items[0].terms[0]), "-Z mode");
3745 assert_eq!(items[0].identity.as_ref().unwrap().names, ["-Z"]);
3746 assert!(items[0].description.iter().any(|description| {
3747 matches!(description, Block::Paragraph { children, .. }
3748 if inline_text(children)
3749 == "Select the archive mode without losing this description.")
3750 }));
3751 }
3752
3753 #[test]
3754 fn carries_mdoc_spacing_state_into_display_lines() {
3755 let document = parse_manual_bytes(
3756 std::path::Path::new("display-spacing.8"),
3757 b".Dd August 24, 2026\n.Dt DISPLAY-SPACING 8\n.Os\n.Sh FORMAT\n\
3758.Sm off\n.D1 Ar name : uid : gid\n.Sm on\n",
3759 )
3760 .expect("lower display-scoped mdoc spacing controls");
3761
3762 let Block::Preformatted { children, .. } = &document.sections[0].blocks[0] else {
3763 panic!("expected one display line");
3764 };
3765 assert_eq!(inline_text(children), "name:uid:gid");
3766 }
3767
3768 #[test]
3769 fn carries_mdoc_spacing_state_across_list_item_boundaries() {
3770 let document = parse_manual_bytes(
3771 std::path::Path::new("list-spacing.8"),
3772 b".Dd August 19, 2026\n.Dt LIST-SPACING 8\n.Os\n.Sh COMMANDS\n\
3773.Bl -tag -width Ds\n.Sm off\n.It Ic O Ar device\n.Sm on\n.It Ic done\nFinished.\n.El\n",
3774 )
3775 .expect("lower list-scoped mdoc spacing controls");
3776
3777 let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
3778 panic!("expected a command definition list");
3779 };
3780 assert_eq!(inline_text(&items[0].terms[0]), "Odevice");
3781 assert_eq!(inline_text(&items[1].terms[0]), "done");
3782 }
3783
3784 #[test]
3785 fn carries_mdoc_spacing_state_out_of_nested_synopsis_enclosures() {
3786 let document = parse_manual_bytes(
3787 std::path::Path::new("nested-synopsis-spacing.8"),
3788 b".Dd August 19, 2026\n.Dt NESTED-SYNOPSIS-SPACING 8\n.Os\n.Sh SYNOPSIS\n\
3789.Nm demo\n.Sm off\n.Oo Fl m\\~\n.Ar memory\n.Sm on\n.Oc\n\
3790.Op Fl o Ar variable Ns Cm = Ns Ar value\n.Ar name\n",
3791 )
3792 .expect("lower nested synopsis spacing transitions");
3793
3794 let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
3795 panic!("expected synopsis paragraph");
3796 };
3797 assert_eq!(
3798 inline_text(children),
3799 "demo [-m memory] [-o variable=value] name"
3800 );
3801 }
3802
3803 #[test]
3804 fn preserves_the_boundary_that_enters_a_compact_mdoc_term() {
3805 let document = parse_manual_bytes(
3806 std::path::Path::new("spacing-transition.5"),
3807 b".Dd August 19, 2026\n.Dt SPACING-TRANSITION 5\n.Os\n.Sh KEYWORDS\n\
3808.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",
3809 )
3810 .expect("lower an mdoc spacing transition inside a term");
3811
3812 let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
3813 panic!("expected a keyword definition list");
3814 };
3815 assert_eq!(inline_text(&items[0].terms[0]), "@newuser name:uid:gid");
3816 }
3817
3818 #[test]
3819 fn separates_alternative_terms_in_an_extended_mdoc_definition_head() {
3820 let document = parse_manual_bytes(
3821 std::path::Path::new("extended-term-alternatives.8"),
3822 b".Dd August 19, 2026\n.Dt EXTENDED-TERM-ALTERNATIVES 8\n.Os\n.Sh OPTIONS\n\
3823.Bl -tag -width Ds\n.It Xo\n.Sm off\n.Ar ipaddr\n.Op / Ar masklen\n.Pp\n\
3824.Ar ipaddr\n.Op / Ar prefixlen\n.Sm on\n.Xc\nAccept this peer.\n.El\n",
3825 )
3826 .expect("lower alternative extended definition terms");
3827
3828 let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
3829 panic!("expected a definition list");
3830 };
3831 assert_eq!(items.len(), 1);
3832 assert_eq!(items[0].terms.len(), 2);
3833 assert_eq!(inline_text(&items[0].terms[0]), "ipaddr[/masklen]");
3834 assert_eq!(inline_text(&items[0].terms[1]), "ipaddr[/prefixlen]");
3835 }
3836
3837 fn inline_text(children: &[Inline]) -> String {
3838 children
3839 .iter()
3840 .map(|child| match child {
3841 Inline::Text { value } | Inline::Code { value } => value.clone(),
3842 Inline::Strong { children }
3843 | Inline::Emphasis { children }
3844 | Inline::Link { children, .. } => inline_text(children),
3845 Inline::Anchor { .. } => String::new(),
3846 Inline::LineBreak => "\n".to_owned(),
3847 })
3848 .collect()
3849 }
3850}