mdbook_lint_core/rules/standard/
md041.rs1use crate::error::Result;
6use crate::rule::{Rule, RuleCategory, RuleMetadata};
7use crate::{
8 Document,
9 violation::{Severity, Violation},
10};
11
12pub struct MD041;
14
15impl MD041 {
16 fn is_top_level_heading(&self, line: &str) -> bool {
18 let trimmed = line.trim();
19
20 if trimmed.starts_with("# ") && !trimmed.starts_with("## ") {
22 return true;
23 }
24
25 if trimmed.starts_with('#') && !trimmed.starts_with("##") && trimmed.len() > 1 {
27 return true;
28 }
29
30 false
31 }
32
33 fn is_setext_h1_underline(&self, line: &str) -> bool {
35 let trimmed = line.trim();
36 !trimmed.is_empty() && trimmed.chars().all(|c| c == '=')
37 }
38
39 fn could_be_setext_heading(&self, line: &str) -> bool {
41 let trimmed = line.trim();
42 !trimmed.is_empty() && !trimmed.starts_with('#')
43 }
44}
45
46impl Rule for MD041 {
47 fn id(&self) -> &'static str {
48 "MD041"
49 }
50
51 fn name(&self) -> &'static str {
52 "first-line-heading"
53 }
54
55 fn description(&self) -> &'static str {
56 "First line in file should be a top level heading"
57 }
58
59 fn metadata(&self) -> RuleMetadata {
60 RuleMetadata::stable(RuleCategory::Structure).introduced_in("mdbook-lint v0.1.0")
61 }
62
63 fn check_with_ast<'a>(
64 &self,
65 document: &Document,
66 _ast: Option<&'a comrak::nodes::AstNode<'a>>,
67 ) -> Result<Vec<Violation>> {
68 let mut violations = Vec::new();
69
70 if document.lines.is_empty() {
71 return Ok(violations);
72 }
73
74 let mut first_content_line_idx = None;
76 for (idx, line) in document.lines.iter().enumerate() {
77 if !line.trim().is_empty() {
78 first_content_line_idx = Some(idx);
79 break;
80 }
81 }
82
83 let Some(first_idx) = first_content_line_idx else {
84 return Ok(violations);
86 };
87
88 let first_line = &document.lines[first_idx];
89
90 if self.is_top_level_heading(first_line) {
92 return Ok(violations);
93 }
94
95 if first_idx + 1 < document.lines.len() {
97 let second_line = &document.lines[first_idx + 1];
98 if self.could_be_setext_heading(first_line) && self.is_setext_h1_underline(second_line)
99 {
100 return Ok(violations);
101 }
102 }
103
104 violations.push(self.create_violation(
106 "First line in file should be a top level heading".to_string(),
107 first_idx + 1, 1,
109 Severity::Warning,
110 ));
111
112 Ok(violations)
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119 use crate::rule::Rule;
120 use std::path::PathBuf;
121
122 fn create_test_document(content: &str) -> Document {
123 Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
124 }
125
126 #[test]
127 fn test_md041_atx_h1_valid() {
128 let content = "# Top Level Heading\n\nSome content here.";
129 let document = create_test_document(content);
130 let rule = MD041;
131 let violations = rule.check(&document).unwrap();
132
133 assert_eq!(violations.len(), 0);
134 }
135
136 #[test]
137 fn test_md041_atx_h1_no_space_valid() {
138 let content = "#Top Level Heading\n\nSome content here.";
139 let document = create_test_document(content);
140 let rule = MD041;
141 let violations = rule.check(&document).unwrap();
142
143 assert_eq!(violations.len(), 0);
144 }
145
146 #[test]
147 fn test_md041_setext_h1_valid() {
148 let content = "Top Level Heading\n=================\n\nSome content here.";
149 let document = create_test_document(content);
150 let rule = MD041;
151 let violations = rule.check(&document).unwrap();
152
153 assert_eq!(violations.len(), 0);
154 }
155
156 #[test]
157 fn test_md041_h2_invalid() {
158 let content = "## Second Level Heading\n\nSome content here.";
159 let document = create_test_document(content);
160 let rule = MD041;
161 let violations = rule.check(&document).unwrap();
162
163 assert_eq!(violations.len(), 1);
164 assert_eq!(violations[0].rule_id, "MD041");
165 assert_eq!(violations[0].line, 1);
166 assert!(
167 violations[0]
168 .message
169 .contains("First line in file should be a top level heading")
170 );
171 }
172
173 #[test]
174 fn test_md041_paragraph_first_invalid() {
175 let content = "This is a paragraph.\n\n# Heading comes later";
176 let document = create_test_document(content);
177 let rule = MD041;
178 let violations = rule.check(&document).unwrap();
179
180 assert_eq!(violations.len(), 1);
181 assert_eq!(violations[0].line, 1);
182 }
183
184 #[test]
185 fn test_md041_setext_h2_invalid() {
186 let content = "Second Level Heading\n--------------------\n\nSome content here.";
187 let document = create_test_document(content);
188 let rule = MD041;
189 let violations = rule.check(&document).unwrap();
190
191 assert_eq!(violations.len(), 1);
192 assert_eq!(violations[0].line, 1);
193 }
194
195 #[test]
196 fn test_md041_empty_file_valid() {
197 let content = "";
198 let document = create_test_document(content);
199 let rule = MD041;
200 let violations = rule.check(&document).unwrap();
201
202 assert_eq!(violations.len(), 0);
203 }
204
205 #[test]
206 fn test_md041_whitespace_only_valid() {
207 let content = " \n\n\t\n ";
208 let document = create_test_document(content);
209 let rule = MD041;
210 let violations = rule.check(&document).unwrap();
211
212 assert_eq!(violations.len(), 0);
213 }
214
215 #[test]
216 fn test_md041_leading_whitespace_valid() {
217 let content = "\n\n# Top Level Heading\n\nSome content here.";
218 let document = create_test_document(content);
219 let rule = MD041;
220 let violations = rule.check(&document).unwrap();
221
222 assert_eq!(violations.len(), 0);
223 }
224
225 #[test]
226 fn test_md041_leading_whitespace_invalid() {
227 let content = "\n\nSome paragraph first.\n\n# Heading later";
228 let document = create_test_document(content);
229 let rule = MD041;
230 let violations = rule.check(&document).unwrap();
231
232 assert_eq!(violations.len(), 1);
233 assert_eq!(violations[0].line, 3); }
235
236 #[test]
237 fn test_md041_bare_hash_invalid() {
238 let content = "#\n\nSome content here.";
239 let document = create_test_document(content);
240 let rule = MD041;
241 let violations = rule.check(&document).unwrap();
242
243 assert_eq!(violations.len(), 1);
244 assert_eq!(violations[0].line, 1);
245 }
246
247 #[test]
248 fn test_md041_code_block_first_invalid() {
249 let content = "```\ncode block\n```\n\n# Heading later";
250 let document = create_test_document(content);
251 let rule = MD041;
252 let violations = rule.check(&document).unwrap();
253
254 assert_eq!(violations.len(), 1);
255 assert_eq!(violations[0].line, 1);
256 }
257
258 #[test]
259 fn test_md041_list_first_invalid() {
260 let content = "- List item\n- Another item\n\n# Heading later";
261 let document = create_test_document(content);
262 let rule = MD041;
263 let violations = rule.check(&document).unwrap();
264
265 assert_eq!(violations.len(), 1);
266 assert_eq!(violations[0].line, 1);
267 }
268
269 #[test]
270 fn test_md041_setext_incomplete_invalid() {
271 let content = "Potential heading\n\nBut no underline.";
272 let document = create_test_document(content);
273 let rule = MD041;
274 let violations = rule.check(&document).unwrap();
275
276 assert_eq!(violations.len(), 1);
277 assert_eq!(violations[0].line, 1);
278 }
279}