use std::fs;
use assert_cmd::Command;
use mdtablefix::report::LineDelta;
use proptest::prelude::*;
use rstest::rstest;
use tempfile::tempdir;
const FLAGS: [&str; 8] = [
"--wrap",
"--renumber",
"--breaks",
"--ellipsis",
"--fences",
"--footnotes",
"--code-emphasis",
"--headings",
];
const CORPUS: &[(&str, &str)] = &[
("ragged_table", "|A|B|\n|---|---|\n|1|2|\n"),
(
"clean_table",
"| A | B |\n| --- | --- |\n| 1 | 2 |\n",
),
(
"prose",
"one two three four five six seven eight nine ten eleven twelve\n",
),
("ordered_list", "1. alpha\n1. beta\n1. gamma\n"),
("thematic_break", "alpha\n\n***\n\nbeta\n"),
("ellipsis", "alpha ... beta\n"),
("long_fence", "````sh\necho hi\n````\n"),
("footnotes", "alpha[1]\n\n1. beta\n"),
("code_emphasis", "alpha *`beta`* gamma\n"),
("setext_heading", "Title\n=====\n"),
("crlf_table", "|A|B|\r\n|---|---|\r\n|1|2|\r\n"),
("bom_table", "\u{FEFF}|A|B|\n|---|---|\n|1|2|\n"),
("unterminated_table", "|A|B|\n|---|---|\n|1|2|"),
("empty", ""),
];
fn token_count(text: &str) -> usize {
let line_feeds = text.matches('\n').count();
let lone_carriage_returns = text
.match_indices('\r')
.filter(|(index, _)| !text[index + 1..].starts_with('\n'))
.count();
let unterminated =
usize::from(!text.is_empty() && !text.ends_with('\n') && !text.ends_with('\r'));
line_feeds + lone_carriage_returns + unterminated
}
fn format_with_cli(input: &str, flags: &[&str]) -> String {
let dir = tempdir().expect("temporary directory");
let path = dir.path().join("input.dat");
fs::write(&path, input).expect("write input");
let stdout = Command::cargo_bin("mdtablefix")
.expect("cargo binary")
.args(flags)
.arg(&path)
.assert()
.success()
.get_output()
.stdout
.clone();
String::from_utf8(stdout).expect("stdout is UTF-8")
}
fn related_texts() -> impl Strategy<Value = (String, String)> {
(markdown_document(), 0..=5u8).prop_map(|(document, edit)| {
let formatted = match edit {
0 => document.clone(),
1 => document.replace('\n', "\r\n"),
2 => document.trim_end_matches('\n').to_string(),
3 => format!("{document}extra line\n"),
4 => document.replace("prose words here", "prose"),
_ => document.replacen('\n', "\r\n", 1),
};
(document, formatted)
})
}
fn text_pairs() -> impl Strategy<Value = (String, String)> {
prop_oneof![
1 => (any::<String>(), any::<String>()),
3 => related_texts(),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn count_conserves_tokens_and_agrees_with_byte_equality(
(original, formatted) in text_pairs(),
) {
let delta = LineDelta::between(&original, &formatted);
let before = token_count(&original).cast_signed();
let after = token_count(&formatted).cast_signed();
prop_assert_eq!(
after,
before + delta.insertions().cast_signed() - delta.deletions().cast_signed(),
"tokens are conserved: after = before + insertions - deletions"
);
prop_assert_eq!(
delta.has_changes(),
original != formatted,
"the delta is non-zero exactly when the bytes differ"
);
}
}
#[rstest]
#[case::pure_insertion(
include_str!("data/numstat/pure_insertion.before.dat"),
include_str!("data/numstat/pure_insertion.after.dat"),
1,
0
)]
#[case::pure_deletion(
include_str!("data/numstat/pure_deletion.before.dat"),
include_str!("data/numstat/pure_deletion.after.dat"),
0,
1
)]
#[case::table_replacement(
include_str!("data/numstat/table_replacement.before.dat"),
include_str!("data/numstat/table_replacement.after.dat"),
3,
3
)]
#[case::long_file_single_line(
include_str!("data/numstat/long_file_single_line.before.dat"),
include_str!("data/numstat/long_file_single_line.after.dat"),
1,
1
)]
fn count_matches_git_numstat(
#[case] before: &str,
#[case] after: &str,
#[case] insertions: usize,
#[case] deletions: usize,
) {
let delta = LineDelta::between(before, after);
assert_eq!(
(delta.insertions(), delta.deletions()),
(insertions, deletions)
);
}
#[test]
fn count_handles_empty_and_identical_texts() {
let inserted = LineDelta::between("", "alpha\n");
assert_eq!((inserted.insertions(), inserted.deletions()), (1, 0));
let deleted = LineDelta::between("alpha\n", "");
assert_eq!((deleted.insertions(), deleted.deletions()), (0, 1));
let identical = LineDelta::between("alpha\n", "alpha\n");
assert_eq!((identical.insertions(), identical.deletions()), (0, 0));
assert!(!identical.has_changes());
}
#[rstest]
#[case::same_bytes("alpha\nbeta\n", "alpha\nbeta\n", false)]
#[case::line_endings_only("alpha\nbeta\n", "alpha\r\nbeta\r\n", true)]
#[case::trailing_newline_only("alpha\nbeta\n", "alpha\nbeta", true)]
fn agree_reports_drift_exactly_when_bytes_differ(
#[case] original: &str,
#[case] formatted: &str,
#[case] expected: bool,
) {
assert_eq!(
LineDelta::between(original, formatted).has_changes(),
expected
);
}
#[rstest]
#[case(&[])]
#[case(&["--wrap"])]
#[case(&["--renumber"])]
#[case(&["--breaks"])]
#[case(&["--ellipsis"])]
#[case(&["--fences"])]
#[case(&["--footnotes"])]
#[case(&["--code-emphasis"])]
#[case(&["--headings"])]
#[case(&[
"--wrap",
"--renumber",
"--breaks",
"--ellipsis",
"--fences",
"--footnotes",
"--code-emphasis",
"--headings",
])]
fn formatting_is_idempotent(#[case] flags: &[&str]) {
let mut drifted = 0;
for (name, input) in CORPUS {
let once = format_with_cli(input, flags);
let twice = format_with_cli(&once, flags);
assert_eq!(twice, once, "{name} must be a fixed point under {flags:?}");
if once != *input {
drifted += 1;
}
}
assert!(
drifted > 0,
"no corpus document drifted under {flags:?}, so the case is vacuous"
);
}
fn markdown_lines() -> impl Strategy<Value = &'static str> {
prop_oneof![
Just("|A|B|"),
Just("|---|---|"),
Just("|1|2|"),
Just(""),
Just("prose words here"),
Just("1. item"),
Just("***"),
Just("```sh"),
Just("echo hi"),
Just("```"),
Just("---"),
Just("Title"),
]
}
fn markdown_document() -> impl Strategy<Value = String> {
(
prop::collection::vec(markdown_lines(), 0..12),
prop_oneof![Just("\n"), Just("\r\n")],
any::<bool>(),
any::<bool>(),
)
.prop_map(|(lines, ending, bom, terminated)| {
let mut text = String::new();
if bom {
text.push('\u{FEFF}');
}
for (index, line) in lines.iter().enumerate() {
text.push_str(line);
if index + 1 < lines.len() || terminated {
text.push_str(ending);
}
}
text
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(48))]
#[test]
fn generated_documents_reach_a_fixed_point(
document in markdown_document(),
mask in any::<u8>(),
) {
let flags: Vec<&str> = FLAGS
.iter()
.enumerate()
.filter(|(index, _)| mask & (1 << index) != 0)
.map(|(_, flag)| *flag)
.collect();
let once = format_with_cli(&document, &flags);
let twice = format_with_cli(&once, &flags);
prop_assert_eq!(twice, once, "document must be a fixed point under {:?}", flags);
}
}