mdlint/lint/rules/
md020.rs1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::{Fix, Violation};
4use serde_json::Value;
5
6pub struct MD020;
7
8impl Rule for MD020 {
9 fn name(&self) -> &str {
10 "MD020"
11 }
12
13 fn description(&self) -> &str {
14 "No space inside hashes on closed atx style heading"
15 }
16
17 fn tags(&self) -> &[&str] {
18 &["headings", "atx_closed", "spaces"]
19 }
20
21 fn check(&self, parser: &MarkdownParser, _config: Option<&Value>) -> Vec<Violation> {
22 let mut violations = Vec::new();
23
24 for (line_num, line) in parser.lines().iter().enumerate() {
25 let line_number = line_num + 1;
26 let trimmed = line.trim();
27
28 if trimmed.starts_with('#') && trimmed.ends_with('#') {
30 let opening_hashes = trimmed.chars().take_while(|&c| c == '#').count();
32
33 let closing_hashes = trimmed.chars().rev().take_while(|&c| c == '#').count();
35
36 if opening_hashes + closing_hashes < trimmed.len() {
38 let chars: Vec<char> = trimmed.chars().collect();
40 let pos_before_closing = chars.len() - closing_hashes - 1;
41
42 if chars[pos_before_closing] != ' ' {
43 let before_closing: String = chars[..=pos_before_closing].iter().collect();
45 let closing: String = chars[(pos_before_closing + 1)..].iter().collect();
46 let replacement = format!("{} {}", before_closing, closing);
47
48 violations.push(Violation {
49 line: line_number,
50 column: Some(1),
51 rule: self.name().to_string(),
52 message: "No space inside hashes on closed atx style heading"
53 .to_string(),
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 before closing hashes".to_string(),
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_closed_heading() {
90 let content = "# Heading #";
91 let parser = MarkdownParser::new(content);
92 let rule = MD020;
93 let violations = rule.check(&parser, None);
94
95 assert_eq!(violations.len(), 0); }
97
98 #[test]
99 fn test_no_space_before_closing() {
100 let content = "# Heading#";
101 let parser = MarkdownParser::new(content);
102 let rule = MD020;
103 let violations = rule.check(&parser, None);
104
105 assert_eq!(violations.len(), 1); }
107
108 #[test]
109 fn test_regular_heading() {
110 let content = "# Heading";
111 let parser = MarkdownParser::new(content);
112 let rule = MD020;
113 let violations = rule.check(&parser, None);
114
115 assert_eq!(violations.len(), 0); }
117
118 #[test]
119 fn test_multiple_levels() {
120 let content = "## Heading##\n### Another###";
121 let parser = MarkdownParser::new(content);
122 let rule = MD020;
123 let violations = rule.check(&parser, None);
124
125 assert_eq!(violations.len(), 2); }
127
128 #[test]
129 fn test_fix_inserts_space_before_closing_hashes() {
130 let content = "# Heading#\n";
131 let parser = MarkdownParser::new(content);
132 let rule = MD020;
133 let violations = rule.check(&parser, None);
134 assert_eq!(violations.len(), 1);
135 let fixed = apply_fixes(content, &violations);
136 assert_eq!(fixed, "# Heading #\n");
137 }
138}