Skip to main content

rumdl_lib/rules/
md022_blanks_around_headings.rs

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