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    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
388    where
389        Self: Sized,
390    {
391        let rule_config = crate::rule_config_serde::load_rule_config::<MD018Config>(config);
392        Box::new(MD018NoMissingSpaceAtx::from_config_struct(rule_config))
393    }
394
395    fn default_config_section(&self) -> Option<(String, toml::Value)> {
396        let json_value = serde_json::to_value(&self.config).ok()?;
397        Some((
398            self.name().to_string(),
399            crate::rule_config_serde::json_to_toml_value(&json_value)?,
400        ))
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use crate::lint_context::LintContext;
408
409    #[test]
410    fn test_basic_functionality() {
411        let rule = MD018NoMissingSpaceAtx::new();
412
413        // Test with correct space
414        let content = "# Heading 1\n## Heading 2\n### Heading 3";
415        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
416        let result = rule.check(&ctx).unwrap();
417        assert!(result.is_empty());
418
419        // Test with missing space
420        let content = "#Heading 1\n## Heading 2\n###Heading 3";
421        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
422        let result = rule.check(&ctx).unwrap();
423        assert_eq!(result.len(), 2); // Should flag the two headings with missing spaces
424        assert_eq!(result[0].line, 1);
425        assert_eq!(result[1].line, 3);
426    }
427
428    #[test]
429    fn test_malformed_heading_detection() {
430        let rule = MD018NoMissingSpaceAtx::new();
431
432        // Test the check_atx_heading_line method
433        assert!(
434            rule.check_atx_heading_line("##Introduction", MarkdownFlavor::Standard)
435                .is_some()
436        );
437        assert!(
438            rule.check_atx_heading_line("###Background", MarkdownFlavor::Standard)
439                .is_some()
440        );
441        assert!(
442            rule.check_atx_heading_line("####Details", MarkdownFlavor::Standard)
443                .is_some()
444        );
445        assert!(
446            rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
447                .is_some()
448        );
449        assert!(
450            rule.check_atx_heading_line("######Conclusion", MarkdownFlavor::Standard)
451                .is_some()
452        );
453        assert!(
454            rule.check_atx_heading_line("##Table of Contents", MarkdownFlavor::Standard)
455                .is_some()
456        );
457
458        // Should NOT detect these
459        assert!(rule.check_atx_heading_line("###", MarkdownFlavor::Standard).is_none()); // Just hashes
460        assert!(rule.check_atx_heading_line("#", MarkdownFlavor::Standard).is_none()); // Single hash
461        assert!(rule.check_atx_heading_line("##a", MarkdownFlavor::Standard).is_none()); // Too short
462        assert!(
463            rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
464                .is_none()
465        ); // Emphasis marker
466        assert!(
467            rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
468                .is_none()
469        ); // More than 6 hashes
470    }
471
472    #[test]
473    fn test_malformed_heading_with_context() {
474        let rule = MD018NoMissingSpaceAtx::new();
475
476        // Test with full content that includes code blocks
477        let content = r#"# Test Document
478
479##Introduction
480This should be detected.
481
482    ##CodeBlock
483This should NOT be detected (indented code block).
484
485```
486##FencedCodeBlock
487This should NOT be detected (fenced code block).
488```
489
490##Conclusion
491This should be detected.
492"#;
493
494        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
495        let result = rule.check(&ctx).unwrap();
496
497        // Should detect malformed headings but ignore code blocks
498        let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
499        assert!(detected_lines.contains(&3)); // ##Introduction
500        assert!(detected_lines.contains(&14)); // ##Conclusion (updated line number)
501        assert!(!detected_lines.contains(&6)); // ##CodeBlock (should be ignored)
502        assert!(!detected_lines.contains(&10)); // ##FencedCodeBlock (should be ignored)
503    }
504
505    #[test]
506    fn test_malformed_heading_fix() {
507        let rule = MD018NoMissingSpaceAtx::new();
508
509        let content = r#"##Introduction
510This is a test.
511
512###Background
513More content."#;
514
515        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
516        let fixed = rule.fix(&ctx).unwrap();
517
518        let expected = r#"## Introduction
519This is a test.
520
521### Background
522More content."#;
523
524        assert_eq!(fixed, expected);
525    }
526
527    #[test]
528    fn test_mixed_proper_and_malformed_headings() {
529        let rule = MD018NoMissingSpaceAtx::new();
530
531        let content = r#"# Proper Heading
532
533##Malformed Heading
534
535## Another Proper Heading
536
537###Another Malformed
538
539#### Proper with space
540"#;
541
542        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543        let result = rule.check(&ctx).unwrap();
544
545        // Should only detect the malformed ones
546        assert_eq!(result.len(), 2);
547        let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
548        assert!(detected_lines.contains(&3)); // ##Malformed Heading
549        assert!(detected_lines.contains(&7)); // ###Another Malformed
550    }
551
552    #[test]
553    fn test_css_selectors_in_html_blocks() {
554        let rule = MD018NoMissingSpaceAtx::new();
555
556        // Test CSS selectors inside <style> tags should not trigger MD018
557        // This is a common pattern in Quarto/RMarkdown files
558        let content = r#"# Proper Heading
559
560<style>
561#slide-1 ol li {
562    margin-top: 0;
563}
564
565#special-slide ol li {
566    margin-top: 2em;
567}
568</style>
569
570## Another Heading
571"#;
572
573        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
574        let result = rule.check(&ctx).unwrap();
575
576        // Should not detect CSS selectors as malformed headings
577        assert_eq!(
578            result.len(),
579            0,
580            "CSS selectors in <style> blocks should not be flagged as malformed headings"
581        );
582    }
583
584    #[test]
585    fn test_js_code_in_script_blocks() {
586        let rule = MD018NoMissingSpaceAtx::new();
587
588        // Test that patterns like #element in <script> tags don't trigger MD018
589        let content = r#"# Heading
590
591<script>
592const element = document.querySelector('#main-content');
593#another-comment
594</script>
595
596## Another Heading
597"#;
598
599        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
600        let result = rule.check(&ctx).unwrap();
601
602        // Should not detect JS code as malformed headings
603        assert_eq!(
604            result.len(),
605            0,
606            "JavaScript code in <script> blocks should not be flagged as malformed headings"
607        );
608    }
609
610    #[test]
611    fn test_all_malformed_headings_detected() {
612        let rule = MD018NoMissingSpaceAtx::new();
613
614        // All patterns at line start should be detected as malformed headings
615        // (matching markdownlint behavior)
616
617        // Lowercase single-hash - should be detected
618        assert!(
619            rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
620                .is_some(),
621            "#hello SHOULD be detected as malformed heading"
622        );
623        assert!(
624            rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
625            "#tag SHOULD be detected as malformed heading"
626        );
627        assert!(
628            rule.check_atx_heading_line("#hashtag", MarkdownFlavor::Standard)
629                .is_some(),
630            "#hashtag SHOULD be detected as malformed heading"
631        );
632        assert!(
633            rule.check_atx_heading_line("#javascript", MarkdownFlavor::Standard)
634                .is_some(),
635            "#javascript SHOULD be detected as malformed heading"
636        );
637
638        // Numeric patterns - should be detected (could be headings like "# 123")
639        assert!(
640            rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
641            "#123 SHOULD be detected as malformed heading"
642        );
643        assert!(
644            rule.check_atx_heading_line("#12345", MarkdownFlavor::Standard)
645                .is_some(),
646            "#12345 SHOULD be detected as malformed heading"
647        );
648        assert!(
649            rule.check_atx_heading_line("#29039)", MarkdownFlavor::Standard)
650                .is_some(),
651            "#29039) SHOULD be detected as malformed heading"
652        );
653
654        // Uppercase single-hash - should be detected
655        assert!(
656            rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
657                .is_some(),
658            "#Summary SHOULD be detected as malformed heading"
659        );
660        assert!(
661            rule.check_atx_heading_line("#Introduction", MarkdownFlavor::Standard)
662                .is_some(),
663            "#Introduction SHOULD be detected as malformed heading"
664        );
665        assert!(
666            rule.check_atx_heading_line("#API", MarkdownFlavor::Standard).is_some(),
667            "#API SHOULD be detected as malformed heading"
668        );
669
670        // Multi-hash patterns - should be detected
671        assert!(
672            rule.check_atx_heading_line("##introduction", MarkdownFlavor::Standard)
673                .is_some(),
674            "##introduction SHOULD be detected as malformed heading"
675        );
676        assert!(
677            rule.check_atx_heading_line("###section", MarkdownFlavor::Standard)
678                .is_some(),
679            "###section SHOULD be detected as malformed heading"
680        );
681        assert!(
682            rule.check_atx_heading_line("###fer", MarkdownFlavor::Standard)
683                .is_some(),
684            "###fer SHOULD be detected as malformed heading"
685        );
686        assert!(
687            rule.check_atx_heading_line("##123", MarkdownFlavor::Standard).is_some(),
688            "##123 SHOULD be detected as malformed heading"
689        );
690    }
691
692    #[test]
693    fn test_patterns_that_should_not_be_flagged() {
694        let rule = MD018NoMissingSpaceAtx::new();
695
696        // Just hashes (horizontal rule or empty)
697        assert!(rule.check_atx_heading_line("###", MarkdownFlavor::Standard).is_none());
698        assert!(rule.check_atx_heading_line("#", MarkdownFlavor::Standard).is_none());
699
700        // Content too short
701        assert!(rule.check_atx_heading_line("##a", MarkdownFlavor::Standard).is_none());
702
703        // Emphasis markers
704        assert!(
705            rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
706                .is_none()
707        );
708
709        // More than 6 hashes
710        assert!(
711            rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
712                .is_none()
713        );
714
715        // Proper headings with space
716        assert!(
717            rule.check_atx_heading_line("# Hello", MarkdownFlavor::Standard)
718                .is_none()
719        );
720        assert!(
721            rule.check_atx_heading_line("## World", MarkdownFlavor::Standard)
722                .is_none()
723        );
724        assert!(
725            rule.check_atx_heading_line("### Section", MarkdownFlavor::Standard)
726                .is_none()
727        );
728    }
729
730    #[test]
731    fn test_inline_issue_refs_not_at_line_start() {
732        let rule = MD018NoMissingSpaceAtx::new();
733
734        // Inline patterns (not at line start) are not checked by check_atx_heading_line
735        // because that function only checks lines that START with #
736
737        // These should return None because they don't start with #
738        assert!(
739            rule.check_atx_heading_line("See issue #123", MarkdownFlavor::Standard)
740                .is_none()
741        );
742        assert!(
743            rule.check_atx_heading_line("Check #trending on Twitter", MarkdownFlavor::Standard)
744                .is_none()
745        );
746        assert!(
747            rule.check_atx_heading_line("- fix: issue #29039", MarkdownFlavor::Standard)
748                .is_none()
749        );
750    }
751
752    #[test]
753    fn test_lowercase_patterns_full_check() {
754        // Integration test: verify lowercase patterns are flagged through full check() flow
755        let rule = MD018NoMissingSpaceAtx::new();
756
757        let content = "#hello\n\n#world\n\n#tag";
758        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
759        let result = rule.check(&ctx).unwrap();
760
761        assert_eq!(result.len(), 3, "All three lowercase patterns should be flagged");
762        assert_eq!(result[0].line, 1);
763        assert_eq!(result[1].line, 3);
764        assert_eq!(result[2].line, 5);
765    }
766
767    #[test]
768    fn test_numeric_patterns_full_check() {
769        // Integration test: verify numeric patterns are flagged through full check() flow
770        let rule = MD018NoMissingSpaceAtx::new();
771
772        let content = "#123\n\n#456\n\n#29039";
773        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
774        let result = rule.check(&ctx).unwrap();
775
776        assert_eq!(result.len(), 3, "All three numeric patterns should be flagged");
777    }
778
779    #[test]
780    fn test_fix_lowercase_patterns() {
781        // Verify fix() correctly handles lowercase patterns
782        let rule = MD018NoMissingSpaceAtx::new();
783
784        let content = "#hello\nSome text.\n\n#world";
785        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786        let fixed = rule.fix(&ctx).unwrap();
787
788        let expected = "# hello\nSome text.\n\n# world";
789        assert_eq!(fixed, expected);
790    }
791
792    #[test]
793    fn test_fix_numeric_patterns() {
794        // Verify fix() correctly handles numeric patterns
795        let rule = MD018NoMissingSpaceAtx::new();
796
797        let content = "#123\nContent.\n\n##456";
798        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
799        let fixed = rule.fix(&ctx).unwrap();
800
801        let expected = "# 123\nContent.\n\n## 456";
802        assert_eq!(fixed, expected);
803    }
804
805    #[test]
806    fn test_indented_malformed_headings() {
807        // Indented patterns are skipped to match markdownlint behavior.
808        // Markdownlint only flags patterns at column 1 (no indentation).
809        // Indented patterns are often multi-line link continuations or list content.
810        let rule = MD018NoMissingSpaceAtx::new();
811
812        // Indented patterns should NOT be flagged (matches markdownlint)
813        assert!(
814            rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
815                .is_none(),
816            "1-space indented #hello should be skipped"
817        );
818        assert!(
819            rule.check_atx_heading_line("  #hello", MarkdownFlavor::Standard)
820                .is_none(),
821            "2-space indented #hello should be skipped"
822        );
823        assert!(
824            rule.check_atx_heading_line("   #hello", MarkdownFlavor::Standard)
825                .is_none(),
826            "3-space indented #hello should be skipped"
827        );
828
829        // 4+ spaces is a code block, not checked by this function
830        // (code block detection happens at LintContext level)
831
832        // BUT patterns at column 1 (no indentation) ARE flagged
833        assert!(
834            rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
835                .is_some(),
836            "Non-indented #hello should be detected"
837        );
838    }
839
840    #[test]
841    fn test_tab_after_hash_is_valid() {
842        // Tab after hash is valid (acts like space)
843        let rule = MD018NoMissingSpaceAtx::new();
844
845        assert!(
846            rule.check_atx_heading_line("#\tHello", MarkdownFlavor::Standard)
847                .is_none(),
848            "Tab after # should be valid"
849        );
850        assert!(
851            rule.check_atx_heading_line("##\tWorld", MarkdownFlavor::Standard)
852                .is_none(),
853            "Tab after ## should be valid"
854        );
855    }
856
857    #[test]
858    fn test_mixed_case_patterns() {
859        let rule = MD018NoMissingSpaceAtx::new();
860
861        // All should be detected regardless of case
862        assert!(
863            rule.check_atx_heading_line("#hELLO", MarkdownFlavor::Standard)
864                .is_some()
865        );
866        assert!(
867            rule.check_atx_heading_line("#Hello", MarkdownFlavor::Standard)
868                .is_some()
869        );
870        assert!(
871            rule.check_atx_heading_line("#HELLO", MarkdownFlavor::Standard)
872                .is_some()
873        );
874        assert!(
875            rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
876                .is_some()
877        );
878    }
879
880    #[test]
881    fn test_unicode_lowercase() {
882        let rule = MD018NoMissingSpaceAtx::new();
883
884        // Unicode lowercase should be detected
885        assert!(
886            rule.check_atx_heading_line("#über", MarkdownFlavor::Standard).is_some(),
887            "Unicode lowercase #über should be detected"
888        );
889        assert!(
890            rule.check_atx_heading_line("#café", MarkdownFlavor::Standard).is_some(),
891            "Unicode lowercase #café should be detected"
892        );
893        assert!(
894            rule.check_atx_heading_line("#日本語", MarkdownFlavor::Standard)
895                .is_some(),
896            "Japanese #日本語 should be detected"
897        );
898    }
899
900    #[test]
901    fn test_matches_markdownlint_behavior() {
902        // Comprehensive test matching markdownlint's expected behavior
903        let rule = MD018NoMissingSpaceAtx::new();
904
905        let content = r#"#hello
906
907## world
908
909###fer
910
911#123
912
913#Tag
914"#;
915
916        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
917        let result = rule.check(&ctx).unwrap();
918
919        // markdownlint flags: #hello (line 1), ###fer (line 5), #123 (line 7), #Tag (line 9)
920        // ## world is correct (has space)
921        let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
922
923        assert!(flagged_lines.contains(&1), "#hello should be flagged");
924        assert!(!flagged_lines.contains(&3), "## world should NOT be flagged");
925        assert!(flagged_lines.contains(&5), "###fer should be flagged");
926        assert!(flagged_lines.contains(&7), "#123 should be flagged");
927        assert!(flagged_lines.contains(&9), "#Tag should be flagged");
928
929        assert_eq!(result.len(), 4, "Should have exactly 4 warnings");
930    }
931
932    #[test]
933    fn test_skip_frontmatter_yaml_comments() {
934        // YAML comments in frontmatter should NOT be flagged as missing space in headings
935        let rule = MD018NoMissingSpaceAtx::new();
936
937        let content = r#"---
938#reviewers:
939#- sig-api-machinery
940#another_comment: value
941title: Test Document
942---
943
944# Valid heading
945
946#invalid heading without space
947"#;
948
949        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
950        let result = rule.check(&ctx).unwrap();
951
952        // Should only flag line 10 (#invalid heading without space)
953        // Lines 2-4 are YAML comments in frontmatter and should be skipped
954        assert_eq!(
955            result.len(),
956            1,
957            "Should only flag the malformed heading outside frontmatter"
958        );
959        assert_eq!(result[0].line, 10, "Should flag line 10");
960    }
961
962    #[test]
963    fn test_skip_html_comments() {
964        // Content inside HTML comments should NOT be flagged
965        // This includes Jupyter cell markers like #%% in commented-out code blocks
966        let rule = MD018NoMissingSpaceAtx::new();
967
968        let content = r#"# Real Heading
969
970Some text.
971
972<!--
973```
974#%% Cell marker
975import matplotlib.pyplot as plt
976
977#%% Another cell
978data = [1, 2, 3]
979```
980-->
981
982More content.
983"#;
984
985        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
986        let result = rule.check(&ctx).unwrap();
987
988        // Should find no issues - the #%% markers are inside HTML comments
989        assert!(
990            result.is_empty(),
991            "Should not flag content inside HTML comments, found {} issues",
992            result.len()
993        );
994    }
995
996    #[test]
997    fn test_mkdocs_magiclink_skips_numeric_refs() {
998        // With magiclink config enabled, should skip MagicLink-style issue/PR refs (#123, #10, etc.)
999        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1000            magiclink: true,
1001            ..Default::default()
1002        });
1003
1004        // These numeric patterns should be SKIPPED with magiclink enabled
1005        assert!(
1006            rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_none(),
1007            "#10 should be skipped with magiclink config (MagicLink issue ref)"
1008        );
1009        assert!(
1010            rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_none(),
1011            "#123 should be skipped with magiclink config (MagicLink issue ref)"
1012        );
1013        assert!(
1014            rule.check_atx_heading_line("#10 discusses the issue", MarkdownFlavor::Standard)
1015                .is_none(),
1016            "#10 followed by text should be skipped with magiclink config"
1017        );
1018        assert!(
1019            rule.check_atx_heading_line("#37.", MarkdownFlavor::Standard).is_none(),
1020            "#37 followed by punctuation should be skipped with magiclink config"
1021        );
1022    }
1023
1024    #[test]
1025    fn test_mkdocs_magiclink_still_flags_non_numeric() {
1026        // With magiclink config enabled, should still flag non-numeric patterns
1027        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1028            magiclink: true,
1029            ..Default::default()
1030        });
1031
1032        // Non-numeric patterns should still be flagged even with magiclink enabled
1033        assert!(
1034            rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
1035                .is_some(),
1036            "#Summary should still be flagged with magiclink config"
1037        );
1038        assert!(
1039            rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
1040                .is_some(),
1041            "#hello should still be flagged with magiclink config"
1042        );
1043        assert!(
1044            rule.check_atx_heading_line("#10abc", MarkdownFlavor::Standard)
1045                .is_some(),
1046            "#10abc (mixed) should still be flagged with magiclink config"
1047        );
1048    }
1049
1050    #[test]
1051    fn test_mkdocs_magiclink_only_single_hash() {
1052        // MagicLink only uses single #, so ##10 should still be flagged
1053        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1054            magiclink: true,
1055            ..Default::default()
1056        });
1057
1058        assert!(
1059            rule.check_atx_heading_line("##10", MarkdownFlavor::Standard).is_some(),
1060            "##10 should be flagged with magiclink config (only single # is MagicLink)"
1061        );
1062        assert!(
1063            rule.check_atx_heading_line("###123", MarkdownFlavor::Standard)
1064                .is_some(),
1065            "###123 should be flagged with magiclink config"
1066        );
1067    }
1068
1069    #[test]
1070    fn test_standard_flavor_flags_numeric_refs() {
1071        // Standard flavor should still flag numeric patterns (no MagicLink awareness)
1072        let rule = MD018NoMissingSpaceAtx::new();
1073
1074        assert!(
1075            rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_some(),
1076            "#10 should be flagged in Standard flavor"
1077        );
1078        assert!(
1079            rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
1080            "#123 should be flagged in Standard flavor"
1081        );
1082    }
1083
1084    #[test]
1085    fn test_mkdocs_magiclink_full_check() {
1086        // Integration test: verify magiclink config skips MagicLink refs through full check() flow
1087        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1088            magiclink: true,
1089            ..Default::default()
1090        });
1091
1092        let content = r#"# PRs that are helpful for context
1093
1094#10 discusses the philosophy behind the project, and #37 shows a good example.
1095
1096#Summary
1097
1098##Introduction
1099"#;
1100
1101        // With magiclink enabled - should skip #10 and #37, but flag #Summary and ##Introduction
1102        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1103        let result = rule.check(&ctx).unwrap();
1104
1105        let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1106        assert!(
1107            !flagged_lines.contains(&3),
1108            "#10 should NOT be flagged with magiclink config"
1109        );
1110        assert!(
1111            flagged_lines.contains(&5),
1112            "#Summary SHOULD be flagged with magiclink config"
1113        );
1114        assert!(
1115            flagged_lines.contains(&7),
1116            "##Introduction SHOULD be flagged with magiclink config"
1117        );
1118    }
1119
1120    #[test]
1121    fn test_mkdocs_magiclink_fix_exact_output() {
1122        // Verify fix() produces exact expected output with magiclink config
1123        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1124            magiclink: true,
1125            ..Default::default()
1126        });
1127
1128        let content = "#10 discusses the issue.\n\n#Summary";
1129        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1130        let fixed = rule.fix(&ctx).unwrap();
1131
1132        // Exact expected output: #10 preserved, #Summary fixed
1133        let expected = "#10 discusses the issue.\n\n# Summary";
1134        assert_eq!(
1135            fixed, expected,
1136            "magiclink config fix should preserve MagicLink refs and fix non-numeric headings"
1137        );
1138    }
1139
1140    #[test]
1141    fn test_mkdocs_magiclink_edge_cases() {
1142        // Test various edge cases for MagicLink pattern matching
1143        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1144            magiclink: true,
1145            ..Default::default()
1146        });
1147
1148        // These should all be SKIPPED with magiclink config (valid MagicLink refs)
1149        // Note: #1 alone is skipped due to content length < 2, not MagicLink
1150        let valid_refs = [
1151            "#10",             // Two digits
1152            "#999999",         // Large number
1153            "#10 text after",  // Space then text
1154            "#10\ttext after", // Tab then text
1155            "#10.",            // Period after
1156            "#10,",            // Comma after
1157            "#10!",            // Exclamation after
1158            "#10?",            // Question mark after
1159            "#10)",            // Close paren after
1160            "#10]",            // Close bracket after
1161            "#10;",            // Semicolon after
1162            "#10:",            // Colon after
1163        ];
1164
1165        for ref_str in valid_refs {
1166            assert!(
1167                rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_none(),
1168                "{ref_str:?} should be skipped as MagicLink ref with magiclink config"
1169            );
1170        }
1171
1172        // These should still be FLAGGED with magiclink config (not valid MagicLink refs)
1173        let invalid_refs = [
1174            "#10abc",   // Alphanumeric continuation
1175            "#10a",     // Single alpha continuation
1176            "#abc10",   // Alpha prefix
1177            "#10ABC",   // Uppercase continuation
1178            "#Summary", // Pure text
1179            "#hello",   // Lowercase text
1180        ];
1181
1182        for ref_str in invalid_refs {
1183            assert!(
1184                rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_some(),
1185                "{ref_str:?} should be flagged with magiclink config (not a valid MagicLink ref)"
1186            );
1187        }
1188    }
1189
1190    #[test]
1191    fn test_mkdocs_magiclink_hyphenated_continuation() {
1192        // Hyphenated patterns like #10-related should still be flagged
1193        // because they're likely malformed headings, not MagicLink refs
1194        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1195            magiclink: true,
1196            ..Default::default()
1197        });
1198
1199        // Hyphen is not alphanumeric, so #10- would match as MagicLink
1200        // But #10-related has alphanumeric after the hyphen
1201        // The regex ^#\d+(?:\s|[^a-zA-Z0-9]|$) would match #10- but not consume -related
1202        // So #10-related would match (the -r part is after the match)
1203        assert!(
1204            rule.check_atx_heading_line("#10-", MarkdownFlavor::Standard).is_none(),
1205            "#10- should be skipped with magiclink config (hyphen is non-alphanumeric terminator)"
1206        );
1207    }
1208
1209    #[test]
1210    fn test_mkdocs_magiclink_standalone_number() {
1211        // #10 alone on a line (common in changelogs)
1212        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1213            magiclink: true,
1214            ..Default::default()
1215        });
1216
1217        let content = "See issue:\n\n#10\n\nFor details.";
1218        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1219        let result = rule.check(&ctx).unwrap();
1220
1221        // #10 alone should not be flagged with magiclink config
1222        assert!(
1223            result.is_empty(),
1224            "Standalone #10 should not be flagged with magiclink config"
1225        );
1226
1227        // Verify fix doesn't modify it
1228        let fixed = rule.fix(&ctx).unwrap();
1229        assert_eq!(fixed, content, "fix() should not modify standalone MagicLink ref");
1230    }
1231
1232    #[test]
1233    fn test_standard_flavor_flags_all_numeric() {
1234        // Standard flavor should flag ALL numeric patterns (no MagicLink awareness)
1235        // Note: #1 is skipped because content length < 2 (existing behavior)
1236        let rule = MD018NoMissingSpaceAtx::new();
1237
1238        let numeric_patterns = ["#10", "#123", "#999999", "#10 text"];
1239
1240        for pattern in numeric_patterns {
1241            assert!(
1242                rule.check_atx_heading_line(pattern, MarkdownFlavor::Standard).is_some(),
1243                "{pattern:?} should be flagged in Standard flavor"
1244            );
1245        }
1246
1247        // #1 is skipped due to content length < 2 rule (not MagicLink related)
1248        assert!(
1249            rule.check_atx_heading_line("#1", MarkdownFlavor::Standard).is_none(),
1250            "#1 should be skipped (content too short, existing behavior)"
1251        );
1252    }
1253
1254    #[test]
1255    fn test_mkdocs_vs_standard_fix_comparison() {
1256        // Compare fix output between magiclink enabled and disabled
1257        let content = "#10 is an issue\n#Summary";
1258        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1259
1260        // With magiclink: preserves #10, fixes #Summary
1261        let rule_magiclink = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1262            magiclink: true,
1263            ..Default::default()
1264        });
1265        let fixed_magiclink = rule_magiclink.fix(&ctx).unwrap();
1266        assert_eq!(fixed_magiclink, "#10 is an issue\n# Summary");
1267
1268        // Without magiclink: fixes both
1269        let rule_default = MD018NoMissingSpaceAtx::new();
1270        let fixed_default = rule_default.fix(&ctx).unwrap();
1271        assert_eq!(fixed_default, "# 10 is an issue\n# Summary");
1272    }
1273
1274    // ==================== Tags config tests ====================
1275
1276    #[test]
1277    fn test_tags_config_standard_flavor() {
1278        // tags = true with standard flavor should skip tag patterns
1279        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1280            magiclink: false,
1281            tags: Some(true),
1282        });
1283
1284        let content = "#tag\n\n#project/active\n\n##Introduction";
1285        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1286        let result = rule.check(&ctx).unwrap();
1287
1288        let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1289        assert!(!flagged_lines.contains(&1), "#tag should be skipped with tags = true");
1290        assert!(
1291            !flagged_lines.contains(&3),
1292            "#project/active should be skipped with tags = true"
1293        );
1294        assert!(flagged_lines.contains(&5), "##Introduction should still be flagged");
1295    }
1296
1297    #[test]
1298    fn test_tags_config_fix_standard_flavor() {
1299        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1300            magiclink: false,
1301            tags: Some(true),
1302        });
1303
1304        let content = "#tag\n\n##Introduction";
1305        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1306        let fixed = rule.fix(&ctx).unwrap();
1307        assert_eq!(fixed, "#tag\n\n## Introduction");
1308    }
1309
1310    #[test]
1311    fn test_tags_config_disabled_obsidian_flavor() {
1312        // tags = false with Obsidian flavor should flag tag patterns
1313        let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1314            magiclink: false,
1315            tags: Some(false),
1316        });
1317
1318        let content = "#tag\n\n#project/active";
1319        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1320        let result = rule.check(&ctx).unwrap();
1321
1322        assert_eq!(
1323            result.len(),
1324            2,
1325            "tags = false should flag tag patterns even in Obsidian"
1326        );
1327    }
1328
1329    #[test]
1330    fn test_tags_config_default_follows_flavor() {
1331        // Unset tags should default based on flavor
1332        let rule = MD018NoMissingSpaceAtx::new(); // tags: None
1333
1334        // Standard: should flag
1335        let content = "#tag";
1336        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1337        let result = rule.check(&ctx).unwrap();
1338        assert!(!result.is_empty(), "Default standard should flag #tag");
1339
1340        // Obsidian: should skip
1341        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1342        let result = rule.check(&ctx).unwrap();
1343        assert!(result.is_empty(), "Default Obsidian should skip #tag");
1344    }
1345
1346    // ==================== Obsidian flavor tests ====================
1347
1348    #[test]
1349    fn test_obsidian_tag_skips_simple_tags() {
1350        // Obsidian flavor should skip tag syntax (#tagname)
1351        let rule = MD018NoMissingSpaceAtx::new();
1352
1353        // Simple tags should be SKIPPED in Obsidian flavor
1354        assert!(
1355            rule.check_atx_heading_line("#hey", MarkdownFlavor::Obsidian).is_none(),
1356            "#hey should be skipped in Obsidian flavor (tag syntax)"
1357        );
1358        assert!(
1359            rule.check_atx_heading_line("#tag", MarkdownFlavor::Obsidian).is_none(),
1360            "#tag should be skipped in Obsidian flavor"
1361        );
1362        assert!(
1363            rule.check_atx_heading_line("#hello", MarkdownFlavor::Obsidian)
1364                .is_none(),
1365            "#hello should be skipped in Obsidian flavor"
1366        );
1367        assert!(
1368            rule.check_atx_heading_line("#myTag", MarkdownFlavor::Obsidian)
1369                .is_none(),
1370            "#myTag should be skipped in Obsidian flavor"
1371        );
1372    }
1373
1374    #[test]
1375    fn test_obsidian_tag_skips_complex_tags() {
1376        // Obsidian tags can have hyphens, underscores, numbers, and slashes
1377        let rule = MD018NoMissingSpaceAtx::new();
1378
1379        // Complex tag patterns should be SKIPPED in Obsidian flavor
1380        assert!(
1381            rule.check_atx_heading_line("#project/active", MarkdownFlavor::Obsidian)
1382                .is_none(),
1383            "#project/active should be skipped in Obsidian flavor (nested tag)"
1384        );
1385        assert!(
1386            rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1387                .is_none(),
1388            "#my-tag should be skipped in Obsidian flavor"
1389        );
1390        assert!(
1391            rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1392                .is_none(),
1393            "#my_tag should be skipped in Obsidian flavor"
1394        );
1395        assert!(
1396            rule.check_atx_heading_line("#tag2023", MarkdownFlavor::Obsidian)
1397                .is_none(),
1398            "#tag2023 should be skipped in Obsidian flavor"
1399        );
1400        assert!(
1401            rule.check_atx_heading_line("#project/sub/task", MarkdownFlavor::Obsidian)
1402                .is_none(),
1403            "#project/sub/task should be skipped in Obsidian flavor"
1404        );
1405    }
1406
1407    #[test]
1408    fn test_obsidian_tag_with_trailing_content() {
1409        // Tags followed by whitespace should still be skipped
1410        let rule = MD018NoMissingSpaceAtx::new();
1411
1412        assert!(
1413            rule.check_atx_heading_line("#hey ", MarkdownFlavor::Obsidian).is_none(),
1414            "#hey followed by space should be skipped"
1415        );
1416        assert!(
1417            rule.check_atx_heading_line("#tag some text", MarkdownFlavor::Obsidian)
1418                .is_none(),
1419            "#tag followed by text should be skipped"
1420        );
1421    }
1422
1423    #[test]
1424    fn test_obsidian_tag_still_flags_multi_hash() {
1425        // Obsidian tags only use single #, so ##tag should still be flagged
1426        let rule = MD018NoMissingSpaceAtx::new();
1427
1428        assert!(
1429            rule.check_atx_heading_line("##tag", MarkdownFlavor::Obsidian).is_some(),
1430            "##tag should be flagged in Obsidian flavor (only single # is a tag)"
1431        );
1432        assert!(
1433            rule.check_atx_heading_line("###hello", MarkdownFlavor::Obsidian)
1434                .is_some(),
1435            "###hello should be flagged in Obsidian flavor"
1436        );
1437    }
1438
1439    #[test]
1440    fn test_obsidian_tag_numeric_still_flagged() {
1441        // Tags cannot start with a number in Obsidian, so #123 should still be flagged
1442        let rule = MD018NoMissingSpaceAtx::new();
1443
1444        assert!(
1445            rule.check_atx_heading_line("#123", MarkdownFlavor::Obsidian).is_some(),
1446            "#123 should be flagged in Obsidian flavor (tags cannot start with digit)"
1447        );
1448        assert!(
1449            rule.check_atx_heading_line("#10", MarkdownFlavor::Obsidian).is_some(),
1450            "#10 should be flagged in Obsidian flavor"
1451        );
1452    }
1453
1454    #[test]
1455    fn test_obsidian_flavor_full_check() {
1456        // Integration test: verify Obsidian flavor skips tags through full check() flow
1457        let rule = MD018NoMissingSpaceAtx::new();
1458
1459        let content = r#"# Real Heading
1460
1461#hey this is a tag
1462
1463#project/active also a tag
1464
1465##Introduction
1466
1467#123
1468"#;
1469
1470        // Obsidian flavor - should skip #hey and #project/active, but flag ##Introduction and #123
1471        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1472        let result = rule.check(&ctx).unwrap();
1473
1474        let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1475        assert!(
1476            !flagged_lines.contains(&3),
1477            "#hey should NOT be flagged in Obsidian flavor"
1478        );
1479        assert!(
1480            !flagged_lines.contains(&5),
1481            "#project/active should NOT be flagged in Obsidian flavor"
1482        );
1483        assert!(
1484            flagged_lines.contains(&7),
1485            "##Introduction SHOULD be flagged in Obsidian flavor"
1486        );
1487        assert!(flagged_lines.contains(&9), "#123 SHOULD be flagged in Obsidian flavor");
1488    }
1489
1490    #[test]
1491    fn test_obsidian_flavor_fix_exact_output() {
1492        // Verify fix() produces exact expected output
1493        let rule = MD018NoMissingSpaceAtx::new();
1494
1495        // In Obsidian flavor, all single-# patterns that look like tags are preserved
1496        // Only multi-hash patterns (##tag) and numeric patterns (#123) are fixed
1497        let content = "#hey is a tag.\n\n##Introduction";
1498        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1499        let fixed = rule.fix(&ctx).unwrap();
1500
1501        // Exact expected output: #hey preserved, ##Introduction fixed
1502        let expected = "#hey is a tag.\n\n## Introduction";
1503        assert_eq!(
1504            fixed, expected,
1505            "Obsidian fix should preserve tags and fix multi-hash headings"
1506        );
1507    }
1508
1509    #[test]
1510    fn test_standard_flavor_flags_obsidian_tags() {
1511        // Standard flavor should flag patterns that look like Obsidian tags
1512        let rule = MD018NoMissingSpaceAtx::new();
1513
1514        assert!(
1515            rule.check_atx_heading_line("#hey", MarkdownFlavor::Standard).is_some(),
1516            "#hey should be flagged in Standard flavor"
1517        );
1518        assert!(
1519            rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
1520            "#tag should be flagged in Standard flavor"
1521        );
1522        assert!(
1523            rule.check_atx_heading_line("#project/active", MarkdownFlavor::Standard)
1524                .is_some(),
1525            "#project/active should be flagged in Standard flavor"
1526        );
1527    }
1528
1529    #[test]
1530    fn test_obsidian_vs_standard_fix_comparison() {
1531        // Compare fix output between Obsidian and Standard flavors
1532        let rule = MD018NoMissingSpaceAtx::new();
1533
1534        // Use a pattern that clearly shows the difference:
1535        // - #hey tag: single-hash, looks like Obsidian tag followed by text
1536        // - ##Introduction: multi-hash, clearly a malformed heading
1537        let content = "#hey tag\n##Introduction";
1538
1539        // Obsidian: preserves #hey tag (single-hash tag syntax), fixes ##Introduction
1540        let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1541        let fixed_obsidian = rule.fix(&ctx_obsidian).unwrap();
1542        assert_eq!(fixed_obsidian, "#hey tag\n## Introduction");
1543
1544        // Standard: fixes both (no tag awareness)
1545        let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1546        let fixed_standard = rule.fix(&ctx_standard).unwrap();
1547        assert_eq!(fixed_standard, "# hey tag\n## Introduction");
1548    }
1549
1550    #[test]
1551    fn test_obsidian_tag_edge_cases() {
1552        // Test various edge cases for Obsidian tag pattern matching
1553        let rule = MD018NoMissingSpaceAtx::new();
1554
1555        // Valid Obsidian tags - should be SKIPPED
1556        let valid_tags = [
1557            "#a",      // Minimum valid tag (but note: may be skipped due to length < 2)
1558            "#tag",    // Simple tag
1559            "#Tag",    // Capitalized tag
1560            "#TAG",    // Uppercase tag
1561            "#my-tag", // Hyphenated tag
1562            "#my_tag", // Underscored tag
1563            "#tag123", // Tag with trailing numbers
1564            "#a1",     // Short tag with number
1565            "#日本語", // Unicode tag
1566            "#über",   // Unicode with umlaut
1567        ];
1568
1569        for tag in valid_tags {
1570            // Note: #a and #a1 might be skipped due to content length < 2 rule
1571            let result = rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian);
1572            // We don't assert is_none because some might be skipped by other rules
1573            // Just verify the pattern doesn't cause errors
1574            let _ = result;
1575        }
1576
1577        // Invalid tags (start with digit) - should be FLAGGED
1578        let invalid_tags = ["#1tag", "#123", "#2023-project"];
1579
1580        for tag in invalid_tags {
1581            assert!(
1582                rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_some(),
1583                "{tag:?} should be flagged in Obsidian flavor (starts with digit)"
1584            );
1585        }
1586    }
1587
1588    #[test]
1589    fn test_obsidian_tag_alone_on_line() {
1590        // Standalone tag on a line (common in Obsidian notes)
1591        let rule = MD018NoMissingSpaceAtx::new();
1592
1593        let content = "Some text\n\n#todo\n\nMore text.";
1594        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1595        let result = rule.check(&ctx).unwrap();
1596
1597        // #todo alone should not be flagged in Obsidian flavor
1598        assert!(
1599            result.is_empty(),
1600            "Standalone #todo should not be flagged in Obsidian flavor"
1601        );
1602
1603        // Verify fix doesn't modify it
1604        let fixed = rule.fix(&ctx).unwrap();
1605        assert_eq!(fixed, content, "fix() should not modify standalone Obsidian tag");
1606    }
1607
1608    #[test]
1609    fn test_obsidian_deeply_nested_tags() {
1610        // Obsidian supports deeply nested tags with /
1611        let rule = MD018NoMissingSpaceAtx::new();
1612
1613        let nested_tags = [
1614            "#a/b",
1615            "#a/b/c",
1616            "#project/2023/q1/task",
1617            "#work/meetings/weekly",
1618            "#life/health/exercise/running",
1619        ];
1620
1621        for tag in nested_tags {
1622            assert!(
1623                rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1624                "{tag:?} should be skipped in Obsidian flavor (nested tag)"
1625            );
1626        }
1627    }
1628
1629    #[test]
1630    fn test_obsidian_unicode_tags() {
1631        // Obsidian supports Unicode in tags
1632        let rule = MD018NoMissingSpaceAtx::new();
1633
1634        let unicode_tags = [
1635            "#日本語", // Japanese
1636            "#中文",   // Chinese
1637            "#한국어", // Korean
1638            "#über",   // German umlaut
1639            "#café",   // French accent
1640            "#ñoño",   // Spanish tilde
1641            "#Москва", // Russian
1642            "#αβγ",    // Greek
1643        ];
1644
1645        for tag in unicode_tags {
1646            assert!(
1647                rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1648                "{tag:?} should be skipped in Obsidian flavor (Unicode tag)"
1649            );
1650        }
1651    }
1652
1653    #[test]
1654    fn test_obsidian_tags_with_special_endings() {
1655        // Tags followed by various punctuation
1656        let rule = MD018NoMissingSpaceAtx::new();
1657
1658        // Tags followed by space then text should be skipped
1659        assert!(
1660            rule.check_atx_heading_line("#tag followed by text", MarkdownFlavor::Obsidian)
1661                .is_none(),
1662            "#tag followed by text should be skipped"
1663        );
1664
1665        // Tag at end of line (no trailing space)
1666        let content = "#todo";
1667        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1668        let result = rule.check(&ctx).unwrap();
1669        assert!(result.is_empty(), "#todo at end of line should be skipped");
1670    }
1671
1672    #[test]
1673    fn test_obsidian_combined_with_other_skip_contexts() {
1674        // Verify Obsidian tags in code blocks and HTML comments are still skipped
1675        let rule = MD018NoMissingSpaceAtx::new();
1676
1677        // Tag inside code block (should be skipped by code block logic, not Obsidian logic)
1678        let content = "```\n#todo\n```";
1679        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1680        let result = rule.check(&ctx).unwrap();
1681        assert!(result.is_empty(), "Tag in code block should be skipped");
1682
1683        // Tag inside HTML comment
1684        let content = "<!-- #todo -->";
1685        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1686        let result = rule.check(&ctx).unwrap();
1687        assert!(result.is_empty(), "Tag in HTML comment should be skipped");
1688    }
1689
1690    #[test]
1691    fn test_obsidian_boundary_cases() {
1692        // Test boundary cases for Obsidian tag detection
1693        let rule = MD018NoMissingSpaceAtx::new();
1694
1695        // Minimum valid tag (single char after #)
1696        // Note: #a alone might be skipped by content length < 2 rule
1697        // #ab should definitely be recognized as a tag
1698        assert!(
1699            rule.check_atx_heading_line("#ab", MarkdownFlavor::Obsidian).is_none(),
1700            "#ab should be skipped in Obsidian flavor"
1701        );
1702
1703        // Tag with underscore
1704        assert!(
1705            rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1706                .is_none(),
1707            "#my_tag should be skipped"
1708        );
1709
1710        // Tag with hyphen
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 mixed case
1718        assert!(
1719            rule.check_atx_heading_line("#MyTag", MarkdownFlavor::Obsidian)
1720                .is_none(),
1721            "#MyTag should be skipped"
1722        );
1723
1724        // All caps (could be a tag or acronym)
1725        assert!(
1726            rule.check_atx_heading_line("#TODO", MarkdownFlavor::Obsidian).is_none(),
1727            "#TODO should be skipped in Obsidian flavor"
1728        );
1729    }
1730}