rumdl_lib/rules/
md020_no_missing_space_closed_atx.rs1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::range_utils::{LineIndex, calculate_single_line_range};
6use lazy_static::lazy_static;
7use regex::Regex;
8
9lazy_static! {
10 static ref CLOSED_ATX_NO_SPACE_PATTERN: Regex = Regex::new(r"^(\s*)(#+)([^#\s].*?)([^#\s])(#+)(\s*(?:\{#[^}]+\})?\s*)$").unwrap();
12 static ref CLOSED_ATX_NO_SPACE_START_PATTERN: Regex = Regex::new(r"^(\s*)(#+)([^#\s].*?)\s(#+)(\s*(?:\{#[^}]+\})?\s*)$").unwrap();
13 static ref CLOSED_ATX_NO_SPACE_END_PATTERN: Regex = Regex::new(r"^(\s*)(#+)\s(.*?)([^#\s])(#+)(\s*(?:\{#[^}]+\})?\s*)$").unwrap();
14}
15
16#[derive(Clone)]
17pub struct MD020NoMissingSpaceClosedAtx;
18
19impl Default for MD020NoMissingSpaceClosedAtx {
20 fn default() -> Self {
21 Self::new()
22 }
23}
24
25impl MD020NoMissingSpaceClosedAtx {
26 pub fn new() -> Self {
27 Self
28 }
29
30 fn is_closed_atx_heading_without_space(&self, line: &str) -> bool {
31 CLOSED_ATX_NO_SPACE_PATTERN.is_match(line)
32 || CLOSED_ATX_NO_SPACE_START_PATTERN.is_match(line)
33 || CLOSED_ATX_NO_SPACE_END_PATTERN.is_match(line)
34 }
35
36 fn fix_closed_atx_heading(&self, line: &str) -> String {
37 if let Some(captures) = CLOSED_ATX_NO_SPACE_PATTERN.captures(line) {
38 let indentation = &captures[1];
39 let opening_hashes = &captures[2];
40 let content = &captures[3];
41 let last_char = &captures[4];
42 let closing_hashes = &captures[5];
43 let custom_id = &captures[6];
44 format!("{indentation}{opening_hashes} {content}{last_char} {closing_hashes}{custom_id}")
45 } else if let Some(captures) = CLOSED_ATX_NO_SPACE_START_PATTERN.captures(line) {
46 let indentation = &captures[1];
47 let opening_hashes = &captures[2];
48 let content = &captures[3];
49 let closing_hashes = &captures[4];
50 let custom_id = &captures[5];
51 format!("{indentation}{opening_hashes} {content} {closing_hashes}{custom_id}")
52 } else if let Some(captures) = CLOSED_ATX_NO_SPACE_END_PATTERN.captures(line) {
53 let indentation = &captures[1];
54 let opening_hashes = &captures[2];
55 let content = &captures[3];
56 let last_char = &captures[4];
57 let closing_hashes = &captures[5];
58 let custom_id = &captures[6];
59 format!("{indentation}{opening_hashes} {content}{last_char} {closing_hashes}{custom_id}")
60 } else {
61 line.to_string()
62 }
63 }
64}
65
66impl Rule for MD020NoMissingSpaceClosedAtx {
67 fn name(&self) -> &'static str {
68 "MD020"
69 }
70
71 fn description(&self) -> &'static str {
72 "No space inside hashes on closed heading"
73 }
74
75 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
76 let mut warnings = Vec::new();
77
78 for (line_num, line_info) in ctx.lines.iter().enumerate() {
80 if let Some(heading) = &line_info.heading {
81 if line_info.indent >= 4 {
83 continue;
84 }
85
86 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
88 let line = &line_info.content;
89
90 if self.is_closed_atx_heading_without_space(line) {
94 let line_index = LineIndex::new(ctx.content.to_string());
95 let line_range = line_index.line_content_range(line_num + 1);
96
97 let mut start_col = 1;
98 let mut length = 1;
99 let mut message = String::new();
100
101 if let Some(captures) = CLOSED_ATX_NO_SPACE_PATTERN.captures(line) {
102 let opening_hashes = captures.get(2).unwrap();
104 message = format!(
105 "Missing space inside hashes on closed heading (with {} at start and end)",
106 "#".repeat(opening_hashes.as_str().len())
107 );
108 start_col = opening_hashes.end() + 1;
110 length = 1;
111 } else if let Some(captures) = CLOSED_ATX_NO_SPACE_START_PATTERN.captures(line) {
112 let opening_hashes = captures.get(2).unwrap();
114 message = format!(
115 "Missing space after {} at start of closed heading",
116 "#".repeat(opening_hashes.as_str().len())
117 );
118 start_col = opening_hashes.end() + 1;
120 length = 1;
121 } else if let Some(captures) = CLOSED_ATX_NO_SPACE_END_PATTERN.captures(line) {
122 let content = captures.get(3).unwrap();
124 let closing_hashes = captures.get(5).unwrap();
125 message = format!(
126 "Missing space before {} at end of closed heading",
127 "#".repeat(closing_hashes.as_str().len())
128 );
129 start_col = content.end() + 1;
131 length = 1;
132 }
133
134 let (start_line, start_col_calc, end_line, end_col) =
135 calculate_single_line_range(line_num + 1, start_col, length);
136
137 warnings.push(LintWarning {
138 rule_name: Some(self.name()),
139 message,
140 line: start_line,
141 column: start_col_calc,
142 end_line,
143 end_column: end_col,
144 severity: Severity::Warning,
145 fix: Some(Fix {
146 range: line_range,
147 replacement: self.fix_closed_atx_heading(line),
148 }),
149 });
150 }
151 }
152 }
153 }
154
155 Ok(warnings)
156 }
157
158 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
159 let mut lines = Vec::new();
160
161 for line_info in ctx.lines.iter() {
162 let mut fixed = false;
163
164 if let Some(heading) = &line_info.heading {
165 if line_info.indent >= 4 {
167 lines.push(line_info.content.clone());
168 continue;
169 }
170
171 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX)
173 && self.is_closed_atx_heading_without_space(&line_info.content)
174 {
175 lines.push(self.fix_closed_atx_heading(&line_info.content));
176 fixed = true;
177 }
178 }
179
180 if !fixed {
181 lines.push(line_info.content.clone());
182 }
183 }
184
185 let mut result = lines.join("\n");
187 if ctx.content.ends_with('\n') && !result.ends_with('\n') {
188 result.push('\n');
189 }
190
191 Ok(result)
192 }
193
194 fn category(&self) -> RuleCategory {
196 RuleCategory::Heading
197 }
198
199 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
201 ctx.content.is_empty() || !ctx.content.contains('#')
202 }
203
204 fn as_any(&self) -> &dyn std::any::Any {
205 self
206 }
207
208 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
209 where
210 Self: Sized,
211 {
212 Box::new(MD020NoMissingSpaceClosedAtx::new())
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use crate::lint_context::LintContext;
220
221 #[test]
222 fn test_basic_functionality() {
223 let rule = MD020NoMissingSpaceClosedAtx;
224
225 let content = "# Heading 1 #\n## Heading 2 ##\n### Heading 3 ###";
227 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
228 let result = rule.check(&ctx).unwrap();
229 assert!(result.is_empty());
230
231 let content = "# Heading 1#\n## Heading 2 ##\n### Heading 3###";
233 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
234 let result = rule.check(&ctx).unwrap();
235 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
237 assert_eq!(result[1].line, 3);
238 }
239}