use mkdlint::{Config, LintOptions, RuleConfig, apply_fixes, lint_sync, parser};
use proptest::prelude::*;
use std::collections::HashMap;
fn md_line() -> impl Strategy<Value = String> {
prop_oneof![
"[a-zA-Z0-9 ,.!?]{0,120}".prop_map(|s| s),
(1..=6u8, "[a-zA-Z0-9 ]{0,60}").prop_map(|(level, text)| format!(
"{} {}",
"#".repeat(level as usize),
text
)),
"[a-zA-Z0-9 ]{1,40}".prop_map(|text| format!("- {}", text)),
(1..100u32, "[a-zA-Z0-9 ]{1,40}").prop_map(|(n, text)| format!("{}. {}", n, text)),
"[a-z]{0,10}".prop_map(|lang| format!("```{}\ncode\n```", lang)),
"[a-zA-Z0-9 ]{1,60}".prop_map(|text| format!("> {}", text)),
("[a-zA-Z0-9 ]{1,20}", "[a-z]{3,10}")
.prop_map(|(text, url)| format!("[{}]({})", text, url)),
("[a-zA-Z0-9 ]{0,20}", "[a-z.]{3,15}")
.prop_map(|(alt, src)| format!("", alt, src)),
Just(String::new()),
prop_oneof![Just("---".to_string()), Just("***".to_string()),],
("[a-zA-Z0-9]{1,10}", "[a-zA-Z0-9]{1,10}").prop_map(|(a, b)| format!("| {} | {} |", a, b)),
]
}
fn md_document() -> impl Strategy<Value = String> {
prop::collection::vec(md_line(), 1..50).prop_map(|lines| {
let mut doc = lines.join("\n");
doc.push('\n');
doc
})
}
fn arbitrary_utf8() -> impl Strategy<Value = String> {
prop::string::string_regex(".{0,500}")
.unwrap()
.prop_map(|s| s)
}
fn md_line_extended() -> impl Strategy<Value = String> {
prop_oneof![
"[a-zA-Z0-9 ,.!?]{0,120}".prop_map(|s| s),
(1..=6u8, "[a-zA-Z0-9 ]{0,60}").prop_map(|(level, text)| format!(
"{} {}",
"#".repeat(level as usize),
text
)),
"[a-zA-Z0-9 ]{1,40}".prop_map(|text| format!("- {}", text)),
(1..100u32, "[a-zA-Z0-9 ]{1,40}").prop_map(|(n, text)| format!("{}. {}", n, text)),
"[a-z]{0,10}".prop_map(|lang| format!("```{}\ncode\n```", lang)),
"[a-zA-Z0-9 ]{1,60}".prop_map(|text| format!("> {}", text)),
("[a-zA-Z0-9 ]{1,20}", "[a-z]{3,10}")
.prop_map(|(text, url)| format!("[{}]({})", text, url)),
("[a-zA-Z0-9 ]{0,20}", "[a-z.]{3,15}")
.prop_map(|(alt, src)| format!("", alt, src)),
Just(String::new()),
prop_oneof![Just("---".to_string()), Just("***".to_string()),],
("[a-zA-Z0-9]{1,10}", "[a-zA-Z0-9]{1,10}").prop_map(|(a, b)| format!("| {} | {} |", a, b)),
"[\\p{L}\\p{N} ,]{0,60}".prop_map(|s| s),
"[a-zA-Z0-9 ]{1,40}".prop_map(|text| format!("{}\n======", text)),
"[a-zA-Z0-9 ]{1,40}".prop_map(|text| format!("{}\n------", text)),
"[a-z]{0,10}".prop_map(|lang| format!("~~~{}\ncode\n~~~", lang)),
"[a-zA-Z0-9 ]{1,30}".prop_map(|text| format!(" - {}", text)),
"[a-zA-Z0-9 ]{0,30}".prop_map(|text| format!("<div>{}</div>", text)),
]
}
fn md_document_extended() -> impl Strategy<Value = String> {
prop::collection::vec(md_line_extended(), 1..50).prop_map(|lines| {
let mut doc = lines.join("\n");
doc.push('\n');
doc
})
}
fn lint_string(markdown: &str) -> Vec<mkdlint::LintError> {
let mut strings = HashMap::new();
strings.insert("test.md".to_string(), markdown.to_string());
let options = LintOptions {
strings,
..Default::default()
};
let results = lint_sync(&options).unwrap();
results.get("test.md").unwrap_or(&[]).to_vec()
}
fn lint_string_with_config(markdown: &str, config: Config) -> Vec<mkdlint::LintError> {
let mut strings = HashMap::new();
strings.insert("test.md".to_string(), markdown.to_string());
let options = LintOptions {
strings,
config: Some(config),
..Default::default()
};
let results = lint_sync(&options).unwrap();
results.get("test.md").unwrap_or(&[]).to_vec()
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(500))]
#[test]
fn lint_never_panics_structured(doc in md_document()) {
let _ = lint_string(&doc);
}
#[test]
fn lint_never_panics_arbitrary(input in arbitrary_utf8()) {
let _ = lint_string(&input);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(500))]
#[test]
fn parser_never_panics(input in arbitrary_utf8()) {
let _ = parser::parse(&input);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(200))]
#[test]
fn apply_fixes_roundtrip_safe(doc in md_document()) {
let errors = lint_string(&doc);
let fixed = apply_fixes(&doc, &errors);
let _ = lint_string(&fixed);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(300))]
#[test]
fn error_line_numbers_in_bounds(doc in md_document()) {
let line_count = doc.lines().count().max(1);
let errors = lint_string(&doc);
for error in &errors {
prop_assert!(
error.line_number >= 1 && error.line_number <= line_count,
"Line {} out of bounds (document has {} lines). Rule: {:?}",
error.line_number, line_count, error.rule_names
);
}
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(300))]
#[test]
fn error_range_within_line(doc in md_document()) {
let lines: Vec<&str> = doc.lines().collect();
let errors = lint_string(&doc);
for error in &errors {
if let Some((start, len)) = error.error_range {
let line_idx = error.line_number.saturating_sub(1);
if line_idx < lines.len() {
let line_len = lines[line_idx].len();
prop_assert!(
start >= 1,
"error_range start must be 1-based, got {}. Rule: {:?}",
start, error.rule_names
);
prop_assert!(
start.saturating_sub(1) + len <= line_len + 1,
"error_range ({}, {}) exceeds line length {} at line {}. Rule: {:?}",
start, len, line_len, error.line_number, error.rule_names
);
}
}
}
}
}
fn to_crlf(doc: &str) -> String {
doc.replace("\r\n", "\n").replace('\n', "\r\n")
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(200))]
#[test]
fn fixes_preserve_lf_line_endings(doc in md_document()) {
let errors = lint_string(&doc);
let fixed = apply_fixes(&doc, &errors);
prop_assert!(
!fixed.contains("\r\n"),
"LF document should not gain CRLF after fix"
);
}
#[test]
fn fixes_preserve_crlf_line_endings(doc in md_document()) {
let crlf_doc = to_crlf(&doc);
let errors = lint_string(&crlf_doc);
let fixed = apply_fixes(&crlf_doc, &errors);
for (i, byte) in fixed.bytes().enumerate() {
if byte == b'\n' && i > 0 {
prop_assert!(
fixed.as_bytes()[i - 1] == b'\r',
"CRLF document should not gain bare LF after fix. \
Found bare \\n at byte {}. Fixed content: {:?}",
i, &fixed[..fixed.len().min(200)]
);
}
}
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(200))]
#[test]
fn all_rules_disabled_yields_zero_errors(doc in md_document()) {
let config = Config {
default: Some(false),
..Default::default()
};
let errors = lint_string_with_config(&doc, config);
prop_assert_eq!(
errors.len(), 0,
"Disabling all rules should produce zero errors, got {}",
errors.len()
);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn single_rule_only_reports_itself(
doc in md_document(),
rule_idx in 0..53usize,
) {
let rule_ids = [
"MD001", "MD003", "MD004", "MD005", "MD007", "MD009", "MD010",
"MD011", "MD012", "MD013", "MD014", "MD018", "MD019", "MD020",
"MD021", "MD022", "MD023", "MD024", "MD025", "MD026", "MD027",
"MD028", "MD029", "MD030", "MD031", "MD032", "MD033", "MD034",
"MD035", "MD036", "MD037", "MD038", "MD039", "MD040", "MD041",
"MD042", "MD043", "MD044", "MD045", "MD046", "MD047", "MD048",
"MD049", "MD050", "MD051", "MD052", "MD053", "MD054", "MD055",
"MD056", "MD058", "MD059", "MD060",
];
let chosen = rule_ids[rule_idx];
let mut rules = HashMap::new();
rules.insert(chosen.to_string(), RuleConfig::Enabled(true));
let config = Config {
default: Some(false),
rules,
..Default::default()
};
let errors = lint_string_with_config(&doc, config);
for error in &errors {
prop_assert!(
error.rule_names.contains(&chosen),
"Expected only {} errors but got {:?}",
chosen, error.rule_names
);
}
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn config_json_roundtrip(
default_val in prop::option::of(any::<bool>()),
num_rules in 0..10usize,
) {
let mut rules = HashMap::new();
for i in 0..num_rules {
let rule_name = format!("MD{:03}", (i % 53) + 1);
rules.insert(rule_name, RuleConfig::Enabled(i % 2 == 0));
}
let config = Config {
default: default_val,
extends: None,
preset: None,
rules,
};
let json = serde_json::to_string(&config).unwrap();
let roundtripped: Config = serde_json::from_str(&json).unwrap();
prop_assert_eq!(config.default, roundtripped.default);
prop_assert_eq!(config.rules.len(), roundtripped.rules.len());
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(300))]
#[test]
fn parser_tokens_have_valid_lines(doc in md_document()) {
let line_count = doc.lines().count().max(1);
let tokens = parser::parse(&doc);
for token in &tokens {
if token.start_line > 0 {
prop_assert!(
token.start_line <= line_count,
"Token {:?} start_line {} exceeds document line count {}",
token.token_type, token.start_line, line_count
);
}
}
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(50))]
#[test]
fn lint_deterministic(doc in md_document()) {
let errors_a = lint_string(&doc);
let errors_b = lint_string(&doc);
prop_assert_eq!(
errors_a.len(), errors_b.len(),
"Linting same document twice should produce identical error counts"
);
for (a, b) in errors_a.iter().zip(errors_b.iter()) {
prop_assert_eq!(a.line_number, b.line_number);
prop_assert_eq!(a.rule_names, b.rule_names);
}
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(300))]
#[test]
fn lint_never_panics_extended(doc in md_document_extended()) {
let _ = lint_string(&doc);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(200))]
#[test]
fn apply_fixes_never_panics_extended(doc in md_document_extended()) {
let errors = lint_string(&doc);
let fixed = apply_fixes(&doc, &errors);
let _ = lint_string(&fixed);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(200))]
#[test]
fn apply_fixes_idempotent(doc in md_document()) {
let errors1 = lint_string(&doc);
let fixed1 = apply_fixes(&doc, &errors1);
let errors2 = lint_string(&fixed1);
let fixed2 = apply_fixes(&fixed1, &errors2);
let errors3 = lint_string(&fixed2);
let fixed3 = apply_fixes(&fixed2, &errors3);
let errors4 = lint_string(&fixed3);
let total3 = errors3.len();
let total4 = errors4.len();
prop_assert!(
total4 <= total3 + 3,
"Errors should converge after three fix passes, but grew from {} to {}",
total3, total4
);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn fix_reduces_specific_rule_errors(doc in md_document()) {
let well_behaved = ["MD010", "MD034", "MD040", "MD047"];
let errors = lint_string(&doc);
let fixed = apply_fixes(&doc, &errors);
let errors_after = lint_string(&fixed);
for rule in &well_behaved {
let before = errors.iter().filter(|e| e.rule_names.contains(rule)).count();
let after = errors_after.iter().filter(|e| e.rule_names.contains(rule)).count();
prop_assert!(
after <= before,
"Rule {} error count increased after fix: {} -> {}",
rule, before, after
);
}
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn lint_never_panics_mixed_endings(doc in md_document()) {
let mixed: String = doc
.lines()
.enumerate()
.map(|(i, line)| {
if i % 2 == 0 {
format!("{}\r\n", line)
} else {
format!("{}\n", line)
}
})
.collect();
let _ = lint_string(&mixed);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(50))]
#[test]
fn lint_never_panics_with_bom(doc in md_document()) {
let with_bom = format!("\u{FEFF}{}", doc);
let _ = lint_string(&with_bom);
}
}