mdlint/lint/rules/
md021.rs1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::{Fix, Violation};
4use serde_json::Value;
5
6pub struct MD021;
7
8impl Rule for MD021 {
9 fn name(&self) -> &'static str {
10 "MD021"
11 }
12
13 fn description(&self) -> &'static str {
14 "Multiple spaces inside hashes on closed atx style heading"
15 }
16
17 fn tags(&self) -> &[&str] {
18 &["headings", "atx_closed", "spaces"]
19 }
20
21 fn check(&self, parser: &MarkdownParser, _config: Option<&Value>) -> Vec<Violation> {
22 let mut violations = Vec::new();
23
24 for (line_num, line) in parser.lines().iter().enumerate() {
25 let line_number = line_num + 1;
26 let trimmed = line.trim();
27
28 if trimmed.starts_with('#') && trimmed.ends_with('#') {
30 let parts: Vec<&str> = trimmed.split_whitespace().collect();
31 if parts.len() >= 2 && parts.last().is_some_and(|p| p.chars().all(|c| c == '#')) {
32 let closing_hashes = parts.last().expect("len >= 2 checked");
33
34 if let Some(pos) = trimmed.rfind(closing_hashes) {
36 let mut space_count = 0usize;
38 let mut check_pos = pos;
39 while check_pos > 0 {
40 check_pos -= 1;
41 if trimmed.chars().nth(check_pos) == Some(' ') {
42 space_count += 1;
43 } else {
44 break;
45 }
46 }
47
48 if space_count > 1 {
49 let before_spaces = &trimmed[..=check_pos];
51 let after_spaces = &trimmed[pos..];
52 let replacement = format!("{before_spaces} {after_spaces}");
53
54 violations.push(Violation {
55 line: line_number,
56 column: Some(1),
57 rule: self.name().to_owned(),
58 message:
59 "Multiple spaces inside hashes on closed atx style heading"
60 .to_owned(),
61 fix: Some(Fix {
62 line_start: line_number,
63 line_end: line_number,
64 column_start: None,
65 column_end: None,
66 replacement,
67 description: "Replace multiple spaces with single space"
68 .to_owned(),
69 }),
70 });
71 }
72 }
73 }
74 }
75 }
76
77 violations
78 }
79
80 fn fixable(&self) -> bool {
81 true
82 }
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88 use crate::fix::Fixer;
89
90 fn apply_fixes(content: &str, violations: &[Violation]) -> String {
91 let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
92 Fixer::new()
93 .apply_fixes_to_content(content, &fixes)
94 .unwrap()
95 }
96
97 #[test]
98 fn test_single_space() {
99 let content = "# Heading #";
100 let parser = MarkdownParser::new(content);
101 let rule = MD021;
102 let violations = rule.check(&parser, None);
103
104 assert_eq!(violations.len(), 0); }
106
107 #[test]
108 fn test_multiple_spaces() {
109 let content = "# Heading ##";
110 let parser = MarkdownParser::new(content);
111 let rule = MD021;
112 let violations = rule.check(&parser, None);
113
114 assert_eq!(violations.len(), 1);
115 }
116
117 #[test]
118 fn test_no_space() {
119 let content = "# Heading#";
120 let parser = MarkdownParser::new(content);
121 let rule = MD021;
122 let violations = rule.check(&parser, None);
123
124 assert_eq!(violations.len(), 0);
125 }
126
127 #[test]
128 fn test_regular_heading() {
129 let content = "# Heading";
130 let parser = MarkdownParser::new(content);
131 let rule = MD021;
132 let violations = rule.check(&parser, None);
133
134 assert_eq!(violations.len(), 0); }
136
137 #[test]
138 fn test_fix_collapses_spaces_before_closing_hashes() {
139 let content = "# Heading ##\n";
140 let parser = MarkdownParser::new(content);
141 let rule = MD021;
142 let violations = rule.check(&parser, None);
143 assert_eq!(violations.len(), 1);
144 let fixed = apply_fixes(content, &violations);
145 assert_eq!(fixed, "# Heading ##\n");
146 }
147}