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