Skip to main content

rumdl_lib/rules/
md018_no_missing_space_atx.rs

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