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