use libmandoc_rs::{
DisplayKind, Node, NodeKind, NormalizedListKind, TableAlignment as MandocTableAlignment,
};
use mant_ast::{
Block, DefinitionItem, Inline, LayoutHint, ListItem, ListKind, Section,
TableAlignment as AstTableAlignment, TableCell as AstTableCell, TableRow,
};
use super::{
LoweringContext,
inline::{
FilledBoundary, InlineBuilder, append_inline_node, lower_inline_nodes, parse_roff_text,
plain_text, terms_fit_inline,
},
layout::{
add_leading_spacing, block_indent, display_indent, horizontal_distance_columns, layout,
layout_with_spacing, normalize_explicit_vertical_spacing, section_spacing,
set_block_spacing, update_paragraph_distance, vertical_distance_lines,
},
part_children,
roff_escape::visible_text,
source_span,
};
pub(super) fn lower_sections(root: &Node, context: &mut LoweringContext<'_>) -> Vec<Section> {
let mut paragraph_distance = 1;
let mut sections = Vec::new();
for node in &root.children {
update_paragraph_distance(node, &mut paragraph_distance);
if !is_section(node, true) && !is_section(node, false) {
continue;
}
let has_preceding_content = sections.last().is_some_and(section_has_body);
let spacing_before_lines = section_spacing(
node,
sections.is_empty(),
has_preceding_content,
paragraph_distance,
);
sections.push(lower_section(
node,
context,
spacing_before_lines,
&mut paragraph_distance,
));
}
sections
}
fn is_bullet_glyph(text: &str) -> bool {
let mut chars = text.chars();
match (chars.next(), chars.next()) {
(Some(glyph), None) => glyph == 'o' || !glyph.is_alphanumeric(),
_ => false,
}
}
fn lower_section(
node: &Node,
context: &mut LoweringContext<'_>,
spacing_before_lines: u16,
paragraph_distance: &mut u16,
) -> Section {
let heading = lower_inline_nodes(part_children(node, NodeKind::Head), context.default_name);
let title = plain_text(&heading).trim().to_owned();
let id = context.section_id(&title);
let body = part_children(node, NodeKind::Body);
let first_subsection = body
.iter()
.position(|child| is_section(child, false))
.unwrap_or(body.len());
let blocks = lower_blocks(&body[..first_subsection], context, 0, paragraph_distance);
let mut children = Vec::new();
let mut has_preceding_content = !blocks.is_empty();
for child in &body[first_subsection..] {
update_paragraph_distance(child, paragraph_distance);
if !is_section(child, false) {
continue;
}
let child_spacing = section_spacing(
child,
children.is_empty(),
has_preceding_content,
*paragraph_distance,
);
let child = lower_section(child, context, child_spacing, paragraph_distance);
has_preceding_content = section_has_body(&child);
children.push(child);
}
Section {
id,
title,
spacing_before_lines,
blocks,
children,
source: source_span(node),
}
}
fn section_has_body(section: &Section) -> bool {
!section.blocks.is_empty() || section.children.iter().any(section_has_body)
}
fn is_section(node: &Node, top_level: bool) -> bool {
matches!(
(node.macro_name.as_deref(), top_level),
(Some("Sh" | "SH"), true) | (Some("Ss" | "SS"), false)
)
}
fn lower_blocks(
nodes: &[Node],
context: &LoweringContext<'_>,
indent_columns: u16,
paragraph_distance: &mut u16,
) -> Vec<Block> {
let mut state = BlockState::new(indent_columns);
let mut definition_hanging_width = 7;
for node in nodes {
if node.macro_name.as_deref() == Some("PD") {
update_paragraph_distance(node, paragraph_distance);
continue;
}
if node.flags.no_print
|| node.kind == NodeKind::Comment
|| is_section(node, false)
|| is_nonprinting_request(node)
{
continue;
}
if node.kind == NodeKind::Text
&& node.text.as_deref().is_some_and(str::is_empty)
&& !node.flags.no_fill
{
state.flush_paragraph();
state.output.push(Block::VerticalSpace {
lines: 1,
source: source_span(node),
});
continue;
}
if node.flags.no_fill && is_inline(node) {
let line = lower_inline_nodes(std::slice::from_ref(node), context.default_name);
if !line.is_empty() {
state.push_preformatted(line, source_span(node));
}
continue;
}
state.flush_preformatted();
if node.macro_name.as_deref() == Some("Pp") {
state.flush_paragraph();
if !state.output.is_empty() {
state.output.push(Block::VerticalSpace {
lines: 1,
source: source_span(node),
});
}
} else if node.macro_name.as_deref() == Some("sp") {
state.flush_paragraph();
if let Some(lines) = vertical_distance_lines(node).filter(|lines| *lines > 0) {
state.output.push(Block::VerticalSpace {
lines,
source: source_span(node),
});
}
} else if node.macro_name.as_deref() == Some("br") {
state.hard_break();
} else if is_inline(node) {
state.push_inline(
lower_inline_nodes(std::slice::from_ref(node), context.default_name),
source_span(node),
starts_indented_filled_line(node),
);
} else {
state.flush_paragraph();
lower_structural_node(
node,
context,
indent_columns,
paragraph_distance,
&mut state.output,
&mut definition_hanging_width,
);
}
}
state.finish()
}
fn lower_structural_node(
node: &Node,
context: &LoweringContext<'_>,
indent_columns: u16,
paragraph_distance: &mut u16,
output: &mut Vec<Block>,
definition_hanging_width: &mut usize,
) {
match node.macro_name.as_deref() {
Some("PP" | "P" | "LP" | "HP") => {
let spacing_before = if output.is_empty() {
0
} else {
*paragraph_distance
};
let nested = lower_blocks(
part_children(node, NodeKind::Body),
context,
indent_columns,
paragraph_distance,
);
extend_blocks_with_spacing(output, nested, spacing_before);
}
Some("TP" | "IP" | "TQ") => {
lower_man_definition(
node,
context,
indent_columns,
paragraph_distance,
output,
definition_hanging_width,
);
}
Some("Bl") => {
let mut block = lower_mdoc_list(node, context, indent_columns, paragraph_distance);
if !output.is_empty() && !node.compact {
set_block_spacing(&mut block, 1);
}
output.push(block);
}
Some("Bd") if node.display_kind == Some(DisplayKind::Filled) => {
let spacing_before = u16::from(!output.is_empty() && !node.compact);
let nested = lower_blocks(
part_children(node, NodeKind::Body),
context,
indent_columns + display_indent(node),
paragraph_distance,
);
extend_blocks_with_spacing(output, nested, spacing_before);
}
Some("Bd" | "D1" | "Dl") => {
let mut block =
preformatted_block(node, context, indent_columns + display_indent(node));
if node.macro_name.as_deref() == Some("Bd") && !output.is_empty() && !node.compact {
set_block_spacing(&mut block, 1);
}
output.push(block);
}
Some("RS") => {
let nested = lower_blocks(
part_children(node, NodeKind::Body),
context,
indent_columns + 4,
paragraph_distance,
);
extend_transparent_blocks(output, nested, *paragraph_distance);
}
_ if node.kind == NodeKind::Table => {
append_table_row(output, node, indent_columns);
}
_ if node.kind == NodeKind::Equation => {
output.push(Block::Equation {
value: node.equation.clone().unwrap_or_default(),
display: true,
layout: layout(indent_columns),
source: source_span(node),
});
}
_ => {
let body = part_children(node, NodeKind::Body);
let children = if body.is_empty() {
node.children.as_slice()
} else {
body
};
output.extend(lower_blocks(
children,
context,
indent_columns,
paragraph_distance,
));
}
}
}
fn lower_man_definition(
node: &Node,
context: &LoweringContext<'_>,
indent_columns: u16,
paragraph_distance: &mut u16,
output: &mut Vec<Block>,
definition_hanging_width: &mut usize,
) {
let spacing_before = if node.macro_name.as_deref() == Some("TQ") {
0
} else {
*paragraph_distance
};
update_man_definition_width(node, definition_hanging_width);
let max_width = definition_hanging_width.saturating_sub(1);
let item = definition_item(node, context, indent_columns, paragraph_distance, max_width);
if node.macro_name.as_deref() == Some("IP") && is_ip_bullet_item(&item) {
append_ip_bullet(
output,
item,
indent_columns,
spacing_before,
source_span(node),
);
} else {
append_definition(
output,
item,
indent_columns,
spacing_before,
source_span(node),
max_width,
);
}
}
fn append_table_row(output: &mut Vec<Block>, node: &Node, indent_columns: u16) {
if node.table_cells.is_empty() {
return;
}
let row = TableRow {
cells: node
.table_cells
.iter()
.map(|cell| AstTableCell {
blocks: vec![Block::Paragraph {
children: cell.text.as_deref().map_or_else(Vec::new, parse_roff_text),
layout: LayoutHint::default(),
source: source_span(node),
}],
column_span: cell.column_span,
row_span: cell.row_span,
alignment: Some(match cell.alignment {
MandocTableAlignment::Left => AstTableAlignment::Left,
MandocTableAlignment::Center => AstTableAlignment::Center,
MandocTableAlignment::Right => AstTableAlignment::Right,
}),
})
.collect(),
};
if let Some(Block::Table { rows, .. }) = output.last_mut() {
rows.push(row);
} else {
output.push(Block::Table {
rows: vec![row],
layout: layout(indent_columns),
source: source_span(node),
});
}
}
struct BlockState {
output: Vec<Block>,
paragraph: InlineBuilder,
paragraph_source: Option<mant_ast::SourceSpan>,
paragraph_last_line: Option<u32>,
preformatted: Vec<Inline>,
pre_source: Option<mant_ast::SourceSpan>,
indent_columns: u16,
}
impl BlockState {
const fn new(indent_columns: u16) -> Self {
Self {
output: Vec::new(),
paragraph: InlineBuilder::new(),
paragraph_source: None,
paragraph_last_line: None,
preformatted: Vec::new(),
pre_source: None,
indent_columns,
}
}
fn push_inline(
&mut self,
nodes: Vec<Inline>,
source: Option<mant_ast::SourceSpan>,
starts_indented_line: bool,
) {
if nodes.is_empty() {
return;
}
if self.paragraph_source.is_none() {
self.paragraph_source = source;
}
let source_line = source.map(|span| span.line);
let crossed_source_line = self
.paragraph_last_line
.zip(source_line)
.is_some_and(|(previous, current)| current > previous);
let boundary = if !crossed_source_line {
FilledBoundary::SameLine
} else if starts_indented_line {
FilledBoundary::LineBreak
} else {
FilledBoundary::Word
};
self.paragraph.append_filled(nodes, boundary);
if source_line.is_some() {
self.paragraph_last_line = source_line;
}
}
fn hard_break(&mut self) {
self.paragraph.hard_break();
}
fn push_preformatted(&mut self, nodes: Vec<Inline>, source: Option<mant_ast::SourceSpan>) {
self.flush_paragraph();
if !self.preformatted.is_empty() {
self.preformatted.push(Inline::LineBreak);
}
self.preformatted.extend(nodes);
if self.pre_source.is_none() {
self.pre_source = source;
}
}
fn flush_paragraph(&mut self) {
flush_paragraph(
&mut self.output,
&mut self.paragraph,
&mut self.paragraph_source,
self.indent_columns,
);
self.paragraph_last_line = None;
}
fn flush_preformatted(&mut self) {
flush_preformatted(
&mut self.output,
&mut self.preformatted,
&mut self.pre_source,
self.indent_columns,
);
}
fn finish(mut self) -> Vec<Block> {
self.flush_preformatted();
self.flush_paragraph();
normalize_explicit_vertical_spacing(&mut self.output);
self.output
}
}
fn starts_indented_filled_line(node: &Node) -> bool {
if node.flags.no_print || node.kind == NodeKind::Comment {
return false;
}
if node.kind == NodeKind::Text {
return node.flags.line_start
&& node
.text
.as_deref()
.is_some_and(|text| text.starts_with(char::is_whitespace));
}
node.children
.iter()
.find(|child| !child.flags.no_print && child.kind != NodeKind::Comment)
.is_some_and(starts_indented_filled_line)
}
fn lower_mdoc_list(
node: &Node,
context: &LoweringContext<'_>,
indent_columns: u16,
paragraph_distance: &mut u16,
) -> Block {
let items: Vec<&Node> = part_children(node, NodeKind::Body)
.iter()
.filter(|child| child.macro_name.as_deref() == Some("It"))
.collect();
let is_definition = matches!(
node.list_kind,
Some(NormalizedListKind::Definition | NormalizedListKind::Column)
) || (node.list_kind.is_none()
&& items
.iter()
.any(|item| !part_children(item, NodeKind::Head).is_empty()));
let list_indent = indent_columns + display_indent(node);
if is_definition {
let max_term_width = node
.width
.as_deref()
.and_then(horizontal_distance_columns)
.unwrap_or(6);
Block::DefinitionList {
items: items
.into_iter()
.map(|item| {
definition_item(
item,
context,
list_indent,
paragraph_distance,
max_term_width,
)
})
.collect(),
compact: node.compact,
layout: layout(indent_columns),
source: source_span(node),
}
} else {
Block::List {
kind: match node.list_kind {
Some(NormalizedListKind::Ordered) => ListKind::Ordered,
Some(NormalizedListKind::Plain) => ListKind::Plain,
_ => ListKind::Bullet,
},
start: (node.list_kind == Some(NormalizedListKind::Ordered)).then_some(1),
compact: node.compact,
items: items
.into_iter()
.map(|item| ListItem {
blocks: lower_blocks(
part_children(item, NodeKind::Body),
context,
list_indent,
paragraph_distance,
),
})
.collect(),
layout: layout(indent_columns),
source: source_span(node),
}
}
}
fn definition_item(
node: &Node,
context: &LoweringContext<'_>,
indent_columns: u16,
paragraph_distance: &mut u16,
max_term_width: usize,
) -> DefinitionItem {
let mut term = lower_inline_nodes(visible_definition_head(node), context.default_name);
if let Some(id) = definition_head_anchor(node, &term) {
term.insert(0, Inline::Anchor { id });
}
let terms: Vec<Vec<Inline>> = (!term.is_empty()).then_some(term).into_iter().collect();
DefinitionItem {
identity: None,
inline_term: terms_fit_inline(&terms, max_term_width),
terms,
description: lower_blocks(
part_children(node, NodeKind::Body),
context,
indent_columns + 4,
paragraph_distance,
),
spacing_before_lines: None,
}
}
fn definition_head_anchor(node: &Node, term: &[Inline]) -> Option<String> {
let head = node
.children
.iter()
.find(|child| child.kind == NodeKind::Head)?;
if !head.flags.deep_link_target {
return None;
}
head.tag.as_deref().map(visible_text).or_else(|| {
plain_text(term)
.trim_start_matches('-')
.split_whitespace()
.next()
.map(ToOwned::to_owned)
})
}
fn visible_definition_head(node: &Node) -> &[Node] {
let head = part_children(node, NodeKind::Head);
match node.macro_name.as_deref() {
Some("IP") => head.first().map_or(&[], std::slice::from_ref),
Some("TP" | "TQ") => head
.iter()
.position(|child| child.flags.line_start)
.map_or(&[], |visible_start| &head[visible_start..]),
_ => head,
}
}
fn append_definition(
output: &mut Vec<Block>,
mut item: DefinitionItem,
indent_columns: u16,
paragraph_distance: u16,
source: Option<mant_ast::SourceSpan>,
max_term_width: usize,
) {
if let Some(Block::DefinitionList { items, compact, .. }) = output
.last_mut()
.filter(|block| block_indent(block) == Some(indent_columns))
{
if !item.description.is_empty() {
let first_pending = items
.iter()
.rposition(|previous| !previous.description.is_empty())
.map_or(0, |index| index + 1);
for pending in items.drain(first_pending..) {
item.terms.splice(0..0, pending.terms);
}
item.inline_term = terms_fit_inline(&item.terms, max_term_width);
}
item.spacing_before_lines = Some(if items.is_empty() {
0
} else {
paragraph_distance
});
*compact = *compact && paragraph_distance == 0;
items.push(item);
} else {
item.spacing_before_lines = Some(0);
let spacing_before_lines = if output.is_empty() {
0
} else {
paragraph_distance
};
output.push(Block::DefinitionList {
items: vec![item],
compact: paragraph_distance == 0,
layout: layout_with_spacing(indent_columns, spacing_before_lines),
source,
});
}
}
fn update_man_definition_width(node: &Node, current_width: &mut usize) {
let head = part_children(node, NodeKind::Head);
let argument = match node.macro_name.as_deref() {
Some("TP" | "TQ") => head
.iter()
.find(|child| !child.flags.line_start)
.and_then(first_node_text),
Some("IP") => head.get(1).and_then(first_node_text),
_ => None,
};
if let Some(width) = argument.and_then(horizontal_distance_columns) {
*current_width = width;
}
}
fn first_node_text(node: &Node) -> Option<&str> {
node.text
.as_deref()
.or_else(|| node.children.iter().find_map(first_node_text))
}
fn append_ip_bullet(
output: &mut Vec<Block>,
item: DefinitionItem,
indent_columns: u16,
paragraph_distance: u16,
source: Option<mant_ast::SourceSpan>,
) {
let list_item = ListItem {
blocks: item.description,
};
if let Some(Block::List {
kind: ListKind::Bullet,
compact,
items,
..
}) = output
.last_mut()
.filter(|block| block_indent(block) == Some(indent_columns))
{
*compact = *compact && paragraph_distance == 0;
items.push(list_item);
return;
}
let spacing_before_lines = if output.is_empty() {
0
} else {
paragraph_distance
};
output.push(Block::List {
kind: ListKind::Bullet,
start: None,
compact: paragraph_distance == 0,
items: vec![list_item],
layout: layout_with_spacing(indent_columns, spacing_before_lines),
source,
});
}
fn is_ip_bullet_item(item: &DefinitionItem) -> bool {
let [term] = item.terms.as_slice() else {
return false;
};
is_bullet_glyph(plain_text(term).trim())
}
fn extend_transparent_blocks(
output: &mut Vec<Block>,
mut nested: Vec<Block>,
paragraph_distance: u16,
) {
let boundary_spacing = if output.is_empty()
|| output
.last()
.is_some_and(|block| matches!(block, Block::VerticalSpace { .. }))
{
0
} else {
paragraph_distance
};
add_leading_spacing(&mut nested, boundary_spacing);
let merged_first = match (output.last_mut(), nested.first_mut()) {
(
Some(Block::DefinitionList {
items: previous_items,
compact: previous_compact,
layout: previous_layout,
..
}),
Some(Block::DefinitionList {
items: nested_items,
compact: nested_compact,
layout: nested_layout,
..
}),
) if previous_layout.indent_columns == nested_layout.indent_columns => {
if let Some(first) = nested_items.first_mut() {
first.spacing_before_lines = Some(if previous_items.is_empty() {
0
} else {
nested_layout.spacing_before_lines
});
}
previous_items.append(nested_items);
*previous_compact = *previous_compact && *nested_compact && paragraph_distance == 0;
true
}
_ => false,
};
if merged_first {
nested.remove(0);
}
output.extend(nested);
}
fn extend_blocks_with_spacing(output: &mut Vec<Block>, mut nested: Vec<Block>, lines: u16) {
add_leading_spacing(&mut nested, lines);
output.extend(nested);
}
fn preformatted_block(node: &Node, context: &LoweringContext<'_>, indent_columns: u16) -> Block {
let body_index = node
.children
.iter()
.position(|child| child.kind == NodeKind::Body);
let children = body_index.map_or_else(
|| node.children.as_slice(),
|index| node.children[index].children.as_slice(),
);
let mut inlines = preformatted_inlines(children, context.default_name);
if let Some(body_index) = body_index {
let tail = &node.children[body_index + 1..];
let tail_len = tail
.iter()
.take_while(|child| child.line == node.line && is_inline(child))
.count();
if tail
.first()
.is_some_and(|child| child.flags.delimiter_close)
{
inlines.extend(lower_inline_nodes(&tail[..tail_len], context.default_name));
}
}
Block::Preformatted {
children: inlines,
language: None,
layout: layout(indent_columns),
source: source_span(node),
}
}
fn preformatted_inlines(nodes: &[Node], default_name: Option<&str>) -> Vec<Inline> {
let mut output = Vec::new();
let mut line = InlineBuilder::new();
let mut previous_line = None;
for node in nodes {
if node.kind == NodeKind::Comment || node.flags.no_print {
continue;
}
if previous_line.is_some_and(|previous| node.line > previous) {
output.extend(std::mem::replace(&mut line, InlineBuilder::new()).finish());
}
if previous_line.is_some_and(|previous| node.line > previous) && !output.is_empty() {
output.push(Inline::LineBreak);
}
if node.kind == NodeKind::Text || node.macro_name.is_some() {
append_inline_node(&mut line, node, default_name);
} else {
line.append(preformatted_inlines(&node.children, default_name));
}
previous_line = Some(node.line);
}
output.extend(line.finish());
output
}
fn flush_paragraph(
output: &mut Vec<Block>,
paragraph: &mut InlineBuilder,
source: &mut Option<mant_ast::SourceSpan>,
indent_columns: u16,
) {
let current = std::mem::replace(paragraph, InlineBuilder::new()).finish();
if current.is_empty() {
*source = None;
} else {
output.push(Block::Paragraph {
children: current,
layout: layout(indent_columns),
source: source.take(),
});
}
}
fn flush_preformatted(
output: &mut Vec<Block>,
preformatted: &mut Vec<Inline>,
source: &mut Option<mant_ast::SourceSpan>,
indent_columns: u16,
) {
if preformatted.is_empty() {
*source = None;
return;
}
output.push(Block::Preformatted {
children: std::mem::take(preformatted),
language: None,
layout: layout(indent_columns),
source: source.take(),
});
}
fn is_inline(node: &Node) -> bool {
matches!(node.kind, NodeKind::Text | NodeKind::Element)
|| matches!(
node.macro_name.as_deref(),
Some("Nm" | "Nd" | "Op" | "Oo" | "Dq" | "Sq" | "Pq" | "Bq" | "Brq" | "Aq")
)
}
fn is_nonprinting_request(node: &Node) -> bool {
matches!(
node.macro_name.as_deref(),
Some("ad" | "fi" | "ft" | "hy" | "in" | "na" | "ne" | "nf" | "nh" | "nr" | "ta")
)
}
#[cfg(test)]
mod tests {
use mant_ast::{Block, DefinitionItem, Inline, LayoutHint};
fn text(value: &str) -> Vec<Inline> {
vec![Inline::Text {
value: value.to_owned(),
}]
}
fn definition(term: &str, description: &str) -> DefinitionItem {
DefinitionItem {
identity: None,
inline_term: false,
terms: vec![text(term)],
description: vec![Block::Paragraph {
children: text(description),
layout: LayoutHint::default(),
source: None,
}],
spacing_before_lines: None,
}
}
#[test]
fn only_single_glyph_definition_terms_are_ip_bullets() {
assert!(super::is_ip_bullet_item(&definition("*", "multiply")));
assert!(super::is_ip_bullet_item(&definition("o", "item")));
assert!(!super::is_ip_bullet_item(&definition("&&", "logical and")));
assert!(!super::is_ip_bullet_item(&definition(
"-a, --all",
"show all"
)));
}
#[test]
fn short_terms_hang_inline_but_long_ones_do_not() {
assert!(super::terms_fit_inline(&[text("space")], 6));
assert!(super::terms_fit_inline(&[text("* / %")], 6));
assert!(!super::terms_fit_inline(&[text("--listed-incremental")], 6));
assert!(!super::terms_fit_inline(&[], 6));
}
}