mdbook_lint_core/rules/standard/
md002.rs1use crate::error::Result;
7use crate::rule::{AstRule, RuleCategory, RuleMetadata};
8use crate::{
9 Document,
10 violation::{Severity, Violation},
11};
12use comrak::nodes::AstNode;
13
14pub struct MD002 {
16 level: u32,
18}
19
20impl MD002 {
21 pub fn new() -> Self {
23 Self { level: 1 }
24 }
25
26 #[allow(dead_code)]
28 pub fn with_level(level: u32) -> Self {
29 Self { level }
30 }
31}
32
33impl Default for MD002 {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39impl AstRule for MD002 {
40 fn id(&self) -> &'static str {
41 "MD002"
42 }
43
44 fn name(&self) -> &'static str {
45 "first-heading-h1"
46 }
47
48 fn description(&self) -> &'static str {
49 "First heading should be a top-level heading"
50 }
51
52 fn metadata(&self) -> RuleMetadata {
53 RuleMetadata::deprecated(
54 RuleCategory::Structure,
55 "Superseded by MD041 which offers improved implementation",
56 Some("MD041"),
57 )
58 .introduced_in("markdownlint v0.1.0")
59 }
60
61 fn check_ast<'a>(&self, document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
62 let mut violations = Vec::new();
63 let headings = document.headings(ast);
64
65 if let Some(first_heading) = headings.first()
67 && let Some(heading_level) = Document::heading_level(first_heading)
68 && heading_level != self.level
69 && let Some((line, column)) = document.node_position(first_heading)
70 {
71 let heading_text = document.node_text(first_heading);
72 let message = format!(
73 "First heading should be level {} but got level {}{}",
74 self.level,
75 heading_level,
76 if heading_text.is_empty() {
77 String::new()
78 } else {
79 format!(": {}", heading_text.trim())
80 }
81 );
82
83 violations.push(self.create_violation(message, line, column, Severity::Warning));
84 }
85
86 Ok(violations)
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93 use crate::rule::Rule;
94 use std::path::PathBuf;
95
96 fn create_test_document(content: &str) -> Document {
97 Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
98 }
99
100 #[test]
101 fn test_md002_valid_first_heading() {
102 let content = "# First heading\n## Second heading";
103 let document = create_test_document(content);
104 let rule = MD002::new();
105 let violations = rule.check(&document).unwrap();
106
107 assert_eq!(violations.len(), 0);
108 }
109
110 #[test]
111 fn test_md002_invalid_first_heading() {
112 let content = "## This should be h1\n### This is h3";
113 let document = create_test_document(content);
114 let rule = MD002::new();
115 let violations = rule.check(&document).unwrap();
116
117 assert_eq!(violations.len(), 1);
118 assert_eq!(violations[0].rule_id, "MD002");
119 assert_eq!(violations[0].line, 1);
120 assert!(violations[0].message.contains("should be level 1"));
121 assert!(violations[0].message.contains("got level 2"));
122 }
123
124 #[test]
125 fn test_md002_custom_level() {
126 let content = "## Starting with h2\n### Then h3";
127 let document = create_test_document(content);
128 let rule = MD002::with_level(2);
129 let violations = rule.check(&document).unwrap();
130
131 assert_eq!(violations.len(), 0);
133 }
134
135 #[test]
136 fn test_md002_custom_level_violation() {
137 let content = "### Starting with h3\n#### Then h4";
138 let document = create_test_document(content);
139 let rule = MD002::with_level(2);
140 let violations = rule.check(&document).unwrap();
141
142 assert_eq!(violations.len(), 1);
143 assert!(violations[0].message.contains("should be level 2"));
144 assert!(violations[0].message.contains("got level 3"));
145 }
146
147 #[test]
148 fn test_md002_no_headings() {
149 let content = "Just some text without headings.";
150 let document = create_test_document(content);
151 let rule = MD002::new();
152 let violations = rule.check(&document).unwrap();
153
154 assert_eq!(violations.len(), 0);
156 }
157
158 #[test]
159 fn test_md002_setext_heading() {
160 let content = "First Heading\n=============\n\nSecond Heading\n--------------";
161 let document = create_test_document(content);
162 let rule = MD002::new();
163 let violations = rule.check(&document).unwrap();
164
165 assert_eq!(violations.len(), 0);
167 }
168
169 #[test]
170 fn test_md002_setext_heading_violation() {
171 let content = "First Heading\n--------------\n\nAnother Heading\n===============";
172 let document = create_test_document(content);
173 let rule = MD002::new();
174 let violations = rule.check(&document).unwrap();
175
176 assert_eq!(violations.len(), 1);
178 assert!(violations[0].message.contains("should be level 1"));
179 assert!(violations[0].message.contains("got level 2"));
180 }
181
182 #[test]
183 fn test_md002_heading_with_text() {
184 let content = "### My Third Level Heading\n#### Subheading";
185 let document = create_test_document(content);
186 let rule = MD002::new();
187 let violations = rule.check(&document).unwrap();
188
189 assert_eq!(violations.len(), 1);
190 assert!(violations[0].message.contains("My Third Level Heading"));
191 }
192
193 #[test]
194 fn test_md002_mixed_content_before_heading() {
195 let content = "Some intro text\n\n## First heading\n### Second heading";
196 let document = create_test_document(content);
197 let rule = MD002::new();
198 let violations = rule.check(&document).unwrap();
199
200 assert_eq!(violations.len(), 1);
202 assert!(violations[0].message.contains("should be level 1"));
203 assert!(violations[0].message.contains("got level 2"));
204 }
205}