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