use std::borrow::Cow;
use crate::{
classify::{
ClassifiedLine,
ClassifyCtx,
LineClass,
ListContinuationState,
classify_line_with_body,
is_canonical_break_line,
quote_depth,
structural_content_indent,
},
wrap::{BlockKind, FenceTracker, LinkReferenceMatcher, classify_residual_block},
};
mod item_stack;
use item_stack::{ItemStack, Placement};
pub const THEMATIC_BREAK_LEN: usize = 70;
static THEMATIC_BREAK_LINE: std::sync::LazyLock<String> =
std::sync::LazyLock::new(|| "_".repeat(THEMATIC_BREAK_LEN));
fn holds_paragraph_text(class: LineClass) -> bool {
matches!(class, LineClass::ParagraphText | LineClass::ListItem)
}
struct BreakLineState {
previous: Option<(LineClass, usize, Option<usize>)>,
lists: ListContinuationState,
links: LinkReferenceMatcher,
}
impl BreakLineState {
fn new(links: LinkReferenceMatcher) -> Self {
Self {
previous: None,
lists: ListContinuationState::default(),
links,
}
}
fn reset(&mut self) {
self.previous = None;
self.lists.reset();
}
fn context(&self, line: &str, first_pass: &ClassifiedLine<'_>, depth: usize) -> ClassifyCtx {
if self.continues_paragraph(line, first_pass, depth) {
ClassifyCtx::following(LineClass::ParagraphText, true)
} else {
ClassifyCtx::default()
}
}
fn continues_paragraph(
&self,
line: &str,
classified: &ClassifiedLine<'_>,
depth: usize,
) -> bool {
self.previous
.is_some_and(|(class, old_depth, continuation_indent)| {
(class == LineClass::ParagraphText
|| (class == LineClass::ListItem && continuation_indent.is_some()))
&& old_depth == depth
&& continuation_indent.is_none_or(|indent| {
structural_content_indent(line, classified.body) >= indent
})
})
}
fn observe(&mut self, line: &str, classified: &ClassifiedLine<'_>, depth: usize) {
let residual = if classified.class == LineClass::ParagraphText {
classify_residual_block(classified.body.trim(), self.links)
} else {
None
};
let is_paragraph_link = residual == Some(BlockKind::LinkReferenceDefinition)
&& self.previous.is_some_and(|(class, old_depth, _)| {
holds_paragraph_text(class) && old_depth == depth
});
let is_residual_block = residual.is_some() && !is_paragraph_link;
let continuation_indent = if is_residual_block {
self.lists.reset();
None
} else {
self.lists.observe(line, classified)
};
self.previous = if classified.class == LineClass::Blank || is_residual_block {
None
} else {
Some((classified.class, depth, continuation_indent))
};
}
}
#[must_use]
pub(crate) fn canonical_break() -> &'static str { THEMATIC_BREAK_LINE.as_str() }
#[must_use]
pub fn format_breaks(lines: &[String]) -> Vec<Cow<'_, str>> {
let mut out = Vec::with_capacity(lines.len());
let mut fences = FenceTracker::default();
let mut state = BreakLineState::new(LinkReferenceMatcher::production());
let mut items = ItemStack::default();
for line in lines {
let fence = fences.observe_source_line(line);
if fence.is_fence_marker || fence.is_in_fence {
state.reset();
items.close_outdented(line);
out.push(Cow::Borrowed(line.as_str()));
continue;
}
let first_pass = classify_line_with_body(line, &ClassifyCtx::default());
let prefix_len = line.len() - first_pass.body.len();
let prefix = &line[..prefix_len];
let depth = quote_depth(prefix);
let context = state.context(line, &first_pass, depth);
let classified = if context == ClassifyCtx::default() {
first_pass
} else {
classify_line_with_body(line, &context)
};
state.observe(line, &classified, depth);
let placement = place_in_item(&mut items, line, prefix);
if let Some(indentation) = placement
.as_ref()
.and_then(|p| item_break(p, line, &context))
{
out.push(canonicalized_break(indentation, true));
} else if placement.is_none() && is_canonical_break_line(line, &context) {
out.push(canonicalized_break(prefix, false));
} else {
out.push(Cow::Borrowed(line.as_str()));
}
}
out
}
fn place_in_item(items: &mut ItemStack, line: &str, prefix: &str) -> Option<Placement> {
if line.trim().is_empty() {
items.blank();
return None;
}
if prefix.contains('>') {
items.close_outdented(line);
return None;
}
Some(items.place(line)).filter(|placement| placement.container > 0)
}
fn item_break<'line>(
placement: &Placement,
line: &'line str,
context: &ClassifyCtx,
) -> Option<&'line str> {
is_canonical_break_line(&placement.relative, context)
.then(|| &line[..line.len() - line.trim_start().len()])
}
fn keeps_prefix(prefix: &str, is_in_item: bool) -> bool {
let is_indented_in_item = is_in_item && !prefix.is_empty();
prefix.contains('>') || is_indented_in_item
}
fn canonicalized_break(prefix: &str, is_in_item: bool) -> Cow<'static, str> {
if keeps_prefix(prefix, is_in_item) {
Cow::Owned(format!("{prefix}{}", canonical_break()))
} else {
Cow::Borrowed(canonical_break())
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod prop_tests;