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