Skip to main content

rumdl_lib/rules/
md022_blanks_around_headings.rs

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