use std::collections::HashMap;
use regex::Regex;
use tracing::debug;
use crate::{
classify::{ClassifyCtx, LineClass, classify_line_with_body, list_content_indent},
list_interrupt::interrupts_paragraph,
wrap::{FenceObservation, FenceTracker},
};
fn parse_numbered(line: &str) -> Option<(usize, &str, &str, &str)> {
static NUMBERED_RE: std::sync::LazyLock<Regex> = lazy_regex!(
r"^(\s*)(?:[1-9][0-9]*)\.(\s+)(.*)",
"numbered list item pattern should compile",
);
let cap = NUMBERED_RE.captures(line)?;
let indent_str = cap.get(1)?.as_str();
let indent = indent_len(indent_str);
let sep = cap.get(2)?.as_str();
let rest = cap.get(3)?.as_str();
Some((indent, indent_str, sep, rest))
}
fn content_column(indent: usize, number: usize, sep: &str, rest: &str) -> usize {
list_content_indent(&format!("{number}.{sep}{rest}"), indent)
}
fn relative_to(line: &str, indent: usize, strip: usize) -> String {
format!(
"{}{}",
" ".repeat(indent.saturating_sub(strip)),
line.trim_start()
)
}
fn holds_paragraph_text(line: &str) -> bool {
matches!(
classify_line_with_body(line.trim_start(), &ClassifyCtx::default()).class,
LineClass::ParagraphText | LineClass::ListItem
)
}
fn passes_through(fence: FenceObservation, line: &str) -> Option<bool> {
let is_blank = line.trim().is_empty();
(fence.is_fence_marker || fence.is_in_fence || is_blank).then_some(is_blank)
}
fn continues_paragraph(state: &ListState, line: &str) -> bool {
parse_numbered(line).is_some_and(|(indent, ..)| {
let starts_at_one = line.trim_start().split('.').next() == Some("1");
!starts_at_one && !state.has_list_at(indent)
})
}
fn prune_deeper(
indent: usize,
inclusive: bool,
indent_stack: &mut Vec<usize>,
counters: &mut HashMap<usize, usize>,
) {
while indent_stack
.last()
.is_some_and(|&d| if inclusive { d >= indent } else { d > indent })
{
if let Some(d) = indent_stack.pop() {
counters.remove(&d);
}
}
}
fn indent_len(indent: &str) -> usize {
indent
.chars()
.fold(0, |acc, ch| acc + if ch == '\t' { 4 } else { 1 })
}
fn leading_indent(line: &str) -> usize {
let indent_end = line
.char_indices()
.find(|&(_, c)| !c.is_whitespace())
.map_or_else(|| line.len(), |(i, _)| i);
indent_len(&line[..indent_end])
}
#[derive(Default)]
struct ListState {
indent_stack: Vec<usize>,
counters: HashMap<usize, usize>,
content_columns: HashMap<usize, usize>,
}
impl ListState {
fn prune_deeper(&mut self, indent: usize, inclusive: bool) {
prune_deeper(
indent,
inclusive,
&mut self.indent_stack,
&mut self.counters,
);
}
fn has_list_at(&self, indent: usize) -> bool { self.indent_stack.contains(&indent) }
fn next_number(&mut self, indent: usize) -> usize {
self.prune_deeper(indent, false);
if self.indent_stack.last().is_none_or(|&d| d < indent) {
self.indent_stack.push(indent);
}
let num = self.counters.entry(indent).or_insert(1);
let current = *num;
*num += 1;
current
}
fn containing_content_column(&self, indent: usize) -> usize {
self.indent_stack
.iter()
.filter_map(|depth| self.content_columns.get(depth).copied())
.filter(|&column| column <= indent)
.max()
.unwrap_or(0)
}
fn record_content_column(&mut self, indent: usize, column: usize) {
self.content_columns.insert(indent, column);
}
fn end_lists_at(&mut self, column: usize) {
debug!(
column,
indent_depths = self.indent_stack.len(),
"ending ordered lists whose items cannot contain the block"
);
while let Some(&depth) = self.indent_stack.last() {
let content = self
.content_columns
.get(&depth)
.copied()
.unwrap_or(depth + 1);
if content <= column {
break;
}
self.indent_stack.pop();
self.counters.remove(&depth);
self.content_columns.remove(&depth);
}
}
fn apply_block(&mut self, indent: usize, line: &str, prev_blank: bool) {
if prev_blank || interrupts_paragraph(line) {
self.end_lists_at(indent);
} else {
self.prune_deeper(indent, false);
}
}
}
#[must_use]
pub fn renumber_lists(lines: &[String]) -> Vec<String> {
let mut out = Vec::with_capacity(lines.len());
let mut state = ListState::default();
let mut fences = FenceTracker::default();
let mut prev_blank = lines.first().is_none_or(|l| l.trim().is_empty());
let mut prev_text = false;
for line in lines {
let fence = fences.observe_source_line(line);
if fence.is_fence_marker {
state.end_lists_at(leading_indent(line));
}
if let Some(is_blank) = passes_through(fence, line) {
out.push(line.clone());
prev_blank = is_blank;
prev_text = false;
continue;
}
if prev_text && continues_paragraph(&state, line) {
out.push(line.clone());
prev_blank = false;
continue;
}
if let Some((indent, indent_str, sep, rest)) = parse_numbered(line) {
let current = state.next_number(indent);
state.record_content_column(indent, content_column(indent, current, sep, rest));
out.push(format!("{indent_str}{current}.{sep}{rest}"));
prev_blank = false;
prev_text = true;
continue;
}
let indent = leading_indent(line);
let relative = relative_to(line, indent, state.containing_content_column(indent));
let classified = classify_line_with_body(&relative, &ClassifyCtx::default());
let prefix = &relative[..relative.len() - classified.body.len()];
if !prefix.contains('>')
&& matches!(
classified.class,
LineClass::AtxHeading | LineClass::ThematicBreak
)
{
state.end_lists_at(indent);
out.push(line.clone());
prev_blank = false;
prev_text = false;
continue;
}
state.apply_block(indent, line, prev_blank);
out.push(line.clone());
prev_blank = false;
prev_text = holds_paragraph_text(line);
}
out
}
#[cfg(test)]
#[path = "lists_tests.rs"]
mod tests;