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