mdbook_lint_core/rules/standard/
md018.rs

1//! MD018: No space after hash on atx style heading
2//!
3//! This rule checks for missing space after hash characters in ATX style headings.
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 for missing space after hash on ATX style headings
13pub struct MD018;
14
15impl Rule for MD018 {
16    fn id(&self) -> &'static str {
17        "MD018"
18    }
19
20    fn name(&self) -> &'static str {
21        "no-missing-space-atx"
22    }
23
24    fn description(&self) -> &'static str {
25        "No space after hash on atx style heading"
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("#!") {
46                // Find where the heading content starts
47                let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
48
49                // Check if there's content after the hashes
50                if trimmed.len() > hash_count {
51                    let after_hashes = &trimmed[hash_count..];
52
53                    // If there's content but no space, it's a violation
54                    if !after_hashes.is_empty() && !after_hashes.starts_with(' ') {
55                        let column = line.len() - line.trim_start().len() + hash_count + 1;
56
57                        violations.push(self.create_violation(
58                            "No space after hash on atx style heading".to_string(),
59                            line_num,
60                            column,
61                            Severity::Warning,
62                        ));
63                    }
64                }
65            }
66        }
67
68        Ok(violations)
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use crate::rule::Rule;
76    use std::path::PathBuf;
77
78    fn create_test_document(content: &str) -> Document {
79        Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
80    }
81
82    #[test]
83    fn test_md018_valid_headings() {
84        let content = "# Heading 1\n## Heading 2\n### Heading 3";
85        let document = create_test_document(content);
86        let rule = MD018;
87        let violations = rule.check(&document).unwrap();
88
89        assert_eq!(violations.len(), 0);
90    }
91
92    #[test]
93    fn test_md018_no_space_after_hash() {
94        let content = "#Heading without space";
95        let document = create_test_document(content);
96        let rule = MD018;
97        let violations = rule.check(&document).unwrap();
98
99        assert_eq!(violations.len(), 1);
100        assert_eq!(violations[0].rule_id, "MD018");
101        assert_eq!(violations[0].line, 1);
102        assert_eq!(violations[0].column, 2);
103        assert!(violations[0].message.contains("No space after hash"));
104    }
105
106    #[test]
107    fn test_md018_multiple_violations() {
108        let content = "#Heading 1\n##Heading 2\n### Valid heading\n####Another violation";
109        let document = create_test_document(content);
110        let rule = MD018;
111        let violations = rule.check(&document).unwrap();
112
113        assert_eq!(violations.len(), 3);
114        assert_eq!(violations[0].line, 1);
115        assert_eq!(violations[1].line, 2);
116        assert_eq!(violations[2].line, 4);
117    }
118
119    #[test]
120    fn test_md018_indented_heading() {
121        let content = "  #Indented heading without space";
122        let document = create_test_document(content);
123        let rule = MD018;
124        let violations = rule.check(&document).unwrap();
125
126        assert_eq!(violations.len(), 1);
127        assert_eq!(violations[0].column, 4); // After the hash
128    }
129
130    #[test]
131    fn test_md018_empty_heading() {
132        let content = "#\n##\n###";
133        let document = create_test_document(content);
134        let rule = MD018;
135        let violations = rule.check(&document).unwrap();
136
137        // Empty headings (just hashes) should not trigger violations
138        assert_eq!(violations.len(), 0);
139    }
140
141    #[test]
142    fn test_md018_closed_atx_style() {
143        let content = "#Heading#\n##Another#Heading##";
144        let document = create_test_document(content);
145        let rule = MD018;
146        let violations = rule.check(&document).unwrap();
147
148        assert_eq!(violations.len(), 2);
149        assert_eq!(violations[0].line, 1);
150        assert_eq!(violations[1].line, 2);
151    }
152
153    #[test]
154    fn test_md018_setext_headings_ignored() {
155        let content = "Setext Heading\n==============\n\nAnother Setext\n--------------";
156        let document = create_test_document(content);
157        let rule = MD018;
158        let violations = rule.check(&document).unwrap();
159
160        // Setext headings should not trigger this rule
161        assert_eq!(violations.len(), 0);
162    }
163
164    #[test]
165    fn test_md018_mixed_valid_invalid() {
166        let content = "# Valid heading\n#Invalid heading\n## Another valid\n###Invalid again";
167        let document = create_test_document(content);
168        let rule = MD018;
169        let violations = rule.check(&document).unwrap();
170
171        assert_eq!(violations.len(), 2);
172        assert_eq!(violations[0].line, 2);
173        assert_eq!(violations[1].line, 4);
174    }
175
176    #[test]
177    fn test_md018_shebang_lines_ignored() {
178        let content = "#!/bin/bash\n#This should trigger\n#!/usr/bin/env python3\n# This is valid";
179        let document = create_test_document(content);
180        let rule = MD018;
181        let violations = rule.check(&document).unwrap();
182
183        // Only the actual malformed heading should trigger, not shebangs
184        assert_eq!(violations.len(), 1);
185        assert_eq!(violations[0].line, 2);
186        assert!(violations[0].message.contains("No space after hash"));
187    }
188}