mdbook_lint_core/rules/standard/
md002.rs

1//! MD002: First heading should be a top-level heading
2//!
3//! This rule checks that the first heading in a document is a top-level heading (h1).
4//! Note: This rule is deprecated in the original markdownlint but included for completeness.
5
6use crate::error::Result;
7use crate::rule::{AstRule, RuleCategory, RuleMetadata};
8use crate::{
9    Document,
10    violation::{Severity, Violation},
11};
12use comrak::nodes::AstNode;
13
14/// Rule to check that the first heading is a top-level heading
15pub struct MD002 {
16    /// The level that the first heading should be (default: 1)
17    level: u32,
18}
19
20impl MD002 {
21    /// Create a new MD002 rule with default settings (level 1)
22    pub fn new() -> Self {
23        Self { level: 1 }
24    }
25
26    /// Create a new MD002 rule with custom level
27    #[allow(dead_code)]
28    pub fn with_level(level: u32) -> Self {
29        Self { level }
30    }
31}
32
33impl Default for MD002 {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl AstRule for MD002 {
40    fn id(&self) -> &'static str {
41        "MD002"
42    }
43
44    fn name(&self) -> &'static str {
45        "first-heading-h1"
46    }
47
48    fn description(&self) -> &'static str {
49        "First heading should be a top-level heading"
50    }
51
52    fn metadata(&self) -> RuleMetadata {
53        RuleMetadata::deprecated(
54            RuleCategory::Structure,
55            "Superseded by MD041 which offers improved implementation",
56            Some("MD041"),
57        )
58        .introduced_in("markdownlint v0.1.0")
59    }
60
61    fn check_ast<'a>(&self, document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
62        let mut violations = Vec::new();
63        let headings = document.headings(ast);
64
65        // Find the first heading
66        if let Some(first_heading) = headings.first()
67            && let Some(heading_level) = Document::heading_level(first_heading)
68            && heading_level != self.level
69            && let Some((line, column)) = document.node_position(first_heading)
70        {
71            let heading_text = document.node_text(first_heading);
72            let message = format!(
73                "First heading should be level {} but got level {}{}",
74                self.level,
75                heading_level,
76                if heading_text.is_empty() {
77                    String::new()
78                } else {
79                    format!(": {}", heading_text.trim())
80                }
81            );
82
83            violations.push(self.create_violation(message, line, column, Severity::Warning));
84        }
85
86        Ok(violations)
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::rule::Rule;
94    use std::path::PathBuf;
95
96    fn create_test_document(content: &str) -> Document {
97        Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
98    }
99
100    #[test]
101    fn test_md002_valid_first_heading() {
102        let content = "# First heading\n## Second heading";
103        let document = create_test_document(content);
104        let rule = MD002::new();
105        let violations = rule.check(&document).unwrap();
106
107        assert_eq!(violations.len(), 0);
108    }
109
110    #[test]
111    fn test_md002_invalid_first_heading() {
112        let content = "## This should be h1\n### This is h3";
113        let document = create_test_document(content);
114        let rule = MD002::new();
115        let violations = rule.check(&document).unwrap();
116
117        assert_eq!(violations.len(), 1);
118        assert_eq!(violations[0].rule_id, "MD002");
119        assert_eq!(violations[0].line, 1);
120        assert!(violations[0].message.contains("should be level 1"));
121        assert!(violations[0].message.contains("got level 2"));
122    }
123
124    #[test]
125    fn test_md002_custom_level() {
126        let content = "## Starting with h2\n### Then h3";
127        let document = create_test_document(content);
128        let rule = MD002::with_level(2);
129        let violations = rule.check(&document).unwrap();
130
131        // Should be valid since we configured level 2 as the expected first level
132        assert_eq!(violations.len(), 0);
133    }
134
135    #[test]
136    fn test_md002_custom_level_violation() {
137        let content = "### Starting with h3\n#### Then h4";
138        let document = create_test_document(content);
139        let rule = MD002::with_level(2);
140        let violations = rule.check(&document).unwrap();
141
142        assert_eq!(violations.len(), 1);
143        assert!(violations[0].message.contains("should be level 2"));
144        assert!(violations[0].message.contains("got level 3"));
145    }
146
147    #[test]
148    fn test_md002_no_headings() {
149        let content = "Just some text without headings.";
150        let document = create_test_document(content);
151        let rule = MD002::new();
152        let violations = rule.check(&document).unwrap();
153
154        // No headings means no violations
155        assert_eq!(violations.len(), 0);
156    }
157
158    #[test]
159    fn test_md002_setext_heading() {
160        let content = "First Heading\n=============\n\nSecond Heading\n--------------";
161        let document = create_test_document(content);
162        let rule = MD002::new();
163        let violations = rule.check(&document).unwrap();
164
165        // Setext heading (=====) is level 1, so should be valid
166        assert_eq!(violations.len(), 0);
167    }
168
169    #[test]
170    fn test_md002_setext_heading_violation() {
171        let content = "First Heading\n--------------\n\nAnother Heading\n===============";
172        let document = create_test_document(content);
173        let rule = MD002::new();
174        let violations = rule.check(&document).unwrap();
175
176        // Setext heading (-----) is level 2, should trigger violation
177        assert_eq!(violations.len(), 1);
178        assert!(violations[0].message.contains("should be level 1"));
179        assert!(violations[0].message.contains("got level 2"));
180    }
181
182    #[test]
183    fn test_md002_heading_with_text() {
184        let content = "### My Third Level Heading\n#### Subheading";
185        let document = create_test_document(content);
186        let rule = MD002::new();
187        let violations = rule.check(&document).unwrap();
188
189        assert_eq!(violations.len(), 1);
190        assert!(violations[0].message.contains("My Third Level Heading"));
191    }
192
193    #[test]
194    fn test_md002_mixed_content_before_heading() {
195        let content = "Some intro text\n\n## First heading\n### Second heading";
196        let document = create_test_document(content);
197        let rule = MD002::new();
198        let violations = rule.check(&document).unwrap();
199
200        // The first *heading* should be h1, regardless of other content
201        assert_eq!(violations.len(), 1);
202        assert!(violations[0].message.contains("should be level 1"));
203        assert!(violations[0].message.contains("got level 2"));
204    }
205}