use regex::Regex;
use tracing::debug;
#[must_use]
pub fn split_cells(line: &str) -> Vec<String> {
let trimmed = line.trim().trim_start_matches('|');
let trimmed = match trimmed.strip_suffix('|') {
Some(without_pipe)
if without_pipe
.chars()
.rev()
.take_while(|character| *character == '\\')
.count()
% 2
== 0 =>
{
without_pipe
}
_ => trimmed,
};
let mut cells = Vec::new();
let mut cell = String::new();
let mut characters = trimmed.chars().peekable();
while let Some(character) = characters.next() {
match character {
'\\' if characters.peek() == Some(&'|') => {
let _pipe = characters.next();
cell.push('|');
}
'|' => {
cells.push(cell.trim().to_string());
cell.clear();
}
_ => cell.push(character),
}
}
cells.push(cell.trim().to_string());
cells
}
pub(crate) fn format_separator_cells(widths: &[usize], sep_cells: &[String]) -> Vec<String> {
if sep_cells.len() != widths.len() {
return Vec::new();
}
sep_cells
.iter()
.enumerate()
.map(|(i, cell)| {
let trimmed = cell.trim();
let left = trimmed.starts_with(':');
let right = trimmed.ends_with(':');
let mut dashes = "-".repeat(widths[i].max(3));
if left {
dashes.remove(0);
dashes.insert(0, ':');
}
if right {
dashes.pop();
dashes.push(':');
}
dashes
})
.collect()
}
fn sep_index_within(idx: Option<usize>, len: usize) -> Option<usize> {
match idx {
Some(i) if i < len => Some(i),
_ => None,
}
}
fn rows_mismatched(rows: &[Vec<String>], split_within_line: bool) -> bool {
if split_within_line {
return false;
}
let Some(first_len) = rows.first().map(Vec::len) else {
return false;
};
rows.iter()
.skip(1)
.any(|row| row.len() != first_len && !row.iter().all(|c| SEP_RE.is_match(c)))
}
pub(crate) static SEP_RE: std::sync::LazyLock<Regex> = lazy_regex!(
r"^[\s|:-]+$",
"Markdown table separator row pattern should compile",
);
pub(crate) static SEP_CELL_RE: std::sync::LazyLock<Regex> = lazy_regex!(
r"^:?-+:?$",
"Markdown table separator cell pattern should compile",
);
pub(crate) fn is_delimiter_cell(payload: &str) -> bool { SEP_CELL_RE.is_match(payload.trim()) }
struct ParsedTable {
output_rows: Vec<Vec<String>>,
sep_cells: Option<Vec<String>>,
max_cols: usize,
}
fn extract_indent_and_trim(lines: &[String]) -> (String, Vec<String>) {
let indent = crate::textproc::leading_indent(&lines[0]).to_string();
let trimmed = lines
.iter()
.map(|l| l.trim().to_string())
.filter(|l| !l.trim_start().starts_with("\\-"))
.collect();
(indent, trimmed)
}
fn is_delimiter_row(line: &str) -> bool {
SEP_RE.is_match(line) && split_cells(line).iter().all(|cell| is_delimiter_cell(cell))
}
fn extract_separator_line(lines: &mut Vec<String>) -> Option<String> {
let sep_idx = lines.iter().position(|l| is_delimiter_row(l));
sep_idx.map(|idx| lines.remove(idx))
}
fn is_pipe_only(line: &str) -> bool {
line.contains('|') && line.chars().all(|ch| ch == '|' || ch.is_whitespace())
}
fn parse_and_validate(trimmed: &[String], sep_line: Option<&String>) -> Option<ParsedTable> {
if trimmed.iter().any(|line| is_pipe_only(line)) {
debug!(reason = "pipe_only_line", "table candidate rejected");
return None;
}
let (rows, split_within_line) = crate::reflow::parse_rows(trimmed);
let max_cols = rows.iter().map(Vec::len).max().unwrap_or(0);
let (sep_cells, sep_row_idx) = crate::reflow::detect_separator(sep_line, &rows, max_cols);
let cleaned = crate::reflow::clean_rows(rows);
if rows_mismatched(&cleaned, split_within_line) {
debug!(
reason = "rows_mismatched",
row_count = cleaned.len(),
has_separator = sep_cells.is_some(),
"table candidate rejected"
);
return None;
}
let mut output_rows = cleaned.clone();
if let Some(idx) = sep_index_within(sep_row_idx, output_rows.len()) {
output_rows.remove(idx);
}
if sep_cells.is_none()
&& output_rows.len() == 1
&& output_rows.first().is_some_and(|row| row.len() == 1)
{
debug!(
reason = "lone_single_cell",
row_count = output_rows.len(),
has_separator = sep_cells.is_some(),
"table candidate rejected"
);
return None;
}
Some(ParsedTable {
output_rows,
sep_cells,
max_cols,
})
}
fn calculate_and_format(parsed: &ParsedTable, indent: &str) -> Option<Vec<String>> {
let mut widths = crate::reflow::calculate_widths(&parsed.output_rows, parsed.max_cols);
if parsed.sep_cells.is_some() {
for width in &mut widths {
*width = (*width).max(3);
}
}
if parsed
.sep_cells
.as_ref()
.is_some_and(|cells| format_separator_cells(&widths, cells).is_empty())
{
return None;
}
let out = crate::reflow::format_rows(&parsed.output_rows, &widths, indent);
Some(crate::reflow::insert_separator(
out,
parsed.sep_cells.clone(),
&widths,
indent,
))
}
#[must_use]
pub fn reflow_table(lines: &[String]) -> Vec<String> {
reflow_valid_table(lines).unwrap_or_else(|| lines.to_vec())
}
pub(crate) fn is_valid_table(lines: &[String]) -> bool { reflow_valid_table(lines).is_some() }
fn reflow_valid_table(lines: &[String]) -> Option<Vec<String>> {
if lines.is_empty() {
return Some(Vec::new());
}
let (indent, mut trimmed) = extract_indent_and_trim(lines);
let sep_line = extract_separator_line(&mut trimmed);
let parsed = parse_and_validate(&trimmed, sep_line.as_ref())?;
calculate_and_format(&parsed, &indent)
}
mod empty_rows;
pub(crate) use empty_rows::reflow_generated_rows;
#[cfg(test)]
mod tests;