mdbook_lint_core/rules/standard/
md043.rs

1//! MD043: Required heading structure
2//!
3//! This rule checks that headings follow a required structure/hierarchy pattern.
4
5use crate::error::Result;
6use crate::rule::{AstRule, RuleCategory, RuleMetadata};
7use crate::{
8    Document,
9    violation::{Severity, Violation},
10};
11use comrak::nodes::{AstNode, NodeValue};
12
13/// Rule to check required heading structure
14pub struct MD043 {
15    /// Required heading patterns
16    headings: Vec<String>,
17}
18
19impl MD043 {
20    /// Create a new MD043 rule with default heading structure
21    pub fn new() -> Self {
22        Self {
23            headings: Vec::new(), // No required structure by default
24        }
25    }
26
27    /// Create a new MD043 rule with required heading structure
28    #[allow(dead_code)]
29    pub fn with_headings(headings: Vec<String>) -> Self {
30        Self { headings }
31    }
32
33    /// Get line and column position for a node
34    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    /// Extract text content from a heading node
41    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    /// Recursively collect text content from a node and its children
48    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    /// Check if a heading text matches a required pattern
61    fn matches_pattern(&self, heading_text: &str, pattern: &str) -> bool {
62        // For now, implement exact match (case-insensitive)
63        // Could be extended to support regex patterns in the future
64        heading_text.trim().to_lowercase() == pattern.trim().to_lowercase()
65    }
66
67    /// Walk AST and collect headings, then validate structure
68    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        // Recursively check children
76        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 no required structure is configured, skip checking
109        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        // Check if document has the required number of headings
117        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        // Check each required heading
132        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); // No requirements, so no violations
173    }
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); // Case-insensitive matching should work
267    }
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); // Extra headings are allowed
292    }
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); // All three headings are wrong
340        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); // Should extract text content ignoring formatting
376    }
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); // Should handle whitespace properly
418    }
419}