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