rumdl_lib/rules/
md001_heading_increment.rs

1use crate::HeadingStyle;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::rules::front_matter_utils::FrontMatterUtils;
4use crate::rules::heading_utils::HeadingUtils;
5use crate::utils::range_utils::calculate_heading_range;
6use regex::Regex;
7
8/// Rule MD001: Heading levels should only increment by one level at a time
9///
10/// See [docs/md001.md](../../docs/md001.md) for full documentation, configuration, and examples.
11///
12/// This rule enforces a fundamental principle of document structure: heading levels
13/// should increase by exactly one level at a time to maintain a proper document hierarchy.
14///
15/// ## Purpose
16///
17/// Proper heading structure creates a logical document outline and improves:
18/// - Readability for humans
19/// - Accessibility for screen readers
20/// - Navigation in rendered documents
21/// - Automatic generation of tables of contents
22///
23/// ## Examples
24///
25/// ### Correct Heading Structure
26/// ```markdown
27/// # Heading 1
28/// ## Heading 2
29/// ### Heading 3
30/// ## Another Heading 2
31/// ```
32///
33/// ### Incorrect Heading Structure
34/// ```markdown
35/// # Heading 1
36/// ### Heading 3 (skips level 2)
37/// #### Heading 4
38/// ```
39///
40/// ## Behavior
41///
42/// This rule:
43/// - Tracks the heading level throughout the document
44/// - Validates that each new heading is at most one level deeper than the previous heading
45/// - Allows heading levels to decrease by any amount (e.g., going from ### to #)
46/// - Works with both ATX (`#`) and Setext (underlined) heading styles
47///
48/// ## Fix Behavior
49///
50/// When applying automatic fixes, this rule:
51/// - Changes the level of non-compliant headings to be one level deeper than the previous heading
52/// - Preserves the original heading style (ATX or Setext)
53/// - Maintains indentation and other formatting
54///
55/// ## Rationale
56///
57/// Skipping heading levels (e.g., from `h1` to `h3`) can confuse readers and screen readers
58/// by creating gaps in the document structure. Consistent heading increments create a proper
59/// hierarchical outline essential for well-structured documents.
60///
61/// ## Front Matter Title Support
62///
63/// When `front_matter_title` is enabled (default: true), this rule recognizes a `title:` field
64/// in YAML/TOML frontmatter as an implicit level-1 heading. This allows documents like:
65///
66/// ```markdown
67/// ---
68/// title: My Document
69/// ---
70///
71/// ## First Section
72/// ```
73///
74/// Without triggering a warning about skipping from H1 to H2, since the frontmatter title
75/// counts as the H1.
76///
77#[derive(Debug, Clone)]
78pub struct MD001HeadingIncrement {
79    /// Whether to treat frontmatter title field as an implicit H1
80    pub front_matter_title: bool,
81    /// Optional regex pattern to match custom title fields in frontmatter
82    pub front_matter_title_pattern: Option<Regex>,
83}
84
85impl Default for MD001HeadingIncrement {
86    fn default() -> Self {
87        Self {
88            front_matter_title: true,
89            front_matter_title_pattern: None,
90        }
91    }
92}
93
94impl MD001HeadingIncrement {
95    /// Create a new instance with specified settings
96    pub fn new(front_matter_title: bool) -> Self {
97        Self {
98            front_matter_title,
99            front_matter_title_pattern: None,
100        }
101    }
102
103    /// Create a new instance with a custom pattern for matching title fields
104    pub fn with_pattern(front_matter_title: bool, pattern: Option<String>) -> Self {
105        let front_matter_title_pattern = pattern.and_then(|p| match Regex::new(&p) {
106            Ok(regex) => Some(regex),
107            Err(e) => {
108                log::warn!("Invalid front_matter_title_pattern regex for MD001: {e}");
109                None
110            }
111        });
112
113        Self {
114            front_matter_title,
115            front_matter_title_pattern,
116        }
117    }
118
119    /// Check if the document has a front matter title field
120    fn has_front_matter_title(&self, content: &str) -> bool {
121        if !self.front_matter_title {
122            return false;
123        }
124
125        // If we have a custom pattern, use it to search front matter content
126        if let Some(ref pattern) = self.front_matter_title_pattern {
127            let front_matter_lines = FrontMatterUtils::extract_front_matter(content);
128            for line in front_matter_lines {
129                if pattern.is_match(line) {
130                    return true;
131                }
132            }
133            return false;
134        }
135
136        // Default behavior: check for "title:" field
137        FrontMatterUtils::has_front_matter_field(content, "title:")
138    }
139}
140
141impl Rule for MD001HeadingIncrement {
142    fn name(&self) -> &'static str {
143        "MD001"
144    }
145
146    fn description(&self) -> &'static str {
147        "Heading levels should only increment by one level at a time"
148    }
149
150    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
151        let mut warnings = Vec::new();
152
153        // If frontmatter has a title field, treat it as an implicit H1
154        let mut prev_level: Option<usize> = if self.has_front_matter_title(ctx.content) {
155            Some(1)
156        } else {
157            None
158        };
159
160        // Process valid headings using the filtered iterator
161        for valid_heading in ctx.valid_headings() {
162            let heading = valid_heading.heading;
163            let line_info = valid_heading.line_info;
164            let level = heading.level as usize;
165
166            // Check if this heading level is more than one level deeper than the previous
167            if let Some(prev) = prev_level
168                && level > prev + 1
169            {
170                // Preserve original indentation (including tabs)
171                let line = line_info.content(ctx.content);
172                let original_indent = &line[..line_info.indent];
173                let heading_text = &heading.text;
174
175                // Map heading style
176                let style = match heading.style {
177                    crate::lint_context::HeadingStyle::ATX => HeadingStyle::Atx,
178                    crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
179                    crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
180                };
181
182                // Create a fix with the correct heading level
183                let fixed_level = prev + 1;
184                let replacement = HeadingUtils::convert_heading_style(heading_text, fixed_level as u32, style);
185
186                // Calculate precise range: highlight the entire heading
187                let line_content = line_info.content(ctx.content);
188                let (start_line, start_col, end_line, end_col) =
189                    calculate_heading_range(valid_heading.line_num, line_content);
190
191                warnings.push(LintWarning {
192                    rule_name: Some(self.name().to_string()),
193                    line: start_line,
194                    column: start_col,
195                    end_line,
196                    end_column: end_col,
197                    message: format!("Expected heading level {}, but found heading level {}", prev + 1, level),
198                    severity: Severity::Error,
199                    fix: Some(Fix {
200                        range: ctx.line_index.line_content_range(valid_heading.line_num),
201                        replacement: format!("{original_indent}{replacement}"),
202                    }),
203                });
204            }
205
206            prev_level = Some(level);
207        }
208
209        Ok(warnings)
210    }
211
212    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
213        let mut fixed_lines = Vec::new();
214
215        // If frontmatter has a title field, treat it as an implicit H1
216        let mut prev_level: Option<usize> = if self.has_front_matter_title(ctx.content) {
217            Some(1)
218        } else {
219            None
220        };
221
222        for line_info in ctx.lines.iter() {
223            if let Some(heading) = &line_info.heading {
224                // Skip invalid headings (e.g., `#NoSpace` which lacks required space after #)
225                if !heading.is_valid {
226                    fixed_lines.push(line_info.content(ctx.content).to_string());
227                    continue;
228                }
229
230                let level = heading.level as usize;
231                let mut fixed_level = level;
232
233                // Check if this heading needs fixing
234                if let Some(prev) = prev_level
235                    && level > prev + 1
236                {
237                    fixed_level = prev + 1;
238                }
239
240                // Map heading style - when fixing, we may need to change Setext style based on level
241                let style = match heading.style {
242                    crate::lint_context::HeadingStyle::ATX => HeadingStyle::Atx,
243                    crate::lint_context::HeadingStyle::Setext1 => {
244                        if fixed_level == 1 {
245                            HeadingStyle::Setext1
246                        } else {
247                            HeadingStyle::Setext2
248                        }
249                    }
250                    crate::lint_context::HeadingStyle::Setext2 => {
251                        if fixed_level == 1 {
252                            HeadingStyle::Setext1
253                        } else {
254                            HeadingStyle::Setext2
255                        }
256                    }
257                };
258
259                let replacement = HeadingUtils::convert_heading_style(&heading.text, fixed_level as u32, style);
260                // Preserve original indentation (including tabs)
261                let line = line_info.content(ctx.content);
262                let original_indent = &line[..line_info.indent];
263                fixed_lines.push(format!("{original_indent}{replacement}"));
264
265                prev_level = Some(fixed_level);
266            } else {
267                fixed_lines.push(line_info.content(ctx.content).to_string());
268            }
269        }
270
271        let mut result = fixed_lines.join("\n");
272        if ctx.content.ends_with('\n') && !result.ends_with('\n') {
273            result.push('\n');
274        }
275        Ok(result)
276    }
277
278    fn category(&self) -> RuleCategory {
279        RuleCategory::Heading
280    }
281
282    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
283        // Fast path: check if document likely has headings
284        if ctx.content.is_empty() || !ctx.likely_has_headings() {
285            return true;
286        }
287        // Verify valid headings actually exist
288        !ctx.has_valid_headings()
289    }
290
291    fn as_any(&self) -> &dyn std::any::Any {
292        self
293    }
294
295    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
296    where
297        Self: Sized,
298    {
299        // Get MD001 config section
300        let (front_matter_title, front_matter_title_pattern) = if let Some(rule_config) = config.rules.get("MD001") {
301            let fmt = rule_config
302                .values
303                .get("front-matter-title")
304                .or_else(|| rule_config.values.get("front_matter_title"))
305                .and_then(|v| v.as_bool())
306                .unwrap_or(true);
307
308            let pattern = rule_config
309                .values
310                .get("front-matter-title-pattern")
311                .or_else(|| rule_config.values.get("front_matter_title_pattern"))
312                .and_then(|v| v.as_str())
313                .filter(|s: &&str| !s.is_empty())
314                .map(String::from);
315
316            (fmt, pattern)
317        } else {
318            (true, None)
319        };
320
321        Box::new(MD001HeadingIncrement::with_pattern(
322            front_matter_title,
323            front_matter_title_pattern,
324        ))
325    }
326
327    fn default_config_section(&self) -> Option<(String, toml::Value)> {
328        Some((
329            "MD001".to_string(),
330            toml::toml! {
331                front-matter-title = true
332            }
333            .into(),
334        ))
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::lint_context::LintContext;
342
343    #[test]
344    fn test_basic_functionality() {
345        let rule = MD001HeadingIncrement::default();
346
347        // Test with valid headings
348        let content = "# Heading 1\n## Heading 2\n### Heading 3";
349        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
350        let result = rule.check(&ctx).unwrap();
351        assert!(result.is_empty());
352
353        // Test with invalid headings
354        let content = "# Heading 1\n### Heading 3\n#### Heading 4";
355        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
356        let result = rule.check(&ctx).unwrap();
357        assert_eq!(result.len(), 1);
358        assert_eq!(result[0].line, 2);
359    }
360
361    #[test]
362    fn test_frontmatter_title_counts_as_h1() {
363        let rule = MD001HeadingIncrement::default();
364
365        // Frontmatter with title, followed by H2 - should pass
366        let content = "---\ntitle: My Document\n---\n\n## First Section";
367        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
368        let result = rule.check(&ctx).unwrap();
369        assert!(
370            result.is_empty(),
371            "H2 after frontmatter title should not trigger warning"
372        );
373
374        // Frontmatter with title, followed by H3 - should warn (skips H2)
375        let content = "---\ntitle: My Document\n---\n\n### Third Level";
376        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
377        let result = rule.check(&ctx).unwrap();
378        assert_eq!(result.len(), 1, "H3 after frontmatter title should warn");
379        assert!(result[0].message.contains("Expected heading level 2"));
380    }
381
382    #[test]
383    fn test_frontmatter_without_title() {
384        let rule = MD001HeadingIncrement::default();
385
386        // Frontmatter without title, followed by H2 - first heading has no predecessor
387        // so it should pass (no increment check for the first heading)
388        let content = "---\nauthor: John\n---\n\n## First Section";
389        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
390        let result = rule.check(&ctx).unwrap();
391        assert!(
392            result.is_empty(),
393            "First heading after frontmatter without title has no predecessor"
394        );
395    }
396
397    #[test]
398    fn test_frontmatter_title_disabled() {
399        let rule = MD001HeadingIncrement::new(false);
400
401        // Frontmatter with title, but feature disabled - H2 has no predecessor
402        let content = "---\ntitle: My Document\n---\n\n## First Section";
403        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
404        let result = rule.check(&ctx).unwrap();
405        assert!(
406            result.is_empty(),
407            "With front_matter_title disabled, first heading has no predecessor"
408        );
409    }
410
411    #[test]
412    fn test_frontmatter_title_with_subsequent_headings() {
413        let rule = MD001HeadingIncrement::default();
414
415        // Complete document with frontmatter title
416        let content = "---\ntitle: My Document\n---\n\n## Introduction\n\n### Details\n\n## Conclusion";
417        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
418        let result = rule.check(&ctx).unwrap();
419        assert!(result.is_empty(), "Valid heading progression after frontmatter title");
420    }
421
422    #[test]
423    fn test_frontmatter_title_fix() {
424        let rule = MD001HeadingIncrement::default();
425
426        // Frontmatter with title, H3 should be fixed to H2
427        let content = "---\ntitle: My Document\n---\n\n### Third Level";
428        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
429        let fixed = rule.fix(&ctx).unwrap();
430        assert!(
431            fixed.contains("## Third Level"),
432            "H3 should be fixed to H2 when frontmatter has title"
433        );
434    }
435
436    #[test]
437    fn test_toml_frontmatter_title() {
438        let rule = MD001HeadingIncrement::default();
439
440        // TOML frontmatter with title
441        let content = "+++\ntitle = \"My Document\"\n+++\n\n## First Section";
442        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
443        let result = rule.check(&ctx).unwrap();
444        assert!(result.is_empty(), "TOML frontmatter title should count as H1");
445    }
446
447    #[test]
448    fn test_no_frontmatter_no_h1() {
449        let rule = MD001HeadingIncrement::default();
450
451        // No frontmatter, starts with H2 - first heading has no predecessor, so no warning
452        let content = "## First Section\n\n### Subsection";
453        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
454        let result = rule.check(&ctx).unwrap();
455        assert!(
456            result.is_empty(),
457            "First heading (even if H2) has no predecessor to compare against"
458        );
459    }
460}