use proptest::prelude::*;
use rumdl_lib::config::MarkdownFlavor;
use rumdl_lib::lint_context::LintContext;
use rumdl_lib::rule::{LintWarning, Rule};
use rumdl_lib::rules::*;
fn apply_all_fixes(content: &str, warnings: &[LintWarning]) -> String {
let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
fixes.sort_by(|a, b| b.range.start.cmp(&a.range.start));
let mut result = content.to_string();
for fix in fixes {
if fix.range.end <= result.len()
&& result.is_char_boundary(fix.range.start)
&& result.is_char_boundary(fix.range.end)
{
result.replace_range(fix.range.clone(), &fix.replacement);
}
}
result
}
fn markdown_content_strategy() -> impl Strategy<Value = String> {
prop::collection::vec(markdown_line_strategy(), 0..20).prop_map(|lines| lines.join("\n"))
}
fn markdown_line_strategy() -> impl Strategy<Value = String> {
prop_oneof![
(
1..7u8,
any::<String>().prop_filter("valid heading text", |s| { s.len() < 100 && !s.contains('\n') })
)
.prop_map(|(level, text)| format!("{} {}", "#".repeat(level as usize), text)),
any::<String>()
.prop_filter("valid list text", |s| s.len() < 100 && !s.contains('\n'))
.prop_map(|text| format!("- {text}")),
(
1..100u32,
any::<String>().prop_filter("valid list text", |s| { s.len() < 100 && !s.contains('\n') })
)
.prop_map(|(num, text)| format!("{num}. {text}")),
any::<String>()
.prop_filter("valid quote text", |s| s.len() < 100 && !s.contains('\n'))
.prop_map(|text| format!("> {text}")),
prop::collection::vec(
any::<String>().prop_filter("valid code", |s| s.len() < 50 && !s.contains("```")),
0..5
)
.prop_map(|lines| format!("```\n{}\n```", lines.join("\n"))),
any::<String>()
.prop_filter("valid inline code", |s| s.len() < 50 && !s.contains('`'))
.prop_map(|text| format!("`{text}`")),
(
any::<String>().prop_filter("valid link text", |s| s.len() < 30 && !s.contains(&['[', ']'][..])),
any::<String>().prop_filter("valid url", |s| s.len() < 50 && !s.contains(&['(', ')'][..]))
)
.prop_map(|(text, url)| format!("[{text}]({url})")),
any::<String>()
.prop_filter("valid emphasis text", |s| s.len() < 50 && !s.contains('*'))
.prop_map(|text| format!("*{text}*")),
any::<String>()
.prop_filter("valid strong text", |s| s.len() < 50 && !s.contains("**"))
.prop_map(|text| format!("**{text}**")),
any::<String>().prop_filter("valid text", |s| s.len() < 200 && !s.contains('\n')),
Just("".to_string()),
prop_oneof![
Just("---".to_string()),
Just("***".to_string()),
Just("___".to_string()),
],
(
any::<String>().prop_filter("valid alt text", |s| s.len() < 30 && !s.contains(&['[', ']'][..])),
any::<String>().prop_filter("valid url", |s| s.len() < 50 && !s.contains(&['(', ')'][..]))
)
.prop_map(|(alt, url)| format!("")),
any::<String>()
.prop_filter("valid html text", |s| s.len() < 50 && !s.contains(&['<', '>'][..]))
.prop_map(|text| format!("<span>{text}</span>")),
(
any::<String>().prop_filter("valid cell", |s| s.len() < 20 && !s.contains(&['|', '\n'][..])),
any::<String>().prop_filter("valid cell", |s| s.len() < 20 && !s.contains(&['|', '\n'][..]))
)
.prop_map(|(c1, c2)| format!("| {c1} | {c2} |\n| --- | --- |")),
]
}
fn random_content_strategy() -> impl Strategy<Value = String> {
any::<String>().prop_filter("reasonable size", |s| s.len() < 10000)
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn test_lint_context_no_crash(content in random_content_strategy()) {
let _ = LintContext::new(&content, MarkdownFlavor::Standard, None);
let _ = LintContext::new(&content, MarkdownFlavor::MkDocs, None);
let _ = LintContext::new(&content, MarkdownFlavor::MDX, None);
let _ = LintContext::new(&content, MarkdownFlavor::Quarto, None);
}
#[test]
fn test_rules_no_crash(content in markdown_content_strategy()) {
let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
let rules: Vec<Box<dyn Rule>> = vec![
Box::new(MD001HeadingIncrement::default()),
Box::new(MD003HeadingStyle::default()),
Box::new(MD004UnorderedListStyle::default()),
Box::new(MD005ListIndent::default()),
Box::new(MD007ULIndent::default()),
Box::new(MD009TrailingSpaces::default()),
Box::new(MD010NoHardTabs::default()),
Box::new(MD011NoReversedLinks),
Box::new(MD012NoMultipleBlanks::default()),
Box::new(MD013LineLength::default()),
Box::new(MD014CommandsShowOutput::default()),
Box::new(MD018NoMissingSpaceAtx::new()),
Box::new(MD019NoMultipleSpaceAtx),
Box::new(MD020NoMissingSpaceClosedAtx),
Box::new(MD021NoMultipleSpaceClosedAtx),
Box::new(MD022BlanksAroundHeadings::default()),
Box::new(MD023HeadingStartLeft),
Box::new(MD024NoDuplicateHeading::default()),
Box::new(MD025SingleTitle::default()),
Box::new(MD026NoTrailingPunctuation::default()),
Box::new(MD027MultipleSpacesBlockquote::default()),
Box::new(MD028NoBlanksBlockquote),
Box::new(MD029OrderedListPrefix::default()),
Box::new(MD030ListMarkerSpace::default()),
Box::new(MD031BlanksAroundFences::default()),
Box::new(MD032BlanksAroundLists::default()),
Box::new(MD033NoInlineHtml::default()),
Box::new(MD034NoBareUrls),
Box::new(MD035HRStyle::default()),
Box::new(MD036NoEmphasisAsHeading::default()),
Box::new(MD037NoSpaceInEmphasis),
Box::new(MD038NoSpaceInCode::default()),
Box::new(MD039NoSpaceInLinks),
Box::new(MD040FencedCodeLanguage::default()),
Box::new(MD041FirstLineHeading::default()),
Box::new(MD042NoEmptyLinks::default()),
Box::new(MD043RequiredHeadings::new(vec![])),
Box::new(MD044ProperNames::new(vec![], true)),
Box::new(MD045NoAltText::default()),
Box::new(MD046CodeBlockStyle::new(rumdl_lib::rules::CodeBlockStyle::Fenced)),
Box::new(MD047SingleTrailingNewline),
Box::new(MD048CodeFenceStyle::new(rumdl_lib::rules::code_fence_utils::CodeFenceStyle::Backtick)),
Box::new(MD049EmphasisStyle::default()),
Box::new(MD050StrongStyle::default()),
Box::new(MD051LinkFragments::default()),
Box::new(MD052ReferenceLinkImages::default()),
Box::new(MD053LinkImageReferenceDefinitions::default()),
Box::new(MD054LinkImageStyle::default()),
Box::new(MD055TablePipeStyle::default()),
Box::new(MD056TableColumnCount),
Box::new(MD057ExistingRelativeLinks::default()),
Box::new(MD058BlanksAroundTables::default()),
Box::new(MD059LinkText::default()),
Box::new(MD060TableFormat::default()),
Box::new(MD061ForbiddenTerms::default()),
Box::new(MD062LinkDestinationWhitespace),
Box::new(MD063HeadingCapitalization::default()),
Box::new(MD064NoMultipleConsecutiveSpaces::default()),
Box::new(MD065BlanksAroundHorizontalRules),
Box::new(MD066FootnoteValidation),
Box::new(MD067FootnoteDefinitionOrder),
Box::new(MD068EmptyFootnoteDefinition),
Box::new(MD069NoDuplicateListMarkers),
Box::new(MD070NestedCodeFence),
Box::new(MD071BlankLineAfterFrontmatter),
Box::new(MD072FrontmatterKeySort::default()),
Box::new(MD073TocValidation::default()),
Box::new(MD074MkDocsNav::default()),
Box::new(MD075OrphanedTableRows::default()),
Box::new(MD076ListItemSpacing::default()),
Box::new(MD077ListContinuationIndent),
];
for rule in &rules {
let _ = rule.check(&ctx);
let _ = rule.fix(&ctx);
}
}
}
macro_rules! idempotent_rule {
($name:ident, $rule:expr, $strategy:expr $(, $flavor:ident)+ $(,)?) => {
$(
paste::paste! {
proptest! {
#![proptest_config(ProptestConfig::with_cases(50))]
#[test]
fn [<test_ $name _idempotent_ $flavor:lower>](content in $strategy) {
let rule = $rule;
let flavor = MarkdownFlavor::$flavor;
let ctx1 = LintContext::new(&content, flavor, None);
let warnings1 = rule.check(&ctx1).unwrap_or_default();
let fixed1 = apply_all_fixes(&content, &warnings1);
let ctx2 = LintContext::new(&fixed1, flavor, None);
let warnings2 = rule.check(&ctx2).unwrap_or_default();
let fixed2 = apply_all_fixes(&fixed1, &warnings2);
prop_assert_eq!(
fixed1, fixed2,
"{} fix not idempotent (flavor={:?})",
stringify!($name),
flavor
);
}
}
}
)+
};
}
idempotent_rule!(
md001,
MD001HeadingIncrement::default(),
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md003,
MD003HeadingStyle::default(),
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md004,
MD004UnorderedListStyle::default(),
markdown_content_strategy(),
Standard
);
idempotent_rule!(md005, MD005ListIndent::default(), markdown_content_strategy(), Standard);
idempotent_rule!(
md007,
MD007ULIndent::default(),
markdown_content_strategy(),
Standard,
MkDocs,
MDX,
Quarto
);
idempotent_rule!(
md009,
MD009TrailingSpaces::default(),
markdown_content_strategy(),
Standard
);
idempotent_rule!(md010, MD010NoHardTabs::default(), markdown_content_strategy(), Standard);
idempotent_rule!(md011, MD011NoReversedLinks, markdown_content_strategy(), Standard);
idempotent_rule!(
md012,
MD012NoMultipleBlanks::default(),
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md013,
MD013LineLength::default(),
markdown_content_strategy(),
Standard,
MkDocs,
MDX,
Quarto
);
idempotent_rule!(
md014,
MD014CommandsShowOutput::default(),
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md018,
MD018NoMissingSpaceAtx::new(),
markdown_content_strategy(),
Standard
);
idempotent_rule!(md019, MD019NoMultipleSpaceAtx, markdown_content_strategy(), Standard);
idempotent_rule!(
md020,
MD020NoMissingSpaceClosedAtx,
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md021,
MD021NoMultipleSpaceClosedAtx,
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md022,
MD022BlanksAroundHeadings::default(),
markdown_content_strategy(),
Standard,
MkDocs,
MDX,
Quarto
);
idempotent_rule!(md023, MD023HeadingStartLeft, markdown_content_strategy(), Standard);
idempotent_rule!(
md025,
MD025SingleTitle::default(),
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md026,
MD026NoTrailingPunctuation::default(),
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md027,
MD027MultipleSpacesBlockquote::default(),
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md028,
MD028NoBlanksBlockquote,
markdown_content_strategy(),
Standard,
MkDocs,
MDX,
Quarto
);
idempotent_rule!(
md029,
MD029OrderedListPrefix::default(),
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md030,
MD030ListMarkerSpace::default(),
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md031,
MD031BlanksAroundFences::default(),
markdown_content_strategy(),
Standard,
MkDocs,
MDX,
Quarto
);
proptest! {
#![proptest_config(ProptestConfig::with_cases(50))]
#[test]
fn test_md032_idempotent_standard(content in markdown_content_strategy()) {
let rule = MD032BlanksAroundLists::default();
let ctx1 = LintContext::new(&content, MarkdownFlavor::Standard, None);
let fixed1 = rule.fix(&ctx1).unwrap_or_else(|_| content.to_string());
let ctx2 = LintContext::new(&fixed1, MarkdownFlavor::Standard, None);
let fixed2 = rule.fix(&ctx2).unwrap_or_else(|_| fixed1.clone());
if fixed1 != fixed2 {
let ctx3 = LintContext::new(&fixed2, MarkdownFlavor::Standard, None);
let fixed3 = rule.fix(&ctx3).unwrap_or_else(|_| fixed2.clone());
prop_assert_eq!(fixed2, fixed3, "MD032 fix did not converge within 3 passes");
}
}
}
idempotent_rule!(
md033,
MD033NoInlineHtml::default(),
markdown_content_strategy(),
Standard,
MkDocs,
MDX,
Quarto
);
idempotent_rule!(
md034,
MD034NoBareUrls,
markdown_content_strategy(),
Standard,
MkDocs,
MDX,
Quarto
);
idempotent_rule!(md035, MD035HRStyle::default(), markdown_content_strategy(), Standard);
idempotent_rule!(
md036,
MD036NoEmphasisAsHeading::default(),
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md037,
MD037NoSpaceInEmphasis,
markdown_content_strategy(),
Standard,
MkDocs,
MDX,
Quarto
);
idempotent_rule!(
md038,
MD038NoSpaceInCode::default(),
markdown_content_strategy(),
Standard,
MkDocs,
MDX,
Quarto
);
idempotent_rule!(md039, MD039NoSpaceInLinks, markdown_content_strategy(), Standard);
idempotent_rule!(
md040,
MD040FencedCodeLanguage::default(),
markdown_content_strategy(),
Standard,
MkDocs,
MDX,
Quarto
);
idempotent_rule!(
md041,
MD041FirstLineHeading::default(),
markdown_content_strategy(),
Standard,
MkDocs,
MDX,
Quarto
);
idempotent_rule!(
md042,
MD042NoEmptyLinks::default(),
markdown_content_strategy(),
Standard,
MkDocs,
MDX,
Quarto
);
idempotent_rule!(
md044,
MD044ProperNames::new(vec![], true),
markdown_content_strategy(),
Standard
);
idempotent_rule!(md045, MD045NoAltText::default(), markdown_content_strategy(), Standard);
idempotent_rule!(
md046,
MD046CodeBlockStyle::new(rumdl_lib::rules::CodeBlockStyle::Fenced),
markdown_content_strategy(),
Standard,
MkDocs,
MDX,
Quarto
);
idempotent_rule!(md047, MD047SingleTrailingNewline, markdown_content_strategy(), Standard);
idempotent_rule!(
md048,
MD048CodeFenceStyle::new(rumdl_lib::rules::code_fence_utils::CodeFenceStyle::Backtick),
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md049,
MD049EmphasisStyle::default(),
markdown_content_strategy(),
Standard,
MkDocs,
MDX,
Quarto
);
idempotent_rule!(
md050,
MD050StrongStyle::default(),
markdown_content_strategy(),
Standard,
MkDocs,
MDX,
Quarto
);
idempotent_rule!(
md051,
MD051LinkFragments::default(),
markdown_content_strategy(),
Standard,
MkDocs,
MDX,
Quarto
);
idempotent_rule!(
md052,
MD052ReferenceLinkImages::default(),
markdown_content_strategy(),
Standard,
MkDocs,
MDX,
Quarto
);
idempotent_rule!(
md054,
MD054LinkImageStyle::default(),
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md055,
MD055TablePipeStyle::default(),
markdown_content_strategy(),
Standard
);
idempotent_rule!(md056, MD056TableColumnCount, markdown_content_strategy(), Standard);
proptest! {
#![proptest_config(ProptestConfig::with_cases(50))]
#[test]
fn test_md058_idempotent_standard(content in markdown_content_strategy()) {
let rule = MD058BlanksAroundTables::default();
let ctx1 = LintContext::new(&content, MarkdownFlavor::Standard, None);
let fixed1 = rule.fix(&ctx1).unwrap_or_else(|_| content.to_string());
let ctx2 = LintContext::new(&fixed1, MarkdownFlavor::Standard, None);
let fixed2 = rule.fix(&ctx2).unwrap_or_else(|_| fixed1.clone());
if fixed1 != fixed2 {
let ctx3 = LintContext::new(&fixed2, MarkdownFlavor::Standard, None);
let fixed3 = rule.fix(&ctx3).unwrap_or_else(|_| fixed2.clone());
prop_assert_eq!(fixed2, fixed3, "MD058 fix did not converge within 3 passes");
}
}
}
idempotent_rule!(md059, MD059LinkText::default(), markdown_content_strategy(), Standard);
proptest! {
#![proptest_config(ProptestConfig::with_cases(50))]
#[test]
fn test_md060_idempotent_standard(content in markdown_content_strategy()) {
let rule = MD060TableFormat::new(true, "aligned".to_string());
let ctx1 = LintContext::new(&content, MarkdownFlavor::Standard, None);
let fixed1 = rule.fix(&ctx1).unwrap_or_else(|_| content.to_string());
let ctx2 = LintContext::new(&fixed1, MarkdownFlavor::Standard, None);
let fixed2 = rule.fix(&ctx2).unwrap_or_else(|_| fixed1.clone());
if fixed1 != fixed2 {
let ctx3 = LintContext::new(&fixed2, MarkdownFlavor::Standard, None);
let fixed3 = rule.fix(&ctx3).unwrap_or_else(|_| fixed2.clone());
prop_assert_eq!(fixed2, fixed3, "MD060 fix did not converge within 3 passes");
}
}
}
idempotent_rule!(
md061,
MD061ForbiddenTerms::default(),
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md062,
MD062LinkDestinationWhitespace,
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md063,
MD063HeadingCapitalization::default(),
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md064,
MD064NoMultipleConsecutiveSpaces::default(),
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md065,
MD065BlanksAroundHorizontalRules,
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md067,
MD067FootnoteDefinitionOrder,
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md069,
MD069NoDuplicateListMarkers,
markdown_content_strategy(),
Standard
);
idempotent_rule!(md070, MD070NestedCodeFence, markdown_content_strategy(), Standard);
idempotent_rule!(
md071,
MD071BlankLineAfterFrontmatter,
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md072,
MD072FrontmatterKeySort::default(),
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md073,
MD073TocValidation::default(),
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md075,
MD075OrphanedTableRows::default(),
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md076,
MD076ListItemSpacing::default(),
markdown_content_strategy(),
Standard
);
idempotent_rule!(
md077,
MD077ListContinuationIndent,
markdown_content_strategy(),
Standard,
MkDocs,
MDX,
Quarto
);