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 (applies to all heading levels)
98    pub fn with_values(lines_above: usize, lines_below: usize) -> Self {
99        use md022_config::HeadingLevelConfig;
100        Self {
101            config: MD022Config {
102                lines_above: HeadingLevelConfig::scalar(lines_above),
103                lines_below: HeadingLevelConfig::scalar(lines_below),
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                let heading_level = heading.level as usize;
152
153                // Count existing blank lines above in the result
154                let mut blank_lines_above = 0;
155                let mut check_idx = result.len();
156                while check_idx > 0 && result[check_idx - 1].trim().is_empty() {
157                    blank_lines_above += 1;
158                    check_idx -= 1;
159                }
160
161                // Determine how many blank lines we need above
162                let requirement_above = self.config.lines_above.get_for_level(heading_level);
163                let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
164                    0
165                } else {
166                    requirement_above.required_count().unwrap_or(0)
167                };
168
169                // Add missing blank lines above if needed
170                while blank_lines_above < needed_blanks_above {
171                    result.push(String::new());
172                    blank_lines_above += 1;
173                }
174
175                // Add the heading line
176                result.push(line.to_string());
177
178                // For Setext headings, also add the underline immediately
179                if matches!(
180                    heading.style,
181                    crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
182                ) {
183                    // Add the underline (next line)
184                    if i + 1 < ctx.lines.len() {
185                        result.push(ctx.lines[i + 1].content(ctx.content).to_string());
186                        skip_next = true; // Skip the underline in the main loop
187                    }
188
189                    // Now check blank lines below the underline
190                    let mut blank_lines_below = 0;
191                    let mut next_content_line_idx = None;
192                    for j in (i + 2)..ctx.lines.len() {
193                        if ctx.lines[j].is_blank {
194                            blank_lines_below += 1;
195                        } else {
196                            next_content_line_idx = Some(j);
197                            break;
198                        }
199                    }
200
201                    // Check if the next non-blank line is special
202                    let next_is_special = if let Some(idx) = next_content_line_idx {
203                        let next_line = &ctx.lines[idx];
204                        next_line.list_item.is_some() || {
205                            let trimmed = next_line.content(ctx.content).trim();
206                            (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
207                                && (trimmed.len() == 3
208                                    || (trimmed.len() > 3
209                                        && trimmed
210                                            .chars()
211                                            .nth(3)
212                                            .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
213                        }
214                    } else {
215                        false
216                    };
217
218                    // Add missing blank lines below if needed
219                    let requirement_below = self.config.lines_below.get_for_level(heading_level);
220                    let needed_blanks_below = if next_is_special {
221                        0
222                    } else {
223                        requirement_below.required_count().unwrap_or(0)
224                    };
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(ctx.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 requirement_below = self.config.lines_below.get_for_level(heading_level);
262                    let needed_blanks_below = if next_is_special {
263                        0
264                    } else {
265                        requirement_below.required_count().unwrap_or(0)
266                    };
267                    if blank_lines_below < needed_blanks_below {
268                        for _ in 0..(needed_blanks_below - blank_lines_below) {
269                            result.push(String::new());
270                        }
271                    }
272                }
273            } else {
274                // Regular line - just add it
275                result.push(line.to_string());
276            }
277        }
278
279        let joined = result.join(line_ending);
280
281        // Preserve original trailing newline behavior
282        // Content is normalized to LF at I/O boundary
283        if had_trailing_newline && !joined.ends_with('\n') {
284            format!("{joined}{line_ending}")
285        } else if !had_trailing_newline && joined.ends_with('\n') {
286            // Remove trailing newline if original didn't have one
287            joined[..joined.len() - 1].to_string()
288        } else {
289            joined
290        }
291    }
292}
293
294impl Rule for MD022BlanksAroundHeadings {
295    fn name(&self) -> &'static str {
296        "MD022"
297    }
298
299    fn description(&self) -> &'static str {
300        "Headings should be surrounded by blank lines"
301    }
302
303    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
304        let mut result = Vec::new();
305
306        // Skip if empty document
307        if ctx.lines.is_empty() {
308            return Ok(result);
309        }
310
311        // Content is normalized to LF at I/O boundary
312        let line_ending = "\n";
313
314        let heading_at_start_idx = {
315            let mut found_non_blank = false;
316            ctx.lines.iter().enumerate().find_map(|(i, line)| {
317                if line.heading.is_some() && !found_non_blank {
318                    Some(i)
319                } else {
320                    if !line.is_blank {
321                        found_non_blank = true;
322                    }
323                    None
324                }
325            })
326        };
327
328        // Collect all headings first to batch process
329        let mut heading_violations = Vec::new();
330        let mut processed_headings = std::collections::HashSet::new();
331
332        for (line_num, line_info) in ctx.lines.iter().enumerate() {
333            // Skip if already processed or not a heading
334            if processed_headings.contains(&line_num) || line_info.heading.is_none() {
335                continue;
336            }
337
338            let heading = line_info.heading.as_ref().unwrap();
339            let heading_level = heading.level as usize;
340
341            // For Setext headings, skip the underline line (we process from the content line)
342            if matches!(
343                heading.style,
344                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
345            ) {
346                // Check if this is the underline, not the content
347                if line_num > 0 && ctx.lines[line_num - 1].heading.is_none() {
348                    continue; // This is the underline line
349                }
350            }
351
352            processed_headings.insert(line_num);
353
354            // Check if this heading is at document start
355            let is_first_heading = Some(line_num) == heading_at_start_idx;
356
357            // Get configured blank line requirements for this heading level
358            let required_above_count = self.config.lines_above.get_for_level(heading_level).required_count();
359            let required_below_count = self.config.lines_below.get_for_level(heading_level).required_count();
360
361            // Count blank lines above if needed
362            let should_check_above =
363                required_above_count.is_some() && line_num > 0 && (!is_first_heading || !self.config.allowed_at_start);
364            if should_check_above {
365                let mut blank_lines_above = 0;
366                for j in (0..line_num).rev() {
367                    if ctx.lines[j].is_blank {
368                        blank_lines_above += 1;
369                    } else {
370                        break;
371                    }
372                }
373                let required = required_above_count.unwrap();
374                if blank_lines_above < required {
375                    let needed_blanks = required - blank_lines_above;
376                    heading_violations.push((line_num, "above", needed_blanks, heading_level));
377                }
378            }
379
380            // Determine the effective last line of the heading
381            let effective_last_line = if matches!(
382                heading.style,
383                crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
384            ) {
385                line_num + 1 // For Setext, include the underline
386            } else {
387                line_num
388            };
389
390            // Check blank lines below
391            if effective_last_line < ctx.lines.len() - 1 {
392                // Find next non-blank line
393                let mut next_non_blank_idx = effective_last_line + 1;
394                while next_non_blank_idx < ctx.lines.len() && ctx.lines[next_non_blank_idx].is_blank {
395                    next_non_blank_idx += 1;
396                }
397
398                // Check if next line is a code fence or list item
399                let next_line_is_special = next_non_blank_idx < ctx.lines.len() && {
400                    let next_line = &ctx.lines[next_non_blank_idx];
401                    let next_trimmed = next_line.content(ctx.content).trim();
402
403                    // Check for code fence
404                    let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
405                        && (next_trimmed.len() == 3
406                            || (next_trimmed.len() > 3
407                                && next_trimmed
408                                    .chars()
409                                    .nth(3)
410                                    .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
411
412                    // Check for list item
413                    let is_list_item = next_line.list_item.is_some();
414
415                    is_code_fence || is_list_item
416                };
417
418                // Only generate warning if next line is NOT a code fence or list item
419                if !next_line_is_special && let Some(required) = required_below_count {
420                    // Count blank lines below
421                    let blank_lines_below = next_non_blank_idx - effective_last_line - 1;
422
423                    if blank_lines_below < required {
424                        let needed_blanks = required - blank_lines_below;
425                        heading_violations.push((line_num, "below", needed_blanks, heading_level));
426                    }
427                }
428            }
429        }
430
431        // Generate warnings for all violations
432        for (heading_line, position, needed_blanks, heading_level) in heading_violations {
433            let heading_display_line = heading_line + 1; // 1-indexed for display
434            let line_info = &ctx.lines[heading_line];
435
436            // Calculate precise character range for the heading
437            let (start_line, start_col, end_line, end_col) =
438                calculate_heading_range(heading_display_line, line_info.content(ctx.content));
439
440            let required_above_count = self
441                .config
442                .lines_above
443                .get_for_level(heading_level)
444                .required_count()
445                .expect("Violations only generated for limited 'above' requirements");
446            let required_below_count = self
447                .config
448                .lines_below
449                .get_for_level(heading_level)
450                .required_count()
451                .expect("Violations only generated for limited 'below' requirements");
452
453            let (message, insertion_point) = match position {
454                "above" => (
455                    format!(
456                        "Expected {} blank {} above heading",
457                        required_above_count,
458                        if required_above_count == 1 { "line" } else { "lines" }
459                    ),
460                    heading_line, // Insert before the heading line
461                ),
462                "below" => {
463                    // For Setext headings, insert after the underline
464                    let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
465                        matches!(
466                            h.style,
467                            crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
468                        )
469                    }) {
470                        heading_line + 2
471                    } else {
472                        heading_line + 1
473                    };
474
475                    (
476                        format!(
477                            "Expected {} blank {} below heading",
478                            required_below_count,
479                            if required_below_count == 1 { "line" } else { "lines" }
480                        ),
481                        insert_after,
482                    )
483                }
484                _ => continue,
485            };
486
487            // Calculate byte range for insertion
488            let byte_range = if insertion_point == 0 && position == "above" {
489                // Insert at beginning of document (only for "above" case at line 0)
490                0..0
491            } else if position == "above" && insertion_point > 0 {
492                // For "above", insert at the start of the heading line
493                ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
494            } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
495                // For "below", insert after the line
496                let line_idx = insertion_point - 1;
497                let line_end_offset = if line_idx + 1 < ctx.lines.len() {
498                    ctx.lines[line_idx + 1].byte_offset
499                } else {
500                    ctx.content.len()
501                };
502                line_end_offset..line_end_offset
503            } else {
504                // Insert at end of file
505                let content_len = ctx.content.len();
506                content_len..content_len
507            };
508
509            result.push(LintWarning {
510                rule_name: Some(self.name().to_string()),
511                message,
512                line: start_line,
513                column: start_col,
514                end_line,
515                end_column: end_col,
516                severity: Severity::Warning,
517                fix: Some(Fix {
518                    range: byte_range,
519                    replacement: line_ending.repeat(needed_blanks),
520                }),
521            });
522        }
523
524        Ok(result)
525    }
526
527    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
528        if ctx.content.is_empty() {
529            return Ok(ctx.content.to_string());
530        }
531
532        // Use a consolidated fix that avoids adding multiple blank lines
533        let fixed = self._fix_content(ctx);
534
535        Ok(fixed)
536    }
537
538    /// Get the category of this rule for selective processing
539    fn category(&self) -> RuleCategory {
540        RuleCategory::Heading
541    }
542
543    /// Check if this rule should be skipped
544    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
545        // Fast path: check if document likely has headings
546        if ctx.content.is_empty() || !ctx.likely_has_headings() {
547            return true;
548        }
549        // Verify headings actually exist
550        ctx.lines.iter().all(|line| line.heading.is_none())
551    }
552
553    fn as_any(&self) -> &dyn std::any::Any {
554        self
555    }
556
557    fn default_config_section(&self) -> Option<(String, toml::Value)> {
558        let default_config = MD022Config::default();
559        let json_value = serde_json::to_value(&default_config).ok()?;
560        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
561
562        if let toml::Value::Table(table) = toml_value {
563            if !table.is_empty() {
564                Some((MD022Config::RULE_NAME.to_string(), toml::Value::Table(table)))
565            } else {
566                None
567            }
568        } else {
569            None
570        }
571    }
572
573    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
574    where
575        Self: Sized,
576    {
577        let rule_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
578        Box::new(Self::from_config_struct(rule_config))
579    }
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585    use crate::lint_context::LintContext;
586
587    #[test]
588    fn test_valid_headings() {
589        let rule = MD022BlanksAroundHeadings::default();
590        let content = "\n# 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!(result.is_empty());
594    }
595
596    #[test]
597    fn test_missing_blank_above() {
598        let rule = MD022BlanksAroundHeadings::default();
599        let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
600        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
601        let result = rule.check(&ctx).unwrap();
602        assert_eq!(result.len(), 0); // No warning for first heading
603
604        let fixed = rule.fix(&ctx).unwrap();
605
606        // Test for the ability to handle the content without breaking it
607        // Don't check for exact string equality which may break with implementation changes
608        assert!(fixed.contains("# Heading 1"));
609        assert!(fixed.contains("Some content."));
610        assert!(fixed.contains("## Heading 2"));
611        assert!(fixed.contains("More content."));
612    }
613
614    #[test]
615    fn test_missing_blank_below() {
616        let rule = MD022BlanksAroundHeadings::default();
617        let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
618        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
619        let result = rule.check(&ctx).unwrap();
620        assert_eq!(result.len(), 1);
621        assert_eq!(result[0].line, 2);
622
623        // Test the fix
624        let fixed = rule.fix(&ctx).unwrap();
625        assert!(fixed.contains("# Heading 1\n\nSome content"));
626    }
627
628    #[test]
629    fn test_missing_blank_above_and_below() {
630        let rule = MD022BlanksAroundHeadings::default();
631        let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
632        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
633        let result = rule.check(&ctx).unwrap();
634        assert_eq!(result.len(), 3); // Missing blanks: below first heading, above second heading, below second heading
635
636        // Test the fix
637        let fixed = rule.fix(&ctx).unwrap();
638        assert!(fixed.contains("# Heading 1\n\nSome content"));
639        assert!(fixed.contains("Some content.\n\n## Heading 2"));
640        assert!(fixed.contains("## Heading 2\n\nMore content"));
641    }
642
643    #[test]
644    fn test_fix_headings() {
645        let rule = MD022BlanksAroundHeadings::default();
646        let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
647        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
648        let result = rule.fix(&ctx).unwrap();
649
650        let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
651        assert_eq!(result, expected);
652    }
653
654    #[test]
655    fn test_consecutive_headings_pattern() {
656        let rule = MD022BlanksAroundHeadings::default();
657        let content = "# Heading 1\n## Heading 2\n### Heading 3";
658        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
659        let result = rule.fix(&ctx).unwrap();
660
661        // Using more specific assertions to check the structure
662        let lines: Vec<&str> = result.lines().collect();
663        assert!(!lines.is_empty());
664
665        // Find the positions of the headings
666        let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
667        let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
668        let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
669
670        // Verify blank lines between headings
671        assert!(
672            h2_pos > h1_pos + 1,
673            "Should have at least one blank line after first heading"
674        );
675        assert!(
676            h3_pos > h2_pos + 1,
677            "Should have at least one blank line after second heading"
678        );
679
680        // Verify there's a blank line between h1 and h2
681        assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
682
683        // Verify there's a blank line between h2 and h3
684        assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
685    }
686
687    #[test]
688    fn test_blanks_around_setext_headings() {
689        let rule = MD022BlanksAroundHeadings::default();
690        let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
691        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
692        let result = rule.fix(&ctx).unwrap();
693
694        // Check that the fix follows requirements without being too rigid about the exact output format
695        let lines: Vec<&str> = result.lines().collect();
696
697        // Verify key elements are present
698        assert!(result.contains("Heading 1"));
699        assert!(result.contains("========="));
700        assert!(result.contains("Some content."));
701        assert!(result.contains("Heading 2"));
702        assert!(result.contains("---------"));
703        assert!(result.contains("More content."));
704
705        // Verify structure ensures blank lines are added after headings
706        let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
707        let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
708        assert!(
709            some_content_idx > heading1_marker_idx + 1,
710            "Should have a blank line after the first heading"
711        );
712
713        let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
714        let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
715        assert!(
716            more_content_idx > heading2_marker_idx + 1,
717            "Should have a blank line after the second heading"
718        );
719
720        // Verify that the fixed content has no warnings
721        let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard);
722        let fixed_warnings = rule.check(&fixed_ctx).unwrap();
723        assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
724    }
725
726    #[test]
727    fn test_fix_specific_blank_line_cases() {
728        let rule = MD022BlanksAroundHeadings::default();
729
730        // Case 1: Testing consecutive headings
731        let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
732        let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard);
733        let result1 = rule.fix(&ctx1).unwrap();
734        // Verify structure rather than exact content as the fix implementation may vary
735        assert!(result1.contains("# Heading 1"));
736        assert!(result1.contains("## Heading 2"));
737        assert!(result1.contains("### Heading 3"));
738        // Ensure each heading has a blank line after it
739        let lines: Vec<&str> = result1.lines().collect();
740        let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
741        let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
742        assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
743        assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
744
745        // Case 2: Headings with content
746        let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
747        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard);
748        let result2 = rule.fix(&ctx2).unwrap();
749        // Verify structure
750        assert!(result2.contains("# Heading 1"));
751        assert!(result2.contains("Content under heading 1"));
752        assert!(result2.contains("## Heading 2"));
753        // Check spacing
754        let lines2: Vec<&str> = result2.lines().collect();
755        let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
756        let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
757        assert!(
758            lines2[h1_pos2 + 1].trim().is_empty(),
759            "Should have a blank line after heading 1"
760        );
761
762        // Case 3: Multiple consecutive headings with blank lines preserved
763        let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
764        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard);
765        let result3 = rule.fix(&ctx3).unwrap();
766        // Just verify it doesn't crash and properly formats headings
767        assert!(result3.contains("# Heading 1"));
768        assert!(result3.contains("## Heading 2"));
769        assert!(result3.contains("### Heading 3"));
770        assert!(result3.contains("Content"));
771    }
772
773    #[test]
774    fn test_fix_preserves_existing_blank_lines() {
775        let rule = MD022BlanksAroundHeadings::new();
776        let content = "# Title
777
778## Section 1
779
780Content here.
781
782## Section 2
783
784More content.
785### Missing Blank Above
786
787Even more content.
788
789## Section 3
790
791Final content.";
792
793        let expected = "# Title
794
795## Section 1
796
797Content here.
798
799## Section 2
800
801More content.
802
803### Missing Blank Above
804
805Even more content.
806
807## Section 3
808
809Final content.";
810
811        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
812        let result = rule._fix_content(&ctx);
813        assert_eq!(
814            result, expected,
815            "Fix should only add missing blank lines, never remove existing ones"
816        );
817    }
818
819    #[test]
820    fn test_fix_preserves_trailing_newline() {
821        let rule = MD022BlanksAroundHeadings::new();
822
823        // Test with trailing newline
824        let content_with_newline = "# Title\nContent here.\n";
825        let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard);
826        let result = rule.fix(&ctx).unwrap();
827        assert!(result.ends_with('\n'), "Should preserve trailing newline");
828
829        // Test without trailing newline
830        let content_without_newline = "# Title\nContent here.";
831        let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard);
832        let result = rule.fix(&ctx).unwrap();
833        assert!(
834            !result.ends_with('\n'),
835            "Should not add trailing newline if original didn't have one"
836        );
837    }
838
839    #[test]
840    fn test_fix_does_not_add_blank_lines_before_lists() {
841        let rule = MD022BlanksAroundHeadings::new();
842        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.";
843
844        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.";
845
846        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
847        let result = rule._fix_content(&ctx);
848        assert_eq!(result, expected, "Fix should not add blank lines before lists");
849    }
850
851    #[test]
852    fn test_per_level_configuration_no_blank_above_h1() {
853        use md022_config::HeadingLevelConfig;
854
855        // Configure: no blank above H1, 1 blank above H2-H6
856        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
857            lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
858            lines_below: HeadingLevelConfig::scalar(1),
859            allowed_at_start: false, // Disable special handling for first heading
860        });
861
862        // H1 without blank above should be OK
863        let content = "Some text\n# Heading 1\n\nMore text";
864        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
865        let warnings = rule.check(&ctx).unwrap();
866        assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
867
868        // H2 without blank above should trigger warning
869        let content = "Some text\n## Heading 2\n\nMore text";
870        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
871        let warnings = rule.check(&ctx).unwrap();
872        assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
873        assert!(warnings[0].message.contains("above"));
874    }
875
876    #[test]
877    fn test_per_level_configuration_different_requirements() {
878        use md022_config::HeadingLevelConfig;
879
880        // Configure: 0 blank above H1, 1 above H2-H3, 2 above H4-H6
881        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
882            lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
883            lines_below: HeadingLevelConfig::scalar(1),
884            allowed_at_start: false,
885        });
886
887        let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
888        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
889        let warnings = rule.check(&ctx).unwrap();
890
891        // Should have no warnings - all headings satisfy their level-specific requirements
892        assert_eq!(
893            warnings.len(),
894            0,
895            "All headings should satisfy level-specific requirements"
896        );
897    }
898
899    #[test]
900    fn test_per_level_configuration_violations() {
901        use md022_config::HeadingLevelConfig;
902
903        // Configure: H4 needs 2 blanks above
904        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
905            lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
906            lines_below: HeadingLevelConfig::scalar(1),
907            allowed_at_start: false,
908        });
909
910        // H4 with only 1 blank above should trigger warning
911        let content = "Text\n\n#### Heading 4\n\nMore text";
912        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
913        let warnings = rule.check(&ctx).unwrap();
914
915        assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
916        assert!(warnings[0].message.contains("2 blank lines above"));
917    }
918
919    #[test]
920    fn test_per_level_fix_different_levels() {
921        use md022_config::HeadingLevelConfig;
922
923        // Configure: 0 blank above H1, 1 above H2, 2 above H3+
924        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
925            lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
926            lines_below: HeadingLevelConfig::scalar(1),
927            allowed_at_start: false,
928        });
929
930        let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
931        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
932        let fixed = rule.fix(&ctx).unwrap();
933
934        // Verify structure: H1 gets 0 blanks above, H2 gets 1, H3 gets 2
935        assert!(fixed.contains("Text\n# H1\n\nContent"));
936        assert!(fixed.contains("Content\n\n## H2\n\nContent"));
937        assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
938    }
939
940    #[test]
941    fn test_per_level_below_configuration() {
942        use md022_config::HeadingLevelConfig;
943
944        // Configure: different blank line requirements below headings
945        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
946            lines_above: HeadingLevelConfig::scalar(1),
947            lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), // H1 needs 2 blanks below
948            allowed_at_start: true,
949        });
950
951        // H1 with only 1 blank below should trigger warning
952        let content = "# Heading 1\n\nSome text";
953        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
954        let warnings = rule.check(&ctx).unwrap();
955
956        assert_eq!(
957            warnings.len(),
958            1,
959            "H1 with insufficient blanks below should trigger warning"
960        );
961        assert!(warnings[0].message.contains("2 blank lines below"));
962    }
963
964    #[test]
965    fn test_scalar_configuration_still_works() {
966        use md022_config::HeadingLevelConfig;
967
968        // Ensure scalar configuration still works (backward compatibility)
969        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
970            lines_above: HeadingLevelConfig::scalar(2),
971            lines_below: HeadingLevelConfig::scalar(2),
972            allowed_at_start: false,
973        });
974
975        let content = "Text\n# H1\nContent\n## H2\nContent";
976        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
977        let warnings = rule.check(&ctx).unwrap();
978
979        // All headings should need 2 blanks above and below
980        assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
981    }
982
983    #[test]
984    fn test_unlimited_configuration_skips_requirements() {
985        use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
986
987        // H1 can have any number of blank lines above/below; others require defaults
988        let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
989            lines_above: HeadingLevelConfig::per_level_requirements([
990                HeadingBlankRequirement::unlimited(),
991                HeadingBlankRequirement::limited(1),
992                HeadingBlankRequirement::limited(1),
993                HeadingBlankRequirement::limited(1),
994                HeadingBlankRequirement::limited(1),
995                HeadingBlankRequirement::limited(1),
996            ]),
997            lines_below: HeadingLevelConfig::per_level_requirements([
998                HeadingBlankRequirement::unlimited(),
999                HeadingBlankRequirement::limited(1),
1000                HeadingBlankRequirement::limited(1),
1001                HeadingBlankRequirement::limited(1),
1002                HeadingBlankRequirement::limited(1),
1003                HeadingBlankRequirement::limited(1),
1004            ]),
1005            allowed_at_start: false,
1006        });
1007
1008        let content = "# H1\nParagraph\n## H2\nParagraph";
1009        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1010        let warnings = rule.check(&ctx).unwrap();
1011
1012        // H1 has no blanks above/below but is unlimited; H2 should get violations
1013        assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1014        assert!(
1015            warnings.iter().all(|w| w.line >= 3),
1016            "Warnings should target later headings"
1017        );
1018
1019        // Fixing should insert blanks around H2 but leave H1 untouched
1020        let fixed = rule.fix(&ctx).unwrap();
1021        assert!(
1022            fixed.starts_with("# H1\nParagraph\n\n## H2"),
1023            "H1 should remain unchanged"
1024        );
1025    }
1026}