Skip to main content

rumdl_lib/rules/
md022_blanks_around_headings.rs

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