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