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::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::range_utils::calculate_single_line_range;
6use crate::utils::regex_cache::get_cached_regex;
7
8// Emoji and Unicode hashtag patterns
9const EMOJI_HASHTAG_PATTERN_STR: &str = r"^#️⃣|^#⃣";
10const UNICODE_HASHTAG_PATTERN_STR: &str = r"^#[\u{FE0F}\u{20E3}]";
11
12#[derive(Clone)]
13pub struct MD018NoMissingSpaceAtx;
14
15impl Default for MD018NoMissingSpaceAtx {
16    fn default() -> Self {
17        Self::new()
18    }
19}
20
21impl MD018NoMissingSpaceAtx {
22    pub fn new() -> Self {
23        Self
24    }
25
26    /// Check if an ATX heading line is missing space after the marker
27    fn check_atx_heading_line(&self, line: &str) -> Option<(usize, String)> {
28        // Look for ATX marker at start of line (with optional indentation)
29        let trimmed_line = line.trim_start();
30        let indent = line.len() - trimmed_line.len();
31
32        if !trimmed_line.starts_with('#') {
33            return None;
34        }
35
36        // Only flag patterns at column 1 (no indentation) to match markdownlint behavior
37        // Indented patterns are likely:
38        // - Multi-line link continuations (e.g., "  #sig-contribex](url)")
39        // - List item content
40        // - Other continuation contexts
41        if indent > 0 {
42            return None;
43        }
44
45        // Skip emoji hashtags and Unicode hashtag patterns
46        let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
47            .map(|re| re.is_match(trimmed_line))
48            .unwrap_or(false);
49        let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
50            .map(|re| re.is_match(trimmed_line))
51            .unwrap_or(false);
52        if is_emoji || is_unicode {
53            return None;
54        }
55
56        // Count the number of hashes
57        let hash_count = trimmed_line.chars().take_while(|&c| c == '#').count();
58        if hash_count == 0 || hash_count > 6 {
59            return None;
60        }
61
62        // Check what comes after the hashes
63        let after_hashes = &trimmed_line[hash_count..];
64
65        // Skip if what follows the hashes is an emoji modifier or variant selector
66        if after_hashes
67            .chars()
68            .next()
69            .is_some_and(|ch| matches!(ch, '\u{FE0F}' | '\u{20E3}' | '\u{FE0E}'))
70        {
71            return None;
72        }
73
74        // If there's content immediately after hashes (no space), it needs fixing
75        if !after_hashes.is_empty() && !after_hashes.starts_with(' ') && !after_hashes.starts_with('\t') {
76            // Additional checks to avoid false positives
77            let content = after_hashes.trim();
78
79            // Skip if it's just more hashes (horizontal rule)
80            if content.chars().all(|c| c == '#') {
81                return None;
82            }
83
84            // Skip if content is too short to be meaningful
85            if content.len() < 2 {
86                return None;
87            }
88
89            // Skip if it starts with emphasis markers
90            if content.starts_with('*') || content.starts_with('_') {
91                return None;
92            }
93
94            // This looks like a malformed heading that needs a space
95            let fixed = format!("{}{} {}", " ".repeat(indent), "#".repeat(hash_count), after_hashes);
96            return Some((indent + hash_count, fixed));
97        }
98
99        None
100    }
101
102    // Calculate the byte range for a specific line in the content
103    fn get_line_byte_range(&self, content: &str, line_num: usize) -> std::ops::Range<usize> {
104        let mut current_line = 1;
105        let mut start_byte = 0;
106
107        for (i, c) in content.char_indices() {
108            if current_line == line_num && c == '\n' {
109                return start_byte..i;
110            } else if c == '\n' {
111                current_line += 1;
112                if current_line == line_num {
113                    start_byte = i + 1;
114                }
115            }
116        }
117
118        // If we're looking for the last line and it doesn't end with a newline
119        if current_line == line_num {
120            return start_byte..content.len();
121        }
122
123        // Fallback if line not found (shouldn't happen)
124        0..0
125    }
126}
127
128impl Rule for MD018NoMissingSpaceAtx {
129    fn name(&self) -> &'static str {
130        "MD018"
131    }
132
133    fn description(&self) -> &'static str {
134        "No space after hash in heading"
135    }
136
137    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
138        let mut warnings = Vec::new();
139
140        // Check all lines that have ATX headings from cached info
141        for (line_num, line_info) in ctx.lines.iter().enumerate() {
142            // Skip lines inside HTML blocks or HTML comments (e.g., CSS selectors like #id)
143            if line_info.in_html_block || line_info.in_html_comment {
144                continue;
145            }
146
147            if let Some(heading) = &line_info.heading {
148                // Only check ATX headings
149                if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
150                    // Skip indented headings to match markdownlint behavior
151                    // Markdownlint only flags patterns at column 1
152                    if line_info.indent > 0 {
153                        continue;
154                    }
155
156                    // Check if there's a space after the marker
157                    let line = line_info.content(ctx.content);
158                    let trimmed = line.trim_start();
159
160                    // Skip emoji hashtags and Unicode hashtag patterns
161                    let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
162                        .map(|re| re.is_match(trimmed))
163                        .unwrap_or(false);
164                    let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
165                        .map(|re| re.is_match(trimmed))
166                        .unwrap_or(false);
167                    if is_emoji || is_unicode {
168                        continue;
169                    }
170
171                    if trimmed.len() > heading.marker.len() {
172                        let after_marker = &trimmed[heading.marker.len()..];
173                        if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
174                        {
175                            // Missing space after ATX marker
176                            let hash_end_col = line_info.indent + heading.marker.len() + 1; // 1-indexed
177                            let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
178                                line_num + 1, // Convert to 1-indexed
179                                hash_end_col,
180                                0, // Zero-width to indicate missing space
181                            );
182
183                            warnings.push(LintWarning {
184                                rule_name: Some(self.name().to_string()),
185                                message: format!("No space after {} in heading", "#".repeat(heading.level as usize)),
186                                line: start_line,
187                                column: start_col,
188                                end_line,
189                                end_column: end_col,
190                                severity: Severity::Warning,
191                                fix: Some(Fix {
192                                    range: self.get_line_byte_range(ctx.content, line_num + 1),
193                                    replacement: {
194                                        // Preserve original indentation (including tabs)
195                                        let line = line_info.content(ctx.content);
196                                        let original_indent = &line[..line_info.indent];
197                                        format!("{original_indent}{} {after_marker}", heading.marker)
198                                    },
199                                }),
200                            });
201                        }
202                    }
203                }
204            } else if !line_info.in_code_block
205                && !line_info.in_front_matter
206                && !line_info.in_html_comment
207                && !line_info.is_blank
208            {
209                // Check for malformed headings that weren't detected as proper headings
210                if let Some((hash_end_pos, fixed_line)) = self.check_atx_heading_line(line_info.content(ctx.content)) {
211                    let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
212                        line_num + 1,     // Convert to 1-indexed
213                        hash_end_pos + 1, // 1-indexed column
214                        0,                // Zero-width to indicate missing space
215                    );
216
217                    warnings.push(LintWarning {
218                        rule_name: Some(self.name().to_string()),
219                        message: "No space after hash in heading".to_string(),
220                        line: start_line,
221                        column: start_col,
222                        end_line,
223                        end_column: end_col,
224                        severity: Severity::Warning,
225                        fix: Some(Fix {
226                            range: self.get_line_byte_range(ctx.content, line_num + 1),
227                            replacement: fixed_line,
228                        }),
229                    });
230                }
231            }
232        }
233
234        Ok(warnings)
235    }
236
237    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
238        let mut lines = Vec::new();
239
240        for line_info in ctx.lines.iter() {
241            let mut fixed = false;
242
243            if let Some(heading) = &line_info.heading {
244                // Fix ATX headings missing space
245                if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
246                    let line = line_info.content(ctx.content);
247                    let trimmed = line.trim_start();
248
249                    // Skip emoji hashtags and Unicode hashtag patterns
250                    let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
251                        .map(|re| re.is_match(trimmed))
252                        .unwrap_or(false);
253                    let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
254                        .map(|re| re.is_match(trimmed))
255                        .unwrap_or(false);
256                    if is_emoji || is_unicode {
257                        continue;
258                    }
259
260                    if trimmed.len() > heading.marker.len() {
261                        let after_marker = &trimmed[heading.marker.len()..];
262                        if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
263                        {
264                            // Add space after marker, preserving original indentation (including tabs)
265                            let line = line_info.content(ctx.content);
266                            let original_indent = &line[..line_info.indent];
267                            lines.push(format!("{original_indent}{} {after_marker}", heading.marker));
268                            fixed = true;
269                        }
270                    }
271                }
272            } else if !line_info.in_code_block
273                && !line_info.in_front_matter
274                && !line_info.in_html_comment
275                && !line_info.is_blank
276            {
277                // Fix malformed headings
278                if let Some((_, fixed_line)) = self.check_atx_heading_line(line_info.content(ctx.content)) {
279                    lines.push(fixed_line);
280                    fixed = true;
281                }
282            }
283
284            if !fixed {
285                lines.push(line_info.content(ctx.content).to_string());
286            }
287        }
288
289        // Reconstruct content preserving line endings
290        let mut result = lines.join("\n");
291        if ctx.content.ends_with('\n') && !result.ends_with('\n') {
292            result.push('\n');
293        }
294
295        Ok(result)
296    }
297
298    /// Get the category of this rule for selective processing
299    fn category(&self) -> RuleCategory {
300        RuleCategory::Heading
301    }
302
303    /// Check if this rule should be skipped
304    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
305        // Fast path: check if document likely has headings
306        !ctx.likely_has_headings()
307    }
308
309    fn as_any(&self) -> &dyn std::any::Any {
310        self
311    }
312
313    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
314    where
315        Self: Sized,
316    {
317        Box::new(MD018NoMissingSpaceAtx::new())
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::lint_context::LintContext;
325
326    #[test]
327    fn test_basic_functionality() {
328        let rule = MD018NoMissingSpaceAtx;
329
330        // Test with correct space
331        let content = "# Heading 1\n## Heading 2\n### Heading 3";
332        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
333        let result = rule.check(&ctx).unwrap();
334        assert!(result.is_empty());
335
336        // Test with missing space
337        let content = "#Heading 1\n## Heading 2\n###Heading 3";
338        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
339        let result = rule.check(&ctx).unwrap();
340        assert_eq!(result.len(), 2); // Should flag the two headings with missing spaces
341        assert_eq!(result[0].line, 1);
342        assert_eq!(result[1].line, 3);
343    }
344
345    #[test]
346    fn test_malformed_heading_detection() {
347        let rule = MD018NoMissingSpaceAtx::new();
348
349        // Test the check_atx_heading_line method
350        assert!(rule.check_atx_heading_line("##Introduction").is_some());
351        assert!(rule.check_atx_heading_line("###Background").is_some());
352        assert!(rule.check_atx_heading_line("####Details").is_some());
353        assert!(rule.check_atx_heading_line("#Summary").is_some());
354        assert!(rule.check_atx_heading_line("######Conclusion").is_some());
355        assert!(rule.check_atx_heading_line("##Table of Contents").is_some());
356
357        // Should NOT detect these
358        assert!(rule.check_atx_heading_line("###").is_none()); // Just hashes
359        assert!(rule.check_atx_heading_line("#").is_none()); // Single hash
360        assert!(rule.check_atx_heading_line("##a").is_none()); // Too short
361        assert!(rule.check_atx_heading_line("#*emphasis").is_none()); // Emphasis marker
362        assert!(rule.check_atx_heading_line("#######TooBig").is_none()); // More than 6 hashes
363    }
364
365    #[test]
366    fn test_malformed_heading_with_context() {
367        let rule = MD018NoMissingSpaceAtx::new();
368
369        // Test with full content that includes code blocks
370        let content = r#"# Test Document
371
372##Introduction
373This should be detected.
374
375    ##CodeBlock
376This should NOT be detected (indented code block).
377
378```
379##FencedCodeBlock
380This should NOT be detected (fenced code block).
381```
382
383##Conclusion
384This should be detected.
385"#;
386
387        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
388        let result = rule.check(&ctx).unwrap();
389
390        // Should detect malformed headings but ignore code blocks
391        let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
392        assert!(detected_lines.contains(&3)); // ##Introduction
393        assert!(detected_lines.contains(&14)); // ##Conclusion (updated line number)
394        assert!(!detected_lines.contains(&6)); // ##CodeBlock (should be ignored)
395        assert!(!detected_lines.contains(&10)); // ##FencedCodeBlock (should be ignored)
396    }
397
398    #[test]
399    fn test_malformed_heading_fix() {
400        let rule = MD018NoMissingSpaceAtx::new();
401
402        let content = r#"##Introduction
403This is a test.
404
405###Background
406More content."#;
407
408        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
409        let fixed = rule.fix(&ctx).unwrap();
410
411        let expected = r#"## Introduction
412This is a test.
413
414### Background
415More content."#;
416
417        assert_eq!(fixed, expected);
418    }
419
420    #[test]
421    fn test_mixed_proper_and_malformed_headings() {
422        let rule = MD018NoMissingSpaceAtx::new();
423
424        let content = r#"# Proper Heading
425
426##Malformed Heading
427
428## Another Proper Heading
429
430###Another Malformed
431
432#### Proper with space
433"#;
434
435        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
436        let result = rule.check(&ctx).unwrap();
437
438        // Should only detect the malformed ones
439        assert_eq!(result.len(), 2);
440        let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
441        assert!(detected_lines.contains(&3)); // ##Malformed Heading
442        assert!(detected_lines.contains(&7)); // ###Another Malformed
443    }
444
445    #[test]
446    fn test_css_selectors_in_html_blocks() {
447        let rule = MD018NoMissingSpaceAtx::new();
448
449        // Test CSS selectors inside <style> tags should not trigger MD018
450        // This is a common pattern in Quarto/RMarkdown files
451        let content = r#"# Proper Heading
452
453<style>
454#slide-1 ol li {
455    margin-top: 0;
456}
457
458#special-slide ol li {
459    margin-top: 2em;
460}
461</style>
462
463## Another Heading
464"#;
465
466        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
467        let result = rule.check(&ctx).unwrap();
468
469        // Should not detect CSS selectors as malformed headings
470        assert_eq!(
471            result.len(),
472            0,
473            "CSS selectors in <style> blocks should not be flagged as malformed headings"
474        );
475    }
476
477    #[test]
478    fn test_js_code_in_script_blocks() {
479        let rule = MD018NoMissingSpaceAtx::new();
480
481        // Test that patterns like #element in <script> tags don't trigger MD018
482        let content = r#"# Heading
483
484<script>
485const element = document.querySelector('#main-content');
486#another-comment
487</script>
488
489## Another Heading
490"#;
491
492        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
493        let result = rule.check(&ctx).unwrap();
494
495        // Should not detect JS code as malformed headings
496        assert_eq!(
497            result.len(),
498            0,
499            "JavaScript code in <script> blocks should not be flagged as malformed headings"
500        );
501    }
502
503    #[test]
504    fn test_all_malformed_headings_detected() {
505        let rule = MD018NoMissingSpaceAtx::new();
506
507        // All patterns at line start should be detected as malformed headings
508        // (matching markdownlint behavior)
509
510        // Lowercase single-hash - should be detected
511        assert!(
512            rule.check_atx_heading_line("#hello").is_some(),
513            "#hello SHOULD be detected as malformed heading"
514        );
515        assert!(
516            rule.check_atx_heading_line("#tag").is_some(),
517            "#tag SHOULD be detected as malformed heading"
518        );
519        assert!(
520            rule.check_atx_heading_line("#hashtag").is_some(),
521            "#hashtag SHOULD be detected as malformed heading"
522        );
523        assert!(
524            rule.check_atx_heading_line("#javascript").is_some(),
525            "#javascript SHOULD be detected as malformed heading"
526        );
527
528        // Numeric patterns - should be detected (could be headings like "# 123")
529        assert!(
530            rule.check_atx_heading_line("#123").is_some(),
531            "#123 SHOULD be detected as malformed heading"
532        );
533        assert!(
534            rule.check_atx_heading_line("#12345").is_some(),
535            "#12345 SHOULD be detected as malformed heading"
536        );
537        assert!(
538            rule.check_atx_heading_line("#29039)").is_some(),
539            "#29039) SHOULD be detected as malformed heading"
540        );
541
542        // Uppercase single-hash - should be detected
543        assert!(
544            rule.check_atx_heading_line("#Summary").is_some(),
545            "#Summary SHOULD be detected as malformed heading"
546        );
547        assert!(
548            rule.check_atx_heading_line("#Introduction").is_some(),
549            "#Introduction SHOULD be detected as malformed heading"
550        );
551        assert!(
552            rule.check_atx_heading_line("#API").is_some(),
553            "#API SHOULD be detected as malformed heading"
554        );
555
556        // Multi-hash patterns - should be detected
557        assert!(
558            rule.check_atx_heading_line("##introduction").is_some(),
559            "##introduction SHOULD be detected as malformed heading"
560        );
561        assert!(
562            rule.check_atx_heading_line("###section").is_some(),
563            "###section SHOULD be detected as malformed heading"
564        );
565        assert!(
566            rule.check_atx_heading_line("###fer").is_some(),
567            "###fer SHOULD be detected as malformed heading"
568        );
569        assert!(
570            rule.check_atx_heading_line("##123").is_some(),
571            "##123 SHOULD be detected as malformed heading"
572        );
573    }
574
575    #[test]
576    fn test_patterns_that_should_not_be_flagged() {
577        let rule = MD018NoMissingSpaceAtx::new();
578
579        // Just hashes (horizontal rule or empty)
580        assert!(rule.check_atx_heading_line("###").is_none());
581        assert!(rule.check_atx_heading_line("#").is_none());
582
583        // Content too short
584        assert!(rule.check_atx_heading_line("##a").is_none());
585
586        // Emphasis markers
587        assert!(rule.check_atx_heading_line("#*emphasis").is_none());
588
589        // More than 6 hashes
590        assert!(rule.check_atx_heading_line("#######TooBig").is_none());
591
592        // Proper headings with space
593        assert!(rule.check_atx_heading_line("# Hello").is_none());
594        assert!(rule.check_atx_heading_line("## World").is_none());
595        assert!(rule.check_atx_heading_line("### Section").is_none());
596    }
597
598    #[test]
599    fn test_inline_issue_refs_not_at_line_start() {
600        let rule = MD018NoMissingSpaceAtx::new();
601
602        // Inline patterns (not at line start) are not checked by check_atx_heading_line
603        // because that function only checks lines that START with #
604
605        // These should return None because they don't start with #
606        assert!(rule.check_atx_heading_line("See issue #123").is_none());
607        assert!(rule.check_atx_heading_line("Check #trending on Twitter").is_none());
608        assert!(rule.check_atx_heading_line("- fix: issue #29039").is_none());
609    }
610
611    #[test]
612    fn test_lowercase_patterns_full_check() {
613        // Integration test: verify lowercase patterns are flagged through full check() flow
614        let rule = MD018NoMissingSpaceAtx::new();
615
616        let content = "#hello\n\n#world\n\n#tag";
617        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
618        let result = rule.check(&ctx).unwrap();
619
620        assert_eq!(result.len(), 3, "All three lowercase patterns should be flagged");
621        assert_eq!(result[0].line, 1);
622        assert_eq!(result[1].line, 3);
623        assert_eq!(result[2].line, 5);
624    }
625
626    #[test]
627    fn test_numeric_patterns_full_check() {
628        // Integration test: verify numeric patterns are flagged through full check() flow
629        let rule = MD018NoMissingSpaceAtx::new();
630
631        let content = "#123\n\n#456\n\n#29039";
632        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
633        let result = rule.check(&ctx).unwrap();
634
635        assert_eq!(result.len(), 3, "All three numeric patterns should be flagged");
636    }
637
638    #[test]
639    fn test_fix_lowercase_patterns() {
640        // Verify fix() correctly handles lowercase patterns
641        let rule = MD018NoMissingSpaceAtx::new();
642
643        let content = "#hello\nSome text.\n\n#world";
644        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
645        let fixed = rule.fix(&ctx).unwrap();
646
647        let expected = "# hello\nSome text.\n\n# world";
648        assert_eq!(fixed, expected);
649    }
650
651    #[test]
652    fn test_fix_numeric_patterns() {
653        // Verify fix() correctly handles numeric patterns
654        let rule = MD018NoMissingSpaceAtx::new();
655
656        let content = "#123\nContent.\n\n##456";
657        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
658        let fixed = rule.fix(&ctx).unwrap();
659
660        let expected = "# 123\nContent.\n\n## 456";
661        assert_eq!(fixed, expected);
662    }
663
664    #[test]
665    fn test_indented_malformed_headings() {
666        // Indented patterns are skipped to match markdownlint behavior.
667        // Markdownlint only flags patterns at column 1 (no indentation).
668        // Indented patterns are often multi-line link continuations or list content.
669        let rule = MD018NoMissingSpaceAtx::new();
670
671        // Indented patterns should NOT be flagged (matches markdownlint)
672        assert!(
673            rule.check_atx_heading_line(" #hello").is_none(),
674            "1-space indented #hello should be skipped"
675        );
676        assert!(
677            rule.check_atx_heading_line("  #hello").is_none(),
678            "2-space indented #hello should be skipped"
679        );
680        assert!(
681            rule.check_atx_heading_line("   #hello").is_none(),
682            "3-space indented #hello should be skipped"
683        );
684
685        // 4+ spaces is a code block, not checked by this function
686        // (code block detection happens at LintContext level)
687
688        // BUT patterns at column 1 (no indentation) ARE flagged
689        assert!(
690            rule.check_atx_heading_line("#hello").is_some(),
691            "Non-indented #hello should be detected"
692        );
693    }
694
695    #[test]
696    fn test_tab_after_hash_is_valid() {
697        // Tab after hash is valid (acts like space)
698        let rule = MD018NoMissingSpaceAtx::new();
699
700        assert!(
701            rule.check_atx_heading_line("#\tHello").is_none(),
702            "Tab after # should be valid"
703        );
704        assert!(
705            rule.check_atx_heading_line("##\tWorld").is_none(),
706            "Tab after ## should be valid"
707        );
708    }
709
710    #[test]
711    fn test_mixed_case_patterns() {
712        let rule = MD018NoMissingSpaceAtx::new();
713
714        // All should be detected regardless of case
715        assert!(rule.check_atx_heading_line("#hELLO").is_some());
716        assert!(rule.check_atx_heading_line("#Hello").is_some());
717        assert!(rule.check_atx_heading_line("#HELLO").is_some());
718        assert!(rule.check_atx_heading_line("#hello").is_some());
719    }
720
721    #[test]
722    fn test_unicode_lowercase() {
723        let rule = MD018NoMissingSpaceAtx::new();
724
725        // Unicode lowercase should be detected
726        assert!(
727            rule.check_atx_heading_line("#über").is_some(),
728            "Unicode lowercase #über should be detected"
729        );
730        assert!(
731            rule.check_atx_heading_line("#café").is_some(),
732            "Unicode lowercase #café should be detected"
733        );
734        assert!(
735            rule.check_atx_heading_line("#日本語").is_some(),
736            "Japanese #日本語 should be detected"
737        );
738    }
739
740    #[test]
741    fn test_matches_markdownlint_behavior() {
742        // Comprehensive test matching markdownlint's expected behavior
743        let rule = MD018NoMissingSpaceAtx::new();
744
745        let content = r#"#hello
746
747## world
748
749###fer
750
751#123
752
753#Tag
754"#;
755
756        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
757        let result = rule.check(&ctx).unwrap();
758
759        // markdownlint flags: #hello (line 1), ###fer (line 5), #123 (line 7), #Tag (line 9)
760        // ## world is correct (has space)
761        let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
762
763        assert!(flagged_lines.contains(&1), "#hello should be flagged");
764        assert!(!flagged_lines.contains(&3), "## world should NOT be flagged");
765        assert!(flagged_lines.contains(&5), "###fer should be flagged");
766        assert!(flagged_lines.contains(&7), "#123 should be flagged");
767        assert!(flagged_lines.contains(&9), "#Tag should be flagged");
768
769        assert_eq!(result.len(), 4, "Should have exactly 4 warnings");
770    }
771
772    #[test]
773    fn test_skip_frontmatter_yaml_comments() {
774        // YAML comments in frontmatter should NOT be flagged as missing space in headings
775        let rule = MD018NoMissingSpaceAtx::new();
776
777        let content = r#"---
778#reviewers:
779#- sig-api-machinery
780#another_comment: value
781title: Test Document
782---
783
784# Valid heading
785
786#invalid heading without space
787"#;
788
789        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790        let result = rule.check(&ctx).unwrap();
791
792        // Should only flag line 10 (#invalid heading without space)
793        // Lines 2-4 are YAML comments in frontmatter and should be skipped
794        assert_eq!(
795            result.len(),
796            1,
797            "Should only flag the malformed heading outside frontmatter"
798        );
799        assert_eq!(result[0].line, 10, "Should flag line 10");
800    }
801
802    #[test]
803    fn test_skip_html_comments() {
804        // Content inside HTML comments should NOT be flagged
805        // This includes Jupyter cell markers like #%% in commented-out code blocks
806        let rule = MD018NoMissingSpaceAtx::new();
807
808        let content = r#"# Real Heading
809
810Some text.
811
812<!--
813```
814#%% Cell marker
815import matplotlib.pyplot as plt
816
817#%% Another cell
818data = [1, 2, 3]
819```
820-->
821
822More content.
823"#;
824
825        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
826        let result = rule.check(&ctx).unwrap();
827
828        // Should find no issues - the #%% markers are inside HTML comments
829        assert!(
830            result.is_empty(),
831            "Should not flag content inside HTML comments, found {} issues",
832            result.len()
833        );
834    }
835}