use libmandoc_rs::{Node, NodeKind, NormalizedListKind};
use mant_ir::{
Block, DefinitionItem, Inline, ListItem, ListKind, TableAlignment as AstTableAlignment,
TableCell as AstTableCell, TableRow,
};
use super::super::{
LoweringContext, first_part_children,
inline::{
InlineBuilder, lower_inline_nodes_with_spacing, plain_text, spacing_after_node,
spacing_after_nodes, terms_fit_inline,
},
layout::{
block_indent, display_indent, horizontal_distance_columns, layout, layout_with_spacing,
paragraph_distance_lines,
},
part_child_groups,
roff_escape::visible_text,
source_span,
};
use super::{
ends_with_line_continuation, is_inline_equation, is_inline_equation_quote_artifact,
lower_blocks_with_spacing,
};
use crate::block::block_layout_mut;
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,
}
}
pub(super) fn lower_man_definition(
node: &Node,
context: &LoweringContext<'_>,
indent_columns: u16,
state: ManDefinitionState<'_>,
spacing_enabled: bool,
) {
let ManDefinitionState {
paragraph_distance,
output,
definition_hanging_width,
alias_state,
} = state;
let spacing_before = if node.macro_name.as_deref() == Some("TQ") {
0
} else {
*paragraph_distance
};
let head = first_part_children(node, NodeKind::Head);
let body = first_part_children(node, NodeKind::Body);
let leading_head_distance = leading_paragraph_distance(head);
let leading_body_distance = leading_paragraph_distance(body);
if let Some(distance) = leading_head_distance {
*paragraph_distance = distance;
}
update_man_definition_width(node, definition_hanging_width);
let max_width = definition_hanging_width.saturating_sub(1);
let mut item = definition_item(
node,
context,
indent_columns,
paragraph_distance,
max_width,
spacing_enabled,
);
let macro_name = node.macro_name.as_deref();
let description_empty = item.description.is_empty();
let opens_compact_group = macro_name == Some("TP")
&& description_empty
&& (leading_head_distance == Some(0) || leading_body_distance == Some(0));
let closes_compact_group = macro_name == Some("TP")
&& !description_empty
&& leading_body_distance.is_some_and(|distance| distance != 0);
let explicit_continuation = description_empty
&& (macro_name == Some("TQ")
|| visible_definition_head(node)
.last()
.is_some_and(ends_with_line_continuation));
let previous_location = last_definition_location(output, indent_columns);
let merge = definition_merge(
macro_name,
closes_compact_group,
*alias_state,
previous_location,
);
warn_unproven_alias_boundary(
node,
context,
output,
indent_columns,
macro_name,
description_empty,
merge,
);
if node.macro_name.as_deref() == Some("IP")
&& item.terms.is_empty()
&& append_ip_continuation(output, &mut item, indent_columns, spacing_before)
{
return;
}
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 {
let location = append_definition(
output,
item,
indent_columns,
spacing_before,
source_span(node),
max_width,
merge,
);
transition_alias_state(
alias_state,
AliasTransition {
macro_name,
description_empty,
opens_compact_group,
explicit_continuation,
previous_location,
merge,
location,
spacing_before,
leading_head_distance,
leading_body_distance,
},
);
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum ManAliasState {
None,
ExplicitContinuation(DefinitionLocation),
CompactRun(DefinitionLocation),
}
impl ManAliasState {
const fn compact_start(self) -> Option<DefinitionLocation> {
match self {
Self::CompactRun(location) => Some(location),
Self::None | Self::ExplicitContinuation(_) => None,
}
}
const fn explicit_start(self) -> Option<DefinitionLocation> {
match self {
Self::ExplicitContinuation(location) => Some(location),
Self::None | Self::CompactRun(_) => None,
}
}
}
pub(super) struct ManDefinitionState<'a> {
pub(super) paragraph_distance: &'a mut u16,
pub(super) output: &'a mut Vec<Block>,
pub(super) definition_hanging_width: &'a mut usize,
pub(super) alias_state: &'a mut ManAliasState,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) struct DefinitionLocation {
block: usize,
item: usize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum DefinitionMerge {
None,
From(DefinitionLocation),
}
#[derive(Clone, Copy)]
struct AliasTransition<'a> {
macro_name: Option<&'a str>,
description_empty: bool,
opens_compact_group: bool,
explicit_continuation: bool,
previous_location: Option<DefinitionLocation>,
merge: DefinitionMerge,
location: DefinitionLocation,
spacing_before: u16,
leading_head_distance: Option<u16>,
leading_body_distance: Option<u16>,
}
fn transition_alias_state(alias_state: &mut ManAliasState, transition: AliasTransition<'_>) {
let AliasTransition {
macro_name,
description_empty,
opens_compact_group,
explicit_continuation,
previous_location,
merge,
location,
spacing_before,
leading_head_distance,
leading_body_distance,
} = transition;
*alias_state = if opens_compact_group
|| (macro_name == Some("IP")
&& description_empty
&& (spacing_before == 0
|| leading_head_distance == Some(0)
|| leading_body_distance == Some(0)))
{
match *alias_state {
ManAliasState::CompactRun(start) => ManAliasState::CompactRun(start),
ManAliasState::None | ManAliasState::ExplicitContinuation(_) => {
ManAliasState::CompactRun(location)
}
}
} else if explicit_continuation {
match *alias_state {
ManAliasState::CompactRun(start) => ManAliasState::CompactRun(start),
ManAliasState::ExplicitContinuation(start) => {
ManAliasState::ExplicitContinuation(start)
}
ManAliasState::None if macro_name == Some("TQ") => previous_location.map_or(
ManAliasState::ExplicitContinuation(location),
ManAliasState::ExplicitContinuation,
),
ManAliasState::None => ManAliasState::ExplicitContinuation(location),
}
} else if matches!(merge, DefinitionMerge::None) && description_empty {
*alias_state
} else {
ManAliasState::None
};
}
fn definition_merge(
macro_name: Option<&str>,
closes_compact_group: bool,
alias_state: ManAliasState,
previous_location: Option<DefinitionLocation>,
) -> DefinitionMerge {
let start = if macro_name == Some("TQ") {
alias_state
.explicit_start()
.or_else(|| alias_state.compact_start())
.or(previous_location)
} else if closes_compact_group {
alias_state.compact_start()
} else if let Some(start) = alias_state.explicit_start() {
Some(start)
} else if macro_name == Some("IP") {
alias_state.compact_start()
} else {
None
};
start.map_or(DefinitionMerge::None, DefinitionMerge::From)
}
fn warn_unproven_alias_boundary(
node: &Node,
context: &LoweringContext<'_>,
output: &[Block],
indent_columns: u16,
macro_name: Option<&str>,
description_empty: bool,
merge: DefinitionMerge,
) {
if !description_empty
&& matches!(macro_name, Some("IP" | "TQ"))
&& pending_definition_start(output, indent_columns).is_some_and(
|pending| !matches!(merge, DefinitionMerge::From(start) if start == pending),
)
{
context.warn_definition_alias_boundary(node);
}
}
fn last_definition_location(output: &[Block], indent_columns: u16) -> Option<DefinitionLocation> {
let block = output.len().checked_sub(1)?;
let Block::DefinitionList { items, .. } = output
.last()
.filter(|candidate| block_indent(candidate) == Some(indent_columns))?
else {
return None;
};
Some(DefinitionLocation {
block,
item: items.len().checked_sub(1)?,
})
}
fn pending_definition_start(output: &[Block], indent_columns: u16) -> Option<DefinitionLocation> {
let block = output.len().checked_sub(1)?;
let Block::DefinitionList { items, .. } = output
.last()
.filter(|candidate| block_indent(candidate) == Some(indent_columns))?
else {
return None;
};
let item = items
.iter()
.rposition(|previous| !previous.description.is_empty())
.map_or(0, |index| index + 1);
(item < items.len()).then_some(DefinitionLocation { block, item })
}
fn leading_paragraph_distance(nodes: &[Node]) -> Option<u16> {
let mut distance = None;
for node in nodes {
if node.macro_name.as_deref() == Some("PD") {
if let Some(value) = paragraph_distance_lines(node) {
distance = Some(value);
}
} else if !node.flags.no_print && node.kind != NodeKind::Comment {
break;
}
}
distance
}
fn append_ip_continuation(
output: &mut [Block],
item: &mut DefinitionItem,
indent_columns: u16,
paragraph_distance: u16,
) -> bool {
if item.description.is_empty() {
return false;
}
let Some(Block::DefinitionList { items, compact, .. }) = output
.last_mut()
.filter(|block| block_indent(block) == Some(indent_columns))
else {
return false;
};
let Some(previous) = items
.last_mut()
.filter(|previous| !previous.terms.is_empty() && !previous.description.is_empty())
else {
return false;
};
if let Some(layout) = item.description.first_mut().and_then(block_layout_mut) {
layout.spacing_before_lines = layout.spacing_before_lines.max(paragraph_distance);
}
previous.description.append(&mut item.description);
*compact = *compact && paragraph_distance == 0;
true
}
pub(super) fn lower_mdoc_list(
node: &Node,
context: &LoweringContext<'_>,
indent_columns: u16,
paragraph_distance: &mut u16,
initial_spacing: bool,
) -> Block {
let items = mdoc_list_items(node, initial_spacing, context.default_name);
let is_definition = matches!(
node.list_kind,
Some(NormalizedListKind::Definition | NormalizedListKind::Column)
) || (node.list_kind.is_none()
&& items
.iter()
.any(|item| !first_part_children(item.node, NodeKind::Head).is_empty()));
let list_indent = indent_columns + display_indent(node);
if node.list_kind == Some(NormalizedListKind::Column) {
return lower_mdoc_column_list(
node,
items,
context,
indent_columns,
list_indent,
paragraph_distance,
);
}
if is_definition {
let max_term_width = node
.width
.as_deref()
.and_then(horizontal_distance_columns)
.unwrap_or(6);
let lowered_items = items
.into_iter()
.map(|item| {
definition_item(
item.node,
context,
list_indent,
paragraph_distance,
max_term_width,
item.spacing_enabled,
)
})
.collect();
Block::DefinitionList {
items: coalesce_pending_definition_terms(lowered_items, max_term_width),
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_with_spacing(
first_part_children(item.node, NodeKind::Body),
context,
list_indent,
paragraph_distance,
spacing_after_nodes(
first_part_children(item.node, NodeKind::Head),
item.spacing_enabled,
context.default_name,
),
),
})
.collect(),
layout: layout(indent_columns),
source: source_span(node),
}
}
}
fn coalesce_pending_definition_terms(
items: Vec<DefinitionItem>,
max_term_width: usize,
) -> Vec<DefinitionItem> {
let mut output = Vec::with_capacity(items.len());
let mut pending = Vec::new();
for mut item in items {
if item.description.is_empty() {
pending.push(item);
continue;
}
if is_option_definition(&item) && pending.iter().all(is_option_definition) {
let pending_terms = pending
.drain(..)
.flat_map(|pending: DefinitionItem| pending.terms);
item.terms.splice(0..0, pending_terms);
item.inline_term = terms_fit_inline(&item.terms, max_term_width);
} else {
output.append(&mut pending);
}
output.push(item);
}
output.extend(pending);
output
}
fn is_option_definition(item: &DefinitionItem) -> bool {
item.terms.iter().any(|term| {
let text = plain_text(term);
let Some(token) = text.split_whitespace().next() else {
return false;
};
let name =
token.trim_matches(|character: char| matches!(character, '[' | ']' | '(' | ')' | ','));
name.starts_with('-') && name != "-"
})
}
#[derive(Clone, Copy)]
struct MdocListItem<'a> {
node: &'a Node,
spacing_enabled: bool,
}
fn mdoc_list_items<'a>(
node: &'a Node,
initial_spacing: bool,
default_name: Option<&str>,
) -> Vec<MdocListItem<'a>> {
let mut spacing_enabled = initial_spacing;
let mut items = Vec::new();
for child in first_part_children(node, NodeKind::Body) {
if child.macro_name.as_deref() == Some("It") {
items.push(MdocListItem {
node: child,
spacing_enabled,
});
}
spacing_enabled = spacing_after_node(child, spacing_enabled, default_name);
}
items
}
fn lower_mdoc_column_list(
node: &Node,
items: Vec<MdocListItem<'_>>,
context: &LoweringContext<'_>,
indent_columns: u16,
cell_indent: u16,
paragraph_distance: &mut u16,
) -> Block {
let rows = items
.into_iter()
.map(|item| {
let body_spacing = spacing_after_nodes(
first_part_children(item.node, NodeKind::Head),
item.spacing_enabled,
context.default_name,
);
let mut cells = part_child_groups(item.node, NodeKind::Body)
.map(|body| AstTableCell {
blocks: lower_blocks_with_spacing(
body,
context,
cell_indent,
paragraph_distance,
body_spacing,
),
column_span: 1,
row_span: 1,
alignment: Some(AstTableAlignment::Left),
})
.collect::<Vec<_>>();
if item.node.flags.deep_link_target
&& let Some(id) = item.node.tag.as_deref()
&& let Some(Block::Paragraph { children, .. }) =
cells.first_mut().and_then(|cell| cell.blocks.first_mut())
{
children.insert(0, Inline::Anchor { id: id.into() });
}
TableRow { cells }
})
.filter(|row| !row.cells.is_empty())
.collect();
Block::Table {
rows,
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,
spacing_enabled: bool,
) -> DefinitionItem {
let head = visible_definition_head(node);
let body = first_part_children(node, NodeKind::Body);
let (displaced_equations, body) = displaced_definition_equations(head, body);
let mut term_builder = InlineBuilder::with_spacing(spacing_enabled);
term_builder.append(lower_inline_nodes_with_spacing(
head,
context.default_name,
spacing_enabled,
));
for equation in displaced_equations {
term_builder.append(lower_inline_nodes_with_spacing(
std::slice::from_ref(equation),
context.default_name,
spacing_enabled,
));
}
let mut term = term_builder.finish();
if let Some(id) = definition_head_anchor(node, &term) {
term.insert(0, Inline::Anchor { id: id.into() });
}
let terms = split_definition_terms(term);
DefinitionItem {
identity: None,
inline_term: terms_fit_inline(&terms, max_term_width),
terms,
description: lower_blocks_with_spacing(
body,
context,
indent_columns + 4,
paragraph_distance,
spacing_after_nodes(head, spacing_enabled, context.default_name),
),
spacing_before_lines: None,
}
}
fn displaced_definition_equations<'a>(
head: &[Node],
body: &'a [Node],
) -> (Vec<&'a Node>, &'a [Node]) {
let Some(head_line) = head.iter().map(maximum_node_line).max() else {
return (Vec::new(), body);
};
let mut equations = Vec::new();
let mut consumed = 0;
while let Some(candidate) = body
.get(consumed)
.filter(|candidate| candidate.line == head_line)
{
if is_inline_equation(candidate) {
equations.push(candidate);
consumed += 1;
continue;
}
if consumed > 0 && is_inline_equation_quote_artifact(body, consumed) {
consumed += 1;
continue;
}
break;
}
if equations.is_empty() {
(equations, body)
} else {
(equations, &body[consumed..])
}
}
fn maximum_node_line(node: &Node) -> u32 {
node.children
.iter()
.map(maximum_node_line)
.fold(node.line, u32::max)
}
fn split_definition_terms(term: Vec<Inline>) -> Vec<Vec<Inline>> {
let mut terms = Vec::new();
let mut current = Vec::new();
for node in term {
if node == Inline::LineBreak {
if !current.is_empty() {
terms.push(std::mem::take(&mut current));
}
} else {
current.push(node);
}
}
if !current.is_empty() {
terms.push(current);
}
terms
}
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 = first_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_ir::SourceSpan>,
max_term_width: usize,
merge: DefinitionMerge,
) -> DefinitionLocation {
let block_index = output.len().saturating_sub(1);
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 = match merge {
DefinitionMerge::From(location)
if location.block == block_index
&& location.item < items.len()
&& items[location.item..]
.iter()
.all(|pending| pending.description.is_empty()) =>
{
Some(location.item)
}
DefinitionMerge::None | DefinitionMerge::From(_) => None,
};
if let Some(first_pending) = first_pending {
let pending_terms = items
.drain(first_pending..)
.flat_map(|pending| pending.terms);
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;
let item_index = items.len();
items.push(item);
DefinitionLocation {
block: block_index,
item: item_index,
}
} 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,
});
DefinitionLocation {
block: output.len() - 1,
item: 0,
}
}
}
fn update_man_definition_width(node: &Node, current_width: &mut usize) {
let head = first_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_ir::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())
}
#[cfg(test)]
mod tests {
use mant_ir::{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));
}
#[test]
fn extended_definition_terms_split_only_at_semantic_line_breaks() {
let terms = super::split_definition_terms(vec![
Inline::Text {
value: "first".to_owned(),
},
Inline::LineBreak,
Inline::Strong {
children: text("second"),
},
]);
assert_eq!(
terms,
[
text("first"),
vec![Inline::Strong {
children: text("second")
}]
]
);
}
}