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.
4mod md018_config;
5
6pub(super) use md018_config::MD018Config;
7
8use crate::config::MarkdownFlavor;
9use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
10use crate::utils::obsidian_tag::TAG_PATTERN;
11use crate::utils::range_utils::{byte_to_char_count, calculate_single_line_range};
12use regex::Regex;
13use std::sync::LazyLock;
14
15// Emoji and Unicode hashtag patterns
16const EMOJI_HASHTAG_PATTERN_STR: &str = r"^#️⃣|^#⃣";
17const UNICODE_HASHTAG_PATTERN_STR: &str = r"^#[\u{FE0F}\u{20E3}]";
18static EMOJI_HASHTAG_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(EMOJI_HASHTAG_PATTERN_STR).unwrap());
19static UNICODE_HASHTAG_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(UNICODE_HASHTAG_PATTERN_STR).unwrap());
20
21// MagicLink issue/PR reference pattern: #123, #10, etc.
22// Matches # followed by one or more digits, then either end of string,
23// whitespace, or punctuation (not alphanumeric continuation)
24const MAGICLINK_REF_PATTERN_STR: &str = r"^#\d+(?:\s|[^a-zA-Z0-9]|$)";
25static MAGICLINK_REF_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(MAGICLINK_REF_PATTERN_STR).unwrap());
26
27#[derive(Clone)]
28pub struct MD018NoMissingSpaceAtx {
29    config: MD018Config,
30}
31
32impl Default for MD018NoMissingSpaceAtx {
33    fn default() -> Self {
34        Self::new()
35    }
36}
37
38impl MD018NoMissingSpaceAtx {
39    pub fn new() -> Self {
40        Self {
41            config: MD018Config::default(),
42        }
43    }
44
45    pub fn from_config_struct(config: MD018Config) -> Self {
46        Self { config }
47    }
48
49    /// Check if a line is a MagicLink-style issue/PR reference (e.g., #123, #10)
50    /// Used by MkDocs flavor to skip PyMdown MagicLink patterns
51    fn is_magiclink_ref(line: &str) -> bool {
52        MAGICLINK_REF_PATTERN.is_match(line.trim_start())
53    }
54
55    /// Check if a line is a tag (e.g., #tagname, #project/active, #3d_printing)
56    fn is_tag(line: &str) -> bool {
57        TAG_PATTERN.is_match(line.trim_start())
58    }
59
60    /// Whether tag patterns should be recognized for the given flavor
61    fn tags_enabled(&self, flavor: MarkdownFlavor) -> bool {
62        self.config.tags_enabled(flavor)
63    }
64
65    /// Check if an ATX heading line is missing space after the marker
66    fn check_atx_heading_line(&self, line: &str, flavor: MarkdownFlavor) -> Option<(usize, String)> {
67        // Look for ATX marker at start of line (with optional indentation)
68        let trimmed_line = line.trim_start();
69        let indent = line.len() - trimmed_line.len();
70
71        if !trimmed_line.starts_with('#') {
72            return None;
73        }
74
75        // Only flag patterns at column 1 (no indentation) to match markdownlint behavior
76        // Indented patterns are likely:
77        // - Multi-line link continuations (e.g., "  #sig-contribex](url)")
78        // - List item content
79        // - Other continuation contexts
80        if indent > 0 {
81            return None;
82        }
83
84        // Skip emoji hashtags and Unicode hashtag patterns
85        let is_emoji = EMOJI_HASHTAG_PATTERN.is_match(trimmed_line);
86        let is_unicode = UNICODE_HASHTAG_PATTERN.is_match(trimmed_line);
87        if is_emoji || is_unicode {
88            return None;
89        }
90
91        // Count the number of hashes
92        let hash_count = trimmed_line.chars().take_while(|&c| c == '#').count();
93        if hash_count == 0 || hash_count > 6 {
94            return None;
95        }
96
97        // Check what comes after the hashes
98        let after_hashes = &trimmed_line[hash_count..];
99
100        // Skip if what follows the hashes is an emoji modifier or variant selector
101        if after_hashes
102            .chars()
103            .next()
104            .is_some_and(|ch| matches!(ch, '\u{FE0F}' | '\u{20E3}' | '\u{FE0E}'))
105        {
106            return None;
107        }
108
109        // If there's content immediately after hashes (no space), it needs fixing
110        if !after_hashes.is_empty() && !after_hashes.starts_with(' ') && !after_hashes.starts_with('\t') {
111            // Additional checks to avoid false positives
112            let content = after_hashes.trim();
113
114            // Skip if it's just more hashes (horizontal rule)
115            if content.chars().all(|c| c == '#') {
116                return None;
117            }
118
119            // Skip if content is too short to be meaningful
120            if content.len() < 2 {
121                return None;
122            }
123
124            // Skip if it starts with emphasis markers
125            if content.starts_with('*') || content.starts_with('_') {
126                return None;
127            }
128
129            // MagicLink config: skip MagicLink-style issue/PR refs (#123, #10, etc.)
130            // MagicLink only uses single #, so check hash_count == 1
131            if self.config.magiclink && hash_count == 1 && Self::is_magiclink_ref(line) {
132                return None;
133            }
134
135            // Tags mode: skip tag syntax (#tagname, #project/active, etc.)
136            // Tags only use single #
137            if self.tags_enabled(flavor) && hash_count == 1 && Self::is_tag(line) {
138                return None;
139            }
140
141            // This looks like a malformed heading that needs a space
142            let fixed = format!("{}{} {}", " ".repeat(indent), "#".repeat(hash_count), after_hashes);
143            return Some((indent + hash_count, fixed));
144        }
145
146        None
147    }
148
149    // Calculate the byte range for a specific line in the content
150    fn get_line_byte_range(&self, content: &str, line_num: usize) -> std::ops::Range<usize> {
151        let mut current_line = 1;
152        let mut start_byte = 0;
153
154        for (i, c) in content.char_indices() {
155            if current_line == line_num && c == '\n' {
156                return start_byte..i;
157            } else if c == '\n' {
158                current_line += 1;
159                if current_line == line_num {
160                    start_byte = i + 1;
161                }
162            }
163        }
164
165        // If we're looking for the last line and it doesn't end with a newline
166        if current_line == line_num {
167            return start_byte..content.len();
168        }
169
170        // Fallback if line not found (shouldn't happen)
171        0..0
172    }
173}
174
175impl Rule for MD018NoMissingSpaceAtx {
176    fn name(&self) -> &'static str {
177        "MD018"
178    }
179
180    fn description(&self) -> &'static str {
181        "No space after hash in heading"
182    }
183
184    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
185        let mut warnings = Vec::new();
186
187        // Check all lines that have ATX headings from cached info
188        for (line_num, line_info) in ctx.lines.iter().enumerate() {
189            // Skip lines inside HTML blocks, HTML comments, or PyMdown blocks
190            if line_info.in_html_block
191                || line_info.in_html_comment
192                || line_info.in_mdx_comment
193                || line_info.in_pymdown_block
194            {
195                continue;
196            }
197
198            if let Some(heading) = &line_info.heading {
199                // Only check ATX headings
200                if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
201                    // Skip indented headings to match markdownlint behavior
202                    // Markdownlint only flags patterns at column 1
203                    if line_info.indent > 0 {
204                        continue;
205                    }
206
207                    // Check if there's a space after the marker
208                    let line = line_info.content(ctx.content);
209                    let trimmed = line.trim_start();
210
211                    // Skip emoji hashtags and Unicode hashtag patterns
212                    let is_emoji = EMOJI_HASHTAG_PATTERN.is_match(trimmed);
213                    let is_unicode = UNICODE_HASHTAG_PATTERN.is_match(trimmed);
214                    if is_emoji || is_unicode {
215                        continue;
216                    }
217
218                    // MagicLink config: skip MagicLink-style issue/PR refs (#123, #10, etc.)
219                    if self.config.magiclink && heading.level == 1 && Self::is_magiclink_ref(line) {
220                        continue;
221                    }
222
223                    // Tags mode: skip tag syntax (#tagname, #project/active, etc.)
224                    if self.tags_enabled(ctx.flavor) && heading.level == 1 && Self::is_tag(line) {
225                        continue;
226                    }
227
228                    if trimmed.len() > heading.marker.len() {
229                        let after_marker = &trimmed[heading.marker.len()..];
230                        if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
231                        {
232                            // Missing space after ATX marker. The indent and '#' markers
233                            // are ASCII, so convert the byte offset to a character column.
234                            let hash_end_col = byte_to_char_count(line, line_info.indent + heading.marker.len());
235                            let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
236                                line_num + 1, // Convert to 1-indexed
237                                hash_end_col,
238                                0, // Zero-width to indicate missing space
239                            );
240
241                            warnings.push(LintWarning {
242                                rule_name: Some(self.name().to_string()),
243                                message: format!("No space after {} in heading", "#".repeat(heading.level as usize)),
244                                line: start_line,
245                                column: start_col,
246                                end_line,
247                                end_column: end_col,
248                                severity: Severity::Warning,
249                                fix: Some(Fix::new(self.get_line_byte_range(ctx.content, line_num + 1), {
250                                    // Preserve original indentation (including tabs)
251                                    let line = line_info.content(ctx.content);
252                                    let original_indent = &line[..line_info.indent];
253                                    format!("{original_indent}{} {after_marker}", heading.marker)
254                                })),
255                            });
256                        }
257                    }
258                }
259            } else if !line_info.in_code_block
260                && !line_info.in_front_matter
261                && !line_info.in_html_comment
262                && !line_info.in_mdx_comment
263                && !line_info.is_blank
264            {
265                // Check for malformed headings that weren't detected as proper headings
266                if let Some((hash_end_pos, fixed_line)) =
267                    self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor)
268                {
269                    let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
270                        line_num + 1,     // Convert to 1-indexed
271                        hash_end_pos + 1, // 1-indexed column
272                        0,                // Zero-width to indicate missing space
273                    );
274
275                    warnings.push(LintWarning {
276                        rule_name: Some(self.name().to_string()),
277                        message: "No space after hash in heading".to_string(),
278                        line: start_line,
279                        column: start_col,
280                        end_line,
281                        end_column: end_col,
282                        severity: Severity::Warning,
283                        fix: Some(Fix::new(
284                            self.get_line_byte_range(ctx.content, line_num + 1),
285                            fixed_line,
286                        )),
287                    });
288                }
289            }
290        }
291
292        Ok(warnings)
293    }
294
295    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
296        let warnings = self.check(ctx)?;
297        let warnings =
298            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
299        let warning_lines: std::collections::HashSet<usize> = warnings.iter().map(|w| w.line).collect();
300
301        let mut lines = Vec::new();
302
303        for (idx, line_info) in ctx.lines.iter().enumerate() {
304            let mut fixed = false;
305
306            if !warning_lines.contains(&(idx + 1)) {
307                lines.push(line_info.content(ctx.content).to_string());
308                continue;
309            }
310
311            if let Some(heading) = &line_info.heading {
312                // Fix ATX headings missing space
313                if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
314                    let line = line_info.content(ctx.content);
315                    let trimmed = line.trim_start();
316
317                    // Skip emoji hashtags and Unicode hashtag patterns
318                    let is_emoji = EMOJI_HASHTAG_PATTERN.is_match(trimmed);
319                    let is_unicode = UNICODE_HASHTAG_PATTERN.is_match(trimmed);
320
321                    // MagicLink config: skip MagicLink-style issue/PR refs (#123, #10, etc.)
322                    let is_magiclink = self.config.magiclink && heading.level == 1 && Self::is_magiclink_ref(line);
323
324                    // Tags mode: skip tag syntax (#tagname, #project/active, etc.)
325                    let is_tag = self.tags_enabled(ctx.flavor) && heading.level == 1 && Self::is_tag(line);
326
327                    // Only attempt fix if not a special pattern
328                    if !is_emoji && !is_unicode && !is_magiclink && !is_tag && trimmed.len() > heading.marker.len() {
329                        let after_marker = &trimmed[heading.marker.len()..];
330                        if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
331                        {
332                            // Add space after marker, preserving original indentation (including tabs)
333                            let line = line_info.content(ctx.content);
334                            let original_indent = &line[..line_info.indent];
335                            lines.push(format!("{original_indent}{} {after_marker}", heading.marker));
336                            fixed = true;
337                        }
338                    }
339                }
340            } else if !line_info.in_code_block
341                && !line_info.in_front_matter
342                && !line_info.in_html_comment
343                && !line_info.in_mdx_comment
344                && !line_info.is_blank
345            {
346                // Fix malformed headings
347                if let Some((_, fixed_line)) = self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor) {
348                    lines.push(fixed_line);
349                    fixed = true;
350                }
351            }
352
353            if !fixed {
354                lines.push(line_info.content(ctx.content).to_string());
355            }
356        }
357
358        // Reconstruct content preserving line endings
359        let mut result = lines.join("\n");
360        if ctx.content.ends_with('\n') && !result.ends_with('\n') {
361            result.push('\n');
362        }
363
364        Ok(result)
365    }
366
367    /// Get the category of this rule for selective processing
368    fn category(&self) -> RuleCategory {
369        RuleCategory::Heading
370    }
371
372    /// Check if this rule should be skipped
373    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
374        // Fast path: check if document likely has headings
375        !ctx.likely_has_headings()
376    }
377
378    fn as_any(&self) -> &dyn std::any::Any {
379        self
380    }
381
382    crate::impl_rule_config_methods!(MD018Config);
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use crate::lint_context::LintContext;
389
390    #[test]
391    fn test_basic_functionality() {
392        let rule = MD018NoMissingSpaceAtx::new();
393
394        // Test with correct space
395        let content = "# Heading 1\n## Heading 2\n### Heading 3";
396        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
397        let result = rule.check(&ctx).unwrap();
398        assert!(result.is_empty());
399
400        // Test with missing space
401        let content = "#Heading 1\n## Heading 2\n###Heading 3";
402        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
403        let result = rule.check(&ctx).unwrap();
404        assert_eq!(result.len(), 2); // Should flag the two headings with missing spaces
405        assert_eq!(result[0].line, 1);
406        assert_eq!(result[1].line, 3);
407    }
408
409    #[test]
410    fn test_malformed_heading_detection() {
411        let rule = MD018NoMissingSpaceAtx::new();
412
413        // Test the check_atx_heading_line method
414        assert!(
415            rule.check_atx_heading_line("##Introduction", MarkdownFlavor::Standard)
416                .is_some()
417        );
418        assert!(
419            rule.check_atx_heading_line("###Background", MarkdownFlavor::Standard)
420                .is_some()
421        );
422        assert!(
423            rule.check_atx_heading_line("####Details", MarkdownFlavor::Standard)
424                .is_some()
425        );
426        assert!(
427            rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
428                .is_some()
429        );
430        assert!(
431            rule.check_atx_heading_line("######Conclusion", MarkdownFlavor::Standard)
432                .is_some()
433        );
434        assert!(
435            rule.check_atx_heading_line("##Table of Contents", MarkdownFlavor::Standard)
436                .is_some()
437        );
438
439        // Should NOT detect these
440        assert!(rule.check_atx_heading_line("###", MarkdownFlavor::Standard).is_none()); // Just hashes
441        assert!(rule.check_atx_heading_line("#", MarkdownFlavor::Standard).is_none()); // Single hash
442        assert!(rule.check_atx_heading_line("##a", MarkdownFlavor::Standard).is_none()); // Too short
443        assert!(
444            rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
445                .is_none()
446        ); // Emphasis marker
447        assert!(
448            rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
449                .is_none()
450        ); // More than 6 hashes
451    }
452
453    #[test]
454    fn test_malformed_heading_with_context() {
455        let rule = MD018NoMissingSpaceAtx::new();
456
457        // Test with full content that includes code blocks
458        let content = r#"# Test Document
459
460##Introduction
461This should be detected.
462
463    ##CodeBlock
464This should NOT be detected (indented code block).
465
466```
467##FencedCodeBlock
468This should NOT be detected (fenced code block).
469```
470
471##Conclusion
472This should be detected.
473"#;
474
475        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
476        let result = rule.check(&ctx).unwrap();
477
478        // Should detect malformed headings but ignore code blocks
479        let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
480        assert!(detected_lines.contains(&3)); // ##Introduction
481        assert!(detected_lines.contains(&14)); // ##Conclusion (updated line number)
482        assert!(!detected_lines.contains(&6)); // ##CodeBlock (should be ignored)
483        assert!(!detected_lines.contains(&10)); // ##FencedCodeBlock (should be ignored)
484    }
485
486    #[test]
487    fn test_malformed_heading_fix() {
488        let rule = MD018NoMissingSpaceAtx::new();
489
490        let content = r#"##Introduction
491This is a test.
492
493###Background
494More content."#;
495
496        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
497        let fixed = rule.fix(&ctx).unwrap();
498
499        let expected = r#"## Introduction
500This is a test.
501
502### Background
503More content."#;
504
505        assert_eq!(fixed, expected);
506    }
507
508    #[test]
509    fn test_mixed_proper_and_malformed_headings() {
510        let rule = MD018NoMissingSpaceAtx::new();
511
512        let content = r#"# Proper Heading
513
514##Malformed Heading
515
516## Another Proper Heading
517
518###Another Malformed
519
520#### Proper with space
521"#;
522
523        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
524        let result = rule.check(&ctx).unwrap();
525
526        // Should only detect the malformed ones
527        assert_eq!(result.len(), 2);
528        let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
529        assert!(detected_lines.contains(&3)); // ##Malformed Heading
530        assert!(detected_lines.contains(&7)); // ###Another Malformed
531    }
532
533    #[test]
534    fn test_css_selectors_in_html_blocks() {
535        let rule = MD018NoMissingSpaceAtx::new();
536
537        // Test CSS selectors inside <style> tags should not trigger MD018
538        // This is a common pattern in Quarto/RMarkdown files
539        let content = r#"# Proper Heading
540
541<style>
542#slide-1 ol li {
543    margin-top: 0;
544}
545
546#special-slide ol li {
547    margin-top: 2em;
548}
549</style>
550
551## Another Heading
552"#;
553
554        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
555        let result = rule.check(&ctx).unwrap();
556
557        // Should not detect CSS selectors as malformed headings
558        assert_eq!(
559            result.len(),
560            0,
561            "CSS selectors in <style> blocks should not be flagged as malformed headings"
562        );
563    }
564
565    #[test]
566    fn test_js_code_in_script_blocks() {
567        let rule = MD018NoMissingSpaceAtx::new();
568
569        // Test that patterns like #element in <script> tags don't trigger MD018
570        let content = r#"# Heading
571
572<script>
573const element = document.querySelector('#main-content');
574#another-comment
575</script>
576
577## Another Heading
578"#;
579
580        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
581        let result = rule.check(&ctx).unwrap();
582
583        // Should not detect JS code as malformed headings
584        assert_eq!(
585            result.len(),
586            0,
587            "JavaScript code in <script> blocks should not be flagged as malformed headings"
588        );
589    }
590
591    #[test]
592    fn test_all_malformed_headings_detected() {
593        let rule = MD018NoMissingSpaceAtx::new();
594
595        // All patterns at line start should be detected as malformed headings
596        // (matching markdownlint behavior)
597
598        // Lowercase single-hash - should be detected
599        assert!(
600            rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
601                .is_some(),
602            "#hello SHOULD be detected as malformed heading"
603        );
604        assert!(
605            rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
606            "#tag SHOULD be detected as malformed heading"
607        );
608        assert!(
609            rule.check_atx_heading_line("#hashtag", MarkdownFlavor::Standard)
610                .is_some(),
611            "#hashtag SHOULD be detected as malformed heading"
612        );
613        assert!(
614            rule.check_atx_heading_line("#javascript", MarkdownFlavor::Standard)
615                .is_some(),
616            "#javascript SHOULD be detected as malformed heading"
617        );
618
619        // Numeric patterns - should be detected (could be headings like "# 123")
620        assert!(
621            rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
622            "#123 SHOULD be detected as malformed heading"
623        );
624        assert!(
625            rule.check_atx_heading_line("#12345", MarkdownFlavor::Standard)
626                .is_some(),
627            "#12345 SHOULD be detected as malformed heading"
628        );
629        assert!(
630            rule.check_atx_heading_line("#29039)", MarkdownFlavor::Standard)
631                .is_some(),
632            "#29039) SHOULD be detected as malformed heading"
633        );
634
635        // Uppercase single-hash - should be detected
636        assert!(
637            rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
638                .is_some(),
639            "#Summary SHOULD be detected as malformed heading"
640        );
641        assert!(
642            rule.check_atx_heading_line("#Introduction", MarkdownFlavor::Standard)
643                .is_some(),
644            "#Introduction SHOULD be detected as malformed heading"
645        );
646        assert!(
647            rule.check_atx_heading_line("#API", MarkdownFlavor::Standard).is_some(),
648            "#API SHOULD be detected as malformed heading"
649        );
650
651        // Multi-hash patterns - should be detected
652        assert!(
653            rule.check_atx_heading_line("##introduction", MarkdownFlavor::Standard)
654                .is_some(),
655            "##introduction SHOULD be detected as malformed heading"
656        );
657        assert!(
658            rule.check_atx_heading_line("###section", MarkdownFlavor::Standard)
659                .is_some(),
660            "###section SHOULD be detected as malformed heading"
661        );
662        assert!(
663            rule.check_atx_heading_line("###fer", MarkdownFlavor::Standard)
664                .is_some(),
665            "###fer SHOULD be detected as malformed heading"
666        );
667        assert!(
668            rule.check_atx_heading_line("##123", MarkdownFlavor::Standard).is_some(),
669            "##123 SHOULD be detected as malformed heading"
670        );
671    }
672
673    #[test]
674    fn test_patterns_that_should_not_be_flagged() {
675        let rule = MD018NoMissingSpaceAtx::new();
676
677        // Just hashes (horizontal rule or empty)
678        assert!(rule.check_atx_heading_line("###", MarkdownFlavor::Standard).is_none());
679        assert!(rule.check_atx_heading_line("#", MarkdownFlavor::Standard).is_none());
680
681        // Content too short
682        assert!(rule.check_atx_heading_line("##a", MarkdownFlavor::Standard).is_none());
683
684        // Emphasis markers
685        assert!(
686            rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
687                .is_none()
688        );
689
690        // More than 6 hashes
691        assert!(
692            rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
693                .is_none()
694        );
695
696        // Proper headings with space
697        assert!(
698            rule.check_atx_heading_line("# Hello", MarkdownFlavor::Standard)
699                .is_none()
700        );
701        assert!(
702            rule.check_atx_heading_line("## World", MarkdownFlavor::Standard)
703                .is_none()
704        );
705        assert!(
706            rule.check_atx_heading_line("### Section", MarkdownFlavor::Standard)
707                .is_none()
708        );
709    }
710
711    #[test]
712    fn test_inline_issue_refs_not_at_line_start() {
713        let rule = MD018NoMissingSpaceAtx::new();
714
715        // Inline patterns (not at line start) are not checked by check_atx_heading_line
716        // because that function only checks lines that START with #
717
718        // These should return None because they don't start with #
719        assert!(
720            rule.check_atx_heading_line("See issue #123", MarkdownFlavor::Standard)
721                .is_none()
722        );
723        assert!(
724            rule.check_atx_heading_line("Check #trending on Twitter", MarkdownFlavor::Standard)
725                .is_none()
726        );
727        assert!(
728            rule.check_atx_heading_line("- fix: issue #29039", MarkdownFlavor::Standard)
729                .is_none()
730        );
731    }
732
733    #[test]
734    fn test_lowercase_patterns_full_check() {
735        // Integration test: verify lowercase patterns are flagged through full check() flow
736        let rule = MD018NoMissingSpaceAtx::new();
737
738        let content = "#hello\n\n#world\n\n#tag";
739        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
740        let result = rule.check(&ctx).unwrap();
741
742        assert_eq!(result.len(), 3, "All three lowercase patterns should be flagged");
743        assert_eq!(result[0].line, 1);
744        assert_eq!(result[1].line, 3);
745        assert_eq!(result[2].line, 5);
746    }
747
748    #[test]
749    fn test_numeric_patterns_full_check() {
750        // Integration test: verify numeric patterns are flagged through full check() flow
751        let rule = MD018NoMissingSpaceAtx::new();
752
753        let content = "#123\n\n#456\n\n#29039";
754        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
755        let result = rule.check(&ctx).unwrap();
756
757        assert_eq!(result.len(), 3, "All three numeric patterns should be flagged");
758    }
759
760    #[test]
761    fn test_fix_lowercase_patterns() {
762        // Verify fix() correctly handles lowercase patterns
763        let rule = MD018NoMissingSpaceAtx::new();
764
765        let content = "#hello\nSome text.\n\n#world";
766        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
767        let fixed = rule.fix(&ctx).unwrap();
768
769        let expected = "# hello\nSome text.\n\n# world";
770        assert_eq!(fixed, expected);
771    }
772
773    #[test]
774    fn test_fix_numeric_patterns() {
775        // Verify fix() correctly handles numeric patterns
776        let rule = MD018NoMissingSpaceAtx::new();
777
778        let content = "#123\nContent.\n\n##456";
779        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
780        let fixed = rule.fix(&ctx).unwrap();
781
782        let expected = "# 123\nContent.\n\n## 456";
783        assert_eq!(fixed, expected);
784    }
785
786    #[test]
787    fn test_indented_malformed_headings() {
788        // Indented patterns are skipped to match markdownlint behavior.
789        // Markdownlint only flags patterns at column 1 (no indentation).
790        // Indented patterns are often multi-line link continuations or list content.
791        let rule = MD018NoMissingSpaceAtx::new();
792
793        // Indented patterns should NOT be flagged (matches markdownlint)
794        assert!(
795            rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
796                .is_none(),
797            "1-space indented #hello should be skipped"
798        );
799        assert!(
800            rule.check_atx_heading_line("  #hello", MarkdownFlavor::Standard)
801                .is_none(),
802            "2-space indented #hello should be skipped"
803        );
804        assert!(
805            rule.check_atx_heading_line("   #hello", MarkdownFlavor::Standard)
806                .is_none(),
807            "3-space indented #hello should be skipped"
808        );
809
810        // 4+ spaces is a code block, not checked by this function
811        // (code block detection happens at LintContext level)
812
813        // BUT patterns at column 1 (no indentation) ARE flagged
814        assert!(
815            rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
816                .is_some(),
817            "Non-indented #hello should be detected"
818        );
819    }
820
821    #[test]
822    fn test_tab_after_hash_is_valid() {
823        // Tab after hash is valid (acts like space)
824        let rule = MD018NoMissingSpaceAtx::new();
825
826        assert!(
827            rule.check_atx_heading_line("#\tHello", MarkdownFlavor::Standard)
828                .is_none(),
829            "Tab after # should be valid"
830        );
831        assert!(
832            rule.check_atx_heading_line("##\tWorld", MarkdownFlavor::Standard)
833                .is_none(),
834            "Tab after ## should be valid"
835        );
836    }
837
838    #[test]
839    fn test_mixed_case_patterns() {
840        let rule = MD018NoMissingSpaceAtx::new();
841
842        // All should be detected regardless of case
843        assert!(
844            rule.check_atx_heading_line("#hELLO", MarkdownFlavor::Standard)
845                .is_some()
846        );
847        assert!(
848            rule.check_atx_heading_line("#Hello", MarkdownFlavor::Standard)
849                .is_some()
850        );
851        assert!(
852            rule.check_atx_heading_line("#HELLO", MarkdownFlavor::Standard)
853                .is_some()
854        );
855        assert!(
856            rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
857                .is_some()
858        );
859    }
860
861    #[test]
862    fn test_unicode_lowercase() {
863        let rule = MD018NoMissingSpaceAtx::new();
864
865        // Unicode lowercase should be detected
866        assert!(
867            rule.check_atx_heading_line("#über", MarkdownFlavor::Standard).is_some(),
868            "Unicode lowercase #über should be detected"
869        );
870        assert!(
871            rule.check_atx_heading_line("#café", MarkdownFlavor::Standard).is_some(),
872            "Unicode lowercase #café should be detected"
873        );
874        assert!(
875            rule.check_atx_heading_line("#日本語", MarkdownFlavor::Standard)
876                .is_some(),
877            "Japanese #日本語 should be detected"
878        );
879    }
880
881    #[test]
882    fn test_matches_markdownlint_behavior() {
883        // Comprehensive test matching markdownlint's expected behavior
884        let rule = MD018NoMissingSpaceAtx::new();
885
886        let content = r#"#hello
887
888## world
889
890###fer
891
892#123
893
894#Tag
895"#;
896
897        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898        let result = rule.check(&ctx).unwrap();
899
900        // markdownlint flags: #hello (line 1), ###fer (line 5), #123 (line 7), #Tag (line 9)
901        // ## world is correct (has space)
902        let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
903
904        assert!(flagged_lines.contains(&1), "#hello should be flagged");
905        assert!(!flagged_lines.contains(&3), "## world should NOT be flagged");
906        assert!(flagged_lines.contains(&5), "###fer should be flagged");
907        assert!(flagged_lines.contains(&7), "#123 should be flagged");
908        assert!(flagged_lines.contains(&9), "#Tag should be flagged");
909
910        assert_eq!(result.len(), 4, "Should have exactly 4 warnings");
911    }
912
913    #[test]
914    fn test_skip_frontmatter_yaml_comments() {
915        // YAML comments in frontmatter should NOT be flagged as missing space in headings
916        let rule = MD018NoMissingSpaceAtx::new();
917
918        let content = r#"---
919#reviewers:
920#- sig-api-machinery
921#another_comment: value
922title: Test Document
923---
924
925# Valid heading
926
927#invalid heading without space
928"#;
929
930        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
931        let result = rule.check(&ctx).unwrap();
932
933        // Should only flag line 10 (#invalid heading without space)
934        // Lines 2-4 are YAML comments in frontmatter and should be skipped
935        assert_eq!(
936            result.len(),
937            1,
938            "Should only flag the malformed heading outside frontmatter"
939        );
940        assert_eq!(result[0].line, 10, "Should flag line 10");
941    }
942
943    #[test]
944    fn test_skip_html_comments() {
945        // Content inside HTML comments should NOT be flagged
946        // This includes Jupyter cell markers like #%% in commented-out code blocks
947        let rule = MD018NoMissingSpaceAtx::new();
948
949        let content = r#"# Real Heading
950
951Some text.
952
953<!--
954```
955#%% Cell marker
956import matplotlib.pyplot as plt
957
958#%% Another cell
959data = [1, 2, 3]
960```
961-->
962
963More content.
964"#;
965
966        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
967        let result = rule.check(&ctx).unwrap();
968
969        // Should find no issues - the #%% markers are inside HTML comments
970        assert!(
971            result.is_empty(),
972            "Should not flag content inside HTML comments, found {} issues",
973            result.len()
974        );
975    }
976
977    #[test]
978    fn test_mkdocs_magiclink_skips_numeric_refs() {
979        // With magiclink config enabled, should skip MagicLink-style issue/PR refs (#123, #10, etc.)
980        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
981            magiclink: true,
982            ..Default::default()
983        });
984
985        // These numeric patterns should be SKIPPED with magiclink enabled
986        assert!(
987            rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_none(),
988            "#10 should be skipped with magiclink config (MagicLink issue ref)"
989        );
990        assert!(
991            rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_none(),
992            "#123 should be skipped with magiclink config (MagicLink issue ref)"
993        );
994        assert!(
995            rule.check_atx_heading_line("#10 discusses the issue", MarkdownFlavor::Standard)
996                .is_none(),
997            "#10 followed by text should be skipped with magiclink config"
998        );
999        assert!(
1000            rule.check_atx_heading_line("#37.", MarkdownFlavor::Standard).is_none(),
1001            "#37 followed by punctuation should be skipped with magiclink config"
1002        );
1003    }
1004
1005    #[test]
1006    fn test_mkdocs_magiclink_still_flags_non_numeric() {
1007        // With magiclink config enabled, should still flag non-numeric patterns
1008        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1009            magiclink: true,
1010            ..Default::default()
1011        });
1012
1013        // Non-numeric patterns should still be flagged even with magiclink enabled
1014        assert!(
1015            rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
1016                .is_some(),
1017            "#Summary should still be flagged with magiclink config"
1018        );
1019        assert!(
1020            rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
1021                .is_some(),
1022            "#hello should still be flagged with magiclink config"
1023        );
1024        assert!(
1025            rule.check_atx_heading_line("#10abc", MarkdownFlavor::Standard)
1026                .is_some(),
1027            "#10abc (mixed) should still be flagged with magiclink config"
1028        );
1029    }
1030
1031    #[test]
1032    fn test_mkdocs_magiclink_only_single_hash() {
1033        // MagicLink only uses single #, so ##10 should still be flagged
1034        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1035            magiclink: true,
1036            ..Default::default()
1037        });
1038
1039        assert!(
1040            rule.check_atx_heading_line("##10", MarkdownFlavor::Standard).is_some(),
1041            "##10 should be flagged with magiclink config (only single # is MagicLink)"
1042        );
1043        assert!(
1044            rule.check_atx_heading_line("###123", MarkdownFlavor::Standard)
1045                .is_some(),
1046            "###123 should be flagged with magiclink config"
1047        );
1048    }
1049
1050    #[test]
1051    fn test_standard_flavor_flags_numeric_refs() {
1052        // Standard flavor should still flag numeric patterns (no MagicLink awareness)
1053        let rule = MD018NoMissingSpaceAtx::new();
1054
1055        assert!(
1056            rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_some(),
1057            "#10 should be flagged in Standard flavor"
1058        );
1059        assert!(
1060            rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
1061            "#123 should be flagged in Standard flavor"
1062        );
1063    }
1064
1065    #[test]
1066    fn test_mkdocs_magiclink_full_check() {
1067        // Integration test: verify magiclink config skips MagicLink refs through full check() flow
1068        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1069            magiclink: true,
1070            ..Default::default()
1071        });
1072
1073        let content = r#"# PRs that are helpful for context
1074
1075#10 discusses the philosophy behind the project, and #37 shows a good example.
1076
1077#Summary
1078
1079##Introduction
1080"#;
1081
1082        // With magiclink enabled - should skip #10 and #37, but flag #Summary and ##Introduction
1083        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1084        let result = rule.check(&ctx).unwrap();
1085
1086        let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1087        assert!(
1088            !flagged_lines.contains(&3),
1089            "#10 should NOT be flagged with magiclink config"
1090        );
1091        assert!(
1092            flagged_lines.contains(&5),
1093            "#Summary SHOULD be flagged with magiclink config"
1094        );
1095        assert!(
1096            flagged_lines.contains(&7),
1097            "##Introduction SHOULD be flagged with magiclink config"
1098        );
1099    }
1100
1101    #[test]
1102    fn test_mkdocs_magiclink_fix_exact_output() {
1103        // Verify fix() produces exact expected output with magiclink config
1104        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1105            magiclink: true,
1106            ..Default::default()
1107        });
1108
1109        let content = "#10 discusses the issue.\n\n#Summary";
1110        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1111        let fixed = rule.fix(&ctx).unwrap();
1112
1113        // Exact expected output: #10 preserved, #Summary fixed
1114        let expected = "#10 discusses the issue.\n\n# Summary";
1115        assert_eq!(
1116            fixed, expected,
1117            "magiclink config fix should preserve MagicLink refs and fix non-numeric headings"
1118        );
1119    }
1120
1121    #[test]
1122    fn test_mkdocs_magiclink_edge_cases() {
1123        // Test various edge cases for MagicLink pattern matching
1124        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1125            magiclink: true,
1126            ..Default::default()
1127        });
1128
1129        // These should all be SKIPPED with magiclink config (valid MagicLink refs)
1130        // Note: #1 alone is skipped due to content length < 2, not MagicLink
1131        let valid_refs = [
1132            "#10",             // Two digits
1133            "#999999",         // Large number
1134            "#10 text after",  // Space then text
1135            "#10\ttext after", // Tab then text
1136            "#10.",            // Period after
1137            "#10,",            // Comma after
1138            "#10!",            // Exclamation after
1139            "#10?",            // Question mark after
1140            "#10)",            // Close paren after
1141            "#10]",            // Close bracket after
1142            "#10;",            // Semicolon after
1143            "#10:",            // Colon after
1144        ];
1145
1146        for ref_str in valid_refs {
1147            assert!(
1148                rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_none(),
1149                "{ref_str:?} should be skipped as MagicLink ref with magiclink config"
1150            );
1151        }
1152
1153        // These should still be FLAGGED with magiclink config (not valid MagicLink refs)
1154        let invalid_refs = [
1155            "#10abc",   // Alphanumeric continuation
1156            "#10a",     // Single alpha continuation
1157            "#abc10",   // Alpha prefix
1158            "#10ABC",   // Uppercase continuation
1159            "#Summary", // Pure text
1160            "#hello",   // Lowercase text
1161        ];
1162
1163        for ref_str in invalid_refs {
1164            assert!(
1165                rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_some(),
1166                "{ref_str:?} should be flagged with magiclink config (not a valid MagicLink ref)"
1167            );
1168        }
1169    }
1170
1171    #[test]
1172    fn test_mkdocs_magiclink_hyphenated_continuation() {
1173        // Hyphenated patterns like #10-related should still be flagged
1174        // because they're likely malformed headings, not MagicLink refs
1175        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1176            magiclink: true,
1177            ..Default::default()
1178        });
1179
1180        // Hyphen is not alphanumeric, so #10- would match as MagicLink
1181        // But #10-related has alphanumeric after the hyphen
1182        // The regex ^#\d+(?:\s|[^a-zA-Z0-9]|$) would match #10- but not consume -related
1183        // So #10-related would match (the -r part is after the match)
1184        assert!(
1185            rule.check_atx_heading_line("#10-", MarkdownFlavor::Standard).is_none(),
1186            "#10- should be skipped with magiclink config (hyphen is non-alphanumeric terminator)"
1187        );
1188    }
1189
1190    #[test]
1191    fn test_mkdocs_magiclink_standalone_number() {
1192        // #10 alone on a line (common in changelogs)
1193        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1194            magiclink: true,
1195            ..Default::default()
1196        });
1197
1198        let content = "See issue:\n\n#10\n\nFor details.";
1199        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1200        let result = rule.check(&ctx).unwrap();
1201
1202        // #10 alone should not be flagged with magiclink config
1203        assert!(
1204            result.is_empty(),
1205            "Standalone #10 should not be flagged with magiclink config"
1206        );
1207
1208        // Verify fix doesn't modify it
1209        let fixed = rule.fix(&ctx).unwrap();
1210        assert_eq!(fixed, content, "fix() should not modify standalone MagicLink ref");
1211    }
1212
1213    #[test]
1214    fn test_standard_flavor_flags_all_numeric() {
1215        // Standard flavor should flag ALL numeric patterns (no MagicLink awareness)
1216        // Note: #1 is skipped because content length < 2 (existing behavior)
1217        let rule = MD018NoMissingSpaceAtx::new();
1218
1219        let numeric_patterns = ["#10", "#123", "#999999", "#10 text"];
1220
1221        for pattern in numeric_patterns {
1222            assert!(
1223                rule.check_atx_heading_line(pattern, MarkdownFlavor::Standard).is_some(),
1224                "{pattern:?} should be flagged in Standard flavor"
1225            );
1226        }
1227
1228        // #1 is skipped due to content length < 2 rule (not MagicLink related)
1229        assert!(
1230            rule.check_atx_heading_line("#1", MarkdownFlavor::Standard).is_none(),
1231            "#1 should be skipped (content too short, existing behavior)"
1232        );
1233    }
1234
1235    #[test]
1236    fn test_mkdocs_vs_standard_fix_comparison() {
1237        // Compare fix output between magiclink enabled and disabled
1238        let content = "#10 is an issue\n#Summary";
1239        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1240
1241        // With magiclink: preserves #10, fixes #Summary
1242        let rule_magiclink = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1243            magiclink: true,
1244            ..Default::default()
1245        });
1246        let fixed_magiclink = rule_magiclink.fix(&ctx).unwrap();
1247        assert_eq!(fixed_magiclink, "#10 is an issue\n# Summary");
1248
1249        // Without magiclink: fixes both
1250        let rule_default = MD018NoMissingSpaceAtx::new();
1251        let fixed_default = rule_default.fix(&ctx).unwrap();
1252        assert_eq!(fixed_default, "# 10 is an issue\n# Summary");
1253    }
1254
1255    // ==================== Tags config tests ====================
1256
1257    #[test]
1258    fn test_tags_config_standard_flavor() {
1259        // tags = true with standard flavor should skip tag patterns
1260        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1261            magiclink: false,
1262            tags: Some(true),
1263        });
1264
1265        let content = "#tag\n\n#project/active\n\n##Introduction";
1266        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1267        let result = rule.check(&ctx).unwrap();
1268
1269        let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1270        assert!(!flagged_lines.contains(&1), "#tag should be skipped with tags = true");
1271        assert!(
1272            !flagged_lines.contains(&3),
1273            "#project/active should be skipped with tags = true"
1274        );
1275        assert!(flagged_lines.contains(&5), "##Introduction should still be flagged");
1276    }
1277
1278    #[test]
1279    fn test_tags_config_fix_standard_flavor() {
1280        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1281            magiclink: false,
1282            tags: Some(true),
1283        });
1284
1285        let content = "#tag\n\n##Introduction";
1286        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1287        let fixed = rule.fix(&ctx).unwrap();
1288        assert_eq!(fixed, "#tag\n\n## Introduction");
1289    }
1290
1291    #[test]
1292    fn test_tags_config_disabled_obsidian_flavor() {
1293        // tags = false with Obsidian flavor should flag tag patterns
1294        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1295            magiclink: false,
1296            tags: Some(false),
1297        });
1298
1299        let content = "#tag\n\n#project/active";
1300        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1301        let result = rule.check(&ctx).unwrap();
1302
1303        assert_eq!(
1304            result.len(),
1305            2,
1306            "tags = false should flag tag patterns even in Obsidian"
1307        );
1308    }
1309
1310    #[test]
1311    fn test_tags_config_default_follows_flavor() {
1312        // Unset tags should default based on flavor
1313        let rule = MD018NoMissingSpaceAtx::new(); // tags: None
1314
1315        // Standard: should flag
1316        let content = "#tag";
1317        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1318        let result = rule.check(&ctx).unwrap();
1319        assert!(!result.is_empty(), "Default standard should flag #tag");
1320
1321        // Obsidian: should skip
1322        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1323        let result = rule.check(&ctx).unwrap();
1324        assert!(result.is_empty(), "Default Obsidian should skip #tag");
1325    }
1326
1327    // ==================== Obsidian flavor tests ====================
1328
1329    #[test]
1330    fn test_obsidian_tag_skips_simple_tags() {
1331        // Obsidian flavor should skip tag syntax (#tagname)
1332        let rule = MD018NoMissingSpaceAtx::new();
1333
1334        // Simple tags should be SKIPPED in Obsidian flavor
1335        assert!(
1336            rule.check_atx_heading_line("#hey", MarkdownFlavor::Obsidian).is_none(),
1337            "#hey should be skipped in Obsidian flavor (tag syntax)"
1338        );
1339        assert!(
1340            rule.check_atx_heading_line("#tag", MarkdownFlavor::Obsidian).is_none(),
1341            "#tag should be skipped in Obsidian flavor"
1342        );
1343        assert!(
1344            rule.check_atx_heading_line("#hello", MarkdownFlavor::Obsidian)
1345                .is_none(),
1346            "#hello should be skipped in Obsidian flavor"
1347        );
1348        assert!(
1349            rule.check_atx_heading_line("#myTag", MarkdownFlavor::Obsidian)
1350                .is_none(),
1351            "#myTag should be skipped in Obsidian flavor"
1352        );
1353    }
1354
1355    #[test]
1356    fn test_obsidian_tag_skips_complex_tags() {
1357        // Obsidian tags can have hyphens, underscores, numbers, and slashes
1358        let rule = MD018NoMissingSpaceAtx::new();
1359
1360        // Complex tag patterns should be SKIPPED in Obsidian flavor
1361        assert!(
1362            rule.check_atx_heading_line("#project/active", MarkdownFlavor::Obsidian)
1363                .is_none(),
1364            "#project/active should be skipped in Obsidian flavor (nested tag)"
1365        );
1366        assert!(
1367            rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1368                .is_none(),
1369            "#my-tag should be skipped in Obsidian flavor"
1370        );
1371        assert!(
1372            rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1373                .is_none(),
1374            "#my_tag should be skipped in Obsidian flavor"
1375        );
1376        assert!(
1377            rule.check_atx_heading_line("#tag2023", MarkdownFlavor::Obsidian)
1378                .is_none(),
1379            "#tag2023 should be skipped in Obsidian flavor"
1380        );
1381        assert!(
1382            rule.check_atx_heading_line("#project/sub/task", MarkdownFlavor::Obsidian)
1383                .is_none(),
1384            "#project/sub/task should be skipped in Obsidian flavor"
1385        );
1386    }
1387
1388    #[test]
1389    fn test_obsidian_tag_with_trailing_content() {
1390        // Tags followed by whitespace should still be skipped
1391        let rule = MD018NoMissingSpaceAtx::new();
1392
1393        assert!(
1394            rule.check_atx_heading_line("#hey ", MarkdownFlavor::Obsidian).is_none(),
1395            "#hey followed by space should be skipped"
1396        );
1397        assert!(
1398            rule.check_atx_heading_line("#tag some text", MarkdownFlavor::Obsidian)
1399                .is_none(),
1400            "#tag followed by text should be skipped"
1401        );
1402    }
1403
1404    #[test]
1405    fn test_obsidian_tag_still_flags_multi_hash() {
1406        // Obsidian tags only use single #, so ##tag should still be flagged
1407        let rule = MD018NoMissingSpaceAtx::new();
1408
1409        assert!(
1410            rule.check_atx_heading_line("##tag", MarkdownFlavor::Obsidian).is_some(),
1411            "##tag should be flagged in Obsidian flavor (only single # is a tag)"
1412        );
1413        assert!(
1414            rule.check_atx_heading_line("###hello", MarkdownFlavor::Obsidian)
1415                .is_some(),
1416            "###hello should be flagged in Obsidian flavor"
1417        );
1418    }
1419
1420    #[test]
1421    fn test_obsidian_tag_numeric_still_flagged() {
1422        // An Obsidian tag needs at least one non-numerical character, so an
1423        // all-numeric #123 is not a tag and should still be flagged
1424        let rule = MD018NoMissingSpaceAtx::new();
1425
1426        assert!(
1427            rule.check_atx_heading_line("#123", MarkdownFlavor::Obsidian).is_some(),
1428            "#123 should be flagged in Obsidian flavor (no non-numerical character)"
1429        );
1430        assert!(
1431            rule.check_atx_heading_line("#10", MarkdownFlavor::Obsidian).is_some(),
1432            "#10 should be flagged in Obsidian flavor"
1433        );
1434    }
1435
1436    #[test]
1437    fn test_obsidian_flavor_full_check() {
1438        // Integration test: verify Obsidian flavor skips tags through full check() flow
1439        let rule = MD018NoMissingSpaceAtx::new();
1440
1441        let content = r#"# Real Heading
1442
1443#hey this is a tag
1444
1445#project/active also a tag
1446
1447##Introduction
1448
1449#123
1450"#;
1451
1452        // Obsidian flavor - should skip #hey and #project/active, but flag ##Introduction and #123
1453        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1454        let result = rule.check(&ctx).unwrap();
1455
1456        let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1457        assert!(
1458            !flagged_lines.contains(&3),
1459            "#hey should NOT be flagged in Obsidian flavor"
1460        );
1461        assert!(
1462            !flagged_lines.contains(&5),
1463            "#project/active should NOT be flagged in Obsidian flavor"
1464        );
1465        assert!(
1466            flagged_lines.contains(&7),
1467            "##Introduction SHOULD be flagged in Obsidian flavor"
1468        );
1469        assert!(flagged_lines.contains(&9), "#123 SHOULD be flagged in Obsidian flavor");
1470    }
1471
1472    #[test]
1473    fn test_obsidian_flavor_fix_exact_output() {
1474        // Verify fix() produces exact expected output
1475        let rule = MD018NoMissingSpaceAtx::new();
1476
1477        // In Obsidian flavor, all single-# patterns that look like tags are preserved
1478        // Only multi-hash patterns (##tag) and numeric patterns (#123) are fixed
1479        let content = "#hey is a tag.\n\n##Introduction";
1480        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1481        let fixed = rule.fix(&ctx).unwrap();
1482
1483        // Exact expected output: #hey preserved, ##Introduction fixed
1484        let expected = "#hey is a tag.\n\n## Introduction";
1485        assert_eq!(
1486            fixed, expected,
1487            "Obsidian fix should preserve tags and fix multi-hash headings"
1488        );
1489    }
1490
1491    #[test]
1492    fn test_standard_flavor_flags_obsidian_tags() {
1493        // Standard flavor should flag patterns that look like Obsidian tags
1494        let rule = MD018NoMissingSpaceAtx::new();
1495
1496        assert!(
1497            rule.check_atx_heading_line("#hey", MarkdownFlavor::Standard).is_some(),
1498            "#hey should be flagged in Standard flavor"
1499        );
1500        assert!(
1501            rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
1502            "#tag should be flagged in Standard flavor"
1503        );
1504        assert!(
1505            rule.check_atx_heading_line("#project/active", MarkdownFlavor::Standard)
1506                .is_some(),
1507            "#project/active should be flagged in Standard flavor"
1508        );
1509    }
1510
1511    #[test]
1512    fn test_obsidian_vs_standard_fix_comparison() {
1513        // Compare fix output between Obsidian and Standard flavors
1514        let rule = MD018NoMissingSpaceAtx::new();
1515
1516        // Use a pattern that clearly shows the difference:
1517        // - #hey tag: single-hash, looks like Obsidian tag followed by text
1518        // - ##Introduction: multi-hash, clearly a malformed heading
1519        let content = "#hey tag\n##Introduction";
1520
1521        // Obsidian: preserves #hey tag (single-hash tag syntax), fixes ##Introduction
1522        let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1523        let fixed_obsidian = rule.fix(&ctx_obsidian).unwrap();
1524        assert_eq!(fixed_obsidian, "#hey tag\n## Introduction");
1525
1526        // Standard: fixes both (no tag awareness)
1527        let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1528        let fixed_standard = rule.fix(&ctx_standard).unwrap();
1529        assert_eq!(fixed_standard, "# hey tag\n## Introduction");
1530    }
1531
1532    #[test]
1533    fn test_obsidian_tag_edge_cases() {
1534        // Test various edge cases for Obsidian tag pattern matching
1535        let rule = MD018NoMissingSpaceAtx::new();
1536
1537        // Valid Obsidian tags - should be SKIPPED
1538        let valid_tags = [
1539            "#a",      // Minimum valid tag (also under the content length < 2 rule)
1540            "#tag",    // Simple tag
1541            "#Tag",    // Capitalized tag
1542            "#TAG",    // Uppercase tag
1543            "#my-tag", // Hyphenated tag
1544            "#my_tag", // Underscored tag
1545            "#tag123", // Tag with trailing numbers
1546            "#a1",     // Short tag with number
1547            "#日本語", // Unicode tag
1548            "#über",   // Unicode with umlaut
1549        ];
1550
1551        for tag in valid_tags {
1552            assert!(
1553                rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1554                "{tag:?} should be skipped in Obsidian flavor (valid tag)"
1555            );
1556        }
1557
1558        // Not tags - should be FLAGGED. Every one of these is all-numeric up to
1559        // the first character Obsidian would not accept in a tag.
1560        let invalid_tags = ["#123", "#1984", "#37.", "#42,"];
1561
1562        for tag in invalid_tags {
1563            assert!(
1564                rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_some(),
1565                "{tag:?} should be flagged in Obsidian flavor (no non-numerical character)"
1566            );
1567        }
1568    }
1569
1570    #[test]
1571    fn test_obsidian_tag_alone_on_line() {
1572        // Standalone tag on a line (common in Obsidian notes)
1573        let rule = MD018NoMissingSpaceAtx::new();
1574
1575        let content = "Some text\n\n#todo\n\nMore text.";
1576        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1577        let result = rule.check(&ctx).unwrap();
1578
1579        // #todo alone should not be flagged in Obsidian flavor
1580        assert!(
1581            result.is_empty(),
1582            "Standalone #todo should not be flagged in Obsidian flavor"
1583        );
1584
1585        // Verify fix doesn't modify it
1586        let fixed = rule.fix(&ctx).unwrap();
1587        assert_eq!(fixed, content, "fix() should not modify standalone Obsidian tag");
1588    }
1589
1590    #[test]
1591    fn test_obsidian_deeply_nested_tags() {
1592        // Obsidian supports deeply nested tags with /
1593        let rule = MD018NoMissingSpaceAtx::new();
1594
1595        let nested_tags = [
1596            "#a/b",
1597            "#a/b/c",
1598            "#project/2023/q1/task",
1599            "#work/meetings/weekly",
1600            "#life/health/exercise/running",
1601        ];
1602
1603        for tag in nested_tags {
1604            assert!(
1605                rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1606                "{tag:?} should be skipped in Obsidian flavor (nested tag)"
1607            );
1608        }
1609    }
1610
1611    #[test]
1612    fn test_obsidian_unicode_tags() {
1613        // Obsidian supports Unicode in tags
1614        let rule = MD018NoMissingSpaceAtx::new();
1615
1616        let unicode_tags = [
1617            "#日本語", // Japanese
1618            "#中文",   // Chinese
1619            "#한국어", // Korean
1620            "#über",   // German umlaut
1621            "#café",   // French accent
1622            "#ñoño",   // Spanish tilde
1623            "#Москва", // Russian
1624            "#αβγ",    // Greek
1625        ];
1626
1627        for tag in unicode_tags {
1628            assert!(
1629                rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1630                "{tag:?} should be skipped in Obsidian flavor (Unicode tag)"
1631            );
1632        }
1633    }
1634
1635    #[test]
1636    fn test_obsidian_tags_with_special_endings() {
1637        // Tags followed by various punctuation
1638        let rule = MD018NoMissingSpaceAtx::new();
1639
1640        // Tags followed by space then text should be skipped
1641        assert!(
1642            rule.check_atx_heading_line("#tag followed by text", MarkdownFlavor::Obsidian)
1643                .is_none(),
1644            "#tag followed by text should be skipped"
1645        );
1646
1647        // Tag at end of line (no trailing space)
1648        let content = "#todo";
1649        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1650        let result = rule.check(&ctx).unwrap();
1651        assert!(result.is_empty(), "#todo at end of line should be skipped");
1652    }
1653
1654    #[test]
1655    fn test_obsidian_combined_with_other_skip_contexts() {
1656        // Verify Obsidian tags in code blocks and HTML comments are still skipped
1657        let rule = MD018NoMissingSpaceAtx::new();
1658
1659        // Tag inside code block (should be skipped by code block logic, not Obsidian logic)
1660        let content = "```\n#todo\n```";
1661        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1662        let result = rule.check(&ctx).unwrap();
1663        assert!(result.is_empty(), "Tag in code block should be skipped");
1664
1665        // Tag inside HTML comment
1666        let content = "<!-- #todo -->";
1667        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1668        let result = rule.check(&ctx).unwrap();
1669        assert!(result.is_empty(), "Tag in HTML comment should be skipped");
1670    }
1671
1672    #[test]
1673    fn test_obsidian_boundary_cases() {
1674        // Test boundary cases for Obsidian tag detection
1675        let rule = MD018NoMissingSpaceAtx::new();
1676
1677        // Minimum valid tag (single char after #)
1678        // Note: #a alone might be skipped by content length < 2 rule
1679        // #ab should definitely be recognized as a tag
1680        assert!(
1681            rule.check_atx_heading_line("#ab", MarkdownFlavor::Obsidian).is_none(),
1682            "#ab should be skipped in Obsidian flavor"
1683        );
1684
1685        // Tag with underscore
1686        assert!(
1687            rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1688                .is_none(),
1689            "#my_tag should be skipped"
1690        );
1691
1692        // Tag with hyphen
1693        assert!(
1694            rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1695                .is_none(),
1696            "#my-tag should be skipped"
1697        );
1698
1699        // Tag with mixed case
1700        assert!(
1701            rule.check_atx_heading_line("#MyTag", MarkdownFlavor::Obsidian)
1702                .is_none(),
1703            "#MyTag should be skipped"
1704        );
1705
1706        // All caps (could be a tag or acronym)
1707        assert!(
1708            rule.check_atx_heading_line("#TODO", MarkdownFlavor::Obsidian).is_none(),
1709            "#TODO should be skipped in Obsidian flavor"
1710        );
1711    }
1712
1713    #[test]
1714    fn test_obsidian_tag_may_start_with_a_digit() {
1715        // Obsidian requires at least one non-numerical character anywhere in the
1716        // tag, not a non-numerical FIRST character.
1717        let rule = MD018NoMissingSpaceAtx::new();
1718
1719        let tags = [
1720            "#3d_printing",       // the reported case
1721            "#1tag",              // digit then letters
1722            "#2023-project",      // digit run then a hyphen
1723            "#100DaysOfCode",     // digits then mixed case
1724            "#1on1",              // digits on both sides of letters
1725            "#5S",                // single digit, single letter
1726            "#3/4",               // digit run then a nested-tag separator
1727            "#1_2",               // digit run then an underscore
1728            "#3🔥",               // digit run then an emoji
1729            "#3\u{FE0F}\u{20E3}", // keycap: digit, variation selector, enclosing mark
1730        ];
1731
1732        for tag in tags {
1733            assert!(
1734                rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1735                "{tag:?} contains a non-numerical character and should be skipped as a tag"
1736            );
1737        }
1738    }
1739
1740    #[test]
1741    fn test_numeric_references_are_not_tags() {
1742        // The counterpart of the test above: allowing a leading digit run must
1743        // not turn an issue reference or a numeric heading into a tag. Without
1744        // these, widening the pattern to any non-digit would pass unnoticed.
1745        let rule = MD018NoMissingSpaceAtx::new();
1746
1747        let not_tags = [
1748            "#1984",                   // all-numeric, Obsidian's own example
1749            "#123",                    // all-numeric
1750            "#10",                     // issue reference
1751            "#37.",                    // issue reference ending a sentence
1752            "#42,",                    // issue reference in a list
1753            "#42)",                    // issue reference in parentheses
1754            "#404 Not Found",          // numeric heading
1755            "#10 discusses the issue", // issue reference opening a line
1756        ];
1757
1758        for line in not_tags {
1759            assert!(
1760                rule.check_atx_heading_line(line, MarkdownFlavor::Obsidian).is_some(),
1761                "{line:?} has no non-numerical tag character and should stay flagged"
1762            );
1763        }
1764    }
1765
1766    #[test]
1767    fn test_digit_leading_tag_survives_check_and_fix() {
1768        // End-to-end through the two paths a user actually hits: the Obsidian
1769        // flavor default, and `tags = true` on Standard flavor as reported.
1770        let obsidian = MD018NoMissingSpaceAtx::new();
1771        let standard = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1772            magiclink: false,
1773            tags: Some(true),
1774        });
1775
1776        // ##Heading is the positive control: it must still be flagged and fixed,
1777        // otherwise an untouched tag proves nothing about the fix having run.
1778        let content = "# Header\n\n#3d_printing\n\n##Heading\n";
1779
1780        for (rule, flavor, label) in [
1781            (&obsidian, MarkdownFlavor::Obsidian, "obsidian flavor"),
1782            (&standard, MarkdownFlavor::Standard, "tags = true"),
1783        ] {
1784            let ctx = LintContext::new(content, flavor, None);
1785            let flagged: Vec<usize> = rule.check(&ctx).unwrap().iter().map(|w| w.line).collect();
1786            assert_eq!(
1787                flagged,
1788                vec![5],
1789                "{label}: only ##Heading should be flagged, got {flagged:?}"
1790            );
1791
1792            let fixed = rule.fix(&ctx).unwrap();
1793            assert_eq!(
1794                fixed, "# Header\n\n#3d_printing\n\n## Heading\n",
1795                "{label}: fix must leave the tag alone and still fix the heading"
1796            );
1797        }
1798    }
1799
1800    #[test]
1801    fn test_digit_leading_tag_is_still_flagged_without_tags_mode() {
1802        // Standard flavor without the option keeps treating it as a malformed
1803        // heading, so the fix does not leak tag awareness into other flavors.
1804        let rule = MD018NoMissingSpaceAtx::new();
1805
1806        assert!(
1807            rule.check_atx_heading_line("#3d_printing", MarkdownFlavor::Standard)
1808                .is_some(),
1809            "#3d_printing should be flagged in Standard flavor (tags disabled)"
1810        );
1811    }
1812}