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
//! Utilities for converting HTML tables embedded in Markdown into
//! Markdown table syntax.
//!
//! The conversion is intentionally simple: only `<table>`, `<tr>`,
//! `<th>`, and `<td>` tags are recognized. Attributes and tag casing
//! are ignored. The resulting Markdown lines are passed to
//! `reflow_table` to ensure consistent column widths.

use std::sync::LazyLock;

use html5ever::{driver::ParseOpts, parse_document, tendril::TendrilSink};
use markup5ever_rcdom::{Handle, NodeData, RcDom};
use regex::Regex;
use tracing::debug;

use crate::wrap::FenceTracker;

/// Matches an HTML `<table>` tag at the start of a Markdown block, ignoring case.
static TABLE_START_RE: LazyLock<Regex> = lazy_regex!(
    r"(?i)^(?:<table(?:\s|>|$))",
    "HTML table start pattern should compile"
);
/// Matches every HTML `<table>` tag while inside an HTML table block.
static TABLE_TAG_RE: LazyLock<Regex> = lazy_regex!(
    r"(?i)<table(?:\s|>|$)",
    "HTML table tag pattern should compile"
);
/// Matches the end of an HTML `</table>` tag, ignoring case.
static TABLE_END_RE: LazyLock<Regex> =
    lazy_regex!(r"(?i)</table>", "HTML table end pattern should compile");

/// Extracts the text content of a DOM node, collapsing consecutive
/// whitespace to single spaces.
fn node_text(handle: &Handle) -> String {
    let mut out = String::new();
    let mut last_space = false;
    collect_text(handle, &mut out, &mut last_space);
    if last_space {
        out.push(' ');
    }
    out.trim_start().to_string()
}

/// Reports whether a subtree is metadata or executable content excluded from cell text.
fn is_ignored_tag(tag: &str) -> bool {
    matches!(
        tag,
        t if t.eq_ignore_ascii_case("script")
            || t.eq_ignore_ascii_case("style")
            || t.eq_ignore_ascii_case("noscript")
            || t.eq_ignore_ascii_case("template")
            || t.eq_ignore_ascii_case("head")
    )
}

/// Recursively appends text nodes from `handle` to `out`, tracking whether the
/// previous output was whitespace.
fn collect_text(handle: &Handle, out: &mut String, last_space: &mut bool) {
    match &handle.data {
        NodeData::Text { contents } => {
            for ch in contents.borrow().chars() {
                push_collapsed_text_char(ch, out, last_space);
            }
        }
        NodeData::Element { name, .. } => {
            if is_ignored_tag(name.local.as_ref()) {
                return;
            }
            for child in handle.children.borrow().iter() {
                collect_text(child, out, last_space);
            }
        }
        NodeData::Document => {
            for child in handle.children.borrow().iter() {
                collect_text(child, out, last_space);
            }
        }
        _ => {}
    }
}

/// Appends one DOM character while coalescing whitespace between visible text fragments.
fn push_collapsed_text_char(ch: char, out: &mut String, last_space: &mut bool) {
    if ch.is_whitespace() {
        *last_space = true;
        return;
    }
    if *last_space && !out.is_empty() {
        out.push(' ');
    }
    out.push(ch);
    *last_space = false;
}

/// Returns `true` if `handle` is an HTML element with the given tag name.
fn is_element(handle: &Handle, tag: &str) -> bool {
    if let NodeData::Element { name, .. } = &handle.data {
        name.local.as_ref().eq_ignore_ascii_case(tag)
    } else {
        false
    }
}

/// Returns `true` if `handle` represents a `<td>` or `<th>` element.
fn is_table_cell(handle: &Handle) -> bool { is_element(handle, "td") || is_element(handle, "th") }

/// DOM input is unbounded; bounded model checking is not a good fit for
/// verifying traversal over arbitrary tree depth. Property tests cover
/// invariants via random small-tree generation instead.
///
/// Walks the DOM tree in pre-order, cloning nodes that satisfy `pred` into `out`.
///
/// # Examples
///
/// For a root containing `<table id="outer"><td><table id="inner"></table></td></table>`
/// followed by `<table id="sibling"></table>`, matching table elements preserves
/// that pre-order sequence:
///
/// ```rust,ignore
/// let mut tables = Vec::new();
/// collect_matching(&root, |node| is_element(node, "table"), &mut tables);
/// assert_eq!(tables, vec![outer, inner, sibling]);
/// ```
fn collect_matching<F>(handle: &Handle, pred: F, out: &mut Vec<Handle>)
where
    F: Fn(&Handle) -> bool + Copy,
{
    if pred(handle) {
        out.push(handle.clone());
    }
    for child in handle.children.borrow().iter() {
        collect_matching(child, pred, out);
    }
}
/// Walks the DOM tree collecting `<table>` nodes under `handle`.
fn collect_tables(handle: &Handle, tables: &mut Vec<Handle>) {
    collect_matching(handle, |node| is_element(node, "table"), tables);
}

/// Collects all `<tr>` nodes beneath `handle`.
fn collect_rows(handle: &Handle, rows: &mut Vec<Handle>) {
    collect_matching(handle, |node| is_element(node, "tr"), rows);
}

/// Reports whether a tag marks text as bold for implicit header detection.
fn is_bold_tag(tag: &str) -> bool {
    matches!(
        tag,
        t if t.eq_ignore_ascii_case("strong") || t.eq_ignore_ascii_case("b")
    )
}

/// Returns `true` if `handle` contains a `<b>` or `<strong>` descendant.
fn contains_strong(handle: &Handle) -> bool {
    if let NodeData::Element { name, .. } = &handle.data
        && is_bold_tag(name.local.as_ref())
    {
        return true;
    }
    let children = handle.children.borrow();
    children.iter().any(contains_strong)
}

/// Extracts cell text from a row and reports whether all cells are header cells.
fn parse_row(row: &Handle) -> (Vec<String>, bool) {
    let mut cells = Vec::new();
    let mut all_header = true;
    for child in row.children.borrow().iter() {
        if is_table_cell(child) {
            let is_header = if is_element(child, "th") {
                true
            } else {
                contains_strong(child)
            };
            all_header &= is_header;
            cells.push(node_text(child));
        }
    }
    (cells, all_header)
}

/// Converts a `<table>` DOM node into Markdown table lines and calls
/// `reflow_table` so the columns are uniformly padded.
fn table_node_to_markdown(table: &Handle) -> Vec<String> {
    let mut row_handles = Vec::new();
    collect_rows(table, &mut row_handles);
    if row_handles.is_empty() {
        return Vec::new();
    }

    let (first_cells, explicit_header) = parse_row(&row_handles[0]);
    let col_count = first_cells.len();
    let fallback_header = !explicit_header && row_handles.len() > 1;
    let has_header = explicit_header || fallback_header;

    let mut out = Vec::new();
    out.push(format!("| {} |", first_cells.join(" | ")));
    for row in row_handles.iter().skip(1) {
        let (cells, _) = parse_row(row);
        out.push(format!("| {} |", cells.join(" | ")));
    }

    if has_header {
        let sep: Vec<String> = (0..col_count).map(|_| "---".to_string()).collect();
        out.insert(1, format!("| {} |", sep.join(" | ")));
    }

    crate::table::reflow_generated_rows(&out)
}

/// Parses HTML table markup and returns the equivalent Markdown lines.
///
/// If no `<table>` elements are present, the input is returned unchanged.
fn table_lines_to_markdown(lines: &[String]) -> Vec<String> {
    let indent: String = lines
        .first()
        .map(|line| crate::textproc::leading_indent(line).to_string())
        .unwrap_or_default();
    let html: String = lines.join("\n");
    let opts = ParseOpts::default();
    let dom: RcDom = parse_document(RcDom::default(), opts).one(html);

    let mut tables = Vec::new();
    collect_tables(&dom.document, &mut tables);
    if tables.is_empty() {
        return lines.to_vec();
    }

    let mut out = Vec::new();
    for table in tables {
        for line in table_node_to_markdown(&table) {
            out.push(format!("{indent}{line}"));
        }
    }
    out
}

/// Buffers a contiguous HTML table block while tracking nested table depth.
#[derive(Default)]
struct HtmlTableState {
    /// Original lines retained until the outermost table closes or conversion is abandoned.
    buf: Vec<String>,
    /// Number of currently open table elements in the buffered block.
    depth: usize,
}

impl HtmlTableState {
    /// Reports whether a table block is currently being buffered.
    fn in_html(&self) -> bool { !self.buf.is_empty() }

    /// Flushes buffered source lines unchanged when the block cannot be converted safely.
    fn flush_raw(&mut self, out: &mut Vec<String>) {
        if !self.buf.is_empty() {
            debug!(
                line_count = self.buf.len(),
                depth = self.depth,
                "flushing buffered HTML table block without conversion"
            );
            out.append(&mut self.buf);
        }
        self.depth = 0;
    }

    /// Adds one source line, converting the block when the outermost table closes.
    fn push_html_line(&mut self, line: &str, out: &mut Vec<String>) {
        let trimmed = line.trim_start();
        let previous_depth = self.depth;
        let start_count = TABLE_TAG_RE.find_iter(trimmed).count();
        let end_count = TABLE_END_RE.find_iter(trimmed).count();
        self.buf.push(line.to_string());
        self.depth += start_count;
        if end_count > 0 {
            self.depth = self.depth.saturating_sub(end_count);
        }
        debug!(
            previous_depth,
            start_count,
            end_count,
            depth = self.depth,
            buffered_lines = self.buf.len(),
            "updated HTML table buffer depth"
        );
        if self.depth == 0 {
            debug!(
                line_count = self.buf.len(),
                "converting HTML table block to Markdown"
            );
            out.extend(table_lines_to_markdown(&self.buf));
            self.buf.clear();
        }
    }
}

/// Replaces HTML tables in the provided lines with equivalent Markdown table syntax.
///
/// Scans the input lines for HTML `<table>` blocks, converts each detected table to Markdown using
/// `table_lines_to_markdown`, and preserves all other content unchanged. Handles nested tables and
/// maintains original line formatting outside of tables.
///
/// # Arguments
///
/// * `lines` - A slice of strings representing lines of Markdown, possibly containing HTML tables.
///
/// # Returns
///
/// A vector of strings with HTML tables replaced by Markdown tables, leaving other lines intact.
///
/// # Examples
///
/// ```no_run
/// use mdtablefix::html_table_to_markdown;
/// let html_lines =
///     vec!["<table><tr><th>Header</th></tr><tr><td>Cell</td></tr></table>".to_string()];
/// let md_lines = html_table_to_markdown(&html_lines);
/// assert!(md_lines[0].starts_with("| Header |"));
/// ```
pub(crate) fn html_table_to_markdown(lines: &[String]) -> Vec<String> {
    let mut out = Vec::new();
    let mut html_state = HtmlTableState::default();

    for line in lines {
        if html_state.in_html() || TABLE_START_RE.is_match(line.trim_start()) {
            html_state.push_html_line(line, &mut out);
            continue;
        }

        out.push(line.clone());
    }

    html_state.flush_raw(&mut out);

    out
}

/// Converts HTML tables embedded in Markdown lines to Markdown table syntax.
///
/// Scans the input lines, detects HTML table blocks outside of fenced code blocks, and replaces
/// them with equivalent Markdown tables. Fenced code blocks are left untouched, allowing raw HTML
/// examples to be documented without modification. Handles nested tables and preserves original
/// line formatting outside of tables.
///
/// # Examples
///
/// ```no_run
/// use mdtablefix::convert_html_tables;
/// let lines = vec![
///     "<table>".to_string(),
///     "  <tr><th>Header</th></tr>".to_string(),
///     "  <tr><td>Cell</td></tr>".to_string(),
///     "</table>".to_string(),
/// ];
/// let result = convert_html_tables(&lines);
/// assert!(result[0].starts_with("| Header |"));
/// ```
#[must_use]
pub fn convert_html_tables(lines: &[String]) -> Vec<String> {
    let mut out = Vec::new();
    let mut html_state = HtmlTableState::default();
    let mut fences = FenceTracker::new();

    for line in lines {
        let fence = fences.observe_source_line(line);
        if fence.is_fence_marker {
            if html_state.in_html() {
                html_state.flush_raw(&mut out);
            }
            out.push(line.clone());
            continue;
        }

        if fence.is_in_fence {
            out.push(line.clone());
            continue;
        }

        if html_state.in_html() {
            html_state.push_html_line(line, &mut out);
            continue;
        }

        if TABLE_START_RE.is_match(line.trim_start()) {
            html_state.push_html_line(line, &mut out);
            continue;
        }

        out.push(line.clone());
    }

    html_state.flush_raw(&mut out);

    out
}

#[cfg(test)]
#[path = "html_tests.rs"]
mod tests;