mdlint/lint/rules/
md018.rs1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::{Fix, Violation};
4use serde_json::Value;
5
6pub struct MD018;
7
8impl Rule for MD018 {
9 fn name(&self) -> &'static str {
10 "MD018"
11 }
12
13 fn description(&self) -> &'static str {
14 "No space after hash on atx style heading"
15 }
16
17 fn tags(&self) -> &[&str] {
18 &["headings", "headers", "atx", "spaces"]
19 }
20
21 fn check(&self, parser: &MarkdownParser, _config: Option<&Value>) -> Vec<Violation> {
22 let mut violations = Vec::new();
23 let code_block_lines = parser.get_code_block_line_numbers();
24
25 for (line_num, line) in parser.lines().iter().enumerate() {
26 let line_number = line_num + 1;
27 if code_block_lines.contains(&line_number) {
28 continue;
29 }
30 let trimmed = line.trim();
31
32 if trimmed.starts_with('#') {
34 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
36
37 if hash_count > 0 && hash_count <= 6 {
39 if let Some(next_char) = trimmed.chars().nth(hash_count)
41 && !next_char.is_whitespace()
42 && next_char != '#'
43 {
44 let hashes = "#".repeat(hash_count);
46 let rest = &trimmed[hash_count..];
47 let replacement = format!("{hashes} {rest}");
48
49 violations.push(Violation {
50 line: line_number,
51 column: Some(hash_count + 1),
52 rule: self.name().to_owned(),
53 message: "No space after hash on atx style heading".to_owned(),
54 fix: Some(Fix {
55 line_start: line_number,
56 line_end: line_number,
57 column_start: None,
58 column_end: None,
59 replacement,
60 description: "Add space after hash".to_owned(),
61 }),
62 });
63 }
64 }
65 }
66 }
67
68 violations
69 }
70
71 fn fixable(&self) -> bool {
72 true
73 }
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79 use crate::fix::Fixer;
80
81 fn apply_fixes(content: &str, violations: &[Violation]) -> String {
82 let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
83 Fixer::new()
84 .apply_fixes_to_content(content, &fixes)
85 .unwrap()
86 }
87
88 #[test]
89 fn test_correct_spacing() {
90 let content = "# Heading 1\n## Heading 2\n### Heading 3";
91 let parser = MarkdownParser::new(content);
92 let rule = MD018;
93 let violations = rule.check(&parser, None);
94
95 assert_eq!(violations.len(), 0);
96 }
97
98 #[test]
99 fn test_no_space_after_hash() {
100 let content = "#Heading without space\n## Correct heading";
101 let parser = MarkdownParser::new(content);
102 let rule = MD018;
103 let violations = rule.check(&parser, None);
104
105 assert_eq!(violations.len(), 1);
106 assert_eq!(violations[0].line, 1);
107 }
108
109 #[test]
110 fn test_multiple_violations() {
111 let content = "#First\n##Second\n### Correct";
112 let parser = MarkdownParser::new(content);
113 let rule = MD018;
114 let violations = rule.check(&parser, None);
115
116 assert_eq!(violations.len(), 2);
117 }
118
119 #[test]
120 fn test_closed_heading() {
121 let content = "## Closed heading ##";
122 let parser = MarkdownParser::new(content);
123 let rule = MD018;
124 let violations = rule.check(&parser, None);
125
126 assert_eq!(violations.len(), 0);
127 }
128
129 #[test]
130 fn test_heading_in_code_block_not_flagged() {
131 let content = "# Real heading\n\n```\n#NotAHeading\n```\n";
132 let parser = MarkdownParser::new(content);
133 let rule = MD018;
134 let violations = rule.check(&parser, None);
135
136 assert_eq!(violations.len(), 0);
137 }
138
139 #[test]
140 fn test_fix_inserts_space_after_hash() {
141 let content = "#Heading\n\n##Another\n";
142 let parser = MarkdownParser::new(content);
143 let rule = MD018;
144 let violations = rule.check(&parser, None);
145 assert_eq!(violations.len(), 2);
146 let fixed = apply_fixes(content, &violations);
147 assert_eq!(fixed, "# Heading\n\n## Another\n");
148 }
149}