mdbook_lint_core/rules/standard/
md001.rs1use crate::error::Result;
2use crate::{
3 Document,
4 rule::{AstRule, RuleCategory, RuleMetadata},
5 violation::{Severity, Violation},
6};
7use comrak::nodes::AstNode;
8
9pub 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 if previous_level == 0 {
46 previous_level = level;
47 continue;
48 }
49
50 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 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 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 assert_eq!(violations.len(), 0);
172 }
173}