rumdl_lib/rules/
md001_heading_increment.rs

1use crate::HeadingStyle;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::rules::heading_utils::HeadingUtils;
4use crate::utils::range_utils::{LineIndex, calculate_heading_range};
5
6/// Rule MD001: Heading levels should only increment by one level at a time
7///
8/// See [docs/md001.md](../../docs/md001.md) for full documentation, configuration, and examples.
9///
10/// This rule enforces a fundamental principle of document structure: heading levels
11/// should increase by exactly one level at a time to maintain a proper document hierarchy.
12///
13/// ## Purpose
14///
15/// Proper heading structure creates a logical document outline and improves:
16/// - Readability for humans
17/// - Accessibility for screen readers
18/// - Navigation in rendered documents
19/// - Automatic generation of tables of contents
20///
21/// ## Examples
22///
23/// ### Correct Heading Structure
24/// ```markdown
25/// # Heading 1
26/// ## Heading 2
27/// ### Heading 3
28/// ## Another Heading 2
29/// ```
30///
31/// ### Incorrect Heading Structure
32/// ```markdown
33/// # Heading 1
34/// ### Heading 3 (skips level 2)
35/// #### Heading 4
36/// ```
37///
38/// ## Behavior
39///
40/// This rule:
41/// - Tracks the heading level throughout the document
42/// - Validates that each new heading is at most one level deeper than the previous heading
43/// - Allows heading levels to decrease by any amount (e.g., going from ### to #)
44/// - Works with both ATX (`#`) and Setext (underlined) heading styles
45///
46/// ## Fix Behavior
47///
48/// When applying automatic fixes, this rule:
49/// - Changes the level of non-compliant headings to be one level deeper than the previous heading
50/// - Preserves the original heading style (ATX or Setext)
51/// - Maintains indentation and other formatting
52///
53/// ## Rationale
54///
55/// Skipping heading levels (e.g., from `h1` to `h3`) can confuse readers and screen readers
56/// by creating gaps in the document structure. Consistent heading increments create a proper
57/// hierarchical outline essential for well-structured documents.
58///
59#[derive(Debug, Default, Clone)]
60pub struct MD001HeadingIncrement;
61
62impl Rule for MD001HeadingIncrement {
63    fn name(&self) -> &'static str {
64        "MD001"
65    }
66
67    fn description(&self) -> &'static str {
68        "Heading levels should only increment by one level at a time"
69    }
70
71    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
72        let line_index = LineIndex::new(ctx.content.to_string());
73        let mut warnings = Vec::new();
74        let mut prev_level: Option<usize> = None;
75
76        // Process headings using cached heading information
77        for (line_num, line_info) in ctx.lines.iter().enumerate() {
78            if let Some(heading) = &line_info.heading {
79                let level = heading.level as usize;
80
81                // Check if this heading level is more than one level deeper than the previous
82                if let Some(prev) = prev_level
83                    && level > prev + 1
84                {
85                    let indentation = line_info.indent;
86                    let heading_text = &heading.text;
87
88                    // Map heading style
89                    let style = match heading.style {
90                        crate::lint_context::HeadingStyle::ATX => HeadingStyle::Atx,
91                        crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
92                        crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
93                    };
94
95                    // Create a fix with the correct heading level
96                    let fixed_level = prev + 1;
97                    let replacement = HeadingUtils::convert_heading_style(heading_text, fixed_level as u32, style);
98
99                    // Calculate precise range: highlight the entire heading
100                    let line_content = &line_info.content;
101                    let (start_line, start_col, end_line, end_col) =
102                        calculate_heading_range(line_num + 1, line_content);
103
104                    warnings.push(LintWarning {
105                        rule_name: Some(self.name()),
106                        line: start_line,
107                        column: start_col,
108                        end_line,
109                        end_column: end_col,
110                        message: format!("Expected heading level {}, but found heading level {}", prev + 1, level),
111                        severity: Severity::Warning,
112                        fix: Some(Fix {
113                            range: line_index.line_content_range(line_num + 1),
114                            replacement: format!("{}{}", " ".repeat(indentation), replacement),
115                        }),
116                    });
117                }
118
119                prev_level = Some(level);
120            }
121        }
122
123        Ok(warnings)
124    }
125
126    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
127        let mut fixed_lines = Vec::new();
128        let mut prev_level: Option<usize> = None;
129
130        for line_info in ctx.lines.iter() {
131            if let Some(heading) = &line_info.heading {
132                let level = heading.level as usize;
133                let mut fixed_level = level;
134
135                // Check if this heading needs fixing
136                if let Some(prev) = prev_level
137                    && level > prev + 1
138                {
139                    fixed_level = prev + 1;
140                }
141
142                // Map heading style - when fixing, we may need to change Setext style based on level
143                let style = match heading.style {
144                    crate::lint_context::HeadingStyle::ATX => HeadingStyle::Atx,
145                    crate::lint_context::HeadingStyle::Setext1 => {
146                        if fixed_level == 1 {
147                            HeadingStyle::Setext1
148                        } else {
149                            HeadingStyle::Setext2
150                        }
151                    }
152                    crate::lint_context::HeadingStyle::Setext2 => {
153                        if fixed_level == 1 {
154                            HeadingStyle::Setext1
155                        } else {
156                            HeadingStyle::Setext2
157                        }
158                    }
159                };
160
161                let replacement = HeadingUtils::convert_heading_style(&heading.text, fixed_level as u32, style);
162                fixed_lines.push(format!("{}{}", " ".repeat(line_info.indent), replacement));
163
164                prev_level = Some(fixed_level);
165            } else {
166                fixed_lines.push(line_info.content.clone());
167            }
168        }
169
170        let mut result = fixed_lines.join("\n");
171        if ctx.content.ends_with('\n') && !result.ends_with('\n') {
172            result.push('\n');
173        }
174        Ok(result)
175    }
176
177    fn category(&self) -> RuleCategory {
178        RuleCategory::Heading
179    }
180
181    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
182        // Fast path: check if document likely has headings
183        if ctx.content.is_empty() || !ctx.likely_has_headings() {
184            return true;
185        }
186        // Verify headings actually exist
187        !ctx.lines.iter().any(|line| line.heading.is_some())
188    }
189
190    fn as_any(&self) -> &dyn std::any::Any {
191        self
192    }
193
194    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
195    where
196        Self: Sized,
197    {
198        Box::new(MD001HeadingIncrement)
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::lint_context::LintContext;
206
207    #[test]
208    fn test_basic_functionality() {
209        let rule = MD001HeadingIncrement;
210
211        // Test with valid headings
212        let content = "# Heading 1\n## Heading 2\n### Heading 3";
213        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
214        let result = rule.check(&ctx).unwrap();
215        assert!(result.is_empty());
216
217        // Test with invalid headings
218        let content = "# Heading 1\n### Heading 3\n#### Heading 4";
219        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
220        let result = rule.check(&ctx).unwrap();
221        assert_eq!(result.len(), 1);
222        assert_eq!(result[0].line, 2);
223    }
224}