rumdl_lib/rules/
md020_no_missing_space_closed_atx.rs

1/// Rule MD020: No missing space inside closed ATX heading
2///
3/// See [docs/md020.md](../../docs/md020.md) for full documentation, configuration, and examples.
4use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::range_utils::calculate_single_line_range;
6use crate::utils::regex_cache::get_cached_regex;
7
8// Closed ATX heading patterns
9const CLOSED_ATX_NO_SPACE_PATTERN_STR: &str = r"^(\s*)(#+)([^#\s].*?)([^#\s])(#+)(\s*(?:\{#[^}]+\})?\s*)$";
10const CLOSED_ATX_NO_SPACE_START_PATTERN_STR: &str = r"^(\s*)(#+)([^#\s].*?)\s(#+)(\s*(?:\{#[^}]+\})?\s*)$";
11const CLOSED_ATX_NO_SPACE_END_PATTERN_STR: &str = r"^(\s*)(#+)\s(.*?)([^#\s])(#+)(\s*(?:\{#[^}]+\})?\s*)$";
12
13#[derive(Clone)]
14pub struct MD020NoMissingSpaceClosedAtx;
15
16impl Default for MD020NoMissingSpaceClosedAtx {
17    fn default() -> Self {
18        Self::new()
19    }
20}
21
22impl MD020NoMissingSpaceClosedAtx {
23    pub fn new() -> Self {
24        Self
25    }
26
27    fn is_closed_atx_heading_without_space(&self, line: &str) -> bool {
28        get_cached_regex(CLOSED_ATX_NO_SPACE_PATTERN_STR)
29            .map(|re| re.is_match(line))
30            .unwrap_or(false)
31            || get_cached_regex(CLOSED_ATX_NO_SPACE_START_PATTERN_STR)
32                .map(|re| re.is_match(line))
33                .unwrap_or(false)
34            || get_cached_regex(CLOSED_ATX_NO_SPACE_END_PATTERN_STR)
35                .map(|re| re.is_match(line))
36                .unwrap_or(false)
37    }
38
39    fn fix_closed_atx_heading(&self, line: &str) -> String {
40        if let Some(captures) = get_cached_regex(CLOSED_ATX_NO_SPACE_PATTERN_STR)
41            .ok()
42            .and_then(|re| re.captures(line))
43        {
44            let indentation = &captures[1];
45            let opening_hashes = &captures[2];
46            let content = &captures[3];
47            let last_char = &captures[4];
48            let closing_hashes = &captures[5];
49            let custom_id = &captures[6];
50            format!("{indentation}{opening_hashes} {content}{last_char} {closing_hashes}{custom_id}")
51        } else if let Some(captures) = get_cached_regex(CLOSED_ATX_NO_SPACE_START_PATTERN_STR)
52            .ok()
53            .and_then(|re| re.captures(line))
54        {
55            let indentation = &captures[1];
56            let opening_hashes = &captures[2];
57            let content = &captures[3];
58            let closing_hashes = &captures[4];
59            let custom_id = &captures[5];
60            format!("{indentation}{opening_hashes} {content} {closing_hashes}{custom_id}")
61        } else if let Some(captures) = get_cached_regex(CLOSED_ATX_NO_SPACE_END_PATTERN_STR)
62            .ok()
63            .and_then(|re| re.captures(line))
64        {
65            let indentation = &captures[1];
66            let opening_hashes = &captures[2];
67            let content = &captures[3];
68            let last_char = &captures[4];
69            let closing_hashes = &captures[5];
70            let custom_id = &captures[6];
71            format!("{indentation}{opening_hashes} {content}{last_char} {closing_hashes}{custom_id}")
72        } else {
73            line.to_string()
74        }
75    }
76}
77
78impl Rule for MD020NoMissingSpaceClosedAtx {
79    fn name(&self) -> &'static str {
80        "MD020"
81    }
82
83    fn description(&self) -> &'static str {
84        "No space inside hashes on closed heading"
85    }
86
87    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
88        let mut warnings = Vec::new();
89
90        // Check all closed ATX headings from cached info
91        for (line_num, line_info) in ctx.lines.iter().enumerate() {
92            if let Some(heading) = &line_info.heading {
93                // Skip headings indented 4+ spaces (they're code blocks)
94                if line_info.indent >= 4 {
95                    continue;
96                }
97
98                // Check all ATX headings (both properly closed and malformed)
99                if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
100                    let line = line_info.content(ctx.content);
101
102                    // Check if line matches closed ATX pattern without space
103                    // This will detect both properly closed headings with missing space
104                    // and malformed attempts at closed headings like "# Heading#"
105                    if self.is_closed_atx_heading_without_space(line) {
106                        let line_range = ctx.line_index.line_content_range(line_num + 1);
107
108                        let mut start_col = 1;
109                        let mut length = 1;
110                        let mut message = String::new();
111
112                        if let Some(captures) = get_cached_regex(CLOSED_ATX_NO_SPACE_PATTERN_STR)
113                            .ok()
114                            .and_then(|re| re.captures(line))
115                        {
116                            // Missing space at both start and end: #Heading#
117                            let opening_hashes = captures.get(2).unwrap();
118                            message = format!(
119                                "Missing space inside hashes on closed heading (with {} at start and end)",
120                                "#".repeat(opening_hashes.as_str().len())
121                            );
122                            // Highlight the position right after the opening hashes
123                            start_col = opening_hashes.end() + 1;
124                            length = 1;
125                        } else if let Some(captures) = get_cached_regex(CLOSED_ATX_NO_SPACE_START_PATTERN_STR)
126                            .ok()
127                            .and_then(|re| re.captures(line))
128                        {
129                            // Missing space at start: #Heading #
130                            let opening_hashes = captures.get(2).unwrap();
131                            message = format!(
132                                "Missing space after {} at start of closed heading",
133                                "#".repeat(opening_hashes.as_str().len())
134                            );
135                            // Highlight the position right after the opening hashes
136                            start_col = opening_hashes.end() + 1;
137                            length = 1;
138                        } else if let Some(captures) = get_cached_regex(CLOSED_ATX_NO_SPACE_END_PATTERN_STR)
139                            .ok()
140                            .and_then(|re| re.captures(line))
141                        {
142                            // Missing space at end: # Heading#
143                            let content = captures.get(3).unwrap();
144                            let closing_hashes = captures.get(5).unwrap();
145                            message = format!(
146                                "Missing space before {} at end of closed heading",
147                                "#".repeat(closing_hashes.as_str().len())
148                            );
149                            // Highlight the position right before the closing hashes
150                            start_col = content.end() + 1;
151                            length = 1;
152                        }
153
154                        let (start_line, start_col_calc, end_line, end_col) =
155                            calculate_single_line_range(line_num + 1, start_col, length);
156
157                        warnings.push(LintWarning {
158                            rule_name: Some(self.name().to_string()),
159                            message,
160                            line: start_line,
161                            column: start_col_calc,
162                            end_line,
163                            end_column: end_col,
164                            severity: Severity::Warning,
165                            fix: Some(Fix {
166                                range: line_range,
167                                replacement: self.fix_closed_atx_heading(line),
168                            }),
169                        });
170                    }
171                }
172            }
173        }
174
175        Ok(warnings)
176    }
177
178    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
179        let mut lines = Vec::new();
180
181        for line_info in ctx.lines.iter() {
182            let mut fixed = false;
183
184            if let Some(heading) = &line_info.heading {
185                // Skip headings indented 4+ spaces (they're code blocks)
186                if line_info.indent >= 4 {
187                    lines.push(line_info.content(ctx.content).to_string());
188                    continue;
189                }
190
191                // Fix ATX headings without space (both properly closed and malformed)
192                if matches!(heading.style, crate::lint_context::HeadingStyle::ATX)
193                    && self.is_closed_atx_heading_without_space(line_info.content(ctx.content))
194                {
195                    lines.push(self.fix_closed_atx_heading(line_info.content(ctx.content)));
196                    fixed = true;
197                }
198            }
199
200            if !fixed {
201                lines.push(line_info.content(ctx.content).to_string());
202            }
203        }
204
205        // Reconstruct content preserving line endings
206        let mut result = lines.join("\n");
207        if ctx.content.ends_with('\n') && !result.ends_with('\n') {
208            result.push('\n');
209        }
210
211        Ok(result)
212    }
213
214    /// Get the category of this rule for selective processing
215    fn category(&self) -> RuleCategory {
216        RuleCategory::Heading
217    }
218
219    /// Check if this rule should be skipped
220    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
221        ctx.content.is_empty() || !ctx.likely_has_headings()
222    }
223
224    fn as_any(&self) -> &dyn std::any::Any {
225        self
226    }
227
228    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
229    where
230        Self: Sized,
231    {
232        Box::new(MD020NoMissingSpaceClosedAtx::new())
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::lint_context::LintContext;
240
241    #[test]
242    fn test_basic_functionality() {
243        let rule = MD020NoMissingSpaceClosedAtx;
244
245        // Test with correct spacing
246        let content = "# Heading 1 #\n## Heading 2 ##\n### Heading 3 ###";
247        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
248        let result = rule.check(&ctx).unwrap();
249        assert!(result.is_empty());
250
251        // Test with missing spaces
252        let content = "# Heading 1#\n## Heading 2 ##\n### Heading 3###";
253        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
254        let result = rule.check(&ctx).unwrap();
255        assert_eq!(result.len(), 2); // Should flag the two headings with missing spaces
256        assert_eq!(result[0].line, 1);
257        assert_eq!(result[1].line, 3);
258    }
259}