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 regex::Regex;
7use std::sync::LazyLock;
8
9static CLOSED_ATX_NO_SPACE_PATTERN: LazyLock<Regex> =
12 LazyLock::new(|| Regex::new(r"^(\s*)(#+)([^#\s].*?)([^#\s\\])(#+)(\s*(?:\{#[^}]+\})?\s*)$").unwrap());
13static CLOSED_ATX_NO_SPACE_START_PATTERN: LazyLock<Regex> =
14 LazyLock::new(|| Regex::new(r"^(\s*)(#+)([^#\s].*?)\s(#+)(\s*(?:\{#[^}]+\})?\s*)$").unwrap());
15static CLOSED_ATX_NO_SPACE_END_PATTERN: LazyLock<Regex> =
16 LazyLock::new(|| Regex::new(r"^(\s*)(#+)\s(.*?)([^#\s\\])(#+)(\s*(?:\{#[^}]+\})?\s*)$").unwrap());
17
18#[derive(Clone)]
19pub struct MD020NoMissingSpaceClosedAtx;
20
21impl Default for MD020NoMissingSpaceClosedAtx {
22 fn default() -> Self {
23 Self::new()
24 }
25}
26
27impl MD020NoMissingSpaceClosedAtx {
28 pub fn new() -> Self {
29 Self
30 }
31
32 fn opens_with_atx_marker(line_info: &crate::lint_context::LineInfo) -> bool {
35 line_info.atx_missing_space.is_some()
36 || line_info
37 .heading
38 .as_deref()
39 .is_some_and(|heading| matches!(heading.style, crate::lint_context::HeadingStyle::ATX))
40 }
41
42 fn is_closed_atx_heading_without_space(&self, line: &str) -> bool {
43 CLOSED_ATX_NO_SPACE_PATTERN.is_match(line)
44 || CLOSED_ATX_NO_SPACE_START_PATTERN.is_match(line)
45 || CLOSED_ATX_NO_SPACE_END_PATTERN.is_match(line)
46 }
47
48 fn fix_closed_atx_heading(&self, line: &str) -> String {
49 if let Some(captures) = CLOSED_ATX_NO_SPACE_PATTERN.captures(line) {
50 let indentation = &captures[1];
51 let opening_hashes = &captures[2];
52 let content = &captures[3];
53 let last_char = &captures[4];
54 let closing_hashes = &captures[5];
55 let custom_id = &captures[6];
56 format!("{indentation}{opening_hashes} {content}{last_char} {closing_hashes}{custom_id}")
57 } else if let Some(captures) = CLOSED_ATX_NO_SPACE_START_PATTERN.captures(line) {
58 let indentation = &captures[1];
59 let opening_hashes = &captures[2];
60 let content = &captures[3];
61 let closing_hashes = &captures[4];
62 let custom_id = &captures[5];
63 format!("{indentation}{opening_hashes} {content} {closing_hashes}{custom_id}")
64 } else if let Some(captures) = CLOSED_ATX_NO_SPACE_END_PATTERN.captures(line) {
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 !Self::opens_with_atx_marker(line_info) || line_info.visual_indent >= 4 {
95 continue;
96 }
97
98 let line = line_info.content(ctx.content);
99
100 if self.is_closed_atx_heading_without_space(line) {
104 let line_range = ctx.line_content_byte_range(line_num + 1);
105
106 let mut start_col = 1;
107 let mut length = 1;
108 let mut message = String::new();
109
110 if let Some(captures) = CLOSED_ATX_NO_SPACE_PATTERN.captures(line) {
111 let opening_hashes = captures.get(2).unwrap();
113 message = format!(
114 "Missing space inside hashes on closed heading (with {} at start and end)",
115 "#".repeat(opening_hashes.as_str().len())
116 );
117 start_col = line[..opening_hashes.end()].chars().count() + 1;
120 length = 1;
121 } else if let Some(captures) = CLOSED_ATX_NO_SPACE_START_PATTERN.captures(line) {
122 let opening_hashes = captures.get(2).unwrap();
124 message = format!(
125 "Missing space after {} at start of closed heading",
126 "#".repeat(opening_hashes.as_str().len())
127 );
128 start_col = line[..opening_hashes.end()].chars().count() + 1;
131 length = 1;
132 } else if let Some(captures) = CLOSED_ATX_NO_SPACE_END_PATTERN.captures(line) {
133 let content = captures.get(3).unwrap();
135 let closing_hashes = captures.get(5).unwrap();
136 message = format!(
137 "Missing space before {} at end of closed heading",
138 "#".repeat(closing_hashes.as_str().len())
139 );
140 start_col = line[..content.end()].chars().count() + 1;
143 length = 1;
144 }
145
146 let (start_line, start_col_calc, end_line, end_col) =
147 calculate_single_line_range(line_num + 1, start_col, length);
148
149 warnings.push(LintWarning {
150 rule_name: Some(self.name().to_string()),
151 message,
152 line: start_line,
153 column: start_col_calc,
154 end_line,
155 end_column: end_col,
156 severity: Severity::Warning,
157 fix: Some(Fix::new(line_range, self.fix_closed_atx_heading(line))),
158 });
159 }
160 }
161
162 Ok(warnings)
163 }
164
165 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
166 let mut lines = Vec::new();
167
168 for (i, line_info) in ctx.lines.iter().enumerate() {
169 let line_num = i + 1;
170 if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
172 lines.push(line_info.content(ctx.content).to_string());
173 continue;
174 }
175
176 let mut fixed = false;
177
178 if Self::opens_with_atx_marker(line_info) {
179 if line_info.visual_indent >= 4 {
181 lines.push(line_info.content(ctx.content).to_string());
182 continue;
183 }
184
185 if self.is_closed_atx_heading_without_space(line_info.content(ctx.content)) {
187 lines.push(self.fix_closed_atx_heading(line_info.content(ctx.content)));
188 fixed = true;
189 }
190 }
191
192 if !fixed {
193 lines.push(line_info.content(ctx.content).to_string());
194 }
195 }
196
197 let mut result = lines.join("\n");
199 if ctx.content.ends_with('\n') && !result.ends_with('\n') {
200 result.push('\n');
201 }
202
203 Ok(result)
204 }
205
206 fn category(&self) -> RuleCategory {
208 RuleCategory::Heading
209 }
210
211 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
213 ctx.content.is_empty() || !ctx.likely_has_headings()
214 }
215
216 fn as_any(&self) -> &dyn std::any::Any {
217 self
218 }
219
220 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
221 where
222 Self: Sized,
223 {
224 Box::new(MD020NoMissingSpaceClosedAtx::new())
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use crate::lint_context::LintContext;
232
233 #[test]
234 fn test_basic_functionality() {
235 let rule = MD020NoMissingSpaceClosedAtx;
236
237 let content = "# Heading 1 #\n## Heading 2 ##\n### Heading 3 ###";
239 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
240 let result = rule.check(&ctx).unwrap();
241 assert!(result.is_empty());
242
243 let content = "# Heading 1#\n## Heading 2 ##\n### Heading 3###";
245 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
246 let result = rule.check(&ctx).unwrap();
247 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
249 assert_eq!(result[1].line, 3);
250 }
251
252 #[test]
253 fn test_multibyte_char_column_position() {
254 let rule = MD020NoMissingSpaceClosedAtx;
255
256 let content = "##Ünited##";
262 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
263 let result = rule.check(&ctx).unwrap();
264
265 assert_eq!(result.len(), 1);
266 let content = "## Ü test##";
275 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
276 let result = rule.check(&ctx).unwrap();
277
278 assert_eq!(result.len(), 1);
279 assert_eq!(
283 result[0].column, 9,
284 "Column should use character position, not byte offset"
285 );
286 }
287}