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) -> &'static str {
10 "MD020"
11 }
12
13 fn description(&self) -> &'static 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
43 .get(pos_before_closing)
44 .copied()
45 .expect("pos bounded by len")
46 != ' '
47 {
48 let before_closing: String = chars
50 .get(..=pos_before_closing)
51 .expect("pos bounded by len")
52 .iter()
53 .collect();
54 let closing: String = chars
55 .get((pos_before_closing + 1)..)
56 .expect("pos+1 bounded by len")
57 .iter()
58 .collect();
59 let replacement = format!("{before_closing} {closing}");
60
61 violations.push(Violation {
62 line: line_number,
63 column: Some(1),
64 rule: self.name().to_owned(),
65 message: "No space inside hashes on closed atx style heading"
66 .to_owned(),
67 fix: Some(Fix {
68 line_start: line_number,
69 line_end: line_number,
70 column_start: None,
71 column_end: None,
72 replacement,
73 description: "Add space before closing hashes".to_owned(),
74 }),
75 });
76 }
77 }
78 }
79 }
80
81 violations
82 }
83
84 fn fixable(&self) -> bool {
85 true
86 }
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92 use crate::fix::Fixer;
93
94 fn apply_fixes(content: &str, violations: &[Violation]) -> String {
95 let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
96 Fixer::new()
97 .apply_fixes_to_content(content, &fixes)
98 .unwrap()
99 }
100
101 #[test]
102 fn test_correct_closed_heading() {
103 let content = "# Heading #";
104 let parser = MarkdownParser::new(content);
105 let rule = MD020;
106 let violations = rule.check(&parser, None);
107
108 assert_eq!(violations.len(), 0); }
110
111 #[test]
112 fn test_no_space_before_closing() {
113 let content = "# Heading#";
114 let parser = MarkdownParser::new(content);
115 let rule = MD020;
116 let violations = rule.check(&parser, None);
117
118 assert_eq!(violations.len(), 1); }
120
121 #[test]
122 fn test_regular_heading() {
123 let content = "# Heading";
124 let parser = MarkdownParser::new(content);
125 let rule = MD020;
126 let violations = rule.check(&parser, None);
127
128 assert_eq!(violations.len(), 0); }
130
131 #[test]
132 fn test_multiple_levels() {
133 let content = "## Heading##\n### Another###";
134 let parser = MarkdownParser::new(content);
135 let rule = MD020;
136 let violations = rule.check(&parser, None);
137
138 assert_eq!(violations.len(), 2); }
140
141 #[test]
142 fn test_fix_inserts_space_before_closing_hashes() {
143 let content = "# Heading#\n";
144 let parser = MarkdownParser::new(content);
145 let rule = MD020;
146 let violations = rule.check(&parser, None);
147 assert_eq!(violations.len(), 1);
148 let fixed = apply_fixes(content, &violations);
149 assert_eq!(fixed, "# Heading #\n");
150 }
151}