mdbook_lint_core/rules/standard/
md023.rs

1//! MD023: Headings must start at the beginning of the line
2//!
3//! This rule checks that headings are not indented with spaces or tabs.
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 headings start at the beginning of the line
13pub struct MD023;
14
15impl Rule for MD023 {
16    fn id(&self) -> &'static str {
17        "MD023"
18    }
19
20    fn name(&self) -> &'static str {
21        "heading-start-left"
22    }
23
24    fn description(&self) -> &'static str {
25        "Headings must start at the beginning of the line"
26    }
27
28    fn metadata(&self) -> RuleMetadata {
29        RuleMetadata::stable(RuleCategory::Structure).introduced_in("markdownlint v0.1.0")
30    }
31
32    fn check_with_ast<'a>(
33        &self,
34        document: &Document,
35        _ast: Option<&'a comrak::nodes::AstNode<'a>>,
36    ) -> Result<Vec<Violation>> {
37        let mut violations = Vec::new();
38
39        for (line_number, line) in document.lines.iter().enumerate() {
40            let line_num = line_number + 1; // Convert to 1-based line numbers
41
42            // Check if this is an ATX-style heading (starts with #)
43            // Skip shebang lines (#!/...)
44            let trimmed = line.trim_start();
45            if trimmed.starts_with('#') && !trimmed.starts_with("#!") && line != trimmed {
46                let leading_whitespace = line.len() - trimmed.len();
47
48                violations.push(self.create_violation(
49                    format!(
50                        "Heading is indented by {} character{}",
51                        leading_whitespace,
52                        if leading_whitespace == 1 { "" } else { "s" }
53                    ),
54                    line_num,
55                    1,
56                    Severity::Warning,
57                ));
58            }
59            // Note: Setext headings are handled differently as they span multiple lines
60            // and the heading text itself might be indented, but we only care about
61            // ATX headings for this rule
62        }
63
64        Ok(violations)
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use crate::rule::Rule;
72    use std::path::PathBuf;
73
74    fn create_test_document(content: &str) -> Document {
75        Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
76    }
77
78    #[test]
79    fn test_md023_valid_headings() {
80        let content = "# Heading 1\n## Heading 2\n### Heading 3";
81        let document = create_test_document(content);
82        let rule = MD023;
83        let violations = rule.check(&document).unwrap();
84
85        assert_eq!(violations.len(), 0);
86    }
87
88    #[test]
89    fn test_md023_single_space_indent() {
90        let content = " # Indented heading";
91        let document = create_test_document(content);
92        let rule = MD023;
93        let violations = rule.check(&document).unwrap();
94
95        assert_eq!(violations.len(), 1);
96        assert_eq!(violations[0].rule_id, "MD023");
97        assert_eq!(violations[0].line, 1);
98        assert_eq!(violations[0].column, 1);
99        assert!(violations[0].message.contains("indented by 1 character"));
100    }
101
102    #[test]
103    fn test_md023_multiple_spaces_indent() {
104        let content = "   ## Heading with 3 spaces";
105        let document = create_test_document(content);
106        let rule = MD023;
107        let violations = rule.check(&document).unwrap();
108
109        assert_eq!(violations.len(), 1);
110        assert!(violations[0].message.contains("indented by 3 characters"));
111    }
112
113    #[test]
114    fn test_md023_tab_indent() {
115        let content = "\t# Tab indented heading";
116        let document = create_test_document(content);
117        let rule = MD023;
118        let violations = rule.check(&document).unwrap();
119
120        assert_eq!(violations.len(), 1);
121        assert!(violations[0].message.contains("indented by 1 character"));
122    }
123
124    #[test]
125    fn test_md023_mixed_whitespace_indent() {
126        let content = " \t # Mixed whitespace indent";
127        let document = create_test_document(content);
128        let rule = MD023;
129        let violations = rule.check(&document).unwrap();
130
131        assert_eq!(violations.len(), 1);
132        assert!(violations[0].message.contains("indented by 3 characters"));
133    }
134
135    #[test]
136    fn test_md023_multiple_violations() {
137        let content = " # Heading 1\n## Valid heading\n  ### Heading 3\n#### Valid heading";
138        let document = create_test_document(content);
139        let rule = MD023;
140        let violations = rule.check(&document).unwrap();
141
142        assert_eq!(violations.len(), 2);
143        assert_eq!(violations[0].line, 1);
144        assert_eq!(violations[1].line, 3);
145    }
146
147    #[test]
148    fn test_md023_setext_headings_ignored() {
149        let content = "  Setext Heading\n  ==============\n\n  Another Setext\n  --------------";
150        let document = create_test_document(content);
151        let rule = MD023;
152        let violations = rule.check(&document).unwrap();
153
154        // Setext headings should not trigger this rule (they don't start with #)
155        assert_eq!(violations.len(), 0);
156    }
157
158    #[test]
159    fn test_md023_code_blocks_detected() {
160        let content = "```\n  # This is in a code block\n  ## Should trigger\n```";
161        let document = create_test_document(content);
162        let rule = MD023;
163        let violations = rule.check(&document).unwrap();
164
165        // Simple line-based approach will detect indented # as violations
166        // even in code blocks (more sophisticated parsing would be needed to avoid this)
167        assert_eq!(violations.len(), 2);
168        assert_eq!(violations[0].line, 2);
169        assert_eq!(violations[1].line, 3);
170    }
171
172    #[test]
173    fn test_md023_blockquote_headings() {
174        let content = "> # Heading in blockquote\n>  ## Indented heading in blockquote";
175        let document = create_test_document(content);
176        let rule = MD023;
177        let violations = rule.check(&document).unwrap();
178
179        // Simple line-based approach doesn't understand blockquote context
180        // so it won't detect these as headings since they don't start with #
181        assert_eq!(violations.len(), 0);
182    }
183
184    #[test]
185    fn test_md023_closed_atx_headings() {
186        let content = "  # Indented closed heading #\n   ## Another indented ##";
187        let document = create_test_document(content);
188        let rule = MD023;
189        let violations = rule.check(&document).unwrap();
190
191        assert_eq!(violations.len(), 2);
192        assert!(violations[0].message.contains("indented by 2 characters"));
193        assert!(violations[1].message.contains("indented by 3 characters"));
194    }
195
196    #[test]
197    fn test_md023_shebang_lines_ignored() {
198        let content =
199            "#!/bin/bash\n  #This should trigger\n  #!/usr/bin/env python3\n# This is valid";
200        let document = create_test_document(content);
201        let rule = MD023;
202        let violations = rule.check(&document).unwrap();
203
204        // Only the actual indented heading should trigger, not shebangs
205        assert_eq!(violations.len(), 1);
206        assert_eq!(violations[0].line, 2);
207        assert!(violations[0].message.contains("indented by 2 characters"));
208    }
209}