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::{LineIndex, 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;
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_index = LineIndex::new(ctx.content.to_string());
107                        let line_range = line_index.line_content_range(line_num + 1);
108
109                        let mut start_col = 1;
110                        let mut length = 1;
111                        let mut message = String::new();
112
113                        if let Some(captures) = get_cached_regex(CLOSED_ATX_NO_SPACE_PATTERN_STR)
114                            .ok()
115                            .and_then(|re| re.captures(line))
116                        {
117                            // Missing space at both start and end: #Heading#
118                            let opening_hashes = captures.get(2).unwrap();
119                            message = format!(
120                                "Missing space inside hashes on closed heading (with {} at start and end)",
121                                "#".repeat(opening_hashes.as_str().len())
122                            );
123                            // Highlight the position right after the opening hashes
124                            start_col = opening_hashes.end() + 1;
125                            length = 1;
126                        } else if let Some(captures) = get_cached_regex(CLOSED_ATX_NO_SPACE_START_PATTERN_STR)
127                            .ok()
128                            .and_then(|re| re.captures(line))
129                        {
130                            // Missing space at start: #Heading #
131                            let opening_hashes = captures.get(2).unwrap();
132                            message = format!(
133                                "Missing space after {} at start of closed heading",
134                                "#".repeat(opening_hashes.as_str().len())
135                            );
136                            // Highlight the position right after the opening hashes
137                            start_col = opening_hashes.end() + 1;
138                            length = 1;
139                        } else if let Some(captures) = get_cached_regex(CLOSED_ATX_NO_SPACE_END_PATTERN_STR)
140                            .ok()
141                            .and_then(|re| re.captures(line))
142                        {
143                            // Missing space at end: # Heading#
144                            let content = captures.get(3).unwrap();
145                            let closing_hashes = captures.get(5).unwrap();
146                            message = format!(
147                                "Missing space before {} at end of closed heading",
148                                "#".repeat(closing_hashes.as_str().len())
149                            );
150                            // Highlight the position right before the closing hashes
151                            start_col = content.end() + 1;
152                            length = 1;
153                        }
154
155                        let (start_line, start_col_calc, end_line, end_col) =
156                            calculate_single_line_range(line_num + 1, start_col, length);
157
158                        warnings.push(LintWarning {
159                            rule_name: Some(self.name()),
160                            message,
161                            line: start_line,
162                            column: start_col_calc,
163                            end_line,
164                            end_column: end_col,
165                            severity: Severity::Warning,
166                            fix: Some(Fix {
167                                range: line_range,
168                                replacement: self.fix_closed_atx_heading(line),
169                            }),
170                        });
171                    }
172                }
173            }
174        }
175
176        Ok(warnings)
177    }
178
179    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
180        let mut lines = Vec::new();
181
182        for line_info in ctx.lines.iter() {
183            let mut fixed = false;
184
185            if let Some(heading) = &line_info.heading {
186                // Skip headings indented 4+ spaces (they're code blocks)
187                if line_info.indent >= 4 {
188                    lines.push(line_info.content.clone());
189                    continue;
190                }
191
192                // Fix ATX headings without space (both properly closed and malformed)
193                if matches!(heading.style, crate::lint_context::HeadingStyle::ATX)
194                    && self.is_closed_atx_heading_without_space(&line_info.content)
195                {
196                    lines.push(self.fix_closed_atx_heading(&line_info.content));
197                    fixed = true;
198                }
199            }
200
201            if !fixed {
202                lines.push(line_info.content.clone());
203            }
204        }
205
206        // Reconstruct content preserving line endings
207        let mut result = lines.join("\n");
208        if ctx.content.ends_with('\n') && !result.ends_with('\n') {
209            result.push('\n');
210        }
211
212        Ok(result)
213    }
214
215    /// Get the category of this rule for selective processing
216    fn category(&self) -> RuleCategory {
217        RuleCategory::Heading
218    }
219
220    /// Check if this rule should be skipped
221    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
222        ctx.content.is_empty() || !ctx.likely_has_headings()
223    }
224
225    fn as_any(&self) -> &dyn std::any::Any {
226        self
227    }
228
229    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
230    where
231        Self: Sized,
232    {
233        Box::new(MD020NoMissingSpaceClosedAtx::new())
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::lint_context::LintContext;
241
242    #[test]
243    fn test_basic_functionality() {
244        let rule = MD020NoMissingSpaceClosedAtx;
245
246        // Test with correct spacing
247        let content = "# Heading 1 #\n## Heading 2 ##\n### Heading 3 ###";
248        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
249        let result = rule.check(&ctx).unwrap();
250        assert!(result.is_empty());
251
252        // Test with missing spaces
253        let content = "# Heading 1#\n## Heading 2 ##\n### Heading 3###";
254        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
255        let result = rule.check(&ctx).unwrap();
256        assert_eq!(result.len(), 2); // Should flag the two headings with missing spaces
257        assert_eq!(result[0].line, 1);
258        assert_eq!(result[1].line, 3);
259    }
260}