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::range_utils::calculate_heading_range;
7use toml;
8
9mod md022_config;
10use md022_config::MD022Config;
11
12///
13/// This rule enforces consistent spacing around headings to improve document readability
14/// and visual structure.
15///
16/// ## Purpose
17///
18/// - **Readability**: Blank lines create visual separation, making headings stand out
19/// - **Parsing**: Many Markdown parsers require blank lines around headings for proper rendering
20/// - **Consistency**: Creates a uniform document style throughout
21/// - **Focus**: Helps readers identify and focus on section transitions
22///
23/// ## Configuration Options
24///
25/// The rule supports customizing the number of blank lines required:
26///
27/// ```yaml
28/// MD022:
29///   lines_above: 1  # Number of blank lines required above headings (default: 1)
30///   lines_below: 1  # Number of blank lines required below headings (default: 1)
31/// ```
32///
33/// ## Examples
34///
35/// ### Correct (with default configuration)
36///
37/// ```markdown
38/// Regular paragraph text.
39///
40/// # Heading 1
41///
42/// Content under heading 1.
43///
44/// ## Heading 2
45///
46/// More content here.
47/// ```
48///
49/// ### Incorrect (with default configuration)
50///
51/// ```markdown
52/// Regular paragraph text.
53/// # Heading 1
54/// Content under heading 1.
55/// ## Heading 2
56/// More content here.
57/// ```
58///
59/// ## Special Cases
60///
61/// This rule handles several special cases:
62///
63/// - **First Heading**: The first heading in a document doesn't require blank lines above
64///   if it appears at the very start of the document
65/// - **Front Matter**: YAML front matter is detected and skipped
66/// - **Code Blocks**: Headings inside code blocks are ignored
67/// - **Document Start/End**: Adjusts requirements for headings at the beginning or end of a document
68///
69/// ## Fix Behavior
70///
71/// When applying automatic fixes, this rule:
72/// - Adds the required number of blank lines above headings
73/// - Adds the required number of blank lines below headings
74/// - Preserves document structure and existing content
75///
76/// ## Performance Considerations
77///
78/// The rule is optimized for performance with:
79/// - Efficient line counting algorithms
80/// - Proper handling of front matter
81/// - Smart code block detection
82///
83#[derive(Clone, Default)]
84pub struct MD022BlanksAroundHeadings {
85    config: MD022Config,
86}
87
88impl MD022BlanksAroundHeadings {
89    /// Create a new instance of the rule with default values:
90    /// lines_above = 1, lines_below = 1
91    pub fn new() -> Self {
92        Self {
93            config: MD022Config::default(),
94        }
95    }
96
97    /// Create with custom numbers of blank lines (applies to all heading levels)
98    pub fn with_values(lines_above: usize, lines_below: usize) -> Self {
99        use md022_config::HeadingLevelConfig;
100        Self {
101            config: MD022Config {
102                lines_above: HeadingLevelConfig::scalar(lines_above),
103                lines_below: HeadingLevelConfig::scalar(lines_below),
104                allowed_at_start: true,
105            },
106        }
107    }
108
109    pub fn from_config_struct(config: MD022Config) -> Self {
110        Self { config }
111    }
112
113    /// Fix a document by adding appropriate blank lines around headings
114    fn _fix_content(&self, ctx: &crate::lint_context::LintContext) -> String {
115        // Content is normalized to LF at I/O boundary
116        let line_ending = "\n";
117        let had_trailing_newline = ctx.content.ends_with('\n');
118        let mut result = Vec::new();
119        let mut skip_next = false;
120
121        let heading_at_start_idx = {
122            let mut found_non_blank = false;
123            ctx.lines.iter().enumerate().find_map(|(i, line)| {
124                // Only count valid headings (skip malformed ones like `#NoSpace`)
125                if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_blank {
126                    Some(i)
127                } else {
128                    if !line.is_blank {
129                        found_non_blank = true;
130                    }
131                    None
132                }
133            })
134        };
135
136        for (i, line_info) in ctx.lines.iter().enumerate() {
137            if skip_next {
138                skip_next = false;
139                continue;
140            }
141            let line = line_info.content(ctx.content);
142
143            if line_info.in_code_block {
144                result.push(line.to_string());
145                continue;
146            }
147
148            // Check if it's a heading
149            if let Some(heading) = &line_info.heading {
150                // Skip invalid headings (e.g., `#NoSpace` which lacks required space after #)
151                if !heading.is_valid {
152                    result.push(line.to_string());
153                    continue;
154                }
155
156                // This is a heading line (ATX or Setext content)
157                let is_first_heading = Some(i) == heading_at_start_idx;
158                let heading_level = heading.level as usize;
159
160                // Count existing blank lines above in the result, skipping HTML comments
161                let mut blank_lines_above = 0;
162                let mut check_idx = result.len();
163                while check_idx > 0 {
164                    let prev_line = &result[check_idx - 1];
165                    let trimmed = prev_line.trim();
166                    if trimmed.is_empty() {
167                        blank_lines_above += 1;
168                        check_idx -= 1;
169                    } else if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
170                        // Skip HTML comments - they are transparent for blank line counting
171                        check_idx -= 1;
172                    } else {
173                        break;
174                    }
175                }
176
177                // Determine how many blank lines we need above
178                let requirement_above = self.config.lines_above.get_for_level(heading_level);
179                let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
180                    0
181                } else {
182                    requirement_above.required_count().unwrap_or(0)
183                };
184
185                // Add missing blank lines above if needed
186                while blank_lines_above < needed_blanks_above {
187                    result.push(String::new());
188                    blank_lines_above += 1;
189                }
190
191                // Add the heading line
192                result.push(line.to_string());
193
194                // For Setext headings, also add the underline immediately
195                if matches!(
196                    heading.style,
197                    crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
198                ) {
199                    // Add the underline (next line)
200                    if i + 1 < ctx.lines.len() {
201                        result.push(ctx.lines[i + 1].content(ctx.content).to_string());
202                        skip_next = true; // Skip the underline in the main loop
203                    }
204
205                    // Now check blank lines below the underline
206                    let mut blank_lines_below = 0;
207                    let mut next_content_line_idx = None;
208                    for j in (i + 2)..ctx.lines.len() {
209                        if ctx.lines[j].is_blank {
210                            blank_lines_below += 1;
211                        } else {
212                            next_content_line_idx = Some(j);
213                            break;
214                        }
215                    }
216
217                    // Check if the next non-blank line is special
218                    let next_is_special = if let Some(idx) = next_content_line_idx {
219                        let next_line = &ctx.lines[idx];
220                        next_line.list_item.is_some() || {
221                            let trimmed = next_line.content(ctx.content).trim();
222                            (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
223                                && (trimmed.len() == 3
224                                    || (trimmed.len() > 3
225                                        && trimmed
226                                            .chars()
227                                            .nth(3)
228                                            .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
229                        }
230                    } else {
231                        false
232                    };
233
234                    // Add missing blank lines below if needed
235                    let requirement_below = self.config.lines_below.get_for_level(heading_level);
236                    let needed_blanks_below = if next_is_special {
237                        0
238                    } else {
239                        requirement_below.required_count().unwrap_or(0)
240                    };
241                    if blank_lines_below < needed_blanks_below {
242                        for _ in 0..(needed_blanks_below - blank_lines_below) {
243                            result.push(String::new());
244                        }
245                    }
246                } else {
247                    // For ATX headings, check blank lines below
248                    let mut blank_lines_below = 0;
249                    let mut next_content_line_idx = None;
250                    for j in (i + 1)..ctx.lines.len() {
251                        if ctx.lines[j].is_blank {
252                            blank_lines_below += 1;
253                        } else {
254                            next_content_line_idx = Some(j);
255                            break;
256                        }
257                    }
258
259                    // Check if the next non-blank line is special
260                    let next_is_special = if let Some(idx) = next_content_line_idx {
261                        let next_line = &ctx.lines[idx];
262                        next_line.list_item.is_some() || {
263                            let trimmed = next_line.content(ctx.content).trim();
264                            (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
265                                && (trimmed.len() == 3
266                                    || (trimmed.len() > 3
267                                        && trimmed
268                                            .chars()
269                                            .nth(3)
270                                            .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
271                        }
272                    } else {
273                        false
274                    };
275
276                    // Add missing blank lines below if needed
277                    let requirement_below = self.config.lines_below.get_for_level(heading_level);
278                    let needed_blanks_below = if next_is_special {
279                        0
280                    } else {
281                        requirement_below.required_count().unwrap_or(0)
282                    };
283                    if blank_lines_below < needed_blanks_below {
284                        for _ in 0..(needed_blanks_below - blank_lines_below) {
285                            result.push(String::new());
286                        }
287                    }
288                }
289            } else {
290                // Regular line - just add it
291                result.push(line.to_string());
292            }
293        }
294
295        let joined = result.join(line_ending);
296
297        // Preserve original trailing newline behavior
298        // Content is normalized to LF at I/O boundary
299        if had_trailing_newline && !joined.ends_with('\n') {
300            format!("{joined}{line_ending}")
301        } else if !had_trailing_newline && joined.ends_with('\n') {
302            // Remove trailing newline if original didn't have one
303            joined[..joined.len() - 1].to_string()
304        } else {
305            joined
306        }
307    }
308}
309
310impl Rule for MD022BlanksAroundHeadings {
311    fn name(&self) -> &'static str {
312        "MD022"
313    }
314
315    fn description(&self) -> &'static str {
316        "Headings should be surrounded by blank lines"
317    }
318
319    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
320        let mut result = Vec::new();
321
322        // Skip if empty document
323        if ctx.lines.is_empty() {
324            return Ok(result);
325        }
326
327        // Content is normalized to LF at I/O boundary
328        let line_ending = "\n";
329
330        let heading_at_start_idx = {
331            let mut found_non_blank = false;
332            ctx.lines.iter().enumerate().find_map(|(i, line)| {
333                // Only count valid headings (skip malformed ones like `#NoSpace`)
334                if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_blank {
335                    Some(i)
336                } else {
337                    if !line.is_blank {
338                        found_non_blank = true;
339                    }
340                    None
341                }
342            })
343        };
344
345        // Collect all headings first to batch process
346        let mut heading_violations = Vec::new();
347        let mut processed_headings = std::collections::HashSet::new();
348
349        for (line_num, line_info) in ctx.lines.iter().enumerate() {
350            // Skip if already processed or not a heading
351            if processed_headings.contains(&line_num) || line_info.heading.is_none() {
352                continue;
353            }
354
355            let heading = line_info.heading.as_ref().unwrap();
356
357            // Skip invalid headings (e.g., `#NoSpace` which lacks required space after #)
358            if !heading.is_valid {
359                continue;
360            }
361
362            let heading_level = heading.level as usize;
363
364            // For Setext headings, skip the underline line (we process from the content line)
365            if matches!(
366                heading.style,
367                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
368            ) {
369                // Check if this is the underline, not the content
370                if line_num > 0 && ctx.lines[line_num - 1].heading.is_none() {
371                    continue; // This is the underline line
372                }
373            }
374
375            processed_headings.insert(line_num);
376
377            // Check if this heading is at document start
378            let is_first_heading = Some(line_num) == heading_at_start_idx;
379
380            // Get configured blank line requirements for this heading level
381            let required_above_count = self.config.lines_above.get_for_level(heading_level).required_count();
382            let required_below_count = self.config.lines_below.get_for_level(heading_level).required_count();
383
384            // Count blank lines above if needed
385            let should_check_above =
386                required_above_count.is_some() && line_num > 0 && (!is_first_heading || !self.config.allowed_at_start);
387            if should_check_above {
388                let mut blank_lines_above = 0;
389                let mut hit_frontmatter_end = false;
390                for j in (0..line_num).rev() {
391                    let line_content = ctx.lines[j].content(ctx.content);
392                    let trimmed = line_content.trim();
393                    if ctx.lines[j].is_blank {
394                        blank_lines_above += 1;
395                    } else if ctx.lines[j].in_html_comment || (trimmed.starts_with("<!--") && trimmed.ends_with("-->"))
396                    {
397                        // Skip HTML comments - they are transparent for blank line counting
398                        continue;
399                    } else if ctx.lines[j].in_front_matter || trimmed == "---" {
400                        // Skip frontmatter - first heading after frontmatter doesn't need blank line above
401                        hit_frontmatter_end = true;
402                        break;
403                    } else {
404                        break;
405                    }
406                }
407                let required = required_above_count.unwrap();
408                if !hit_frontmatter_end && blank_lines_above < required {
409                    let needed_blanks = required - blank_lines_above;
410                    heading_violations.push((line_num, "above", needed_blanks, heading_level));
411                }
412            }
413
414            // Determine the effective last line of the heading
415            let effective_last_line = if matches!(
416                heading.style,
417                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
418            ) {
419                line_num + 1 // For Setext, include the underline
420            } else {
421                line_num
422            };
423
424            // Check blank lines below
425            if effective_last_line < ctx.lines.len() - 1 {
426                // Find next non-blank line
427                let mut next_non_blank_idx = effective_last_line + 1;
428                while next_non_blank_idx < ctx.lines.len() && ctx.lines[next_non_blank_idx].is_blank {
429                    next_non_blank_idx += 1;
430                }
431
432                // Check if next line is a code fence or list item
433                let next_line_is_special = next_non_blank_idx < ctx.lines.len() && {
434                    let next_line = &ctx.lines[next_non_blank_idx];
435                    let next_trimmed = next_line.content(ctx.content).trim();
436
437                    // Check for code fence
438                    let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
439                        && (next_trimmed.len() == 3
440                            || (next_trimmed.len() > 3
441                                && next_trimmed
442                                    .chars()
443                                    .nth(3)
444                                    .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
445
446                    // Check for list item
447                    let is_list_item = next_line.list_item.is_some();
448
449                    is_code_fence || is_list_item
450                };
451
452                // Only generate warning if next line is NOT a code fence or list item
453                if !next_line_is_special && let Some(required) = required_below_count {
454                    // Count blank lines below
455                    let blank_lines_below = next_non_blank_idx - effective_last_line - 1;
456
457                    if blank_lines_below < required {
458                        let needed_blanks = required - blank_lines_below;
459                        heading_violations.push((line_num, "below", needed_blanks, heading_level));
460                    }
461                }
462            }
463        }
464
465        // Generate warnings for all violations
466        for (heading_line, position, needed_blanks, heading_level) in heading_violations {
467            let heading_display_line = heading_line + 1; // 1-indexed for display
468            let line_info = &ctx.lines[heading_line];
469
470            // Calculate precise character range for the heading
471            let (start_line, start_col, end_line, end_col) =
472                calculate_heading_range(heading_display_line, line_info.content(ctx.content));
473
474            let required_above_count = self
475                .config
476                .lines_above
477                .get_for_level(heading_level)
478                .required_count()
479                .expect("Violations only generated for limited 'above' requirements");
480            let required_below_count = self
481                .config
482                .lines_below
483                .get_for_level(heading_level)
484                .required_count()
485                .expect("Violations only generated for limited 'below' requirements");
486
487            let (message, insertion_point) = match position {
488                "above" => (
489                    format!(
490                        "Expected {} blank {} above heading",
491                        required_above_count,
492                        if required_above_count == 1 { "line" } else { "lines" }
493                    ),
494                    heading_line, // Insert before the heading line
495                ),
496                "below" => {
497                    // For Setext headings, insert after the underline
498                    let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
499                        matches!(
500                            h.style,
501                            crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
502                        )
503                    }) {
504                        heading_line + 2
505                    } else {
506                        heading_line + 1
507                    };
508
509                    (
510                        format!(
511                            "Expected {} blank {} below heading",
512                            required_below_count,
513                            if required_below_count == 1 { "line" } else { "lines" }
514                        ),
515                        insert_after,
516                    )
517                }
518                _ => continue,
519            };
520
521            // Calculate byte range for insertion
522            let byte_range = if insertion_point == 0 && position == "above" {
523                // Insert at beginning of document (only for "above" case at line 0)
524                0..0
525            } else if position == "above" && insertion_point > 0 {
526                // For "above", insert at the start of the heading line
527                ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
528            } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
529                // For "below", insert after the line
530                let line_idx = insertion_point - 1;
531                let line_end_offset = if line_idx + 1 < ctx.lines.len() {
532                    ctx.lines[line_idx + 1].byte_offset
533                } else {
534                    ctx.content.len()
535                };
536                line_end_offset..line_end_offset
537            } else {
538                // Insert at end of file
539                let content_len = ctx.content.len();
540                content_len..content_len
541            };
542
543            result.push(LintWarning {
544                rule_name: Some(self.name().to_string()),
545                message,
546                line: start_line,
547                column: start_col,
548                end_line,
549                end_column: end_col,
550                severity: Severity::Warning,
551                fix: Some(Fix {
552                    range: byte_range,
553                    replacement: line_ending.repeat(needed_blanks),
554                }),
555            });
556        }
557
558        Ok(result)
559    }
560
561    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
562        if ctx.content.is_empty() {
563            return Ok(ctx.content.to_string());
564        }
565
566        // Use a consolidated fix that avoids adding multiple blank lines
567        let fixed = self._fix_content(ctx);
568
569        Ok(fixed)
570    }
571
572    /// Get the category of this rule for selective processing
573    fn category(&self) -> RuleCategory {
574        RuleCategory::Heading
575    }
576
577    /// Check if this rule should be skipped
578    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
579        // Fast path: check if document likely has headings
580        if ctx.content.is_empty() || !ctx.likely_has_headings() {
581            return true;
582        }
583        // Verify headings actually exist
584        ctx.lines.iter().all(|line| line.heading.is_none())
585    }
586
587    fn as_any(&self) -> &dyn std::any::Any {
588        self
589    }
590
591    fn default_config_section(&self) -> Option<(String, toml::Value)> {
592        let default_config = MD022Config::default();
593        let json_value = serde_json::to_value(&default_config).ok()?;
594        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
595
596        if let toml::Value::Table(table) = toml_value {
597            if !table.is_empty() {
598                Some((MD022Config::RULE_NAME.to_string(), toml::Value::Table(table)))
599            } else {
600                None
601            }
602        } else {
603            None
604        }
605    }
606
607    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
608    where
609        Self: Sized,
610    {
611        let rule_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
612        Box::new(Self::from_config_struct(rule_config))
613    }
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619    use crate::lint_context::LintContext;
620
621    #[test]
622    fn test_valid_headings() {
623        let rule = MD022BlanksAroundHeadings::default();
624        let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
625        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
626        let result = rule.check(&ctx).unwrap();
627        assert!(result.is_empty());
628    }
629
630    #[test]
631    fn test_missing_blank_above() {
632        let rule = MD022BlanksAroundHeadings::default();
633        let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
634        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
635        let result = rule.check(&ctx).unwrap();
636        assert_eq!(result.len(), 0); // No warning for first heading
637
638        let fixed = rule.fix(&ctx).unwrap();
639
640        // Test for the ability to handle the content without breaking it
641        // Don't check for exact string equality which may break with implementation changes
642        assert!(fixed.contains("# Heading 1"));
643        assert!(fixed.contains("Some content."));
644        assert!(fixed.contains("## Heading 2"));
645        assert!(fixed.contains("More content."));
646    }
647
648    #[test]
649    fn test_missing_blank_below() {
650        let rule = MD022BlanksAroundHeadings::default();
651        let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
652        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
653        let result = rule.check(&ctx).unwrap();
654        assert_eq!(result.len(), 1);
655        assert_eq!(result[0].line, 2);
656
657        // Test the fix
658        let fixed = rule.fix(&ctx).unwrap();
659        assert!(fixed.contains("# Heading 1\n\nSome content"));
660    }
661
662    #[test]
663    fn test_missing_blank_above_and_below() {
664        let rule = MD022BlanksAroundHeadings::default();
665        let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
666        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
667        let result = rule.check(&ctx).unwrap();
668        assert_eq!(result.len(), 3); // Missing blanks: below first heading, above second heading, below second heading
669
670        // Test the fix
671        let fixed = rule.fix(&ctx).unwrap();
672        assert!(fixed.contains("# Heading 1\n\nSome content"));
673        assert!(fixed.contains("Some content.\n\n## Heading 2"));
674        assert!(fixed.contains("## Heading 2\n\nMore content"));
675    }
676
677    #[test]
678    fn test_fix_headings() {
679        let rule = MD022BlanksAroundHeadings::default();
680        let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
681        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
682        let result = rule.fix(&ctx).unwrap();
683
684        let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
685        assert_eq!(result, expected);
686    }
687
688    #[test]
689    fn test_consecutive_headings_pattern() {
690        let rule = MD022BlanksAroundHeadings::default();
691        let content = "# Heading 1\n## Heading 2\n### Heading 3";
692        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
693        let result = rule.fix(&ctx).unwrap();
694
695        // Using more specific assertions to check the structure
696        let lines: Vec<&str> = result.lines().collect();
697        assert!(!lines.is_empty());
698
699        // Find the positions of the headings
700        let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
701        let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
702        let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
703
704        // Verify blank lines between headings
705        assert!(
706            h2_pos > h1_pos + 1,
707            "Should have at least one blank line after first heading"
708        );
709        assert!(
710            h3_pos > h2_pos + 1,
711            "Should have at least one blank line after second heading"
712        );
713
714        // Verify there's a blank line between h1 and h2
715        assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
716
717        // Verify there's a blank line between h2 and h3
718        assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
719    }
720
721    #[test]
722    fn test_blanks_around_setext_headings() {
723        let rule = MD022BlanksAroundHeadings::default();
724        let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
725        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
726        let result = rule.fix(&ctx).unwrap();
727
728        // Check that the fix follows requirements without being too rigid about the exact output format
729        let lines: Vec<&str> = result.lines().collect();
730
731        // Verify key elements are present
732        assert!(result.contains("Heading 1"));
733        assert!(result.contains("========="));
734        assert!(result.contains("Some content."));
735        assert!(result.contains("Heading 2"));
736        assert!(result.contains("---------"));
737        assert!(result.contains("More content."));
738
739        // Verify structure ensures blank lines are added after headings
740        let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
741        let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
742        assert!(
743            some_content_idx > heading1_marker_idx + 1,
744            "Should have a blank line after the first heading"
745        );
746
747        let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
748        let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
749        assert!(
750            more_content_idx > heading2_marker_idx + 1,
751            "Should have a blank line after the second heading"
752        );
753
754        // Verify that the fixed content has no warnings
755        let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
756        let fixed_warnings = rule.check(&fixed_ctx).unwrap();
757        assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
758    }
759
760    #[test]
761    fn test_fix_specific_blank_line_cases() {
762        let rule = MD022BlanksAroundHeadings::default();
763
764        // Case 1: Testing consecutive headings
765        let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
766        let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
767        let result1 = rule.fix(&ctx1).unwrap();
768        // Verify structure rather than exact content as the fix implementation may vary
769        assert!(result1.contains("# Heading 1"));
770        assert!(result1.contains("## Heading 2"));
771        assert!(result1.contains("### Heading 3"));
772        // Ensure each heading has a blank line after it
773        let lines: Vec<&str> = result1.lines().collect();
774        let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
775        let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
776        assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
777        assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
778
779        // Case 2: Headings with content
780        let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
781        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
782        let result2 = rule.fix(&ctx2).unwrap();
783        // Verify structure
784        assert!(result2.contains("# Heading 1"));
785        assert!(result2.contains("Content under heading 1"));
786        assert!(result2.contains("## Heading 2"));
787        // Check spacing
788        let lines2: Vec<&str> = result2.lines().collect();
789        let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
790        let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
791        assert!(
792            lines2[h1_pos2 + 1].trim().is_empty(),
793            "Should have a blank line after heading 1"
794        );
795
796        // Case 3: Multiple consecutive headings with blank lines preserved
797        let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
798        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
799        let result3 = rule.fix(&ctx3).unwrap();
800        // Just verify it doesn't crash and properly formats headings
801        assert!(result3.contains("# Heading 1"));
802        assert!(result3.contains("## Heading 2"));
803        assert!(result3.contains("### Heading 3"));
804        assert!(result3.contains("Content"));
805    }
806
807    #[test]
808    fn test_fix_preserves_existing_blank_lines() {
809        let rule = MD022BlanksAroundHeadings::new();
810        let content = "# Title
811
812## Section 1
813
814Content here.
815
816## Section 2
817
818More content.
819### Missing Blank Above
820
821Even more content.
822
823## Section 3
824
825Final content.";
826
827        let expected = "# Title
828
829## Section 1
830
831Content here.
832
833## Section 2
834
835More content.
836
837### Missing Blank Above
838
839Even more content.
840
841## Section 3
842
843Final content.";
844
845        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
846        let result = rule._fix_content(&ctx);
847        assert_eq!(
848            result, expected,
849            "Fix should only add missing blank lines, never remove existing ones"
850        );
851    }
852
853    #[test]
854    fn test_fix_preserves_trailing_newline() {
855        let rule = MD022BlanksAroundHeadings::new();
856
857        // Test with trailing newline
858        let content_with_newline = "# Title\nContent here.\n";
859        let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
860        let result = rule.fix(&ctx).unwrap();
861        assert!(result.ends_with('\n'), "Should preserve trailing newline");
862
863        // Test without trailing newline
864        let content_without_newline = "# Title\nContent here.";
865        let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
866        let result = rule.fix(&ctx).unwrap();
867        assert!(
868            !result.ends_with('\n'),
869            "Should not add trailing newline if original didn't have one"
870        );
871    }
872
873    #[test]
874    fn test_fix_does_not_add_blank_lines_before_lists() {
875        let rule = MD022BlanksAroundHeadings::new();
876        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.";
877
878        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.";
879
880        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
881        let result = rule._fix_content(&ctx);
882        assert_eq!(result, expected, "Fix should not add blank lines before lists");
883    }
884
885    #[test]
886    fn test_per_level_configuration_no_blank_above_h1() {
887        use md022_config::HeadingLevelConfig;
888
889        // Configure: no blank above H1, 1 blank above H2-H6
890        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
891            lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
892            lines_below: HeadingLevelConfig::scalar(1),
893            allowed_at_start: false, // Disable special handling for first heading
894        });
895
896        // H1 without blank above should be OK
897        let content = "Some text\n# Heading 1\n\nMore text";
898        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
899        let warnings = rule.check(&ctx).unwrap();
900        assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
901
902        // H2 without blank above should trigger warning
903        let content = "Some text\n## Heading 2\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(), 1, "H2 without blank above should trigger warning");
907        assert!(warnings[0].message.contains("above"));
908    }
909
910    #[test]
911    fn test_per_level_configuration_different_requirements() {
912        use md022_config::HeadingLevelConfig;
913
914        // Configure: 0 blank above H1, 1 above H2-H3, 2 above H4-H6
915        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
916            lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
917            lines_below: HeadingLevelConfig::scalar(1),
918            allowed_at_start: false,
919        });
920
921        let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
922        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
923        let warnings = rule.check(&ctx).unwrap();
924
925        // Should have no warnings - all headings satisfy their level-specific requirements
926        assert_eq!(
927            warnings.len(),
928            0,
929            "All headings should satisfy level-specific requirements"
930        );
931    }
932
933    #[test]
934    fn test_per_level_configuration_violations() {
935        use md022_config::HeadingLevelConfig;
936
937        // Configure: H4 needs 2 blanks above
938        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
939            lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
940            lines_below: HeadingLevelConfig::scalar(1),
941            allowed_at_start: false,
942        });
943
944        // H4 with only 1 blank above should trigger warning
945        let content = "Text\n\n#### Heading 4\n\nMore text";
946        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
947        let warnings = rule.check(&ctx).unwrap();
948
949        assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
950        assert!(warnings[0].message.contains("2 blank lines above"));
951    }
952
953    #[test]
954    fn test_per_level_fix_different_levels() {
955        use md022_config::HeadingLevelConfig;
956
957        // Configure: 0 blank above H1, 1 above H2, 2 above H3+
958        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
959            lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
960            lines_below: HeadingLevelConfig::scalar(1),
961            allowed_at_start: false,
962        });
963
964        let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
965        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
966        let fixed = rule.fix(&ctx).unwrap();
967
968        // Verify structure: H1 gets 0 blanks above, H2 gets 1, H3 gets 2
969        assert!(fixed.contains("Text\n# H1\n\nContent"));
970        assert!(fixed.contains("Content\n\n## H2\n\nContent"));
971        assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
972    }
973
974    #[test]
975    fn test_per_level_below_configuration() {
976        use md022_config::HeadingLevelConfig;
977
978        // Configure: different blank line requirements below headings
979        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
980            lines_above: HeadingLevelConfig::scalar(1),
981            lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), // H1 needs 2 blanks below
982            allowed_at_start: true,
983        });
984
985        // H1 with only 1 blank below should trigger warning
986        let content = "# Heading 1\n\nSome text";
987        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
988        let warnings = rule.check(&ctx).unwrap();
989
990        assert_eq!(
991            warnings.len(),
992            1,
993            "H1 with insufficient blanks below should trigger warning"
994        );
995        assert!(warnings[0].message.contains("2 blank lines below"));
996    }
997
998    #[test]
999    fn test_scalar_configuration_still_works() {
1000        use md022_config::HeadingLevelConfig;
1001
1002        // Ensure scalar configuration still works (backward compatibility)
1003        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1004            lines_above: HeadingLevelConfig::scalar(2),
1005            lines_below: HeadingLevelConfig::scalar(2),
1006            allowed_at_start: false,
1007        });
1008
1009        let content = "Text\n# H1\nContent\n## H2\nContent";
1010        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1011        let warnings = rule.check(&ctx).unwrap();
1012
1013        // All headings should need 2 blanks above and below
1014        assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
1015    }
1016
1017    #[test]
1018    fn test_unlimited_configuration_skips_requirements() {
1019        use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1020
1021        // H1 can have any number of blank lines above/below; others require defaults
1022        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1023            lines_above: HeadingLevelConfig::per_level_requirements([
1024                HeadingBlankRequirement::unlimited(),
1025                HeadingBlankRequirement::limited(1),
1026                HeadingBlankRequirement::limited(1),
1027                HeadingBlankRequirement::limited(1),
1028                HeadingBlankRequirement::limited(1),
1029                HeadingBlankRequirement::limited(1),
1030            ]),
1031            lines_below: HeadingLevelConfig::per_level_requirements([
1032                HeadingBlankRequirement::unlimited(),
1033                HeadingBlankRequirement::limited(1),
1034                HeadingBlankRequirement::limited(1),
1035                HeadingBlankRequirement::limited(1),
1036                HeadingBlankRequirement::limited(1),
1037                HeadingBlankRequirement::limited(1),
1038            ]),
1039            allowed_at_start: false,
1040        });
1041
1042        let content = "# H1\nParagraph\n## H2\nParagraph";
1043        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1044        let warnings = rule.check(&ctx).unwrap();
1045
1046        // H1 has no blanks above/below but is unlimited; H2 should get violations
1047        assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1048        assert!(
1049            warnings.iter().all(|w| w.line >= 3),
1050            "Warnings should target later headings"
1051        );
1052
1053        // Fixing should insert blanks around H2 but leave H1 untouched
1054        let fixed = rule.fix(&ctx).unwrap();
1055        assert!(
1056            fixed.starts_with("# H1\nParagraph\n\n## H2"),
1057            "H1 should remain unchanged"
1058        );
1059    }
1060
1061    #[test]
1062    fn test_html_comment_transparency() {
1063        // HTML comments are transparent for blank line counting
1064        // A heading following a blank line + HTML comment should be valid
1065        // Verified with markdownlint: no MD022 warning for this pattern
1066        let rule = MD022BlanksAroundHeadings::default();
1067
1068        // Pattern: content, blank line, HTML comment, heading
1069        // The blank line before the HTML comment counts for the heading
1070        let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
1071        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1072        let warnings = rule.check(&ctx).unwrap();
1073        assert!(
1074            warnings.is_empty(),
1075            "HTML comment is transparent - blank line above it counts for heading"
1076        );
1077
1078        // Multi-line HTML comment is also transparent
1079        let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
1080        let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
1081        let warnings_multiline = rule.check(&ctx_multiline).unwrap();
1082        assert!(
1083            warnings_multiline.is_empty(),
1084            "Multi-line HTML comment is also transparent"
1085        );
1086    }
1087
1088    #[test]
1089    fn test_frontmatter_transparency() {
1090        // Frontmatter is transparent for MD022 - heading can appear immediately after
1091        // Verified with markdownlint: no MD022 warning for heading after frontmatter
1092        let rule = MD022BlanksAroundHeadings::default();
1093
1094        // Heading immediately after frontmatter closing ---
1095        let content = "---\ntitle: Test\n---\n# First heading";
1096        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1097        let warnings = rule.check(&ctx).unwrap();
1098        assert!(
1099            warnings.is_empty(),
1100            "Frontmatter is transparent - heading can appear immediately after"
1101        );
1102
1103        // Heading with blank line after frontmatter is also valid
1104        let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
1105        let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1106        let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1107        assert!(
1108            warnings_with_blank.is_empty(),
1109            "Heading with blank line after frontmatter should also be valid"
1110        );
1111
1112        // TOML frontmatter (+++...+++) is also transparent
1113        let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
1114        let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
1115        let warnings_toml = rule.check(&ctx_toml).unwrap();
1116        assert!(
1117            warnings_toml.is_empty(),
1118            "TOML frontmatter is also transparent for MD022"
1119        );
1120    }
1121}