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"^([ \t]*)(#+)([ \t]+)(.*?)([ \t]+)(#+)[ \t]*$";
13static CLOSED_ATX_MULTIPLE_SPACE_PATTERN: LazyLock<Regex> =
14 LazyLock::new(|| Regex::new(CLOSED_ATX_MULTIPLE_SPACE_PATTERN_STR).unwrap());
15
16#[derive(Clone)]
17pub struct MD021NoMultipleSpaceClosedAtx;
18
19impl Default for MD021NoMultipleSpaceClosedAtx {
20 fn default() -> Self {
21 Self::new()
22 }
23}
24
25impl MD021NoMultipleSpaceClosedAtx {
26 pub fn new() -> Self {
27 Self
28 }
29
30 fn is_closed_atx_heading_with_multiple_spaces(&self, line: &str) -> bool {
31 if let Some(captures) = CLOSED_ATX_MULTIPLE_SPACE_PATTERN.captures(line) {
32 let start_spaces = captures.get(3).unwrap().as_str().len();
33 let end_spaces = captures.get(5).unwrap().as_str().len();
34 start_spaces > 1 || end_spaces > 1
35 } else {
36 false
37 }
38 }
39
40 fn fix_closed_atx_heading(&self, line: &str) -> String {
41 if let Some(captures) = CLOSED_ATX_MULTIPLE_SPACE_PATTERN.captures(line) {
42 let indentation = &captures[1];
43 let opening_hashes = &captures[2];
44 let content = &captures[4];
45 let closing_hashes = &captures[6];
46 format!(
47 "{}{} {} {}",
48 indentation,
49 opening_hashes,
50 content.trim_matches([' ', '\t']),
51 closing_hashes
52 )
53 } else {
54 line.to_string()
55 }
56 }
57
58 fn count_spaces(&self, line: &str) -> (usize, usize) {
59 if let Some(captures) = CLOSED_ATX_MULTIPLE_SPACE_PATTERN.captures(line) {
60 let start_spaces = captures.get(3).unwrap().as_str().len();
61 let end_spaces = captures.get(5).unwrap().as_str().len();
62 (start_spaces, end_spaces)
63 } else {
64 (0, 0)
65 }
66 }
67}
68
69impl Rule for MD021NoMultipleSpaceClosedAtx {
70 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
71 Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(21))
72 }
73
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 let edges = crate::utils::blockquote::container_edges(ctx);
86
87 for parsed in ctx.headings() {
89 let line_num = parsed.line_num - 1;
90 let line_info = parsed.line_info;
91 let heading = parsed.heading;
92 let marker_byte = line_info.byte_offset
94 + if parsed.is_blockquote() {
95 heading.marker_column
96 } else {
97 line_info.indent
98 };
99 if ctx.overlaps_template_code(marker_byte, marker_byte + 1) {
100 continue;
101 }
102 if !parsed.is_blockquote()
105 && crate::utils::blockquote::is_indented_past_edge(&edges, line_num, line_info.content(ctx.content))
106 {
107 continue;
108 }
109
110 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) && heading.has_closing_sequence {
112 let full_line = line_info.content(ctx.content);
113 let (kept_prefix, line) = full_line.split_at(heading.marker_column);
116
117 if self.is_closed_atx_heading_with_multiple_spaces(line) {
119 let captures = CLOSED_ATX_MULTIPLE_SPACE_PATTERN.captures(line).unwrap();
120 let _indentation = captures.get(1).unwrap();
121 let opening_hashes = captures.get(2).unwrap();
122 let (start_spaces, end_spaces) = self.count_spaces(line);
123
124 let message = if start_spaces > 1 && end_spaces > 1 {
125 format!(
126 "Multiple spaces ({} at start, {} at end) inside hashes on closed heading (with {} at start and end)",
127 start_spaces,
128 end_spaces,
129 "#".repeat(opening_hashes.as_str().len())
130 )
131 } else if start_spaces > 1 {
132 format!(
133 "Multiple spaces ({}) after {} at start of closed heading",
134 start_spaces,
135 "#".repeat(opening_hashes.as_str().len())
136 )
137 } else {
138 format!(
139 "Multiple spaces ({}) before {} at end of closed heading",
140 end_spaces,
141 "#".repeat(opening_hashes.as_str().len())
142 )
143 };
144
145 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num + 1, full_line);
147 let replacement = format!("{kept_prefix}{}", self.fix_closed_atx_heading(line));
148
149 warnings.push(LintWarning {
150 rule_name: Some(self.name().to_string()),
151 message,
152 line: start_line,
153 column: start_col,
154 end_line,
155 end_column: end_col,
156 severity: Severity::Warning,
157 fix: Some(Fix::new(
158 ctx.line_column_byte_range_with_length(start_line, 1, full_line.len()),
159 replacement,
160 )),
161 });
162 }
163 }
164 }
165
166 Ok(warnings)
167 }
168
169 fn fixes_from_diagnostics(&self) -> bool {
170 true
171 }
172
173 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
174 if self.should_skip(ctx) {
175 return Ok(ctx.content.to_string());
176 }
177 let warnings = self.check(ctx)?;
178 if warnings.is_empty() {
179 return Ok(ctx.content.to_string());
180 }
181 let warnings =
182 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
183 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
184 }
185
186 fn category(&self) -> RuleCategory {
188 RuleCategory::Heading
189 }
190
191 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
193 ctx.content.is_empty() || !ctx.likely_has_headings()
194 }
195
196 fn as_any(&self) -> &dyn std::any::Any {
197 self
198 }
199
200 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
201 where
202 Self: Sized,
203 {
204 Box::new(MD021NoMultipleSpaceClosedAtx::new())
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use crate::lint_context::LintContext;
212
213 #[test]
214 fn test_basic_functionality() {
215 let rule = MD021NoMultipleSpaceClosedAtx;
216
217 let content = "# Heading 1 #\n## Heading 2 ##\n### Heading 3 ###";
219 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
220 let result = rule.check(&ctx).unwrap();
221 assert!(result.is_empty());
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_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
229 assert_eq!(result[1].line, 3);
230 }
231
232 #[test]
233 fn test_unicode_space_in_closed_atx_is_content() {
234 let rule = MD021NoMultipleSpaceClosedAtx;
235 let src = "#\u{a0}\u{a0}Title\u{a0}\u{a0}#\n";
236 let ctx = LintContext::new(src, crate::config::MarkdownFlavor::Standard, None);
237 assert_eq!(rule.fix(&ctx).unwrap(), src);
238 }
239
240 #[test]
241 fn test_ascii_spaces_around_unicode_space_are_still_collapsed() {
242 let rule = MD021NoMultipleSpaceClosedAtx;
243 let ctx = LintContext::new("# \u{a0}Title #\n", crate::config::MarkdownFlavor::Standard, None);
244 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
245 assert_eq!(rule.fix(&ctx).unwrap(), "# \u{a0}Title #\n");
246 }
247}