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