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
//! Line buffering and table-flush state machine for stream processing.
//!
//! [`ProcessBuffer`] accumulates lines while [`super::process_stream_inner`] walks
//! the input, deciding when a run of lines forms a Markdown table and should
//! be reflowed. It is kept in its own module so the orchestration in the
//! parent [`process`](super) module stays within the repository size limit.

use tracing::debug;

use crate::{
    classify::{ClassifyCtx, LineClass, classify_line_with_body},
    ellipsis::replace_ellipsis,
    table::reflow_table,
    wrap::{LinkReferenceMatcher, classify_block, leading_indent},
};

/// Substitutions that must precede table reflow so it measures final cell text.
pub(super) struct TableSubstitutions {
    /// Whether ellipsis replacement must run before table widths are measured.
    pub(super) ellipsis: bool,
    /// Whether code-emphasis repair must run before table widths are measured.
    pub(super) code_emphasis: bool,
}

/// Reports whether a classifier decision opens a top-level table run.
///
/// A quoted pipe body is not a top-level table row, and a delimiter is only a
/// table start when its own body leads with the pipe: the classifier accepts a
/// bare delimiter run that closes an existing table instead.
fn opens_table_run(line_class: LineClass, body: &str, is_quoted: bool) -> bool {
    if is_quoted {
        return false;
    }
    line_class == LineClass::TableRow
        || (line_class == LineClass::TableDelimiter && body.starts_with('|'))
}

/// Identifies a non-empty line whose indentation makes it an indented code
/// block rather than a table row.
fn is_indented_content_line(line: &str) -> bool {
    let (indent_width, first_content_byte) = leading_indent(line);
    indent_width >= 4
        && line[first_content_byte..]
            .chars()
            .any(|c| !c.is_whitespace())
}

// Note: `warn` is intentionally not imported. `flush` only calls
// `reflow_table` after its `buf.is_empty()` guard, and `reflow_table` returns
// an empty vector solely for empty input; for any non-empty input it yields
// either the reflowed table or the original lines verbatim. An empty result
// from a non-empty buffer is therefore unreachable, so no `warn!` is needed.

/// Flushes buffered lines to `out`, formatting as a table when required.
///
/// Fields are private; the parent module drives the buffer through the
/// narrow [`new`](Self::new), [`push_out`](Self::push_out),
/// [`handle_fence_line`](Self::handle_fence_line),
/// [`handle_table_line`](Self::handle_table_line), [`flush`](Self::flush),
/// and [`finish`](Self::finish) API so the table-detection invariants
/// stay encapsulated.
pub(super) struct ProcessBuffer {
    /// Completed output rows, including rows flushed from the pending buffer.
    out: Vec<String>,
    /// Parallel markers identifying rows that came from a valid table.
    table_lines: Vec<bool>,
    /// Lines awaiting classification as table content or ordinary output.
    buf: Vec<String>,
    /// Whether the pending buffer currently represents a table candidate.
    in_table: bool,
    /// Whether ellipsis replacement is enabled for the next table flush.
    ellipsis: bool,
    /// Whether code-emphasis repair is enabled for the next table flush.
    code_emphasis: bool,
}

impl ProcessBuffer {
    /// Creates an empty buffer with the substitutions needed before table
    /// reflow during [`flush`](Self::flush).
    pub(super) fn new(substitutions: &TableSubstitutions) -> Self {
        Self {
            out: Vec::new(),
            table_lines: Vec::new(),
            buf: Vec::new(),
            in_table: false,
            ellipsis: substitutions.ellipsis,
            code_emphasis: substitutions.code_emphasis,
        }
    }

    /// Appends a finished line directly to the output, without touching the
    /// pending table buffer. Callers that must preserve table/verbatim
    /// ordering call [`flush`](Self::flush) first.
    pub(super) fn push_out(&mut self, line: String) {
        self.out.push(line);
        self.table_lines.push(false);
    }

    /// Flushes pending lines, consumes the buffer, and returns output lines
    /// with their table markers.
    ///
    /// This drains pending buffered lines internally, so callers do not need a
    /// separate [`flush`](Self::flush) call before finishing.
    pub(super) fn finish(mut self) -> (Vec<String>, Vec<bool>) {
        self.flush();
        (self.out, self.table_lines)
    }

    /// Drains pending lines, reflowing a valid table after enabled substitutions.
    ///
    /// Invalid table candidates are emitted verbatim and marked as ordinary
    /// rows, so later stages never mistake a failed candidate for a table.
    pub(super) fn flush(&mut self) {
        debug!(
            in_table = self.in_table,
            lines = self.buf.len(),
            "ProcessBuffer::flush"
        );
        if self.buf.is_empty() {
            return;
        }
        let buffered = std::mem::take(&mut self.buf);
        if self.in_table {
            if !crate::table::is_valid_table(&buffered) {
                self.table_lines
                    .extend(std::iter::repeat_n(false, buffered.len()));
                self.out.extend(buffered);
                self.in_table = false;
                return;
            }
            let table_lines = if self.code_emphasis {
                crate::code_emphasis::fix_code_emphasis(&buffered)
            } else {
                buffered
            };
            let table_lines = if self.ellipsis {
                replace_ellipsis(&table_lines)
            } else {
                table_lines
            };
            let table_lines = reflow_table(&table_lines);
            self.table_lines
                .extend(std::iter::repeat_n(true, table_lines.len()));
            self.out.extend(table_lines);
        } else {
            self.table_lines
                .extend(std::iter::repeat_n(false, buffered.len()));
            self.out.extend(buffered);
        }
        self.in_table = false;
    }

    /// Flushes pending state and appends `line` without table processing.
    ///
    /// Fence markers and other protected lines use this boundary to prevent
    /// content from one Markdown block entering a neighbouring table run.
    pub(super) fn push_verbatim(&mut self, line: &str) {
        self.flush();
        self.push_out(line.to_string());
    }

    /// Handles a fence marker and reports whether it consumed the line.
    ///
    /// A marker always flushes pending content first; non-markers are returned
    /// to the caller for ordinary block classification.
    pub(super) fn handle_fence_line(&mut self, line: &str, is_fence_marker: bool) -> bool {
        if !is_fence_marker {
            return false;
        }

        self.push_verbatim(line);
        true
    }

    /// Adds a line to table state or returns it when it begins another block.
    ///
    /// The pipe heuristic is applied only after indentation and block markers
    /// have been checked, preventing quoted, list, or definition lines that
    /// contain pipes from corrupting a table candidate.
    pub(super) fn handle_table_line(&mut self, line: String) -> Option<String> {
        // The shared classifier returns Literal for indented code, so those
        // lines must never enter table mode. A quoted pipe body is also not a
        // top-level table row.
        let classified = classify_line_with_body(&line, &ClassifyCtx::default());
        let prefix = &line[..line.len() - classified.body.len()];
        let is_quoted = prefix.contains('>');
        let line_class = classified.class;
        if opens_table_run(line_class, classified.body, is_quoted) {
            debug!(
                line_len = line.len(),
                buffered_lines = self.buf.len(),
                "ProcessBuffer: table-mode on"
            );
            self.in_table = true;
            self.buf.push(line);
            return None;
        }
        // No earlier line opened a table, so there is no run to extend or
        // flush and the caller decides what this line is. Every branch below
        // therefore starts from an active table and can flush unconditionally.
        if !self.in_table {
            return Some(line);
        }
        if line.trim().is_empty() {
            self.flush();
            return Some(line);
        }
        // Recognise a new Markdown block *before* the pipe heuristic below.
        // Block markers such as `> `, `- `, or `[^id]:` may themselves contain
        // a `|`; if the pipe check ran first it would absorb such a line into
        // the table run, both corrupting the block and preventing the genuine
        // table from being reflowed (a stray non-table row makes
        // `reflow_table` bail). Flushing here keeps wrapping and table
        // detection aligned.
        //
        // Thematic breaks need no separate test: `classify_block` derives
        // `BlockKind::ThematicBreak` from the same default-context
        // classification that produced `line_class`, so every break already
        // reaches this branch.
        if classify_block(&line, LinkReferenceMatcher::production()).is_some() {
            debug!(
                line_len = line.len(),
                in_table = self.in_table,
                buffered_lines = self.buf.len(),
                "ProcessBuffer: flushing on block boundary"
            );
            self.flush();
            return Some(line);
        }
        if is_indented_content_line(&line) {
            self.flush();
            return Some(line);
        }
        if line.contains('|') || line_class == LineClass::TableDelimiter {
            self.buf.push(line);
            return None;
        }
        self.flush();
        Some(line)
    }
}

#[cfg(test)]
mod tests;