mdbook_lint_core/rules/standard/
md003.rs

1//! MD003: Heading style consistency
2//!
3//! This rule is triggered when different heading styles (ATX, Setext, and ATX closed)
4//! are used in the same document.
5
6use crate::Document;
7use crate::error::Result;
8use crate::rule::{RuleCategory, RuleMetadata};
9use crate::violation::{Severity, Violation};
10use comrak::nodes::{AstNode, NodeValue};
11use serde::{Deserialize, Serialize};
12
13/// Configuration for MD003 heading style consistency
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Md003Config {
16    /// The heading style to enforce
17    /// - "consistent": Auto-detect from first heading and enforce consistency
18    /// - "atx": Require ATX style (# Header)
19    /// - "atx_closed": Require ATX closed style (# Header #)
20    /// - "setext": Require Setext style (Header\n======)
21    /// - "setext_with_atx": Allow Setext for levels 1-2, ATX for 3+
22    pub style: String,
23}
24
25impl Default for Md003Config {
26    fn default() -> Self {
27        Self {
28            style: "consistent".to_string(),
29        }
30    }
31}
32
33/// MD003: Heading style should be consistent throughout the document
34pub struct MD003 {
35    config: Md003Config,
36}
37
38impl MD003 {
39    pub fn new() -> Self {
40        Self {
41            config: Md003Config::default(),
42        }
43    }
44
45    #[allow(dead_code)]
46    pub fn with_config(config: Md003Config) -> Self {
47        Self { config }
48    }
49}
50
51impl Default for MD003 {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57impl crate::rule::AstRule for MD003 {
58    fn id(&self) -> &'static str {
59        "MD003"
60    }
61
62    fn name(&self) -> &'static str {
63        "heading-style"
64    }
65
66    fn description(&self) -> &'static str {
67        "Heading style should be consistent throughout the document"
68    }
69
70    fn metadata(&self) -> RuleMetadata {
71        RuleMetadata::stable(RuleCategory::Structure).introduced_in("markdownlint v0.1.0")
72    }
73
74    fn check_ast<'a>(&self, document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
75        let mut violations = Vec::new();
76        let mut headings = Vec::new();
77
78        // Collect all headings with their styles
79        self.collect_headings(ast, document, &mut headings);
80
81        if headings.is_empty() {
82            return Ok(violations);
83        }
84
85        // Determine the expected style
86        let expected_style = self.determine_expected_style(&headings);
87
88        // Check each heading against the expected style
89        for heading in &headings {
90            if !self.is_valid_style(&heading.style, &expected_style, heading.level) {
91                violations.push(self.create_violation(
92                    format!(
93                        "Expected '{}' style heading but found '{}' style",
94                        expected_style, heading.style
95                    ),
96                    heading.line,
97                    heading.column,
98                    Severity::Error,
99                ));
100            }
101        }
102
103        Ok(violations)
104    }
105}
106
107impl MD003 {
108    /// Recursively collect all headings from the AST
109    fn collect_headings<'a>(
110        &self,
111        node: &'a AstNode<'a>,
112        document: &Document,
113        headings: &mut Vec<HeadingInfo>,
114    ) {
115        if let NodeValue::Heading(heading_data) = &node.data.borrow().value {
116            let position = node.data.borrow().sourcepos;
117            let style = self.determine_heading_style(node, document, position.start.line);
118            headings.push(HeadingInfo {
119                level: heading_data.level,
120                style,
121                line: position.start.line,
122                column: position.start.column,
123            });
124        }
125
126        // Recursively process child nodes
127        for child in node.children() {
128            self.collect_headings(child, document, headings);
129        }
130    }
131
132    /// Determine the style of a specific heading
133    fn determine_heading_style(
134        &self,
135        _node: &AstNode,
136        document: &Document,
137        line_number: usize,
138    ) -> HeadingStyle {
139        // Get the line content (convert to 0-based indexing)
140        let line_index = line_number.saturating_sub(1);
141        if line_index >= document.lines.len() {
142            return HeadingStyle::Atx;
143        }
144
145        let line = &document.lines[line_index];
146        let trimmed = line.trim();
147
148        // Check if it's ATX style (starts with #)
149        if trimmed.starts_with('#') {
150            // Check if it's ATX closed (ends with #)
151            if trimmed.ends_with('#') && trimmed.len() > 1 {
152                // Make sure it's not just a line of # characters
153                let content = trimmed.trim_start_matches('#').trim_end_matches('#').trim();
154                if !content.is_empty() {
155                    return HeadingStyle::AtxClosed;
156                }
157            }
158            return HeadingStyle::Atx;
159        }
160
161        // Check if it's Setext style (next line has === or ---)
162        if line_index + 1 < document.lines.len() {
163            let next_line = &document.lines[line_index + 1];
164            let next_trimmed = next_line.trim();
165
166            if !next_trimmed.is_empty() {
167                let first_char = next_trimmed.chars().next().unwrap();
168                if (first_char == '=' || first_char == '-')
169                    && next_trimmed.chars().all(|c| c == first_char)
170                {
171                    return HeadingStyle::Setext;
172                }
173            }
174        }
175
176        // Default to ATX if we can't determine (shouldn't happen with valid markdown)
177        HeadingStyle::Atx
178    }
179
180    /// Determine the expected style for the document
181    fn determine_expected_style(&self, headings: &[HeadingInfo]) -> HeadingStyle {
182        match self.config.style.as_str() {
183            "atx" => HeadingStyle::Atx,
184            "atx_closed" => HeadingStyle::AtxClosed,
185            "setext" => HeadingStyle::Setext,
186            "setext_with_atx" => HeadingStyle::SetextWithAtx,
187            "consistent" => {
188                // Use the style of the first heading
189                headings
190                    .first()
191                    .map(|h| h.style.clone())
192                    .unwrap_or(HeadingStyle::Atx)
193            }
194            _ => {
195                // Use the style of the first heading
196                headings
197                    .first()
198                    .map(|h| h.style.clone())
199                    .unwrap_or(HeadingStyle::Atx)
200            }
201        }
202    }
203
204    /// Check if a heading style is valid given the expected style and level
205    fn is_valid_style(&self, actual: &HeadingStyle, expected: &HeadingStyle, level: u8) -> bool {
206        match expected {
207            HeadingStyle::SetextWithAtx => {
208                // Setext for levels 1-2, ATX for 3+
209                if level <= 2 {
210                    matches!(actual, HeadingStyle::Setext)
211                } else {
212                    matches!(actual, HeadingStyle::Atx)
213                }
214            }
215            _ => actual == expected,
216        }
217    }
218}
219
220/// Information about a heading found in the document
221#[derive(Debug, Clone)]
222struct HeadingInfo {
223    level: u8,
224    style: HeadingStyle,
225    line: usize,
226    column: usize,
227}
228
229/// The different heading styles in Markdown
230#[derive(Debug, Clone, PartialEq, Eq)]
231enum HeadingStyle {
232    /// ATX style: # Header
233    Atx,
234    /// ATX closed style: # Header #
235    AtxClosed,
236    /// Setext style: Header\n======
237    Setext,
238    /// Mixed style: Setext for levels 1-2, ATX for 3+
239    SetextWithAtx,
240}
241
242impl std::fmt::Display for HeadingStyle {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        match self {
245            HeadingStyle::Atx => write!(f, "atx"),
246            HeadingStyle::AtxClosed => write!(f, "atx_closed"),
247            HeadingStyle::Setext => write!(f, "setext"),
248            HeadingStyle::SetextWithAtx => write!(f, "setext_with_atx"),
249        }
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use crate::Document;
257    use crate::rule::Rule;
258    use std::path::PathBuf;
259
260    fn create_test_document(content: &str) -> Document {
261        Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
262    }
263
264    #[test]
265    fn test_md003_consistent_atx_style() {
266        let content = r#"# Main Title
267
268## Section A
269
270### Subsection 1
271
272## Section B
273
274### Subsection 2
275"#;
276        let doc = create_test_document(content);
277        let rule = MD003::new();
278        let violations = rule.check(&doc).unwrap();
279
280        assert_eq!(
281            violations.len(),
282            0,
283            "Consistent ATX style should not trigger violations"
284        );
285    }
286
287    #[test]
288    fn test_md003_consistent_atx_closed_style() {
289        let content = r#"# Main Title #
290
291## Section A ##
292
293### Subsection 1 ###
294
295## Section B ##
296"#;
297        let doc = create_test_document(content);
298        let rule = MD003::new();
299        let violations = rule.check(&doc).unwrap();
300        assert_eq!(
301            violations.len(),
302            0,
303            "Consistent ATX closed style should not trigger violations"
304        );
305    }
306
307    #[test]
308    fn test_md003_consistent_setext_style() {
309        let content = r#"Main Title
310==========
311
312Section A
313---------
314
315Section B
316---------
317"#;
318        let doc = create_test_document(content);
319        let rule = MD003::new();
320        let violations = rule.check(&doc).unwrap();
321        assert_eq!(
322            violations.len(),
323            0,
324            "Consistent Setext style should not trigger violations"
325        );
326    }
327
328    #[test]
329    fn test_md003_mixed_styles_violation() {
330        let content = r#"# Main Title
331
332Section A
333---------
334
335## Section B
336"#;
337        let doc = create_test_document(content);
338        let rule = MD003::new();
339        let violations = rule.check(&doc).unwrap();
340
341        // Should have violations for inconsistent styles
342        assert!(
343            !violations.is_empty(),
344            "Mixed heading styles should trigger violations"
345        );
346
347        let violation_messages: Vec<&str> = violations.iter().map(|v| v.message.as_str()).collect();
348
349        // At least one violation should mention the style inconsistency
350        assert!(
351            violation_messages
352                .iter()
353                .any(|msg| msg.contains("Expected 'atx' style"))
354        );
355    }
356
357    #[test]
358    fn test_md003_atx_and_atx_closed_mixed() {
359        let content = r#"# Main Title
360
361## Section A ##
362
363### Subsection 1
364
365## Section B ##
366"#;
367        let doc = create_test_document(content);
368        let rule = MD003::new();
369        let violations = rule.check(&doc).unwrap();
370
371        // Should have violations for mixing ATX and ATX closed
372        assert!(
373            !violations.is_empty(),
374            "Mixed ATX and ATX closed styles should trigger violations"
375        );
376    }
377
378    #[test]
379    fn test_md003_configured_atx_style() {
380        let content = r#"Main Title
381==========
382
383Section A
384---------
385"#;
386        let doc = create_test_document(content);
387        let config = Md003Config {
388            style: "atx".to_string(),
389        };
390        let rule = MD003::with_config(config);
391        let violations = rule.check(&doc).unwrap();
392
393        // Should have violations because we're requiring ATX but document uses Setext
394        assert!(
395            !violations.is_empty(),
396            "Setext headings should violate when ATX is required"
397        );
398    }
399
400    #[test]
401    fn test_md003_configured_setext_style() {
402        let content = r#"# Main Title
403
404## Section A
405"#;
406        let doc = create_test_document(content);
407        let config = Md003Config {
408            style: "setext".to_string(),
409        };
410        let rule = MD003::with_config(config);
411        let violations = rule.check(&doc).unwrap();
412
413        // Should have violations because we're requiring Setext but document uses ATX
414        assert!(
415            !violations.is_empty(),
416            "ATX headings should violate when Setext is required"
417        );
418    }
419
420    #[test]
421    fn test_md003_setext_with_atx_valid() {
422        let content = r#"Main Title
423==========
424
425Section A
426---------
427
428### Subsection 1
429
430#### Deep Section
431"#;
432        let doc = create_test_document(content);
433        let config = Md003Config {
434            style: "setext_with_atx".to_string(),
435        };
436        let rule = MD003::with_config(config);
437        let violations = rule.check(&doc).unwrap();
438
439        assert_eq!(
440            violations.len(),
441            0,
442            "Setext for levels 1-2 and ATX for 3+ should be valid"
443        );
444    }
445
446    #[test]
447    fn test_md003_setext_with_atx_violation() {
448        let content = r#"# Main Title
449
450Section A
451---------
452
453### Subsection 1
454"#;
455        let doc = create_test_document(content);
456        let config = Md003Config {
457            style: "setext_with_atx".to_string(),
458        };
459        let rule = MD003::with_config(config);
460        let violations = rule.check(&doc).unwrap();
461
462        // Should have violation for ATX level 1 when Setext is expected
463        assert!(
464            !violations.is_empty(),
465            "ATX level 1 should violate setext_with_atx style"
466        );
467    }
468
469    #[test]
470    fn test_md003_no_headings() {
471        let content = r#"This is a document with no headings.
472
473Just some regular text content.
474"#;
475        let doc = create_test_document(content);
476        let rule = MD003::new();
477        let violations = rule.check(&doc).unwrap();
478        assert_eq!(
479            violations.len(),
480            0,
481            "Documents with no headings should not trigger violations"
482        );
483    }
484
485    #[test]
486    fn test_md003_single_heading() {
487        let content = r#"# Only One Heading
488
489Some content here.
490"#;
491        let doc = create_test_document(content);
492        let rule = MD003::new();
493        let violations = rule.check(&doc).unwrap();
494        assert_eq!(
495            violations.len(),
496            0,
497            "Documents with single heading should not trigger violations"
498        );
499    }
500}