Skip to main content

rumdl_lib/rules/
md022_blanks_around_headings.rs

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