use std::{
fs,
path::{Path, PathBuf},
};
use assert_cmd::Command;
use mdtablefix::THEMATIC_BREAK_LEN;
use tempfile::TempDir;
const WRAP: &[&str] = &["--wrap"];
const WRAP_FOOTNOTES: &[&str] = &["--wrap", "--footnotes"];
const WRAP_RENUMBER: &[&str] = &["--wrap", "--renumber"];
const WRAP_BREAKS: &[&str] = &["--wrap", "--breaks"];
const FULL: &[&str] = &["--wrap", "--renumber", "--breaks", "--ellipsis", "--fences"];
const HEADINGS: &[&str] = &["--footnotes", "--code-emphasis", "--headings"];
const HEADINGS_ONLY: &[&str] = &["--headings"];
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Standalone {
NormalisedBreak,
Literal(&'static str),
DelimiterRow,
Row(&'static str),
}
impl Standalone {
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)),
}
}
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:?}"),
}
}
}
fn is_delimiter_row(line: &str) -> bool {
line.contains('|')
&& line.contains('-')
&& line
.chars()
.all(|ch| matches!(ch, '|' | ':' | '-') || ch.is_whitespace())
}
struct IdempotenceCase {
id: &'static str,
fixture: &'static str,
flags: &'static [&'static str],
expects: &'static [Standalone],
}
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("---")],
},
IdempotenceCase {
id: "E1_bracket_after_bold_code",
fixture: "E1_bracket_after_bold_code.dat",
flags: WRAP,
expects: &[Standalone::Literal("[1]")],
},
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: &[],
},
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("---")],
},
IdempotenceCase {
id: "T6_prose_between",
fixture: "T6_prose_between.dat",
flags: HEADINGS_ONLY,
expects: &[Standalone::DelimiterRow, Standalone::Literal("## Title")],
},
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("---"),
],
},
];
fn corpus_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("data")
.join("idempotence")
}
fn corpus_path(fixture: &str) -> PathBuf { corpus_dir().join(fixture) }
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)?)
}
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(())
}