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        ctx.content.is_empty() || ctx.lines.iter().all(|line| line.heading.is_none())
520    }
521
522    fn as_any(&self) -> &dyn std::any::Any {
523        self
524    }
525
526    fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
527        None
528    }
529
530    fn default_config_section(&self) -> Option<(String, toml::Value)> {
531        let default_config = MD022Config::default();
532        let json_value = serde_json::to_value(&default_config).ok()?;
533        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
534
535        if let toml::Value::Table(table) = toml_value {
536            if !table.is_empty() {
537                Some((MD022Config::RULE_NAME.to_string(), toml::Value::Table(table)))
538            } else {
539                None
540            }
541        } else {
542            None
543        }
544    }
545
546    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
547    where
548        Self: Sized,
549    {
550        let rule_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
551        Box::new(Self::from_config_struct(rule_config))
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use crate::lint_context::LintContext;
559
560    #[test]
561    fn test_valid_headings() {
562        let rule = MD022BlanksAroundHeadings::default();
563        let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
564        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
565        let result = rule.check(&ctx).unwrap();
566        assert!(result.is_empty());
567    }
568
569    #[test]
570    fn test_missing_blank_above() {
571        let rule = MD022BlanksAroundHeadings::default();
572        let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
573        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
574        let result = rule.check(&ctx).unwrap();
575        assert_eq!(result.len(), 0); // No warning for first heading
576
577        let fixed = rule.fix(&ctx).unwrap();
578
579        // Test for the ability to handle the content without breaking it
580        // Don't check for exact string equality which may break with implementation changes
581        assert!(fixed.contains("# Heading 1"));
582        assert!(fixed.contains("Some content."));
583        assert!(fixed.contains("## Heading 2"));
584        assert!(fixed.contains("More content."));
585    }
586
587    #[test]
588    fn test_missing_blank_below() {
589        let rule = MD022BlanksAroundHeadings::default();
590        let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
591        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
592        let result = rule.check(&ctx).unwrap();
593        assert_eq!(result.len(), 1);
594        assert_eq!(result[0].line, 2);
595
596        // Test the fix
597        let fixed = rule.fix(&ctx).unwrap();
598        assert!(fixed.contains("# Heading 1\n\nSome content"));
599    }
600
601    #[test]
602    fn test_missing_blank_above_and_below() {
603        let rule = MD022BlanksAroundHeadings::default();
604        let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
605        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
606        let result = rule.check(&ctx).unwrap();
607        assert_eq!(result.len(), 3); // Missing blanks: below first heading, above second heading, below second heading
608
609        // Test the fix
610        let fixed = rule.fix(&ctx).unwrap();
611        assert!(fixed.contains("# Heading 1\n\nSome content"));
612        assert!(fixed.contains("Some content.\n\n## Heading 2"));
613        assert!(fixed.contains("## Heading 2\n\nMore content"));
614    }
615
616    #[test]
617    fn test_fix_headings() {
618        let rule = MD022BlanksAroundHeadings::default();
619        let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
620        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
621        let result = rule.fix(&ctx).unwrap();
622
623        let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
624        assert_eq!(result, expected);
625    }
626
627    #[test]
628    fn test_consecutive_headings_pattern() {
629        let rule = MD022BlanksAroundHeadings::default();
630        let content = "# Heading 1\n## Heading 2\n### Heading 3";
631        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
632        let result = rule.fix(&ctx).unwrap();
633
634        // Using more specific assertions to check the structure
635        let lines: Vec<&str> = result.lines().collect();
636        assert!(!lines.is_empty());
637
638        // Find the positions of the headings
639        let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
640        let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
641        let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
642
643        // Verify blank lines between headings
644        assert!(
645            h2_pos > h1_pos + 1,
646            "Should have at least one blank line after first heading"
647        );
648        assert!(
649            h3_pos > h2_pos + 1,
650            "Should have at least one blank line after second heading"
651        );
652
653        // Verify there's a blank line between h1 and h2
654        assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
655
656        // Verify there's a blank line between h2 and h3
657        assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
658    }
659
660    #[test]
661    fn test_blanks_around_setext_headings() {
662        let rule = MD022BlanksAroundHeadings::default();
663        let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
664        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
665        let result = rule.fix(&ctx).unwrap();
666
667        // Check that the fix follows requirements without being too rigid about the exact output format
668        let lines: Vec<&str> = result.lines().collect();
669
670        // Verify key elements are present
671        assert!(result.contains("Heading 1"));
672        assert!(result.contains("========="));
673        assert!(result.contains("Some content."));
674        assert!(result.contains("Heading 2"));
675        assert!(result.contains("---------"));
676        assert!(result.contains("More content."));
677
678        // Verify structure ensures blank lines are added after headings
679        let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
680        let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
681        assert!(
682            some_content_idx > heading1_marker_idx + 1,
683            "Should have a blank line after the first heading"
684        );
685
686        let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
687        let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
688        assert!(
689            more_content_idx > heading2_marker_idx + 1,
690            "Should have a blank line after the second heading"
691        );
692
693        // Verify that the fixed content has no warnings
694        let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard);
695        let fixed_warnings = rule.check(&fixed_ctx).unwrap();
696        assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
697    }
698
699    #[test]
700    fn test_fix_specific_blank_line_cases() {
701        let rule = MD022BlanksAroundHeadings::default();
702
703        // Case 1: Testing consecutive headings
704        let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
705        let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard);
706        let result1 = rule.fix(&ctx1).unwrap();
707        // Verify structure rather than exact content as the fix implementation may vary
708        assert!(result1.contains("# Heading 1"));
709        assert!(result1.contains("## Heading 2"));
710        assert!(result1.contains("### Heading 3"));
711        // Ensure each heading has a blank line after it
712        let lines: Vec<&str> = result1.lines().collect();
713        let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
714        let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
715        assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
716        assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
717
718        // Case 2: Headings with content
719        let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
720        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard);
721        let result2 = rule.fix(&ctx2).unwrap();
722        // Verify structure
723        assert!(result2.contains("# Heading 1"));
724        assert!(result2.contains("Content under heading 1"));
725        assert!(result2.contains("## Heading 2"));
726        // Check spacing
727        let lines2: Vec<&str> = result2.lines().collect();
728        let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
729        let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
730        assert!(
731            lines2[h1_pos2 + 1].trim().is_empty(),
732            "Should have a blank line after heading 1"
733        );
734
735        // Case 3: Multiple consecutive headings with blank lines preserved
736        let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
737        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard);
738        let result3 = rule.fix(&ctx3).unwrap();
739        // Just verify it doesn't crash and properly formats headings
740        assert!(result3.contains("# Heading 1"));
741        assert!(result3.contains("## Heading 2"));
742        assert!(result3.contains("### Heading 3"));
743        assert!(result3.contains("Content"));
744    }
745
746    #[test]
747    fn test_fix_preserves_existing_blank_lines() {
748        let rule = MD022BlanksAroundHeadings::new();
749        let content = "# Title
750
751## Section 1
752
753Content here.
754
755## Section 2
756
757More content.
758### Missing Blank Above
759
760Even more content.
761
762## Section 3
763
764Final content.";
765
766        let expected = "# Title
767
768## Section 1
769
770Content here.
771
772## Section 2
773
774More content.
775
776### Missing Blank Above
777
778Even more content.
779
780## Section 3
781
782Final content.";
783
784        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
785        let result = rule._fix_content(&ctx);
786        assert_eq!(
787            result, expected,
788            "Fix should only add missing blank lines, never remove existing ones"
789        );
790    }
791
792    #[test]
793    fn test_fix_preserves_trailing_newline() {
794        let rule = MD022BlanksAroundHeadings::new();
795
796        // Test with trailing newline
797        let content_with_newline = "# Title\nContent here.\n";
798        let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard);
799        let result = rule.fix(&ctx).unwrap();
800        assert!(result.ends_with('\n'), "Should preserve trailing newline");
801
802        // Test without trailing newline
803        let content_without_newline = "# Title\nContent here.";
804        let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard);
805        let result = rule.fix(&ctx).unwrap();
806        assert!(
807            !result.ends_with('\n'),
808            "Should not add trailing newline if original didn't have one"
809        );
810    }
811
812    #[test]
813    fn test_fix_does_not_add_blank_lines_before_lists() {
814        let rule = MD022BlanksAroundHeadings::new();
815        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.";
816
817        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.";
818
819        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
820        let result = rule._fix_content(&ctx);
821        assert_eq!(result, expected, "Fix should not add blank lines before lists");
822    }
823}