mdbook_lint_core/rules/standard/
md025.rs

1//! MD025: Single H1 per document
2//!
3//! This rule checks that a document has only one top-level heading (H1).
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 that documents have only one H1 heading
14pub struct MD025 {
15    /// The heading level to check (default: 1)
16    level: u8,
17}
18
19impl MD025 {
20    /// Create a new MD025 rule with default settings (level 1)
21    pub fn new() -> Self {
22        Self { level: 1 }
23    }
24
25    /// Create a new MD025 rule with custom level
26    #[allow(dead_code)]
27    pub fn with_level(level: u8) -> Self {
28        Self { level }
29    }
30}
31
32impl Default for MD025 {
33    fn default() -> Self {
34        Self::new()
35    }
36}
37
38impl AstRule for MD025 {
39    fn id(&self) -> &'static str {
40        "MD025"
41    }
42
43    fn name(&self) -> &'static str {
44        "single-title"
45    }
46
47    fn description(&self) -> &'static str {
48        "Multiple top-level headings in the same document"
49    }
50
51    fn metadata(&self) -> RuleMetadata {
52        RuleMetadata::stable(RuleCategory::Structure).introduced_in("mdbook-lint v0.1.0")
53    }
54
55    fn check_ast<'a>(&self, document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
56        let mut violations = Vec::new();
57        let mut h1_headings = Vec::new();
58
59        // Find all headings at the specified level
60        for node in ast.descendants() {
61            if let NodeValue::Heading(heading) = &node.data.borrow().value
62                && heading.level == self.level
63                && let Some((line, column)) = document.node_position(node)
64            {
65                let heading_text = document.node_text(node);
66                let heading_text = heading_text.trim();
67                h1_headings.push((line, column, heading_text.to_string()));
68            }
69        }
70
71        // If we have more than one H1, create violations for all but the first
72        if h1_headings.len() > 1 {
73            for (_i, (line, column, heading_text)) in h1_headings.iter().enumerate().skip(1) {
74                violations.push(self.create_violation(
75                    format!(
76                        "Multiple top-level headings in the same document (first at line {}): {}",
77                        h1_headings[0].0, heading_text
78                    ),
79                    *line,
80                    *column,
81                    Severity::Error,
82                ));
83            }
84        }
85
86        Ok(violations)
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::Document;
94    use crate::rule::Rule;
95    use std::path::PathBuf;
96
97    #[test]
98    fn test_md025_single_h1() {
99        let content = r#"# Single H1 heading
100## H2 heading
101### H3 heading
102"#;
103        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
104        let rule = MD025::new();
105        let violations = rule.check(&document).unwrap();
106
107        assert_eq!(violations.len(), 0);
108    }
109
110    #[test]
111    fn test_md025_multiple_h1_violation() {
112        let content = r#"# First H1 heading
113Some content here.
114
115# Second H1 heading
116More content.
117"#;
118        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
119        let rule = MD025::new();
120        let violations = rule.check(&document).unwrap();
121
122        assert_eq!(violations.len(), 1);
123        assert!(
124            violations[0]
125                .message
126                .contains("Multiple top-level headings")
127        );
128        assert!(violations[0].message.contains("first at line 1"));
129        assert!(violations[0].message.contains("Second H1 heading"));
130        assert_eq!(violations[0].line, 4);
131    }
132
133    #[test]
134    fn test_md025_three_h1_violations() {
135        let content = r#"# First H1
136Content here.
137
138# Second H1
139More content.
140
141# Third H1
142Even more content.
143"#;
144        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
145        let rule = MD025::new();
146        let violations = rule.check(&document).unwrap();
147
148        assert_eq!(violations.len(), 2);
149
150        // Both violations should reference the first H1 at line 1
151        assert!(violations[0].message.contains("first at line 1"));
152        assert!(violations[1].message.contains("first at line 1"));
153
154        // Check violation lines
155        assert_eq!(violations[0].line, 4); // Second H1
156        assert_eq!(violations[1].line, 7); // Third H1
157    }
158
159    #[test]
160    fn test_md025_no_h1_headings() {
161        let content = r#"## H2 heading
162### H3 heading
163#### H4 heading
164"#;
165        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
166        let rule = MD025::new();
167        let violations = rule.check(&document).unwrap();
168
169        assert_eq!(violations.len(), 0);
170    }
171
172    #[test]
173    fn test_md025_setext_headings() {
174        let content = r#"First H1 Setext
175===============
176
177Second H1 Setext
178================
179"#;
180        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
181        let rule = MD025::new();
182        let violations = rule.check(&document).unwrap();
183
184        assert_eq!(violations.len(), 1);
185        assert!(violations[0].message.contains("first at line 1"));
186        assert!(violations[0].message.contains("Second H1 Setext"));
187        assert_eq!(violations[0].line, 4);
188    }
189
190    #[test]
191    fn test_md025_mixed_atx_setext() {
192        let content = r#"# ATX H1 heading
193
194Setext H1 heading
195=================
196"#;
197        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
198        let rule = MD025::new();
199        let violations = rule.check(&document).unwrap();
200
201        assert_eq!(violations.len(), 1);
202        assert!(violations[0].message.contains("first at line 1"));
203        assert!(violations[0].message.contains("Setext H1 heading"));
204        assert_eq!(violations[0].line, 3);
205    }
206
207    #[test]
208    fn test_md025_custom_level() {
209        let content = r#"# H1 heading
210## First H2 heading
211### H3 heading
212## Second H2 heading
213"#;
214        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
215        let rule = MD025::with_level(2);
216        let violations = rule.check(&document).unwrap();
217
218        assert_eq!(violations.len(), 1);
219        assert!(violations[0].message.contains("first at line 2"));
220        assert!(violations[0].message.contains("Second H2 heading"));
221        assert_eq!(violations[0].line, 4);
222    }
223
224    #[test]
225    fn test_md025_h1_with_other_levels() {
226        let content = r#"# Main heading
227## Introduction
228### Details
229## Conclusion
230### More details
231#### Sub-details
232"#;
233        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
234        let rule = MD025::new();
235        let violations = rule.check(&document).unwrap();
236
237        assert_eq!(violations.len(), 0);
238    }
239
240    #[test]
241    fn test_md025_empty_h1_headings() {
242        let content = r#"#
243Content here.
244
245#
246More content.
247"#;
248        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
249        let rule = MD025::new();
250        let violations = rule.check(&document).unwrap();
251
252        assert_eq!(violations.len(), 1);
253        assert_eq!(violations[0].line, 4);
254    }
255
256    #[test]
257    fn test_md025_h1_in_code_blocks() {
258        let content = r#"# Real H1 heading
259
260```markdown
261# Fake H1 in code block
262```
263
264Some content.
265"#;
266        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
267        let rule = MD025::new();
268        let violations = rule.check(&document).unwrap();
269
270        // Should not detect the H1 in the code block
271        assert_eq!(violations.len(), 0);
272    }
273
274    #[test]
275    fn test_md025_regular_file_still_triggers() {
276        let content = r#"# First H1 heading
277Some content here.
278
279# Second H1 heading
280More content.
281"#;
282        let document = Document::new(content.to_string(), PathBuf::from("chapter.md")).unwrap();
283        let rule = MD025::new();
284        let violations = rule.check(&document).unwrap();
285
286        // Regular files should still trigger MD025 violations
287        assert_eq!(violations.len(), 1);
288        assert!(
289            violations[0]
290                .message
291                .contains("Multiple top-level headings")
292        );
293    }
294}