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
//! Definition scanning helpers for footnote renumbering.
//!
//! The parent module keeps the top-level rewrite flow, while this submodule
//! owns the detail-heavy definition parsing and scanning machinery. The
//! sibling [`reorder`](super::reorder) module owns block reordering, so each
//! source file stays readable and within the repository size limit.

use std::collections::HashMap;

use tracing::trace;

use super::{
    FOOTNOTE_LINE_RE,
    footnote_block_range,
    has_existing_footnote_block,
    is_definition_continuation,
    parse_definition,
    rewrite_tokens,
};
use crate::wrap::FenceTracker;

/// Rewrite plan for a single footnote-definition line.
///
/// Each instance describes one definition that should replace the source
/// line at `index` once renumbering completes. Fields are populated once at
/// construction; the parent module treats the value as read-only state.
#[derive(Clone)]
pub(super) struct DefinitionLine {
    /// Zero-based row of the definition within the original `lines` slice.
    pub(super) index: usize,
    /// New sequential footnote number assigned to this definition.
    pub(super) new_number: usize,
    /// Fully rewritten line, including any leading indent and prefix, ready
    /// to be stored back into `lines[index]`.
    pub(super) line: String,
}

/// Buffered numeric-list line that may become a footnote definition.
///
/// Candidates are collected on the first pass and finalized once the scan has
/// found every definition, so they take the numbers left over after the
/// explicit `[^n]:` definitions have claimed theirs. All fields are populated
/// at construction and not mutated afterwards.
pub(super) struct NumericCandidate {
    /// Zero-based row in the original `lines` slice.
    index: usize,
    /// Original ordered-list number to renumber.
    number: usize,
    /// Indentation preserved from the source line.
    indent: String,
    /// Whitespace separating the ordered-list marker from the body.
    whitespace: String,
    /// Definition body, still containing any inline `[^n]` references that
    /// will be rewritten using the final mapping.
    rest: String,
}

/// Aggregated output of a single definition scan.
///
/// Returned by [`collect_definition_updates`]. Treat both fields as a
/// matched pair: `is_definition_line` is indexed by source row and
/// `definitions` is keyed by `DefinitionLine::index`.
pub(super) struct DefinitionUpdates {
    /// Rewrite plans for every definition encountered. Explicit `[^n]:`
    /// definitions appear in scan order, and promoted numeric candidates
    /// follow them, also in scan order, as `finalize_numeric_candidates`
    /// appends them once the scan is done.
    pub(super) definitions: Vec<DefinitionLine>,
    /// `is_definition_line[i]` is `true` when row `i` of the source slice is
    /// the header of a footnote definition (existing or freshly promoted),
    /// so the caller can skip reference rewriting on that line.
    pub(super) is_definition_line: Vec<bool>,
}

/// Mutable state shared by the definition scan and candidate finalisation.
///
/// The vectors are kept aligned with source rows so later passes can update
/// headers without rewriting prose or fenced code payload.
struct DefinitionScanState<'a> {
    /// Mapping from source footnote numbers to their sequential replacements.
    mapping: &'a mut HashMap<usize, usize>,
    /// Next sequential number available to a newly discovered definition.
    next_number: &'a mut usize,
    /// Trailing ordered-list range eligible for promotion into definitions.
    numeric_list_range: Option<(usize, usize)>,
    /// Whether an existing definition block disables numeric-list promotion.
    skip_numeric_conversion: bool,
    /// Rewrite plans for explicit and promoted definition headers.
    definitions: Vec<DefinitionLine>,
    /// Bitmap marking source rows whose headers must not receive prose rewrites.
    is_definition_line: Vec<bool>,
    /// Numeric-list candidates held until explicit mappings are assigned.
    numeric_candidates: Vec<NumericCandidate>,
}

/// Returns the exclusive end row of the definition segment that begins at
/// `start`, scanning no further than `block_end`.
///
/// A segment absorbs continuation lines and blank lines that merely separate
/// wrapped continuation text, but stops at the next `[^n]:` definition header
/// or any other non-continuation content. Shared with the sibling
/// [`reorder`](super::reorder) module so segment boundaries are computed
/// identically during scanning and reordering.
pub(super) fn definition_segment_end(lines: &[String], start: usize, block_end: usize) -> usize {
    let mut idx = start + 1;
    while idx < block_end {
        let line = &lines[idx];
        if parse_definition(line).is_some() {
            break;
        }
        if is_definition_continuation(line) {
            idx += 1;
            continue;
        }
        if line.trim().is_empty() {
            if idx + 1 < block_end && parse_definition(&lines[idx + 1]).is_some() {
                break;
            }
            idx += 1;
            continue;
        }
        break;
    }
    idx
}

/// Returns the existing mapping for `number`, or assigns the next free number.
///
/// Keeping assignment in one helper ensures explicit definitions and promoted
/// list items share the same sequence and never receive conflicting numbers.
fn assign_new_number(
    mapping: &mut HashMap<usize, usize>,
    number: usize,
    next_number: &mut usize,
) -> usize {
    if let Some(&mapped) = mapping.get(&number) {
        mapped
    } else {
        let assigned = *next_number;
        *next_number += 1;
        mapping.insert(number, assigned);
        assigned
    }
}

/// Checks whether a row belongs to the eligible numeric-list range.
///
/// Existing definition blocks disable promotion even when the row otherwise
/// looks like a footnote list item, preserving externally maintained blocks.
fn should_convert_numeric_line(
    index: usize,
    numeric_range: Option<(usize, usize)>,
    skip_numeric_conversion: bool,
) -> bool {
    if skip_numeric_conversion {
        return false;
    }
    numeric_range.is_some_and(|(start, end)| index >= start && index < end)
}

/// Builds a rewritten definition header from parsed source parts.
///
/// The prefix remains byte-for-byte intact while the identifier and body are
/// rewritten through the shared mapping.
fn definition_line_from_parts(
    index: usize,
    parts: super::parsing::DefinitionParts<'_>,
    mapping: &mut HashMap<usize, usize>,
    next_number: &mut usize,
) -> DefinitionLine {
    let new_number = assign_new_number(mapping, parts.number, next_number);
    let rewritten_rest = rewrite_tokens(parts.rest, mapping);
    let mut line = String::with_capacity(parts.prefix.len() + rewritten_rest.len() + 8);
    line.push_str(parts.prefix);
    let header = format!("[^{new_number}]:");
    line.push_str(&header);
    line.push_str(&rewritten_rest);
    DefinitionLine {
        index,
        new_number,
        line,
    }
}

/// Parses `line` at row `index` into a [`NumericCandidate`] if it matches
/// the footnote-line pattern. Returns `None` when the line is not an
/// ordered-list candidate or the captured number fails to parse.
pub(super) fn numeric_candidate_from_line(line: &str, index: usize) -> Option<NumericCandidate> {
    let caps = FOOTNOTE_LINE_RE.captures(line)?;
    let indent = caps.name("indent").map_or("", |m| m.as_str()).to_string();
    let num_match = caps.name("num")?;
    let rest_match = caps.name("rest")?;
    let number = num_match.as_str().parse::<usize>().ok()?;
    let rest = rest_match.as_str().to_string();
    let sep_start = num_match.end().saturating_add(1);
    let rest_start = rest_match.start();
    if sep_start > rest_start || sep_start > line.len() || rest_start > line.len() {
        return None;
    }
    let whitespace = line[sep_start..rest_start].to_string();
    Some(NumericCandidate {
        index,
        number,
        indent,
        whitespace,
        rest,
    })
}

/// Collect eligible definitions and numeric candidates outside fenced blocks.
///
/// For example, a definition following a matching closer is collected, while
/// a definition-shaped line within the fence remains literal payload.
fn collect_scan_updates(lines: &[String], state: &mut DefinitionScanState<'_>) {
    let mut fences = FenceTracker::default();

    for (index, line) in lines.iter().enumerate() {
        let fence = fences.observe_source_line(line);
        if fence.is_fence_marker || fence.is_in_fence {
            continue;
        }

        if let Some(parts) = parse_definition(line) {
            state.definitions.push(definition_line_from_parts(
                index,
                parts,
                state.mapping,
                state.next_number,
            ));
            state.is_definition_line[index] = true;
            continue;
        }

        if !should_convert_numeric_line(
            index,
            state.numeric_list_range,
            state.skip_numeric_conversion,
        ) {
            trace!(
                index,
                numeric_list_range = ?state.numeric_list_range,
                skip_numeric_conversion = state.skip_numeric_conversion,
                "skipping numeric footnote candidate conversion"
            );
            continue;
        }
        if state.mapping.is_empty() && state.definitions.is_empty() {
            trace!(
                index,
                "skipping numeric footnote candidate before any reference mapping exists"
            );
            continue;
        }
        if let Some(candidate) = numeric_candidate_from_line(line, index) {
            state.numeric_candidates.push(candidate);
        }
    }
}

/// Promotes buffered numeric candidates after explicit mappings are known.
///
/// Candidates are drained from the bottom so their continuation rows remain
/// associated with the source item when the resulting definitions are sorted.
fn finalize_numeric_candidates(state: &mut DefinitionScanState<'_>) {
    // Number the candidates in line order. The block is sorted by number once
    // the scan is done, so an item no reference reaches — which is the item
    // that takes the next number from the pool — keeps the position it was
    // written in, and only the items a reference reaches move ahead of it.
    // Numbering them bottom-up would reverse every run of unreferenced items.
    for candidate in state.numeric_candidates.drain(..) {
        let new_number = assign_new_number(state.mapping, candidate.number, state.next_number);
        let rewritten_rest = rewrite_tokens(&candidate.rest, state.mapping);
        let mut line = String::with_capacity(
            candidate.indent.len() + candidate.whitespace.len() + rewritten_rest.len() + 8,
        );
        line.push_str(&candidate.indent);
        let header = format!("[^{new_number}]:");
        line.push_str(&header);
        line.push_str(&candidate.whitespace);
        line.push_str(&rewritten_rest);
        state.definitions.push(DefinitionLine {
            index: candidate.index,
            new_number,
            line,
        });
        state.is_definition_line[candidate.index] = true;
    }
}

/// Scans `lines` for footnote definitions and numeric candidates, updating
/// the shared reference `mapping` as numbers are assigned.
///
/// `mapping` is mutated in place to record every `(original, new)` pairing
/// discovered while building the [`DefinitionUpdates`] plan. Returns one
/// [`DefinitionLine`] per definition or promoted candidate, in scan order,
/// together with a per-row `is_definition_line` bitmap so the caller can
/// suppress reference rewriting on those rows.
pub(super) fn collect_definition_updates(
    lines: &[String],
    mapping: &mut HashMap<usize, usize>,
) -> DefinitionUpdates {
    let mut next_number = mapping.values().copied().max().unwrap_or(0) + 1;
    let numeric_list_range = footnote_block_range(lines);
    let skip_numeric_conversion = numeric_list_range
        .as_ref()
        .is_some_and(|(start, _)| has_existing_footnote_block(lines, *start));
    let mut state = DefinitionScanState {
        mapping,
        next_number: &mut next_number,
        numeric_list_range,
        skip_numeric_conversion,
        definitions: Vec::new(),
        is_definition_line: vec![false; lines.len()],
        numeric_candidates: Vec::new(),
    };
    collect_scan_updates(lines, &mut state);
    finalize_numeric_candidates(&mut state);

    DefinitionUpdates {
        definitions: state.definitions,
        is_definition_line: state.is_definition_line,
    }
}

/// Collects the definition headers of a document whose labels are already
/// final.
///
/// [`renumber_labels`](super::renumber_labels) applies the reference mapping to
/// the headers as well as the references, so each header already carries the
/// number it keeps and only the block's order is left to settle. Numbering the
/// headers from the free pool here would move every definition no reference
/// points at, because those are the ones the pool reaches, and it would do so
/// in line order: a definition the label stage had placed ahead of another
/// would come out behind it.
pub(super) fn settled_definitions(lines: &[String]) -> Vec<DefinitionLine> {
    let mut definitions = Vec::new();
    let mut fences = FenceTracker::default();

    for (index, line) in lines.iter().enumerate() {
        let fence = fences.observe_source_line(line);
        if fence.is_fence_marker || fence.is_in_fence {
            continue;
        }
        if let Some(parts) = parse_definition(line) {
            definitions.push(DefinitionLine {
                index,
                new_number: parts.number,
                line: line.clone(),
            });
        }
    }

    definitions
}

/// Applies the rewrite plan in `definitions` to `lines`, replacing each
/// `lines[definition.index]` with `definition.line`. Other rows are left
/// untouched.
pub(super) fn rewrite_definition_headers(lines: &mut [String], definitions: &[DefinitionLine]) {
    for definition in definitions {
        lines[definition.index].clone_from(&definition.line);
    }
}

#[cfg(test)]
mod tests;