use libmandoc_rs::{AuthorMode, DisplayKind, Node, NodeKind, NormalizedFont};
use mant_ir::{Block, Inline, Section};
use super::{
LoweringContext, first_part_children,
inline::{
FilledBoundary, InlineBuilder, is_enclosure_macro, lower_inline_nodes,
lower_inline_nodes_with_spacing, lower_man_link, plain_text, spacing_after_node,
updated_spacing,
},
layout::{
add_leading_spacing, display_indent, layout, layout_with_spacing,
normalize_explicit_vertical_spacing, section_spacing, set_block_spacing,
update_paragraph_distance, vertical_distance_lines,
},
part_child_groups,
roff_escape::visible_text,
source_span,
};
mod lists;
mod preformatted;
mod tables;
use lists::{
ManAliasState, ManDefinitionState, lower_man_definition as lower_man_definition_block,
lower_mdoc_list,
};
use preformatted::{preformatted_blocks, style_preformatted_inlines};
use tables::{TableEmbedding, append_table_row, table_embeddings};
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
}
pub(super) fn lower_root_blocks(root: &Node, context: &LoweringContext<'_>) -> Vec<Block> {
let mut output = Vec::new();
let mut paragraph_distance = 1;
let mut start = 0;
for (index, node) in root.children.iter().enumerate() {
if !is_section(node, true) && !is_section(node, false) {
continue;
}
output.extend(lower_blocks(
&root.children[start..index],
context,
0,
&mut paragraph_distance,
));
start = index + 1;
}
output.extend(lower_blocks(
&root.children[start..],
context,
0,
&mut paragraph_distance,
));
output
}
fn lower_section(
node: &Node,
context: &mut LoweringContext<'_>,
spacing_before_lines: u16,
paragraph_distance: &mut u16,
) -> Section {
let heading = lower_inline_nodes(
first_part_children(node, NodeKind::Head),
context.default_name,
);
let title = plain_text(&heading).trim().to_owned();
let id = context.section_id(&title);
let body = first_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: id.into(),
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> {
lower_blocks_with_spacing(nodes, context, indent_columns, paragraph_distance, true)
}
fn lower_blocks_with_spacing(
nodes: &[Node],
context: &LoweringContext<'_>,
indent_columns: u16,
paragraph_distance: &mut u16,
spacing_enabled: bool,
) -> Vec<Block> {
lower_blocks_onto(
nodes,
context,
indent_columns,
paragraph_distance,
spacing_enabled,
Vec::new(),
)
}
fn lower_blocks_onto(
nodes: &[Node],
context: &LoweringContext<'_>,
indent_columns: u16,
paragraph_distance: &mut u16,
spacing_enabled: bool,
output: Vec<Block>,
) -> Vec<Block> {
let (table_embeddings, embedded_nodes) = table_embeddings(nodes, context);
let mut lowerer = BlockLowerer::new(
context,
indent_columns,
paragraph_distance,
spacing_enabled,
output,
);
for (index, node) in nodes.iter().enumerate() {
if !embedded_nodes[index] && !is_inline_equation_quote_artifact(nodes, index) {
if follows_inline_equation_punctuation(nodes, index) {
lowerer.state.tighten_next_boundary();
}
lowerer.push(node, table_embeddings[index].as_ref());
}
}
lowerer.finish()
}
struct BlockLowerer<'a, 'source> {
context: &'a LoweringContext<'source>,
indent_columns: u16,
paragraph_distance: &'a mut u16,
state: BlockState,
definition_hanging_width: usize,
split_authors: bool,
synopsis_return_type_open: bool,
man_alias_state: ManAliasState,
}
impl<'a, 'source> BlockLowerer<'a, 'source> {
fn new(
context: &'a LoweringContext<'source>,
indent_columns: u16,
paragraph_distance: &'a mut u16,
spacing_enabled: bool,
output: Vec<Block>,
) -> Self {
Self {
context,
indent_columns,
paragraph_distance,
state: BlockState::with_output(indent_columns, spacing_enabled, output),
definition_hanging_width: 7,
split_authors: false,
synopsis_return_type_open: false,
man_alias_state: ManAliasState::None,
}
}
fn push(&mut self, node: &Node, table_embedding: Option<&TableEmbedding<'_>>) {
if self.consume_control_or_empty_block(node) {
return;
}
if self.push_no_fill_lines(node) {
return;
}
self.state.flush_preformatted();
if self.push_mdoc_synopsis_declaration(node) {
self.state.inherit_spacing(spacing_after_node(
node,
self.state.spacing_enabled(),
self.context.default_name,
));
return;
}
if node.flags.delimiter_close
&& participates_in_inline_flow(node)
&& self.state.paragraph.is_empty()
{
let tail = lower_inline_nodes(std::slice::from_ref(node), self.context.default_name);
if append_to_last_inline_block(&mut self.state.output, &tail) {
return;
}
}
if node.macro_name.as_deref() == Some("Pp") {
self.state.flush_paragraph();
if !self.state.output.is_empty() {
self.state.output.push(Block::VerticalSpace {
lines: 1,
source: source_span(node),
});
}
} else if node.macro_name.as_deref() == Some("sp") {
self.state.flush_paragraph();
if let Some(lines) = vertical_distance_lines(node).filter(|lines| *lines > 0) {
self.state.output.push(Block::VerticalSpace {
lines,
source: source_span(node),
});
}
} else if node.macro_name.as_deref() == Some("br") {
self.state.hard_break();
} else if matches!(node.macro_name.as_deref(), Some("UR" | "MT")) {
let spacing_enabled = self.state.spacing_enabled();
push_man_link(
&mut self.state,
node,
self.context.default_name,
spacing_enabled,
);
} else if participates_in_inline_flow(node) {
self.push_inline_node(node);
} else {
self.state.flush_paragraph();
let spacing_enabled = self.state.spacing_enabled();
StructuralLowerer {
context: self.context,
indent_columns: self.indent_columns,
paragraph_distance: self.paragraph_distance,
output: &mut self.state.output,
definition_hanging_width: &mut self.definition_hanging_width,
man_alias_state: &mut self.man_alias_state,
spacing_enabled,
}
.push(node, table_embedding);
}
self.state.inherit_spacing(spacing_after_node(
node,
self.state.spacing_enabled(),
self.context.default_name,
));
}
fn consume_control_or_empty_block(&mut self, node: &Node) -> bool {
if consume_block_control(
node,
self.context,
&mut self.state,
self.paragraph_distance,
&mut self.split_authors,
) || node.flags.no_print
|| node.kind == NodeKind::Comment
|| is_section(node, false)
|| is_nonprinting_request(node)
{
return true;
}
if node.kind == NodeKind::Equation
&& node
.equation
.as_deref()
.is_none_or(|value| value.trim().is_empty())
{
return true;
}
if node.kind == NodeKind::Text
&& node.text.as_deref().is_some_and(str::is_empty)
&& !node.flags.no_fill
{
self.state.flush_paragraph();
self.state.output.push(Block::VerticalSpace {
lines: 1,
source: source_span(node),
});
return true;
}
false
}
fn finish(self) -> Vec<Block> {
self.state.finish()
}
fn push_no_fill_lines(&mut self, node: &Node) -> bool {
let Some(lines) = lower_no_fill_lines(node, self.context.default_name) else {
return false;
};
for line in lines {
self.state.push_preformatted(
line.nodes,
line.source,
line.continues_line,
self.context,
);
}
true
}
fn push_inline_node(&mut self, node: &Node) {
let source = source_span(node);
if node.flags.delimiter_close || node.macro_name.as_deref() == Some("Ns") {
self.state.tighten_next_boundary();
}
self.state.push_inline(
lower_inline_nodes_with_spacing(
std::slice::from_ref(node),
self.context.default_name,
self.state.spacing_enabled(),
),
source,
starts_indented_filled_line(node),
ends_with_line_continuation(node),
);
if node.macro_name.as_deref() == Some("Pf") {
self.state.tighten_next_boundary();
}
}
fn push_mdoc_synopsis_declaration(&mut self, node: &Node) -> bool {
let Some(role) = mdoc_synopsis_declaration_role(node) else {
if self.synopsis_return_type_open {
self.state.flush_paragraph();
self.synopsis_return_type_open = false;
}
return false;
};
match role {
SynopsisDeclarationRole::ReturnType => {
self.state.flush_paragraph();
self.push_inline_node(node);
self.synopsis_return_type_open = true;
}
SynopsisDeclarationRole::Function => {
if !self.synopsis_return_type_open {
self.state.flush_paragraph();
}
self.push_inline_node(node);
self.state.flush_paragraph();
self.synopsis_return_type_open = false;
}
SynopsisDeclarationRole::Standalone => {
self.state.flush_paragraph();
self.push_inline_node(node);
self.state.flush_paragraph();
self.synopsis_return_type_open = false;
}
}
true
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SynopsisDeclarationRole {
Standalone,
ReturnType,
Function,
}
fn mdoc_synopsis_declaration_role(node: &Node) -> Option<SynopsisDeclarationRole> {
let synopsis_pretty = node.flags.synopsis_pretty
|| node
.children
.iter()
.any(|child| child.flags.synopsis_pretty);
if !synopsis_pretty {
return None;
}
match node.macro_name.as_deref()? {
"Fd" | "In" | "Vt" => Some(SynopsisDeclarationRole::Standalone),
"Ft" => Some(SynopsisDeclarationRole::ReturnType),
"Fn" | "Fo" => Some(SynopsisDeclarationRole::Function),
_ => None,
}
}
fn consume_block_control(
node: &Node,
context: &LoweringContext<'_>,
state: &mut BlockState,
paragraph_distance: &mut u16,
split_authors: &mut bool,
) -> bool {
match node.macro_name.as_deref() {
Some("PD") => update_paragraph_distance(node, paragraph_distance),
Some("An") => match node.author_mode {
Some(AuthorMode::Split) => *split_authors = true,
Some(AuthorMode::NoSplit) => *split_authors = false,
None if *split_authors => {
state.hard_break();
return false;
}
None => return false,
},
Some("Sm") => {
let setting = plain_text(&lower_inline_nodes(&node.children, context.default_name));
state.set_spacing(setting.trim());
}
_ => return false,
}
true
}
fn push_man_link(
state: &mut BlockState,
node: &Node,
default_name: Option<&str>,
spacing_enabled: bool,
) {
state.push_inline(
lower_man_link(node, default_name, spacing_enabled),
source_span(node),
starts_indented_filled_line(node),
ends_with_line_continuation(node),
);
}
struct LoweredNoFillLine {
nodes: Vec<Inline>,
source: Option<mant_ir::SourceSpan>,
continues_line: bool,
}
fn lower_no_fill_lines(node: &Node, default_name: Option<&str>) -> Option<Vec<LoweredNoFillLine>> {
if node.flags.no_fill && participates_in_inline_flow(node) {
return Some(vec![LoweredNoFillLine {
nodes: lower_inline_nodes(std::slice::from_ref(node), default_name),
source: source_span(node),
continues_line: ends_with_line_continuation(node),
}]);
}
let body = first_part_children(node, NodeKind::Body);
if node.macro_name.as_deref() != Some("SY") || !body.iter().any(|child| child.flags.no_fill) {
return None;
}
let mut lines = Vec::new();
let head = lower_inline_nodes(first_part_children(node, NodeKind::Head), default_name);
if !head.is_empty() {
lines.push(LoweredNoFillLine {
nodes: vec![Inline::Strong { children: head }],
source: source_span(node),
continues_line: first_part_children(node, NodeKind::Head)
.last()
.is_some_and(ends_with_line_continuation),
});
}
for child in body {
let line = lower_inline_nodes(std::slice::from_ref(child), default_name);
if !line.is_empty() {
lines.push(LoweredNoFillLine {
nodes: line,
source: source_span(child),
continues_line: ends_with_line_continuation(child),
});
}
}
Some(lines)
}
struct StructuralLowerer<'a, 'source, 'state> {
context: &'a LoweringContext<'source>,
indent_columns: u16,
paragraph_distance: &'state mut u16,
output: &'state mut Vec<Block>,
definition_hanging_width: &'state mut usize,
man_alias_state: &'state mut ManAliasState,
spacing_enabled: bool,
}
impl StructuralLowerer<'_, '_, '_> {
fn lower_man_definition(&mut self, node: &Node) {
lower_man_definition_block(
node,
self.context,
self.indent_columns,
ManDefinitionState {
paragraph_distance: self.paragraph_distance,
output: self.output,
definition_hanging_width: self.definition_hanging_width,
alias_state: self.man_alias_state,
},
self.spacing_enabled,
);
}
fn push(&mut self, node: &Node, table_embedding: Option<&TableEmbedding<'_>>) {
if !matches!(node.macro_name.as_deref(), Some("TP" | "IP" | "TQ")) {
*self.man_alias_state = ManAliasState::None;
}
if self.lower_transparent_container(node) {
return;
}
match node.macro_name.as_deref() {
Some("TP" | "IP" | "TQ") => self.lower_man_definition(node),
Some("Bl") => {
let mut block = lower_mdoc_list(
node,
self.context,
self.indent_columns,
self.paragraph_distance,
self.spacing_enabled,
);
if !self.output.is_empty() && !node.compact {
set_block_spacing(&mut block, 1);
}
self.output.push(block);
}
Some("Bd" | "D1" | "Dl") => {
let mut nested = preformatted_blocks(
node,
self.context,
self.indent_columns + display_indent(node),
self.spacing_enabled,
);
if node.macro_name.as_deref() == Some("Bd")
&& !self.output.is_empty()
&& !node.compact
{
add_leading_spacing(&mut nested, 1);
}
self.output.extend(nested);
}
Some("Rs") => {
let mut children = lower_inline_nodes_with_spacing(
first_part_children(node, NodeKind::Body),
self.context.default_name,
self.spacing_enabled,
);
if !children.is_empty() {
append_bibliography_period(&mut children);
self.output.push(Block::Paragraph {
children,
layout: layout_with_spacing(
self.indent_columns,
u16::from(!self.output.is_empty()),
),
source: source_span(node),
});
}
}
Some("SY" | "Nm") => lower_synopsis_head(
self.output,
node,
self.context,
self.indent_columns,
self.paragraph_distance,
self.spacing_enabled,
),
Some("Fo") => lower_mdoc_function(
self.output,
node,
self.context,
self.indent_columns,
self.spacing_enabled,
),
_ if node.kind == NodeKind::Table => append_table_row(
self.output,
node,
self.context,
self.indent_columns,
table_embedding,
),
_ if node.kind == NodeKind::Equation => {
self.output.push(equation_block(node, self.indent_columns));
}
_ => lower_structural_fallback(
self.output,
node,
self.context,
self.indent_columns,
self.paragraph_distance,
self.spacing_enabled,
),
}
}
fn lower_transparent_container(&mut self, node: &Node) -> bool {
match node.macro_name.as_deref() {
Some("PP" | "P" | "LP" | "HP") => {
let spacing_before = if self.output.is_empty() {
0
} else {
*self.paragraph_distance
};
let nested = lower_blocks_with_spacing(
first_part_children(node, NodeKind::Body),
self.context,
self.indent_columns,
self.paragraph_distance,
self.spacing_enabled,
);
extend_blocks_with_spacing(self.output, nested, spacing_before);
}
Some("Bf") => {
let mut nested = lower_blocks_with_spacing(
first_part_children(node, NodeKind::Body),
self.context,
self.indent_columns,
self.paragraph_distance,
self.spacing_enabled,
);
if let Some(font) = node.font {
apply_normalized_font(&mut nested, font);
}
extend_transparent_blocks(self.output, nested, *self.paragraph_distance);
}
Some("Bd") if node.display_kind == Some(DisplayKind::Filled) => {
let spacing_before = u16::from(!self.output.is_empty() && !node.compact);
let nested = lower_blocks_with_spacing(
first_part_children(node, NodeKind::Body),
self.context,
self.indent_columns + display_indent(node),
self.paragraph_distance,
self.spacing_enabled,
);
extend_blocks_with_spacing(self.output, nested, spacing_before);
}
Some("RS") => {
let output = std::mem::take(self.output);
*self.output = lower_blocks_onto(
first_part_children(node, NodeKind::Body),
self.context,
self.indent_columns + 4,
self.paragraph_distance,
self.spacing_enabled,
output,
);
}
_ => return false,
}
true
}
}
fn append_bibliography_period(children: &mut Vec<Inline>) {
let text = plain_text(children);
if !text.trim_end().ends_with(['.', '!', '?']) {
children.push(Inline::Text { value: ".".into() });
}
}
fn lower_structural_fallback(
output: &mut Vec<Block>,
node: &Node,
context: &LoweringContext<'_>,
indent_columns: u16,
paragraph_distance: &mut u16,
spacing_enabled: bool,
) {
let heads = part_child_groups(node, NodeKind::Head).collect::<Vec<_>>();
let bodies = part_child_groups(node, NodeKind::Body).collect::<Vec<_>>();
let tails = part_child_groups(node, NodeKind::Tail).collect::<Vec<_>>();
if heads
.iter()
.chain(&tails)
.any(|part| parts_have_visible_text(part, context.default_name))
|| bodies.len() > 1
{
context.warn_unhandled_structural_parts(node);
}
if bodies.is_empty() {
output.extend(lower_blocks_with_spacing(
node.children.as_slice(),
context,
indent_columns,
paragraph_distance,
spacing_enabled,
));
} else {
for body in bodies {
output.extend(lower_blocks_with_spacing(
body,
context,
indent_columns,
paragraph_distance,
spacing_enabled,
));
}
}
}
fn parts_have_visible_text(nodes: &[Node], default_name: Option<&str>) -> bool {
!plain_text(&lower_inline_nodes(nodes, default_name))
.trim()
.is_empty()
}
fn lower_mdoc_function(
output: &mut Vec<Block>,
node: &Node,
context: &LoweringContext<'_>,
indent_columns: u16,
spacing_enabled: bool,
) {
let children = lower_inline_nodes_with_spacing(
std::slice::from_ref(node),
context.default_name,
spacing_enabled,
);
if children.is_empty() {
return;
}
output.push(Block::Paragraph {
children,
layout: layout(indent_columns),
source: source_span(node),
});
}
fn equation_block(node: &Node, indent_columns: u16) -> Block {
Block::Equation {
value: visible_text(node.equation.as_deref().unwrap_or_default()),
display: true,
layout: layout(indent_columns),
source: source_span(node),
}
}
fn is_inline_equation(node: &Node) -> bool {
node.kind == NodeKind::Equation
&& !node.flags.line_start
&& node
.equation
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
}
fn is_inline_equation_quote_artifact(nodes: &[Node], index: usize) -> bool {
let Some(node) = nodes.get(index) else {
return false;
};
let Some(previous) = index.checked_sub(1).and_then(|index| nodes.get(index)) else {
return false;
};
is_inline_equation(previous)
&& previous.line == node.line
&& node.kind == NodeKind::Text
&& node
.text
.as_deref()
.is_some_and(|text| visible_text(text).trim() == "\"")
}
fn follows_inline_equation_punctuation(nodes: &[Node], index: usize) -> bool {
let Some(node) = nodes.get(index) else {
return false;
};
let Some(previous) = index.checked_sub(1).and_then(|index| nodes.get(index)) else {
return false;
};
is_inline_equation(previous)
&& previous.line == node.line
&& node.kind == NodeKind::Text
&& node
.text
.as_deref()
.map(visible_text)
.and_then(|text| text.chars().next())
.is_some_and(|character| matches!(character, '.' | ',' | ':' | ';' | '!' | '?'))
}
fn lower_synopsis_head(
output: &mut Vec<Block>,
node: &Node,
context: &LoweringContext<'_>,
indent_columns: u16,
paragraph_distance: &mut u16,
spacing_enabled: bool,
) {
let head = lower_inline_nodes_with_spacing(
first_part_children(node, NodeKind::Head),
context.default_name,
spacing_enabled,
);
let mut nested = lower_blocks_with_spacing(
first_part_children(node, NodeKind::Body),
context,
indent_columns,
paragraph_distance,
spacing_enabled,
);
if head.is_empty() {
output.extend(nested);
return;
}
let head = vec![Inline::Strong { children: head }];
if let Some(Block::Paragraph {
children, source, ..
}) = nested.first_mut()
{
let body = std::mem::take(children);
let mut synopsis = InlineBuilder::with_spacing(spacing_enabled);
synopsis.append(head);
synopsis.append(body);
*children = synopsis.finish();
*source = source_span(node);
output.extend(nested);
return;
}
output.push(Block::Paragraph {
children: head,
layout: layout(indent_columns),
source: source_span(node),
});
output.extend(nested);
}
fn apply_normalized_font(blocks: &mut [Block], font: NormalizedFont) {
for block in blocks {
match block {
Block::Paragraph { children, .. } | Block::Preformatted { children, .. } => {
let content = std::mem::take(children);
if content.is_empty() {
continue;
}
*children = style_preformatted_inlines(content, font);
}
Block::List { items, .. } => {
for item in items {
apply_normalized_font(&mut item.blocks, font);
}
}
Block::DefinitionList { items, .. } => {
for item in items {
for term in &mut item.terms {
let content = std::mem::take(term);
if !content.is_empty() {
*term = style_preformatted_inlines(content, font);
}
}
apply_normalized_font(&mut item.description, font);
}
}
Block::Table { rows, .. } => {
for cell in rows.iter_mut().flat_map(|row| &mut row.cells) {
apply_normalized_font(&mut cell.blocks, font);
}
}
Block::Equation { .. }
| Block::VerticalSpace { .. }
| Block::ThematicBreak { .. }
| Block::Unsupported { .. } => {}
}
}
}
fn append_to_last_inline_block(blocks: &mut [Block], tail: &[Inline]) -> bool {
for block in blocks.iter_mut().rev() {
match block {
Block::Paragraph { children, .. } | Block::Preformatted { children, .. } => {
children.extend_from_slice(tail);
return true;
}
Block::List { items, .. } => {
if items
.last_mut()
.is_some_and(|item| append_to_last_inline_block(&mut item.blocks, tail))
{
return true;
}
}
Block::DefinitionList { items, .. } => {
if items.last_mut().is_some_and(|item| {
append_to_last_inline_block(&mut item.description, tail)
|| item.terms.last_mut().is_some_and(|term| {
term.extend_from_slice(tail);
true
})
}) {
return true;
}
}
Block::Table { rows, .. } => {
if rows
.last_mut()
.and_then(|row| row.cells.last_mut())
.is_some_and(|cell| append_to_last_inline_block(&mut cell.blocks, tail))
{
return true;
}
}
Block::Equation { .. }
| Block::VerticalSpace { .. }
| Block::ThematicBreak { .. }
| Block::Unsupported { .. } => {}
}
}
false
}
struct BlockState {
output: Vec<Block>,
paragraph: InlineBuilder,
paragraph_source: Option<mant_ir::SourceSpan>,
paragraph_last_line: Option<u32>,
preformatted: Vec<Inline>,
pre_source: Option<mant_ir::SourceSpan>,
preformatted_last_line: Option<u32>,
preformatted_tight_boundary: bool,
indent_columns: u16,
spacing_enabled: bool,
}
impl BlockState {
const fn with_output(indent_columns: u16, spacing_enabled: bool, output: Vec<Block>) -> Self {
Self {
output,
paragraph: InlineBuilder::with_spacing(spacing_enabled),
paragraph_source: None,
paragraph_last_line: None,
preformatted: Vec::new(),
pre_source: None,
preformatted_last_line: None,
preformatted_tight_boundary: false,
indent_columns,
spacing_enabled,
}
}
const fn spacing_enabled(&self) -> bool {
self.spacing_enabled
}
fn set_spacing(&mut self, setting: &str) {
self.paragraph.set_spacing(setting);
self.spacing_enabled = updated_spacing(self.spacing_enabled, setting);
}
fn inherit_spacing(&mut self, spacing_enabled: bool) {
if spacing_enabled == self.spacing_enabled {
return;
}
self.paragraph.inherit_spacing(spacing_enabled);
self.spacing_enabled = spacing_enabled;
}
fn push_inline(
&mut self,
nodes: Vec<Inline>,
source: Option<mant_ir::SourceSpan>,
starts_indented_line: bool,
continues_line: bool,
) {
if nodes.is_empty() {
if continues_line {
self.paragraph.tighten_next_boundary();
}
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 self.paragraph.has_tight_boundary() || !crossed_source_line {
FilledBoundary::SameLine
} else if starts_indented_line {
FilledBoundary::LineBreak
} else {
FilledBoundary::Word
};
self.paragraph.append_filled(nodes, boundary);
if continues_line {
self.paragraph.tighten_next_boundary();
}
if source_line.is_some() {
self.paragraph_last_line = source_line;
}
}
fn hard_break(&mut self) {
self.paragraph.hard_break();
}
fn tighten_next_boundary(&mut self) {
self.paragraph.tighten_next_boundary();
}
fn push_preformatted(
&mut self,
nodes: Vec<Inline>,
source: Option<mant_ir::SourceSpan>,
continues_line: bool,
context: &LoweringContext<'_>,
) {
self.flush_paragraph();
if nodes.is_empty() {
if continues_line {
self.preformatted_tight_boundary = true;
}
return;
}
if !self.preformatted.is_empty() && !self.preformatted_tight_boundary {
self.preformatted.push(Inline::LineBreak);
let blank_rows = context.no_fill_blank_rows_between(
self.preformatted_last_line,
source.map(|span| span.line),
);
self.preformatted.extend(std::iter::repeat_n(
Inline::LineBreak,
usize::from(blank_rows),
));
}
self.preformatted.extend(nodes);
self.preformatted_tight_boundary = continues_line;
if let Some(source_line) = source.map(|span| span.line) {
self.preformatted_last_line = Some(source_line);
}
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.spacing_enabled,
);
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,
);
self.preformatted_last_line = None;
self.preformatted_tight_boundary = false;
}
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 ends_with_line_continuation(node: &Node) -> bool {
if node.flags.no_print || node.kind == NodeKind::Comment {
return false;
}
if node.kind == NodeKind::Text {
return node.flags.line_continuation;
}
node.children
.iter()
.rev()
.find(|child| !child.flags.no_print && child.kind != NodeKind::Comment)
.is_some_and(ends_with_line_continuation)
}
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 flush_paragraph(
output: &mut Vec<Block>,
paragraph: &mut InlineBuilder,
source: &mut Option<mant_ir::SourceSpan>,
indent_columns: u16,
spacing_enabled: bool,
) {
let current =
std::mem::replace(paragraph, InlineBuilder::with_spacing(spacing_enabled)).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_ir::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 participates_in_inline_flow(node: &Node) -> bool {
matches!(node.kind, NodeKind::Text | NodeKind::Element)
|| is_inline_equation(node)
|| is_enclosure_macro(node.macro_name.as_deref())
|| node.macro_name.as_deref() == Some("Nd")
}
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" | "ti")
)
}
#[cfg(test)]
mod tests {
use mant_ir::{Block, Inline, SourceSpan};
use super::{BlockState, LoweringContext, layout, plain_text};
fn text(value: &str) -> Vec<Inline> {
vec![Inline::Text {
value: value.to_owned(),
}]
}
const fn source(line: u32) -> SourceSpan {
SourceSpan {
byte_range: None,
line,
column: 1,
end_line: None,
end_column: None,
}
}
#[test]
fn block_state_preserves_filled_line_boundaries_and_continuations() {
let mut state = BlockState::with_output(3, true, Vec::new());
state.push_inline(text("alpha"), Some(source(1)), false, false);
state.push_inline(text("beta"), Some(source(2)), false, false);
state.push_inline(text("gamma"), Some(source(3)), true, false);
state.push_inline(text("delta"), Some(source(4)), false, true);
state.push_inline(text("epsilon"), Some(source(5)), false, false);
let output = state.finish();
let [
Block::Paragraph {
children,
layout: paragraph_layout,
source: paragraph_source,
},
] = output.as_slice()
else {
panic!("expected one filled paragraph, got {output:?}");
};
assert_eq!(plain_text(children), "alpha beta\ngamma deltaepsilon");
assert_eq!(
children
.iter()
.filter(|inline| matches!(inline, Inline::LineBreak))
.count(),
1
);
assert_eq!(*paragraph_layout, layout(3));
assert_eq!(*paragraph_source, Some(source(1)));
}
#[test]
fn block_state_flushes_paragraph_before_tight_preformatted_lines() {
let context = LoweringContext::new(None, None);
let mut state = BlockState::with_output(2, true, Vec::new());
state.push_inline(text("prose"), Some(source(1)), false, false);
state.push_preformatted(text("first"), Some(source(3)), false, &context);
state.push_preformatted(text("second"), Some(source(4)), true, &context);
state.push_preformatted(text("third"), Some(source(5)), false, &context);
let output = state.finish();
let [
Block::Paragraph {
children: paragraph,
layout: paragraph_layout,
source: paragraph_source,
},
Block::Preformatted {
children: preformatted,
language,
layout: preformatted_layout,
source: preformatted_source,
},
] = output.as_slice()
else {
panic!("expected prose followed by one preformatted block, got {output:?}");
};
assert_eq!(plain_text(paragraph), "prose");
assert_eq!(plain_text(preformatted), "first\nsecondthird");
assert_eq!(
preformatted
.iter()
.filter(|inline| matches!(inline, Inline::LineBreak))
.count(),
1
);
assert_eq!(*paragraph_layout, layout(2));
assert_eq!(*preformatted_layout, layout(2));
assert_eq!(*paragraph_source, Some(source(1)));
assert_eq!(*preformatted_source, Some(source(3)));
assert_eq!(*language, None);
}
}