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