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