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::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 mut warnings = Vec::new();
73        let mut prev_level: Option<usize> = None;
74
75        // Process valid headings using the filtered iterator
76        for valid_heading in ctx.valid_headings() {
77            let heading = valid_heading.heading;
78            let line_info = valid_heading.line_info;
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(ctx.content);
101                let (start_line, start_col, end_line, end_col) =
102                    calculate_heading_range(valid_heading.line_num, line_content);
103
104                warnings.push(LintWarning {
105                    rule_name: Some(self.name().to_string()),
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::Error,
112                    fix: Some(Fix {
113                        range: ctx.line_index.line_content_range(valid_heading.line_num),
114                        replacement: format!("{}{}", " ".repeat(indentation), replacement),
115                    }),
116                });
117            }
118
119            prev_level = Some(level);
120        }
121
122        Ok(warnings)
123    }
124
125    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
126        let mut fixed_lines = Vec::new();
127        let mut prev_level: Option<usize> = None;
128
129        for line_info in ctx.lines.iter() {
130            if let Some(heading) = &line_info.heading {
131                // Skip invalid headings (e.g., `#NoSpace` which lacks required space after #)
132                if !heading.is_valid {
133                    fixed_lines.push(line_info.content(ctx.content).to_string());
134                    continue;
135                }
136
137                let level = heading.level as usize;
138                let mut fixed_level = level;
139
140                // Check if this heading needs fixing
141                if let Some(prev) = prev_level
142                    && level > prev + 1
143                {
144                    fixed_level = prev + 1;
145                }
146
147                // Map heading style - when fixing, we may need to change Setext style based on level
148                let style = match heading.style {
149                    crate::lint_context::HeadingStyle::ATX => HeadingStyle::Atx,
150                    crate::lint_context::HeadingStyle::Setext1 => {
151                        if fixed_level == 1 {
152                            HeadingStyle::Setext1
153                        } else {
154                            HeadingStyle::Setext2
155                        }
156                    }
157                    crate::lint_context::HeadingStyle::Setext2 => {
158                        if fixed_level == 1 {
159                            HeadingStyle::Setext1
160                        } else {
161                            HeadingStyle::Setext2
162                        }
163                    }
164                };
165
166                let replacement = HeadingUtils::convert_heading_style(&heading.text, fixed_level as u32, style);
167                fixed_lines.push(format!("{}{}", " ".repeat(line_info.indent), replacement));
168
169                prev_level = Some(fixed_level);
170            } else {
171                fixed_lines.push(line_info.content(ctx.content).to_string());
172            }
173        }
174
175        let mut result = fixed_lines.join("\n");
176        if ctx.content.ends_with('\n') && !result.ends_with('\n') {
177            result.push('\n');
178        }
179        Ok(result)
180    }
181
182    fn category(&self) -> RuleCategory {
183        RuleCategory::Heading
184    }
185
186    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
187        // Fast path: check if document likely has headings
188        if ctx.content.is_empty() || !ctx.likely_has_headings() {
189            return true;
190        }
191        // Verify valid headings actually exist
192        !ctx.has_valid_headings()
193    }
194
195    fn as_any(&self) -> &dyn std::any::Any {
196        self
197    }
198
199    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
200    where
201        Self: Sized,
202    {
203        Box::new(MD001HeadingIncrement)
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::lint_context::LintContext;
211
212    #[test]
213    fn test_basic_functionality() {
214        let rule = MD001HeadingIncrement;
215
216        // Test with valid headings
217        let content = "# Heading 1\n## Heading 2\n### Heading 3";
218        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
219        let result = rule.check(&ctx).unwrap();
220        assert!(result.is_empty());
221
222        // Test with invalid headings
223        let content = "# Heading 1\n### Heading 3\n#### Heading 4";
224        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
225        let result = rule.check(&ctx).unwrap();
226        assert_eq!(result.len(), 1);
227        assert_eq!(result[0].line, 2);
228    }
229}