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 in_front_matter = false;
119        let mut front_matter_delimiter_count = 0;
120        let mut skip_next = false;
121
122        for (i, line_info) in ctx.lines.iter().enumerate() {
123            if skip_next {
124                skip_next = false;
125                continue;
126            }
127            let line = &line_info.content;
128
129            // Handle front matter
130            if line.trim() == "---" {
131                if i == 0 || (i > 0 && ctx.lines[..i].iter().all(|l| l.is_blank)) {
132                    if front_matter_delimiter_count == 0 {
133                        in_front_matter = true;
134                        front_matter_delimiter_count = 1;
135                    }
136                } else if in_front_matter && front_matter_delimiter_count == 1 {
137                    in_front_matter = false;
138                    front_matter_delimiter_count = 2;
139                }
140                result.push(line.to_string());
141                continue;
142            }
143
144            // Inside front matter or code block, preserve content exactly
145            if in_front_matter || line_info.in_code_block {
146                result.push(line.to_string());
147                continue;
148            }
149
150            // Check if it's a heading
151            if let Some(heading) = &line_info.heading {
152                // This is a heading line (ATX or Setext content)
153                let is_first_heading = (0..i).all(|j| {
154                    ctx.lines[j].is_blank
155                        || (j == 0 && ctx.lines[j].content.trim() == "---")
156                        || (in_front_matter && ctx.lines[j].content.trim() == "---")
157                });
158
159                // Count existing blank lines above in the result
160                let mut blank_lines_above = 0;
161                let mut check_idx = result.len();
162                while check_idx > 0 && result[check_idx - 1].trim().is_empty() {
163                    blank_lines_above += 1;
164                    check_idx -= 1;
165                }
166
167                // Determine how many blank lines we need above
168                let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
169                    0
170                } else {
171                    self.config.lines_above
172                };
173
174                // Add missing blank lines above if needed
175                while blank_lines_above < needed_blanks_above {
176                    result.push(String::new());
177                    blank_lines_above += 1;
178                }
179
180                // Add the heading line
181                result.push(line.to_string());
182
183                // For Setext headings, also add the underline immediately
184                if matches!(
185                    heading.style,
186                    crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
187                ) {
188                    // Add the underline (next line)
189                    if i + 1 < ctx.lines.len() {
190                        result.push(ctx.lines[i + 1].content.clone());
191                        skip_next = true; // Skip the underline in the main loop
192                    }
193
194                    // Now check blank lines below the underline
195                    let mut blank_lines_below = 0;
196                    let mut next_content_line_idx = None;
197                    for j in (i + 2)..ctx.lines.len() {
198                        if ctx.lines[j].is_blank {
199                            blank_lines_below += 1;
200                        } else {
201                            next_content_line_idx = Some(j);
202                            break;
203                        }
204                    }
205
206                    // Check if the next non-blank line is special
207                    let next_is_special = if let Some(idx) = next_content_line_idx {
208                        let next_line = &ctx.lines[idx];
209                        next_line.list_item.is_some() || {
210                            let trimmed = next_line.content.trim();
211                            (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
212                                && (trimmed.len() == 3
213                                    || (trimmed.len() > 3
214                                        && trimmed
215                                            .chars()
216                                            .nth(3)
217                                            .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
218                        }
219                    } else {
220                        false
221                    };
222
223                    // Add missing blank lines below if needed
224                    let needed_blanks_below = if next_is_special { 0 } else { self.config.lines_below };
225                    if blank_lines_below < needed_blanks_below {
226                        for _ in 0..(needed_blanks_below - blank_lines_below) {
227                            result.push(String::new());
228                        }
229                    }
230                } else {
231                    // For ATX headings, check blank lines below
232                    let mut blank_lines_below = 0;
233                    let mut next_content_line_idx = None;
234                    for j in (i + 1)..ctx.lines.len() {
235                        if ctx.lines[j].is_blank {
236                            blank_lines_below += 1;
237                        } else {
238                            next_content_line_idx = Some(j);
239                            break;
240                        }
241                    }
242
243                    // Check if the next non-blank line is special
244                    let next_is_special = if let Some(idx) = next_content_line_idx {
245                        let next_line = &ctx.lines[idx];
246                        next_line.list_item.is_some() || {
247                            let trimmed = next_line.content.trim();
248                            (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
249                                && (trimmed.len() == 3
250                                    || (trimmed.len() > 3
251                                        && trimmed
252                                            .chars()
253                                            .nth(3)
254                                            .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
255                        }
256                    } else {
257                        false
258                    };
259
260                    // Add missing blank lines below if needed
261                    let needed_blanks_below = if next_is_special { 0 } else { self.config.lines_below };
262                    if blank_lines_below < needed_blanks_below {
263                        for _ in 0..(needed_blanks_below - blank_lines_below) {
264                            result.push(String::new());
265                        }
266                    }
267                }
268            } else {
269                // Regular line - just add it
270                result.push(line.to_string());
271            }
272        }
273
274        let joined = result.join(line_ending);
275
276        // Preserve original trailing newline behavior
277        // Content is normalized to LF at I/O boundary
278        if had_trailing_newline && !joined.ends_with('\n') {
279            format!("{joined}{line_ending}")
280        } else if !had_trailing_newline && joined.ends_with('\n') {
281            // Remove trailing newline if original didn't have one
282            joined[..joined.len() - 1].to_string()
283        } else {
284            joined
285        }
286    }
287}
288
289impl Rule for MD022BlanksAroundHeadings {
290    fn name(&self) -> &'static str {
291        "MD022"
292    }
293
294    fn description(&self) -> &'static str {
295        "Headings should be surrounded by blank lines"
296    }
297
298    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
299        let mut result = Vec::new();
300
301        // Skip if empty document
302        if ctx.lines.is_empty() {
303            return Ok(result);
304        }
305
306        // Content is normalized to LF at I/O boundary
307        let line_ending = "\n";
308
309        // Collect all headings first to batch process
310        let mut heading_violations = Vec::new();
311        let mut processed_headings = std::collections::HashSet::new();
312
313        for (line_num, line_info) in ctx.lines.iter().enumerate() {
314            // Skip if already processed or not a heading
315            if processed_headings.contains(&line_num) || line_info.heading.is_none() {
316                continue;
317            }
318
319            let heading = line_info.heading.as_ref().unwrap();
320
321            // For Setext headings, skip the underline line (we process from the content line)
322            if matches!(
323                heading.style,
324                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
325            ) {
326                // Check if this is the underline, not the content
327                if line_num > 0 && ctx.lines[line_num - 1].heading.is_none() {
328                    continue; // This is the underline line
329                }
330            }
331
332            processed_headings.insert(line_num);
333
334            // Check if this is the first heading in the document
335            let is_first_heading = (0..line_num).all(|j| {
336                ctx.lines[j].is_blank ||
337                // Check for front matter lines
338                (j == 0 && ctx.lines[j].content.trim() == "---") ||
339                (j > 0 && ctx.lines[0].content.trim() == "---" && ctx.lines[j].content.trim() == "---")
340            });
341
342            // Count blank lines above
343            let blank_lines_above = if line_num > 0 && (!is_first_heading || !self.config.allowed_at_start) {
344                let mut count = 0;
345                for j in (0..line_num).rev() {
346                    if ctx.lines[j].is_blank {
347                        count += 1;
348                    } else {
349                        break;
350                    }
351                }
352                count
353            } else {
354                self.config.lines_above // Consider it as having enough blanks if it's the first heading
355            };
356
357            // Check if we need blank lines above
358            if line_num > 0
359                && blank_lines_above < self.config.lines_above
360                && (!is_first_heading || !self.config.allowed_at_start)
361            {
362                let needed_blanks = self.config.lines_above - blank_lines_above;
363                heading_violations.push((line_num, "above", needed_blanks));
364            }
365
366            // Determine the effective last line of the heading
367            let effective_last_line = if matches!(
368                heading.style,
369                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
370            ) {
371                line_num + 1 // For Setext, include the underline
372            } else {
373                line_num
374            };
375
376            // Check blank lines below
377            if effective_last_line < ctx.lines.len() - 1 {
378                // Find next non-blank line
379                let mut next_non_blank_idx = effective_last_line + 1;
380                while next_non_blank_idx < ctx.lines.len() && ctx.lines[next_non_blank_idx].is_blank {
381                    next_non_blank_idx += 1;
382                }
383
384                // Check if next line is a code fence or list item
385                let next_line_is_special = next_non_blank_idx < ctx.lines.len() && {
386                    let next_line = &ctx.lines[next_non_blank_idx];
387                    let next_trimmed = next_line.content.trim();
388
389                    // Check for code fence
390                    let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
391                        && (next_trimmed.len() == 3
392                            || (next_trimmed.len() > 3
393                                && next_trimmed
394                                    .chars()
395                                    .nth(3)
396                                    .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
397
398                    // Check for list item
399                    let is_list_item = next_line.list_item.is_some();
400
401                    is_code_fence || is_list_item
402                };
403
404                // Only generate warning if next line is NOT a code fence or list item
405                if !next_line_is_special {
406                    // Count blank lines below
407                    let blank_lines_below = next_non_blank_idx - effective_last_line - 1;
408
409                    if blank_lines_below < self.config.lines_below {
410                        let needed_blanks = self.config.lines_below - blank_lines_below;
411                        heading_violations.push((line_num, "below", needed_blanks));
412                    }
413                }
414            }
415        }
416
417        // Generate warnings for all violations
418        for (heading_line, position, needed_blanks) in heading_violations {
419            let heading_display_line = heading_line + 1; // 1-indexed for display
420            let line_info = &ctx.lines[heading_line];
421
422            // Calculate precise character range for the heading
423            let (start_line, start_col, end_line, end_col) =
424                calculate_heading_range(heading_display_line, &line_info.content);
425
426            let (message, insertion_point) = match position {
427                "above" => (
428                    format!(
429                        "Expected {} blank {} above heading",
430                        self.config.lines_above,
431                        if self.config.lines_above == 1 { "line" } else { "lines" }
432                    ),
433                    heading_line, // Insert before the heading line
434                ),
435                "below" => {
436                    // For Setext headings, insert after the underline
437                    let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
438                        matches!(
439                            h.style,
440                            crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
441                        )
442                    }) {
443                        heading_line + 2
444                    } else {
445                        heading_line + 1
446                    };
447
448                    (
449                        format!(
450                            "Expected {} blank {} below heading",
451                            self.config.lines_below,
452                            if self.config.lines_below == 1 { "line" } else { "lines" }
453                        ),
454                        insert_after,
455                    )
456                }
457                _ => continue,
458            };
459
460            // Calculate byte range for insertion
461            let byte_range = if insertion_point == 0 && position == "above" {
462                // Insert at beginning of document (only for "above" case at line 0)
463                0..0
464            } else if position == "above" && insertion_point > 0 {
465                // For "above", insert at the start of the heading line
466                ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
467            } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
468                // For "below", insert after the line
469                let line_idx = insertion_point - 1;
470                let line_end_offset = if line_idx + 1 < ctx.lines.len() {
471                    ctx.lines[line_idx + 1].byte_offset
472                } else {
473                    ctx.content.len()
474                };
475                line_end_offset..line_end_offset
476            } else {
477                // Insert at end of file
478                let content_len = ctx.content.len();
479                content_len..content_len
480            };
481
482            result.push(LintWarning {
483                rule_name: Some(self.name().to_string()),
484                message,
485                line: start_line,
486                column: start_col,
487                end_line,
488                end_column: end_col,
489                severity: Severity::Warning,
490                fix: Some(Fix {
491                    range: byte_range,
492                    replacement: line_ending.repeat(needed_blanks),
493                }),
494            });
495        }
496
497        Ok(result)
498    }
499
500    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
501        if ctx.content.is_empty() {
502            return Ok(ctx.content.to_string());
503        }
504
505        // Use a consolidated fix that avoids adding multiple blank lines
506        let fixed = self._fix_content(ctx);
507
508        Ok(fixed)
509    }
510
511    /// Get the category of this rule for selective processing
512    fn category(&self) -> RuleCategory {
513        RuleCategory::Heading
514    }
515
516    /// Check if this rule should be skipped
517    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
518        // Fast path: check if document likely has headings
519        if ctx.content.is_empty() || !ctx.likely_has_headings() {
520            return true;
521        }
522        // Verify headings actually exist
523        ctx.lines.iter().all(|line| line.heading.is_none())
524    }
525
526    fn as_any(&self) -> &dyn std::any::Any {
527        self
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}