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        let line_ending = crate::utils::detect_line_ending(ctx.content);
115        let had_trailing_newline = ctx.content.ends_with('\n') || ctx.content.ends_with("\r\n");
116        let mut result = Vec::new();
117        let mut in_front_matter = false;
118        let mut front_matter_delimiter_count = 0;
119        let mut skip_next = false;
120
121        for (i, line_info) in ctx.lines.iter().enumerate() {
122            if skip_next {
123                skip_next = false;
124                continue;
125            }
126            let line = &line_info.content;
127
128            // Handle front matter
129            if line.trim() == "---" {
130                if i == 0 || (i > 0 && ctx.lines[..i].iter().all(|l| l.is_blank)) {
131                    if front_matter_delimiter_count == 0 {
132                        in_front_matter = true;
133                        front_matter_delimiter_count = 1;
134                    }
135                } else if in_front_matter && front_matter_delimiter_count == 1 {
136                    in_front_matter = false;
137                    front_matter_delimiter_count = 2;
138                }
139                result.push(line.to_string());
140                continue;
141            }
142
143            // Inside front matter or code block, preserve content exactly
144            if in_front_matter || line_info.in_code_block {
145                result.push(line.to_string());
146                continue;
147            }
148
149            // Check if it's a heading
150            if let Some(heading) = &line_info.heading {
151                // This is a heading line (ATX or Setext content)
152                let is_first_heading = (0..i).all(|j| {
153                    ctx.lines[j].is_blank
154                        || (j == 0 && ctx.lines[j].content.trim() == "---")
155                        || (in_front_matter && ctx.lines[j].content.trim() == "---")
156                });
157
158                // Count existing blank lines above in the result
159                let mut blank_lines_above = 0;
160                let mut check_idx = result.len();
161                while check_idx > 0 && result[check_idx - 1].trim().is_empty() {
162                    blank_lines_above += 1;
163                    check_idx -= 1;
164                }
165
166                // Determine how many blank lines we need above
167                let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
168                    0
169                } else {
170                    self.config.lines_above
171                };
172
173                // Add missing blank lines above if needed
174                while blank_lines_above < needed_blanks_above {
175                    result.push(String::new());
176                    blank_lines_above += 1;
177                }
178
179                // Add the heading line
180                result.push(line.to_string());
181
182                // For Setext headings, also add the underline immediately
183                if matches!(
184                    heading.style,
185                    crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
186                ) {
187                    // Add the underline (next line)
188                    if i + 1 < ctx.lines.len() {
189                        result.push(ctx.lines[i + 1].content.clone());
190                        skip_next = true; // Skip the underline in the main loop
191                    }
192
193                    // Now check blank lines below the underline
194                    let mut blank_lines_below = 0;
195                    let mut next_content_line_idx = None;
196                    for j in (i + 2)..ctx.lines.len() {
197                        if ctx.lines[j].is_blank {
198                            blank_lines_below += 1;
199                        } else {
200                            next_content_line_idx = Some(j);
201                            break;
202                        }
203                    }
204
205                    // Check if the next non-blank line is special
206                    let next_is_special = if let Some(idx) = next_content_line_idx {
207                        let next_line = &ctx.lines[idx];
208                        next_line.list_item.is_some() || {
209                            let trimmed = next_line.content.trim();
210                            (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
211                                && (trimmed.len() == 3
212                                    || (trimmed.len() > 3
213                                        && trimmed
214                                            .chars()
215                                            .nth(3)
216                                            .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
217                        }
218                    } else {
219                        false
220                    };
221
222                    // Add missing blank lines below if needed
223                    let needed_blanks_below = if next_is_special { 0 } else { self.config.lines_below };
224                    if blank_lines_below < needed_blanks_below {
225                        for _ in 0..(needed_blanks_below - blank_lines_below) {
226                            result.push(String::new());
227                        }
228                    }
229                } else {
230                    // For ATX headings, check blank lines below
231                    let mut blank_lines_below = 0;
232                    let mut next_content_line_idx = None;
233                    for j in (i + 1)..ctx.lines.len() {
234                        if ctx.lines[j].is_blank {
235                            blank_lines_below += 1;
236                        } else {
237                            next_content_line_idx = Some(j);
238                            break;
239                        }
240                    }
241
242                    // Check if the next non-blank line is special
243                    let next_is_special = if let Some(idx) = next_content_line_idx {
244                        let next_line = &ctx.lines[idx];
245                        next_line.list_item.is_some() || {
246                            let trimmed = next_line.content.trim();
247                            (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
248                                && (trimmed.len() == 3
249                                    || (trimmed.len() > 3
250                                        && trimmed
251                                            .chars()
252                                            .nth(3)
253                                            .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
254                        }
255                    } else {
256                        false
257                    };
258
259                    // Add missing blank lines below if needed
260                    let needed_blanks_below = if next_is_special { 0 } else { self.config.lines_below };
261                    if blank_lines_below < needed_blanks_below {
262                        for _ in 0..(needed_blanks_below - blank_lines_below) {
263                            result.push(String::new());
264                        }
265                    }
266                }
267            } else {
268                // Regular line - just add it
269                result.push(line.to_string());
270            }
271        }
272
273        let joined = result.join(line_ending);
274
275        // Preserve original trailing newline behavior
276        if had_trailing_newline && !joined.ends_with('\n') && !joined.ends_with("\r\n") {
277            format!("{joined}{line_ending}")
278        } else if !had_trailing_newline && (joined.ends_with('\n') || joined.ends_with("\r\n")) {
279            // Remove trailing newline if original didn't have one
280            if joined.ends_with("\r\n") {
281                joined[..joined.len() - 2].to_string()
282            } else {
283                joined[..joined.len() - 1].to_string()
284            }
285        } else {
286            joined
287        }
288    }
289}
290
291impl Rule for MD022BlanksAroundHeadings {
292    fn name(&self) -> &'static str {
293        "MD022"
294    }
295
296    fn description(&self) -> &'static str {
297        "Headings should be surrounded by blank lines"
298    }
299
300    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
301        let mut result = Vec::new();
302
303        // Skip if empty document
304        if ctx.lines.is_empty() {
305            return Ok(result);
306        }
307
308        let line_ending = crate::utils::detect_line_ending(ctx.content);
309
310        // Collect all headings first to batch process
311        let mut heading_violations = Vec::new();
312        let mut processed_headings = std::collections::HashSet::new();
313
314        for (line_num, line_info) in ctx.lines.iter().enumerate() {
315            // Skip if already processed or not a heading
316            if processed_headings.contains(&line_num) || line_info.heading.is_none() {
317                continue;
318            }
319
320            let heading = line_info.heading.as_ref().unwrap();
321
322            // For Setext headings, skip the underline line (we process from the content line)
323            if matches!(
324                heading.style,
325                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
326            ) {
327                // Check if this is the underline, not the content
328                if line_num > 0 && ctx.lines[line_num - 1].heading.is_none() {
329                    continue; // This is the underline line
330                }
331            }
332
333            processed_headings.insert(line_num);
334
335            // Check if this is the first heading in the document
336            let is_first_heading = (0..line_num).all(|j| {
337                ctx.lines[j].is_blank ||
338                // Check for front matter lines
339                (j == 0 && ctx.lines[j].content.trim() == "---") ||
340                (j > 0 && ctx.lines[0].content.trim() == "---" && ctx.lines[j].content.trim() == "---")
341            });
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()),
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}