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 line_info.in_html_block {
148 continue;
149 }
150
151 if let Some(heading) = &line_info.heading {
152 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
154 let line = &line_info.content;
156 let trimmed = line.trim_start();
157
158 let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
160 .map(|re| re.is_match(trimmed))
161 .unwrap_or(false);
162 let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
163 .map(|re| re.is_match(trimmed))
164 .unwrap_or(false);
165 if is_emoji || is_unicode {
166 continue;
167 }
168
169 if trimmed.len() > heading.marker.len() {
170 let after_marker = &trimmed[heading.marker.len()..];
171 if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
172 {
173 let hash_end_col = line_info.indent + heading.marker.len() + 1; let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
176 line_num + 1, hash_end_col,
178 0, );
180
181 warnings.push(LintWarning {
182 rule_name: Some(self.name().to_string()),
183 message: format!("No space after {} in heading", "#".repeat(heading.level as usize)),
184 line: start_line,
185 column: start_col,
186 end_line,
187 end_column: end_col,
188 severity: Severity::Warning,
189 fix: Some(Fix {
190 range: self.get_line_byte_range(ctx.content, line_num + 1),
191 replacement: format!(
192 "{}{} {}",
193 " ".repeat(line_info.indent),
194 heading.marker,
195 after_marker
196 ),
197 }),
198 });
199 }
200 }
201 }
202 } else if !line_info.in_code_block && !line_info.is_blank {
203 if let Some((hash_end_pos, fixed_line)) = self.check_atx_heading_line(&line_info.content) {
205 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
206 line_num + 1, hash_end_pos + 1, 0, );
210
211 warnings.push(LintWarning {
212 rule_name: Some(self.name().to_string()),
213 message: "No space after hash in heading".to_string(),
214 line: start_line,
215 column: start_col,
216 end_line,
217 end_column: end_col,
218 severity: Severity::Warning,
219 fix: Some(Fix {
220 range: self.get_line_byte_range(ctx.content, line_num + 1),
221 replacement: fixed_line,
222 }),
223 });
224 }
225 }
226 }
227
228 Ok(warnings)
229 }
230
231 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
232 let mut lines = Vec::new();
233
234 for line_info in ctx.lines.iter() {
235 let mut fixed = false;
236
237 if let Some(heading) = &line_info.heading {
238 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
240 let line = &line_info.content;
241 let trimmed = line.trim_start();
242
243 let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
245 .map(|re| re.is_match(trimmed))
246 .unwrap_or(false);
247 let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
248 .map(|re| re.is_match(trimmed))
249 .unwrap_or(false);
250 if is_emoji || is_unicode {
251 continue;
252 }
253
254 if trimmed.len() > heading.marker.len() {
255 let after_marker = &trimmed[heading.marker.len()..];
256 if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
257 {
258 lines.push(format!(
260 "{}{} {}",
261 " ".repeat(line_info.indent),
262 heading.marker,
263 after_marker
264 ));
265 fixed = true;
266 }
267 }
268 }
269 } else if !line_info.in_code_block && !line_info.is_blank {
270 if let Some((_, fixed_line)) = self.check_atx_heading_line(&line_info.content) {
272 lines.push(fixed_line);
273 fixed = true;
274 }
275 }
276
277 if !fixed {
278 lines.push(line_info.content.clone());
279 }
280 }
281
282 let mut result = lines.join("\n");
284 if ctx.content.ends_with('\n') && !result.ends_with('\n') {
285 result.push('\n');
286 }
287
288 Ok(result)
289 }
290
291 fn category(&self) -> RuleCategory {
293 RuleCategory::Heading
294 }
295
296 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
298 if !ctx.likely_has_headings() {
300 return true;
301 }
302 !ctx.lines.iter().any(|line| line.content.contains('#'))
304 }
305
306 fn as_any(&self) -> &dyn std::any::Any {
307 self
308 }
309
310 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
311 where
312 Self: Sized,
313 {
314 Box::new(MD018NoMissingSpaceAtx::new())
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use crate::lint_context::LintContext;
322
323 #[test]
324 fn test_basic_functionality() {
325 let rule = MD018NoMissingSpaceAtx;
326
327 let content = "# Heading 1\n## Heading 2\n### Heading 3";
329 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
330 let result = rule.check(&ctx).unwrap();
331 assert!(result.is_empty());
332
333 let content = "#Heading 1\n## Heading 2\n###Heading 3";
335 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
336 let result = rule.check(&ctx).unwrap();
337 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
339 assert_eq!(result[1].line, 3);
340 }
341
342 #[test]
343 fn test_malformed_heading_detection() {
344 let rule = MD018NoMissingSpaceAtx::new();
345
346 assert!(rule.check_atx_heading_line("##Introduction").is_some());
348 assert!(rule.check_atx_heading_line("###Background").is_some());
349 assert!(rule.check_atx_heading_line("####Details").is_some());
350 assert!(rule.check_atx_heading_line("#Summary").is_some());
351 assert!(rule.check_atx_heading_line("######Conclusion").is_some());
352 assert!(rule.check_atx_heading_line("##Table of Contents").is_some());
353
354 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()); }
361
362 #[test]
363 fn test_malformed_heading_with_context() {
364 let rule = MD018NoMissingSpaceAtx::new();
365
366 let content = r#"# Test Document
368
369##Introduction
370This should be detected.
371
372 ##CodeBlock
373This should NOT be detected (indented code block).
374
375```
376##FencedCodeBlock
377This should NOT be detected (fenced code block).
378```
379
380##Conclusion
381This should be detected.
382"#;
383
384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
385 let result = rule.check(&ctx).unwrap();
386
387 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
389 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&14)); assert!(!detected_lines.contains(&6)); assert!(!detected_lines.contains(&10)); }
394
395 #[test]
396 fn test_malformed_heading_fix() {
397 let rule = MD018NoMissingSpaceAtx::new();
398
399 let content = r#"##Introduction
400This is a test.
401
402###Background
403More content."#;
404
405 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
406 let fixed = rule.fix(&ctx).unwrap();
407
408 let expected = r#"## Introduction
409This is a test.
410
411### Background
412More content."#;
413
414 assert_eq!(fixed, expected);
415 }
416
417 #[test]
418 fn test_mixed_proper_and_malformed_headings() {
419 let rule = MD018NoMissingSpaceAtx::new();
420
421 let content = r#"# Proper Heading
422
423##Malformed Heading
424
425## Another Proper Heading
426
427###Another Malformed
428
429#### Proper with space
430"#;
431
432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
433 let result = rule.check(&ctx).unwrap();
434
435 assert_eq!(result.len(), 2);
437 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
438 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&7)); }
441
442 #[test]
443 fn test_css_selectors_in_html_blocks() {
444 let rule = MD018NoMissingSpaceAtx::new();
445
446 let content = r#"# Proper Heading
449
450<style>
451#slide-1 ol li {
452 margin-top: 0;
453}
454
455#special-slide ol li {
456 margin-top: 2em;
457}
458</style>
459
460## Another Heading
461"#;
462
463 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
464 let result = rule.check(&ctx).unwrap();
465
466 assert_eq!(
468 result.len(),
469 0,
470 "CSS selectors in <style> blocks should not be flagged as malformed headings"
471 );
472 }
473
474 #[test]
475 fn test_js_code_in_script_blocks() {
476 let rule = MD018NoMissingSpaceAtx::new();
477
478 let content = r#"# Heading
480
481<script>
482const element = document.querySelector('#main-content');
483#another-comment
484</script>
485
486## Another Heading
487"#;
488
489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
490 let result = rule.check(&ctx).unwrap();
491
492 assert_eq!(
494 result.len(),
495 0,
496 "JavaScript code in <script> blocks should not be flagged as malformed headings"
497 );
498 }
499}