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