mdbook_lint_core/rules/standard/
md001.rs

1use crate::error::Result;
2use crate::{
3    Document,
4    rule::{AstRule, RuleCategory, RuleMetadata},
5    violation::{Severity, Violation},
6};
7use comrak::nodes::AstNode;
8
9/// MD001: Heading levels should only increment by one level at a time
10///
11/// This rule is triggered when you skip heading levels in a markdown document.
12/// For example, a heading level 1 should be followed by level 2, not level 3.
13pub struct MD001;
14
15impl AstRule for MD001 {
16    fn id(&self) -> &'static str {
17        "MD001"
18    }
19
20    fn name(&self) -> &'static str {
21        "heading-increment"
22    }
23
24    fn description(&self) -> &'static str {
25        "Heading levels should only increment by one level at a time"
26    }
27
28    fn metadata(&self) -> RuleMetadata {
29        RuleMetadata::stable(RuleCategory::Structure).introduced_in("markdownlint v0.1.0")
30    }
31
32    fn check_ast<'a>(&self, document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
33        let mut violations = Vec::new();
34        let headings = document.headings(ast);
35
36        if headings.is_empty() {
37            return Ok(violations);
38        }
39
40        let mut previous_level = 0u32;
41
42        for heading in headings {
43            if let Some(level) = Document::heading_level(heading) {
44                // First heading can be any level
45                if previous_level == 0 {
46                    previous_level = level;
47                    continue;
48                }
49
50                // Check if we've skipped levels
51                if level > previous_level + 1 {
52                    let (line, column) = document.node_position(heading).unwrap_or((1, 1));
53
54                    let heading_text = document.node_text(heading);
55                    let message = format!(
56                        "Expected heading level {} (max {}) but got level {}{}",
57                        previous_level + 1,
58                        previous_level + 1,
59                        level,
60                        if heading_text.is_empty() {
61                            String::new()
62                        } else {
63                            format!(": {}", heading_text.trim())
64                        }
65                    );
66
67                    violations.push(self.create_violation(message, line, column, Severity::Error));
68                }
69
70                previous_level = level;
71            }
72        }
73
74        Ok(violations)
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use crate::rule::Rule;
82    use std::path::PathBuf;
83
84    #[test]
85    fn test_md001_valid_sequence() {
86        let content = r#"# Level 1
87## Level 2
88### Level 3
89## Level 2 again
90"#;
91        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
92        let rule = MD001;
93        let violations = rule.check(&document).unwrap();
94
95        assert_eq!(violations.len(), 0);
96    }
97
98    #[test]
99    fn test_md001_skip_level() {
100        let content = r#"# Level 1
101### Level 3 - skipped level 2
102"#;
103        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
104        let rule = MD001;
105        let violations = rule.check(&document).unwrap();
106
107        assert_eq!(violations.len(), 1);
108        assert_eq!(violations[0].rule_id, "MD001");
109        assert_eq!(violations[0].line, 2);
110        assert_eq!(violations[0].severity, Severity::Error);
111        assert!(violations[0].message.contains("Expected heading level 2"));
112        assert!(violations[0].message.contains("got level 3"));
113    }
114
115    #[test]
116    fn test_md001_multiple_skips() {
117        let content = r#"# Level 1
118#### Level 4 - skipped levels 2 and 3
119## Level 2
120##### Level 5 - skipped level 4
121"#;
122        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
123        let rule = MD001;
124        let violations = rule.check(&document).unwrap();
125
126        assert_eq!(violations.len(), 2);
127
128        // First violation: level 1 to level 4
129        assert_eq!(violations[0].line, 2);
130        assert!(violations[0].message.contains("Expected heading level 2"));
131        assert!(violations[0].message.contains("got level 4"));
132
133        // Second violation: level 2 to level 5
134        assert_eq!(violations[1].line, 4);
135        assert!(violations[1].message.contains("Expected heading level 3"));
136        assert!(violations[1].message.contains("got level 5"));
137    }
138
139    #[test]
140    fn test_md001_decrease_is_ok() {
141        let content = r#"# Level 1
142## Level 2
143### Level 3
144# Level 1 again - this is OK
145"#;
146        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
147        let rule = MD001;
148        let violations = rule.check(&document).unwrap();
149
150        assert_eq!(violations.len(), 0);
151    }
152
153    #[test]
154    fn test_md001_no_headings() {
155        let content = "Just some text without headings.";
156        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
157        let rule = MD001;
158        let violations = rule.check(&document).unwrap();
159
160        assert_eq!(violations.len(), 0);
161    }
162
163    #[test]
164    fn test_md001_single_heading() {
165        let content = "### Starting with level 3";
166        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
167        let rule = MD001;
168        let violations = rule.check(&document).unwrap();
169
170        // Single heading is always OK, regardless of level
171        assert_eq!(violations.len(), 0);
172    }
173}