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 regex::Regex;
7use std::sync::LazyLock;
8
9const CLOSED_ATX_MULTIPLE_SPACE_PATTERN_STR: &str = r"^(\s*)(#+)(\s+)(.*?)(\s+)(#+)\s*$";
11static CLOSED_ATX_MULTIPLE_SPACE_PATTERN: LazyLock<Regex> =
12 LazyLock::new(|| Regex::new(CLOSED_ATX_MULTIPLE_SPACE_PATTERN_STR).unwrap());
13
14#[derive(Clone)]
15pub struct MD021NoMultipleSpaceClosedAtx;
16
17impl Default for MD021NoMultipleSpaceClosedAtx {
18 fn default() -> Self {
19 Self::new()
20 }
21}
22
23impl MD021NoMultipleSpaceClosedAtx {
24 pub fn new() -> Self {
25 Self
26 }
27
28 fn is_closed_atx_heading_with_multiple_spaces(&self, line: &str) -> bool {
29 if let Some(captures) = CLOSED_ATX_MULTIPLE_SPACE_PATTERN.captures(line) {
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) = CLOSED_ATX_MULTIPLE_SPACE_PATTERN.captures(line) {
40 let indentation = &captures[1];
41 let opening_hashes = &captures[2];
42 let content = &captures[4];
43 let closing_hashes = &captures[6];
44 format!(
45 "{}{} {} {}",
46 indentation,
47 opening_hashes,
48 content.trim(),
49 closing_hashes
50 )
51 } else {
52 line.to_string()
53 }
54 }
55
56 fn count_spaces(&self, line: &str) -> (usize, usize) {
57 if let Some(captures) = CLOSED_ATX_MULTIPLE_SPACE_PATTERN.captures(line) {
58 let start_spaces = captures.get(3).unwrap().as_str().len();
59 let end_spaces = captures.get(5).unwrap().as_str().len();
60 (start_spaces, end_spaces)
61 } else {
62 (0, 0)
63 }
64 }
65}
66
67impl Rule for MD021NoMultipleSpaceClosedAtx {
68 fn name(&self) -> &'static str {
69 "MD021"
70 }
71
72 fn description(&self) -> &'static str {
73 "Multiple spaces inside hashes on closed heading"
74 }
75
76 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
77 let mut warnings = Vec::new();
78
79 for (line_num, line_info) in ctx.lines.iter().enumerate() {
81 let marker_byte = line_info.byte_offset + line_info.indent;
82 if ctx.overlaps_template_code(marker_byte, marker_byte + 1) {
83 continue;
84 }
85 if let Some(heading) = &line_info.heading {
86 if line_info.visual_indent >= 4 {
88 continue;
89 }
90
91 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) && heading.has_closing_sequence {
93 let line = line_info.content(ctx.content);
94
95 if self.is_closed_atx_heading_with_multiple_spaces(line) {
97 let captures = CLOSED_ATX_MULTIPLE_SPACE_PATTERN.captures(line).unwrap();
98 let _indentation = captures.get(1).unwrap();
99 let opening_hashes = captures.get(2).unwrap();
100 let (start_spaces, end_spaces) = self.count_spaces(line);
101
102 let message = if start_spaces > 1 && end_spaces > 1 {
103 format!(
104 "Multiple spaces ({} at start, {} at end) inside hashes on closed heading (with {} at start and end)",
105 start_spaces,
106 end_spaces,
107 "#".repeat(opening_hashes.as_str().len())
108 )
109 } else if start_spaces > 1 {
110 format!(
111 "Multiple spaces ({}) after {} at start of closed heading",
112 start_spaces,
113 "#".repeat(opening_hashes.as_str().len())
114 )
115 } else {
116 format!(
117 "Multiple spaces ({}) before {} at end of closed heading",
118 end_spaces,
119 "#".repeat(opening_hashes.as_str().len())
120 )
121 };
122
123 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num + 1, line);
125 let replacement = self.fix_closed_atx_heading(line);
126
127 warnings.push(LintWarning {
128 rule_name: Some(self.name().to_string()),
129 message,
130 line: start_line,
131 column: start_col,
132 end_line,
133 end_column: end_col,
134 severity: Severity::Warning,
135 fix: Some(Fix::new(
136 ctx.line_column_byte_range_with_length(start_line, 1, line.len()),
137 replacement,
138 )),
139 });
140 }
141 }
142 }
143 }
144
145 Ok(warnings)
146 }
147
148 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
149 if self.should_skip(ctx) {
150 return Ok(ctx.content.to_string());
151 }
152 let warnings = self.check(ctx)?;
153 if warnings.is_empty() {
154 return Ok(ctx.content.to_string());
155 }
156 let warnings =
157 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
158 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
159 }
160
161 fn category(&self) -> RuleCategory {
163 RuleCategory::Heading
164 }
165
166 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
168 ctx.content.is_empty() || !ctx.likely_has_headings()
169 }
170
171 fn as_any(&self) -> &dyn std::any::Any {
172 self
173 }
174
175 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
176 where
177 Self: Sized,
178 {
179 Box::new(MD021NoMultipleSpaceClosedAtx::new())
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use crate::lint_context::LintContext;
187
188 #[test]
189 fn test_basic_functionality() {
190 let rule = MD021NoMultipleSpaceClosedAtx;
191
192 let content = "# Heading 1 #\n## Heading 2 ##\n### Heading 3 ###";
194 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
195 let result = rule.check(&ctx).unwrap();
196 assert!(result.is_empty());
197
198 let content = "# Heading 1 #\n## Heading 2 ##\n### Heading 3 ###";
200 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
201 let result = rule.check(&ctx).unwrap();
202 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
204 assert_eq!(result[1].line, 3);
205 }
206}