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