rumdl_lib/rules/
md022_blanks_around_headings.rs

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