use rumdl_lib::lint_context::LintContext;
use rumdl_lib::rule::Rule;
use rumdl_lib::rules::CodeBlockStyle;
use rumdl_lib::rules::code_fence_utils::CodeFenceStyle;
use rumdl_lib::rules::strong_style::StrongStyle;
use rumdl_lib::rules::*;
fn simulate_vscode_fix(content: &str, rule: &dyn Rule) -> Result<String, String> {
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).map_err(|e| format!("Check failed: {e:?}"))?;
if warnings.is_empty() {
return Ok(content.to_string());
}
let warning = &warnings[0];
let fix = warning.fix.as_ref().ok_or("No fix available")?;
let warning_start_line = warning.line;
let warning_start_col = warning.column;
let warning_end_line = warning.end_line;
let warning_end_col = warning.end_column;
let lines: Vec<&str> = content.lines().collect();
if warning_start_line == 0 || warning_start_line > lines.len() {
return Err("Invalid warning line number".to_string());
}
if warning_start_line == warning_end_line {
let line = lines[warning_start_line - 1];
let start_byte = warning_start_col.saturating_sub(1);
let end_byte = warning_end_col.saturating_sub(1);
if start_byte > line.len() || end_byte > line.len() {
return Err("Invalid warning column range".to_string());
}
let before = &line[..start_byte];
let after = &line[end_byte..];
let new_line = format!("{}{}{}", before, fix.replacement, after);
let mut result_lines: Vec<String> = lines.iter().map(std::string::ToString::to_string).collect();
result_lines[warning_start_line - 1] = new_line;
Ok(result_lines.join("\n") + if content.ends_with('\n') { "\n" } else { "" })
} else {
if warning_end_line > lines.len() {
return Err("Invalid warning end line number".to_string());
}
let start_line_content = lines[warning_start_line - 1];
let end_line_content = lines[warning_end_line - 1];
let start_byte = warning_start_col.saturating_sub(1);
let end_byte = warning_end_col.saturating_sub(1);
if start_byte > start_line_content.len() || end_byte > end_line_content.len() {
return Err("Invalid warning column range for multiline fix".to_string());
}
let before = &start_line_content[..start_byte];
let after = &end_line_content[end_byte..];
let new_line = format!("{}{}{}", before, fix.replacement, after);
let mut result_lines: Vec<String> = lines.iter().map(std::string::ToString::to_string).collect();
result_lines.splice((warning_start_line - 1)..warning_end_line, std::iter::once(new_line));
Ok(result_lines.join("\n") + if content.ends_with('\n') { "\n" } else { "" })
}
}
fn create_test_case_for_rule(rule_name: &str) -> Option<(&'static str, Box<dyn Rule>)> {
match rule_name {
"MD001" => Some((
"# H1\n### H3 (should be H2)",
Box::new(MD001HeadingIncrement::default()),
)),
"MD003" => Some(("# ATX\nSetext\n======", Box::new(MD003HeadingStyle::default()))),
"MD004" => Some((
"* Item 1\n- Item 2",
Box::new(MD004UnorderedListStyle::new(UnorderedListStyle::Consistent)),
)),
"MD005" => Some((
"* Item 1\n * Item with 3 spaces (should be 2)",
Box::new(MD005ListIndent::default()),
)),
"MD007" => Some(("- Item 1\n - Wrong indent", Box::new(MD007ULIndent::default()))),
"MD009" => Some(("Line with trailing spaces ", Box::new(MD009TrailingSpaces::default()))),
"MD010" => Some(("Line with\ttab", Box::new(MD010NoHardTabs::default()))),
"MD011" => Some(("(http://example.com)[Example]", Box::new(MD011NoReversedLinks))),
"MD012" => Some((
"Content\n\n\n\nToo many blanks",
Box::new(MD012NoMultipleBlanks::default()),
)),
"MD013" => Some((
"This is a very long line that exceeds the maximum line length limit and should trigger MD013",
Box::new(MD013LineLength::default()),
)),
"MD014" => Some(("```bash\n$ command\n```", Box::new(MD014CommandsShowOutput::default()))),
"MD018" => Some(("#Missing space", Box::new(MD018NoMissingSpaceAtx::new()))),
"MD019" => Some(("## Multiple spaces", Box::new(MD019NoMultipleSpaceAtx::new()))),
"MD020" => Some(("##No space in closed##", Box::new(MD020NoMissingSpaceClosedAtx))),
"MD021" => Some(("## Multiple spaces ##", Box::new(MD021NoMultipleSpaceClosedAtx))),
"MD022" => Some((
"Text\n# Heading\nMore text",
Box::new(MD022BlanksAroundHeadings::default()),
)),
"MD023" => Some((" # Indented heading", Box::new(MD023HeadingStartLeft))),
"MD024" => Some(("# Duplicate\n# Duplicate", Box::new(MD024NoDuplicateHeading::default()))),
"MD025" => Some(("# First\n# Second H1", Box::new(MD025SingleTitle::default()))),
"MD026" => Some(("# Heading!", Box::new(MD026NoTrailingPunctuation::default()))),
"MD027" => Some((
"> Multiple spaces in blockquote",
Box::new(MD027MultipleSpacesBlockquote::default()),
)),
"MD028" => Some(("> Quote\n>\n> More quote", Box::new(MD028NoBlanksBlockquote))),
"MD029" => Some((
"1. First\n3. Third",
Box::new(MD029OrderedListPrefix::new(ListStyle::Ordered)),
)),
"MD030" => Some((
"1. Multiple spaces after marker",
Box::new(MD030ListMarkerSpace::new(1, 1, 1, 1)),
)),
"MD031" => Some((
"Text\n```\ncode\n```\nText",
Box::new(MD031BlanksAroundFences::default()),
)),
"MD032" => Some(("Text\n* List item\nText", Box::new(MD032BlanksAroundLists::default()))),
"MD033" => Some(("Text with <div>HTML</div>", Box::new(MD033NoInlineHtml::default()))),
"MD034" => Some(("Visit https://example.com", Box::new(MD034NoBareUrls))),
"MD035" => Some(("Text\n***\nText", Box::new(MD035HRStyle::default()))),
"MD036" => Some((
"**Bold text as heading**",
Box::new(MD036NoEmphasisAsHeading::new("!?.,:;".to_string())),
)),
"MD037" => Some(("Text with * spaces around * emphasis", Box::new(MD037NoSpaceInEmphasis))),
"MD038" => Some(("`code `", Box::new(MD038NoSpaceInCode::default()))),
"MD039" => Some(("[link text ]( url )", Box::new(MD039NoSpaceInLinks))),
"MD040" => Some((
"```\ncode without language\n```",
Box::new(MD040FencedCodeLanguage::default()),
)),
"MD041" => Some(("Not a heading", Box::new(MD041FirstLineHeading::default()))),
"MD042" => Some(("[]()", Box::new(MD042NoEmptyLinks::new()))),
"MD043" => Some((
"# Wrong heading",
Box::new(MD043RequiredHeadings::new(vec!["Introduction".to_string()])),
)),
"MD044" => Some((
"javascript instead of JavaScript",
Box::new(MD044ProperNames::new(vec!["JavaScript".to_string()], false)),
)),
"MD045" => Some(("", Box::new(MD045NoAltText::new()))),
"MD046" => Some((
" indented code",
Box::new(MD046CodeBlockStyle::new(CodeBlockStyle::Fenced)),
)),
"MD047" => Some(("File without trailing newline", Box::new(MD047SingleTrailingNewline))),
"MD048" => Some((
"~~~\ncode\n~~~",
Box::new(MD048CodeFenceStyle::new(CodeFenceStyle::Tilde)),
)),
"MD049" => Some(("Text _emphasis_ text", Box::new(MD049EmphasisStyle::default()))),
"MD050" => Some((
"Text __strong__ text",
Box::new(MD050StrongStyle::new(StrongStyle::Underscore)),
)),
"MD051" => Some(("[link](#nonexistent)", Box::new(MD051LinkFragments::new()))),
"MD052" => Some(("[ref link][ref]", Box::new(MD052ReferenceLinkImages::new()))),
"MD053" => Some((
"[ref]: https://example.com",
Box::new(MD053LinkImageReferenceDefinitions::default()),
)),
"MD054" => Some(("", Box::new(MD054LinkImageStyle::default()))),
"MD055" => Some(("|col1|col2|\n|--|--|\n|a|b|", Box::new(MD055TablePipeStyle::default()))),
"MD056" => Some(("|col1|col2|\n|--|--|\n|a|", Box::new(MD056TableColumnCount))),
"MD057" => Some(("[link](missing.md)", Box::new(MD057ExistingRelativeLinks::default()))),
"MD058" => Some(("Text\n|table|\nText", Box::new(MD058BlanksAroundTables::default()))),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_md030_vscode_fix_no_duplication() {
let rule = MD030ListMarkerSpace::new(1, 1, 1, 1);
let content = "1. Supporting a new storage platform for MLflow artifacts";
let result = simulate_vscode_fix(content, &rule).unwrap();
assert_eq!(result, "1. Supporting a new storage platform for MLflow artifacts");
assert!(!result.contains("1. 1."), "Should not contain duplicated list marker");
}
#[test]
fn test_md019_vscode_fix_no_duplication() {
let rule = MD019NoMultipleSpaceAtx::new();
let content = "## Multiple Spaces Heading";
let result = simulate_vscode_fix(content, &rule).unwrap();
assert_eq!(result, "## Multiple Spaces Heading");
assert!(!result.contains("## ##"), "Should not contain duplicated hashes");
}
#[test]
fn test_md023_vscode_fix_no_duplication() {
let rule = MD023HeadingStartLeft;
let content = " # Indented Heading";
let result = simulate_vscode_fix(content, &rule).unwrap();
assert_eq!(result, "# Indented Heading");
assert!(
!result.contains("# # "),
"Should not contain duplicated heading markers"
);
}
#[test]
fn test_md030_multiple_spaces() {
let rule = MD030ListMarkerSpace::new(1, 1, 1, 1);
let content = "* Item with three spaces";
let result = simulate_vscode_fix(content, &rule).unwrap();
assert_eq!(result, "* Item with three spaces");
assert!(!result.contains("* *"), "Should not contain duplicated asterisks");
}
#[test]
fn test_md019_various_heading_levels() {
let rule = MD019NoMultipleSpaceAtx::new();
let test_cases = vec![
("# H1", "# H1"),
("## H2", "## H2"),
("### H3", "### H3"),
("###### H6", "###### H6"),
];
for (input, expected) in test_cases {
let result = simulate_vscode_fix(input, &rule).unwrap();
assert_eq!(result, expected, "Failed for input: {input}");
let hash_count = input.chars().take_while(|&c| c == '#').count();
let result_hash_count = result.chars().take_while(|&c| c == '#').count();
assert_eq!(
hash_count, result_hash_count,
"Hash count should remain the same for: {input}"
);
}
}
#[test]
fn test_md023_various_indentations() {
let rule = MD023HeadingStartLeft;
let test_cases = vec![(" # H1", "# H1"), (" ## H2", "## H2")];
for (input, expected) in test_cases {
let result = simulate_vscode_fix(input, &rule).unwrap();
assert_eq!(result, expected, "Failed for input: {input:?}");
}
}
#[test]
fn test_md005_vscode_fix_no_duplication() {
let rule = MD005ListIndent::default();
let content = "* Item 1\n * Item with 3 spaces (should be 2)\n* Item 3";
let result = simulate_vscode_fix(content, &rule);
if let Ok(fixed) = result {
assert!(!fixed.contains("* *"), "Should not contain duplicated list markers");
assert!(!fixed.contains(" * *"), "Should not contain duplicated content");
assert!(
fixed.contains(" * Item with 3 spaces"),
"Should fix indentation to 2 spaces"
);
} else {
panic!("Expected MD005 to provide a fix");
}
}
#[test]
fn test_md027_vscode_fix_no_duplication() {
let rule = MD027MultipleSpacesBlockquote::default();
let content = "> Multiple spaces in blockquote";
let result = simulate_vscode_fix(content, &rule);
if let Ok(fixed) = result {
assert!(
!fixed.contains("> >"),
"Should not contain duplicated blockquote markers"
);
assert!(!fixed.contains(">>"), "Should not contain merged blockquote markers");
assert!(
fixed.contains("> Multiple spaces"),
"Should fix to single space after marker"
);
} else {
panic!("Expected MD027 to provide a fix");
}
}
#[test]
fn test_md032_vscode_fix_no_duplication() {
let rule = MD032BlanksAroundLists::default();
let content = "Text\n* List item\nMore text";
let result = simulate_vscode_fix(content, &rule);
if let Ok(fixed) = result {
let lines: Vec<&str> = fixed.lines().collect();
for (i, line) in lines.iter().enumerate() {
for (j, other_line) in lines.iter().enumerate() {
if i != j && !line.trim().is_empty() && line == other_line {
panic!("Found duplicated line: {line:?}");
}
}
}
}
}
#[test]
fn test_md007_vscode_fix_no_duplication() {
let rule = MD007ULIndent::default();
let content = "- Item 1\n - Wrong indent";
let result = simulate_vscode_fix(content, &rule);
if let Ok(fixed) = result {
assert!(!fixed.contains("- -"), "Should not contain duplicated list markers");
assert!(!fixed.contains(" - -"), "Should not contain duplicated content");
assert!(fixed.contains(" - Wrong indent"), "Should fix indentation to 2 spaces");
} else {
panic!("Expected MD007 to provide a fix");
}
}
#[test]
fn test_md046_vscode_fix_no_duplication() {
let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Fenced);
let content = " indented code";
let result = simulate_vscode_fix(content, &rule);
if let Ok(fixed) = result {
assert!(
!fixed.contains(" indented"),
"Should not contain original indented code"
);
assert!(fixed.contains("```"), "Should contain fenced code block marker");
assert!(fixed.contains("indented code"), "Should contain the code content");
}
}
#[test]
fn test_all_rules_vscode_fix_no_duplication() {
let rules_to_test = vec![
"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", "MD057", "MD058",
];
let mut tested_rules = 0;
let mut rules_with_fixes = 0;
let mut passed_tests = 0;
for rule_name in rules_to_test {
if let Some((test_content, rule)) = create_test_case_for_rule(rule_name) {
tested_rules += 1;
match simulate_vscode_fix(test_content, rule.as_ref()) {
Ok(fixed_content) => {
rules_with_fixes += 1;
let original_non_whitespace: String =
test_content.chars().filter(|c| !c.is_whitespace()).collect();
let fixed_non_whitespace: String =
fixed_content.chars().filter(|c| !c.is_whitespace()).collect();
let has_obvious_duplication = fixed_content.contains("# # ")
|| fixed_content.contains("## ## ")
|| fixed_content.contains("### ### ")
|| fixed_content.contains("* *")
|| fixed_content.contains("- -")
|| fixed_content.contains("+ +")
|| fixed_content.contains("> >")
|| fixed_content.contains("1. 1.")
|| fixed_content.contains("2. 2.");
let has_size_based_duplication =
if rule_name == "MD042" || rule_name == "MD043" || rule_name == "MD044" {
false
} else {
fixed_non_whitespace.len() > original_non_whitespace.len() * 3
};
if has_obvious_duplication || has_size_based_duplication {
panic!(
"Rule {rule_name} has content duplication in VS Code extension fix!\nOriginal: {test_content:?}\nFixed: {fixed_content:?}"
);
}
passed_tests += 1;
println!("✓ {rule_name}: Fix applied successfully");
}
Err(e) => {
println!("- {rule_name}: No fix available ({e})");
}
}
} else {
println!("⚠ {rule_name}: No test case defined");
}
}
println!("\n=== Test Summary ===");
println!("Rules tested: {tested_rules}");
println!("Rules with fixes: {rules_with_fixes}");
println!("Tests passed: {passed_tests}");
assert!(rules_with_fixes > 0, "Expected at least some rules to have fixes");
assert_eq!(
passed_tests, rules_with_fixes,
"All rules with fixes should pass the duplication test"
);
}
#[test]
fn test_simulate_vscode_fix_multiline_splice_is_correct() {
use rumdl_lib::config::{Config, MarkdownFlavor};
use rumdl_lib::lint_context::LintContext;
use rumdl_lib::rule::{
CrossFileScope, Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity,
};
#[derive(Clone)]
struct MultilineTestRule;
impl Rule for MultilineTestRule {
fn name(&self) -> &'static str {
"TEST"
}
fn description(&self) -> &'static str {
"Test rule for multiline splice"
}
fn check(&self, _ctx: &LintContext) -> LintResult {
Ok(vec![LintWarning {
line: 2,
column: 1,
end_line: 3,
end_column: 6,
message: "test".to_string(),
fix: Some(Fix::new(0..0, "REPLACED".to_string())),
severity: Severity::Warning,
rule_name: Some("TEST".to_string()),
}])
}
fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
Ok(ctx.content.to_string())
}
fn category(&self) -> RuleCategory {
RuleCategory::Other
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn fix_capability(&self) -> FixCapability {
FixCapability::FullyFixable
}
fn cross_file_scope(&self) -> CrossFileScope {
CrossFileScope::None
}
fn from_config(_config: &Config) -> Box<dyn Rule>
where
Self: Sized,
{
Box::new(MultilineTestRule)
}
}
let content = "BEFORE\nSTART HERE\nEND X\nAFTER\n";
let rule = MultilineTestRule;
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
let multiline_warning = warnings
.iter()
.find(|w| w.end_line != w.line)
.expect("MultilineTestRule should produce a multiline warning");
assert_eq!(multiline_warning.line, 2);
assert_eq!(multiline_warning.end_line, 3);
let result = simulate_vscode_fix(content, &rule);
let fixed = result.expect("Multiline fix should succeed");
assert_eq!(
fixed, "BEFORE\nREPLACED\nAFTER\n",
"Multiline splice should replace lines 2–3 with the replacement text. Got: {fixed:?}"
);
}
#[test]
fn test_simulate_vscode_fix_handles_multiline_warning_range() {
use rumdl_lib::config::MarkdownFlavor;
use rumdl_lib::lint_context::LintContext;
let rule_names = [
"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", "MD057", "MD058",
];
for rule_name in rule_names {
if let Some((content, rule)) = create_test_case_for_rule(rule_name) {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
if let Ok(warnings) = rule.check(&ctx) {
let has_multiline = warnings.iter().any(|w| w.end_line != w.line);
if has_multiline {
let result = simulate_vscode_fix(content, rule.as_ref());
assert!(
result != Err("Multi-line warning ranges not implemented yet".to_string()),
"Rule {rule_name} has multiline warnings but simulate_vscode_fix returned 'not implemented'"
);
}
}
}
}
}
}