mdbook_lint_core/rules/standard/
md041.rs

1//! MD041: First line in file should be a top level heading
2//!
3//! This rule checks that the first line of the file is a top-level heading (H1).
4
5use crate::error::Result;
6use crate::rule::{Rule, RuleCategory, RuleMetadata};
7use crate::{
8    Document,
9    violation::{Severity, Violation},
10};
11
12/// Rule to check that the first line is a top-level heading
13pub struct MD041;
14
15impl MD041 {
16    /// Check if a line is a top-level heading (H1)
17    fn is_top_level_heading(&self, line: &str) -> bool {
18        let trimmed = line.trim();
19
20        // ATX style: # Heading
21        if trimmed.starts_with("# ") && !trimmed.starts_with("## ") {
22            return true;
23        }
24
25        // Also accept just # without space if there's content after
26        if trimmed.starts_with('#') && !trimmed.starts_with("##") && trimmed.len() > 1 {
27            return true;
28        }
29
30        false
31    }
32
33    /// Check if a line is a setext-style H1 (underlined with =)
34    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    /// Check if a line is content that could be a setext heading
40    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        // Find the first non-empty line
75        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            // File is empty or only whitespace
85            return Ok(violations);
86        };
87
88        let first_line = &document.lines[first_idx];
89
90        // Check if first line is an ATX H1
91        if self.is_top_level_heading(first_line) {
92            return Ok(violations);
93        }
94
95        // Check for setext-style H1 (current line + next line with =)
96        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        // If we get here, the first line is not a top-level heading
105        violations.push(self.create_violation(
106            "First line in file should be a top level heading".to_string(),
107            first_idx + 1, // Convert to 1-based line number
108            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); // Line with "Some paragraph first."
234    }
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}