mdbook_lint_core/rules/standard/
md025.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 MD025 {
15 level: u8,
17}
18
19impl MD025 {
20 pub fn new() -> Self {
22 Self { level: 1 }
23 }
24
25 #[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 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 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 assert!(violations[0].message.contains("first at line 1"));
152 assert!(violations[1].message.contains("first at line 1"));
153
154 assert_eq!(violations[0].line, 4); assert_eq!(violations[1].line, 7); }
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 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 assert_eq!(violations.len(), 1);
288 assert!(
289 violations[0]
290 .message
291 .contains("Multiple top-level headings")
292 );
293 }
294}