mdtablefix 0.6.1

`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
//! Helper functions for table reflow.
//!
//! The routines here parse raw rows, calculate cell widths, and format
//! aligned output for the main [`reflow_table`] function.

use unicode_width::UnicodeWidthStr;

use crate::table::{format_separator_cells, is_delimiter_cell, split_cells};

mod row_parsing;

use row_parsing::{cell_is_semantically_empty, split_physical_rows};

/// A parsed cell with leading-empty state kept separately from its payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct Cell {
    /// Cell text without structural delimiters or escaped separator pipes.
    payload: String,
    /// Whether the parser consumed this empty cell to preserve a continuation column.
    leading_empty: bool,
}

/// Parses reflow input into rows while preserving continuation-cell boundaries.
///
/// Leading empty cells are held in [`Cell::leading_empty`] while physical lines
/// are parsed, so continuation rows keep their original column positions.
/// Complete legacy rows concatenated on one line are recovered using the
/// inferred table width.
///
/// # Arguments
///
/// - `trimmed`: Trimmed table lines collected from the source document.
///
/// # Returns
///
/// A tuple containing parsed [`Cell`] rows and a flag indicating whether the
/// physical source line contained multiple complete logical rows.
///
/// # Examples
///
/// ```rust,ignore
/// let trimmed = vec!["| a | b |".to_string(), "| 1 | 2 |".to_string()];
/// let (rows, split_within_line) = mdtablefix::reflow::parse_rows(&trimmed);
///
/// assert_eq!(
///     rows,
///     vec![
///         vec!["a".to_string(), "b".to_string()],
///         vec!["1".to_string(), "2".to_string()],
///     ]
/// );
/// assert!(!split_within_line);
/// ```
#[tracing::instrument(level = "trace", skip(trimmed))]
pub(crate) fn parse_rows(trimmed: &[String]) -> (Vec<Vec<Cell>>, bool) {
    let physical_rows = trimmed
        .iter()
        .map(|line| parse_cells(line))
        .collect::<Vec<_>>();
    let (parsed_rows, split_within_line) = split_physical_rows(physical_rows);
    let rows = parsed_rows
        .into_iter()
        .enumerate()
        .filter_map(|(row_index, row)| retain_parsed_row(row_index, &row).then_some(row))
        .collect();

    (rows, split_within_line)
}

/// Reports whether a parsed row contains any meaningful cell content.
fn retain_parsed_row(row_index: usize, row: &[Cell]) -> bool {
    if row.iter().all(cell_is_semantically_empty) {
        tracing::debug!(
            row_index,
            cell_count = row.len(),
            error_category = "empty_row_discarded",
            "discarded empty parsed row"
        );
        false
    } else {
        tracing::debug!(row_index, cell_count = row.len(), "parsed table row");
        true
    }
}

/// Converts parsed cells to payload strings and removes fully empty rows.
///
/// # Arguments
///
/// - `rows`: Parsed rows that may contain leading-empty state.
///
/// # Returns
///
/// Rows with leading-empty cells restored to empty strings and fully empty rows
/// removed.
///
/// # Examples
///
/// ```rust,ignore
/// let rows = vec![vec![
///     Cell {
///         payload: String::new(),
///         leading_empty: true,
///     },
///     Cell {
///         payload: "value".to_string(),
///         leading_empty: false,
///     },
/// ]];
/// let cleaned = mdtablefix::reflow::clean_rows(rows);
///
/// assert_eq!(cleaned, vec![vec![String::new(), "value".to_string()]]);
/// ```
pub(crate) fn clean_rows(rows: Vec<Vec<Cell>>) -> Vec<Vec<String>> {
    rows.into_iter()
        .map(|row| {
            row.into_iter()
                .map(|cell| cell.leading_empty.then(String::new).unwrap_or(cell.payload))
                .collect::<Vec<_>>()
        })
        .filter(|row| row.iter().any(|cell| !cell.is_empty()))
        .collect()
}

/// Calculates display widths for each column across all parsed rows.
///
/// Widths are measured with `unicode-width` so wide glyphs align correctly in
/// the rendered table output.
///
/// # Arguments
///
/// - `rows`: Parsed rows whose cell widths should contribute to the final table layout.
/// - `max_cols`: The number of output columns to size.
///
/// # Returns
///
/// A vector containing the widest emitted display width for each column.
///
/// # Examples
///
/// ```rust,ignore
/// let rows = vec![
///     vec!["ASCII".to_string(), "漢".to_string()],
///     vec!["a | b".to_string(), "wide".to_string()],
/// ];
/// let widths = mdtablefix::reflow::calculate_widths(&rows, 2);
///
/// assert_eq!(widths, vec![6, 4]);
/// ```
pub(crate) fn calculate_widths(rows: &[Vec<String>], max_cols: usize) -> Vec<usize> {
    let mut widths = vec![0; max_cols];
    for row in rows {
        for (idx, cell) in row.iter().enumerate() {
            widths[idx] = widths[idx].max(emitted_cell_width(cell));
        }
    }
    widths
}

/// Formats each row with the supplied display widths and original indentation.
///
/// # Arguments
///
/// - `rows`: Output rows to emit.
/// - `widths`: Display widths calculated for each column.
/// - `indent`: Leading whitespace that should prefix every emitted row.
///
/// # Returns
///
/// Fully formatted table rows with escaped literal pipes and padding applied.
///
/// # Examples
///
/// ```rust,ignore
/// let rows = vec![vec!["a".to_string(), "b | c".to_string()]];
/// let widths = vec![1, 5];
/// let formatted = mdtablefix::reflow::format_rows(&rows, &widths, "  ");
///
/// assert_eq!(formatted, vec!["  | a | b \\| c |".to_string()]);
/// ```
pub(crate) fn format_rows(rows: &[Vec<String>], widths: &[usize], indent: &str) -> Vec<String> {
    rows.iter()
        .map(|row| {
            let padded: Vec<String> = row
                .iter()
                .enumerate()
                .map(|(i, cell)| pad_cell_to_width(cell, widths[i]))
                .collect();
            format!("{}| {} |", indent, padded.join(" | "))
        })
        .collect()
}

/// Reinserts the separator row, when present, ahead of the table body.
///
/// # Arguments
///
/// - `out`: Formatted table body rows.
/// - `sep_cells`: Optional separator cells to reinsert.
/// - `widths`: Display widths for each column.
/// - `indent`: Leading whitespace to prefix the separator row.
///
/// # Returns
///
/// The output rows with a formatted separator inserted after the header row
/// when separator cells are available.
///
/// # Examples
///
/// ```rust,ignore
/// let out = vec!["| head | body |".to_string(), "| row  | text |".to_string()];
/// let inserted = mdtablefix::reflow::insert_separator(
///     out,
///     Some(vec!["---".to_string(), ":--".to_string()]),
///     &[4, 4],
///     "",
/// );
///
/// assert_eq!(
///     inserted,
///     vec![
///         "| head | body |".to_string(),
///         "| ---- | :--- |".to_string(),
///         "| row  | text |".to_string(),
///     ]
/// );
/// ```
pub(crate) fn insert_separator(
    out: Vec<String>,
    sep_cells: Option<Vec<String>>,
    widths: &[usize],
    indent: &str,
) -> Vec<String> {
    if let Some(mut cells) = sep_cells {
        while cells.len() < widths.len() {
            cells.push(String::new());
        }
        let sep_padded = format_separator_cells(widths, &cells);
        if sep_padded.is_empty() {
            return out;
        }
        let sep_line_out = format!("{}| {} |", indent, sep_padded.join(" | "));
        if let Some(first) = out.first().cloned() {
            let mut with_sep = vec![first, sep_line_out];
            with_sep.extend(out.into_iter().skip(1));
            return with_sep;
        }
        return vec![sep_line_out];
    }
    out
}

/// Detects which row should act as the table separator, if any.
///
/// The explicit separator line is preferred, but the second parsed row can be
/// promoted when the source omitted a standalone separator line.
///
/// # Arguments
///
/// - `sep_line`: Optional separator line extracted from the original input.
/// - `rows`: Parsed table rows.
/// - `max_cols`: The maximum column count across the parsed rows.
///
/// # Returns
///
/// A tuple containing the chosen separator cells and the index of the
/// promoted separator row when one of the parsed rows is reused.
///
/// # Examples
///
/// ```rust,ignore
/// let rows = vec![
///     vec!["head".to_string(), "body".to_string()],
///     vec!["---".to_string(), "---".to_string()],
///     vec!["row".to_string(), "text".to_string()],
/// ];
/// let (sep_cells, sep_row_idx) = mdtablefix::reflow::detect_separator(None, &rows, 2);
///
/// assert_eq!(sep_cells, Some(vec!["---".to_string(), "---".to_string()]));
/// assert_eq!(sep_row_idx, Some(1));
/// ```
pub(crate) fn detect_separator(
    sep_line: Option<&String>,
    rows: &[Vec<Cell>],
    max_cols: usize,
) -> (Option<Vec<String>>, Option<usize>) {
    let mut sep_cells: Option<Vec<String>> = sep_line.map(|l| split_cells(l));
    let mut sep_row_idx: Option<usize> = None;

    let sep_invalid = invalid_separator(sep_cells.as_ref(), max_cols);
    if should_use_second_row_as_separator(sep_invalid, rows) {
        sep_cells = Some(rows[1].iter().map(|cell| cell.payload.clone()).collect());
        sep_row_idx = Some(1);
    }

    (sep_cells, sep_row_idx)
}

/// Reports whether an explicit separator is absent or has the wrong number of columns.
fn invalid_separator(sep_cells: Option<&Vec<String>>, max_cols: usize) -> bool {
    match sep_cells {
        Some(c) => c.len() != max_cols,
        None => true,
    }
}

/// Reports whether an invalid explicit separator should promote the second row.
fn should_use_second_row_as_separator(sep_invalid: bool, rows: &[Vec<Cell>]) -> bool {
    sep_invalid && second_row_is_separator(rows)
}

/// Reports whether the row below the header is a delimiter row.
///
/// Each cell must be a delimiter cell as [`is_delimiter_cell`] defines it: an
/// optional colon, one or more dashes, and an optional trailing colon, with no
/// embedded whitespace, the same rule `row_parsing` requires. `SEP_RE` alone is
/// too weak, because it also matches an empty cell and permits whitespace, so a
/// row of nothing but pipes and spaces — or one whose cells merely carry a
/// dash, such as `| - - |` — would otherwise be taken for the delimiter row.
fn second_row_is_separator(rows: &[Vec<Cell>]) -> bool {
    rows.len() > 1 && rows[1].iter().all(|cell| is_delimiter_cell(&cell.payload))
}

/// Splits one physical row while retaining leading empty cells as column structure.
fn parse_cells(line: &str) -> Vec<Cell> {
    let cells = split_cells(line);
    let leading_empty_cells = cells.iter().take_while(|cell| cell.is_empty()).count();
    cells
        .into_iter()
        .enumerate()
        .map(|(index, payload)| Cell {
            payload,
            leading_empty: index < leading_empty_cells,
        })
        .collect()
}

/// Escapes literal pipes and pads a cell to its measured display width.
fn pad_cell_to_width(cell: &str, width: usize) -> String {
    let escaped = escape_literal_pipes(cell);
    let padding = width.saturating_sub(emitted_cell_width(cell));
    format!("{escaped}{}", " ".repeat(padding))
}

/// Escapes payload pipes so reflow cannot mistake them for column separators.
fn escape_literal_pipes(cell: &str) -> String { cell.replace('|', r"\|") }

/// Measures the rendered width after pipe escaping, matching the formatter output.
fn emitted_cell_width(cell: &str) -> usize {
    UnicodeWidthStr::width(escape_literal_pipes(cell).as_str())
}

#[cfg(test)]
mod tests;