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