mdbook_lint_core/rules/standard/
md043.rs1use crate::error::Result;
6use crate::rule::{AstRule, RuleCategory, RuleMetadata};
7use crate::{
8 Document,
9 violation::{Severity, Violation},
10};
11use comrak::nodes::{AstNode, NodeValue};
12
13pub struct MD043 {
15 headings: Vec<String>,
17}
18
19impl MD043 {
20 pub fn new() -> Self {
22 Self {
23 headings: Vec::new(), }
25 }
26
27 #[allow(dead_code)]
29 pub fn with_headings(headings: Vec<String>) -> Self {
30 Self { headings }
31 }
32
33 fn get_position<'a>(&self, node: &'a AstNode<'a>) -> (usize, usize) {
35 let data = node.data.borrow();
36 let pos = data.sourcepos;
37 (pos.start.line, pos.start.column)
38 }
39
40 fn extract_heading_text<'a>(&self, node: &'a AstNode<'a>) -> String {
42 let mut text = String::new();
43 Self::collect_text_content(node, &mut text);
44 text
45 }
46
47 fn collect_text_content<'a>(node: &'a AstNode<'a>, text: &mut String) {
49 match &node.data.borrow().value {
50 NodeValue::Text(t) => text.push_str(t),
51 NodeValue::Code(code) => text.push_str(&code.literal),
52 _ => {}
53 }
54
55 for child in node.children() {
56 Self::collect_text_content(child, text);
57 }
58 }
59
60 fn matches_pattern(&self, heading_text: &str, pattern: &str) -> bool {
62 heading_text.trim().to_lowercase() == pattern.trim().to_lowercase()
65 }
66
67 fn check_node<'a>(&self, node: &'a AstNode<'a>, headings: &mut Vec<(usize, String, usize)>) {
69 if let NodeValue::Heading(heading_data) = &node.data.borrow().value {
70 let (line, _) = self.get_position(node);
71 let text = self.extract_heading_text(node);
72 headings.push((line, text, heading_data.level as usize));
73 }
74
75 for child in node.children() {
77 self.check_node(child, headings);
78 }
79 }
80}
81
82impl Default for MD043 {
83 fn default() -> Self {
84 Self::new()
85 }
86}
87
88impl AstRule for MD043 {
89 fn id(&self) -> &'static str {
90 "MD043"
91 }
92
93 fn name(&self) -> &'static str {
94 "required-headings"
95 }
96
97 fn description(&self) -> &'static str {
98 "Required heading structure"
99 }
100
101 fn metadata(&self) -> RuleMetadata {
102 RuleMetadata::stable(RuleCategory::Structure).introduced_in("mdbook-lint v0.1.0")
103 }
104
105 fn check_ast<'a>(&self, _document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
106 let mut violations = Vec::new();
107
108 if self.headings.is_empty() {
110 return Ok(violations);
111 }
112
113 let mut document_headings = Vec::new();
114 self.check_node(ast, &mut document_headings);
115
116 if document_headings.len() < self.headings.len() {
118 violations.push(self.create_violation(
119 format!(
120 "Document should have at least {} headings but found {}",
121 self.headings.len(),
122 document_headings.len()
123 ),
124 1,
125 1,
126 Severity::Warning,
127 ));
128 return Ok(violations);
129 }
130
131 for (i, required_heading) in self.headings.iter().enumerate() {
133 if i < document_headings.len() {
134 let (line, actual_text, _level) = &document_headings[i];
135 if !self.matches_pattern(actual_text, required_heading) {
136 violations.push(self.create_violation(
137 format!("Expected heading '{required_heading}' but found '{actual_text}'"),
138 *line,
139 1,
140 Severity::Warning,
141 ));
142 }
143 }
144 }
145
146 Ok(violations)
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use crate::rule::Rule;
154 use std::path::PathBuf;
155
156 fn create_test_document(content: &str) -> Document {
157 Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
158 }
159
160 #[test]
161 fn test_md043_no_required_structure() {
162 let content = r#"# Any Heading
163
164## Any Subheading
165
166### Any Sub-subheading
167"#;
168
169 let document = create_test_document(content);
170 let rule = MD043::new();
171 let violations = rule.check(&document).unwrap();
172 assert_eq!(violations.len(), 0); }
174
175 #[test]
176 fn test_md043_correct_structure() {
177 let content = r#"# Introduction
178
179## Getting Started
180
181## Configuration
182"#;
183
184 let required_headings = vec![
185 "Introduction".to_string(),
186 "Getting Started".to_string(),
187 "Configuration".to_string(),
188 ];
189
190 let document = create_test_document(content);
191 let rule = MD043::with_headings(required_headings);
192 let violations = rule.check(&document).unwrap();
193 assert_eq!(violations.len(), 0);
194 }
195
196 #[test]
197 fn test_md043_incorrect_heading_text() {
198 let content = r#"# Introduction
199
200## Getting Started
201
202## Setup
203"#;
204
205 let required_headings = vec![
206 "Introduction".to_string(),
207 "Getting Started".to_string(),
208 "Configuration".to_string(),
209 ];
210
211 let document = create_test_document(content);
212 let rule = MD043::with_headings(required_headings);
213 let violations = rule.check(&document).unwrap();
214 assert_eq!(violations.len(), 1);
215 assert_eq!(violations[0].rule_id, "MD043");
216 assert!(
217 violations[0]
218 .message
219 .contains("Expected heading 'Configuration' but found 'Setup'")
220 );
221 assert_eq!(violations[0].line, 5);
222 }
223
224 #[test]
225 fn test_md043_missing_headings() {
226 let content = r#"# Introduction
227
228## Getting Started
229"#;
230
231 let required_headings = vec![
232 "Introduction".to_string(),
233 "Getting Started".to_string(),
234 "Configuration".to_string(),
235 ];
236
237 let document = create_test_document(content);
238 let rule = MD043::with_headings(required_headings);
239 let violations = rule.check(&document).unwrap();
240 assert_eq!(violations.len(), 1);
241 assert!(
242 violations[0]
243 .message
244 .contains("should have at least 3 headings but found 2")
245 );
246 }
247
248 #[test]
249 fn test_md043_case_insensitive_matching() {
250 let content = r#"# INTRODUCTION
251
252## getting started
253
254## Configuration
255"#;
256
257 let required_headings = vec![
258 "Introduction".to_string(),
259 "Getting Started".to_string(),
260 "Configuration".to_string(),
261 ];
262
263 let document = create_test_document(content);
264 let rule = MD043::with_headings(required_headings);
265 let violations = rule.check(&document).unwrap();
266 assert_eq!(violations.len(), 0); }
268
269 #[test]
270 fn test_md043_extra_headings_allowed() {
271 let content = r#"# Introduction
272
273## Getting Started
274
275## Configuration
276
277## Advanced Topics
278
279### Customization
280"#;
281
282 let required_headings = vec![
283 "Introduction".to_string(),
284 "Getting Started".to_string(),
285 "Configuration".to_string(),
286 ];
287
288 let document = create_test_document(content);
289 let rule = MD043::with_headings(required_headings);
290 let violations = rule.check(&document).unwrap();
291 assert_eq!(violations.len(), 0); }
293
294 #[test]
295 fn test_md043_first_heading_wrong() {
296 let content = r#"# Overview
297
298## Getting Started
299
300## Configuration
301"#;
302
303 let required_headings = vec![
304 "Introduction".to_string(),
305 "Getting Started".to_string(),
306 "Configuration".to_string(),
307 ];
308
309 let document = create_test_document(content);
310 let rule = MD043::with_headings(required_headings);
311 let violations = rule.check(&document).unwrap();
312 assert_eq!(violations.len(), 1);
313 assert!(
314 violations[0]
315 .message
316 .contains("Expected heading 'Introduction' but found 'Overview'")
317 );
318 assert_eq!(violations[0].line, 1);
319 }
320
321 #[test]
322 fn test_md043_multiple_violations() {
323 let content = r#"# Overview
324
325## Setup
326
327## Deployment
328"#;
329
330 let required_headings = vec![
331 "Introduction".to_string(),
332 "Getting Started".to_string(),
333 "Configuration".to_string(),
334 ];
335
336 let document = create_test_document(content);
337 let rule = MD043::with_headings(required_headings);
338 let violations = rule.check(&document).unwrap();
339 assert_eq!(violations.len(), 3); assert!(
341 violations[0]
342 .message
343 .contains("Expected heading 'Introduction' but found 'Overview'")
344 );
345 assert!(
346 violations[1]
347 .message
348 .contains("Expected heading 'Getting Started' but found 'Setup'")
349 );
350 assert!(
351 violations[2]
352 .message
353 .contains("Expected heading 'Configuration' but found 'Deployment'")
354 );
355 }
356
357 #[test]
358 fn test_md043_headings_with_formatting() {
359 let content = r#"# **Introduction**
360
361## *Getting Started*
362
363## Configuration
364"#;
365
366 let required_headings = vec![
367 "Introduction".to_string(),
368 "Getting Started".to_string(),
369 "Configuration".to_string(),
370 ];
371
372 let document = create_test_document(content);
373 let rule = MD043::with_headings(required_headings);
374 let violations = rule.check(&document).unwrap();
375 assert_eq!(violations.len(), 0); }
377
378 #[test]
379 fn test_md043_headings_with_code() {
380 let content = r#"# Introduction
381
382## Getting Started with `npm`
383
384## Configuration
385"#;
386
387 let required_headings = vec![
388 "Introduction".to_string(),
389 "Getting Started with npm".to_string(),
390 "Configuration".to_string(),
391 ];
392
393 let document = create_test_document(content);
394 let rule = MD043::with_headings(required_headings);
395 let violations = rule.check(&document).unwrap();
396 assert_eq!(violations.len(), 0);
397 }
398
399 #[test]
400 fn test_md043_whitespace_handling() {
401 let content = r#"# Introduction
402
403## Getting Started
404
405## Configuration
406"#;
407
408 let required_headings = vec![
409 "Introduction".to_string(),
410 "Getting Started".to_string(),
411 "Configuration".to_string(),
412 ];
413
414 let document = create_test_document(content);
415 let rule = MD043::with_headings(required_headings);
416 let violations = rule.check(&document).unwrap();
417 assert_eq!(violations.len(), 0); }
419}