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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
//! Idempotence regression corpus for issue #468.
//!
//! Every fixture under `tests/data/idempotence/` is formatted twice through the
//! real binary with its recorded flag set. A case passes when the second pass
//! is byte-identical to the first, and when a thematic break recorded with the
//! case survives as a standalone line instead of being absorbed into a
//! paragraph.
//!
//! The corpus covers both defect classes from the issue. Class A normalises a
//! thematic break and then re-wraps it on the next pass; class B re-wraps the
//! tail of a list item whose first line ends with a parenthesised inline code
//! span. `R1` and `R2` were already fixed points; they are here because a fix
//! that absorbed breaks on the first pass instead of the second would satisfy
//! the idempotence cases for the wrong reason.
//! `A3` is a Setext heading pair, so its underline must survive without
//! `--headings` rather than being normalized as a thematic break.
//!
//! Case `H1` reaches the same invariant through `--headings`, which the
//! `make fmt` flag set does not enable: a candidate line that is itself a block
//! start must not swallow the underline below it. It is recorded separately
//! because the first defect classes were all reachable without `--headings`.
//!
//! Class `T` is a second `--headings` defect, found by the property suite after
//! the first was fixed. A table whose delimiter row is the last line before a
//! thematic break is not a fixed point: the delimiter row is table syntax, so
//! the `---` below it is a break, but the Setext pass read the pair as a
//! level-2 heading and rewrote the delimiter row as `## | --- | --- |`. The
//! table then had no delimiter row, so the next pass padded its header row
//! differently and the output never settled. `T6` is the control: a line of
//! prose between the delimiter row and the `---` still becomes a heading, so
//! the class is the adjacency and not Setext conversion itself.
//!
//! `T7` is the same class one row further down, found by the widened generators
//! of issue #493: the row above the `---` is the table's last body row rather
//! than its delimiter row. Refusing only delimiter rows left that row to be
//! consumed as a heading, which narrowed the rows above it on the next pass,
//! so the screen covers table rows of every kind.
//!
//! Class `E` is the inline-wrapping seam recorded in the ADR 0006 addendum. A
//! paragraph ending in a bolded inline code span, followed by a bare `[1]` on
//! the line below, put the opener at the end of one line and the digits at the
//! start of the next, so the second pass read the pair as a single paragraph
//! again and rejoined them with a space. The reference line is recorded as the
//! structural expectation, so a fix that settled the output by dropping it
//! fails rather than passes.
//!
//! Every case also records the structural expectation its fix must preserve, so
//! a fix that reached a fixed point by consuming the break on the *first* pass
//! fails rather than passing for the wrong reason.

use std::{
    fs,
    path::{Path, PathBuf},
};

use assert_cmd::Command;
use mdtablefix::THEMATIC_BREAK_LEN;
use tempfile::TempDir;

/// Flag set recorded for the class B, `--wrap`-only cases.
const WRAP: &[&str] = &["--wrap"];
/// Flag set for fixtures where footnotes must be final before wrapping.
const WRAP_FOOTNOTES: &[&str] = &["--wrap", "--footnotes"];
/// Flag set for fixtures where list markers must be final before wrapping.
const WRAP_RENUMBER: &[&str] = &["--wrap", "--renumber"];
/// Flag set recorded for the class A cases that need break normalisation.
const WRAP_BREAKS: &[&str] = &["--wrap", "--breaks"];
/// Flag set `make fmt` runs, matching the estate `markdown-formatting-baseline`
/// rule's `MDTABLEFIX_RULES`.
const FULL: &[&str] = &["--wrap", "--renumber", "--breaks", "--ellipsis", "--fences"];
/// Flag set recorded for the heading cases, which `make fmt` does not enable.
const HEADINGS: &[&str] = &["--footnotes", "--code-emphasis", "--headings"];
/// Flag set recorded for the class `T` cases, which isolate the Setext pass.
///
/// `--headings` alone reaches the defect: `HEADINGS` also enables `--footnotes`
/// and `--code-emphasis`, and the class `T` fixtures must pin the Setext pass
/// rather than whatever those two flags happen to do to the output.
const HEADINGS_ONLY: &[&str] = &["--headings"];

/// Line that a case must keep standalone in the formatted output.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Standalone {
    /// The 70-underscore line `--breaks` writes for a thematic break.
    NormalisedBreak,
    /// A thematic break that must survive spelling-for-spelling.
    Literal(&'static str),
    /// A table delimiter row that must survive as table syntax.
    DelimiterRow,
    /// A table row carrying `cell` that must survive as table syntax.
    Row(&'static str),
}

impl Standalone {
    /// Returns whether the formatted output satisfies this expectation.
    fn holds(self, lines: &[String]) -> bool {
        match self {
            Self::NormalisedBreak => {
                let expected = "_".repeat(THEMATIC_BREAK_LEN);
                lines.iter().any(|line| line == &expected)
            }
            Self::Literal(expected) => lines.iter().any(|line| line.as_str() == expected),
            Self::DelimiterRow => lines.iter().any(|line| is_delimiter_row(line)),
            Self::Row(cell) => lines
                .iter()
                .any(|line| line.trim_start().starts_with('|') && line.contains(cell)),
        }
    }

    /// Describes this expectation for an assertion message.
    fn describe(self) -> String {
        match self {
            Self::NormalisedBreak => format!("{:?}", "_".repeat(THEMATIC_BREAK_LEN)),
            Self::Literal(expected) => format!("{expected:?}"),
            Self::DelimiterRow => "a table delimiter row".to_string(),
            Self::Row(cell) => format!("a table row carrying {cell:?}"),
        }
    }
}

/// Returns whether `line` is still a table delimiter row.
///
/// The shape is the one the table parser reads as the alignment row: built only
/// from pipes, colons, dashes, and spaces, carrying at least one pipe and one
/// dash. The reported class rewrote the row as `## | --- | --- |`, so the hash
/// marker alone disqualifies the line.
fn is_delimiter_row(line: &str) -> bool {
    line.contains('|')
        && line.contains('-')
        && line
            .chars()
            .all(|ch| matches!(ch, '|' | ':' | '-') || ch.is_whitespace())
}

/// One case in the reproduction corpus.
struct IdempotenceCase {
    /// Stable identifier matching the corpus recorded in the issue.
    id: &'static str,
    /// Fixture filename under `tests/data/idempotence/`.
    fixture: &'static str,
    /// Flags recorded for this case.
    flags: &'static [&'static str],
    /// Structural expectations the formatted output must satisfy.
    ///
    /// A slice rather than a single value because the class `T` control records
    /// two: its delimiter row must survive *and* the prose line below it must
    /// still convert. Either one alone would let a regression through.
    expects: &'static [Standalone],
}

/// The reproduction corpus, one fixture per case.
const CASES: &[IdempotenceCase] = &[
    IdempotenceCase {
        id: "A1_hyphen_break_unterminated",
        fixture: "A1_hyphen_break_unterminated.dat",
        flags: WRAP_BREAKS,
        expects: &[Standalone::NormalisedBreak],
    },
    IdempotenceCase {
        id: "A2_hyphen_break_terminated",
        fixture: "A2_hyphen_break_terminated.dat",
        flags: WRAP_BREAKS,
        expects: &[Standalone::NormalisedBreak],
    },
    IdempotenceCase {
        id: "A3_setext_underline",
        fixture: "A3_setext_underline.dat",
        flags: WRAP_BREAKS,
        expects: &[Standalone::Literal("-----")],
    },
    IdempotenceCase {
        id: "A4_frontmatter_fixture",
        fixture: "A4_frontmatter_fixture.dat",
        flags: FULL,
        expects: &[Standalone::NormalisedBreak, Standalone::Literal("-----")],
    },
    IdempotenceCase {
        id: "B1_code_span_tail",
        fixture: "B1_code_span_tail.dat",
        flags: WRAP,
        expects: &[],
    },
    IdempotenceCase {
        id: "B2_code_span_tail_full_flags",
        fixture: "B2_code_span_tail_full_flags.dat",
        flags: FULL,
        expects: &[],
    },
    IdempotenceCase {
        id: "R1_asterisk_break_absorbed",
        fixture: "R1_asterisk_break_absorbed.dat",
        flags: WRAP,
        expects: &[Standalone::Literal("***")],
    },
    IdempotenceCase {
        id: "R2_underscore_break_absorbed",
        fixture: "R2_underscore_break_absorbed.dat",
        flags: WRAP,
        expects: &[Standalone::Literal("___")],
    },
    IdempotenceCase {
        id: "H1_atx_heading_above_break",
        fixture: "H1_atx_heading_above_break.dat",
        flags: HEADINGS,
        expects: &[Standalone::Literal("---")],
    },
    // Class `E`: the inline wrapping path split a bare bracket reference across
    // the wrap boundary, so the second pass read `[` and `1]` as one paragraph
    // again and rejoined them with a space. The reference is the whole case, so
    // a fix that reached a fixed point by dropping it fails here.
    IdempotenceCase {
        id: "E1_bracket_after_bold_code",
        fixture: "E1_bracket_after_bold_code.dat",
        flags: WRAP,
        expects: &[Standalone::Literal("[1]")],
    },
    // Issue #507: an alphabetic label, after a space and touching the prose.
    IdempotenceCase {
        id: "E2_alphabetic_label_after_space",
        fixture: "issue_507_label_after_space.dat",
        flags: WRAP,
        expects: &[Standalone::Literal("[a]")],
    },
    IdempotenceCase {
        id: "E3_alphabetic_label_touching",
        fixture: "issue_507_label_touching.dat",
        flags: WRAP,
        expects: &[],
    },
    IdempotenceCase {
        id: "L1_footnote_width_boundary",
        fixture: "issue_484_footnotes_wrap.dat",
        flags: WRAP_FOOTNOTES,
        expects: &[],
    },
    IdempotenceCase {
        id: "L2_renumber_width_boundary",
        fixture: "issue_484_renumber_wrap.dat",
        flags: WRAP_RENUMBER,
        expects: &[],
    },
    // Class `T`: a delimiter row directly above a thematic break must keep the
    // break and stay a delimiter row. `T1` has no trailing newline and `T2` does,
    // because a missing terminator is a distinct input the reader sees.
    IdempotenceCase {
        id: "T1_delimiter_then_break",
        fixture: "T1_delimiter_then_break.dat",
        flags: HEADINGS_ONLY,
        expects: &[Standalone::DelimiterRow, Standalone::Literal("---")],
    },
    IdempotenceCase {
        id: "T2_delimiter_then_break_terminated",
        fixture: "T2_delimiter_then_break_terminated.dat",
        flags: HEADINGS_ONLY,
        expects: &[Standalone::DelimiterRow, Standalone::Literal("---")],
    },
    IdempotenceCase {
        id: "T3_leading_prose",
        fixture: "T3_leading_prose.dat",
        flags: HEADINGS_ONLY,
        expects: &[Standalone::DelimiterRow, Standalone::Literal("---")],
    },
    IdempotenceCase {
        id: "T4_trailing_body_row",
        fixture: "T4_trailing_body_row.dat",
        flags: HEADINGS_ONLY,
        expects: &[Standalone::DelimiterRow, Standalone::Literal("---")],
    },
    IdempotenceCase {
        id: "T5_delimiter_first",
        fixture: "T5_delimiter_first.dat",
        flags: HEADINGS_ONLY,
        expects: &[Standalone::DelimiterRow, Standalone::Literal("---")],
    },
    // The control: prose between the delimiter row and the break absorbs the
    // break through ordinary Setext conversion, which `--headings` exists to
    // perform. The delimiter row survives regardless, so the class is the
    // adjacency rather than Setext conversion itself.
    IdempotenceCase {
        id: "T6_prose_between",
        fixture: "T6_prose_between.dat",
        flags: HEADINGS_ONLY,
        expects: &[Standalone::DelimiterRow, Standalone::Literal("## Title")],
    },
    // The same adjacency one row further down. The table's last body row is its
    // widest, so consuming that row as a heading leaves the rows above measured
    // without it and they are padded a column narrower on the pass after: the
    // row has to survive as table syntax, as `T1` to `T5` require of the row
    // that carries the dashes.
    IdempotenceCase {
        id: "T7_body_row_then_break",
        fixture: "T7_body_row_then_break.dat",
        flags: HEADINGS_ONLY,
        expects: &[
            Standalone::DelimiterRow,
            Standalone::Row("ccccc"),
            Standalone::Literal("---"),
        ],
    },
];

/// Returns the fixture directory for the corpus.
fn corpus_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("data")
        .join("idempotence")
}

/// Returns the path of `fixture` in the corpus directory.
fn corpus_path(fixture: &str) -> PathBuf { corpus_dir().join(fixture) }

/// Formats `text` once with `flags` through the real binary.
///
/// The input is written to `directory`, formatted in place, and read back. The
/// binary must succeed with an empty stdout and stderr.
fn format_once(
    directory: &TempDir,
    name: &str,
    text: &[u8],
    flags: &[&str],
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
    let path = directory.path().join(name);
    fs::write(&path, text)?;

    Command::cargo_bin("mdtablefix")?
        .args(flags)
        .arg("--in-place")
        .arg(&path)
        .assert()
        .success()
        .stdout("")
        .stderr("");

    Ok(fs::read(&path)?)
}

/// Returns the output lines of `bytes`, for standalone-line assertions.
fn output_lines(bytes: &[u8]) -> Vec<String> {
    String::from_utf8_lossy(bytes)
        .lines()
        .map(str::to_string)
        .collect()
}

#[test]
fn corpus_case_ids_and_fixtures_are_unique_and_present() {
    let mut fixtures = std::collections::BTreeSet::new();

    for case in CASES {
        assert!(
            fixtures.insert(case.fixture),
            "duplicate fixture {} in case {}",
            case.fixture,
            case.id,
        );
        let path = corpus_path(case.fixture);
        assert!(path.exists(), "missing fixture {}", path.display());
        assert_eq!(
            path.extension().and_then(|extension| extension.to_str()),
            Some("dat"),
            "fixture {} must be a .dat file",
            case.fixture,
        );
    }

    let on_disk: std::collections::BTreeSet<String> = fs::read_dir(corpus_dir())
        .expect("reading the corpus directory")
        .map(|entry| {
            entry
                .expect("reading a corpus entry")
                .file_name()
                .to_string_lossy()
                .into_owned()
        })
        .collect();
    let recorded: std::collections::BTreeSet<String> =
        CASES.iter().map(|case| case.fixture.to_string()).collect();

    assert_eq!(
        on_disk, recorded,
        "every fixture on disk must be recorded in the case table",
    );
}

#[test]
fn corpus_records_a_flag_set_for_every_case() {
    for case in CASES {
        assert!(!case.flags.is_empty(), "case {} records no flags", case.id);
        assert!(
            case.flags.iter().all(|flag| flag.starts_with("--")),
            "case {} records a non-flag argument",
            case.id,
        );
    }
}

#[test]
fn every_corpus_case_is_a_fixed_point() -> Result<(), Box<dyn std::error::Error>> {
    for case in CASES {
        let original = fs::read(corpus_path(case.fixture))?;
        let directory = TempDir::new()?;

        let once = format_once(&directory, case.fixture, &original, case.flags)?;
        let twice = format_once(&directory, case.fixture, &once, case.flags)?;

        assert_eq!(
            String::from_utf8_lossy(&twice),
            String::from_utf8_lossy(&once),
            "case {} is not a fixed point under {:?}",
            case.id,
            case.flags,
        );

        let lines = output_lines(&once);
        for expectation in case.expects {
            assert!(
                expectation.holds(&lines),
                "case {} lost {}; output was {lines:?}",
                case.id,
                expectation.describe(),
            );
        }
    }

    Ok(())
}