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
//! Property-based tests for link reference regex correctness and title window state.

use proptest::prelude::*;

use crate::wrap::{
    BlockKind,
    classify_block,
    link_reference::{LinkReferenceMatcher, LinkTitleWindow, LinkTitleWindowOutcome},
};

/// Arbitrary label: one or more non-`]` printable ASCII characters.
fn arb_label() -> impl Strategy<Value = String> { "[A-Za-z0-9 _-]{1,40}".prop_map(|s| s) }

/// Arbitrary bare URL: starts with `https://` followed by alphanumerics.
fn arb_bare_url() -> impl Strategy<Value = String> {
    "[a-z]{3,8}://[A-Za-z0-9./-]{1,40}".prop_map(|s| s)
}

/// Arbitrary angle-bracketed URL destination.
fn arb_angle_url() -> impl Strategy<Value = String> {
    arb_bare_url().prop_map(|url| format!("<{url}>"))
}

/// Arbitrary inline title in double-quote form.
fn arb_dq_title() -> impl Strategy<Value = String> {
    "[A-Za-z0-9 ]{0,30}".prop_map(|s| format!("\"{s}\""))
}

/// Arbitrary inline title in single-quote form.
fn arb_sq_title() -> impl Strategy<Value = String> {
    "[A-Za-z0-9 ]{0,30}".prop_map(|s| format!("'{s}'"))
}

/// Arbitrary inline title in parenthesis form.
fn arb_paren_title() -> impl Strategy<Value = String> {
    "[A-Za-z0-9 ]{0,30}".prop_map(|s| format!("({s})"))
}

/// Arbitrary title in any supported delimiter form.
fn arb_title_form() -> impl Strategy<Value = String> {
    prop_oneof![arb_dq_title(), arb_sq_title(), arb_paren_title()]
}

/// Arbitrary title body containing an escaped quote or backslash.
fn arb_escaped_title_body() -> impl Strategy<Value = String> {
    prop_oneof![
        Just("\\\"".to_string()),
        Just("\\\\".to_string()),
        "[A-Za-z0-9 ]{0,10}".prop_map(|s| format!("{s}\\\"tail")),
    ]
}

/// Arbitrary escaped double-quoted title.
fn arb_escaped_dq_title() -> impl Strategy<Value = String> {
    arb_escaped_title_body().prop_map(|body| format!("\"{body}\""))
}

proptest! {
    /// Any `[label]: URL` line (no title) must match and require a standalone title.
    #[test]
    fn valid_bare_link_ref_matches(label in arb_label(), url in arb_bare_url()) {
        let matcher = LinkReferenceMatcher::production();
        let line = format!("[{label}]: {url}");
        prop_assert!(matcher.is_definition(&line), "bare link ref should match: {line}");
        prop_assert!(
            matcher.standalone_title_need(&line) == Some(true),
            "bare link ref should need title: {line}"
        );
    }

    /// Angle-bracketed destinations must match bare link reference definitions.
    #[test]
    fn angle_bracket_url_matches(label in arb_label(), url in arb_angle_url()) {
        let matcher = LinkReferenceMatcher::production();
        let line = format!("[{label}]: {url}");
        prop_assert!(matcher.is_definition(&line), "angle URL link ref should match: {line}");
        prop_assert_eq!(matcher.standalone_title_need(&line), Some(true));
    }

    /// Indented definitions (0–3 columns) classify; four or more must not.
    #[test]
    fn definition_indentation_bounds(
        label in arb_label(),
        url in arb_bare_url(),
        indent in 0usize..=6usize,
    ) {
        let matcher = LinkReferenceMatcher::production();
        let line = format!("{:indent$}[{label}]: {url}", "", indent = indent);
        let expected = if indent < 4 {
            Some(BlockKind::LinkReferenceDefinition)
        } else {
            None
        };
        prop_assert!(
            classify_block(&line, matcher) == expected,
            "indent {indent} mismatch for: {line}"
        );
    }

    /// Any `[label]: URL "title"` line must match and NOT require a standalone title.
    #[test]
    fn valid_inline_title_link_ref_matches(
        label in arb_label(),
        url in arb_bare_url(),
        title in arb_dq_title(),
    ) {
        let matcher = LinkReferenceMatcher::production();
        let line = format!("[{label}]: {url} {title}");
        prop_assert!(matcher.is_definition(&line), "inline-title link ref should match: {line}");
        prop_assert!(
            matcher.standalone_title_need(&line) == Some(false),
            "inline-title link ref should not need title: {line}"
        );
    }

    /// Single-quoted inline titles must suppress standalone title continuation.
    #[test]
    fn single_quoted_inline_title_matches(
        label in arb_label(),
        url in arb_bare_url(),
        title in arb_sq_title(),
    ) {
        let matcher = LinkReferenceMatcher::production();
        let line = format!("[{label}]: {url} {title}");
        prop_assert!(matcher.is_definition(&line), "sq-title link ref should match: {line}");
        prop_assert_eq!(matcher.standalone_title_need(&line), Some(false));
    }

    /// Parenthesis inline titles must suppress standalone title continuation.
    #[test]
    fn parenthesis_inline_title_matches(
        label in arb_label(),
        url in arb_bare_url(),
        title in arb_paren_title(),
    ) {
        let matcher = LinkReferenceMatcher::production();
        let line = format!("[{label}]: {url} {title}");
        prop_assert!(matcher.is_definition(&line), "paren-title link ref should match: {line}");
        prop_assert_eq!(matcher.standalone_title_need(&line), Some(false));
    }

    /// Escaped characters inside double-quoted titles must still match.
    #[test]
    fn escaped_inline_title_matches(
        label in arb_label(),
        url in arb_bare_url(),
        title in arb_escaped_dq_title(),
    ) {
        let matcher = LinkReferenceMatcher::production();
        let line = format!("[{label}]: {url} {title}");
        prop_assert!(matcher.is_definition(&line), "escaped-title link ref should match: {line}");
        prop_assert_eq!(matcher.standalone_title_need(&line), Some(false));
    }

    /// Lines without a `[label]:` prefix must NOT match.
    #[test]
    fn plain_prose_does_not_match(text in "[A-Za-z ]{1,60}") {
        let matcher = LinkReferenceMatcher::production();
        // Ensure the text cannot accidentally start with `[`.
        prop_assume!(!text.trim_start().starts_with('['));
        prop_assert!(!matcher.is_definition(&text), "plain prose should not match: {text}");
    }

    /// Valid standalone title lines (any delimiter, ≤ 3 leading spaces) must match.
    #[test]
    fn valid_standalone_title_matches(
        spaces in 0usize..=3usize,
        title in arb_title_form(),
    ) {
        let matcher = LinkReferenceMatcher::production();
        let line = format!("{:spaces$}{title}", "", spaces = spaces);
        prop_assert!(matcher.is_standalone_title_line(&line), "standalone title should match: {line}");
    }

    /// Escaped standalone titles must match when indentation is valid.
    #[test]
    fn escaped_standalone_title_matches(
        spaces in 0usize..=3usize,
        title in arb_escaped_dq_title(),
    ) {
        let matcher = LinkReferenceMatcher::production();
        let line = format!("{:spaces$}{title}", "", spaces = spaces);
        prop_assert!(
            matcher.is_standalone_title_line(&line),
            "escaped standalone title should match: {line}"
        );
    }

    /// Four or more leading spaces must disqualify a standalone title.
    #[test]
    fn over_indented_standalone_title_does_not_match(
        extra in 4usize..=8usize,
        title in arb_title_form(),
    ) {
        let matcher = LinkReferenceMatcher::production();
        let line = format!("{:spaces$}{title}", "", spaces = extra);
        prop_assert!(
            !matcher.is_standalone_title_line(&line),
            "over-indented title should not match: {line}"
        );
    }

    /// Label-only reference lines match separately from complete definitions.
    #[test]
    fn bare_label_only_matches(label in arb_label(), trailing_spaces in 0usize..=4usize) {
        let matcher = LinkReferenceMatcher::production();
        let line = format!("[{label}]:{}", " ".repeat(trailing_spaces));
        prop_assert!(matcher.is_bare_label_only(&line));
        prop_assert!(!matcher.is_definition(&line));
    }

    /// Complete definitions are not label-only matches.
    #[test]
    fn complete_definition_is_not_bare_label(label in arb_label(), url in arb_bare_url()) {
        let matcher = LinkReferenceMatcher::production();
        let line = format!("[{label}]: {url}");
        prop_assert!(!matcher.is_bare_label_only(&line));
    }

    /// Indented non-blank lines qualify as URL continuations.
    #[test]
    fn indented_url_continuation_matches(url in arb_bare_url(), indent in 1usize..=4usize) {
        let matcher = LinkReferenceMatcher::production();
        let line = format!("{:indent$}{url}", "", indent = indent);
        prop_assert!(matcher.is_url_continuation_line(&line));
    }

    /// Non-indented text and blank lines are not URL continuations.
    #[test]
    fn invalid_url_continuations_do_not_match(text in "[A-Za-z][A-Za-z0-9 ]{1,60}") {
        let matcher = LinkReferenceMatcher::production();
        prop_assert!(!matcher.is_url_continuation_line(&text));
        prop_assert!(!matcher.is_url_continuation_line(""));
        prop_assert!(!matcher.is_url_continuation_line("   "));
    }

    /// A closed window ignores subsequent lines until opened again.
    #[test]
    fn closed_window_is_inert(line in "\\PC*") {
        let matcher = LinkReferenceMatcher::production();
        let mut window = LinkTitleWindow::Closed;
        prop_assert_eq!(window.observe_next_line(&line, matcher), None);
        prop_assert_eq!(window, LinkTitleWindow::Closed);
    }

    /// Fence context always returns the window to `Closed`.
    #[test]
    fn fence_context_resets_window(
        prior in prop_oneof![
            Just(LinkTitleWindow::Closed),
            Just(LinkTitleWindow::AwaitingStandaloneTitle),
            Just(LinkTitleWindow::AwaitingUrlContinuation),
        ],
    ) {
        let mut window = prior;
        window.observe_fence_context();
        prop_assert_eq!(window, LinkTitleWindow::Closed);
    }

    /// Standalone title lines emit verbatim and close the window.
    #[test]
    fn awaiting_title_line_emits_verbatim(
        spaces in 0usize..=3usize,
        title in arb_title_form(),
    ) {
        let matcher = LinkReferenceMatcher::production();
        let line = format!("{:spaces$}{title}", "", spaces = spaces);
        prop_assume!(matcher.is_standalone_title_line(&line));
        let mut window = LinkTitleWindow::AwaitingStandaloneTitle;
        prop_assert_eq!(
            window.observe_next_line(&line, matcher),
            Some(LinkTitleWindowOutcome::EmitVerbatim)
        );
        prop_assert_eq!(window, LinkTitleWindow::Closed);
    }

    /// Blank continuation lines emit verbatim and close the window.
    #[test]
    fn awaiting_blank_line_emits_verbatim(pad in 0usize..=4usize) {
        let matcher = LinkReferenceMatcher::production();
        let line = " ".repeat(pad);
        prop_assume!(line.trim().is_empty());
        let mut window = LinkTitleWindow::AwaitingStandaloneTitle;
        prop_assert_eq!(
            window.observe_next_line(&line, matcher),
            Some(LinkTitleWindowOutcome::EmitVerbatim)
        );
        prop_assert_eq!(window, LinkTitleWindow::Closed);
    }

    /// Non-title prose reprocesses through normal wrapping and closes the window.
    #[test]
    fn awaiting_prose_reprocesses(text in "[A-Za-z][A-Za-z0-9 ]{1,60}") {
        let matcher = LinkReferenceMatcher::production();
        prop_assume!(!matcher.is_standalone_title_line(&text));
        let mut window = LinkTitleWindow::AwaitingStandaloneTitle;
        prop_assert_eq!(
            window.observe_next_line(&text, matcher),
            Some(LinkTitleWindowOutcome::Reprocess)
        );
        prop_assert_eq!(window, LinkTitleWindow::Closed);
    }

    /// URL continuations emit verbatim and then await an optional standalone title.
    #[test]
    fn awaiting_url_continuation_emits_verbatim(url in arb_bare_url(), indent in 1usize..=3usize) {
        let matcher = LinkReferenceMatcher::production();
        let line = format!("{:indent$}{url}", "", indent = indent);
        let mut window = LinkTitleWindow::AwaitingUrlContinuation;
        prop_assert_eq!(
            window.observe_next_line(&line, matcher),
            Some(LinkTitleWindowOutcome::EmitVerbatim)
        );
        prop_assert_eq!(window, LinkTitleWindow::AwaitingStandaloneTitle);
    }

    /// Inline titles on URL continuations emit verbatim and close the window.
    #[test]
    fn url_continuation_inline_title_closes_window(
        url in arb_bare_url(),
        title in arb_dq_title(),
        indent in 1usize..=3usize,
    ) {
        let matcher = LinkReferenceMatcher::production();
        let line = format!("{:indent$}{url} {title}", "", indent = indent);
        let mut window = LinkTitleWindow::AwaitingUrlContinuation;
        prop_assert_eq!(
            window.observe_next_line(&line, matcher),
            Some(LinkTitleWindowOutcome::EmitVerbatim)
        );
        prop_assert_eq!(window, LinkTitleWindow::Closed);
    }

    /// Blank lines after a label-only reference emit verbatim and close the window.
    #[test]
    fn awaiting_url_continuation_blank_line_emits_verbatim(pad in 0usize..=4usize) {
        let matcher = LinkReferenceMatcher::production();
        let line = " ".repeat(pad);
        let mut window = LinkTitleWindow::AwaitingUrlContinuation;
        prop_assert_eq!(
            window.observe_next_line(&line, matcher),
            Some(LinkTitleWindowOutcome::EmitVerbatim)
        );
        prop_assert_eq!(window, LinkTitleWindow::Closed);
    }

    /// Non-continuation prose after a label-only reference is reprocessed normally.
    #[test]
    fn awaiting_url_continuation_prose_reprocesses(text in "[A-Za-z][A-Za-z0-9 ]{1,60}") {
        let matcher = LinkReferenceMatcher::production();
        let mut window = LinkTitleWindow::AwaitingUrlContinuation;
        prop_assert_eq!(
            window.observe_next_line(&text, matcher),
            Some(LinkTitleWindowOutcome::Reprocess)
        );
        prop_assert_eq!(window, LinkTitleWindow::Closed);
    }
}

proptest! {
    #[test]
    fn some_outcome_always_closes_terminal_window(
        prior in prop_oneof![
            Just(LinkTitleWindow::AwaitingUrlContinuation),
            Just(LinkTitleWindow::AwaitingStandaloneTitle),
        ],
        line in "[ -~]{1,80}",
    ) {
        let matcher = LinkReferenceMatcher::production();
        let mut window = prior;
        let outcome = window.observe_next_line(&line, matcher);
        prop_assume!(outcome.is_some());
        prop_assume!(window != LinkTitleWindow::AwaitingStandaloneTitle);
        prop_assert_eq!(window, LinkTitleWindow::Closed);
    }

    #[test]
    fn url_continuation_then_title_or_prose_closes_window(
        url in arb_bare_url(),
        second_line in prop_oneof![arb_title_form(), Just(String::new()), "[ -~]{1,80}".prop_map(|s| s)],
    ) {
        let matcher = LinkReferenceMatcher::production();
        let mut window = LinkTitleWindow::Closed;
        let first_line = format!("  {url}");

        prop_assume!(matcher.is_url_continuation_line(&first_line));
        window.observe_bare_label();
        prop_assert_eq!(
            window.observe_next_line(&first_line, matcher),
            Some(LinkTitleWindowOutcome::EmitVerbatim)
        );
        prop_assert_eq!(window, LinkTitleWindow::AwaitingStandaloneTitle);

        prop_assert!(window.observe_next_line(&second_line, matcher).is_some());
        prop_assert_eq!(window, LinkTitleWindow::Closed);
    }
}