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