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
98    pub fn with_values(lines_above: usize, lines_below: usize) -> Self {
99        Self {
100            config: MD022Config {
101                lines_above,
102                lines_below,
103                allowed_at_start: true,
104            },
105        }
106    }
107
108    pub fn from_config_struct(config: MD022Config) -> Self {
109        Self { config }
110    }
111
112    /// Fix a document by adding appropriate blank lines around headings
113    fn _fix_content(&self, ctx: &crate::lint_context::LintContext) -> String {
114        // Content is normalized to LF at I/O boundary
115        let line_ending = "\n";
116        let had_trailing_newline = ctx.content.ends_with('\n');
117        let mut result = Vec::new();
118        let mut skip_next = false;
119
120        let heading_at_start_idx = {
121            let mut found_non_blank = false;
122            ctx.lines.iter().enumerate().find_map(|(i, line)| {
123                if line.heading.is_some() && !found_non_blank {
124                    Some(i)
125                } else {
126                    if !line.is_blank {
127                        found_non_blank = true;
128                    }
129                    None
130                }
131            })
132        };
133
134        for (i, line_info) in ctx.lines.iter().enumerate() {
135            if skip_next {
136                skip_next = false;
137                continue;
138            }
139            let line = &line_info.content;
140
141            if line_info.in_code_block {
142                result.push(line.to_string());
143                continue;
144            }
145
146            // Check if it's a heading
147            if let Some(heading) = &line_info.heading {
148                // This is a heading line (ATX or Setext content)
149                let is_first_heading = Some(i) == heading_at_start_idx;
150
151                // Count existing blank lines above in the result
152                let mut blank_lines_above = 0;
153                let mut check_idx = result.len();
154                while check_idx > 0 && result[check_idx - 1].trim().is_empty() {
155                    blank_lines_above += 1;
156                    check_idx -= 1;
157                }
158
159                // Determine how many blank lines we need above
160                let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
161                    0
162                } else {
163                    self.config.lines_above
164                };
165
166                // Add missing blank lines above if needed
167                while blank_lines_above < needed_blanks_above {
168                    result.push(String::new());
169                    blank_lines_above += 1;
170                }
171
172                // Add the heading line
173                result.push(line.to_string());
174
175                // For Setext headings, also add the underline immediately
176                if matches!(
177                    heading.style,
178                    crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
179                ) {
180                    // Add the underline (next line)
181                    if i + 1 < ctx.lines.len() {
182                        result.push(ctx.lines[i + 1].content.clone());
183                        skip_next = true; // Skip the underline in the main loop
184                    }
185
186                    // Now check blank lines below the underline
187                    let mut blank_lines_below = 0;
188                    let mut next_content_line_idx = None;
189                    for j in (i + 2)..ctx.lines.len() {
190                        if ctx.lines[j].is_blank {
191                            blank_lines_below += 1;
192                        } else {
193                            next_content_line_idx = Some(j);
194                            break;
195                        }
196                    }
197
198                    // Check if the next non-blank line is special
199                    let next_is_special = if let Some(idx) = next_content_line_idx {
200                        let next_line = &ctx.lines[idx];
201                        next_line.list_item.is_some() || {
202                            let trimmed = next_line.content.trim();
203                            (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
204                                && (trimmed.len() == 3
205                                    || (trimmed.len() > 3
206                                        && trimmed
207                                            .chars()
208                                            .nth(3)
209                                            .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
210                        }
211                    } else {
212                        false
213                    };
214
215                    // Add missing blank lines below if needed
216                    let needed_blanks_below = if next_is_special { 0 } else { self.config.lines_below };
217                    if blank_lines_below < needed_blanks_below {
218                        for _ in 0..(needed_blanks_below - blank_lines_below) {
219                            result.push(String::new());
220                        }
221                    }
222                } else {
223                    // For ATX headings, check blank lines below
224                    let mut blank_lines_below = 0;
225                    let mut next_content_line_idx = None;
226                    for j in (i + 1)..ctx.lines.len() {
227                        if ctx.lines[j].is_blank {
228                            blank_lines_below += 1;
229                        } else {
230                            next_content_line_idx = Some(j);
231                            break;
232                        }
233                    }
234
235                    // Check if the next non-blank line is special
236                    let next_is_special = if let Some(idx) = next_content_line_idx {
237                        let next_line = &ctx.lines[idx];
238                        next_line.list_item.is_some() || {
239                            let trimmed = next_line.content.trim();
240                            (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
241                                && (trimmed.len() == 3
242                                    || (trimmed.len() > 3
243                                        && trimmed
244                                            .chars()
245                                            .nth(3)
246                                            .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
247                        }
248                    } else {
249                        false
250                    };
251
252                    // Add missing blank lines below if needed
253                    let needed_blanks_below = if next_is_special { 0 } else { self.config.lines_below };
254                    if blank_lines_below < needed_blanks_below {
255                        for _ in 0..(needed_blanks_below - blank_lines_below) {
256                            result.push(String::new());
257                        }
258                    }
259                }
260            } else {
261                // Regular line - just add it
262                result.push(line.to_string());
263            }
264        }
265
266        let joined = result.join(line_ending);
267
268        // Preserve original trailing newline behavior
269        // Content is normalized to LF at I/O boundary
270        if had_trailing_newline && !joined.ends_with('\n') {
271            format!("{joined}{line_ending}")
272        } else if !had_trailing_newline && joined.ends_with('\n') {
273            // Remove trailing newline if original didn't have one
274            joined[..joined.len() - 1].to_string()
275        } else {
276            joined
277        }
278    }
279}
280
281impl Rule for MD022BlanksAroundHeadings {
282    fn name(&self) -> &'static str {
283        "MD022"
284    }
285
286    fn description(&self) -> &'static str {
287        "Headings should be surrounded by blank lines"
288    }
289
290    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
291        let mut result = Vec::new();
292
293        // Skip if empty document
294        if ctx.lines.is_empty() {
295            return Ok(result);
296        }
297
298        // Content is normalized to LF at I/O boundary
299        let line_ending = "\n";
300
301        let heading_at_start_idx = {
302            let mut found_non_blank = false;
303            ctx.lines.iter().enumerate().find_map(|(i, line)| {
304                if line.heading.is_some() && !found_non_blank {
305                    Some(i)
306                } else {
307                    if !line.is_blank {
308                        found_non_blank = true;
309                    }
310                    None
311                }
312            })
313        };
314
315        // Collect all headings first to batch process
316        let mut heading_violations = Vec::new();
317        let mut processed_headings = std::collections::HashSet::new();
318
319        for (line_num, line_info) in ctx.lines.iter().enumerate() {
320            // Skip if already processed or not a heading
321            if processed_headings.contains(&line_num) || line_info.heading.is_none() {
322                continue;
323            }
324
325            let heading = line_info.heading.as_ref().unwrap();
326
327            // For Setext headings, skip the underline line (we process from the content line)
328            if matches!(
329                heading.style,
330                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
331            ) {
332                // Check if this is the underline, not the content
333                if line_num > 0 && ctx.lines[line_num - 1].heading.is_none() {
334                    continue; // This is the underline line
335                }
336            }
337
338            processed_headings.insert(line_num);
339
340            // Check if this heading is at document start
341            let is_first_heading = Some(line_num) == heading_at_start_idx;
342
343            // Count blank lines above
344            let blank_lines_above = if line_num > 0 && (!is_first_heading || !self.config.allowed_at_start) {
345                let mut count = 0;
346                for j in (0..line_num).rev() {
347                    if ctx.lines[j].is_blank {
348                        count += 1;
349                    } else {
350                        break;
351                    }
352                }
353                count
354            } else {
355                self.config.lines_above // Consider it as having enough blanks if it's the first heading
356            };
357
358            // Check if we need blank lines above
359            if line_num > 0
360                && blank_lines_above < self.config.lines_above
361                && (!is_first_heading || !self.config.allowed_at_start)
362            {
363                let needed_blanks = self.config.lines_above - blank_lines_above;
364                heading_violations.push((line_num, "above", needed_blanks));
365            }
366
367            // Determine the effective last line of the heading
368            let effective_last_line = if matches!(
369                heading.style,
370                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
371            ) {
372                line_num + 1 // For Setext, include the underline
373            } else {
374                line_num
375            };
376
377            // Check blank lines below
378            if effective_last_line < ctx.lines.len() - 1 {
379                // Find next non-blank line
380                let mut next_non_blank_idx = effective_last_line + 1;
381                while next_non_blank_idx < ctx.lines.len() && ctx.lines[next_non_blank_idx].is_blank {
382                    next_non_blank_idx += 1;
383                }
384
385                // Check if next line is a code fence or list item
386                let next_line_is_special = next_non_blank_idx < ctx.lines.len() && {
387                    let next_line = &ctx.lines[next_non_blank_idx];
388                    let next_trimmed = next_line.content.trim();
389
390                    // Check for code fence
391                    let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
392                        && (next_trimmed.len() == 3
393                            || (next_trimmed.len() > 3
394                                && next_trimmed
395                                    .chars()
396                                    .nth(3)
397                                    .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
398
399                    // Check for list item
400                    let is_list_item = next_line.list_item.is_some();
401
402                    is_code_fence || is_list_item
403                };
404
405                // Only generate warning if next line is NOT a code fence or list item
406                if !next_line_is_special {
407                    // Count blank lines below
408                    let blank_lines_below = next_non_blank_idx - effective_last_line - 1;
409
410                    if blank_lines_below < self.config.lines_below {
411                        let needed_blanks = self.config.lines_below - blank_lines_below;
412                        heading_violations.push((line_num, "below", needed_blanks));
413                    }
414                }
415            }
416        }
417
418        // Generate warnings for all violations
419        for (heading_line, position, needed_blanks) in heading_violations {
420            let heading_display_line = heading_line + 1; // 1-indexed for display
421            let line_info = &ctx.lines[heading_line];
422
423            // Calculate precise character range for the heading
424            let (start_line, start_col, end_line, end_col) =
425                calculate_heading_range(heading_display_line, &line_info.content);
426
427            let (message, insertion_point) = match position {
428                "above" => (
429                    format!(
430                        "Expected {} blank {} above heading",
431                        self.config.lines_above,
432                        if self.config.lines_above == 1 { "line" } else { "lines" }
433                    ),
434                    heading_line, // Insert before the heading line
435                ),
436                "below" => {
437                    // For Setext headings, insert after the underline
438                    let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
439                        matches!(
440                            h.style,
441                            crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
442                        )
443                    }) {
444                        heading_line + 2
445                    } else {
446                        heading_line + 1
447                    };
448
449                    (
450                        format!(
451                            "Expected {} blank {} below heading",
452                            self.config.lines_below,
453                            if self.config.lines_below == 1 { "line" } else { "lines" }
454                        ),
455                        insert_after,
456                    )
457                }
458                _ => continue,
459            };
460
461            // Calculate byte range for insertion
462            let byte_range = if insertion_point == 0 && position == "above" {
463                // Insert at beginning of document (only for "above" case at line 0)
464                0..0
465            } else if position == "above" && insertion_point > 0 {
466                // For "above", insert at the start of the heading line
467                ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
468            } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
469                // For "below", insert after the line
470                let line_idx = insertion_point - 1;
471                let line_end_offset = if line_idx + 1 < ctx.lines.len() {
472                    ctx.lines[line_idx + 1].byte_offset
473                } else {
474                    ctx.content.len()
475                };
476                line_end_offset..line_end_offset
477            } else {
478                // Insert at end of file
479                let content_len = ctx.content.len();
480                content_len..content_len
481            };
482
483            result.push(LintWarning {
484                rule_name: Some(self.name().to_string()),
485                message,
486                line: start_line,
487                column: start_col,
488                end_line,
489                end_column: end_col,
490                severity: Severity::Warning,
491                fix: Some(Fix {
492                    range: byte_range,
493                    replacement: line_ending.repeat(needed_blanks),
494                }),
495            });
496        }
497
498        Ok(result)
499    }
500
501    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
502        if ctx.content.is_empty() {
503            return Ok(ctx.content.to_string());
504        }
505
506        // Use a consolidated fix that avoids adding multiple blank lines
507        let fixed = self._fix_content(ctx);
508
509        Ok(fixed)
510    }
511
512    /// Get the category of this rule for selective processing
513    fn category(&self) -> RuleCategory {
514        RuleCategory::Heading
515    }
516
517    /// Check if this rule should be skipped
518    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
519        // Fast path: check if document likely has headings
520        if ctx.content.is_empty() || !ctx.likely_has_headings() {
521            return true;
522        }
523        // Verify headings actually exist
524        ctx.lines.iter().all(|line| line.heading.is_none())
525    }
526
527    fn as_any(&self) -> &dyn std::any::Any {
528        self
529    }
530
531    fn default_config_section(&self) -> Option<(String, toml::Value)> {
532        let default_config = MD022Config::default();
533        let json_value = serde_json::to_value(&default_config).ok()?;
534        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
535
536        if let toml::Value::Table(table) = toml_value {
537            if !table.is_empty() {
538                Some((MD022Config::RULE_NAME.to_string(), toml::Value::Table(table)))
539            } else {
540                None
541            }
542        } else {
543            None
544        }
545    }
546
547    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
548    where
549        Self: Sized,
550    {
551        let rule_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
552        Box::new(Self::from_config_struct(rule_config))
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use crate::lint_context::LintContext;
560
561    #[test]
562    fn test_valid_headings() {
563        let rule = MD022BlanksAroundHeadings::default();
564        let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
565        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
566        let result = rule.check(&ctx).unwrap();
567        assert!(result.is_empty());
568    }
569
570    #[test]
571    fn test_missing_blank_above() {
572        let rule = MD022BlanksAroundHeadings::default();
573        let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
574        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
575        let result = rule.check(&ctx).unwrap();
576        assert_eq!(result.len(), 0); // No warning for first heading
577
578        let fixed = rule.fix(&ctx).unwrap();
579
580        // Test for the ability to handle the content without breaking it
581        // Don't check for exact string equality which may break with implementation changes
582        assert!(fixed.contains("# Heading 1"));
583        assert!(fixed.contains("Some content."));
584        assert!(fixed.contains("## Heading 2"));
585        assert!(fixed.contains("More content."));
586    }
587
588    #[test]
589    fn test_missing_blank_below() {
590        let rule = MD022BlanksAroundHeadings::default();
591        let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
592        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
593        let result = rule.check(&ctx).unwrap();
594        assert_eq!(result.len(), 1);
595        assert_eq!(result[0].line, 2);
596
597        // Test the fix
598        let fixed = rule.fix(&ctx).unwrap();
599        assert!(fixed.contains("# Heading 1\n\nSome content"));
600    }
601
602    #[test]
603    fn test_missing_blank_above_and_below() {
604        let rule = MD022BlanksAroundHeadings::default();
605        let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
606        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
607        let result = rule.check(&ctx).unwrap();
608        assert_eq!(result.len(), 3); // Missing blanks: below first heading, above second heading, below second heading
609
610        // Test the fix
611        let fixed = rule.fix(&ctx).unwrap();
612        assert!(fixed.contains("# Heading 1\n\nSome content"));
613        assert!(fixed.contains("Some content.\n\n## Heading 2"));
614        assert!(fixed.contains("## Heading 2\n\nMore content"));
615    }
616
617    #[test]
618    fn test_fix_headings() {
619        let rule = MD022BlanksAroundHeadings::default();
620        let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
621        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
622        let result = rule.fix(&ctx).unwrap();
623
624        let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
625        assert_eq!(result, expected);
626    }
627
628    #[test]
629    fn test_consecutive_headings_pattern() {
630        let rule = MD022BlanksAroundHeadings::default();
631        let content = "# Heading 1\n## Heading 2\n### Heading 3";
632        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
633        let result = rule.fix(&ctx).unwrap();
634
635        // Using more specific assertions to check the structure
636        let lines: Vec<&str> = result.lines().collect();
637        assert!(!lines.is_empty());
638
639        // Find the positions of the headings
640        let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
641        let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
642        let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
643
644        // Verify blank lines between headings
645        assert!(
646            h2_pos > h1_pos + 1,
647            "Should have at least one blank line after first heading"
648        );
649        assert!(
650            h3_pos > h2_pos + 1,
651            "Should have at least one blank line after second heading"
652        );
653
654        // Verify there's a blank line between h1 and h2
655        assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
656
657        // Verify there's a blank line between h2 and h3
658        assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
659    }
660
661    #[test]
662    fn test_blanks_around_setext_headings() {
663        let rule = MD022BlanksAroundHeadings::default();
664        let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
665        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
666        let result = rule.fix(&ctx).unwrap();
667
668        // Check that the fix follows requirements without being too rigid about the exact output format
669        let lines: Vec<&str> = result.lines().collect();
670
671        // Verify key elements are present
672        assert!(result.contains("Heading 1"));
673        assert!(result.contains("========="));
674        assert!(result.contains("Some content."));
675        assert!(result.contains("Heading 2"));
676        assert!(result.contains("---------"));
677        assert!(result.contains("More content."));
678
679        // Verify structure ensures blank lines are added after headings
680        let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
681        let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
682        assert!(
683            some_content_idx > heading1_marker_idx + 1,
684            "Should have a blank line after the first heading"
685        );
686
687        let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
688        let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
689        assert!(
690            more_content_idx > heading2_marker_idx + 1,
691            "Should have a blank line after the second heading"
692        );
693
694        // Verify that the fixed content has no warnings
695        let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard);
696        let fixed_warnings = rule.check(&fixed_ctx).unwrap();
697        assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
698    }
699
700    #[test]
701    fn test_fix_specific_blank_line_cases() {
702        let rule = MD022BlanksAroundHeadings::default();
703
704        // Case 1: Testing consecutive headings
705        let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
706        let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard);
707        let result1 = rule.fix(&ctx1).unwrap();
708        // Verify structure rather than exact content as the fix implementation may vary
709        assert!(result1.contains("# Heading 1"));
710        assert!(result1.contains("## Heading 2"));
711        assert!(result1.contains("### Heading 3"));
712        // Ensure each heading has a blank line after it
713        let lines: Vec<&str> = result1.lines().collect();
714        let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
715        let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
716        assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
717        assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
718
719        // Case 2: Headings with content
720        let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
721        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard);
722        let result2 = rule.fix(&ctx2).unwrap();
723        // Verify structure
724        assert!(result2.contains("# Heading 1"));
725        assert!(result2.contains("Content under heading 1"));
726        assert!(result2.contains("## Heading 2"));
727        // Check spacing
728        let lines2: Vec<&str> = result2.lines().collect();
729        let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
730        let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
731        assert!(
732            lines2[h1_pos2 + 1].trim().is_empty(),
733            "Should have a blank line after heading 1"
734        );
735
736        // Case 3: Multiple consecutive headings with blank lines preserved
737        let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
738        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard);
739        let result3 = rule.fix(&ctx3).unwrap();
740        // Just verify it doesn't crash and properly formats headings
741        assert!(result3.contains("# Heading 1"));
742        assert!(result3.contains("## Heading 2"));
743        assert!(result3.contains("### Heading 3"));
744        assert!(result3.contains("Content"));
745    }
746
747    #[test]
748    fn test_fix_preserves_existing_blank_lines() {
749        let rule = MD022BlanksAroundHeadings::new();
750        let content = "# Title
751
752## Section 1
753
754Content here.
755
756## Section 2
757
758More content.
759### Missing Blank Above
760
761Even more content.
762
763## Section 3
764
765Final content.";
766
767        let expected = "# Title
768
769## Section 1
770
771Content here.
772
773## Section 2
774
775More content.
776
777### Missing Blank Above
778
779Even more content.
780
781## Section 3
782
783Final content.";
784
785        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
786        let result = rule._fix_content(&ctx);
787        assert_eq!(
788            result, expected,
789            "Fix should only add missing blank lines, never remove existing ones"
790        );
791    }
792
793    #[test]
794    fn test_fix_preserves_trailing_newline() {
795        let rule = MD022BlanksAroundHeadings::new();
796
797        // Test with trailing newline
798        let content_with_newline = "# Title\nContent here.\n";
799        let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard);
800        let result = rule.fix(&ctx).unwrap();
801        assert!(result.ends_with('\n'), "Should preserve trailing newline");
802
803        // Test without trailing newline
804        let content_without_newline = "# Title\nContent here.";
805        let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard);
806        let result = rule.fix(&ctx).unwrap();
807        assert!(
808            !result.ends_with('\n'),
809            "Should not add trailing newline if original didn't have one"
810        );
811    }
812
813    #[test]
814    fn test_fix_does_not_add_blank_lines_before_lists() {
815        let rule = MD022BlanksAroundHeadings::new();
816        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.";
817
818        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.";
819
820        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
821        let result = rule._fix_content(&ctx);
822        assert_eq!(result, expected, "Fix should not add blank lines before lists");
823    }
824}