Skip to main content

rumdl_lib/rules/
md022_blanks_around_headings.rs

1/// Rule MD022: Headings should be surrounded by blank lines
2///
3/// See [docs/md022.md](../../docs/md022.md) for full documentation, configuration, and examples.
4use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::mkdocs_attr_list::is_block_attribute_line;
6use crate::utils::pandoc;
7use crate::utils::range_utils::calculate_heading_range;
8use toml;
9
10pub(crate) mod md022_config;
11use md022_config::MD022Config;
12
13/// Whether a trimmed line begins with a list marker: `-`, `*`, or `+`, or one or more
14/// digits followed by `.` or `)`, in each case followed by a space or the end of the line.
15///
16/// This is a parse-stable substitute for the per-line `list_item` flag when deciding whether
17/// a heading is followed by a list. The flag depends on surrounding block context and can flip
18/// when a blank line is inserted above the heading, which would make the blank-below fix
19/// non-idempotent; the syntactic shape of the following line does not change.
20fn starts_with_list_marker(trimmed: &str) -> bool {
21    let bytes = trimmed.as_bytes();
22    match bytes.first() {
23        Some(b'-' | b'*' | b'+') => matches!(bytes.get(1), None | Some(b' ')),
24        Some(b'0'..=b'9') => {
25            let mut i = 0;
26            while bytes.get(i).is_some_and(u8::is_ascii_digit) {
27                i += 1;
28            }
29            matches!(bytes.get(i), Some(b'.' | b')')) && matches!(bytes.get(i + 1), None | Some(b' '))
30        }
31        _ => false,
32    }
33}
34
35///
36/// This rule enforces consistent spacing around headings to improve document readability
37/// and visual structure.
38///
39/// ## Purpose
40///
41/// - **Readability**: Blank lines create visual separation, making headings stand out
42/// - **Parsing**: Many Markdown parsers require blank lines around headings for proper rendering
43/// - **Consistency**: Creates a uniform document style throughout
44/// - **Focus**: Helps readers identify and focus on section transitions
45///
46/// ## Configuration Options
47///
48/// The rule supports customizing the number of blank lines required:
49///
50/// ```yaml
51/// MD022:
52///   lines_above: 1  # Number of blank lines required above headings (default: 1)
53///   lines_below: 1  # Number of blank lines required below headings (default: 1)
54/// ```
55///
56/// ## Examples
57///
58/// ### Correct (with default configuration)
59///
60/// ```markdown
61/// Regular paragraph text.
62///
63/// # Heading 1
64///
65/// Content under heading 1.
66///
67/// ## Heading 2
68///
69/// More content here.
70/// ```
71///
72/// ### Incorrect (with default configuration)
73///
74/// ```markdown
75/// Regular paragraph text.
76/// # Heading 1
77/// Content under heading 1.
78/// ## Heading 2
79/// More content here.
80/// ```
81///
82/// ## Special Cases
83///
84/// This rule handles several special cases:
85///
86/// - **First Heading**: The first heading in a document doesn't require blank lines above
87///   if it appears at the very start of the document
88/// - **Front Matter**: YAML front matter is detected and skipped
89/// - **Code Blocks**: Headings inside code blocks are ignored
90/// - **Document Start/End**: Adjusts requirements for headings at the beginning or end of a document
91///
92/// ## Fix Behavior
93///
94/// When applying automatic fixes, this rule:
95/// - Adds the required number of blank lines above headings
96/// - Adds the required number of blank lines below headings
97/// - Preserves document structure and existing content
98///
99/// ## Performance Considerations
100///
101/// The rule is optimized for performance with:
102/// - Efficient line counting algorithms
103/// - Proper handling of front matter
104/// - Smart code block detection
105///
106#[derive(Clone, Default)]
107pub struct MD022BlanksAroundHeadings {
108    config: MD022Config,
109}
110
111impl MD022BlanksAroundHeadings {
112    /// Create a new instance of the rule with default values:
113    /// lines_above = 1, lines_below = 1
114    pub fn new() -> Self {
115        Self {
116            config: MD022Config::default(),
117        }
118    }
119
120    /// Create with custom numbers of blank lines (applies to all heading levels)
121    pub fn with_values(lines_above: usize, lines_below: usize) -> Self {
122        use md022_config::HeadingLevelConfig;
123        Self {
124            config: MD022Config {
125                lines_above: HeadingLevelConfig::scalar(lines_above),
126                lines_below: HeadingLevelConfig::scalar(lines_below),
127                allowed_at_start: true,
128            },
129        }
130    }
131
132    pub fn from_config_struct(config: MD022Config) -> Self {
133        Self { config }
134    }
135
136    /// Fix a document by adding appropriate blank lines around headings
137    fn fix_content(&self, ctx: &crate::lint_context::LintContext) -> String {
138        // Content is normalized to LF at I/O boundary
139        let line_ending = "\n";
140        let had_trailing_newline = ctx.content.ends_with('\n');
141        let is_pandoc = ctx.flavor.is_pandoc_compatible();
142        let mut result = Vec::new();
143        let mut skip_count: usize = 0;
144
145        let heading_at_start_idx = {
146            let mut found_non_transparent = false;
147            ctx.lines.iter().enumerate().find_map(|(i, line)| {
148                // Only count valid headings (skip malformed ones like `#NoSpace`)
149                if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
150                    Some(i)
151                } else {
152                    // HTML comments and blank lines are "transparent" - they don't count as content
153                    // that would prevent a heading from being "at document start"
154                    if !line.is_blank && !line.in_html_comment && !line.in_mdx_comment {
155                        let trimmed = line.content(ctx.content).trim();
156                        // Check for single-line HTML comments too
157                        if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
158                            // Transparent - HTML comment
159                        } else if line.in_kramdown_extension_block || line.is_kramdown_block_ial {
160                            // Transparent - Kramdown preamble line
161                        } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
162                            // Transparent - Pandoc/Quarto div marker in Pandoc-compatible flavor
163                        } else {
164                            found_non_transparent = true;
165                        }
166                    }
167                    None
168                }
169            })
170        };
171
172        for (i, line_info) in ctx.lines.iter().enumerate() {
173            if skip_count > 0 {
174                skip_count -= 1;
175                continue;
176            }
177            let line = line_info.content(ctx.content);
178
179            if line_info.in_code_block {
180                result.push(line.to_string());
181                continue;
182            }
183
184            // Check if it's a heading
185            if let Some(heading) = &line_info.heading {
186                // Skip invalid headings (e.g., `#NoSpace` which lacks required space after #)
187                if !heading.is_valid {
188                    result.push(line.to_string());
189                    continue;
190                }
191
192                // If rule is disabled for this heading line, keep original
193                let line_num = i + 1;
194                if ctx.inline_config().is_rule_disabled("MD022", line_num) {
195                    result.push(line.to_string());
196                    // For Setext headings, also add the underline unchanged
197                    if matches!(
198                        heading.style,
199                        crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
200                    ) && i + 1 < ctx.lines.len()
201                    {
202                        result.push(ctx.lines[i + 1].content(ctx.content).to_string());
203                        skip_count += 1;
204                    }
205                    continue;
206                }
207
208                // This is a heading line (ATX or Setext content)
209                let is_first_heading = Some(i) == heading_at_start_idx;
210                let heading_level = heading.level as usize;
211
212                // Count existing blank lines above in the result, skipping HTML comments, IAL, and Quarto div markers
213                let mut blank_lines_above = 0;
214                let mut check_idx = result.len();
215                while check_idx > 0 {
216                    let prev_line = &result[check_idx - 1];
217                    let trimmed = prev_line.trim();
218                    if trimmed.is_empty() {
219                        blank_lines_above += 1;
220                        check_idx -= 1;
221                    } else if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
222                        // Skip HTML comments - they are transparent for blank line counting
223                        check_idx -= 1;
224                    } else if is_block_attribute_line(trimmed, ctx.flavor) {
225                        // Skip kramdown IAL - they are attached to headings and transparent
226                        check_idx -= 1;
227                    } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
228                        // Skip Pandoc/Quarto div markers — transparent for blank line counting
229                        check_idx -= 1;
230                    } else {
231                        break;
232                    }
233                }
234
235                // Determine how many blank lines we need above
236                let requirement_above = self.config.lines_above.get_for_level(heading_level);
237                let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
238                    0
239                } else {
240                    requirement_above.required_count().unwrap_or(0)
241                };
242
243                // Add missing blank lines above if needed
244                while blank_lines_above < needed_blanks_above {
245                    result.push(String::new());
246                    blank_lines_above += 1;
247                }
248
249                // Add the heading line
250                result.push(line.to_string());
251
252                // Determine base index for checking lines below
253                let mut effective_end_idx = i;
254
255                // For Setext headings, also add the underline immediately
256                if matches!(
257                    heading.style,
258                    crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
259                ) {
260                    // Add the underline (next line)
261                    if i + 1 < ctx.lines.len() {
262                        result.push(ctx.lines[i + 1].content(ctx.content).to_string());
263                        skip_count += 1; // Skip the underline in the main loop
264                        effective_end_idx = i + 1;
265                    }
266                }
267
268                // Add any kramdown IAL lines that immediately follow the heading
269                // These are part of the heading element and should not be separated
270                let mut ial_count = 0;
271                while effective_end_idx + 1 < ctx.lines.len() {
272                    let next_line = &ctx.lines[effective_end_idx + 1];
273                    let next_trimmed = next_line.content(ctx.content).trim();
274                    if is_block_attribute_line(next_trimmed, ctx.flavor) {
275                        result.push(next_trimmed.to_string());
276                        effective_end_idx += 1;
277                        ial_count += 1;
278                    } else {
279                        break;
280                    }
281                }
282
283                // Now check blank lines below the heading (including underline and IAL)
284                let mut blank_lines_below = 0;
285                let mut next_content_line_idx = None;
286                for j in (effective_end_idx + 1)..ctx.lines.len() {
287                    if ctx.lines[j].is_blank {
288                        blank_lines_below += 1;
289                    } else {
290                        next_content_line_idx = Some(j);
291                        break;
292                    }
293                }
294
295                // Check if the next non-blank line is special (code fence or list item)
296                let next_is_special = if let Some(idx) = next_content_line_idx {
297                    let next_line = &ctx.lines[idx];
298                    next_line.list_item.is_some() || {
299                        let trimmed = next_line.content(ctx.content).trim();
300                        (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
301                            && (trimmed.len() == 3
302                                || (trimmed.len() > 3
303                                    && trimmed
304                                        .chars()
305                                        .nth(3)
306                                        .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
307                    }
308                } else {
309                    false
310                };
311
312                // Add missing blank lines below if needed
313                let requirement_below = self.config.lines_below.get_for_level(heading_level);
314                let needed_blanks_below = if next_is_special {
315                    0
316                } else {
317                    requirement_below.required_count().unwrap_or(0)
318                };
319                if blank_lines_below < needed_blanks_below {
320                    for _ in 0..(needed_blanks_below - blank_lines_below) {
321                        result.push(String::new());
322                    }
323                }
324
325                // Skip the IAL lines in the main loop since we already added them
326                skip_count += ial_count;
327            } else {
328                // Regular line - just add it
329                result.push(line.to_string());
330            }
331        }
332
333        let joined = result.join(line_ending);
334
335        // Preserve original trailing newline behavior
336        // Content is normalized to LF at I/O boundary
337        if had_trailing_newline && !joined.ends_with('\n') {
338            format!("{joined}{line_ending}")
339        } else if !had_trailing_newline && joined.ends_with('\n') {
340            // Remove trailing newline if original didn't have one
341            joined[..joined.len() - 1].to_string()
342        } else {
343            joined
344        }
345    }
346}
347
348impl Rule for MD022BlanksAroundHeadings {
349    fn name(&self) -> &'static str {
350        "MD022"
351    }
352
353    fn description(&self) -> &'static str {
354        "Headings should be surrounded by blank lines"
355    }
356
357    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
358        let mut result = Vec::new();
359
360        // Skip if empty document
361        if ctx.lines.is_empty() {
362            return Ok(result);
363        }
364
365        // Content is normalized to LF at I/O boundary
366        let line_ending = "\n";
367        let is_pandoc = ctx.flavor.is_pandoc_compatible();
368
369        let heading_at_start_idx = {
370            let mut found_non_transparent = false;
371            ctx.lines.iter().enumerate().find_map(|(i, line)| {
372                // Only count valid headings (skip malformed ones like `#NoSpace`)
373                if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
374                    Some(i)
375                } else {
376                    // HTML comments and blank lines are "transparent" - they don't count as content
377                    // that would prevent a heading from being "at document start"
378                    if !line.is_blank && !line.in_html_comment && !line.in_mdx_comment {
379                        let trimmed = line.content(ctx.content).trim();
380                        // Check for single-line HTML comments too
381                        if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
382                            // Transparent - HTML comment
383                        } else if line.in_kramdown_extension_block || line.is_kramdown_block_ial {
384                            // Transparent - Kramdown preamble line
385                        } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
386                            // Transparent - Pandoc/Quarto div marker in Pandoc-compatible flavor
387                        } else {
388                            found_non_transparent = true;
389                        }
390                    }
391                    None
392                }
393            })
394        };
395
396        // Collect all headings first to batch process
397        let mut heading_violations = Vec::new();
398        let mut processed_headings = std::collections::HashSet::new();
399
400        for (line_num, line_info) in ctx.lines.iter().enumerate() {
401            // Skip if already processed or not a heading
402            if processed_headings.contains(&line_num) || line_info.heading.is_none() {
403                continue;
404            }
405
406            // Skip headings inside PyMdown blocks (/// ... ///) - MkDocs flavor only
407            if line_info.in_pymdown_block {
408                continue;
409            }
410
411            let heading = line_info.heading.as_ref().unwrap();
412
413            // Skip invalid headings (e.g., `#NoSpace` which lacks required space after #)
414            if !heading.is_valid {
415                continue;
416            }
417
418            let heading_level = heading.level as usize;
419
420            // Note: Setext underline lines have heading=None, so they're already
421            // skipped by the check at line 351. No additional check needed here.
422
423            processed_headings.insert(line_num);
424
425            // Check if this heading is at document start
426            let is_first_heading = Some(line_num) == heading_at_start_idx;
427
428            // Get configured blank line requirements for this heading level
429            let required_above_count = self.config.lines_above.get_for_level(heading_level).required_count();
430            let required_below_count = self.config.lines_below.get_for_level(heading_level).required_count();
431
432            // Count blank lines above if needed
433            let should_check_above =
434                required_above_count.is_some() && line_num > 0 && (!is_first_heading || !self.config.allowed_at_start);
435            if should_check_above {
436                let mut blank_lines_above = 0;
437                let mut hit_frontmatter_end = false;
438                for j in (0..line_num).rev() {
439                    let line_content = ctx.lines[j].content(ctx.content);
440                    let trimmed = line_content.trim();
441                    if ctx.lines[j].is_blank {
442                        blank_lines_above += 1;
443                    } else if ctx.lines[j].in_html_comment
444                        || ctx.lines[j].in_mdx_comment
445                        || (trimmed.starts_with("<!--") && trimmed.ends_with("-->"))
446                    {
447                        // Skip HTML comments - they are transparent for blank line counting
448                        continue;
449                    } else if is_block_attribute_line(trimmed, ctx.flavor) {
450                        // Skip kramdown IAL - they are attached to headings and transparent for blank line counting
451                        continue;
452                    } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
453                        // Skip Pandoc/Quarto div markers — transparent for blank line counting
454                        continue;
455                    } else if ctx.lines[j].in_front_matter {
456                        // Skip frontmatter - first heading after frontmatter doesn't need blank line above
457                        // Note: We only check in_front_matter flag, NOT the string "---", because
458                        // a standalone "---" is a horizontal rule and should NOT exempt headings
459                        // from requiring blank lines above
460                        hit_frontmatter_end = true;
461                        break;
462                    } else {
463                        break;
464                    }
465                }
466                let required = required_above_count.unwrap();
467                if !hit_frontmatter_end && blank_lines_above < required {
468                    let needed_blanks = required - blank_lines_above;
469                    heading_violations.push((line_num, "above", needed_blanks, heading_level));
470                }
471            }
472
473            // Determine the effective last line of the heading
474            let mut effective_last_line = if matches!(
475                heading.style,
476                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
477            ) {
478                line_num + 1 // For Setext, include the underline
479            } else {
480                line_num
481            };
482
483            // Extend effective_last_line to include any kramdown IAL lines immediately following
484            // IAL lines like `{: .class #id}` are part of the heading element
485            while effective_last_line + 1 < ctx.lines.len() {
486                let next_line = &ctx.lines[effective_last_line + 1];
487                let next_trimmed = next_line.content(ctx.content).trim();
488                if is_block_attribute_line(next_trimmed, ctx.flavor) {
489                    effective_last_line += 1;
490                } else {
491                    break;
492                }
493            }
494
495            // Check blank lines below
496            if effective_last_line < ctx.lines.len() - 1 {
497                // Find next non-blank line, skipping transparent elements (blank lines, HTML comments, Quarto div markers)
498                let mut next_non_blank_idx = effective_last_line + 1;
499                while next_non_blank_idx < ctx.lines.len() {
500                    let check_line = &ctx.lines[next_non_blank_idx];
501                    let check_trimmed = check_line.content(ctx.content).trim();
502                    if check_line.is_blank {
503                        next_non_blank_idx += 1;
504                    } else if check_line.in_html_comment
505                        || check_line.in_mdx_comment
506                        || (check_trimmed.starts_with("<!--") && check_trimmed.ends_with("-->"))
507                    {
508                        // Skip HTML comments - they are transparent for blank line counting
509                        next_non_blank_idx += 1;
510                    } else if is_pandoc && (pandoc::is_div_open(check_trimmed) || pandoc::is_div_close(check_trimmed)) {
511                        // Skip Pandoc/Quarto div markers — transparent for blank line counting
512                        next_non_blank_idx += 1;
513                    } else {
514                        break;
515                    }
516                }
517
518                // If we've reached end of document (after skipping transparent elements), no blank needed
519                if next_non_blank_idx >= ctx.lines.len() {
520                    // End of document - no blank line needed after heading
521                    continue;
522                }
523
524                // Check if next line is a code fence or list item
525                let next_line_is_special = {
526                    let next_line = &ctx.lines[next_non_blank_idx];
527                    let next_trimmed = next_line.content(ctx.content).trim();
528
529                    // Check for code fence
530                    let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
531                        && (next_trimmed.len() == 3
532                            || (next_trimmed.len() > 3
533                                && next_trimmed
534                                    .chars()
535                                    .nth(3)
536                                    .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
537
538                    // Check for list item. The parser's `list_item` flag for a following
539                    // marker is not parse-stable here: a sibling list directly above the
540                    // heading can leak list context onto this line, so inserting the blank
541                    // line above the heading flips the flag (e.g. `2. ` counts as a list
542                    // item in one pass and not the next). Detect the marker syntactically as
543                    // well, so the guard - and therefore the fix - stays idempotent.
544                    let is_list_item = next_line.list_item.is_some() || starts_with_list_marker(next_trimmed);
545
546                    is_code_fence || is_list_item
547                };
548
549                // Only generate warning if next line is NOT a code fence or list item
550                if !next_line_is_special && let Some(required) = required_below_count {
551                    // Count blank lines below (counting only blank lines, not skipped transparent lines)
552                    let mut blank_lines_below = 0;
553                    for k in (effective_last_line + 1)..next_non_blank_idx {
554                        if ctx.lines[k].is_blank {
555                            blank_lines_below += 1;
556                        }
557                    }
558
559                    if blank_lines_below < required {
560                        let needed_blanks = required - blank_lines_below;
561                        heading_violations.push((line_num, "below", needed_blanks, heading_level));
562                    }
563                }
564            }
565        }
566
567        // Generate warnings for all violations
568        for (heading_line, position, needed_blanks, heading_level) in heading_violations {
569            let heading_display_line = heading_line + 1; // 1-indexed for display
570            let line_info = &ctx.lines[heading_line];
571
572            // Calculate precise character range for the heading
573            let (start_line, start_col, end_line, end_col) =
574                calculate_heading_range(heading_display_line, line_info.content(ctx.content));
575
576            // Each requirement is resolved inside the arm that uses it. A
577            // requirement can be unlimited (a negative config value such as
578            // `lines_above: -1`), in which case it has no required count and
579            // never produces a violation for its own position. Resolving both
580            // up front panicked whenever one position was unlimited and the
581            // other reported.
582            let (message, insertion_point) = match position {
583                "above" => {
584                    let Some(required_above_count) =
585                        self.config.lines_above.get_for_level(heading_level).required_count()
586                    else {
587                        continue;
588                    };
589                    (
590                        format!(
591                            "Expected {} blank {} above heading",
592                            required_above_count,
593                            if required_above_count == 1 { "line" } else { "lines" }
594                        ),
595                        heading_line, // Insert before the heading line
596                    )
597                }
598                "below" => {
599                    let Some(required_below_count) =
600                        self.config.lines_below.get_for_level(heading_level).required_count()
601                    else {
602                        continue;
603                    };
604                    // For Setext headings, insert after the underline
605                    let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
606                        matches!(
607                            h.style,
608                            crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
609                        )
610                    }) {
611                        heading_line + 2
612                    } else {
613                        heading_line + 1
614                    };
615
616                    (
617                        format!(
618                            "Expected {} blank {} below heading",
619                            required_below_count,
620                            if required_below_count == 1 { "line" } else { "lines" }
621                        ),
622                        insert_after,
623                    )
624                }
625                _ => continue,
626            };
627
628            // Calculate byte range for insertion
629            let byte_range = if insertion_point == 0 && position == "above" {
630                // Insert at beginning of document (only for "above" case at line 0)
631                0..0
632            } else if position == "above" && insertion_point > 0 {
633                // For "above", insert at the start of the heading line
634                ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
635            } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
636                // For "below", insert after the line
637                let line_idx = insertion_point - 1;
638                let line_end_offset = if line_idx + 1 < ctx.lines.len() {
639                    ctx.lines[line_idx + 1].byte_offset
640                } else {
641                    ctx.content.len()
642                };
643                line_end_offset..line_end_offset
644            } else {
645                // Insert at end of file
646                let content_len = ctx.content.len();
647                content_len..content_len
648            };
649
650            result.push(LintWarning {
651                rule_name: Some(self.name().to_string()),
652                message,
653                line: start_line,
654                column: start_col,
655                end_line,
656                end_column: end_col,
657                severity: Severity::Warning,
658                fix: Some(Fix::new(byte_range, line_ending.repeat(needed_blanks))),
659            });
660        }
661
662        Ok(result)
663    }
664
665    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
666        if ctx.content.is_empty() {
667            return Ok(ctx.content.to_string());
668        }
669
670        // Use a consolidated fix that avoids adding multiple blank lines
671        let fixed = self.fix_content(ctx);
672
673        Ok(fixed)
674    }
675
676    /// Get the category of this rule for selective processing
677    fn category(&self) -> RuleCategory {
678        RuleCategory::Heading
679    }
680
681    /// Check if this rule should be skipped
682    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
683        // Fast path: check if document likely has headings
684        if ctx.content.is_empty() || !ctx.likely_has_headings() {
685            return true;
686        }
687        // Verify headings actually exist
688        ctx.lines.iter().all(|line| line.heading.is_none())
689    }
690
691    fn as_any(&self) -> &dyn std::any::Any {
692        self
693    }
694
695    crate::impl_rule_config_methods!(MD022Config);
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701    use crate::lint_context::LintContext;
702
703    #[test]
704    fn test_valid_headings() {
705        let rule = MD022BlanksAroundHeadings::default();
706        let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
707        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
708        let result = rule.check(&ctx).unwrap();
709        assert!(result.is_empty());
710    }
711
712    #[test]
713    fn test_missing_blank_above() {
714        let rule = MD022BlanksAroundHeadings::default();
715        let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
716        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
717        let result = rule.check(&ctx).unwrap();
718        assert_eq!(result.len(), 0); // No warning for first heading
719
720        let fixed = rule.fix(&ctx).unwrap();
721
722        // Test for the ability to handle the content without breaking it
723        // Don't check for exact string equality which may break with implementation changes
724        assert!(fixed.contains("# Heading 1"));
725        assert!(fixed.contains("Some content."));
726        assert!(fixed.contains("## Heading 2"));
727        assert!(fixed.contains("More content."));
728    }
729
730    #[test]
731    fn test_missing_blank_below() {
732        let rule = MD022BlanksAroundHeadings::default();
733        let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
734        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
735        let result = rule.check(&ctx).unwrap();
736        assert_eq!(result.len(), 1);
737        assert_eq!(result[0].line, 2);
738
739        // Test the fix
740        let fixed = rule.fix(&ctx).unwrap();
741        assert!(fixed.contains("# Heading 1\n\nSome content"));
742    }
743
744    #[test]
745    fn test_missing_blank_above_and_below() {
746        let rule = MD022BlanksAroundHeadings::default();
747        let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
748        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749        let result = rule.check(&ctx).unwrap();
750        assert_eq!(result.len(), 3); // Missing blanks: below first heading, above second heading, below second heading
751
752        // Test the fix
753        let fixed = rule.fix(&ctx).unwrap();
754        assert!(fixed.contains("# Heading 1\n\nSome content"));
755        assert!(fixed.contains("Some content.\n\n## Heading 2"));
756        assert!(fixed.contains("## Heading 2\n\nMore content"));
757    }
758
759    #[test]
760    fn test_fix_headings() {
761        let rule = MD022BlanksAroundHeadings::default();
762        let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
763        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
764        let result = rule.fix(&ctx).unwrap();
765
766        let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
767        assert_eq!(result, expected);
768    }
769
770    #[test]
771    fn test_consecutive_headings_pattern() {
772        let rule = MD022BlanksAroundHeadings::default();
773        let content = "# Heading 1\n## Heading 2\n### Heading 3";
774        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
775        let result = rule.fix(&ctx).unwrap();
776
777        // Using more specific assertions to check the structure
778        let lines: Vec<&str> = result.lines().collect();
779        assert!(!lines.is_empty());
780
781        // Find the positions of the headings
782        let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
783        let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
784        let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
785
786        // Verify blank lines between headings
787        assert!(
788            h2_pos > h1_pos + 1,
789            "Should have at least one blank line after first heading"
790        );
791        assert!(
792            h3_pos > h2_pos + 1,
793            "Should have at least one blank line after second heading"
794        );
795
796        // Verify there's a blank line between h1 and h2
797        assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
798
799        // Verify there's a blank line between h2 and h3
800        assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
801    }
802
803    #[test]
804    fn test_blanks_around_setext_headings() {
805        let rule = MD022BlanksAroundHeadings::default();
806        let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
807        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
808        let result = rule.fix(&ctx).unwrap();
809
810        // Check that the fix follows requirements without being too rigid about the exact output format
811        let lines: Vec<&str> = result.lines().collect();
812
813        // Verify key elements are present
814        assert!(result.contains("Heading 1"));
815        assert!(result.contains("========="));
816        assert!(result.contains("Some content."));
817        assert!(result.contains("Heading 2"));
818        assert!(result.contains("---------"));
819        assert!(result.contains("More content."));
820
821        // Verify structure ensures blank lines are added after headings
822        let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
823        let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
824        assert!(
825            some_content_idx > heading1_marker_idx + 1,
826            "Should have a blank line after the first heading"
827        );
828
829        let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
830        let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
831        assert!(
832            more_content_idx > heading2_marker_idx + 1,
833            "Should have a blank line after the second heading"
834        );
835
836        // Verify that the fixed content has no warnings
837        let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
838        let fixed_warnings = rule.check(&fixed_ctx).unwrap();
839        assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
840    }
841
842    #[test]
843    fn test_fix_specific_blank_line_cases() {
844        let rule = MD022BlanksAroundHeadings::default();
845
846        // Case 1: Testing consecutive headings
847        let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
848        let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
849        let result1 = rule.fix(&ctx1).unwrap();
850        // Verify structure rather than exact content as the fix implementation may vary
851        assert!(result1.contains("# Heading 1"));
852        assert!(result1.contains("## Heading 2"));
853        assert!(result1.contains("### Heading 3"));
854        // Ensure each heading has a blank line after it
855        let lines: Vec<&str> = result1.lines().collect();
856        let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
857        let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
858        assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
859        assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
860
861        // Case 2: Headings with content
862        let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
863        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
864        let result2 = rule.fix(&ctx2).unwrap();
865        // Verify structure
866        assert!(result2.contains("# Heading 1"));
867        assert!(result2.contains("Content under heading 1"));
868        assert!(result2.contains("## Heading 2"));
869        // Check spacing
870        let lines2: Vec<&str> = result2.lines().collect();
871        let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
872        let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
873        assert!(
874            lines2[h1_pos2 + 1].trim().is_empty(),
875            "Should have a blank line after heading 1"
876        );
877
878        // Case 3: Multiple consecutive headings with blank lines preserved
879        let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
880        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
881        let result3 = rule.fix(&ctx3).unwrap();
882        // Just verify it doesn't crash and properly formats headings
883        assert!(result3.contains("# Heading 1"));
884        assert!(result3.contains("## Heading 2"));
885        assert!(result3.contains("### Heading 3"));
886        assert!(result3.contains("Content"));
887    }
888
889    #[test]
890    fn test_fix_preserves_existing_blank_lines() {
891        let rule = MD022BlanksAroundHeadings::new();
892        let content = "# Title
893
894## Section 1
895
896Content here.
897
898## Section 2
899
900More content.
901### Missing Blank Above
902
903Even more content.
904
905## Section 3
906
907Final content.";
908
909        let expected = "# Title
910
911## Section 1
912
913Content here.
914
915## Section 2
916
917More content.
918
919### Missing Blank Above
920
921Even more content.
922
923## Section 3
924
925Final content.";
926
927        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
928        let result = rule.fix_content(&ctx);
929        assert_eq!(
930            result, expected,
931            "Fix should only add missing blank lines, never remove existing ones"
932        );
933    }
934
935    #[test]
936    fn test_fix_preserves_trailing_newline() {
937        let rule = MD022BlanksAroundHeadings::new();
938
939        // Test with trailing newline
940        let content_with_newline = "# Title\nContent here.\n";
941        let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
942        let result = rule.fix(&ctx).unwrap();
943        assert!(result.ends_with('\n'), "Should preserve trailing newline");
944
945        // Test without trailing newline
946        let content_without_newline = "# Title\nContent here.";
947        let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
948        let result = rule.fix(&ctx).unwrap();
949        assert!(
950            !result.ends_with('\n'),
951            "Should not add trailing newline if original didn't have one"
952        );
953    }
954
955    #[test]
956    fn test_fix_does_not_add_blank_lines_before_lists() {
957        let rule = MD022BlanksAroundHeadings::new();
958        let content = "## Configuration\n\nThis rule has the following configuration options:\n\n- `option1`: Description of option 1.\n- `option2`: Description of option 2.\n\n## Another Section\n\nSome content here.";
959
960        let expected = "## Configuration\n\nThis rule has the following configuration options:\n\n- `option1`: Description of option 1.\n- `option2`: Description of option 2.\n\n## Another Section\n\nSome content here.";
961
962        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
963        let result = rule.fix_content(&ctx);
964        assert_eq!(result, expected, "Fix should not add blank lines before lists");
965    }
966
967    #[test]
968    fn test_fix_idempotent_when_heading_follows_a_list_and_precedes_a_marker() {
969        // Regression: a list directly above a heading (`- a\n# H`) makes the parser tag the
970        // following marker line (`2. `) as a list item, so the blank-below was skipped.
971        // Inserting the blank above closed that list and flipped the flag, so a second pass
972        // then added the blank below — a non-idempotent fix. The marker is now recognized
973        // syntactically, so the heading-followed-by-list decision is stable across passes.
974        let rule = MD022BlanksAroundHeadings::default();
975        let content = "- a\n# H\n2. ";
976        for flavor in [
977            crate::config::MarkdownFlavor::Standard,
978            crate::config::MarkdownFlavor::MkDocs,
979            crate::config::MarkdownFlavor::MDX,
980        ] {
981            let ctx1 = LintContext::new(content, flavor, None);
982            let fixed1 = rule.fix(&ctx1).unwrap();
983            let ctx2 = LintContext::new(&fixed1, flavor, None);
984            let fixed2 = rule.fix(&ctx2).unwrap();
985            assert_eq!(fixed1, fixed2, "MD022 fix must be idempotent (flavor={flavor:?})");
986        }
987    }
988
989    #[test]
990    fn test_per_level_configuration_no_blank_above_h1() {
991        use md022_config::HeadingLevelConfig;
992
993        // Configure: no blank above H1, 1 blank above H2-H6
994        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
995            lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
996            lines_below: HeadingLevelConfig::scalar(1),
997            allowed_at_start: false, // Disable special handling for first heading
998        });
999
1000        // H1 without blank above should be OK
1001        let content = "Some text\n# Heading 1\n\nMore text";
1002        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1003        let warnings = rule.check(&ctx).unwrap();
1004        assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
1005
1006        // H2 without blank above should trigger warning
1007        let content = "Some text\n## Heading 2\n\nMore text";
1008        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1009        let warnings = rule.check(&ctx).unwrap();
1010        assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
1011        assert!(warnings[0].message.contains("above"));
1012    }
1013
1014    #[test]
1015    fn test_unlimited_above_with_limited_below_does_not_panic() {
1016        use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1017
1018        // `lines_above: -1` means "any number of blank lines above", so that
1019        // side has no required count. A violation on the *other* side used to
1020        // resolve both counts up front and panic on the unlimited one.
1021        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1022            lines_above: HeadingLevelConfig::scalar_requirement(HeadingBlankRequirement::Unlimited),
1023            lines_below: HeadingLevelConfig::scalar(1),
1024            allowed_at_start: false,
1025        });
1026
1027        // "## Banana" has no blank line below it, so a "below" violation fires.
1028        let content = "# Title\n\nText\n## Banana\nText\n";
1029        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1030
1031        let warnings = rule.check(&ctx).expect("check must not fail");
1032
1033        assert!(
1034            warnings.iter().any(|w| w.message.contains("below")),
1035            "expected a 'below' violation, got: {:?}",
1036            warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1037        );
1038        assert!(
1039            !warnings.iter().any(|w| w.message.contains("above")),
1040            "an unlimited 'above' requirement must never report: {:?}",
1041            warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1042        );
1043    }
1044
1045    #[test]
1046    fn test_unlimited_below_with_limited_above_does_not_panic() {
1047        use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1048
1049        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1050            lines_above: HeadingLevelConfig::scalar(1),
1051            lines_below: HeadingLevelConfig::scalar_requirement(HeadingBlankRequirement::Unlimited),
1052            allowed_at_start: false,
1053        });
1054
1055        // "## Banana" has no blank line above it, so an "above" violation fires.
1056        let content = "# Title\n\nText\n## Banana\n\nText\n";
1057        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1058
1059        let warnings = rule.check(&ctx).expect("check must not fail");
1060
1061        assert!(
1062            warnings.iter().any(|w| w.message.contains("above")),
1063            "expected an 'above' violation, got: {:?}",
1064            warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1065        );
1066        assert!(
1067            !warnings.iter().any(|w| w.message.contains("below")),
1068            "an unlimited 'below' requirement must never report: {:?}",
1069            warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1070        );
1071    }
1072
1073    #[test]
1074    fn test_per_level_configuration_different_requirements() {
1075        use md022_config::HeadingLevelConfig;
1076
1077        // Configure: 0 blank above H1, 1 above H2-H3, 2 above H4-H6
1078        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1079            lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
1080            lines_below: HeadingLevelConfig::scalar(1),
1081            allowed_at_start: false,
1082        });
1083
1084        let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
1085        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1086        let warnings = rule.check(&ctx).unwrap();
1087
1088        // Should have no warnings - all headings satisfy their level-specific requirements
1089        assert_eq!(
1090            warnings.len(),
1091            0,
1092            "All headings should satisfy level-specific requirements"
1093        );
1094    }
1095
1096    #[test]
1097    fn test_per_level_configuration_violations() {
1098        use md022_config::HeadingLevelConfig;
1099
1100        // Configure: H4 needs 2 blanks above
1101        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1102            lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
1103            lines_below: HeadingLevelConfig::scalar(1),
1104            allowed_at_start: false,
1105        });
1106
1107        // H4 with only 1 blank above should trigger warning
1108        let content = "Text\n\n#### Heading 4\n\nMore text";
1109        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1110        let warnings = rule.check(&ctx).unwrap();
1111
1112        assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
1113        assert!(warnings[0].message.contains("2 blank lines above"));
1114    }
1115
1116    #[test]
1117    fn test_per_level_fix_different_levels() {
1118        use md022_config::HeadingLevelConfig;
1119
1120        // Configure: 0 blank above H1, 1 above H2, 2 above H3+
1121        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1122            lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
1123            lines_below: HeadingLevelConfig::scalar(1),
1124            allowed_at_start: false,
1125        });
1126
1127        let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
1128        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1129        let fixed = rule.fix(&ctx).unwrap();
1130
1131        // Verify structure: H1 gets 0 blanks above, H2 gets 1, H3 gets 2
1132        assert!(fixed.contains("Text\n# H1\n\nContent"));
1133        assert!(fixed.contains("Content\n\n## H2\n\nContent"));
1134        assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
1135    }
1136
1137    #[test]
1138    fn test_per_level_below_configuration() {
1139        use md022_config::HeadingLevelConfig;
1140
1141        // Configure: different blank line requirements below headings
1142        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1143            lines_above: HeadingLevelConfig::scalar(1),
1144            lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), // H1 needs 2 blanks below
1145            allowed_at_start: true,
1146        });
1147
1148        // H1 with only 1 blank below should trigger warning
1149        let content = "# Heading 1\n\nSome text";
1150        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1151        let warnings = rule.check(&ctx).unwrap();
1152
1153        assert_eq!(
1154            warnings.len(),
1155            1,
1156            "H1 with insufficient blanks below should trigger warning"
1157        );
1158        assert!(warnings[0].message.contains("2 blank lines below"));
1159    }
1160
1161    #[test]
1162    fn test_scalar_configuration_still_works() {
1163        use md022_config::HeadingLevelConfig;
1164
1165        // Ensure scalar configuration still works (backward compatibility)
1166        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1167            lines_above: HeadingLevelConfig::scalar(2),
1168            lines_below: HeadingLevelConfig::scalar(2),
1169            allowed_at_start: false,
1170        });
1171
1172        let content = "Text\n# H1\nContent\n## H2\nContent";
1173        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1174        let warnings = rule.check(&ctx).unwrap();
1175
1176        // All headings should need 2 blanks above and below
1177        assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
1178    }
1179
1180    #[test]
1181    fn test_unlimited_configuration_skips_requirements() {
1182        use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1183
1184        // H1 can have any number of blank lines above/below; others require defaults
1185        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1186            lines_above: HeadingLevelConfig::per_level_requirements([
1187                HeadingBlankRequirement::unlimited(),
1188                HeadingBlankRequirement::limited(1),
1189                HeadingBlankRequirement::limited(1),
1190                HeadingBlankRequirement::limited(1),
1191                HeadingBlankRequirement::limited(1),
1192                HeadingBlankRequirement::limited(1),
1193            ]),
1194            lines_below: HeadingLevelConfig::per_level_requirements([
1195                HeadingBlankRequirement::unlimited(),
1196                HeadingBlankRequirement::limited(1),
1197                HeadingBlankRequirement::limited(1),
1198                HeadingBlankRequirement::limited(1),
1199                HeadingBlankRequirement::limited(1),
1200                HeadingBlankRequirement::limited(1),
1201            ]),
1202            allowed_at_start: false,
1203        });
1204
1205        let content = "# H1\nParagraph\n## H2\nParagraph";
1206        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1207        let warnings = rule.check(&ctx).unwrap();
1208
1209        // H1 has no blanks above/below but is unlimited; H2 should get violations
1210        assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1211        assert!(
1212            warnings.iter().all(|w| w.line >= 3),
1213            "Warnings should target later headings"
1214        );
1215
1216        // Fixing should insert blanks around H2 but leave H1 untouched
1217        let fixed = rule.fix(&ctx).unwrap();
1218        assert!(
1219            fixed.starts_with("# H1\nParagraph\n\n## H2"),
1220            "H1 should remain unchanged"
1221        );
1222    }
1223
1224    #[test]
1225    fn test_html_comment_transparency() {
1226        // HTML comments are transparent for blank line counting
1227        // A heading following a blank line + HTML comment should be valid
1228        // Verified with markdownlint: no MD022 warning for this pattern
1229        let rule = MD022BlanksAroundHeadings::default();
1230
1231        // Pattern: content, blank line, HTML comment, heading
1232        // The blank line before the HTML comment counts for the heading
1233        let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
1234        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1235        let warnings = rule.check(&ctx).unwrap();
1236        assert!(
1237            warnings.is_empty(),
1238            "HTML comment is transparent - blank line above it counts for heading"
1239        );
1240
1241        // Multi-line HTML comment is also transparent
1242        let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
1243        let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
1244        let warnings_multiline = rule.check(&ctx_multiline).unwrap();
1245        assert!(
1246            warnings_multiline.is_empty(),
1247            "Multi-line HTML comment is also transparent"
1248        );
1249    }
1250
1251    #[test]
1252    fn test_frontmatter_transparency() {
1253        // Frontmatter is transparent for MD022 - heading can appear immediately after
1254        // Verified with markdownlint: no MD022 warning for heading after frontmatter
1255        let rule = MD022BlanksAroundHeadings::default();
1256
1257        // Heading immediately after frontmatter closing ---
1258        let content = "---\ntitle: Test\n---\n# First heading";
1259        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1260        let warnings = rule.check(&ctx).unwrap();
1261        assert!(
1262            warnings.is_empty(),
1263            "Frontmatter is transparent - heading can appear immediately after"
1264        );
1265
1266        // Heading with blank line after frontmatter is also valid
1267        let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
1268        let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1269        let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1270        assert!(
1271            warnings_with_blank.is_empty(),
1272            "Heading with blank line after frontmatter should also be valid"
1273        );
1274
1275        // TOML frontmatter (+++...+++) is also transparent
1276        let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
1277        let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
1278        let warnings_toml = rule.check(&ctx_toml).unwrap();
1279        assert!(
1280            warnings_toml.is_empty(),
1281            "TOML frontmatter is also transparent for MD022"
1282        );
1283    }
1284
1285    #[test]
1286    fn test_horizontal_rule_not_treated_as_frontmatter() {
1287        // Issue #238: Horizontal rules (---) should NOT be treated as frontmatter.
1288        // A heading after a horizontal rule MUST have a blank line above it.
1289        let rule = MD022BlanksAroundHeadings::default();
1290
1291        // Case 1: Heading immediately after horizontal rule - SHOULD warn
1292        let content = "Some content\n\n---\n# Heading after HR";
1293        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1294        let warnings = rule.check(&ctx).unwrap();
1295        assert!(
1296            !warnings.is_empty(),
1297            "Heading after horizontal rule without blank line SHOULD trigger MD022"
1298        );
1299        assert!(
1300            warnings.iter().any(|w| w.line == 4),
1301            "Warning should be on line 4 (the heading line)"
1302        );
1303
1304        // Case 2: Heading with blank line after HR - should NOT warn
1305        let content_with_blank = "Some content\n\n---\n\n# Heading after HR";
1306        let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1307        let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1308        assert!(
1309            warnings_with_blank.is_empty(),
1310            "Heading with blank line after HR should not trigger MD022"
1311        );
1312
1313        // Case 3: HR at start of document followed by heading - SHOULD warn
1314        let content_hr_start = "---\n# Heading";
1315        let ctx_hr_start = LintContext::new(content_hr_start, crate::config::MarkdownFlavor::Standard, None);
1316        let warnings_hr_start = rule.check(&ctx_hr_start).unwrap();
1317        assert!(
1318            !warnings_hr_start.is_empty(),
1319            "Heading after HR at document start SHOULD trigger MD022"
1320        );
1321
1322        // Case 4: Multiple HRs then heading - SHOULD warn
1323        let content_multi_hr = "Content\n\n---\n\n---\n# Heading";
1324        let ctx_multi_hr = LintContext::new(content_multi_hr, crate::config::MarkdownFlavor::Standard, None);
1325        let warnings_multi_hr = rule.check(&ctx_multi_hr).unwrap();
1326        assert!(
1327            !warnings_multi_hr.is_empty(),
1328            "Heading after multiple HRs without blank line SHOULD trigger MD022"
1329        );
1330    }
1331
1332    #[test]
1333    fn test_all_hr_styles_require_blank_before_heading() {
1334        // CommonMark defines HRs as 3+ of -, *, or _ with optional spaces between
1335        let rule = MD022BlanksAroundHeadings::default();
1336
1337        // All valid HR styles that should trigger MD022 when followed by heading without blank
1338        let hr_styles = [
1339            "---", "***", "___", "- - -", "* * *", "_ _ _", "----", "****", "____", "- - - -",
1340            "-  -  -", // Multiple spaces between
1341            "  ---",   // 2 spaces indent (valid per CommonMark)
1342            "   ---",  // 3 spaces indent (valid per CommonMark)
1343        ];
1344
1345        for hr in hr_styles {
1346            let content = format!("Content\n\n{hr}\n# Heading");
1347            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1348            let warnings = rule.check(&ctx).unwrap();
1349            assert!(
1350                !warnings.is_empty(),
1351                "HR style '{hr}' followed by heading should trigger MD022"
1352            );
1353        }
1354    }
1355
1356    #[test]
1357    fn test_setext_heading_after_hr() {
1358        // Setext headings after HR should also require blank line
1359        let rule = MD022BlanksAroundHeadings::default();
1360
1361        // Setext h1 after HR without blank - SHOULD warn
1362        let content = "Content\n\n---\nHeading\n======";
1363        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1364        let warnings = rule.check(&ctx).unwrap();
1365        assert!(
1366            !warnings.is_empty(),
1367            "Setext heading after HR without blank should trigger MD022"
1368        );
1369
1370        // Setext h2 after HR without blank - SHOULD warn
1371        let content_h2 = "Content\n\n---\nHeading\n------";
1372        let ctx_h2 = LintContext::new(content_h2, crate::config::MarkdownFlavor::Standard, None);
1373        let warnings_h2 = rule.check(&ctx_h2).unwrap();
1374        assert!(
1375            !warnings_h2.is_empty(),
1376            "Setext h2 after HR without blank should trigger MD022"
1377        );
1378
1379        // With blank line - should NOT warn
1380        let content_ok = "Content\n\n---\n\nHeading\n======";
1381        let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1382        let warnings_ok = rule.check(&ctx_ok).unwrap();
1383        assert!(
1384            warnings_ok.is_empty(),
1385            "Setext heading with blank after HR should not warn"
1386        );
1387    }
1388
1389    #[test]
1390    fn test_hr_in_code_block_not_treated_as_hr() {
1391        // HR syntax inside code blocks should be ignored
1392        let rule = MD022BlanksAroundHeadings::default();
1393
1394        // HR inside fenced code block - heading after code block needs blank line check
1395        // but the "---" inside is NOT an HR
1396        let content = "```\n---\n```\n# Heading";
1397        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1398        let warnings = rule.check(&ctx).unwrap();
1399        // The heading is after a code block fence, not after an HR
1400        // This tests that we don't confuse code block content with HRs
1401        assert!(!warnings.is_empty(), "Heading after code block still needs blank line");
1402
1403        // With blank after code block - should be fine
1404        let content_ok = "```\n---\n```\n\n# Heading";
1405        let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1406        let warnings_ok = rule.check(&ctx_ok).unwrap();
1407        assert!(
1408            warnings_ok.is_empty(),
1409            "Heading with blank after code block should not warn"
1410        );
1411    }
1412
1413    #[test]
1414    fn test_hr_in_html_comment_not_treated_as_hr() {
1415        // HR syntax inside HTML comments should be ignored
1416        let rule = MD022BlanksAroundHeadings::default();
1417
1418        // "---" inside HTML comment is NOT an HR
1419        let content = "<!-- \n---\n -->\n# Heading";
1420        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1421        let warnings = rule.check(&ctx).unwrap();
1422        // HTML comments are transparent, so heading after comment at doc start is OK
1423        assert!(
1424            warnings.is_empty(),
1425            "HR inside HTML comment should be ignored - heading after comment is OK"
1426        );
1427    }
1428
1429    #[test]
1430    fn test_invalid_hr_not_triggering() {
1431        // These should NOT be recognized as HRs per CommonMark
1432        let rule = MD022BlanksAroundHeadings::default();
1433
1434        let invalid_hrs = [
1435            "    ---", // 4+ spaces is code block, not HR
1436            "\t---",   // Tab indent makes it code block
1437            "--",      // Only 2 dashes
1438            "**",      // Only 2 asterisks
1439            "__",      // Only 2 underscores
1440            "-*-",     // Mixed characters
1441            "---a",    // Extra character at end
1442            "a---",    // Extra character at start
1443        ];
1444
1445        for invalid in invalid_hrs {
1446            // These are NOT HRs, so if followed by heading, the heading behavior depends
1447            // on what the content actually is (code block, paragraph, etc.)
1448            let content = format!("Content\n\n{invalid}\n# Heading");
1449            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1450            // We're just verifying the HR detection is correct
1451            // The actual warning behavior depends on what the "invalid HR" is parsed as
1452            let _ = rule.check(&ctx);
1453        }
1454    }
1455
1456    #[test]
1457    fn test_frontmatter_vs_horizontal_rule_distinction() {
1458        // Ensure we correctly distinguish between frontmatter delimiters and standalone HRs
1459        let rule = MD022BlanksAroundHeadings::default();
1460
1461        // Frontmatter followed by content, then HR, then heading
1462        // The HR here is NOT frontmatter, so heading needs blank line
1463        let content = "---\ntitle: Test\n---\n\nSome content\n\n---\n# Heading after HR";
1464        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1465        let warnings = rule.check(&ctx).unwrap();
1466        assert!(
1467            !warnings.is_empty(),
1468            "HR after frontmatter content should still require blank line before heading"
1469        );
1470
1471        // Same but with blank line after HR - should be fine
1472        let content_ok = "---\ntitle: Test\n---\n\nSome content\n\n---\n\n# Heading after HR";
1473        let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1474        let warnings_ok = rule.check(&ctx_ok).unwrap();
1475        assert!(
1476            warnings_ok.is_empty(),
1477            "HR with blank line before heading should not warn"
1478        );
1479    }
1480
1481    // ==================== Kramdown IAL Tests ====================
1482
1483    #[test]
1484    fn test_kramdown_ial_after_heading_no_warning() {
1485        // Issue #259: IAL immediately after heading should not trigger MD022
1486        let rule = MD022BlanksAroundHeadings::default();
1487        let content = "## Table of Contents\n{: .hhc-toc-heading}\n\nSome content here.";
1488        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1489        let warnings = rule.check(&ctx).unwrap();
1490
1491        assert!(
1492            warnings.is_empty(),
1493            "IAL after heading should not require blank line between them: {warnings:?}"
1494        );
1495    }
1496
1497    #[test]
1498    fn test_kramdown_ial_with_class() {
1499        let rule = MD022BlanksAroundHeadings::default();
1500        let content = "# Heading\n{:.highlight}\n\nContent.";
1501        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1502        let warnings = rule.check(&ctx).unwrap();
1503
1504        assert!(warnings.is_empty(), "IAL with class should be part of heading");
1505    }
1506
1507    #[test]
1508    fn test_kramdown_ial_with_id() {
1509        let rule = MD022BlanksAroundHeadings::default();
1510        let content = "# Heading\n{:#custom-id}\n\nContent.";
1511        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1512        let warnings = rule.check(&ctx).unwrap();
1513
1514        assert!(warnings.is_empty(), "IAL with id should be part of heading");
1515    }
1516
1517    #[test]
1518    fn test_kramdown_ial_with_multiple_attributes() {
1519        let rule = MD022BlanksAroundHeadings::default();
1520        let content = "# Heading\n{: .class #id style=\"color: red\"}\n\nContent.";
1521        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1522        let warnings = rule.check(&ctx).unwrap();
1523
1524        assert!(
1525            warnings.is_empty(),
1526            "IAL with multiple attributes should be part of heading"
1527        );
1528    }
1529
1530    #[test]
1531    fn test_kramdown_ial_missing_blank_after() {
1532        // IAL is part of heading, but blank line is still needed after IAL
1533        let rule = MD022BlanksAroundHeadings::default();
1534        let content = "# Heading\n{:.class}\nContent without blank.";
1535        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1536        let warnings = rule.check(&ctx).unwrap();
1537
1538        assert_eq!(
1539            warnings.len(),
1540            1,
1541            "Should warn about missing blank after IAL (part of heading)"
1542        );
1543        assert!(warnings[0].message.contains("below"));
1544    }
1545
1546    #[test]
1547    fn test_kramdown_ial_before_heading_transparent() {
1548        // IAL before heading should be transparent for "blank lines above" check
1549        let rule = MD022BlanksAroundHeadings::default();
1550        let content = "Content.\n\n{:.preclass}\n## Heading\n\nMore content.";
1551        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1552        let warnings = rule.check(&ctx).unwrap();
1553
1554        assert!(
1555            warnings.is_empty(),
1556            "IAL before heading should be transparent for blank line count"
1557        );
1558    }
1559
1560    #[test]
1561    fn test_kramdown_ial_setext_heading() {
1562        let rule = MD022BlanksAroundHeadings::default();
1563        let content = "Heading\n=======\n{:.setext-class}\n\nContent.";
1564        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1565        let warnings = rule.check(&ctx).unwrap();
1566
1567        assert!(
1568            warnings.is_empty(),
1569            "IAL after Setext heading should be part of heading"
1570        );
1571    }
1572
1573    #[test]
1574    fn test_kramdown_ial_fix_preserves_ial() {
1575        let rule = MD022BlanksAroundHeadings::default();
1576        let content = "Content.\n# Heading\n{:.class}\nMore content.";
1577        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1578        let fixed = rule.fix(&ctx).unwrap();
1579
1580        // Should add blank line above heading and after IAL, but keep IAL attached to heading
1581        assert!(
1582            fixed.contains("# Heading\n{:.class}"),
1583            "IAL should stay attached to heading"
1584        );
1585        assert!(fixed.contains("{:.class}\n\nMore"), "Should add blank after IAL");
1586    }
1587
1588    #[test]
1589    fn test_kramdown_ial_fix_does_not_separate() {
1590        let rule = MD022BlanksAroundHeadings::default();
1591        let content = "# Heading\n{:.class}\nContent.";
1592        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1593        let fixed = rule.fix(&ctx).unwrap();
1594
1595        // Fix should NOT insert blank line between heading and IAL
1596        assert!(
1597            !fixed.contains("# Heading\n\n{:.class}"),
1598            "Should not add blank between heading and IAL"
1599        );
1600        assert!(fixed.contains("# Heading\n{:.class}"), "IAL should remain attached");
1601    }
1602
1603    #[test]
1604    fn test_kramdown_multiple_ial_lines() {
1605        // Edge case: multiple IAL lines (unusual but valid)
1606        let rule = MD022BlanksAroundHeadings::default();
1607        let content = "# Heading\n{:.class1}\n{:#id}\n\nContent.";
1608        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1609        let warnings = rule.check(&ctx).unwrap();
1610
1611        // Note: Kramdown only attaches one IAL, but we treat consecutive ones as all attached
1612        // to avoid false positives
1613        assert!(
1614            warnings.is_empty(),
1615            "Multiple consecutive IALs should be part of heading"
1616        );
1617    }
1618
1619    #[test]
1620    fn test_kramdown_ial_with_blank_line_not_attached() {
1621        // If there's a blank line between heading and IAL, they're not attached
1622        let rule = MD022BlanksAroundHeadings::default();
1623        let content = "# Heading\n\n{:.class}\nContent.";
1624        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1625        let warnings = rule.check(&ctx).unwrap();
1626
1627        // The IAL here is NOT attached to the heading (blank line separates them)
1628        // So this should NOT trigger a warning for missing blank below heading
1629        // The IAL is just a standalone block-level element
1630        assert!(warnings.is_empty(), "Blank line separates heading from IAL");
1631    }
1632
1633    #[test]
1634    fn test_not_kramdown_ial_regular_braces() {
1635        // Regular braces that don't match IAL pattern
1636        let rule = MD022BlanksAroundHeadings::default();
1637        let content = "# Heading\n{not an ial}\n\nContent.";
1638        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1639        let warnings = rule.check(&ctx).unwrap();
1640
1641        // {not an ial} is not IAL syntax, so it should be regular content
1642        assert_eq!(
1643            warnings.len(),
1644            1,
1645            "Non-IAL braces should be regular content requiring blank"
1646        );
1647    }
1648
1649    #[test]
1650    fn test_kramdown_ial_at_document_end() {
1651        let rule = MD022BlanksAroundHeadings::default();
1652        let content = "# Heading\n{:.class}";
1653        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1654        let warnings = rule.check(&ctx).unwrap();
1655
1656        // No content after IAL, so no blank line needed
1657        assert!(warnings.is_empty(), "IAL at document end needs no blank after");
1658    }
1659
1660    #[test]
1661    fn test_kramdown_ial_followed_by_code_fence() {
1662        let rule = MD022BlanksAroundHeadings::default();
1663        let content = "# Heading\n{:.class}\n```\ncode\n```";
1664        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1665        let warnings = rule.check(&ctx).unwrap();
1666
1667        // Code fence is special - no blank required before it
1668        assert!(warnings.is_empty(), "No blank needed between IAL and code fence");
1669    }
1670
1671    #[test]
1672    fn test_kramdown_ial_followed_by_list() {
1673        let rule = MD022BlanksAroundHeadings::default();
1674        let content = "# Heading\n{:.class}\n- List item";
1675        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1676        let warnings = rule.check(&ctx).unwrap();
1677
1678        // List is special - no blank required before it
1679        assert!(warnings.is_empty(), "No blank needed between IAL and list");
1680    }
1681
1682    #[test]
1683    fn test_kramdown_ial_fix_idempotent() {
1684        let rule = MD022BlanksAroundHeadings::default();
1685        let content = "# Heading\n{:.class}\nContent.";
1686        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1687
1688        let fixed_once = rule.fix(&ctx).unwrap();
1689        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1690        let fixed_twice = rule.fix(&ctx2).unwrap();
1691
1692        assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1693    }
1694
1695    #[test]
1696    fn test_kramdown_ial_whitespace_line_between_not_attached() {
1697        // A whitespace-only line (not truly blank) between heading and IAL
1698        // means the IAL is NOT attached to the heading
1699        let rule = MD022BlanksAroundHeadings::default();
1700        let content = "# Heading\n   \n{:.class}\n\nContent.";
1701        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1702        let warnings = rule.check(&ctx).unwrap();
1703
1704        // Whitespace-only line is treated as blank, so IAL is NOT attached
1705        // The warning should be about the line after heading (whitespace line)
1706        // since {:.class} starts a new block
1707        assert!(
1708            warnings.is_empty(),
1709            "Whitespace between heading and IAL means IAL is not attached"
1710        );
1711    }
1712
1713    #[test]
1714    fn test_kramdown_ial_html_comment_between() {
1715        // HTML comment between heading and IAL means IAL is NOT attached to heading
1716        // IAL must immediately follow the element it modifies
1717        let rule = MD022BlanksAroundHeadings::default();
1718        let content = "# Heading\n<!-- comment -->\n{:.class}\n\nContent.";
1719        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1720        let warnings = rule.check(&ctx).unwrap();
1721
1722        // HTML comment creates separation - IAL is not attached to heading
1723        // Warning is generated because heading doesn't have blank line below
1724        // (the comment is transparent, but IAL is not attached)
1725        assert_eq!(
1726            warnings.len(),
1727            1,
1728            "IAL not attached when comment is between: {warnings:?}"
1729        );
1730    }
1731
1732    #[test]
1733    fn test_kramdown_ial_generic_attribute() {
1734        let rule = MD022BlanksAroundHeadings::default();
1735        let content = "# Heading\n{:data-toc=\"true\" style=\"color: red\"}\n\nContent.";
1736        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1737        let warnings = rule.check(&ctx).unwrap();
1738
1739        assert!(warnings.is_empty(), "Generic attributes should be recognized as IAL");
1740    }
1741
1742    #[test]
1743    fn test_kramdown_ial_fix_multiple_lines_preserves_all() {
1744        let rule = MD022BlanksAroundHeadings::default();
1745        let content = "# Heading\n{:.class1}\n{:#id}\n{:data-x=\"y\"}\nContent.";
1746        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1747
1748        let fixed = rule.fix(&ctx).unwrap();
1749
1750        // All IAL lines should be preserved
1751        assert!(fixed.contains("{:.class1}"), "First IAL should be preserved");
1752        assert!(fixed.contains("{:#id}"), "Second IAL should be preserved");
1753        assert!(fixed.contains("{:data-x=\"y\"}"), "Third IAL should be preserved");
1754        // Blank line should be after all IALs, before content
1755        assert!(
1756            fixed.contains("{:data-x=\"y\"}\n\nContent"),
1757            "Blank line should be after all IALs"
1758        );
1759    }
1760
1761    #[test]
1762    fn test_kramdown_ial_crlf_line_endings() {
1763        let rule = MD022BlanksAroundHeadings::default();
1764        let content = "# Heading\r\n{:.class}\r\n\r\nContent.";
1765        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1766        let warnings = rule.check(&ctx).unwrap();
1767
1768        assert!(warnings.is_empty(), "CRLF should work correctly with IAL");
1769    }
1770
1771    #[test]
1772    fn test_kramdown_ial_invalid_patterns_not_recognized() {
1773        let rule = MD022BlanksAroundHeadings::default();
1774
1775        // Space before colon - not valid IAL
1776        let content = "# Heading\n{ :.class}\n\nContent.";
1777        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1778        let warnings = rule.check(&ctx).unwrap();
1779        assert_eq!(warnings.len(), 1, "Invalid IAL syntax should trigger warning");
1780
1781        // Missing colon entirely
1782        let content2 = "# Heading\n{.class}\n\nContent.";
1783        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1784        let warnings2 = rule.check(&ctx2).unwrap();
1785        // {.class} IS valid kramdown syntax (starts with .)
1786        assert!(warnings2.is_empty(), "{{.class}} is valid kramdown block attribute");
1787
1788        // Just text in braces
1789        let content3 = "# Heading\n{just text}\n\nContent.";
1790        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1791        let warnings3 = rule.check(&ctx3).unwrap();
1792        assert_eq!(
1793            warnings3.len(),
1794            1,
1795            "Text in braces is not IAL and should trigger warning"
1796        );
1797    }
1798
1799    #[test]
1800    fn test_kramdown_ial_toc_marker() {
1801        // {:toc} is a special kramdown table of contents marker
1802        let rule = MD022BlanksAroundHeadings::default();
1803        let content = "# Heading\n{:toc}\n\nContent.";
1804        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1805        let warnings = rule.check(&ctx).unwrap();
1806
1807        // {:toc} starts with {: so it's recognized as IAL
1808        assert!(warnings.is_empty(), "{{:toc}} should be recognized as IAL");
1809    }
1810
1811    #[test]
1812    fn test_kramdown_ial_mixed_headings_in_document() {
1813        let rule = MD022BlanksAroundHeadings::default();
1814        let content = r#"# ATX Heading
1815{:.atx-class}
1816
1817Content after ATX.
1818
1819Setext Heading
1820--------------
1821{:#setext-id}
1822
1823Content after Setext.
1824
1825## Another ATX
1826{:.another}
1827
1828More content."#;
1829        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1830        let warnings = rule.check(&ctx).unwrap();
1831
1832        assert!(
1833            warnings.is_empty(),
1834            "Mixed headings with IAL should all work: {warnings:?}"
1835        );
1836    }
1837
1838    #[test]
1839    fn test_kramdown_extension_block_before_first_heading_is_document_start() {
1840        let rule = MD022BlanksAroundHeadings::default();
1841        let content = "{::comment}\nhidden\n{:/comment}\n# Heading\n\nBody\n";
1842        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1843        let warnings = rule.check(&ctx).unwrap();
1844
1845        assert!(
1846            warnings.is_empty(),
1847            "Kramdown extension preamble should not require blank above first heading: {warnings:?}"
1848        );
1849    }
1850
1851    #[test]
1852    fn test_kramdown_ial_before_first_heading_is_document_start() {
1853        let rule = MD022BlanksAroundHeadings::default();
1854        let content = "{:.doc-class}\n# Heading\n\nBody\n";
1855        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1856        let warnings = rule.check(&ctx).unwrap();
1857
1858        assert!(
1859            warnings.is_empty(),
1860            "Kramdown IAL preamble should not require blank above first heading: {warnings:?}"
1861        );
1862    }
1863
1864    // ==================== Quarto Flavor Tests ====================
1865
1866    #[test]
1867    fn test_quarto_div_marker_transparent_above_heading() {
1868        // Quarto div markers should be transparent for blank line counting
1869        // The blank line before the div opening should count toward the heading
1870        let rule = MD022BlanksAroundHeadings::default();
1871        // Content ends, blank, div opens, blank counts through div marker, heading
1872        let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
1873        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1874        let warnings = rule.check(&ctx).unwrap();
1875        // The blank line before div opening should count as separation for heading
1876        assert!(
1877            warnings.is_empty(),
1878            "Quarto div marker should be transparent above heading: {warnings:?}"
1879        );
1880    }
1881
1882    #[test]
1883    fn test_quarto_div_marker_transparent_below_heading() {
1884        // Quarto div opening marker should be transparent for blank line counting below heading
1885        let rule = MD022BlanksAroundHeadings::default();
1886        let content = "# Heading\n\n::: {.callout-note}\nContent\n:::\n";
1887        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1888        let warnings = rule.check(&ctx).unwrap();
1889        // The blank line after heading should count, and ::: should be transparent
1890        assert!(
1891            warnings.is_empty(),
1892            "Quarto div marker should be transparent below heading: {warnings:?}"
1893        );
1894    }
1895
1896    #[test]
1897    fn test_quarto_heading_inside_callout() {
1898        // Heading inside Quarto callout should work normally
1899        let rule = MD022BlanksAroundHeadings::default();
1900        let content = "::: {.callout-note}\n\n## Note Title\n\nNote content\n:::\n";
1901        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1902        let warnings = rule.check(&ctx).unwrap();
1903        assert!(
1904            warnings.is_empty(),
1905            "Heading inside Quarto callout should have no warnings: {warnings:?}"
1906        );
1907    }
1908
1909    #[test]
1910    fn test_quarto_heading_at_start_after_div_open() {
1911        // Heading immediately after div open counts as being at document start
1912        // because div marker is transparent for "first heading" detection
1913        let rule = MD022BlanksAroundHeadings::default();
1914        // This is the first heading in the document (div marker is transparent)
1915        let content = "::: {.callout-warning}\n# Warning\n\nContent\n:::\n";
1916        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1917        let warnings = rule.check(&ctx).unwrap();
1918        // The heading is at document start (after transparent div marker)
1919        // BUT the default config has allowed_at_start = true, AND there's content inside the div
1920        // that needs blank line below the heading. Let's check what we get.
1921        // Actually, the heading needs a blank below (before "Content"), so let's fix the test.
1922        // For this test, we want to verify the "above" requirement works with div marker transparency.
1923        assert!(
1924            warnings.is_empty(),
1925            "Heading at start after div open should pass: {warnings:?}"
1926        );
1927    }
1928
1929    #[test]
1930    fn test_quarto_heading_before_div_close() {
1931        // Heading immediately before div close: the div close is at end of doc, so no blank needed after
1932        let rule = MD022BlanksAroundHeadings::default();
1933        let content = "::: {.callout-note}\nIntro\n\n## Section\n:::\n";
1934        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1935        let warnings = rule.check(&ctx).unwrap();
1936        // The div closing marker is transparent, and at end of document there's nothing after it
1937        // So technically the heading is at the end (nothing follows the div close).
1938        // We need to check if the transparent marker logic works for end-of-document.
1939        assert!(
1940            warnings.is_empty(),
1941            "Heading before div close should pass: {warnings:?}"
1942        );
1943    }
1944
1945    #[test]
1946    fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
1947        // In standard flavor, ::: is regular text and breaks blank line sequences
1948        let rule = MD022BlanksAroundHeadings::default();
1949        let content = "Content\n\n:::\n# Heading\n\n:::\n";
1950        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1951        let warnings = rule.check(&ctx).unwrap();
1952        // In standard flavor, the ::: is just text. So there's no blank between ::: and heading.
1953        assert!(
1954            !warnings.is_empty(),
1955            "Standard flavor should not treat ::: as transparent: {warnings:?}"
1956        );
1957    }
1958
1959    #[test]
1960    fn test_quarto_nested_divs_with_heading() {
1961        // Nested Quarto divs with heading inside
1962        let rule = MD022BlanksAroundHeadings::default();
1963        let content = "::: {.outer}\n::: {.inner}\n\n# Heading\n\nContent\n:::\n:::\n";
1964        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1965        let warnings = rule.check(&ctx).unwrap();
1966        assert!(
1967            warnings.is_empty(),
1968            "Nested divs with heading should work: {warnings:?}"
1969        );
1970    }
1971
1972    #[test]
1973    fn test_quarto_fix_preserves_div_markers() {
1974        // Fix should preserve Quarto div markers
1975        let rule = MD022BlanksAroundHeadings::default();
1976        let content = "::: {.callout-note}\n\n## Note\n\nContent\n:::\n";
1977        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1978        let fixed = rule.fix(&ctx).unwrap();
1979        // Should preserve all the div markers
1980        assert!(fixed.contains("::: {.callout-note}"), "Should preserve div opening");
1981        assert!(fixed.contains(":::"), "Should preserve div closing");
1982        assert!(fixed.contains("## Note"), "Should preserve heading");
1983    }
1984
1985    #[test]
1986    fn test_quarto_heading_needs_blank_without_div_transparency() {
1987        // Without a blank line, heading after content should warn even with div marker between
1988        // This tests that blank lines are still required, div markers just don't "reset" the count
1989        let rule = MD022BlanksAroundHeadings::default();
1990        // Content directly followed by div opening, then heading - should warn
1991        let content = "Content\n::: {.callout-note}\n# Heading\n\nMore\n:::\n";
1992        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1993        let warnings = rule.check(&ctx).unwrap();
1994        // The div marker is transparent, so we look through it.
1995        // "Content" followed by heading with only a div marker in between - no blank!
1996        assert!(
1997            !warnings.is_empty(),
1998            "Should still require blank line when not present: {warnings:?}"
1999        );
2000    }
2001
2002    #[test]
2003    fn test_pandoc_div_marker_transparent_above_heading() {
2004        // Pandoc div marker should be transparent for blank line counting above heading,
2005        // mirroring the Quarto behavior tested in test_quarto_div_marker_transparent_above_heading.
2006        let rule = MD022BlanksAroundHeadings::default();
2007        let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
2008        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2009        let warnings = rule.check(&ctx).unwrap();
2010        assert!(
2011            warnings.is_empty(),
2012            "MD022 should treat Pandoc div marker as transparent above heading: {warnings:?}"
2013        );
2014    }
2015
2016    #[test]
2017    fn test_hugo_block_attribute_after_heading_not_flagged() {
2018        // Issue #756: a Goldmark/Hugo block attribute list directly under a heading
2019        // describes that heading, so MD022 must not require a blank between them.
2020        let rule = MD022BlanksAroundHeadings::default();
2021        let content = "Intro text.\n\n# Heading\n{class=\"anchor\"}\n\nContent.\n";
2022
2023        for flavor in [
2024            crate::config::MarkdownFlavor::Hugo,
2025            crate::config::MarkdownFlavor::MkDocs,
2026            crate::config::MarkdownFlavor::Kramdown,
2027        ] {
2028            let ctx = LintContext::new(content, flavor, None);
2029            let warnings = rule.check(&ctx).unwrap();
2030            assert!(
2031                warnings.is_empty(),
2032                "MD022 should not flag the block attribute line under {flavor:?}: {warnings:?}"
2033            );
2034        }
2035
2036        // Negative control: in Standard `{class="a"}` is literal text, so the heading
2037        // genuinely has no blank line below it.
2038        let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2039        let warnings_std = rule.check(&ctx_std).unwrap();
2040        assert!(
2041            warnings_std.iter().any(|w| w.message.contains("below heading")),
2042            "MD022 must flag the missing blank below the heading under Standard: {warnings_std:?}"
2043        );
2044    }
2045}