Skip to main content

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 regex::Regex;
7use std::sync::LazyLock;
8
9// Closed ATX heading patterns
10// [^#\s\\] before closing hashes prevents matching escaped hashes like C\# (C-sharp)
11static CLOSED_ATX_NO_SPACE_PATTERN: LazyLock<Regex> =
12    LazyLock::new(|| Regex::new(r"^(\s*)(#+)([^#\s].*?)([^#\s\\])(#+)(\s*(?:\{#[^}]+\})?\s*)$").unwrap());
13static CLOSED_ATX_NO_SPACE_START_PATTERN: LazyLock<Regex> =
14    LazyLock::new(|| Regex::new(r"^(\s*)(#+)([^#\s].*?)\s(#+)(\s*(?:\{#[^}]+\})?\s*)$").unwrap());
15static CLOSED_ATX_NO_SPACE_END_PATTERN: LazyLock<Regex> =
16    LazyLock::new(|| Regex::new(r"^(\s*)(#+)\s(.*?)([^#\s\\])(#+)(\s*(?:\{#[^}]+\})?\s*)$").unwrap());
17
18#[derive(Clone)]
19pub struct MD020NoMissingSpaceClosedAtx;
20
21impl Default for MD020NoMissingSpaceClosedAtx {
22    fn default() -> Self {
23        Self::new()
24    }
25}
26
27impl MD020NoMissingSpaceClosedAtx {
28    pub fn new() -> Self {
29        Self
30    }
31
32    /// Whether a line opens with an ATX marker: a heading, or a `#Heading#`
33    /// line that is paragraph text only because its opening space is missing.
34    fn opens_with_atx_marker(line_info: &crate::lint_context::LineInfo) -> bool {
35        line_info.atx_missing_space.is_some()
36            || line_info
37                .heading
38                .as_deref()
39                .is_some_and(|heading| matches!(heading.style, crate::lint_context::HeadingStyle::ATX))
40    }
41
42    fn is_closed_atx_heading_without_space(&self, line: &str) -> bool {
43        CLOSED_ATX_NO_SPACE_PATTERN.is_match(line)
44            || CLOSED_ATX_NO_SPACE_START_PATTERN.is_match(line)
45            || CLOSED_ATX_NO_SPACE_END_PATTERN.is_match(line)
46    }
47
48    fn fix_closed_atx_heading(&self, line: &str) -> String {
49        if let Some(captures) = CLOSED_ATX_NO_SPACE_PATTERN.captures(line) {
50            let indentation = &captures[1];
51            let opening_hashes = &captures[2];
52            let content = &captures[3];
53            let last_char = &captures[4];
54            let closing_hashes = &captures[5];
55            let custom_id = &captures[6];
56            format!("{indentation}{opening_hashes} {content}{last_char} {closing_hashes}{custom_id}")
57        } else if let Some(captures) = CLOSED_ATX_NO_SPACE_START_PATTERN.captures(line) {
58            let indentation = &captures[1];
59            let opening_hashes = &captures[2];
60            let content = &captures[3];
61            let closing_hashes = &captures[4];
62            let custom_id = &captures[5];
63            format!("{indentation}{opening_hashes} {content} {closing_hashes}{custom_id}")
64        } else if let Some(captures) = CLOSED_ATX_NO_SPACE_END_PATTERN.captures(line) {
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            // Check ATX headings, both properly closed and malformed, skipping
93            // ones indented 4+ spaces (they're code blocks)
94            if !Self::opens_with_atx_marker(line_info) || line_info.visual_indent >= 4 {
95                continue;
96            }
97
98            let line = line_info.content(ctx.content);
99
100            // Check if line matches closed ATX pattern without space
101            // This will detect both properly closed headings with missing space
102            // and malformed attempts at closed headings like "# Heading#"
103            if self.is_closed_atx_heading_without_space(line) {
104                let line_range = ctx.line_content_byte_range(line_num + 1);
105
106                let mut start_col = 1;
107                let mut length = 1;
108                let mut message = String::new();
109
110                if let Some(captures) = CLOSED_ATX_NO_SPACE_PATTERN.captures(line) {
111                    // Missing space at both start and end: #Heading#
112                    let opening_hashes = captures.get(2).unwrap();
113                    message = format!(
114                        "Missing space inside hashes on closed heading (with {} at start and end)",
115                        "#".repeat(opening_hashes.as_str().len())
116                    );
117                    // Highlight the position right after the opening hashes
118                    // Convert byte offset to character count for correct Unicode handling
119                    start_col = line[..opening_hashes.end()].chars().count() + 1;
120                    length = 1;
121                } else if let Some(captures) = CLOSED_ATX_NO_SPACE_START_PATTERN.captures(line) {
122                    // Missing space at start: #Heading #
123                    let opening_hashes = captures.get(2).unwrap();
124                    message = format!(
125                        "Missing space after {} at start of closed heading",
126                        "#".repeat(opening_hashes.as_str().len())
127                    );
128                    // Highlight the position right after the opening hashes
129                    // Convert byte offset to character count for correct Unicode handling
130                    start_col = line[..opening_hashes.end()].chars().count() + 1;
131                    length = 1;
132                } else if let Some(captures) = CLOSED_ATX_NO_SPACE_END_PATTERN.captures(line) {
133                    // Missing space at end: # Heading#
134                    let content = captures.get(3).unwrap();
135                    let closing_hashes = captures.get(5).unwrap();
136                    message = format!(
137                        "Missing space before {} at end of closed heading",
138                        "#".repeat(closing_hashes.as_str().len())
139                    );
140                    // Highlight the last character before the closing hashes
141                    // Convert byte offset to character count for correct Unicode handling
142                    start_col = line[..content.end()].chars().count() + 1;
143                    length = 1;
144                }
145
146                let (start_line, start_col_calc, end_line, end_col) =
147                    calculate_single_line_range(line_num + 1, start_col, length);
148
149                warnings.push(LintWarning {
150                    rule_name: Some(self.name().to_string()),
151                    message,
152                    line: start_line,
153                    column: start_col_calc,
154                    end_line,
155                    end_column: end_col,
156                    severity: Severity::Warning,
157                    fix: Some(Fix::new(line_range, self.fix_closed_atx_heading(line))),
158                });
159            }
160        }
161
162        Ok(warnings)
163    }
164
165    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
166        let mut lines = Vec::new();
167
168        for (i, line_info) in ctx.lines.iter().enumerate() {
169            let line_num = i + 1;
170            // If rule is disabled for this line, keep original
171            if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
172                lines.push(line_info.content(ctx.content).to_string());
173                continue;
174            }
175
176            let mut fixed = false;
177
178            if Self::opens_with_atx_marker(line_info) {
179                // Skip headings indented 4+ spaces (they're code blocks)
180                if line_info.visual_indent >= 4 {
181                    lines.push(line_info.content(ctx.content).to_string());
182                    continue;
183                }
184
185                // Fix ATX headings without space (both properly closed and malformed)
186                if self.is_closed_atx_heading_without_space(line_info.content(ctx.content)) {
187                    lines.push(self.fix_closed_atx_heading(line_info.content(ctx.content)));
188                    fixed = true;
189                }
190            }
191
192            if !fixed {
193                lines.push(line_info.content(ctx.content).to_string());
194            }
195        }
196
197        // Reconstruct content preserving line endings
198        let mut result = lines.join("\n");
199        if ctx.content.ends_with('\n') && !result.ends_with('\n') {
200            result.push('\n');
201        }
202
203        Ok(result)
204    }
205
206    /// Get the category of this rule for selective processing
207    fn category(&self) -> RuleCategory {
208        RuleCategory::Heading
209    }
210
211    /// Check if this rule should be skipped
212    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
213        ctx.content.is_empty() || !ctx.likely_has_headings()
214    }
215
216    fn as_any(&self) -> &dyn std::any::Any {
217        self
218    }
219
220    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
221    where
222        Self: Sized,
223    {
224        Box::new(MD020NoMissingSpaceClosedAtx::new())
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::lint_context::LintContext;
232
233    #[test]
234    fn test_basic_functionality() {
235        let rule = MD020NoMissingSpaceClosedAtx;
236
237        // Test with correct spacing
238        let content = "# Heading 1 #\n## Heading 2 ##\n### Heading 3 ###";
239        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
240        let result = rule.check(&ctx).unwrap();
241        assert!(result.is_empty());
242
243        // Test with missing spaces
244        let content = "# Heading 1#\n## Heading 2 ##\n### Heading 3###";
245        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
246        let result = rule.check(&ctx).unwrap();
247        assert_eq!(result.len(), 2); // Should flag the two headings with missing spaces
248        assert_eq!(result[0].line, 1);
249        assert_eq!(result[1].line, 3);
250    }
251
252    #[test]
253    fn test_multibyte_char_column_position() {
254        let rule = MD020NoMissingSpaceClosedAtx;
255
256        // Multi-byte characters before the content should not affect column calculation
257        // "Ü" is 2 bytes in UTF-8 but 1 character
258        // "##Ünited##" has ## at byte 0-1, content starts at byte 2
259        // Column should be 3 (character position), not 3 (byte position) here they match
260        // But "##über##" tests that column after ## reflects character count
261        let content = "##Ünited##";
262        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
263        let result = rule.check(&ctx).unwrap();
264
265        assert_eq!(result.len(), 1);
266        // Column should be based on character position, not byte offset
267        // "##" is 2 chars, so the position after ## is char position 3
268        // The byte offset of .end() for the opening hashes is 2, so start_col = 2 + 1 = 3
269        // For ASCII this is the same, but let's verify with a more complex case
270
271        // Content with multi-byte chars BEFORE closing hashes
272        // "##Ü test##" - Ü is 2 bytes, test starts at byte 4, char 3
273        // Content ends and closing hashes start after "Ü test" = 7 chars / 8 bytes
274        let content = "## Ü test##";
275        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
276        let result = rule.check(&ctx).unwrap();
277
278        assert_eq!(result.len(), 1);
279        // "## Ü test##" - regex group 3 (content) ends at byte 9 (after "Ü tes")
280        // line[..9] = "## Ü tes" = 8 characters, so start_col = 8 + 1 = 9
281        // Without the fix, byte offset 9 + 1 = 10 (wrong for non-ASCII)
282        assert_eq!(
283            result[0].column, 9,
284            "Column should use character position, not byte offset"
285        );
286    }
287}