1use 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 if indent > 0 {
42 return None;
43 }
44
45 let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
47 .map(|re| re.is_match(trimmed_line))
48 .unwrap_or(false);
49 let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
50 .map(|re| re.is_match(trimmed_line))
51 .unwrap_or(false);
52 if is_emoji || is_unicode {
53 return None;
54 }
55
56 let hash_count = trimmed_line.chars().take_while(|&c| c == '#').count();
58 if hash_count == 0 || hash_count > 6 {
59 return None;
60 }
61
62 let after_hashes = &trimmed_line[hash_count..];
64
65 if after_hashes
67 .chars()
68 .next()
69 .is_some_and(|ch| matches!(ch, '\u{FE0F}' | '\u{20E3}' | '\u{FE0E}'))
70 {
71 return None;
72 }
73
74 if !after_hashes.is_empty() && !after_hashes.starts_with(' ') && !after_hashes.starts_with('\t') {
76 let content = after_hashes.trim();
78
79 if content.chars().all(|c| c == '#') {
81 return None;
82 }
83
84 if content.len() < 2 {
86 return None;
87 }
88
89 if content.starts_with('*') || content.starts_with('_') {
91 return None;
92 }
93
94 let fixed = format!("{}{} {}", " ".repeat(indent), "#".repeat(hash_count), after_hashes);
96 return Some((indent + hash_count, fixed));
97 }
98
99 None
100 }
101
102 fn get_line_byte_range(&self, content: &str, line_num: usize) -> std::ops::Range<usize> {
104 let mut current_line = 1;
105 let mut start_byte = 0;
106
107 for (i, c) in content.char_indices() {
108 if current_line == line_num && c == '\n' {
109 return start_byte..i;
110 } else if c == '\n' {
111 current_line += 1;
112 if current_line == line_num {
113 start_byte = i + 1;
114 }
115 }
116 }
117
118 if current_line == line_num {
120 return start_byte..content.len();
121 }
122
123 0..0
125 }
126}
127
128impl Rule for MD018NoMissingSpaceAtx {
129 fn name(&self) -> &'static str {
130 "MD018"
131 }
132
133 fn description(&self) -> &'static str {
134 "No space after hash in heading"
135 }
136
137 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
138 let mut warnings = Vec::new();
139
140 for (line_num, line_info) in ctx.lines.iter().enumerate() {
142 if line_info.in_html_block || line_info.in_html_comment {
144 continue;
145 }
146
147 if let Some(heading) = &line_info.heading {
148 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
150 if line_info.indent > 0 {
153 continue;
154 }
155
156 let line = line_info.content(ctx.content);
158 let trimmed = line.trim_start();
159
160 let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
162 .map(|re| re.is_match(trimmed))
163 .unwrap_or(false);
164 let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
165 .map(|re| re.is_match(trimmed))
166 .unwrap_or(false);
167 if is_emoji || is_unicode {
168 continue;
169 }
170
171 if trimmed.len() > heading.marker.len() {
172 let after_marker = &trimmed[heading.marker.len()..];
173 if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
174 {
175 let hash_end_col = line_info.indent + heading.marker.len() + 1; let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
178 line_num + 1, hash_end_col,
180 0, );
182
183 warnings.push(LintWarning {
184 rule_name: Some(self.name().to_string()),
185 message: format!("No space after {} in heading", "#".repeat(heading.level as usize)),
186 line: start_line,
187 column: start_col,
188 end_line,
189 end_column: end_col,
190 severity: Severity::Warning,
191 fix: Some(Fix {
192 range: self.get_line_byte_range(ctx.content, line_num + 1),
193 replacement: {
194 let line = line_info.content(ctx.content);
196 let original_indent = &line[..line_info.indent];
197 format!("{original_indent}{} {after_marker}", heading.marker)
198 },
199 }),
200 });
201 }
202 }
203 }
204 } else if !line_info.in_code_block
205 && !line_info.in_front_matter
206 && !line_info.in_html_comment
207 && !line_info.is_blank
208 {
209 if let Some((hash_end_pos, fixed_line)) = self.check_atx_heading_line(line_info.content(ctx.content)) {
211 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
212 line_num + 1, hash_end_pos + 1, 0, );
216
217 warnings.push(LintWarning {
218 rule_name: Some(self.name().to_string()),
219 message: "No space after hash in heading".to_string(),
220 line: start_line,
221 column: start_col,
222 end_line,
223 end_column: end_col,
224 severity: Severity::Warning,
225 fix: Some(Fix {
226 range: self.get_line_byte_range(ctx.content, line_num + 1),
227 replacement: fixed_line,
228 }),
229 });
230 }
231 }
232 }
233
234 Ok(warnings)
235 }
236
237 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
238 let mut lines = Vec::new();
239
240 for line_info in ctx.lines.iter() {
241 let mut fixed = false;
242
243 if let Some(heading) = &line_info.heading {
244 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
246 let line = line_info.content(ctx.content);
247 let trimmed = line.trim_start();
248
249 let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
251 .map(|re| re.is_match(trimmed))
252 .unwrap_or(false);
253 let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
254 .map(|re| re.is_match(trimmed))
255 .unwrap_or(false);
256 if is_emoji || is_unicode {
257 continue;
258 }
259
260 if trimmed.len() > heading.marker.len() {
261 let after_marker = &trimmed[heading.marker.len()..];
262 if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
263 {
264 let line = line_info.content(ctx.content);
266 let original_indent = &line[..line_info.indent];
267 lines.push(format!("{original_indent}{} {after_marker}", heading.marker));
268 fixed = true;
269 }
270 }
271 }
272 } else if !line_info.in_code_block
273 && !line_info.in_front_matter
274 && !line_info.in_html_comment
275 && !line_info.is_blank
276 {
277 if let Some((_, fixed_line)) = self.check_atx_heading_line(line_info.content(ctx.content)) {
279 lines.push(fixed_line);
280 fixed = true;
281 }
282 }
283
284 if !fixed {
285 lines.push(line_info.content(ctx.content).to_string());
286 }
287 }
288
289 let mut result = lines.join("\n");
291 if ctx.content.ends_with('\n') && !result.ends_with('\n') {
292 result.push('\n');
293 }
294
295 Ok(result)
296 }
297
298 fn category(&self) -> RuleCategory {
300 RuleCategory::Heading
301 }
302
303 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
305 !ctx.likely_has_headings()
307 }
308
309 fn as_any(&self) -> &dyn std::any::Any {
310 self
311 }
312
313 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
314 where
315 Self: Sized,
316 {
317 Box::new(MD018NoMissingSpaceAtx::new())
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use crate::lint_context::LintContext;
325
326 #[test]
327 fn test_basic_functionality() {
328 let rule = MD018NoMissingSpaceAtx;
329
330 let content = "# Heading 1\n## Heading 2\n### Heading 3";
332 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
333 let result = rule.check(&ctx).unwrap();
334 assert!(result.is_empty());
335
336 let content = "#Heading 1\n## Heading 2\n###Heading 3";
338 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
339 let result = rule.check(&ctx).unwrap();
340 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
342 assert_eq!(result[1].line, 3);
343 }
344
345 #[test]
346 fn test_malformed_heading_detection() {
347 let rule = MD018NoMissingSpaceAtx::new();
348
349 assert!(rule.check_atx_heading_line("##Introduction").is_some());
351 assert!(rule.check_atx_heading_line("###Background").is_some());
352 assert!(rule.check_atx_heading_line("####Details").is_some());
353 assert!(rule.check_atx_heading_line("#Summary").is_some());
354 assert!(rule.check_atx_heading_line("######Conclusion").is_some());
355 assert!(rule.check_atx_heading_line("##Table of Contents").is_some());
356
357 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()); }
364
365 #[test]
366 fn test_malformed_heading_with_context() {
367 let rule = MD018NoMissingSpaceAtx::new();
368
369 let content = r#"# Test Document
371
372##Introduction
373This should be detected.
374
375 ##CodeBlock
376This should NOT be detected (indented code block).
377
378```
379##FencedCodeBlock
380This should NOT be detected (fenced code block).
381```
382
383##Conclusion
384This should be detected.
385"#;
386
387 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
388 let result = rule.check(&ctx).unwrap();
389
390 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
392 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&14)); assert!(!detected_lines.contains(&6)); assert!(!detected_lines.contains(&10)); }
397
398 #[test]
399 fn test_malformed_heading_fix() {
400 let rule = MD018NoMissingSpaceAtx::new();
401
402 let content = r#"##Introduction
403This is a test.
404
405###Background
406More content."#;
407
408 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
409 let fixed = rule.fix(&ctx).unwrap();
410
411 let expected = r#"## Introduction
412This is a test.
413
414### Background
415More content."#;
416
417 assert_eq!(fixed, expected);
418 }
419
420 #[test]
421 fn test_mixed_proper_and_malformed_headings() {
422 let rule = MD018NoMissingSpaceAtx::new();
423
424 let content = r#"# Proper Heading
425
426##Malformed Heading
427
428## Another Proper Heading
429
430###Another Malformed
431
432#### Proper with space
433"#;
434
435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
436 let result = rule.check(&ctx).unwrap();
437
438 assert_eq!(result.len(), 2);
440 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
441 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&7)); }
444
445 #[test]
446 fn test_css_selectors_in_html_blocks() {
447 let rule = MD018NoMissingSpaceAtx::new();
448
449 let content = r#"# Proper Heading
452
453<style>
454#slide-1 ol li {
455 margin-top: 0;
456}
457
458#special-slide ol li {
459 margin-top: 2em;
460}
461</style>
462
463## Another Heading
464"#;
465
466 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
467 let result = rule.check(&ctx).unwrap();
468
469 assert_eq!(
471 result.len(),
472 0,
473 "CSS selectors in <style> blocks should not be flagged as malformed headings"
474 );
475 }
476
477 #[test]
478 fn test_js_code_in_script_blocks() {
479 let rule = MD018NoMissingSpaceAtx::new();
480
481 let content = r#"# Heading
483
484<script>
485const element = document.querySelector('#main-content');
486#another-comment
487</script>
488
489## Another Heading
490"#;
491
492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
493 let result = rule.check(&ctx).unwrap();
494
495 assert_eq!(
497 result.len(),
498 0,
499 "JavaScript code in <script> blocks should not be flagged as malformed headings"
500 );
501 }
502
503 #[test]
504 fn test_all_malformed_headings_detected() {
505 let rule = MD018NoMissingSpaceAtx::new();
506
507 assert!(
512 rule.check_atx_heading_line("#hello").is_some(),
513 "#hello SHOULD be detected as malformed heading"
514 );
515 assert!(
516 rule.check_atx_heading_line("#tag").is_some(),
517 "#tag SHOULD be detected as malformed heading"
518 );
519 assert!(
520 rule.check_atx_heading_line("#hashtag").is_some(),
521 "#hashtag SHOULD be detected as malformed heading"
522 );
523 assert!(
524 rule.check_atx_heading_line("#javascript").is_some(),
525 "#javascript SHOULD be detected as malformed heading"
526 );
527
528 assert!(
530 rule.check_atx_heading_line("#123").is_some(),
531 "#123 SHOULD be detected as malformed heading"
532 );
533 assert!(
534 rule.check_atx_heading_line("#12345").is_some(),
535 "#12345 SHOULD be detected as malformed heading"
536 );
537 assert!(
538 rule.check_atx_heading_line("#29039)").is_some(),
539 "#29039) SHOULD be detected as malformed heading"
540 );
541
542 assert!(
544 rule.check_atx_heading_line("#Summary").is_some(),
545 "#Summary SHOULD be detected as malformed heading"
546 );
547 assert!(
548 rule.check_atx_heading_line("#Introduction").is_some(),
549 "#Introduction SHOULD be detected as malformed heading"
550 );
551 assert!(
552 rule.check_atx_heading_line("#API").is_some(),
553 "#API SHOULD be detected as malformed heading"
554 );
555
556 assert!(
558 rule.check_atx_heading_line("##introduction").is_some(),
559 "##introduction SHOULD be detected as malformed heading"
560 );
561 assert!(
562 rule.check_atx_heading_line("###section").is_some(),
563 "###section SHOULD be detected as malformed heading"
564 );
565 assert!(
566 rule.check_atx_heading_line("###fer").is_some(),
567 "###fer SHOULD be detected as malformed heading"
568 );
569 assert!(
570 rule.check_atx_heading_line("##123").is_some(),
571 "##123 SHOULD be detected as malformed heading"
572 );
573 }
574
575 #[test]
576 fn test_patterns_that_should_not_be_flagged() {
577 let rule = MD018NoMissingSpaceAtx::new();
578
579 assert!(rule.check_atx_heading_line("###").is_none());
581 assert!(rule.check_atx_heading_line("#").is_none());
582
583 assert!(rule.check_atx_heading_line("##a").is_none());
585
586 assert!(rule.check_atx_heading_line("#*emphasis").is_none());
588
589 assert!(rule.check_atx_heading_line("#######TooBig").is_none());
591
592 assert!(rule.check_atx_heading_line("# Hello").is_none());
594 assert!(rule.check_atx_heading_line("## World").is_none());
595 assert!(rule.check_atx_heading_line("### Section").is_none());
596 }
597
598 #[test]
599 fn test_inline_issue_refs_not_at_line_start() {
600 let rule = MD018NoMissingSpaceAtx::new();
601
602 assert!(rule.check_atx_heading_line("See issue #123").is_none());
607 assert!(rule.check_atx_heading_line("Check #trending on Twitter").is_none());
608 assert!(rule.check_atx_heading_line("- fix: issue #29039").is_none());
609 }
610
611 #[test]
612 fn test_lowercase_patterns_full_check() {
613 let rule = MD018NoMissingSpaceAtx::new();
615
616 let content = "#hello\n\n#world\n\n#tag";
617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
618 let result = rule.check(&ctx).unwrap();
619
620 assert_eq!(result.len(), 3, "All three lowercase patterns should be flagged");
621 assert_eq!(result[0].line, 1);
622 assert_eq!(result[1].line, 3);
623 assert_eq!(result[2].line, 5);
624 }
625
626 #[test]
627 fn test_numeric_patterns_full_check() {
628 let rule = MD018NoMissingSpaceAtx::new();
630
631 let content = "#123\n\n#456\n\n#29039";
632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
633 let result = rule.check(&ctx).unwrap();
634
635 assert_eq!(result.len(), 3, "All three numeric patterns should be flagged");
636 }
637
638 #[test]
639 fn test_fix_lowercase_patterns() {
640 let rule = MD018NoMissingSpaceAtx::new();
642
643 let content = "#hello\nSome text.\n\n#world";
644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
645 let fixed = rule.fix(&ctx).unwrap();
646
647 let expected = "# hello\nSome text.\n\n# world";
648 assert_eq!(fixed, expected);
649 }
650
651 #[test]
652 fn test_fix_numeric_patterns() {
653 let rule = MD018NoMissingSpaceAtx::new();
655
656 let content = "#123\nContent.\n\n##456";
657 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
658 let fixed = rule.fix(&ctx).unwrap();
659
660 let expected = "# 123\nContent.\n\n## 456";
661 assert_eq!(fixed, expected);
662 }
663
664 #[test]
665 fn test_indented_malformed_headings() {
666 let rule = MD018NoMissingSpaceAtx::new();
670
671 assert!(
673 rule.check_atx_heading_line(" #hello").is_none(),
674 "1-space indented #hello should be skipped"
675 );
676 assert!(
677 rule.check_atx_heading_line(" #hello").is_none(),
678 "2-space indented #hello should be skipped"
679 );
680 assert!(
681 rule.check_atx_heading_line(" #hello").is_none(),
682 "3-space indented #hello should be skipped"
683 );
684
685 assert!(
690 rule.check_atx_heading_line("#hello").is_some(),
691 "Non-indented #hello should be detected"
692 );
693 }
694
695 #[test]
696 fn test_tab_after_hash_is_valid() {
697 let rule = MD018NoMissingSpaceAtx::new();
699
700 assert!(
701 rule.check_atx_heading_line("#\tHello").is_none(),
702 "Tab after # should be valid"
703 );
704 assert!(
705 rule.check_atx_heading_line("##\tWorld").is_none(),
706 "Tab after ## should be valid"
707 );
708 }
709
710 #[test]
711 fn test_mixed_case_patterns() {
712 let rule = MD018NoMissingSpaceAtx::new();
713
714 assert!(rule.check_atx_heading_line("#hELLO").is_some());
716 assert!(rule.check_atx_heading_line("#Hello").is_some());
717 assert!(rule.check_atx_heading_line("#HELLO").is_some());
718 assert!(rule.check_atx_heading_line("#hello").is_some());
719 }
720
721 #[test]
722 fn test_unicode_lowercase() {
723 let rule = MD018NoMissingSpaceAtx::new();
724
725 assert!(
727 rule.check_atx_heading_line("#über").is_some(),
728 "Unicode lowercase #über should be detected"
729 );
730 assert!(
731 rule.check_atx_heading_line("#café").is_some(),
732 "Unicode lowercase #café should be detected"
733 );
734 assert!(
735 rule.check_atx_heading_line("#日本語").is_some(),
736 "Japanese #日本語 should be detected"
737 );
738 }
739
740 #[test]
741 fn test_matches_markdownlint_behavior() {
742 let rule = MD018NoMissingSpaceAtx::new();
744
745 let content = r#"#hello
746
747## world
748
749###fer
750
751#123
752
753#Tag
754"#;
755
756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
757 let result = rule.check(&ctx).unwrap();
758
759 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
762
763 assert!(flagged_lines.contains(&1), "#hello should be flagged");
764 assert!(!flagged_lines.contains(&3), "## world should NOT be flagged");
765 assert!(flagged_lines.contains(&5), "###fer should be flagged");
766 assert!(flagged_lines.contains(&7), "#123 should be flagged");
767 assert!(flagged_lines.contains(&9), "#Tag should be flagged");
768
769 assert_eq!(result.len(), 4, "Should have exactly 4 warnings");
770 }
771
772 #[test]
773 fn test_skip_frontmatter_yaml_comments() {
774 let rule = MD018NoMissingSpaceAtx::new();
776
777 let content = r#"---
778#reviewers:
779#- sig-api-machinery
780#another_comment: value
781title: Test Document
782---
783
784# Valid heading
785
786#invalid heading without space
787"#;
788
789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790 let result = rule.check(&ctx).unwrap();
791
792 assert_eq!(
795 result.len(),
796 1,
797 "Should only flag the malformed heading outside frontmatter"
798 );
799 assert_eq!(result[0].line, 10, "Should flag line 10");
800 }
801
802 #[test]
803 fn test_skip_html_comments() {
804 let rule = MD018NoMissingSpaceAtx::new();
807
808 let content = r#"# Real Heading
809
810Some text.
811
812<!--
813```
814#%% Cell marker
815import matplotlib.pyplot as plt
816
817#%% Another cell
818data = [1, 2, 3]
819```
820-->
821
822More content.
823"#;
824
825 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
826 let result = rule.check(&ctx).unwrap();
827
828 assert!(
830 result.is_empty(),
831 "Should not flag content inside HTML comments, found {} issues",
832 result.len()
833 );
834 }
835}