Skip to main content

rumdl_lib/rules/
md018_no_missing_space_atx.rs

1/// Rule MD018: No missing space after ATX heading marker
2///
3/// See [docs/md018.md](../../docs/md018.md) for full documentation, configuration, and examples.
4use crate::config::MarkdownFlavor;
5use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6use crate::utils::range_utils::calculate_single_line_range;
7use crate::utils::regex_cache::get_cached_regex;
8
9// Emoji and Unicode hashtag patterns
10const EMOJI_HASHTAG_PATTERN_STR: &str = r"^#️⃣|^#⃣";
11const UNICODE_HASHTAG_PATTERN_STR: &str = r"^#[\u{FE0F}\u{20E3}]";
12
13// MagicLink issue/PR reference pattern: #123, #10, etc.
14// Matches # followed by one or more digits, then either end of string,
15// whitespace, or punctuation (not alphanumeric continuation)
16const MAGICLINK_REF_PATTERN_STR: &str = r"^#\d+(?:\s|[^a-zA-Z0-9]|$)";
17
18#[derive(Clone)]
19pub struct MD018NoMissingSpaceAtx;
20
21impl Default for MD018NoMissingSpaceAtx {
22    fn default() -> Self {
23        Self::new()
24    }
25}
26
27impl MD018NoMissingSpaceAtx {
28    pub fn new() -> Self {
29        Self
30    }
31
32    /// Check if a line is a MagicLink-style issue/PR reference (e.g., #123, #10)
33    /// Used by MkDocs flavor to skip PyMdown MagicLink patterns
34    fn is_magiclink_ref(line: &str) -> bool {
35        get_cached_regex(MAGICLINK_REF_PATTERN_STR).is_ok_and(|re| re.is_match(line.trim_start()))
36    }
37
38    /// Check if an ATX heading line is missing space after the marker
39    fn check_atx_heading_line(&self, line: &str, flavor: MarkdownFlavor) -> Option<(usize, String)> {
40        // Look for ATX marker at start of line (with optional indentation)
41        let trimmed_line = line.trim_start();
42        let indent = line.len() - trimmed_line.len();
43
44        if !trimmed_line.starts_with('#') {
45            return None;
46        }
47
48        // Only flag patterns at column 1 (no indentation) to match markdownlint behavior
49        // Indented patterns are likely:
50        // - Multi-line link continuations (e.g., "  #sig-contribex](url)")
51        // - List item content
52        // - Other continuation contexts
53        if indent > 0 {
54            return None;
55        }
56
57        // Skip emoji hashtags and Unicode hashtag patterns
58        let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
59            .map(|re| re.is_match(trimmed_line))
60            .unwrap_or(false);
61        let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
62            .map(|re| re.is_match(trimmed_line))
63            .unwrap_or(false);
64        if is_emoji || is_unicode {
65            return None;
66        }
67
68        // Count the number of hashes
69        let hash_count = trimmed_line.chars().take_while(|&c| c == '#').count();
70        if hash_count == 0 || hash_count > 6 {
71            return None;
72        }
73
74        // Check what comes after the hashes
75        let after_hashes = &trimmed_line[hash_count..];
76
77        // Skip if what follows the hashes is an emoji modifier or variant selector
78        if after_hashes
79            .chars()
80            .next()
81            .is_some_and(|ch| matches!(ch, '\u{FE0F}' | '\u{20E3}' | '\u{FE0E}'))
82        {
83            return None;
84        }
85
86        // If there's content immediately after hashes (no space), it needs fixing
87        if !after_hashes.is_empty() && !after_hashes.starts_with(' ') && !after_hashes.starts_with('\t') {
88            // Additional checks to avoid false positives
89            let content = after_hashes.trim();
90
91            // Skip if it's just more hashes (horizontal rule)
92            if content.chars().all(|c| c == '#') {
93                return None;
94            }
95
96            // Skip if content is too short to be meaningful
97            if content.len() < 2 {
98                return None;
99            }
100
101            // Skip if it starts with emphasis markers
102            if content.starts_with('*') || content.starts_with('_') {
103                return None;
104            }
105
106            // MkDocs flavor: skip MagicLink-style issue/PR refs (#123, #10, etc.)
107            // MagicLink only uses single #, so check hash_count == 1
108            if flavor == MarkdownFlavor::MkDocs && hash_count == 1 && Self::is_magiclink_ref(line) {
109                return None;
110            }
111
112            // This looks like a malformed heading that needs a space
113            let fixed = format!("{}{} {}", " ".repeat(indent), "#".repeat(hash_count), after_hashes);
114            return Some((indent + hash_count, fixed));
115        }
116
117        None
118    }
119
120    // Calculate the byte range for a specific line in the content
121    fn get_line_byte_range(&self, content: &str, line_num: usize) -> std::ops::Range<usize> {
122        let mut current_line = 1;
123        let mut start_byte = 0;
124
125        for (i, c) in content.char_indices() {
126            if current_line == line_num && c == '\n' {
127                return start_byte..i;
128            } else if c == '\n' {
129                current_line += 1;
130                if current_line == line_num {
131                    start_byte = i + 1;
132                }
133            }
134        }
135
136        // If we're looking for the last line and it doesn't end with a newline
137        if current_line == line_num {
138            return start_byte..content.len();
139        }
140
141        // Fallback if line not found (shouldn't happen)
142        0..0
143    }
144}
145
146impl Rule for MD018NoMissingSpaceAtx {
147    fn name(&self) -> &'static str {
148        "MD018"
149    }
150
151    fn description(&self) -> &'static str {
152        "No space after hash in heading"
153    }
154
155    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
156        let mut warnings = Vec::new();
157
158        // Check all lines that have ATX headings from cached info
159        for (line_num, line_info) in ctx.lines.iter().enumerate() {
160            // Skip lines inside HTML blocks or HTML comments (e.g., CSS selectors like #id)
161            if line_info.in_html_block || line_info.in_html_comment {
162                continue;
163            }
164
165            if let Some(heading) = &line_info.heading {
166                // Only check ATX headings
167                if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
168                    // Skip indented headings to match markdownlint behavior
169                    // Markdownlint only flags patterns at column 1
170                    if line_info.indent > 0 {
171                        continue;
172                    }
173
174                    // Check if there's a space after the marker
175                    let line = line_info.content(ctx.content);
176                    let trimmed = line.trim_start();
177
178                    // Skip emoji hashtags and Unicode hashtag patterns
179                    let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
180                        .map(|re| re.is_match(trimmed))
181                        .unwrap_or(false);
182                    let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
183                        .map(|re| re.is_match(trimmed))
184                        .unwrap_or(false);
185                    if is_emoji || is_unicode {
186                        continue;
187                    }
188
189                    // MkDocs flavor: skip MagicLink-style issue/PR refs (#123, #10, etc.)
190                    if ctx.flavor == MarkdownFlavor::MkDocs && heading.level == 1 && Self::is_magiclink_ref(line) {
191                        continue;
192                    }
193
194                    if trimmed.len() > heading.marker.len() {
195                        let after_marker = &trimmed[heading.marker.len()..];
196                        if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
197                        {
198                            // Missing space after ATX marker
199                            let hash_end_col = line_info.indent + heading.marker.len() + 1; // 1-indexed
200                            let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
201                                line_num + 1, // Convert to 1-indexed
202                                hash_end_col,
203                                0, // Zero-width to indicate missing space
204                            );
205
206                            warnings.push(LintWarning {
207                                rule_name: Some(self.name().to_string()),
208                                message: format!("No space after {} in heading", "#".repeat(heading.level as usize)),
209                                line: start_line,
210                                column: start_col,
211                                end_line,
212                                end_column: end_col,
213                                severity: Severity::Warning,
214                                fix: Some(Fix {
215                                    range: self.get_line_byte_range(ctx.content, line_num + 1),
216                                    replacement: {
217                                        // Preserve original indentation (including tabs)
218                                        let line = line_info.content(ctx.content);
219                                        let original_indent = &line[..line_info.indent];
220                                        format!("{original_indent}{} {after_marker}", heading.marker)
221                                    },
222                                }),
223                            });
224                        }
225                    }
226                }
227            } else if !line_info.in_code_block
228                && !line_info.in_front_matter
229                && !line_info.in_html_comment
230                && !line_info.is_blank
231            {
232                // Check for malformed headings that weren't detected as proper headings
233                if let Some((hash_end_pos, fixed_line)) =
234                    self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor)
235                {
236                    let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
237                        line_num + 1,     // Convert to 1-indexed
238                        hash_end_pos + 1, // 1-indexed column
239                        0,                // Zero-width to indicate missing space
240                    );
241
242                    warnings.push(LintWarning {
243                        rule_name: Some(self.name().to_string()),
244                        message: "No space after hash in heading".to_string(),
245                        line: start_line,
246                        column: start_col,
247                        end_line,
248                        end_column: end_col,
249                        severity: Severity::Warning,
250                        fix: Some(Fix {
251                            range: self.get_line_byte_range(ctx.content, line_num + 1),
252                            replacement: fixed_line,
253                        }),
254                    });
255                }
256            }
257        }
258
259        Ok(warnings)
260    }
261
262    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
263        let mut lines = Vec::new();
264
265        for line_info in ctx.lines.iter() {
266            let mut fixed = false;
267
268            if let Some(heading) = &line_info.heading {
269                // Fix ATX headings missing space
270                if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
271                    let line = line_info.content(ctx.content);
272                    let trimmed = line.trim_start();
273
274                    // Skip emoji hashtags and Unicode hashtag patterns
275                    let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
276                        .map(|re| re.is_match(trimmed))
277                        .unwrap_or(false);
278                    let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
279                        .map(|re| re.is_match(trimmed))
280                        .unwrap_or(false);
281
282                    // MkDocs flavor: skip MagicLink-style issue/PR refs (#123, #10, etc.)
283                    let is_magiclink =
284                        ctx.flavor == MarkdownFlavor::MkDocs && heading.level == 1 && Self::is_magiclink_ref(line);
285
286                    // Only attempt fix if not a special pattern
287                    if !is_emoji && !is_unicode && !is_magiclink && trimmed.len() > heading.marker.len() {
288                        let after_marker = &trimmed[heading.marker.len()..];
289                        if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
290                        {
291                            // Add space after marker, preserving original indentation (including tabs)
292                            let line = line_info.content(ctx.content);
293                            let original_indent = &line[..line_info.indent];
294                            lines.push(format!("{original_indent}{} {after_marker}", heading.marker));
295                            fixed = true;
296                        }
297                    }
298                }
299            } else if !line_info.in_code_block
300                && !line_info.in_front_matter
301                && !line_info.in_html_comment
302                && !line_info.is_blank
303            {
304                // Fix malformed headings
305                if let Some((_, fixed_line)) = self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor) {
306                    lines.push(fixed_line);
307                    fixed = true;
308                }
309            }
310
311            if !fixed {
312                lines.push(line_info.content(ctx.content).to_string());
313            }
314        }
315
316        // Reconstruct content preserving line endings
317        let mut result = lines.join("\n");
318        if ctx.content.ends_with('\n') && !result.ends_with('\n') {
319            result.push('\n');
320        }
321
322        Ok(result)
323    }
324
325    /// Get the category of this rule for selective processing
326    fn category(&self) -> RuleCategory {
327        RuleCategory::Heading
328    }
329
330    /// Check if this rule should be skipped
331    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
332        // Fast path: check if document likely has headings
333        !ctx.likely_has_headings()
334    }
335
336    fn as_any(&self) -> &dyn std::any::Any {
337        self
338    }
339
340    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
341    where
342        Self: Sized,
343    {
344        Box::new(MD018NoMissingSpaceAtx::new())
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::lint_context::LintContext;
352
353    #[test]
354    fn test_basic_functionality() {
355        let rule = MD018NoMissingSpaceAtx;
356
357        // Test with correct space
358        let content = "# Heading 1\n## Heading 2\n### Heading 3";
359        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
360        let result = rule.check(&ctx).unwrap();
361        assert!(result.is_empty());
362
363        // Test with missing space
364        let content = "#Heading 1\n## Heading 2\n###Heading 3";
365        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
366        let result = rule.check(&ctx).unwrap();
367        assert_eq!(result.len(), 2); // Should flag the two headings with missing spaces
368        assert_eq!(result[0].line, 1);
369        assert_eq!(result[1].line, 3);
370    }
371
372    #[test]
373    fn test_malformed_heading_detection() {
374        let rule = MD018NoMissingSpaceAtx::new();
375
376        // Test the check_atx_heading_line method
377        assert!(
378            rule.check_atx_heading_line("##Introduction", MarkdownFlavor::Standard)
379                .is_some()
380        );
381        assert!(
382            rule.check_atx_heading_line("###Background", MarkdownFlavor::Standard)
383                .is_some()
384        );
385        assert!(
386            rule.check_atx_heading_line("####Details", MarkdownFlavor::Standard)
387                .is_some()
388        );
389        assert!(
390            rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
391                .is_some()
392        );
393        assert!(
394            rule.check_atx_heading_line("######Conclusion", MarkdownFlavor::Standard)
395                .is_some()
396        );
397        assert!(
398            rule.check_atx_heading_line("##Table of Contents", MarkdownFlavor::Standard)
399                .is_some()
400        );
401
402        // Should NOT detect these
403        assert!(rule.check_atx_heading_line("###", MarkdownFlavor::Standard).is_none()); // Just hashes
404        assert!(rule.check_atx_heading_line("#", MarkdownFlavor::Standard).is_none()); // Single hash
405        assert!(rule.check_atx_heading_line("##a", MarkdownFlavor::Standard).is_none()); // Too short
406        assert!(
407            rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
408                .is_none()
409        ); // Emphasis marker
410        assert!(
411            rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
412                .is_none()
413        ); // More than 6 hashes
414    }
415
416    #[test]
417    fn test_malformed_heading_with_context() {
418        let rule = MD018NoMissingSpaceAtx::new();
419
420        // Test with full content that includes code blocks
421        let content = r#"# Test Document
422
423##Introduction
424This should be detected.
425
426    ##CodeBlock
427This should NOT be detected (indented code block).
428
429```
430##FencedCodeBlock
431This should NOT be detected (fenced code block).
432```
433
434##Conclusion
435This should be detected.
436"#;
437
438        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
439        let result = rule.check(&ctx).unwrap();
440
441        // Should detect malformed headings but ignore code blocks
442        let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
443        assert!(detected_lines.contains(&3)); // ##Introduction
444        assert!(detected_lines.contains(&14)); // ##Conclusion (updated line number)
445        assert!(!detected_lines.contains(&6)); // ##CodeBlock (should be ignored)
446        assert!(!detected_lines.contains(&10)); // ##FencedCodeBlock (should be ignored)
447    }
448
449    #[test]
450    fn test_malformed_heading_fix() {
451        let rule = MD018NoMissingSpaceAtx::new();
452
453        let content = r#"##Introduction
454This is a test.
455
456###Background
457More content."#;
458
459        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
460        let fixed = rule.fix(&ctx).unwrap();
461
462        let expected = r#"## Introduction
463This is a test.
464
465### Background
466More content."#;
467
468        assert_eq!(fixed, expected);
469    }
470
471    #[test]
472    fn test_mixed_proper_and_malformed_headings() {
473        let rule = MD018NoMissingSpaceAtx::new();
474
475        let content = r#"# Proper Heading
476
477##Malformed Heading
478
479## Another Proper Heading
480
481###Another Malformed
482
483#### Proper with space
484"#;
485
486        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
487        let result = rule.check(&ctx).unwrap();
488
489        // Should only detect the malformed ones
490        assert_eq!(result.len(), 2);
491        let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
492        assert!(detected_lines.contains(&3)); // ##Malformed Heading
493        assert!(detected_lines.contains(&7)); // ###Another Malformed
494    }
495
496    #[test]
497    fn test_css_selectors_in_html_blocks() {
498        let rule = MD018NoMissingSpaceAtx::new();
499
500        // Test CSS selectors inside <style> tags should not trigger MD018
501        // This is a common pattern in Quarto/RMarkdown files
502        let content = r#"# Proper Heading
503
504<style>
505#slide-1 ol li {
506    margin-top: 0;
507}
508
509#special-slide ol li {
510    margin-top: 2em;
511}
512</style>
513
514## Another Heading
515"#;
516
517        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
518        let result = rule.check(&ctx).unwrap();
519
520        // Should not detect CSS selectors as malformed headings
521        assert_eq!(
522            result.len(),
523            0,
524            "CSS selectors in <style> blocks should not be flagged as malformed headings"
525        );
526    }
527
528    #[test]
529    fn test_js_code_in_script_blocks() {
530        let rule = MD018NoMissingSpaceAtx::new();
531
532        // Test that patterns like #element in <script> tags don't trigger MD018
533        let content = r#"# Heading
534
535<script>
536const element = document.querySelector('#main-content');
537#another-comment
538</script>
539
540## Another Heading
541"#;
542
543        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
544        let result = rule.check(&ctx).unwrap();
545
546        // Should not detect JS code as malformed headings
547        assert_eq!(
548            result.len(),
549            0,
550            "JavaScript code in <script> blocks should not be flagged as malformed headings"
551        );
552    }
553
554    #[test]
555    fn test_all_malformed_headings_detected() {
556        let rule = MD018NoMissingSpaceAtx::new();
557
558        // All patterns at line start should be detected as malformed headings
559        // (matching markdownlint behavior)
560
561        // Lowercase single-hash - should be detected
562        assert!(
563            rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
564                .is_some(),
565            "#hello SHOULD be detected as malformed heading"
566        );
567        assert!(
568            rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
569            "#tag SHOULD be detected as malformed heading"
570        );
571        assert!(
572            rule.check_atx_heading_line("#hashtag", MarkdownFlavor::Standard)
573                .is_some(),
574            "#hashtag SHOULD be detected as malformed heading"
575        );
576        assert!(
577            rule.check_atx_heading_line("#javascript", MarkdownFlavor::Standard)
578                .is_some(),
579            "#javascript SHOULD be detected as malformed heading"
580        );
581
582        // Numeric patterns - should be detected (could be headings like "# 123")
583        assert!(
584            rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
585            "#123 SHOULD be detected as malformed heading"
586        );
587        assert!(
588            rule.check_atx_heading_line("#12345", MarkdownFlavor::Standard)
589                .is_some(),
590            "#12345 SHOULD be detected as malformed heading"
591        );
592        assert!(
593            rule.check_atx_heading_line("#29039)", MarkdownFlavor::Standard)
594                .is_some(),
595            "#29039) SHOULD be detected as malformed heading"
596        );
597
598        // Uppercase single-hash - should be detected
599        assert!(
600            rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
601                .is_some(),
602            "#Summary SHOULD be detected as malformed heading"
603        );
604        assert!(
605            rule.check_atx_heading_line("#Introduction", MarkdownFlavor::Standard)
606                .is_some(),
607            "#Introduction SHOULD be detected as malformed heading"
608        );
609        assert!(
610            rule.check_atx_heading_line("#API", MarkdownFlavor::Standard).is_some(),
611            "#API SHOULD be detected as malformed heading"
612        );
613
614        // Multi-hash patterns - should be detected
615        assert!(
616            rule.check_atx_heading_line("##introduction", MarkdownFlavor::Standard)
617                .is_some(),
618            "##introduction SHOULD be detected as malformed heading"
619        );
620        assert!(
621            rule.check_atx_heading_line("###section", MarkdownFlavor::Standard)
622                .is_some(),
623            "###section SHOULD be detected as malformed heading"
624        );
625        assert!(
626            rule.check_atx_heading_line("###fer", MarkdownFlavor::Standard)
627                .is_some(),
628            "###fer SHOULD be detected as malformed heading"
629        );
630        assert!(
631            rule.check_atx_heading_line("##123", MarkdownFlavor::Standard).is_some(),
632            "##123 SHOULD be detected as malformed heading"
633        );
634    }
635
636    #[test]
637    fn test_patterns_that_should_not_be_flagged() {
638        let rule = MD018NoMissingSpaceAtx::new();
639
640        // Just hashes (horizontal rule or empty)
641        assert!(rule.check_atx_heading_line("###", MarkdownFlavor::Standard).is_none());
642        assert!(rule.check_atx_heading_line("#", MarkdownFlavor::Standard).is_none());
643
644        // Content too short
645        assert!(rule.check_atx_heading_line("##a", MarkdownFlavor::Standard).is_none());
646
647        // Emphasis markers
648        assert!(
649            rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
650                .is_none()
651        );
652
653        // More than 6 hashes
654        assert!(
655            rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
656                .is_none()
657        );
658
659        // Proper headings with space
660        assert!(
661            rule.check_atx_heading_line("# Hello", MarkdownFlavor::Standard)
662                .is_none()
663        );
664        assert!(
665            rule.check_atx_heading_line("## World", MarkdownFlavor::Standard)
666                .is_none()
667        );
668        assert!(
669            rule.check_atx_heading_line("### Section", MarkdownFlavor::Standard)
670                .is_none()
671        );
672    }
673
674    #[test]
675    fn test_inline_issue_refs_not_at_line_start() {
676        let rule = MD018NoMissingSpaceAtx::new();
677
678        // Inline patterns (not at line start) are not checked by check_atx_heading_line
679        // because that function only checks lines that START with #
680
681        // These should return None because they don't start with #
682        assert!(
683            rule.check_atx_heading_line("See issue #123", MarkdownFlavor::Standard)
684                .is_none()
685        );
686        assert!(
687            rule.check_atx_heading_line("Check #trending on Twitter", MarkdownFlavor::Standard)
688                .is_none()
689        );
690        assert!(
691            rule.check_atx_heading_line("- fix: issue #29039", MarkdownFlavor::Standard)
692                .is_none()
693        );
694    }
695
696    #[test]
697    fn test_lowercase_patterns_full_check() {
698        // Integration test: verify lowercase patterns are flagged through full check() flow
699        let rule = MD018NoMissingSpaceAtx::new();
700
701        let content = "#hello\n\n#world\n\n#tag";
702        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
703        let result = rule.check(&ctx).unwrap();
704
705        assert_eq!(result.len(), 3, "All three lowercase patterns should be flagged");
706        assert_eq!(result[0].line, 1);
707        assert_eq!(result[1].line, 3);
708        assert_eq!(result[2].line, 5);
709    }
710
711    #[test]
712    fn test_numeric_patterns_full_check() {
713        // Integration test: verify numeric patterns are flagged through full check() flow
714        let rule = MD018NoMissingSpaceAtx::new();
715
716        let content = "#123\n\n#456\n\n#29039";
717        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
718        let result = rule.check(&ctx).unwrap();
719
720        assert_eq!(result.len(), 3, "All three numeric patterns should be flagged");
721    }
722
723    #[test]
724    fn test_fix_lowercase_patterns() {
725        // Verify fix() correctly handles lowercase patterns
726        let rule = MD018NoMissingSpaceAtx::new();
727
728        let content = "#hello\nSome text.\n\n#world";
729        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
730        let fixed = rule.fix(&ctx).unwrap();
731
732        let expected = "# hello\nSome text.\n\n# world";
733        assert_eq!(fixed, expected);
734    }
735
736    #[test]
737    fn test_fix_numeric_patterns() {
738        // Verify fix() correctly handles numeric patterns
739        let rule = MD018NoMissingSpaceAtx::new();
740
741        let content = "#123\nContent.\n\n##456";
742        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
743        let fixed = rule.fix(&ctx).unwrap();
744
745        let expected = "# 123\nContent.\n\n## 456";
746        assert_eq!(fixed, expected);
747    }
748
749    #[test]
750    fn test_indented_malformed_headings() {
751        // Indented patterns are skipped to match markdownlint behavior.
752        // Markdownlint only flags patterns at column 1 (no indentation).
753        // Indented patterns are often multi-line link continuations or list content.
754        let rule = MD018NoMissingSpaceAtx::new();
755
756        // Indented patterns should NOT be flagged (matches markdownlint)
757        assert!(
758            rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
759                .is_none(),
760            "1-space indented #hello should be skipped"
761        );
762        assert!(
763            rule.check_atx_heading_line("  #hello", MarkdownFlavor::Standard)
764                .is_none(),
765            "2-space indented #hello should be skipped"
766        );
767        assert!(
768            rule.check_atx_heading_line("   #hello", MarkdownFlavor::Standard)
769                .is_none(),
770            "3-space indented #hello should be skipped"
771        );
772
773        // 4+ spaces is a code block, not checked by this function
774        // (code block detection happens at LintContext level)
775
776        // BUT patterns at column 1 (no indentation) ARE flagged
777        assert!(
778            rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
779                .is_some(),
780            "Non-indented #hello should be detected"
781        );
782    }
783
784    #[test]
785    fn test_tab_after_hash_is_valid() {
786        // Tab after hash is valid (acts like space)
787        let rule = MD018NoMissingSpaceAtx::new();
788
789        assert!(
790            rule.check_atx_heading_line("#\tHello", MarkdownFlavor::Standard)
791                .is_none(),
792            "Tab after # should be valid"
793        );
794        assert!(
795            rule.check_atx_heading_line("##\tWorld", MarkdownFlavor::Standard)
796                .is_none(),
797            "Tab after ## should be valid"
798        );
799    }
800
801    #[test]
802    fn test_mixed_case_patterns() {
803        let rule = MD018NoMissingSpaceAtx::new();
804
805        // All should be detected regardless of case
806        assert!(
807            rule.check_atx_heading_line("#hELLO", MarkdownFlavor::Standard)
808                .is_some()
809        );
810        assert!(
811            rule.check_atx_heading_line("#Hello", MarkdownFlavor::Standard)
812                .is_some()
813        );
814        assert!(
815            rule.check_atx_heading_line("#HELLO", MarkdownFlavor::Standard)
816                .is_some()
817        );
818        assert!(
819            rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
820                .is_some()
821        );
822    }
823
824    #[test]
825    fn test_unicode_lowercase() {
826        let rule = MD018NoMissingSpaceAtx::new();
827
828        // Unicode lowercase should be detected
829        assert!(
830            rule.check_atx_heading_line("#über", MarkdownFlavor::Standard).is_some(),
831            "Unicode lowercase #über should be detected"
832        );
833        assert!(
834            rule.check_atx_heading_line("#café", MarkdownFlavor::Standard).is_some(),
835            "Unicode lowercase #café should be detected"
836        );
837        assert!(
838            rule.check_atx_heading_line("#日本語", MarkdownFlavor::Standard)
839                .is_some(),
840            "Japanese #日本語 should be detected"
841        );
842    }
843
844    #[test]
845    fn test_matches_markdownlint_behavior() {
846        // Comprehensive test matching markdownlint's expected behavior
847        let rule = MD018NoMissingSpaceAtx::new();
848
849        let content = r#"#hello
850
851## world
852
853###fer
854
855#123
856
857#Tag
858"#;
859
860        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
861        let result = rule.check(&ctx).unwrap();
862
863        // markdownlint flags: #hello (line 1), ###fer (line 5), #123 (line 7), #Tag (line 9)
864        // ## world is correct (has space)
865        let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
866
867        assert!(flagged_lines.contains(&1), "#hello should be flagged");
868        assert!(!flagged_lines.contains(&3), "## world should NOT be flagged");
869        assert!(flagged_lines.contains(&5), "###fer should be flagged");
870        assert!(flagged_lines.contains(&7), "#123 should be flagged");
871        assert!(flagged_lines.contains(&9), "#Tag should be flagged");
872
873        assert_eq!(result.len(), 4, "Should have exactly 4 warnings");
874    }
875
876    #[test]
877    fn test_skip_frontmatter_yaml_comments() {
878        // YAML comments in frontmatter should NOT be flagged as missing space in headings
879        let rule = MD018NoMissingSpaceAtx::new();
880
881        let content = r#"---
882#reviewers:
883#- sig-api-machinery
884#another_comment: value
885title: Test Document
886---
887
888# Valid heading
889
890#invalid heading without space
891"#;
892
893        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
894        let result = rule.check(&ctx).unwrap();
895
896        // Should only flag line 10 (#invalid heading without space)
897        // Lines 2-4 are YAML comments in frontmatter and should be skipped
898        assert_eq!(
899            result.len(),
900            1,
901            "Should only flag the malformed heading outside frontmatter"
902        );
903        assert_eq!(result[0].line, 10, "Should flag line 10");
904    }
905
906    #[test]
907    fn test_skip_html_comments() {
908        // Content inside HTML comments should NOT be flagged
909        // This includes Jupyter cell markers like #%% in commented-out code blocks
910        let rule = MD018NoMissingSpaceAtx::new();
911
912        let content = r#"# Real Heading
913
914Some text.
915
916<!--
917```
918#%% Cell marker
919import matplotlib.pyplot as plt
920
921#%% Another cell
922data = [1, 2, 3]
923```
924-->
925
926More content.
927"#;
928
929        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
930        let result = rule.check(&ctx).unwrap();
931
932        // Should find no issues - the #%% markers are inside HTML comments
933        assert!(
934            result.is_empty(),
935            "Should not flag content inside HTML comments, found {} issues",
936            result.len()
937        );
938    }
939
940    #[test]
941    fn test_mkdocs_magiclink_skips_numeric_refs() {
942        // MkDocs flavor should skip MagicLink-style issue/PR refs (#123, #10, etc.)
943        let rule = MD018NoMissingSpaceAtx::new();
944
945        // These numeric patterns should be SKIPPED in MkDocs flavor
946        assert!(
947            rule.check_atx_heading_line("#10", MarkdownFlavor::MkDocs).is_none(),
948            "#10 should be skipped in MkDocs flavor (MagicLink issue ref)"
949        );
950        assert!(
951            rule.check_atx_heading_line("#123", MarkdownFlavor::MkDocs).is_none(),
952            "#123 should be skipped in MkDocs flavor (MagicLink issue ref)"
953        );
954        assert!(
955            rule.check_atx_heading_line("#10 discusses the issue", MarkdownFlavor::MkDocs)
956                .is_none(),
957            "#10 followed by text should be skipped in MkDocs flavor"
958        );
959        assert!(
960            rule.check_atx_heading_line("#37.", MarkdownFlavor::MkDocs).is_none(),
961            "#37 followed by punctuation should be skipped in MkDocs flavor"
962        );
963    }
964
965    #[test]
966    fn test_mkdocs_magiclink_still_flags_non_numeric() {
967        // MkDocs flavor should still flag non-numeric patterns
968        let rule = MD018NoMissingSpaceAtx::new();
969
970        // Non-numeric patterns should still be flagged even in MkDocs flavor
971        assert!(
972            rule.check_atx_heading_line("#Summary", MarkdownFlavor::MkDocs)
973                .is_some(),
974            "#Summary should still be flagged in MkDocs flavor"
975        );
976        assert!(
977            rule.check_atx_heading_line("#hello", MarkdownFlavor::MkDocs).is_some(),
978            "#hello should still be flagged in MkDocs flavor"
979        );
980        assert!(
981            rule.check_atx_heading_line("#10abc", MarkdownFlavor::MkDocs).is_some(),
982            "#10abc (mixed) should still be flagged in MkDocs flavor"
983        );
984    }
985
986    #[test]
987    fn test_mkdocs_magiclink_only_single_hash() {
988        // MagicLink only uses single #, so ##10 should still be flagged
989        let rule = MD018NoMissingSpaceAtx::new();
990
991        assert!(
992            rule.check_atx_heading_line("##10", MarkdownFlavor::MkDocs).is_some(),
993            "##10 should be flagged in MkDocs flavor (only single # is MagicLink)"
994        );
995        assert!(
996            rule.check_atx_heading_line("###123", MarkdownFlavor::MkDocs).is_some(),
997            "###123 should be flagged in MkDocs flavor"
998        );
999    }
1000
1001    #[test]
1002    fn test_standard_flavor_flags_numeric_refs() {
1003        // Standard flavor should still flag numeric patterns (no MagicLink awareness)
1004        let rule = MD018NoMissingSpaceAtx::new();
1005
1006        assert!(
1007            rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_some(),
1008            "#10 should be flagged in Standard flavor"
1009        );
1010        assert!(
1011            rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
1012            "#123 should be flagged in Standard flavor"
1013        );
1014    }
1015
1016    #[test]
1017    fn test_mkdocs_magiclink_full_check() {
1018        // Integration test: verify MkDocs flavor skips MagicLink refs through full check() flow
1019        let rule = MD018NoMissingSpaceAtx::new();
1020
1021        let content = r#"# PRs that are helpful for context
1022
1023#10 discusses the philosophy behind the project, and #37 shows a good example.
1024
1025#Summary
1026
1027##Introduction
1028"#;
1029
1030        // MkDocs flavor - should skip #10 and #37, but flag #Summary and ##Introduction
1031        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1032        let result = rule.check(&ctx).unwrap();
1033
1034        let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1035        assert!(
1036            !flagged_lines.contains(&3),
1037            "#10 should NOT be flagged in MkDocs flavor"
1038        );
1039        assert!(
1040            flagged_lines.contains(&5),
1041            "#Summary SHOULD be flagged in MkDocs flavor"
1042        );
1043        assert!(
1044            flagged_lines.contains(&7),
1045            "##Introduction SHOULD be flagged in MkDocs flavor"
1046        );
1047    }
1048
1049    #[test]
1050    fn test_mkdocs_magiclink_fix_exact_output() {
1051        // Verify fix() produces exact expected output
1052        let rule = MD018NoMissingSpaceAtx::new();
1053
1054        let content = "#10 discusses the issue.\n\n#Summary";
1055        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1056        let fixed = rule.fix(&ctx).unwrap();
1057
1058        // Exact expected output: #10 preserved, #Summary fixed
1059        let expected = "#10 discusses the issue.\n\n# Summary";
1060        assert_eq!(
1061            fixed, expected,
1062            "MkDocs fix should preserve MagicLink refs and fix non-numeric headings"
1063        );
1064    }
1065
1066    #[test]
1067    fn test_mkdocs_magiclink_edge_cases() {
1068        // Test various edge cases for MagicLink pattern matching
1069        let rule = MD018NoMissingSpaceAtx::new();
1070
1071        // These should all be SKIPPED in MkDocs flavor (valid MagicLink refs)
1072        // Note: #1 alone is skipped due to content length < 2, not MagicLink
1073        let valid_refs = [
1074            "#10",             // Two digits
1075            "#999999",         // Large number
1076            "#10 text after",  // Space then text
1077            "#10\ttext after", // Tab then text
1078            "#10.",            // Period after
1079            "#10,",            // Comma after
1080            "#10!",            // Exclamation after
1081            "#10?",            // Question mark after
1082            "#10)",            // Close paren after
1083            "#10]",            // Close bracket after
1084            "#10;",            // Semicolon after
1085            "#10:",            // Colon after
1086        ];
1087
1088        for ref_str in valid_refs {
1089            assert!(
1090                rule.check_atx_heading_line(ref_str, MarkdownFlavor::MkDocs).is_none(),
1091                "{ref_str:?} should be skipped as MagicLink ref in MkDocs flavor"
1092            );
1093        }
1094
1095        // These should still be FLAGGED in MkDocs flavor (not valid MagicLink refs)
1096        let invalid_refs = [
1097            "#10abc",   // Alphanumeric continuation
1098            "#10a",     // Single alpha continuation
1099            "#abc10",   // Alpha prefix
1100            "#10ABC",   // Uppercase continuation
1101            "#Summary", // Pure text
1102            "#hello",   // Lowercase text
1103        ];
1104
1105        for ref_str in invalid_refs {
1106            assert!(
1107                rule.check_atx_heading_line(ref_str, MarkdownFlavor::MkDocs).is_some(),
1108                "{ref_str:?} should be flagged in MkDocs flavor (not a valid MagicLink ref)"
1109            );
1110        }
1111    }
1112
1113    #[test]
1114    fn test_mkdocs_magiclink_hyphenated_continuation() {
1115        // Hyphenated patterns like #10-related should still be flagged
1116        // because they're likely malformed headings, not MagicLink refs
1117        let rule = MD018NoMissingSpaceAtx::new();
1118
1119        // Hyphen is not alphanumeric, so #10- would match as MagicLink
1120        // But #10-related has alphanumeric after the hyphen
1121        // The regex ^#\d+(?:\s|[^a-zA-Z0-9]|$) would match #10- but not consume -related
1122        // So #10-related would match (the -r part is after the match)
1123        assert!(
1124            rule.check_atx_heading_line("#10-", MarkdownFlavor::MkDocs).is_none(),
1125            "#10- should be skipped (hyphen is non-alphanumeric terminator)"
1126        );
1127    }
1128
1129    #[test]
1130    fn test_mkdocs_magiclink_standalone_number() {
1131        // #10 alone on a line (common in changelogs)
1132        let rule = MD018NoMissingSpaceAtx::new();
1133
1134        let content = "See issue:\n\n#10\n\nFor details.";
1135        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1136        let result = rule.check(&ctx).unwrap();
1137
1138        // #10 alone should not be flagged in MkDocs flavor
1139        assert!(
1140            result.is_empty(),
1141            "Standalone #10 should not be flagged in MkDocs flavor"
1142        );
1143
1144        // Verify fix doesn't modify it
1145        let fixed = rule.fix(&ctx).unwrap();
1146        assert_eq!(fixed, content, "fix() should not modify standalone MagicLink ref");
1147    }
1148
1149    #[test]
1150    fn test_standard_flavor_flags_all_numeric() {
1151        // Standard flavor should flag ALL numeric patterns (no MagicLink awareness)
1152        // Note: #1 is skipped because content length < 2 (existing behavior)
1153        let rule = MD018NoMissingSpaceAtx::new();
1154
1155        let numeric_patterns = ["#10", "#123", "#999999", "#10 text"];
1156
1157        for pattern in numeric_patterns {
1158            assert!(
1159                rule.check_atx_heading_line(pattern, MarkdownFlavor::Standard).is_some(),
1160                "{pattern:?} should be flagged in Standard flavor"
1161            );
1162        }
1163
1164        // #1 is skipped due to content length < 2 rule (not MagicLink related)
1165        assert!(
1166            rule.check_atx_heading_line("#1", MarkdownFlavor::Standard).is_none(),
1167            "#1 should be skipped (content too short, existing behavior)"
1168        );
1169    }
1170
1171    #[test]
1172    fn test_mkdocs_vs_standard_fix_comparison() {
1173        // Compare fix output between MkDocs and Standard flavors
1174        let rule = MD018NoMissingSpaceAtx::new();
1175
1176        let content = "#10 is an issue\n#Summary";
1177
1178        // MkDocs: preserves #10, fixes #Summary
1179        let ctx_mkdocs = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1180        let fixed_mkdocs = rule.fix(&ctx_mkdocs).unwrap();
1181        assert_eq!(fixed_mkdocs, "#10 is an issue\n# Summary");
1182
1183        // Standard: fixes both
1184        let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1185        let fixed_standard = rule.fix(&ctx_standard).unwrap();
1186        assert_eq!(fixed_standard, "# 10 is an issue\n# Summary");
1187    }
1188}