mod blocks;
mod diagnostics;
mod error;
pub(crate) mod inline;
mod layout;
mod navigation;
mod reference;
mod roff_escape;
mod source;
use std::{cell::RefCell, path::Path};
use libmandoc_rs::{
Compression, Document as MandocDocument, IncludePolicy, MacroSet, Node, ParseOptions,
ParseReport, Parser,
};
use mant_ir::{
Diagnostic, DiagnosticLevel, Document, DocumentMeta, DocumentSource, ParserInfo, SourceFormat,
SourceSpan, validate_document,
};
use self::{
roff_escape::visible_text,
source::{load_manual_source, redirect_target, resolve_manual_redirects},
};
use crate::ManualPage;
use crate::text_safety::mask_terminal_control_bytes;
pub use error::{ManualError, ManualErrorKind};
pub use source::MAX_MANUAL_BYTES;
pub fn parse_manual_source(path: &Path) -> Result<Document, ManualError> {
let loaded = load_manual_source(path)?;
reject_standalone_redirect(path, &loaded.source)?;
parse_plain_manual(path, &loaded.source, None)
}
pub fn parse_manual_bytes(path: &Path, source: &[u8]) -> Result<Document, ManualError> {
reject_standalone_redirect(path, source)?;
parse_plain_manual(path, source, None)
}
fn reject_standalone_redirect(path: &Path, source: &[u8]) -> Result<(), ManualError> {
if redirect_target(path, source)?.is_some() {
return Err(ManualError::redirect(
path,
"standalone .so redirects require MANPATH discovery and cannot be followed by --input",
));
}
Ok(())
}
pub fn parse_manual_page(page: &ManualPage) -> Result<Document, ManualError> {
let resolved = resolve_manual_redirects(page)?;
parse_plain_manual(
&page.path,
&resolved.source,
resolved.alias_target.as_deref(),
)
}
fn parse_plain_manual(
path: &Path,
source: &[u8],
alias_target: Option<&str>,
) -> Result<Document, ManualError> {
let (source, masked_controls) = mask_terminal_control_bytes(source);
let report = Parser::new(ParseOptions {
includes: IncludePolicy::Deny,
compression: Compression::Plain,
})
.parse_bytes(path, source.as_ref())
.map_err(ManualError::from)?;
let mut document = lower_mandoc_document(path, &report);
if masked_controls > 0 {
document.diagnostics.insert(
0,
Diagnostic {
level: DiagnosticLevel::Warning,
code: Some("manual.control-characters".to_owned()),
message: format!("masked {masked_controls} terminal-unsafe control character(s)"),
source: None,
},
);
}
if let Some(alias_target) = alias_target {
document.meta.alias_target = Some(alias_target.to_owned());
}
Ok(document)
}
#[must_use]
pub fn lower_mandoc_document(path: &Path, report: &ParseReport) -> Document {
let parsed: &MandocDocument = &report.document;
let mut context = LoweringContext::new(parsed.metadata.name.as_deref());
let mut diagnostics = diagnostics::lower_diagnostics(&report.diagnostics);
let mut sections = blocks::lower_sections(&parsed.root, &mut context);
diagnostics.extend(context.take_diagnostics());
let explicit_targets = navigation::explicit_targets(&parsed.root);
let mut retained_targets = explicit_targets.clone();
let mut root_blocks = Vec::new();
retained_targets.extend(crate::definitions::identify_definitions(
&mut root_blocks,
&mut sections,
&explicit_targets,
));
navigation::resolve_navigation(&mut sections, &retained_targets, &mut diagnostics);
let mut document = Document {
parser: Some(ParserInfo {
name: "libmandoc".to_owned(),
version: libmandoc_rs::LIBMANDOC_VERSION.to_owned(),
}),
source: DocumentSource {
format: match parsed.macro_set {
MacroSet::Mdoc => SourceFormat::Mdoc,
MacroSet::Man | MacroSet::None => SourceFormat::Man,
},
path: Some(path.to_string_lossy().into_owned()),
},
meta: DocumentMeta {
title: normalize_metadata(parsed.metadata.title.as_deref()),
manual_section: normalize_metadata(parsed.metadata.section.as_deref()),
date: normalize_metadata(parsed.metadata.date.as_deref()),
volume: normalize_metadata(parsed.metadata.volume.as_deref()),
os: normalize_metadata(parsed.metadata.os.as_deref()),
arch: normalize_metadata(parsed.metadata.arch.as_deref()),
names: normalize_metadata(parsed.metadata.name.as_deref())
.into_iter()
.collect(),
alias_target: parsed.metadata.alias_target.clone(),
},
diagnostics,
blocks: root_blocks,
sections,
};
document.diagnostics.extend(validate_document(&document));
document
}
fn normalize_metadata(value: Option<&str>) -> Option<String> {
value.map(visible_text)
}
struct LoweringContext<'a> {
default_name: Option<&'a str>,
next_section_id: usize,
diagnostics: RefCell<Vec<Diagnostic>>,
}
impl<'a> LoweringContext<'a> {
const fn new(default_name: Option<&'a str>) -> Self {
Self {
default_name,
next_section_id: 1,
diagnostics: RefCell::new(Vec::new()),
}
}
fn section_id(&mut self, title: &str) -> String {
let sequence = self.next_section_id;
self.next_section_id += 1;
let slug: String = title
.chars()
.flat_map(char::to_lowercase)
.map(|character| {
if character.is_alphanumeric() {
character
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("-");
if slug.is_empty() {
format!("section-{sequence}")
} else {
format!("{slug}-{sequence}")
}
}
fn warn_unhandled_structural_parts(&self, node: &Node) {
let macro_name = node.macro_name.as_deref().unwrap_or("unknown");
self.diagnostics.borrow_mut().push(Diagnostic {
level: DiagnosticLevel::Warning,
code: Some("manual.unhandled-structural-parts".to_owned()),
message: format!(
"structural macro '{macro_name}' contains visible head or tail content without a lowering policy"
),
source: source_span(node),
});
}
fn take_diagnostics(&self) -> Vec<Diagnostic> {
self.diagnostics.take()
}
}
fn source_span(node: &Node) -> Option<SourceSpan> {
(node.line > 0).then_some(SourceSpan {
byte_range: None,
line: node.line,
column: node.column.max(1),
end_line: None,
end_column: None,
})
}
fn part_children(node: &Node, kind: libmandoc_rs::NodeKind) -> &[Node] {
node.children
.iter()
.find(|child| child.kind == kind)
.map_or(&[], |child| child.children.as_slice())
}
#[cfg(test)]
mod tests {
use std::{fs, process};
use mant_ir::{Block, DiagnosticLevel, Inline, SourceFormat};
use super::{Parser, lower_mandoc_document, parse_manual_bytes, parse_manual_source};
fn temporary_source(label: &str, source: &str) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!("mant-lower-{label}-{}.1", process::id()));
fs::write(&path, source).expect("write temporary roff fixture");
path
}
fn find_macro_mut<'a>(
node: &'a mut libmandoc_rs::Node,
name: &str,
) -> Option<&'a mut libmandoc_rs::Node> {
if node.macro_name.as_deref() == Some(name) {
return Some(node);
}
node.children
.iter_mut()
.find_map(|child| find_macro_mut(child, name))
}
#[test]
fn standalone_inputs_reject_redirect_only_so_pages() {
let error = parse_manual_bytes(std::path::Path::new("stdin"), b".so man1/target.1\n")
.expect_err("standalone input must not follow another file");
assert!(error.to_string().contains("require MANPATH discovery"));
}
#[test]
fn lowers_man_sections_fonts_definitions_and_literal_blocks() {
let path = temporary_source(
"man",
".TH MANT 1 \"July 2026\"\n\
.SH NAME\n\
mant \\- a viewer\n\
.SH OPTIONS\n\
.TP\n\
\\fB\\-h\\fR\n\
Show help.\n\
.nf\n\
mant --help\n\
mant git\n\
.fi\n",
);
let document = parse_manual_source(&path).expect("lower man source");
fs::remove_file(path).expect("remove temporary roff fixture");
assert_eq!(document.source.format, SourceFormat::Man);
assert_eq!(
document
.sections
.iter()
.map(|section| section.title.as_str())
.collect::<Vec<_>>(),
vec!["NAME", "OPTIONS"]
);
assert!(
document.sections[1]
.blocks
.iter()
.any(|block| matches!(block, Block::DefinitionList { .. }))
);
assert!(document.sections[1].blocks.iter().any(|block| matches!(
block,
Block::DefinitionList { items, .. }
if items.iter().any(|item| item.description.iter().any(
|description| matches!(description, Block::Preformatted { .. })
))
)));
}
#[test]
fn separates_definition_layout_arguments_from_visible_terms() {
let path = temporary_source(
"definition-head-roles",
".TH HEAD-ROLES 1\n\
.SH EXAMPLES\n\
.TP \\w'man\\ 'u\n\
.BI man \\ ls\n\
Display ls.\n\
.TP 4\n\
4\n\
A numeric term remains visible.\n\
.IP \"1\" 8n\n\
An IP width remains layout-only.\n",
);
let document = parse_manual_source(&path).expect("lower definition head roles");
fs::remove_file(path).expect("remove temporary roff fixture");
let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
panic!("expected one definition list");
};
assert_eq!(
items
.iter()
.flat_map(|item| item.terms.iter())
.map(|term| inline_text(term))
.collect::<Vec<_>>(),
["man ls", "4", "1"]
);
assert!(matches!(
items[0].terms[0].as_slice(),
[Inline::Strong { .. }, Inline::Emphasis { .. }]
));
assert!(
items
.iter()
.flat_map(|item| item.terms.iter())
.all(|term| !inline_text(term).contains("96u"))
);
}
#[test]
fn preserves_man_synopsis_flow_and_alternating_fonts() {
let path = temporary_source(
"man-synopsis-flow",
".TH MAN 1\n\
.SH SYNOPSIS\n\
.B man\n\
.RI [\\| \"man options\" \\|]\n\
.RI [\\|[\\| section \\|]\n\
.IR page \\ \\|.\\|.\\|.\\|]\\ \\.\\|.\\|.\\&\n\
.br\n\
.B man\n\
.B \\-k\n\
.RI [\\| \"apropos options\" \\|]\n\
.I regexp\n\
\\&.\\|.\\|.\\&\n\
.br\n\
.B man\n\
.BR \\-w \\||\\| \\-W\n\
.RI [\\| \"man options\" \\|]\n\
.I page\n\
\\&.\\|.\\|.\\&\n",
);
let document = parse_manual_source(&path).expect("lower man synopsis");
fs::remove_file(path).expect("remove temporary roff fixture");
let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
panic!("expected one synopsis paragraph");
};
assert_eq!(
inline_text(children),
"man [man options] [[section] page ...] ...\n\
man -k [apropos options] regexp ...\n\
man -w|-W [man options] page ..."
);
assert_eq!(
children
.iter()
.filter(|node| matches!(node, Inline::LineBreak))
.count(),
2
);
assert!(children.iter().any(
|node| matches!(node, Inline::Emphasis { children } if inline_text(children) == "man options")
));
assert!(children.iter().any(
|node| matches!(node, Inline::Strong { children } if inline_text(children) == "-w")
));
assert!(children.iter().any(
|node| matches!(node, Inline::Strong { children } if inline_text(children) == "-W")
));
}
#[test]
fn preserves_man_sy_heads_with_body_content_and_inline_fonts() {
let document = parse_manual_bytes(
std::path::Path::new("sy-heads.1"),
b".TH SY-HEADS 1 \"August 17, 2026\"\n\
.SH SYNOPSIS\n\
.SY getent\n\
.RI [ option ]\n\
.I database\n\
.YS\n\
.SH DESCRIPTION\n\
.SY #!\\f[I]interpreter\\f[]\n\
.RI [ optional-arg ]\n\
.YS\n",
)
.expect("lower SY heads");
let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
panic!("expected one synopsis paragraph");
};
assert_eq!(inline_text(children), "getent [option] database");
assert!(matches!(
children.first(),
Some(Inline::Strong { children }) if inline_text(children) == "getent"
));
let [Block::Paragraph { children, .. }] = document.sections[1].blocks.as_slice() else {
panic!("expected one description paragraph");
};
assert_eq!(inline_text(children), "#!interpreter [optional-arg]");
assert!(matches!(
children.first(),
Some(Inline::Strong { children })
if children.iter().any(|inline| matches!(
inline,
Inline::Emphasis { children } if inline_text(children) == "interpreter"
))
));
assert!(
document.diagnostics.is_empty(),
"{:?}",
document.diagnostics
);
}
#[test]
fn keeps_man_synopsis_lines_together_inside_no_fill_examples() {
let document = parse_manual_bytes(
std::path::Path::new("no-fill-synopsis.2"),
b".TH NO-FILL-SYNOPSIS 2\n\
.SH DESCRIPTION\n\
.EX\n\
.SY #!\\f[I]interpreter\\f[]\n\
.RI [ optional-arg ]\n\
.YS\n\
.EE\n",
)
.expect("lower synopsis inside example");
let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
panic!(
"no-fill synopsis must remain one preformatted block: {:?}",
document.sections[0].blocks
);
};
assert_eq!(inline_text(children), "#!interpreter\n[optional-arg]");
assert_eq!(
children
.iter()
.filter(|inline| matches!(inline, Inline::LineBreak))
.count(),
1
);
}
#[test]
fn distinguishes_filled_source_wrapping_from_indented_output_lines() {
let path = temporary_source(
"filled-line-boundaries",
concat!(
".TH TOOL 1\n",
".SH SYNOPSIS\n",
"tool [first]\n",
" [second]\n",
" [third]\n",
".PP\n",
"Ordinary source wrapping\n",
"remains one filled paragraph.\n",
),
);
let document = parse_manual_source(&path).expect("lower filled line boundaries");
fs::remove_file(path).expect("remove temporary roff fixture");
let [
Block::Paragraph {
children: synopsis, ..
},
Block::Paragraph {
children: prose, ..
},
] = document.sections[0].blocks.as_slice()
else {
panic!("expected synopsis and prose paragraphs");
};
assert_eq!(
inline_text(synopsis),
"tool [first]\n [second]\n [third]"
);
assert_eq!(
synopsis
.iter()
.filter(|inline| matches!(inline, Inline::LineBreak))
.count(),
2
);
assert_eq!(
inline_text(prose),
"Ordinary source wrapping remains one filled paragraph."
);
}
#[test]
fn honours_roff_no_space_line_continuations() {
let document = parse_manual_bytes(
std::path::Path::new("line-continuation.1"),
b".TH LINE-CONTINUATION 1\n\
.SH DESCRIPTION\n\
extsize=\\c\n\
nnnn; multi-\\c\n\
block; (\\c\n\
.BR read (2)\n\
.EX\n\
literal-\\c\n\
continuation\n\
.EE\n",
)
.expect("lower no-space line continuations");
let [
Block::Paragraph {
children: prose, ..
},
Block::Preformatted {
children: literal, ..
},
] = document.sections[0].blocks.as_slice()
else {
panic!(
"expected one filled and one no-fill block: {:?}",
document.sections[0].blocks
);
};
assert_eq!(inline_text(prose), "extsize=nnnn; multi-block; (read(2)");
assert_eq!(inline_text(literal), "literal-continuation");
}
#[test]
fn keeps_explicit_horizontal_separation_at_a_tight_line_join() {
let document = parse_manual_bytes(
std::path::Path::new("motion-continuation.1"),
b".TH MOTION-CONTINUATION 1\n\
.SH DESCRIPTION\n\
\\h'-04' 1.\\h'+01'\\c\n\
The next line.\n",
)
.expect("lower a horizontally spaced continued line");
let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
panic!("expected one paragraph: {:?}", document.sections[0].blocks);
};
assert_eq!(inline_text(children), " 1. The next line.");
}
#[test]
fn lets_explicit_fonts_override_an_alternating_macro_default() {
let path = temporary_source(
"alternating-font-reset",
".TH MAN 1\n\
.SH OPTIONS\n\
.TP\n\
.BI \\-r\\ prompt \\fR,\\ \\fB\\-\\-prompt= prompt\n\
Set the pager prompt.\n",
);
let document = parse_manual_source(&path).expect("lower alternating font reset");
fs::remove_file(path).expect("remove temporary roff fixture");
let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
panic!("expected one definition list");
};
let term = items[0]
.terms
.first()
.expect("first definition term")
.iter()
.filter(|inline| !matches!(inline, Inline::Anchor { .. }))
.collect::<Vec<_>>();
assert_eq!(term.len(), 5);
assert!(matches!(term[0], Inline::Strong { children } if inline_text(children) == "-r "));
assert!(
matches!(term[1], Inline::Emphasis { children } if inline_text(children) == "prompt")
);
assert!(matches!(term[2], Inline::Text { value } if value == ", "));
assert!(
matches!(term[3], Inline::Strong { children } if inline_text(children) == "--prompt=")
);
assert!(
matches!(term[4], Inline::Emphasis { children } if inline_text(children) == "prompt")
);
}
#[test]
fn suppresses_pod_font_requests_around_verbatim_blocks() {
let path = temporary_source(
"pod-verbatim-fonts",
".de Vb\n\
.ft CW\n\
.nf\n\
..\n\
.de Ve\n\
.ft R\n\
.fi\n\
..\n\
.TH POD 1\n\
.SH EXAMPLES\n\
.Vb 2\n\
\\&struct A { int a; };\n\
\\&struct B : A {};\n\
.Ve\n",
);
let document = parse_manual_source(&path).expect("lower Pod::Man verbatim source");
fs::remove_file(path).expect("remove temporary roff fixture");
assert_eq!(document.sections[0].blocks.len(), 1);
let Block::Preformatted { children, .. } = &document.sections[0].blocks[0] else {
panic!("expected one preformatted block");
};
assert_eq!(
inline_text(children),
"struct A { int a; };\nstruct B : A {};"
);
}
#[test]
fn lowers_indented_aliases_without_roff_layout_arguments() {
let path = temporary_source(
"indented-aliases",
".TH CONTROL 1\n\
.SH OPTIONS\n\
.PD 0\n\
.IP \"\\fB-a\\fR\" 4\n\
.IP \"\\fB--all\\fR\" 4\n\
Show all entries.\n\
.PD\n\
.in 168u\n",
);
let document = parse_manual_source(&path).expect("lower indented aliases");
fs::remove_file(path).expect("remove temporary roff fixture");
let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
panic!("expected one definition list");
};
assert_eq!(items.len(), 1);
assert_eq!(
items[0]
.terms
.iter()
.map(|term| inline_text(term))
.collect::<Vec<_>>(),
["-a", "--all"]
);
assert_eq!(items[0].description.len(), 1);
let Block::Paragraph { children, .. } = &items[0].description[0] else {
panic!("expected alias description paragraph");
};
assert_eq!(inline_text(children), "Show all entries.");
}
#[test]
fn preserves_man_paragraph_distance_between_indented_paragraphs() {
let path = temporary_source(
"paragraph-distance",
".TH SPACING 1\n\
.SH OPTIONS\n\
.IP \"\\fB-a\\fR\" 4\n\
First.\n\
.IP \"\\fB-b\\fR\" 4\n\
Second.\n\
.PD 0\n\
.IP \"\\fB-c\\fR\" 4\n\
Third.\n\
.IP \"\\fB-d\\fR\" 4\n\
Fourth.\n\
.PD\n\
.IP \"\\fB-e\\fR\" 4\n\
Fifth.\n",
);
let document = parse_manual_source(&path).expect("lower paragraph distance");
fs::remove_file(path).expect("remove temporary roff fixture");
let [Block::DefinitionList { items, compact, .. }] = document.sections[0].blocks.as_slice()
else {
panic!("expected one definition list");
};
assert!(!compact);
assert_eq!(items.len(), 5);
assert_eq!(
items
.iter()
.map(|item| item.spacing_before_lines)
.collect::<Vec<_>>(),
[Some(0), Some(1), Some(0), Some(0), Some(1)]
);
}
#[test]
fn preserves_man_paragraph_and_heading_distance_as_one_layout_model() {
let path = temporary_source(
"vertical-layout",
".TH SPACING 1\n\
.SH FIRST\n\
First paragraph.\n\
.PP\n\
Second paragraph.\n\
.SS CHILD\n\
Child body.\n\
.PD 0\n\
.SS COMPACT\n\
Compact child.\n\
.SH NEXT\n\
Next body.\n\
.PD\n\
.SH FINAL\n\
Final body.\n",
);
let document = parse_manual_source(&path).expect("lower vertical layout");
fs::remove_file(path).expect("remove temporary roff fixture");
let [first, next, final_section] = document.sections.as_slice() else {
panic!("expected three top-level sections");
};
assert_eq!(first.spacing_before_lines, 0);
let [Block::Paragraph { .. }, Block::Paragraph { layout, .. }] = first.blocks.as_slice()
else {
panic!("expected two semantic paragraphs");
};
assert_eq!(layout.spacing_before_lines, 1);
let [child, compact] = first.children.as_slice() else {
panic!("expected two subsections");
};
assert_eq!(child.spacing_before_lines, 1);
assert_eq!(compact.spacing_before_lines, 0);
assert_eq!(next.spacing_before_lines, 0);
assert_eq!(final_section.spacing_before_lines, 1);
}
#[test]
fn does_not_duplicate_explicit_space_before_a_transparent_indent() {
let path = temporary_source(
"explicit-space-before-indent",
".TH SPACING 1\n\
.SH CONTENT\n\
Before.\n\
.sp\n\
.RS 4\n\
After.\n\
.RE\n",
);
let document = parse_manual_source(&path).expect("lower explicit indented spacing");
fs::remove_file(path).expect("remove temporary roff fixture");
let [
Block::Paragraph { .. },
Block::VerticalSpace { lines: 1, .. },
Block::Paragraph { layout, .. },
] = document.sections[0].blocks.as_slice()
else {
panic!("expected prose, one explicit gap, and indented prose");
};
assert_eq!(layout.indent_columns, 4);
assert_eq!(
layout.spacing_before_lines, 0,
"the explicit gap must not be repeated as wrapper boundary spacing",
);
}
#[test]
fn preserves_mdoc_paragraph_and_heading_distance() {
let path = temporary_source(
"mdoc-vertical-layout",
".Dd July 19, 2026\n\
.Dt SPACING 1\n\
.Os\n\
.Sh FIRST\n\
First paragraph.\n\
.Pp\n\
Second paragraph.\n\
.Ss CHILD\n\
Child body.\n",
);
let document = parse_manual_source(&path).expect("lower mdoc vertical layout");
fs::remove_file(path).expect("remove temporary roff fixture");
let [first] = document.sections.as_slice() else {
panic!("expected one top-level section");
};
assert_eq!(first.spacing_before_lines, 1);
assert!(matches!(
first.blocks.get(1),
Some(Block::VerticalSpace { lines: 1, .. })
));
assert_eq!(first.children[0].spacing_before_lines, 1);
}
#[test]
fn lowers_mdoc_semantic_inline_nodes_and_nested_sections() {
let path = temporary_source(
"mdoc",
".Dd July 19, 2026\n\
.Dt MANT 1\n\
.Os\n\
.Sh DESCRIPTION\n\
Use\n\
.Nm mant\n\
with\n\
.Xr man 1\n\
Read\n\
.Lk https://example.test/docs \"the documentation\"\n\
or contact\n\
.Mt docs@example.test\n\
.Ss Details\n\
.Fl h\n",
);
let document = parse_manual_source(&path).expect("lower mdoc source");
fs::remove_file(path).expect("remove temporary roff fixture");
assert_eq!(document.source.format, SourceFormat::Mdoc);
assert_eq!(document.sections[0].children[0].title, "Details");
let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
panic!("expected description paragraph");
};
assert!(
children
.iter()
.any(|inline| matches!(inline, Inline::Strong { .. }))
);
assert!(
children.iter().any(
|inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::Manual { name, .. }, .. } if name == "man")
)
);
assert!(children.iter().any(
|inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::External { uri }, .. } if uri == "https://example.test/docs")
));
assert!(children.iter().any(
|inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::Email { address }, .. } if address == "docs@example.test")
));
}
#[test]
fn lowers_documented_mdoc_delimiters_and_common_roff_characters() {
let path = temporary_source(
"mdoc-delimiters",
".Dd July 19, 2026\n\
.Dt DELIMITERS 7\n\
.Os\n\
.Sh DESCRIPTION\n\
.Op optional\n\
.Bq bracket\n\
.Dq double\n\
.Sq single\n\
.Pq parenthesized\n\
.Brq braced\n\
.Aq angled\n\
.Oo multi Ar value\n\
.Oc\n\
.Sh CHARACTERS\n\
\\(en \\(em \\(aq \\(dq \\(co \\(rg \\(tm \\(bu \\(ha \\(ti \\(rs\n",
);
let document = parse_manual_source(&path).expect("lower delimiter and character source");
fs::remove_file(path).expect("remove temporary roff fixture");
let description = document.sections[0]
.blocks
.iter()
.map(|block| match block {
Block::Paragraph { children, .. } => inline_text(children),
_ => String::new(),
})
.collect::<Vec<_>>()
.join(" ");
for expected in [
"[optional]",
"[bracket]",
"“double”",
"‘single’",
"(parenthesized)",
"{braced}",
"<angled>",
"[multi value]",
] {
assert!(
description.contains(expected),
"missing {expected:?} in {description:?}"
);
}
let [Block::Paragraph { children, .. }] = document.sections[1].blocks.as_slice() else {
panic!("expected one special-character paragraph");
};
assert_eq!(inline_text(children), "– — ' \" © ® ™ • ^ ~ \\");
}
#[test]
fn lowers_the_pinned_named_character_catalog_without_silent_deletion() {
let document = parse_manual_bytes(
std::path::Path::new("named-characters.7"),
b".TH NAMED-CHARACTERS 7\n\
.SH TEST\n\
at=\\(at ga=\\(ga oq=\\(oq arrow=\\(-> larrow=\\(<- mu=\\(mu\n\
de=\\(de pl=\\(pl dg=\\(dg ua=\\(ua da=\\(da lB=\\(lB rB=\\(rB\n\
unknown=\\[future-glyph]\n",
)
.expect("lower named characters");
let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
panic!("expected one character paragraph");
};
assert_eq!(
inline_text(children),
"at=@ ga=` oq=' arrow=→ larrow=← mu=× de=° pl=+ dg=† ua=↑ da=↓ lB=[ rB=] unknown=\\[future-glyph]"
);
}
#[test]
fn preserves_explicit_mdoc_function_and_enclosure_structure() {
let document = parse_manual_bytes(
std::path::Path::new("explicit-mdoc.1"),
b".Dd August 17, 2026\n\
.Dt EXPLICIT-MDOC 1\n\
.Os\n\
.Sh NAME\n\
.Nm explicit-mdoc\n\
.Nd exercise explicit blocks\n\
.Sh FUNCTION\n\
.Ft int\n\
.Fo audit_open\n\
.Fa const char *path\n\
.Fa int flags\n\
.Fc\n\
.Sh ENCLOSURES\n\
.Ao\nangle\n.Ac\n\
.Bo\nbracket\n.Bc\n\
.Do\ndouble\n.Dc\n\
.Po\nparenthesized\n.Pc\n\
.Qo\nquoted\n.Qc\n\
.So\nsingle\n.Sc\n\
.Bro\nbraced\n.Brc\n\
.Oo\noptional\n.Oc\n\
.Eo <<\ngeneric\n.Ec >>\n\
.Es [[ ]]\n\
.En custom\n",
)
.expect("lower explicit mdoc blocks");
let function = &document.sections[1];
let [
Block::Paragraph {
children: return_type,
..
},
Block::Paragraph {
children: declaration,
..
},
] = function.blocks.as_slice()
else {
panic!("expected return type and function declaration paragraphs");
};
assert_eq!(inline_text(return_type), "int");
assert_eq!(
inline_text(declaration),
"audit_open(const char *path, int flags)"
);
assert!(matches!(
declaration.first(),
Some(Inline::Strong { children }) if inline_text(children) == "audit_open"
));
let [Block::Paragraph { children, .. }] = document.sections[2].blocks.as_slice() else {
panic!("expected one enclosure paragraph");
};
assert_eq!(
inline_text(children),
"<angle> [bracket] “double” (parenthesized) “quoted” ‘single’ {braced} \
[optional] <<generic>> [[custom]]"
);
assert_eq!(document.diagnostics.len(), 2);
assert!(
document
.diagnostics
.iter()
.all(|diagnostic| diagnostic.message.starts_with("obsolete macro:")),
"{:?}",
document.diagnostics
);
}
#[test]
fn diagnoses_future_structural_macros_before_discarding_visible_parts() {
let mut report = Parser::default()
.parse_bytes(
"future-structure.1",
b".Dd August 17, 2026\n.Dt FUTURE 1\n.Os\n.Sh SYNOPSIS\n\
.Fo future_call\n.Fa argument\n.Fc\n",
)
.expect("parse structural fixture");
let block = find_macro_mut(&mut report.document.root, "Fo").expect("Fo block");
block.macro_name = Some("FutureBlock".to_owned());
let document = lower_mandoc_document(std::path::Path::new("future-structure.1"), &report);
assert!(document.diagnostics.iter().any(|diagnostic| {
diagnostic.code.as_deref() == Some("manual.unhandled-structural-parts")
&& diagnostic.message.contains("FutureBlock")
}));
}
#[test]
fn recognizes_explicitly_styled_traditional_man_references_in_any_section() {
let path = temporary_source(
"man-see-also",
".TH TOOL 1\n\
.SH DESCRIPTION\n\
The styled reference \\fBprintf\\fP(3) is usable here.\n\
.SH SEE ALSO\n\
.BR printf (3),\n\
.BR man (1)\n",
);
let document = parse_manual_source(&path).expect("lower man references");
fs::remove_file(path).expect("remove temporary roff fixture");
let see_also = document
.sections
.iter()
.find(|section| section.title == "SEE ALSO")
.expect("SEE ALSO");
let Block::Paragraph { children, .. } = &see_also.blocks[0] else {
panic!("references are a paragraph");
};
assert!(children.iter().any(|inline| matches!(
inline,
Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
if name == "printf" && manual_section == "3"
)));
assert!(children.iter().any(|inline| matches!(
inline,
Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
if name == "man" && manual_section == "1"
)));
let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
panic!("description is a paragraph");
};
assert!(children.iter().any(|inline| matches!(
inline,
Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
if name == "printf" && manual_section == "3"
)));
}
#[test]
fn recognizes_legacy_sphinx_manual_links_in_roff_inputs() {
let path = temporary_source(
"sphinx-manual-links",
".TH BTRFS 8\n\
.SH COMMANDS\n\
See btrfs\\-subvolume(8) \\%<> and btrfs(5) \\%<> for details.\n\
.EX\n\
btrfs-subvolume(8) \\%<>\n\
.EE\n",
);
let document = parse_manual_source(&path).expect("lower legacy Sphinx references");
fs::remove_file(path).expect("remove temporary roff fixture");
let section = &document.sections[0];
let paragraph = section
.blocks
.iter()
.find_map(|block| match block {
Block::Paragraph { children, .. } => Some(children),
_ => None,
})
.expect("commands paragraph");
assert_eq!(
inline_text(paragraph),
"See btrfs-subvolume(8) and btrfs(5) for details."
);
let references = paragraph
.iter()
.filter_map(|inline| match inline {
Inline::Link {
target:
mant_ir::LinkTarget::Manual {
name,
manual_section: Some(manual_section),
},
..
} => Some((name.as_str(), manual_section.as_str())),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(references, [("btrfs-subvolume", "8"), ("btrfs", "5")]);
let literal = section
.blocks
.iter()
.find_map(|block| match block {
Block::Preformatted { children, .. } => Some(children),
_ => None,
})
.expect("literal display");
assert_eq!(inline_text(literal), "btrfs-subvolume(8) <>");
assert!(!literal.iter().any(|inline| matches!(
inline,
Inline::Link {
target: mant_ir::LinkTarget::Manual { .. },
..
}
)));
}
#[test]
fn lowers_modern_groff_manual_uri_and_mail_macros() {
let path = temporary_source(
"man-modern-links",
".TH TOOL 1\n\
.SH DESCRIPTION\n\
.MR git-add 1 ,\n\
.UR https://example.test/docs\n\
Documentation\n\
.UE .\n\
.MT docs@example.test\n\
Mail us\n\
.ME .\n",
);
let document = parse_manual_source(&path).expect("lower modern man links");
fs::remove_file(path).expect("remove temporary roff fixture");
let section = &document.sections[0];
let mut manual = false;
let mut web = false;
let mut mail = false;
for children in section.blocks.iter().filter_map(|block| match block {
Block::Paragraph { children, .. } => Some(children),
_ => None,
}) {
for inline in children {
match inline {
Inline::Link {
target:
mant_ir::LinkTarget::Manual {
name,
manual_section: Some(manual_section),
},
..
} if name == "git-add" && manual_section == "1" => manual = true,
Inline::Link {
target: mant_ir::LinkTarget::External { uri },
..
} if uri == "https://example.test/docs" => {
web = true;
}
Inline::Link {
target: mant_ir::LinkTarget::Email { address },
..
} if address == "docs@example.test" => {
mail = true;
}
_ => {}
}
}
}
assert!(manual && web && mail);
assert!(section.blocks.iter().any(|block| match block {
Block::Paragraph { children, .. } => inline_text(children).contains("git-add(1),"),
_ => false,
}));
let linked_paragraphs = section
.blocks
.iter()
.filter_map(|block| match block {
Block::Paragraph { children, .. }
if children.iter().any(|inline| {
matches!(
inline,
Inline::Link {
target: mant_ir::LinkTarget::External { .. },
..
} | Inline::Link {
target: mant_ir::LinkTarget::Email { .. },
..
}
)
}) =>
{
Some(inline_text(children))
}
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(linked_paragraphs, ["Documentation.", "Mail us."]);
}
#[test]
fn resolves_mdoc_section_references_and_explicit_targets() {
let path = temporary_source(
"mdoc-navigation",
".Dd July 19, 2026\n\
.Dt NAVIGATION 1\n\
.Os\n\
.Sh DESCRIPTION\n\
Continue with\n\
.Sx DETAILS\n\
.Tg explicit-option\n\
.Fl x\n\
.Sh DETAILS\n\
Target content.\n",
);
let document = parse_manual_source(&path).expect("lower navigation mdoc source");
fs::remove_file(path).expect("remove temporary roff fixture");
assert_eq!(document.sections[0].id, "description-1");
assert_eq!(document.sections[1].id, "details-2");
let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
panic!("expected navigation paragraph");
};
assert!(children.iter().any(|inline| matches!(
inline,
Inline::Link {
target: mant_ir::LinkTarget::Section { id },
children,
..
} if id == "details-2" && inline_text(children) == "DETAILS"
)));
assert!(children.iter().any(|inline| matches!(
inline,
Inline::Anchor { id } if id == "explicit-option"
)));
}
#[test]
fn degrades_unresolved_mdoc_section_references_to_text() {
let path = temporary_source(
"mdoc-missing-section",
".Dd July 19, 2026\n.Dt NAVIGATION 1\n.Os\n.Sh DESCRIPTION\n.Sx MISSING\n",
);
let document = parse_manual_source(&path).expect("lower unresolved navigation source");
fs::remove_file(path).expect("remove temporary roff fixture");
let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
panic!("expected reference paragraph");
};
assert_eq!(inline_text(children), "MISSING");
assert!(children.iter().all(|inline| !matches!(
inline,
Inline::Link {
target: mant_ir::LinkTarget::Section { .. },
..
}
)));
assert!(document.diagnostics.iter().any(|diagnostic| {
diagnostic.code.as_deref() == Some("unresolved-section-reference")
}));
}
#[test]
fn turns_captured_parser_findings_into_structured_diagnostics() {
let path = temporary_source(
"unsupported",
".Dd July 19, 2026\n.Dt BAD 1\n.Os\n.Sh NAME\n.Nm bad\n.ab\n",
);
let document = parse_manual_source(&path).expect("best-effort parse");
fs::remove_file(path).expect("remove temporary roff fixture");
assert!(
document
.diagnostics
.iter()
.any(|diagnostic| diagnostic.level == DiagnosticLevel::Unsupported)
);
}
#[test]
fn masks_terminal_controls_before_native_parsing() {
let path = temporary_source("controls", ".TH SAFE 1\n.SH NAME\nsafe \x1b[2J text\n");
let document = parse_manual_source(&path).expect("parse sanitized manual");
fs::remove_file(path).expect("remove temporary roff fixture");
assert!(
document.diagnostics.iter().any(|diagnostic| {
diagnostic.code.as_deref() == Some("manual.control-characters")
})
);
}
#[test]
fn lowers_normalized_ordered_lists_and_literal_displays() {
let path = temporary_source(
"normalized",
".Dd July 19, 2026\n.Dt NORMALIZED 1\n.Os\n.Sh CONTENT\n\
.Bl -enum -compact\n.It\nfirst\n.It\nsecond\n.El\n\
.Bd -literal -offset 6n\nline one\nline two\n.Ed\n",
);
let document = parse_manual_source(&path).expect("lower normalized mdoc");
fs::remove_file(path).expect("remove temporary roff fixture");
assert!(matches!(
document.sections[0].blocks[0],
Block::List {
kind: mant_ir::ListKind::Ordered,
compact: true,
..
}
));
assert!(matches!(
document.sections[0].blocks[1],
Block::Preformatted { layout, .. } if layout.indent_columns == 6
));
}
#[test]
fn lowers_normalized_mdoc_font_and_author_layout() {
let path = temporary_source(
"normalized-mdoc-modes",
".Dd July 19, 2026\n\
.Dt NORMALIZED-MODES 1\n\
.Os\n\
.Sh AUTHORS\n\
.An -split\n\
.An Alice Example\n\
.An Bob Example\n\
.An -nosplit\n\
.An Carol Example\n\
.An Dave Example\n\
.Sh DESCRIPTION\n\
.Bf -literal\n\
literal text\n\
.Ef\n",
);
let document = parse_manual_source(&path).expect("lower normalized mdoc modes");
fs::remove_file(path).expect("remove temporary roff fixture");
let authors = &document.sections[0];
let Block::Paragraph { children, .. } = &authors.blocks[0] else {
panic!("authors are one paragraph");
};
assert_eq!(
inline_text(children),
"Alice Example\nBob Example Carol Example Dave Example"
);
let description = &document.sections[1];
let Block::Paragraph { children, .. } = &description.blocks[0] else {
panic!("font block is a paragraph");
};
assert!(matches!(
children.as_slice(),
[Inline::Code { value }] if value == "literal text"
));
}
#[test]
fn mdoc_definition_layout_uses_the_normalized_list_width() {
let path = temporary_source(
"mdoc-definition-widths",
".Dd July 23, 2026\n.Dt WIDTHS 1\n.Os\n.Sh ITEMS\n\
.Bl -tag -width 20n\n.It tenletters\nwide description\n.El\n\
.Bl -tag -width 3n\n.It short\nnarrow description\n.El\n",
);
let document = parse_manual_source(&path).expect("lower mdoc definition widths");
fs::remove_file(path).expect("remove temporary roff fixture");
let lists = document.sections[0]
.blocks
.iter()
.filter_map(|block| match block {
Block::DefinitionList { items, .. } => Some(items),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(lists.len(), 2);
assert!(lists[0][0].inline_term);
assert!(!lists[1][0].inline_term);
}
#[test]
fn lowers_the_pinned_large_mdoc_fixture_without_empty_sections() {
let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../libmandoc-rs/vendor/mandoc-1.14.6/mandoc.1");
let document = parse_manual_source(&source).expect("lower vendored mandoc manual");
assert!(document.sections.len() > 5);
assert!(
document
.sections
.iter()
.any(|section| section.title == "DESCRIPTION")
);
assert!(
document
.sections
.iter()
.all(|section| !section.blocks.is_empty() || !section.children.is_empty())
);
}
#[test]
fn lowers_tbl_and_eqn_payloads_into_structured_blocks() {
let path = temporary_source(
"table-equation",
".TH PAYLOAD 1\n.SH TABLE\n.TS\ntab(|);\nl r.\nleft|right\n.TE\n\
.SH EQUATION\n.EQ\nx sup 2\n.EN\n",
);
let document = parse_manual_source(&path).expect("lower table and equation");
fs::remove_file(path).expect("remove temporary roff fixture");
assert!(matches!(
document.sections[0].blocks[0],
Block::Table { ref rows, .. } if rows.len() == 1 && rows[0].cells.len() == 2
));
assert!(matches!(
document.sections[1].blocks[0],
Block::Equation { ref value, .. } if value.contains('x')
));
}
fn inline_text(children: &[Inline]) -> String {
children
.iter()
.map(|child| match child {
Inline::Text { value } | Inline::Code { value } => value.clone(),
Inline::Strong { children }
| Inline::Emphasis { children }
| Inline::Link { children, .. } => inline_text(children),
Inline::Anchor { .. } => String::new(),
Inline::LineBreak => "\n".to_owned(),
})
.collect()
}
}