use regex::Regex;
use tracing::debug;
static ESCAPED_PIPE_RE: std::sync::LazyLock<Regex> =
lazy_regex!(r"\\\|", "escaped table pipe pattern should compile");
#[must_use]
pub fn split_cells(line: &str) -> Vec<String> {
let trimmed = line.trim().trim_start_matches('|').trim_end_matches('|');
let placeholder = '\u{1f}';
let replaced = ESCAPED_PIPE_RE.replace_all(trimmed, &placeholder.to_string());
replaced
.split('|')
.map(|cell| cell.trim().replace(placeholder, "|"))
.collect()
}
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",
);
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 extract_separator_line(lines: &mut Vec<String>) -> Option<String> {
let sep_idx = lines.iter().position(|l| SEP_RE.is_match(l));
sep_idx.map(|idx| lines.remove(idx))
}
fn parse_and_validate(trimmed: &[String], sep_line: Option<&String>) -> Option<ParsedTable> {
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> {
if lines.is_empty() {
return Vec::new();
}
let (indent, mut trimmed) = extract_indent_and_trim(lines);
let sep_line = extract_separator_line(&mut trimmed);
let Some(parsed) = parse_and_validate(&trimmed, sep_line.as_ref()) else {
return lines.to_vec();
};
calculate_and_format(&parsed, &indent).unwrap_or_else(|| lines.to_vec())
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[test]
fn sep_index_within_bounds() {
assert_eq!(sep_index_within(Some(1), 3), Some(1));
assert_eq!(sep_index_within(Some(3), 3), None);
assert_eq!(sep_index_within(None, 3), None);
}
#[test]
fn detect_row_mismatch() {
let rows = vec![
vec!["a".to_string(), "b".to_string()],
vec!["1".to_string(), "2".to_string()],
];
assert!(!rows_mismatched(&rows, false));
let mismatch = vec![
vec!["a".to_string(), "b".to_string()],
vec!["1".to_string()],
];
assert!(rows_mismatched(&mismatch, false));
let with_sep = vec![
vec!["a".to_string(), "b".to_string()],
vec!["---".to_string(), "---".to_string()],
vec!["1".to_string(), "2".to_string()],
];
assert!(!rows_mismatched(&with_sep, false));
assert!(!rows_mismatched(&mismatch, true));
}
#[rstest]
#[case(vec![2], vec!["---".to_string()], vec!["---".to_string()])]
#[case(vec![5], vec![":---".to_string()], vec![":----".to_string()])]
#[case(vec![5], vec!["---:".to_string()], vec!["----:".to_string()])]
#[case(vec![5], vec![":--:".to_string()], vec![":---:".to_string()])]
fn format_separator_cells_preserves_alignment_markers(
#[case] widths: Vec<usize>,
#[case] cells: Vec<String>,
#[case] expected: Vec<String>,
) {
assert_eq!(format_separator_cells(&widths, &cells), expected);
}
#[test]
fn format_separator_cells_returns_empty_when_counts_mismatch() {
let sep_cells = vec!["---".to_string()];
assert!(format_separator_cells(&[3, 4], &sep_cells).is_empty());
}
#[test]
fn reflow_table_returns_lone_single_cell_line_unchanged() {
let lines = vec!["| tee /tmp/test.log".to_string()];
assert_eq!(reflow_table(&lines), lines);
}
#[test]
fn reflow_table_returns_original_lines_for_mismatched_separator_columns() {
let lines = vec![
"| head |".to_string(),
"| --- | --- |".to_string(),
"| body |".to_string(),
];
assert_eq!(reflow_table(&lines), lines);
}
}