mdtablefix 0.5.0

`mdtablefix` unb0rks and reflows Markdown tables so that each column has a uniform width. When the `--wrap` option is used, it also wraps paragraphs and list items to 80 columns.
Documentation
//! Markdown table reflow utilities.
//!
//! Implements the algorithm outlined in
//! [`docs/architecture.md`](../../docs/architecture.md).
//! Provides helpers used by the `reflow` module and `reflow_table` itself.

use regex::Regex;
use tracing::debug;

static ESCAPED_PIPE_RE: std::sync::LazyLock<Regex> =
    lazy_regex!(r"\\\|", "escaped table pipe pattern should compile");

/// Split a Markdown table row into individual cell strings.
///
/// Escaped pipe characters (`\|`) are treated as literals and whitespace
/// inside each cell is trimmed.
///
/// # Examples
///
/// ```
/// use mdtablefix::split_cells;
/// assert_eq!(
///     split_cells("| A | B |"),
///     vec!["A".to_string(), "B".to_string()]
/// );
/// assert_eq!(
///     split_cells("a | b \\| c | d"),
///     vec!["a".to_string(), "b | c".to_string(), "d".to_string()]
/// );
/// ```
#[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()
}

/// Formats separator cells so they match the computed table widths.
///
/// Alignment markers from the source separator are preserved while each cell
/// is widened to at least three dashes, as required by Markdown tables.
///
/// # Arguments
///
/// - `widths`: Computed display widths for each output column.
/// - `sep_cells`: Separator cells taken from the parsed Markdown table.
///
/// # Returns
///
/// Separator cells widened to the target widths while preserving left and
/// right alignment markers. When the counts do not match, an empty vector is
/// returned so callers can treat the separator as invalid.
///
/// # Examples
///
/// ```rust,ignore
/// let sep_cells = vec![":--".to_string(), "---:".to_string()];
/// let formatted = mdtablefix::table::format_separator_cells(&[4, 5], &sep_cells);
///
/// assert_eq!(formatted, vec![":---".to_string(), "----:".to_string()]);
/// ```
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)))
}

/// Matches Markdown table separator lines made only of pipes, colons, dashes,
/// and whitespace so parsing can detect and extract the alignment row.
///
/// The pattern `^[\s|:-]+$` accepts common separator forms such as
/// `| --- | :--: | --: |` while rejecting content rows that contain other
/// characters.
pub(crate) static SEP_RE: std::sync::LazyLock<Regex> = lazy_regex!(
    r"^[\s|:-]+$",
    "Markdown table separator row pattern should compile",
);

/// Holds the parsed and validated table data.
///
/// This is produced by [`parse_and_validate`] and passed to
/// [`calculate_and_format`].
///
/// * `output_rows` - rows ready for output (separator removed)
/// * `sep_cells` - optional separator cells for formatting
/// * `max_cols` - maximum column count across all rows
struct ParsedTable {
    output_rows: Vec<Vec<String>>,
    sep_cells: Option<Vec<String>>,
    max_cols: usize,
}

/// Extracts the leading whitespace of the first line and returns trimmed lines.
///
/// Lines beginning with `\-` are removed after trimming. These lines escape a
/// leading pipe marker and should not be part of the table.
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)
}

/// Removes and returns the first separator line detected in `lines`.
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))
}

/// Parses table rows and validates column consistency.
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);
    }
    // A lone single-cell candidate with no separator row is not a table: it is a
    // stray pipe-prefixed line, such as a shell pipeline continuation inside a
    // code block. Formatting it would fabricate a trailing pipe, so treat it as
    // structurally insufficient and return the input unchanged.
    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,
    })
}

/// Calculates column widths and formats the final table output.
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,
    ))
}

/// Reflow a Markdown table so columns align uniformly.
///
/// Invalid tables are returned unchanged.
///
/// # Examples
///
/// ```
/// use mdtablefix::reflow_table;
/// let lines = vec![
///     "| A | B |    |".to_string(),
///     "| 1 | 2 |  | 3 | 4 |".to_string(),
/// ];
/// let expected = vec![
///     "| A | B |".to_string(),
///     "| 1 | 2 |".to_string(),
///     "| 3 | 4 |".to_string(),
/// ];
/// assert_eq!(reflow_table(&lines), expected);
/// ```
#[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 {
    //! Unit tests for table parsing and formatting.

    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() {
        // A single pipe-prefixed line with no separator row is a stray pipe
        // (for example a shell pipeline continuation), not a table. It must pass
        // through verbatim rather than gaining a fabricated trailing pipe.
        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);
    }
}