rumdl_lib/rules/
md018_no_missing_space_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 EMOJI_HASHTAG_PATTERN_STR: &str = r"^#️⃣|^#⃣";
10const UNICODE_HASHTAG_PATTERN_STR: &str = r"^#[\u{FE0F}\u{20E3}]";
11
12#[derive(Clone)]
13pub struct MD018NoMissingSpaceAtx;
14
15impl Default for MD018NoMissingSpaceAtx {
16 fn default() -> Self {
17 Self::new()
18 }
19}
20
21impl MD018NoMissingSpaceAtx {
22 pub fn new() -> Self {
23 Self
24 }
25
26 fn check_atx_heading_line(&self, line: &str) -> Option<(usize, String)> {
28 let trimmed_line = line.trim_start();
30 let indent = line.len() - trimmed_line.len();
31
32 if !trimmed_line.starts_with('#') {
33 return None;
34 }
35
36 let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
38 .map(|re| re.is_match(trimmed_line))
39 .unwrap_or(false);
40 let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
41 .map(|re| re.is_match(trimmed_line))
42 .unwrap_or(false);
43 if is_emoji || is_unicode {
44 return None;
45 }
46
47 let hash_count = trimmed_line.chars().take_while(|&c| c == '#').count();
49 if hash_count == 0 || hash_count > 6 {
50 return None;
51 }
52
53 let after_hashes = &trimmed_line[hash_count..];
55
56 if after_hashes
58 .chars()
59 .next()
60 .is_some_and(|ch| matches!(ch, '\u{FE0F}' | '\u{20E3}' | '\u{FE0E}'))
61 {
62 return None;
63 }
64
65 if !after_hashes.is_empty() && !after_hashes.starts_with(' ') && !after_hashes.starts_with('\t') {
67 let content = after_hashes.trim();
69
70 if content.chars().all(|c| c == '#') {
72 return None;
73 }
74
75 if content.len() < 2 {
77 return None;
78 }
79
80 if content.starts_with('*') || content.starts_with('_') {
82 return None;
83 }
84
85 if hash_count == 1 && !content.is_empty() {
88 let first_char = content.chars().next();
89 if let Some(ch) = first_char {
90 if (ch.is_lowercase() || ch.is_numeric()) && !content.contains(' ') {
93 return None;
94 }
95 }
96 }
97
98 let fixed = format!("{}{} {}", " ".repeat(indent), "#".repeat(hash_count), after_hashes);
100 return Some((indent + hash_count, fixed));
101 }
102
103 None
104 }
105
106 fn get_line_byte_range(&self, content: &str, line_num: usize) -> std::ops::Range<usize> {
108 let mut current_line = 1;
109 let mut start_byte = 0;
110
111 for (i, c) in content.char_indices() {
112 if current_line == line_num && c == '\n' {
113 return start_byte..i;
114 } else if c == '\n' {
115 current_line += 1;
116 if current_line == line_num {
117 start_byte = i + 1;
118 }
119 }
120 }
121
122 if current_line == line_num {
124 return start_byte..content.len();
125 }
126
127 0..0
129 }
130}
131
132impl Rule for MD018NoMissingSpaceAtx {
133 fn name(&self) -> &'static str {
134 "MD018"
135 }
136
137 fn description(&self) -> &'static str {
138 "No space after hash in heading"
139 }
140
141 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
142 let mut warnings = Vec::new();
143
144 for (line_num, line_info) in ctx.lines.iter().enumerate() {
146 if let Some(heading) = &line_info.heading {
147 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
149 let line = &line_info.content;
151 let trimmed = line.trim_start();
152
153 let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
155 .map(|re| re.is_match(trimmed))
156 .unwrap_or(false);
157 let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
158 .map(|re| re.is_match(trimmed))
159 .unwrap_or(false);
160 if is_emoji || is_unicode {
161 continue;
162 }
163
164 if trimmed.len() > heading.marker.len() {
165 let after_marker = &trimmed[heading.marker.len()..];
166 if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
167 {
168 let hash_end_col = line_info.indent + heading.marker.len() + 1; let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
171 line_num + 1, hash_end_col,
173 0, );
175
176 warnings.push(LintWarning {
177 rule_name: Some(self.name()),
178 message: format!("No space after {} in heading", "#".repeat(heading.level as usize)),
179 line: start_line,
180 column: start_col,
181 end_line,
182 end_column: end_col,
183 severity: Severity::Warning,
184 fix: Some(Fix {
185 range: self.get_line_byte_range(ctx.content, line_num + 1),
186 replacement: format!(
187 "{}{} {}",
188 " ".repeat(line_info.indent),
189 heading.marker,
190 after_marker
191 ),
192 }),
193 });
194 }
195 }
196 }
197 } else if !line_info.in_code_block && !line_info.is_blank {
198 if let Some((hash_end_pos, fixed_line)) = self.check_atx_heading_line(&line_info.content) {
200 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
201 line_num + 1, hash_end_pos + 1, 0, );
205
206 warnings.push(LintWarning {
207 rule_name: Some(self.name()),
208 message: "No space after hash in heading".to_string(),
209 line: start_line,
210 column: start_col,
211 end_line,
212 end_column: end_col,
213 severity: Severity::Warning,
214 fix: Some(Fix {
215 range: self.get_line_byte_range(ctx.content, line_num + 1),
216 replacement: fixed_line,
217 }),
218 });
219 }
220 }
221 }
222
223 Ok(warnings)
224 }
225
226 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
227 let mut lines = Vec::new();
228
229 for line_info in ctx.lines.iter() {
230 let mut fixed = false;
231
232 if let Some(heading) = &line_info.heading {
233 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
235 let line = &line_info.content;
236 let trimmed = line.trim_start();
237
238 let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
240 .map(|re| re.is_match(trimmed))
241 .unwrap_or(false);
242 let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
243 .map(|re| re.is_match(trimmed))
244 .unwrap_or(false);
245 if is_emoji || is_unicode {
246 continue;
247 }
248
249 if trimmed.len() > heading.marker.len() {
250 let after_marker = &trimmed[heading.marker.len()..];
251 if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
252 {
253 lines.push(format!(
255 "{}{} {}",
256 " ".repeat(line_info.indent),
257 heading.marker,
258 after_marker
259 ));
260 fixed = true;
261 }
262 }
263 }
264 } else if !line_info.in_code_block && !line_info.is_blank {
265 if let Some((_, fixed_line)) = self.check_atx_heading_line(&line_info.content) {
267 lines.push(fixed_line);
268 fixed = true;
269 }
270 }
271
272 if !fixed {
273 lines.push(line_info.content.clone());
274 }
275 }
276
277 let mut result = lines.join("\n");
279 if ctx.content.ends_with('\n') && !result.ends_with('\n') {
280 result.push('\n');
281 }
282
283 Ok(result)
284 }
285
286 fn category(&self) -> RuleCategory {
288 RuleCategory::Heading
289 }
290
291 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
293 if !ctx.likely_has_headings() {
295 return true;
296 }
297 !ctx.lines.iter().any(|line| line.content.contains('#'))
299 }
300
301 fn as_any(&self) -> &dyn std::any::Any {
302 self
303 }
304
305 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
306 where
307 Self: Sized,
308 {
309 Box::new(MD018NoMissingSpaceAtx::new())
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use crate::lint_context::LintContext;
317
318 #[test]
319 fn test_basic_functionality() {
320 let rule = MD018NoMissingSpaceAtx;
321
322 let content = "# Heading 1\n## Heading 2\n### Heading 3";
324 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
325 let result = rule.check(&ctx).unwrap();
326 assert!(result.is_empty());
327
328 let content = "#Heading 1\n## Heading 2\n###Heading 3";
330 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
331 let result = rule.check(&ctx).unwrap();
332 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
334 assert_eq!(result[1].line, 3);
335 }
336
337 #[test]
338 fn test_malformed_heading_detection() {
339 let rule = MD018NoMissingSpaceAtx::new();
340
341 assert!(rule.check_atx_heading_line("##Introduction").is_some());
343 assert!(rule.check_atx_heading_line("###Background").is_some());
344 assert!(rule.check_atx_heading_line("####Details").is_some());
345 assert!(rule.check_atx_heading_line("#Summary").is_some());
346 assert!(rule.check_atx_heading_line("######Conclusion").is_some());
347 assert!(rule.check_atx_heading_line("##Table of Contents").is_some());
348
349 assert!(rule.check_atx_heading_line("###").is_none()); assert!(rule.check_atx_heading_line("#").is_none()); assert!(rule.check_atx_heading_line("##a").is_none()); assert!(rule.check_atx_heading_line("#*emphasis").is_none()); assert!(rule.check_atx_heading_line("#######TooBig").is_none()); }
356
357 #[test]
358 fn test_malformed_heading_with_context() {
359 let rule = MD018NoMissingSpaceAtx::new();
360
361 let content = r#"# Test Document
363
364##Introduction
365This should be detected.
366
367 ##CodeBlock
368This should NOT be detected (indented code block).
369
370```
371##FencedCodeBlock
372This should NOT be detected (fenced code block).
373```
374
375##Conclusion
376This should be detected.
377"#;
378
379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
380 let result = rule.check(&ctx).unwrap();
381
382 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
384 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&14)); assert!(!detected_lines.contains(&6)); assert!(!detected_lines.contains(&10)); }
389
390 #[test]
391 fn test_malformed_heading_fix() {
392 let rule = MD018NoMissingSpaceAtx::new();
393
394 let content = r#"##Introduction
395This is a test.
396
397###Background
398More content."#;
399
400 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
401 let fixed = rule.fix(&ctx).unwrap();
402
403 let expected = r#"## Introduction
404This is a test.
405
406### Background
407More content."#;
408
409 assert_eq!(fixed, expected);
410 }
411
412 #[test]
413 fn test_mixed_proper_and_malformed_headings() {
414 let rule = MD018NoMissingSpaceAtx::new();
415
416 let content = r#"# Proper Heading
417
418##Malformed Heading
419
420## Another Proper Heading
421
422###Another Malformed
423
424#### Proper with space
425"#;
426
427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
428 let result = rule.check(&ctx).unwrap();
429
430 assert_eq!(result.len(), 2);
432 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
433 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&7)); }
436}