1use crate::config::MarkdownFlavor;
5use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6use crate::utils::range_utils::calculate_single_line_range;
7use crate::utils::regex_cache::get_cached_regex;
8
9const EMOJI_HASHTAG_PATTERN_STR: &str = r"^#️⃣|^#⃣";
11const UNICODE_HASHTAG_PATTERN_STR: &str = r"^#[\u{FE0F}\u{20E3}]";
12
13const MAGICLINK_REF_PATTERN_STR: &str = r"^#\d+(?:\s|[^a-zA-Z0-9]|$)";
17
18#[derive(Clone)]
19pub struct MD018NoMissingSpaceAtx;
20
21impl Default for MD018NoMissingSpaceAtx {
22 fn default() -> Self {
23 Self::new()
24 }
25}
26
27impl MD018NoMissingSpaceAtx {
28 pub fn new() -> Self {
29 Self
30 }
31
32 fn is_magiclink_ref(line: &str) -> bool {
35 get_cached_regex(MAGICLINK_REF_PATTERN_STR).is_ok_and(|re| re.is_match(line.trim_start()))
36 }
37
38 fn check_atx_heading_line(&self, line: &str, flavor: MarkdownFlavor) -> Option<(usize, String)> {
40 let trimmed_line = line.trim_start();
42 let indent = line.len() - trimmed_line.len();
43
44 if !trimmed_line.starts_with('#') {
45 return None;
46 }
47
48 if indent > 0 {
54 return None;
55 }
56
57 let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
59 .map(|re| re.is_match(trimmed_line))
60 .unwrap_or(false);
61 let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
62 .map(|re| re.is_match(trimmed_line))
63 .unwrap_or(false);
64 if is_emoji || is_unicode {
65 return None;
66 }
67
68 let hash_count = trimmed_line.chars().take_while(|&c| c == '#').count();
70 if hash_count == 0 || hash_count > 6 {
71 return None;
72 }
73
74 let after_hashes = &trimmed_line[hash_count..];
76
77 if after_hashes
79 .chars()
80 .next()
81 .is_some_and(|ch| matches!(ch, '\u{FE0F}' | '\u{20E3}' | '\u{FE0E}'))
82 {
83 return None;
84 }
85
86 if !after_hashes.is_empty() && !after_hashes.starts_with(' ') && !after_hashes.starts_with('\t') {
88 let content = after_hashes.trim();
90
91 if content.chars().all(|c| c == '#') {
93 return None;
94 }
95
96 if content.len() < 2 {
98 return None;
99 }
100
101 if content.starts_with('*') || content.starts_with('_') {
103 return None;
104 }
105
106 if flavor == MarkdownFlavor::MkDocs && hash_count == 1 && Self::is_magiclink_ref(line) {
109 return None;
110 }
111
112 let fixed = format!("{}{} {}", " ".repeat(indent), "#".repeat(hash_count), after_hashes);
114 return Some((indent + hash_count, fixed));
115 }
116
117 None
118 }
119
120 fn get_line_byte_range(&self, content: &str, line_num: usize) -> std::ops::Range<usize> {
122 let mut current_line = 1;
123 let mut start_byte = 0;
124
125 for (i, c) in content.char_indices() {
126 if current_line == line_num && c == '\n' {
127 return start_byte..i;
128 } else if c == '\n' {
129 current_line += 1;
130 if current_line == line_num {
131 start_byte = i + 1;
132 }
133 }
134 }
135
136 if current_line == line_num {
138 return start_byte..content.len();
139 }
140
141 0..0
143 }
144}
145
146impl Rule for MD018NoMissingSpaceAtx {
147 fn name(&self) -> &'static str {
148 "MD018"
149 }
150
151 fn description(&self) -> &'static str {
152 "No space after hash in heading"
153 }
154
155 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
156 let mut warnings = Vec::new();
157
158 for (line_num, line_info) in ctx.lines.iter().enumerate() {
160 if line_info.in_html_block || line_info.in_html_comment {
162 continue;
163 }
164
165 if let Some(heading) = &line_info.heading {
166 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
168 if line_info.indent > 0 {
171 continue;
172 }
173
174 let line = line_info.content(ctx.content);
176 let trimmed = line.trim_start();
177
178 let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
180 .map(|re| re.is_match(trimmed))
181 .unwrap_or(false);
182 let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
183 .map(|re| re.is_match(trimmed))
184 .unwrap_or(false);
185 if is_emoji || is_unicode {
186 continue;
187 }
188
189 if ctx.flavor == MarkdownFlavor::MkDocs && heading.level == 1 && Self::is_magiclink_ref(line) {
191 continue;
192 }
193
194 if trimmed.len() > heading.marker.len() {
195 let after_marker = &trimmed[heading.marker.len()..];
196 if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
197 {
198 let hash_end_col = line_info.indent + heading.marker.len() + 1; let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
201 line_num + 1, hash_end_col,
203 0, );
205
206 warnings.push(LintWarning {
207 rule_name: Some(self.name().to_string()),
208 message: format!("No space after {} in heading", "#".repeat(heading.level as usize)),
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: {
217 let line = line_info.content(ctx.content);
219 let original_indent = &line[..line_info.indent];
220 format!("{original_indent}{} {after_marker}", heading.marker)
221 },
222 }),
223 });
224 }
225 }
226 }
227 } else if !line_info.in_code_block
228 && !line_info.in_front_matter
229 && !line_info.in_html_comment
230 && !line_info.is_blank
231 {
232 if let Some((hash_end_pos, fixed_line)) =
234 self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor)
235 {
236 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
237 line_num + 1, hash_end_pos + 1, 0, );
241
242 warnings.push(LintWarning {
243 rule_name: Some(self.name().to_string()),
244 message: "No space after hash in heading".to_string(),
245 line: start_line,
246 column: start_col,
247 end_line,
248 end_column: end_col,
249 severity: Severity::Warning,
250 fix: Some(Fix {
251 range: self.get_line_byte_range(ctx.content, line_num + 1),
252 replacement: fixed_line,
253 }),
254 });
255 }
256 }
257 }
258
259 Ok(warnings)
260 }
261
262 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
263 let mut lines = Vec::new();
264
265 for line_info in ctx.lines.iter() {
266 let mut fixed = false;
267
268 if let Some(heading) = &line_info.heading {
269 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
271 let line = line_info.content(ctx.content);
272 let trimmed = line.trim_start();
273
274 let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
276 .map(|re| re.is_match(trimmed))
277 .unwrap_or(false);
278 let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
279 .map(|re| re.is_match(trimmed))
280 .unwrap_or(false);
281
282 let is_magiclink =
284 ctx.flavor == MarkdownFlavor::MkDocs && heading.level == 1 && Self::is_magiclink_ref(line);
285
286 if !is_emoji && !is_unicode && !is_magiclink && trimmed.len() > heading.marker.len() {
288 let after_marker = &trimmed[heading.marker.len()..];
289 if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
290 {
291 let line = line_info.content(ctx.content);
293 let original_indent = &line[..line_info.indent];
294 lines.push(format!("{original_indent}{} {after_marker}", heading.marker));
295 fixed = true;
296 }
297 }
298 }
299 } else if !line_info.in_code_block
300 && !line_info.in_front_matter
301 && !line_info.in_html_comment
302 && !line_info.is_blank
303 {
304 if let Some((_, fixed_line)) = self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor) {
306 lines.push(fixed_line);
307 fixed = true;
308 }
309 }
310
311 if !fixed {
312 lines.push(line_info.content(ctx.content).to_string());
313 }
314 }
315
316 let mut result = lines.join("\n");
318 if ctx.content.ends_with('\n') && !result.ends_with('\n') {
319 result.push('\n');
320 }
321
322 Ok(result)
323 }
324
325 fn category(&self) -> RuleCategory {
327 RuleCategory::Heading
328 }
329
330 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
332 !ctx.likely_has_headings()
334 }
335
336 fn as_any(&self) -> &dyn std::any::Any {
337 self
338 }
339
340 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
341 where
342 Self: Sized,
343 {
344 Box::new(MD018NoMissingSpaceAtx::new())
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use crate::lint_context::LintContext;
352
353 #[test]
354 fn test_basic_functionality() {
355 let rule = MD018NoMissingSpaceAtx;
356
357 let content = "# Heading 1\n## Heading 2\n### Heading 3";
359 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
360 let result = rule.check(&ctx).unwrap();
361 assert!(result.is_empty());
362
363 let content = "#Heading 1\n## Heading 2\n###Heading 3";
365 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
366 let result = rule.check(&ctx).unwrap();
367 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
369 assert_eq!(result[1].line, 3);
370 }
371
372 #[test]
373 fn test_malformed_heading_detection() {
374 let rule = MD018NoMissingSpaceAtx::new();
375
376 assert!(
378 rule.check_atx_heading_line("##Introduction", MarkdownFlavor::Standard)
379 .is_some()
380 );
381 assert!(
382 rule.check_atx_heading_line("###Background", MarkdownFlavor::Standard)
383 .is_some()
384 );
385 assert!(
386 rule.check_atx_heading_line("####Details", MarkdownFlavor::Standard)
387 .is_some()
388 );
389 assert!(
390 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
391 .is_some()
392 );
393 assert!(
394 rule.check_atx_heading_line("######Conclusion", MarkdownFlavor::Standard)
395 .is_some()
396 );
397 assert!(
398 rule.check_atx_heading_line("##Table of Contents", MarkdownFlavor::Standard)
399 .is_some()
400 );
401
402 assert!(rule.check_atx_heading_line("###", MarkdownFlavor::Standard).is_none()); assert!(rule.check_atx_heading_line("#", MarkdownFlavor::Standard).is_none()); assert!(rule.check_atx_heading_line("##a", MarkdownFlavor::Standard).is_none()); assert!(
407 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
408 .is_none()
409 ); assert!(
411 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
412 .is_none()
413 ); }
415
416 #[test]
417 fn test_malformed_heading_with_context() {
418 let rule = MD018NoMissingSpaceAtx::new();
419
420 let content = r#"# Test Document
422
423##Introduction
424This should be detected.
425
426 ##CodeBlock
427This should NOT be detected (indented code block).
428
429```
430##FencedCodeBlock
431This should NOT be detected (fenced code block).
432```
433
434##Conclusion
435This should be detected.
436"#;
437
438 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
439 let result = rule.check(&ctx).unwrap();
440
441 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
443 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&14)); assert!(!detected_lines.contains(&6)); assert!(!detected_lines.contains(&10)); }
448
449 #[test]
450 fn test_malformed_heading_fix() {
451 let rule = MD018NoMissingSpaceAtx::new();
452
453 let content = r#"##Introduction
454This is a test.
455
456###Background
457More content."#;
458
459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
460 let fixed = rule.fix(&ctx).unwrap();
461
462 let expected = r#"## Introduction
463This is a test.
464
465### Background
466More content."#;
467
468 assert_eq!(fixed, expected);
469 }
470
471 #[test]
472 fn test_mixed_proper_and_malformed_headings() {
473 let rule = MD018NoMissingSpaceAtx::new();
474
475 let content = r#"# Proper Heading
476
477##Malformed Heading
478
479## Another Proper Heading
480
481###Another Malformed
482
483#### Proper with space
484"#;
485
486 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
487 let result = rule.check(&ctx).unwrap();
488
489 assert_eq!(result.len(), 2);
491 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
492 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&7)); }
495
496 #[test]
497 fn test_css_selectors_in_html_blocks() {
498 let rule = MD018NoMissingSpaceAtx::new();
499
500 let content = r#"# Proper Heading
503
504<style>
505#slide-1 ol li {
506 margin-top: 0;
507}
508
509#special-slide ol li {
510 margin-top: 2em;
511}
512</style>
513
514## Another Heading
515"#;
516
517 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
518 let result = rule.check(&ctx).unwrap();
519
520 assert_eq!(
522 result.len(),
523 0,
524 "CSS selectors in <style> blocks should not be flagged as malformed headings"
525 );
526 }
527
528 #[test]
529 fn test_js_code_in_script_blocks() {
530 let rule = MD018NoMissingSpaceAtx::new();
531
532 let content = r#"# Heading
534
535<script>
536const element = document.querySelector('#main-content');
537#another-comment
538</script>
539
540## Another Heading
541"#;
542
543 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
544 let result = rule.check(&ctx).unwrap();
545
546 assert_eq!(
548 result.len(),
549 0,
550 "JavaScript code in <script> blocks should not be flagged as malformed headings"
551 );
552 }
553
554 #[test]
555 fn test_all_malformed_headings_detected() {
556 let rule = MD018NoMissingSpaceAtx::new();
557
558 assert!(
563 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
564 .is_some(),
565 "#hello SHOULD be detected as malformed heading"
566 );
567 assert!(
568 rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
569 "#tag SHOULD be detected as malformed heading"
570 );
571 assert!(
572 rule.check_atx_heading_line("#hashtag", MarkdownFlavor::Standard)
573 .is_some(),
574 "#hashtag SHOULD be detected as malformed heading"
575 );
576 assert!(
577 rule.check_atx_heading_line("#javascript", MarkdownFlavor::Standard)
578 .is_some(),
579 "#javascript SHOULD be detected as malformed heading"
580 );
581
582 assert!(
584 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
585 "#123 SHOULD be detected as malformed heading"
586 );
587 assert!(
588 rule.check_atx_heading_line("#12345", MarkdownFlavor::Standard)
589 .is_some(),
590 "#12345 SHOULD be detected as malformed heading"
591 );
592 assert!(
593 rule.check_atx_heading_line("#29039)", MarkdownFlavor::Standard)
594 .is_some(),
595 "#29039) SHOULD be detected as malformed heading"
596 );
597
598 assert!(
600 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
601 .is_some(),
602 "#Summary SHOULD be detected as malformed heading"
603 );
604 assert!(
605 rule.check_atx_heading_line("#Introduction", MarkdownFlavor::Standard)
606 .is_some(),
607 "#Introduction SHOULD be detected as malformed heading"
608 );
609 assert!(
610 rule.check_atx_heading_line("#API", MarkdownFlavor::Standard).is_some(),
611 "#API SHOULD be detected as malformed heading"
612 );
613
614 assert!(
616 rule.check_atx_heading_line("##introduction", MarkdownFlavor::Standard)
617 .is_some(),
618 "##introduction SHOULD be detected as malformed heading"
619 );
620 assert!(
621 rule.check_atx_heading_line("###section", MarkdownFlavor::Standard)
622 .is_some(),
623 "###section SHOULD be detected as malformed heading"
624 );
625 assert!(
626 rule.check_atx_heading_line("###fer", MarkdownFlavor::Standard)
627 .is_some(),
628 "###fer SHOULD be detected as malformed heading"
629 );
630 assert!(
631 rule.check_atx_heading_line("##123", MarkdownFlavor::Standard).is_some(),
632 "##123 SHOULD be detected as malformed heading"
633 );
634 }
635
636 #[test]
637 fn test_patterns_that_should_not_be_flagged() {
638 let rule = MD018NoMissingSpaceAtx::new();
639
640 assert!(rule.check_atx_heading_line("###", MarkdownFlavor::Standard).is_none());
642 assert!(rule.check_atx_heading_line("#", MarkdownFlavor::Standard).is_none());
643
644 assert!(rule.check_atx_heading_line("##a", MarkdownFlavor::Standard).is_none());
646
647 assert!(
649 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
650 .is_none()
651 );
652
653 assert!(
655 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
656 .is_none()
657 );
658
659 assert!(
661 rule.check_atx_heading_line("# Hello", MarkdownFlavor::Standard)
662 .is_none()
663 );
664 assert!(
665 rule.check_atx_heading_line("## World", MarkdownFlavor::Standard)
666 .is_none()
667 );
668 assert!(
669 rule.check_atx_heading_line("### Section", MarkdownFlavor::Standard)
670 .is_none()
671 );
672 }
673
674 #[test]
675 fn test_inline_issue_refs_not_at_line_start() {
676 let rule = MD018NoMissingSpaceAtx::new();
677
678 assert!(
683 rule.check_atx_heading_line("See issue #123", MarkdownFlavor::Standard)
684 .is_none()
685 );
686 assert!(
687 rule.check_atx_heading_line("Check #trending on Twitter", MarkdownFlavor::Standard)
688 .is_none()
689 );
690 assert!(
691 rule.check_atx_heading_line("- fix: issue #29039", MarkdownFlavor::Standard)
692 .is_none()
693 );
694 }
695
696 #[test]
697 fn test_lowercase_patterns_full_check() {
698 let rule = MD018NoMissingSpaceAtx::new();
700
701 let content = "#hello\n\n#world\n\n#tag";
702 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
703 let result = rule.check(&ctx).unwrap();
704
705 assert_eq!(result.len(), 3, "All three lowercase patterns should be flagged");
706 assert_eq!(result[0].line, 1);
707 assert_eq!(result[1].line, 3);
708 assert_eq!(result[2].line, 5);
709 }
710
711 #[test]
712 fn test_numeric_patterns_full_check() {
713 let rule = MD018NoMissingSpaceAtx::new();
715
716 let content = "#123\n\n#456\n\n#29039";
717 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
718 let result = rule.check(&ctx).unwrap();
719
720 assert_eq!(result.len(), 3, "All three numeric patterns should be flagged");
721 }
722
723 #[test]
724 fn test_fix_lowercase_patterns() {
725 let rule = MD018NoMissingSpaceAtx::new();
727
728 let content = "#hello\nSome text.\n\n#world";
729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
730 let fixed = rule.fix(&ctx).unwrap();
731
732 let expected = "# hello\nSome text.\n\n# world";
733 assert_eq!(fixed, expected);
734 }
735
736 #[test]
737 fn test_fix_numeric_patterns() {
738 let rule = MD018NoMissingSpaceAtx::new();
740
741 let content = "#123\nContent.\n\n##456";
742 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
743 let fixed = rule.fix(&ctx).unwrap();
744
745 let expected = "# 123\nContent.\n\n## 456";
746 assert_eq!(fixed, expected);
747 }
748
749 #[test]
750 fn test_indented_malformed_headings() {
751 let rule = MD018NoMissingSpaceAtx::new();
755
756 assert!(
758 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
759 .is_none(),
760 "1-space indented #hello should be skipped"
761 );
762 assert!(
763 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
764 .is_none(),
765 "2-space indented #hello should be skipped"
766 );
767 assert!(
768 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
769 .is_none(),
770 "3-space indented #hello should be skipped"
771 );
772
773 assert!(
778 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
779 .is_some(),
780 "Non-indented #hello should be detected"
781 );
782 }
783
784 #[test]
785 fn test_tab_after_hash_is_valid() {
786 let rule = MD018NoMissingSpaceAtx::new();
788
789 assert!(
790 rule.check_atx_heading_line("#\tHello", MarkdownFlavor::Standard)
791 .is_none(),
792 "Tab after # should be valid"
793 );
794 assert!(
795 rule.check_atx_heading_line("##\tWorld", MarkdownFlavor::Standard)
796 .is_none(),
797 "Tab after ## should be valid"
798 );
799 }
800
801 #[test]
802 fn test_mixed_case_patterns() {
803 let rule = MD018NoMissingSpaceAtx::new();
804
805 assert!(
807 rule.check_atx_heading_line("#hELLO", MarkdownFlavor::Standard)
808 .is_some()
809 );
810 assert!(
811 rule.check_atx_heading_line("#Hello", MarkdownFlavor::Standard)
812 .is_some()
813 );
814 assert!(
815 rule.check_atx_heading_line("#HELLO", MarkdownFlavor::Standard)
816 .is_some()
817 );
818 assert!(
819 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
820 .is_some()
821 );
822 }
823
824 #[test]
825 fn test_unicode_lowercase() {
826 let rule = MD018NoMissingSpaceAtx::new();
827
828 assert!(
830 rule.check_atx_heading_line("#über", MarkdownFlavor::Standard).is_some(),
831 "Unicode lowercase #über should be detected"
832 );
833 assert!(
834 rule.check_atx_heading_line("#café", MarkdownFlavor::Standard).is_some(),
835 "Unicode lowercase #café should be detected"
836 );
837 assert!(
838 rule.check_atx_heading_line("#日本語", MarkdownFlavor::Standard)
839 .is_some(),
840 "Japanese #日本語 should be detected"
841 );
842 }
843
844 #[test]
845 fn test_matches_markdownlint_behavior() {
846 let rule = MD018NoMissingSpaceAtx::new();
848
849 let content = r#"#hello
850
851## world
852
853###fer
854
855#123
856
857#Tag
858"#;
859
860 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
861 let result = rule.check(&ctx).unwrap();
862
863 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
866
867 assert!(flagged_lines.contains(&1), "#hello should be flagged");
868 assert!(!flagged_lines.contains(&3), "## world should NOT be flagged");
869 assert!(flagged_lines.contains(&5), "###fer should be flagged");
870 assert!(flagged_lines.contains(&7), "#123 should be flagged");
871 assert!(flagged_lines.contains(&9), "#Tag should be flagged");
872
873 assert_eq!(result.len(), 4, "Should have exactly 4 warnings");
874 }
875
876 #[test]
877 fn test_skip_frontmatter_yaml_comments() {
878 let rule = MD018NoMissingSpaceAtx::new();
880
881 let content = r#"---
882#reviewers:
883#- sig-api-machinery
884#another_comment: value
885title: Test Document
886---
887
888# Valid heading
889
890#invalid heading without space
891"#;
892
893 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
894 let result = rule.check(&ctx).unwrap();
895
896 assert_eq!(
899 result.len(),
900 1,
901 "Should only flag the malformed heading outside frontmatter"
902 );
903 assert_eq!(result[0].line, 10, "Should flag line 10");
904 }
905
906 #[test]
907 fn test_skip_html_comments() {
908 let rule = MD018NoMissingSpaceAtx::new();
911
912 let content = r#"# Real Heading
913
914Some text.
915
916<!--
917```
918#%% Cell marker
919import matplotlib.pyplot as plt
920
921#%% Another cell
922data = [1, 2, 3]
923```
924-->
925
926More content.
927"#;
928
929 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
930 let result = rule.check(&ctx).unwrap();
931
932 assert!(
934 result.is_empty(),
935 "Should not flag content inside HTML comments, found {} issues",
936 result.len()
937 );
938 }
939
940 #[test]
941 fn test_mkdocs_magiclink_skips_numeric_refs() {
942 let rule = MD018NoMissingSpaceAtx::new();
944
945 assert!(
947 rule.check_atx_heading_line("#10", MarkdownFlavor::MkDocs).is_none(),
948 "#10 should be skipped in MkDocs flavor (MagicLink issue ref)"
949 );
950 assert!(
951 rule.check_atx_heading_line("#123", MarkdownFlavor::MkDocs).is_none(),
952 "#123 should be skipped in MkDocs flavor (MagicLink issue ref)"
953 );
954 assert!(
955 rule.check_atx_heading_line("#10 discusses the issue", MarkdownFlavor::MkDocs)
956 .is_none(),
957 "#10 followed by text should be skipped in MkDocs flavor"
958 );
959 assert!(
960 rule.check_atx_heading_line("#37.", MarkdownFlavor::MkDocs).is_none(),
961 "#37 followed by punctuation should be skipped in MkDocs flavor"
962 );
963 }
964
965 #[test]
966 fn test_mkdocs_magiclink_still_flags_non_numeric() {
967 let rule = MD018NoMissingSpaceAtx::new();
969
970 assert!(
972 rule.check_atx_heading_line("#Summary", MarkdownFlavor::MkDocs)
973 .is_some(),
974 "#Summary should still be flagged in MkDocs flavor"
975 );
976 assert!(
977 rule.check_atx_heading_line("#hello", MarkdownFlavor::MkDocs).is_some(),
978 "#hello should still be flagged in MkDocs flavor"
979 );
980 assert!(
981 rule.check_atx_heading_line("#10abc", MarkdownFlavor::MkDocs).is_some(),
982 "#10abc (mixed) should still be flagged in MkDocs flavor"
983 );
984 }
985
986 #[test]
987 fn test_mkdocs_magiclink_only_single_hash() {
988 let rule = MD018NoMissingSpaceAtx::new();
990
991 assert!(
992 rule.check_atx_heading_line("##10", MarkdownFlavor::MkDocs).is_some(),
993 "##10 should be flagged in MkDocs flavor (only single # is MagicLink)"
994 );
995 assert!(
996 rule.check_atx_heading_line("###123", MarkdownFlavor::MkDocs).is_some(),
997 "###123 should be flagged in MkDocs flavor"
998 );
999 }
1000
1001 #[test]
1002 fn test_standard_flavor_flags_numeric_refs() {
1003 let rule = MD018NoMissingSpaceAtx::new();
1005
1006 assert!(
1007 rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_some(),
1008 "#10 should be flagged in Standard flavor"
1009 );
1010 assert!(
1011 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
1012 "#123 should be flagged in Standard flavor"
1013 );
1014 }
1015
1016 #[test]
1017 fn test_mkdocs_magiclink_full_check() {
1018 let rule = MD018NoMissingSpaceAtx::new();
1020
1021 let content = r#"# PRs that are helpful for context
1022
1023#10 discusses the philosophy behind the project, and #37 shows a good example.
1024
1025#Summary
1026
1027##Introduction
1028"#;
1029
1030 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1032 let result = rule.check(&ctx).unwrap();
1033
1034 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1035 assert!(
1036 !flagged_lines.contains(&3),
1037 "#10 should NOT be flagged in MkDocs flavor"
1038 );
1039 assert!(
1040 flagged_lines.contains(&5),
1041 "#Summary SHOULD be flagged in MkDocs flavor"
1042 );
1043 assert!(
1044 flagged_lines.contains(&7),
1045 "##Introduction SHOULD be flagged in MkDocs flavor"
1046 );
1047 }
1048
1049 #[test]
1050 fn test_mkdocs_magiclink_fix_exact_output() {
1051 let rule = MD018NoMissingSpaceAtx::new();
1053
1054 let content = "#10 discusses the issue.\n\n#Summary";
1055 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1056 let fixed = rule.fix(&ctx).unwrap();
1057
1058 let expected = "#10 discusses the issue.\n\n# Summary";
1060 assert_eq!(
1061 fixed, expected,
1062 "MkDocs fix should preserve MagicLink refs and fix non-numeric headings"
1063 );
1064 }
1065
1066 #[test]
1067 fn test_mkdocs_magiclink_edge_cases() {
1068 let rule = MD018NoMissingSpaceAtx::new();
1070
1071 let valid_refs = [
1074 "#10", "#999999", "#10 text after", "#10\ttext after", "#10.", "#10,", "#10!", "#10?", "#10)", "#10]", "#10;", "#10:", ];
1087
1088 for ref_str in valid_refs {
1089 assert!(
1090 rule.check_atx_heading_line(ref_str, MarkdownFlavor::MkDocs).is_none(),
1091 "{ref_str:?} should be skipped as MagicLink ref in MkDocs flavor"
1092 );
1093 }
1094
1095 let invalid_refs = [
1097 "#10abc", "#10a", "#abc10", "#10ABC", "#Summary", "#hello", ];
1104
1105 for ref_str in invalid_refs {
1106 assert!(
1107 rule.check_atx_heading_line(ref_str, MarkdownFlavor::MkDocs).is_some(),
1108 "{ref_str:?} should be flagged in MkDocs flavor (not a valid MagicLink ref)"
1109 );
1110 }
1111 }
1112
1113 #[test]
1114 fn test_mkdocs_magiclink_hyphenated_continuation() {
1115 let rule = MD018NoMissingSpaceAtx::new();
1118
1119 assert!(
1124 rule.check_atx_heading_line("#10-", MarkdownFlavor::MkDocs).is_none(),
1125 "#10- should be skipped (hyphen is non-alphanumeric terminator)"
1126 );
1127 }
1128
1129 #[test]
1130 fn test_mkdocs_magiclink_standalone_number() {
1131 let rule = MD018NoMissingSpaceAtx::new();
1133
1134 let content = "See issue:\n\n#10\n\nFor details.";
1135 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1136 let result = rule.check(&ctx).unwrap();
1137
1138 assert!(
1140 result.is_empty(),
1141 "Standalone #10 should not be flagged in MkDocs flavor"
1142 );
1143
1144 let fixed = rule.fix(&ctx).unwrap();
1146 assert_eq!(fixed, content, "fix() should not modify standalone MagicLink ref");
1147 }
1148
1149 #[test]
1150 fn test_standard_flavor_flags_all_numeric() {
1151 let rule = MD018NoMissingSpaceAtx::new();
1154
1155 let numeric_patterns = ["#10", "#123", "#999999", "#10 text"];
1156
1157 for pattern in numeric_patterns {
1158 assert!(
1159 rule.check_atx_heading_line(pattern, MarkdownFlavor::Standard).is_some(),
1160 "{pattern:?} should be flagged in Standard flavor"
1161 );
1162 }
1163
1164 assert!(
1166 rule.check_atx_heading_line("#1", MarkdownFlavor::Standard).is_none(),
1167 "#1 should be skipped (content too short, existing behavior)"
1168 );
1169 }
1170
1171 #[test]
1172 fn test_mkdocs_vs_standard_fix_comparison() {
1173 let rule = MD018NoMissingSpaceAtx::new();
1175
1176 let content = "#10 is an issue\n#Summary";
1177
1178 let ctx_mkdocs = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1180 let fixed_mkdocs = rule.fix(&ctx_mkdocs).unwrap();
1181 assert_eq!(fixed_mkdocs, "#10 is an issue\n# Summary");
1182
1183 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1185 let fixed_standard = rule.fix(&ctx_standard).unwrap();
1186 assert_eq!(fixed_standard, "# 10 is an issue\n# Summary");
1187 }
1188}