Skip to main content

rumdl_lib/rules/
md022_blanks_around_headings.rs

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