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
//! Regression coverage for the `check-static-regexes` lint guard.
//!
//! The guard lives in `scripts/check-static-regexes.sh` (invoked by the
//! Makefile's `check-static-regexes` target). It rejects hand-rolled static
//! regular expressions that bypass the `lazy_regex!` convention by wrapping
//! `Regex::new` directly in a supported lazy-wrapper constructor.
//!
//! These tests exercise the script directly so the Makefile and the tests
//! share a single source of truth for the scan. Every supported wrapper form
//! is asserted to be rejected, a clean fixture is asserted to pass, and a
//! ripgrep scan failure is asserted to propagate.
//!
//! Fixtures live under `tests/data/static_regex/` with a `.rs.txt` extension
//! so the guard (which scans `*.rs`) does not match the fixtures in place; the
//! tests copy each fixture into a temporary directory as a `.rs` file before
//! scanning.
//!
//! Paths are [`camino`] UTF-8 types and every filesystem operation goes
//! through a [`cap_std::fs_utf8::Dir`] capability scoped to the directory it
//! touches, so reads and writes cannot stray outside the manifest or the
//! temporary directory they belong to. [`TempDir`] still provides the isolated
//! directories and [`std::process::Command`] still runs the guard; only the
//! path and filesystem layers change.
//!
//! The guard itself is Unix-only: it is a `bash` script that shells out to
//! ripgrep, and the tests stand in for ripgrep with stub scripts that have to
//! carry the executable bit. The whole file is gated to match, so a checkout
//! without a POSIX shell reports this target as empty rather than failing it.
//! Nothing goes unguarded on account of it: the `check-static-regexes`
//! Makefile target runs the same script over the same sources in the Linux
//! lint job.
#![cfg(unix)]

use std::io;

use proptest::prelude::*;
use rstest::rstest;
use tempfile::TempDir;

#[path = "support/script_stubs.rs"]
mod script_stubs;
use script_stubs::{stub_command, write_stub};
#[path = "support/static_regex_guard.rs"]
mod static_regex_guard;
use static_regex_guard::{open_dir, run_guard, scan_dir_with, utf8};

/// The diagnostic emitted when a prohibited declaration is found.
const PROHIBITED_DIAGNOSTIC: &str = "static regular expressions must use lazy_regex!";

/// Every lazy-wrapper constructor form the guard must reject when it directly
/// wraps `Regex::new`. Each label maps to a fixture under
/// `tests/data/static_regex/` exercising one supported spelling:
///
///   * `lazylock_direct`           — `LazyLock::new(|| Regex::new(...))`
///   * `lazylock_qualified`        — `std::sync::LazyLock::new(|| Regex::new(...))`
///   * `lazylock_move`             — `LazyLock::new(move || Regex::new(...))`
///   * `once_cell_lazy_direct`     — `Lazy::new(|| Regex::new(...))`
///   * `once_cell_lazy_qualified`  — `once_cell::sync::Lazy::new(|| Regex::new(...))`
///   * `once_cell_lazy_move`       — `once_cell::sync::Lazy::new(move || Regex::new(...))`
const PROHIBITED_FORMS: &[&str] = &[
    "lazylock_direct",
    "lazylock_qualified",
    "lazylock_move",
    "once_cell_lazy_direct",
    "once_cell_lazy_qualified",
    "once_cell_lazy_move",
];

#[rstest]
fn rejects_prohibited_lazy_wrapper_form(#[values(0, 1, 2, 3, 4, 5)] index: usize) {
    let label = PROHIBITED_FORMS[index];
    let dir = scan_dir_with(label).expect("stage the labelled fixture");

    let output = run_guard(utf8(dir.path()).expect("temporary path is UTF-8"), None)
        .expect("execute the guard");

    assert_eq!(
        output.status.code(),
        Some(1),
        "form `{label}` should be rejected with status 1"
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains(PROHIBITED_DIAGNOSTIC),
        "form `{label}` should emit the prohibited diagnostic, got: {stdout}"
    );
}

#[test]
fn accepts_clean_sources() {
    // The sanctioned `lazy_regex!` idiom plus an unrelated non-static
    // `Regex::new` call that must not trip the guard.
    let dir = scan_dir_with("clean").expect("stage clean fixture");

    let output = run_guard(utf8(dir.path()).expect("temporary path is UTF-8"), None)
        .expect("execute the guard");

    assert_eq!(
        output.status.code(),
        Some(0),
        "clean sources should pass; stdout: {}",
        String::from_utf8_lossy(&output.stdout)
    );
}

#[test]
fn propagates_ripgrep_scan_failure() {
    let dir = TempDir::new().expect("failed to create temp dir");
    let scan_dir = utf8(dir.path()).expect("temporary path is UTF-8");
    // A stub standing in for ripgrep that fails with a distinctive status.
    let stub = write_stub(scan_dir, "rg-stub.sh", "#!/bin/sh\nexit 3\n")
        .expect("write the failing ripgrep stub");

    let output = run_guard(scan_dir, Some(&stub_command(&stub))).expect("execute the guard");

    assert_eq!(
        output.status.code(),
        Some(3),
        "a ripgrep scan failure should propagate its exit status"
    );
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("failed to scan Rust sources (rg exit 3)"),
        "scan failure should emit the scan-failure diagnostic, got: {stderr}"
    );
}

/// An `RG` override may carry arguments — the Makefile's `$(RG)` expansion
/// supported this before the scan moved into the script, and `check-ripgrep`
/// still validates only `$(firstword $(RG))`. The guard must therefore split
/// `RG` on whitespace and forward the extra arguments to ripgrep ahead of its
/// own, rather than treating the whole value as one executable name.
///
/// The `single_quote` case pins the stub's own quoting: the log path is derived
/// from `$0` inside the script rather than interpolated into its source, so a
/// `TMPDIR` containing a single quote cannot break the recording. (The
/// directory name deliberately omits whitespace, since `RG` is split on it.)
#[rstest]
#[case::plain("scan")]
#[case::single_quote("scan's")]
fn preserves_arguments_supplied_through_rg(#[case] dir_name: &str) {
    const STUB_NAME: &str = "rg-stub.sh";

    let root = TempDir::new().expect("failed to create temp dir");
    let root_dir = utf8(root.path()).expect("temporary path is UTF-8");
    open_dir(root_dir)
        .expect("open the temporary root")
        .create_dir(dir_name)
        .expect("failed to create scan dir");
    let dir = root_dir.join(dir_name);

    // A stub that records its argv, then reports "no matches" so the guard
    // takes its clean-scan path. It writes beside itself via `"$0.argv"`, so no
    // path is interpolated into the script source.
    let stub = write_stub(
        &dir,
        STUB_NAME,
        "#!/bin/sh\nfor a in \"$@\"; do printf '%s\\n' \"$a\"; done > \"$0.argv\"\nexit 1\n",
    )
    .expect("write the argument-recording ripgrep stub");

    let output = run_guard(&dir, Some(&format!("{} --pcre2", stub_command(&stub))))
        .expect("execute the guard");

    assert_eq!(
        output.status.code(),
        Some(0),
        "an argument-bearing RG override must still run; stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let argv: Vec<String> = open_dir(&dir)
        .expect("open the scan directory")
        .read_to_string(format!("{STUB_NAME}.argv"))
        .expect("stub should have recorded its argv")
        .lines()
        .map(str::to_owned)
        .collect();
    assert_eq!(
        argv.first().map(String::as_str),
        Some("--pcre2"),
        "the RG override's own arguments must be forwarded first, got: {argv:?}"
    );
    assert!(
        argv.contains(&"-U".to_owned()) && argv.contains(&"--glob".to_owned()),
        "the guard's own ripgrep arguments must follow, got: {argv:?}"
    );
    assert_eq!(
        argv.last().map(String::as_str),
        Some(dir.as_str()),
        "the scan directory must remain the final argument, got: {argv:?}"
    );
}

// Property-based coverage
// The fixtures pin specific spellings. The guard's contract covers a family of
// declarations: either supported wrapper, any module qualification, any
// `\s*`-legal whitespace (including newlines, since the scan runs with `-U`),
// with or without `move`, and with or without a braced closure body. The
// properties below generate across that space and assert rejection, and
// generate across clearly-sanctioned forms and assert acceptance.
// Each case writes several generated declarations into one directory and runs
// the guard once, then asserts every generated file is named in the output.
// That keeps process spawns proportional to cases rather than declarations
// while still checking each declaration individually.

/// Whitespace runs for positions where the guard's pattern allows `\s*`.
fn optional_ws() -> impl Strategy<Value = String> {
    prop::sample::select(vec!["", " ", "  ", "\t", "\n", "\n    "]).prop_map(str::to_owned)
}

/// Whitespace runs for positions requiring at least one space, such as the
/// gap after a `move` keyword.
fn required_ws() -> impl Strategy<Value = String> {
    prop::sample::select(vec![" ", "  ", "\t", "\n", "\n    "]).prop_map(str::to_owned)
}

/// A possibly-empty module qualification such as `once_cell::sync::`.
fn qualifier() -> impl Strategy<Value = String> {
    prop::collection::vec("[a-z][a-z0-9_]{0,6}", 0..3).prop_map(|segments| {
        segments.iter().fold(String::new(), |mut acc, segment| {
            acc.push_str(segment);
            acc.push_str("::");
            acc
        })
    })
}

prop_compose! {
    /// A static declaration that wraps `Regex::new` in a supported lazy
    /// wrapper, and so must always be rejected.
    fn prohibited_declaration()(
        name in "[A-Z][A-Z0-9_]{0,7}",
        wrapper in prop::sample::select(vec!["LazyLock", "Lazy"]),
        wrapper_qualifier in qualifier(),
        regex_qualifier in qualifier(),
        pattern in "[a-z0-9]{1,8}",
        after_eq in optional_ws(),
        after_new in optional_ws(),
        after_paren in optional_ws(),
        after_closure in optional_ws(),
        move_gap in prop::option::of(required_ws()),
        braced in any::<bool>(),
    ) -> String {
        let move_kw = move_gap.map_or_else(String::new, |gap| format!("move{gap}"));
        let (open, close) = if braced { ("{ ", " }") } else { ("", "") };
        format!(
            "static {name}: {wrapper}<Regex> ={after_eq}{wrapper_qualifier}{wrapper}::new\
             {after_new}({after_paren}{move_kw}||{after_closure}{open}\
             {regex_qualifier}Regex::new(\"{pattern}\").unwrap(){close});\n"
        )
    }
}

prop_compose! {
    /// A declaration the guard must leave alone: the sanctioned macro, a
    /// non-static binding, a supported wrapper around a non-regex value, an
    /// unsupported wrapper, or a function reference rather than a closure.
    fn clean_declaration()(
        name in "[A-Z][A-Z0-9_]{0,7}",
        pattern in "[a-z0-9]{1,8}",
        form in 0_usize..5,
    ) -> String {
        match form {
            0 => format!("static {name}: LazyLock<Regex> = lazy_regex!(\"{pattern}\");\n"),
            1 => format!("fn build_{name}() {{ let re = Regex::new(\"{pattern}\").unwrap(); }}\n"),
            2 => format!("static {name}: LazyLock<String> = LazyLock::new(|| String::new());\n"),
            3 => format!("static {name}: OnceLock<Regex> = OnceLock::new();\n"),
            _ => format!("static {name}: LazyLock<Regex> = LazyLock::new(make_{name});\n"),
        }
    }
}

/// Write each declaration to its own `.rs` file and scan the directory once.
fn scan_generated(declarations: &[String]) -> io::Result<(TempDir, std::process::Output)> {
    let dir = TempDir::new()?;
    let handle = open_dir(utf8(dir.path())?)?;
    for (index, declaration) in declarations.iter().enumerate() {
        handle.write(format!("case{index}.rs"), declaration)?;
    }
    let output = run_guard(utf8(dir.path())?, None)?;
    Ok((dir, output))
}

proptest! {
    #![proptest_config(ProptestConfig::with_cases(64))]

    /// Every declaration in the supported wrapper family is rejected, whatever
    /// its qualification, whitespace, `move` keyword, or closure body shape.
    #[test]
    fn rejects_generated_prohibited_declarations(
        declarations in prop::collection::vec(prohibited_declaration(), 1..5),
    ) {
        let (_dir, output) = scan_generated(&declarations)
            .map_err(|error| TestCaseError::fail(error.to_string()))?;

        prop_assert_eq!(
            output.status.code(),
            Some(1),
            "generated declarations should be rejected: {:?}",
            declarations
        );
        let stdout = String::from_utf8_lossy(&output.stdout);
        prop_assert!(stdout.contains(PROHIBITED_DIAGNOSTIC));
        // Assert per declaration, so one matching file cannot mask the rest.
        for (index, declaration) in declarations.iter().enumerate() {
            prop_assert!(
                stdout.contains(&format!("case{index}.rs")),
                "declaration {} went undetected: {:?}\nguard output: {stdout}",
                index,
                declaration
            );
        }
    }

    /// Sanctioned and unrelated declarations never trip the guard.
    #[test]
    fn accepts_generated_clean_declarations(
        declarations in prop::collection::vec(clean_declaration(), 1..5),
    ) {
        let (_dir, output) = scan_generated(&declarations)
            .map_err(|error| TestCaseError::fail(error.to_string()))?;

        prop_assert_eq!(
            output.status.code(),
            Some(0),
            "clean declarations should pass: {:?}\nguard output: {}",
            declarations,
            String::from_utf8_lossy(&output.stdout)
        );
    }
}

// Bounded model checking is unsuitable here because the guard is a ripgrep
// pattern over unbounded Rust source text rather than a bounded state machine;
// property testing exercises that input domain directly.

/// Regression case for issue #586: a ripgrep stub still runs while a write
/// descriptor on it is open, as one inherited by a concurrently forked child
/// would be.
///
/// Executing a file with an open write descriptor fails with `ETXTBSY`, so
/// the stub runs through `sh`, which only reads it.
#[test]
fn static_regex_issue_586_stub_survives_an_open_write_descriptor() {
    let dir = scan_dir_with("clean").expect("stage clean fixture");
    let scan_dir = utf8(dir.path()).expect("temporary path is UTF-8");
    let stub = write_stub(scan_dir, "rg-stub.sh", "#!/bin/sh\nexit 3\n").expect("write the stub");
    let _held_open = open_dir(scan_dir)
        .expect("open the scan directory")
        .open_with("rg-stub.sh", cap_std::fs::OpenOptions::new().append(true))
        .expect("hold the stub open for writing");

    let output = run_guard(scan_dir, Some(&stub_command(&stub))).expect("execute the guard");

    assert_eq!(
        output.status.code(),
        Some(3),
        "the stub must run despite the open descriptor; stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
}