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