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