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