rumdl_lib/rules/
md021_no_multiple_space_closed_atx.rs1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::range_utils::calculate_line_range;
6use crate::utils::regex_cache::get_cached_regex;
7
8const CLOSED_ATX_MULTIPLE_SPACE_PATTERN_STR: &str = r"^(\s*)(#+)(\s+)(.*?)(\s+)(#+)\s*$";
10
11#[derive(Clone)]
12pub struct MD021NoMultipleSpaceClosedAtx;
13
14impl Default for MD021NoMultipleSpaceClosedAtx {
15 fn default() -> Self {
16 Self::new()
17 }
18}
19
20impl MD021NoMultipleSpaceClosedAtx {
21 pub fn new() -> Self {
22 Self
23 }
24
25 fn is_closed_atx_heading_with_multiple_spaces(&self, line: &str) -> bool {
26 if let Some(captures) = get_cached_regex(CLOSED_ATX_MULTIPLE_SPACE_PATTERN_STR)
27 .ok()
28 .and_then(|re| re.captures(line))
29 {
30 let start_spaces = captures.get(3).unwrap().as_str().len();
31 let end_spaces = captures.get(5).unwrap().as_str().len();
32 start_spaces > 1 || end_spaces > 1
33 } else {
34 false
35 }
36 }
37
38 fn fix_closed_atx_heading(&self, line: &str) -> String {
39 if let Some(captures) = get_cached_regex(CLOSED_ATX_MULTIPLE_SPACE_PATTERN_STR)
40 .ok()
41 .and_then(|re| re.captures(line))
42 {
43 let indentation = &captures[1];
44 let opening_hashes = &captures[2];
45 let content = &captures[4];
46 let closing_hashes = &captures[6];
47 format!(
48 "{}{} {} {}",
49 indentation,
50 opening_hashes,
51 content.trim(),
52 closing_hashes
53 )
54 } else {
55 line.to_string()
56 }
57 }
58
59 fn count_spaces(&self, line: &str) -> (usize, usize) {
60 if let Some(captures) = get_cached_regex(CLOSED_ATX_MULTIPLE_SPACE_PATTERN_STR)
61 .ok()
62 .and_then(|re| re.captures(line))
63 {
64 let start_spaces = captures.get(3).unwrap().as_str().len();
65 let end_spaces = captures.get(5).unwrap().as_str().len();
66 (start_spaces, end_spaces)
67 } else {
68 (0, 0)
69 }
70 }
71}
72
73impl Rule for MD021NoMultipleSpaceClosedAtx {
74 fn name(&self) -> &'static str {
75 "MD021"
76 }
77
78 fn description(&self) -> &'static str {
79 "Multiple spaces inside hashes on closed heading"
80 }
81
82 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
83 let mut warnings = Vec::new();
84
85 for (line_num, line_info) in ctx.lines.iter().enumerate() {
87 if let Some(heading) = &line_info.heading {
88 if line_info.visual_indent >= 4 {
90 continue;
91 }
92
93 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) && heading.has_closing_sequence {
95 let line = line_info.content(ctx.content);
96
97 if self.is_closed_atx_heading_with_multiple_spaces(line) {
99 let captures = get_cached_regex(CLOSED_ATX_MULTIPLE_SPACE_PATTERN_STR)
100 .ok()
101 .and_then(|re| re.captures(line))
102 .unwrap();
103 let _indentation = captures.get(1).unwrap();
104 let opening_hashes = captures.get(2).unwrap();
105 let (start_spaces, end_spaces) = self.count_spaces(line);
106
107 let message = if start_spaces > 1 && end_spaces > 1 {
108 format!(
109 "Multiple spaces ({} at start, {} at end) inside hashes on closed heading (with {} at start and end)",
110 start_spaces,
111 end_spaces,
112 "#".repeat(opening_hashes.as_str().len())
113 )
114 } else if start_spaces > 1 {
115 format!(
116 "Multiple spaces ({}) after {} at start of closed heading",
117 start_spaces,
118 "#".repeat(opening_hashes.as_str().len())
119 )
120 } else {
121 format!(
122 "Multiple spaces ({}) before {} at end of closed heading",
123 end_spaces,
124 "#".repeat(opening_hashes.as_str().len())
125 )
126 };
127
128 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num + 1, line);
130 let replacement = self.fix_closed_atx_heading(line);
131
132 warnings.push(LintWarning {
133 rule_name: Some(self.name().to_string()),
134 message,
135 line: start_line,
136 column: start_col,
137 end_line,
138 end_column: end_col,
139 severity: Severity::Warning,
140 fix: Some(Fix {
141 range: ctx
142 .line_index
143 .line_col_to_byte_range_with_length(start_line, 1, line.len()),
144 replacement,
145 }),
146 });
147 }
148 }
149 }
150 }
151
152 Ok(warnings)
153 }
154
155 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
156 let mut lines = Vec::new();
157
158 for line_info in ctx.lines.iter() {
159 let mut fixed = false;
160
161 if let Some(heading) = &line_info.heading {
162 if line_info.visual_indent >= 4 {
164 lines.push(line_info.content(ctx.content).to_string());
165 continue;
166 }
167
168 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX)
170 && heading.has_closing_sequence
171 && self.is_closed_atx_heading_with_multiple_spaces(line_info.content(ctx.content))
172 {
173 lines.push(self.fix_closed_atx_heading(line_info.content(ctx.content)));
174 fixed = true;
175 }
176 }
177
178 if !fixed {
179 lines.push(line_info.content(ctx.content).to_string());
180 }
181 }
182
183 let mut result = lines.join("\n");
185 if ctx.content.ends_with('\n') && !result.ends_with('\n') {
186 result.push('\n');
187 }
188
189 Ok(result)
190 }
191
192 fn category(&self) -> RuleCategory {
194 RuleCategory::Heading
195 }
196
197 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
199 ctx.content.is_empty() || !ctx.likely_has_headings()
200 }
201
202 fn as_any(&self) -> &dyn std::any::Any {
203 self
204 }
205
206 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
207 where
208 Self: Sized,
209 {
210 Box::new(MD021NoMultipleSpaceClosedAtx::new())
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use crate::lint_context::LintContext;
218
219 #[test]
220 fn test_basic_functionality() {
221 let rule = MD021NoMultipleSpaceClosedAtx;
222
223 let content = "# Heading 1 #\n## Heading 2 ##\n### Heading 3 ###";
225 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
226 let result = rule.check(&ctx).unwrap();
227 assert!(result.is_empty());
228
229 let content = "# Heading 1 #\n## Heading 2 ##\n### Heading 3 ###";
231 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
232 let result = rule.check(&ctx).unwrap();
233 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
235 assert_eq!(result[1].line, 3);
236 }
237}