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    /// Whether a quoted line opens its content with one to six `#` under four
43    /// columns of indentation, so it can be an ATX heading rather than quoted code. The
44    /// indentation counts from the column after the optional space (or tab)
45    /// following the last quote marker, or from `edge` when a list item inside the
46    /// quote starts its content further right; tabs advance to multiples of four.
47    fn quoted_atx_candidate(line: &str, edge: usize) -> bool {
48        let mut column: usize = 0;
49        let mut content_start: usize = 0;
50        for (pos, c) in line.char_indices() {
51            match c {
52                ' ' => column += 1,
53                '\t' => column += 4 - column % 4,
54                '>' => {
55                    column += 1;
56                    content_start = column + 1;
57                }
58                '#' => {
59                    let hashes = line[pos..].len() - line[pos..].trim_start_matches('#').len();
60                    return hashes <= 6 && column.saturating_sub(content_start.max(edge)) < 4;
61                }
62                _ => return false,
63            }
64        }
65        false
66    }
67
68    fn is_closed_atx_heading_without_space(&self, line: &str) -> bool {
69        CLOSED_ATX_NO_SPACE_PATTERN.is_match(line)
70            || CLOSED_ATX_NO_SPACE_START_PATTERN.is_match(line)
71            || CLOSED_ATX_NO_SPACE_END_PATTERN.is_match(line)
72    }
73
74    fn fix_closed_atx_heading(&self, line: &str) -> String {
75        if let Some(captures) = CLOSED_ATX_NO_SPACE_PATTERN.captures(line) {
76            let indentation = &captures[1];
77            let opening_hashes = &captures[2];
78            let content = &captures[3];
79            let last_char = &captures[4];
80            let closing_hashes = &captures[5];
81            let custom_id = &captures[6];
82            format!("{indentation}{opening_hashes} {content}{last_char} {closing_hashes}{custom_id}")
83        } else if let Some(captures) = CLOSED_ATX_NO_SPACE_START_PATTERN.captures(line) {
84            let indentation = &captures[1];
85            let opening_hashes = &captures[2];
86            let content = &captures[3];
87            let closing_hashes = &captures[4];
88            let custom_id = &captures[5];
89            format!("{indentation}{opening_hashes} {content} {closing_hashes}{custom_id}")
90        } else if let Some(captures) = CLOSED_ATX_NO_SPACE_END_PATTERN.captures(line) {
91            let indentation = &captures[1];
92            let opening_hashes = &captures[2];
93            let content = &captures[3];
94            let last_char = &captures[4];
95            let closing_hashes = &captures[5];
96            let custom_id = &captures[6];
97            format!("{indentation}{opening_hashes} {content}{last_char} {closing_hashes}{custom_id}")
98        } else {
99            line.to_string()
100        }
101    }
102}
103
104impl Rule for MD020NoMissingSpaceClosedAtx {
105    fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
106        Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(20))
107    }
108
109    fn name(&self) -> &'static str {
110        "MD020"
111    }
112
113    fn description(&self) -> &'static str {
114        "No space inside hashes on closed heading"
115    }
116
117    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
118        let mut warnings = Vec::new();
119
120        // A quoted setext heading's text lines are heading text, not ATX candidates.
121        let quoted_setext_lines: std::collections::HashSet<usize> = ctx
122            .headings()
123            .filter(|h| h.is_blockquote() && h.is_setext())
124            .flat_map(|h| (h.first_line_num() - 1)..h.line_num)
125            .collect();
126
127        let edges = crate::utils::blockquote::container_edges(ctx);
128
129        // Check all closed ATX headings from cached info
130
131        let source = ctx
132            .candidate_line_indices(crate::lint_context::LineCandidateKind::Hash)
133            .iter()
134            .map(|&index| (index, &ctx.lines[index]));
135
136        for (line_num, line_info) in source {
137            let full_line = line_info.content(ctx.content);
138            // Inside a blockquote the heading candidate is the quoted content,
139            // and the quote prefix stays in the reported columns and the fix.
140            let (prefix_len, marker_offset) = if line_info.blockquote.is_some() {
141                if quoted_setext_lines.contains(&line_num)
142                    || line_info.in_code_block
143                    || line_info.in_html_block
144                    || line_info.in_html_comment
145                    || line_info.in_mdx_comment
146                    || line_info.in_esm_block
147                    || line_info.in_pymdown_block
148                    || line_info.in_front_matter
149                {
150                    continue;
151                }
152                match crate::utils::blockquote::parse_blockquote_prefix(full_line) {
153                    Some(parsed)
154                        if crate::utils::blockquote::quote_markers_are_real(full_line)
155                            && !crate::utils::blockquote::quote_markers_are_paragraph_text(
156                                ctx, &edges, line_num, full_line,
157                            )
158                            && Self::quoted_atx_candidate(full_line, edges[line_num]) =>
159                    {
160                        let prefix_len = full_line.len() - parsed.content.len();
161                        (
162                            prefix_len,
163                            prefix_len + parsed.content.len() - parsed.content.trim_start().len(),
164                        )
165                    }
166                    _ => continue,
167                }
168            } else {
169                (0, line_info.indent)
170            };
171            let marker_byte = line_info.byte_offset + marker_offset;
172            if ctx.overlaps_template_code(marker_byte, marker_byte + 1)
173                || ctx.is_in_html_tag(marker_byte)
174                || ctx.is_in_code_span_byte(marker_byte)
175                || ctx.is_in_parsed_link(marker_byte)
176                || ctx.overlaps_mdx_inline_code(marker_byte, marker_byte + 1)
177                || ctx.is_in_jsx_component_tag(marker_byte)
178            {
179                continue;
180            }
181            // Check ATX headings, both properly closed and malformed, skipping
182            // ones indented 4+ spaces (they're code blocks)
183            if prefix_len == 0
184                && (!Self::opens_with_atx_marker(line_info)
185                    || crate::utils::blockquote::is_indented_past_edge(&edges, line_num, full_line))
186            {
187                continue;
188            }
189
190            let line = &full_line[prefix_len..];
191            let start_offset = full_line[..prefix_len].chars().count();
192
193            // Check if line matches closed ATX pattern without space
194            // This will detect both properly closed headings with missing space
195            // and malformed attempts at closed headings like "# Heading#"
196            if self.is_closed_atx_heading_without_space(line) {
197                let line_range = ctx.line_content_byte_range(line_num + 1);
198
199                let mut start_col = 1;
200                let mut length = 1;
201                let mut message = String::new();
202
203                if let Some(captures) = CLOSED_ATX_NO_SPACE_PATTERN.captures(line) {
204                    // Missing space at both start and end: #Heading#
205                    let opening_hashes = captures.get(2).unwrap();
206                    message = format!(
207                        "Missing space inside hashes on closed heading (with {} at start and end)",
208                        "#".repeat(opening_hashes.as_str().len())
209                    );
210                    // Highlight the position right after the opening hashes
211                    // Convert byte offset to character count for correct Unicode handling
212                    start_col = start_offset + line[..opening_hashes.end()].chars().count() + 1;
213                    length = 1;
214                } else if let Some(captures) = CLOSED_ATX_NO_SPACE_START_PATTERN.captures(line) {
215                    // Missing space at start: #Heading #
216                    let opening_hashes = captures.get(2).unwrap();
217                    message = format!(
218                        "Missing space after {} at start of closed heading",
219                        "#".repeat(opening_hashes.as_str().len())
220                    );
221                    // Highlight the position right after the opening hashes
222                    // Convert byte offset to character count for correct Unicode handling
223                    start_col = start_offset + line[..opening_hashes.end()].chars().count() + 1;
224                    length = 1;
225                } else if let Some(captures) = CLOSED_ATX_NO_SPACE_END_PATTERN.captures(line) {
226                    // Missing space at end: # Heading#
227                    let content = captures.get(3).unwrap();
228                    let closing_hashes = captures.get(5).unwrap();
229                    message = format!(
230                        "Missing space before {} at end of closed heading",
231                        "#".repeat(closing_hashes.as_str().len())
232                    );
233                    // Highlight the last character before the closing hashes
234                    // Convert byte offset to character count for correct Unicode handling
235                    start_col = start_offset + line[..content.end()].chars().count() + 1;
236                    length = 1;
237                }
238
239                let (start_line, start_col_calc, end_line, end_col) =
240                    calculate_single_line_range(line_num + 1, start_col, length);
241
242                warnings.push(LintWarning {
243                    rule_name: Some(self.name().to_string()),
244                    message,
245                    line: start_line,
246                    column: start_col_calc,
247                    end_line,
248                    end_column: end_col,
249                    severity: Severity::Warning,
250                    fix: Some(Fix::new(
251                        line_range,
252                        format!("{}{}", &full_line[..prefix_len], self.fix_closed_atx_heading(line)),
253                    )),
254                });
255            }
256        }
257
258        Ok(warnings)
259    }
260
261    fn fixes_from_diagnostics(&self) -> bool {
262        true
263    }
264
265    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
266        let warnings = self.check(ctx)?;
267        let warnings =
268            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
269        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
270            .map_err(crate::rule::LintError::InvalidInput)
271    }
272
273    /// Get the category of this rule for selective processing
274    fn category(&self) -> RuleCategory {
275        RuleCategory::Heading
276    }
277
278    /// Check if this rule should be skipped
279    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
280        ctx.content.is_empty() || !ctx.likely_has_headings()
281    }
282
283    fn as_any(&self) -> &dyn std::any::Any {
284        self
285    }
286
287    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
288    where
289        Self: Sized,
290    {
291        Box::new(MD020NoMissingSpaceClosedAtx::new())
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use crate::lint_context::LintContext;
299
300    #[test]
301    fn test_basic_functionality() {
302        let rule = MD020NoMissingSpaceClosedAtx;
303
304        // Test with correct spacing
305        let content = "# Heading 1 #\n## Heading 2 ##\n### Heading 3 ###";
306        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
307        let result = rule.check(&ctx).unwrap();
308        assert!(result.is_empty());
309
310        // Test with missing spaces
311        let content = "# Heading 1#\n## Heading 2 ##\n### Heading 3###";
312        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
313        let result = rule.check(&ctx).unwrap();
314        assert_eq!(result.len(), 2); // Should flag the two headings with missing spaces
315        assert_eq!(result[0].line, 1);
316        assert_eq!(result[1].line, 3);
317    }
318
319    #[test]
320    fn test_multibyte_char_column_position() {
321        let rule = MD020NoMissingSpaceClosedAtx;
322
323        // Multi-byte characters before the content should not affect column calculation
324        // "Ü" is 2 bytes in UTF-8 but 1 character
325        // "##Ünited##" has ## at byte 0-1, content starts at byte 2
326        // Column should be 3 (character position), not 3 (byte position) here they match
327        // But "##über##" tests that column after ## reflects character count
328        let content = "##Ünited##";
329        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
330        let result = rule.check(&ctx).unwrap();
331
332        assert_eq!(result.len(), 1);
333        // Column should be based on character position, not byte offset
334        // "##" is 2 chars, so the position after ## is char position 3
335        // The byte offset of .end() for the opening hashes is 2, so start_col = 2 + 1 = 3
336        // For ASCII this is the same, but let's verify with a more complex case
337
338        // Content with multi-byte chars BEFORE closing hashes
339        // "##Ü test##" - Ü is 2 bytes, test starts at byte 4, char 3
340        // Content ends and closing hashes start after "Ü test" = 7 chars / 8 bytes
341        let content = "## Ü test##";
342        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
343        let result = rule.check(&ctx).unwrap();
344
345        assert_eq!(result.len(), 1);
346        // "## Ü test##" - regex group 3 (content) ends at byte 9 (after "Ü tes")
347        // line[..9] = "## Ü tes" = 8 characters, so start_col = 8 + 1 = 9
348        // Without the fix, byte offset 9 + 1 = 10 (wrong for non-ASCII)
349        assert_eq!(
350            result[0].column, 9,
351            "Column should use character position, not byte offset"
352        );
353    }
354}