1mod md018_config;
5
6pub use md018_config::MD018Config;
7
8use crate::config::MarkdownFlavor;
9use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
10use crate::utils::range_utils::calculate_single_line_range;
11use crate::utils::regex_cache::get_cached_regex;
12
13const EMOJI_HASHTAG_PATTERN_STR: &str = r"^#️⃣|^#⃣";
15const UNICODE_HASHTAG_PATTERN_STR: &str = r"^#[\u{FE0F}\u{20E3}]";
16
17const MAGICLINK_REF_PATTERN_STR: &str = r"^#\d+(?:\s|[^a-zA-Z0-9]|$)";
21
22const OBSIDIAN_TAG_PATTERN_STR: &str = r"^#[^\d\s#][^\s#]*(?:\s|$)";
27
28#[derive(Clone)]
29pub struct MD018NoMissingSpaceAtx {
30 config: MD018Config,
31}
32
33impl Default for MD018NoMissingSpaceAtx {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39impl MD018NoMissingSpaceAtx {
40 pub fn new() -> Self {
41 Self {
42 config: MD018Config::default(),
43 }
44 }
45
46 pub fn from_config_struct(config: MD018Config) -> Self {
47 Self { config }
48 }
49
50 fn is_magiclink_ref(line: &str) -> bool {
53 get_cached_regex(MAGICLINK_REF_PATTERN_STR).is_ok_and(|re| re.is_match(line.trim_start()))
54 }
55
56 fn is_obsidian_tag(line: &str) -> bool {
59 get_cached_regex(OBSIDIAN_TAG_PATTERN_STR).is_ok_and(|re| re.is_match(line.trim_start()))
60 }
61
62 fn check_atx_heading_line(&self, line: &str, flavor: MarkdownFlavor) -> Option<(usize, String)> {
64 let trimmed_line = line.trim_start();
66 let indent = line.len() - trimmed_line.len();
67
68 if !trimmed_line.starts_with('#') {
69 return None;
70 }
71
72 if indent > 0 {
78 return None;
79 }
80
81 let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
83 .map(|re| re.is_match(trimmed_line))
84 .unwrap_or(false);
85 let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
86 .map(|re| re.is_match(trimmed_line))
87 .unwrap_or(false);
88 if is_emoji || is_unicode {
89 return None;
90 }
91
92 let hash_count = trimmed_line.chars().take_while(|&c| c == '#').count();
94 if hash_count == 0 || hash_count > 6 {
95 return None;
96 }
97
98 let after_hashes = &trimmed_line[hash_count..];
100
101 if after_hashes
103 .chars()
104 .next()
105 .is_some_and(|ch| matches!(ch, '\u{FE0F}' | '\u{20E3}' | '\u{FE0E}'))
106 {
107 return None;
108 }
109
110 if !after_hashes.is_empty() && !after_hashes.starts_with(' ') && !after_hashes.starts_with('\t') {
112 let content = after_hashes.trim();
114
115 if content.chars().all(|c| c == '#') {
117 return None;
118 }
119
120 if content.len() < 2 {
122 return None;
123 }
124
125 if content.starts_with('*') || content.starts_with('_') {
127 return None;
128 }
129
130 if self.config.magiclink && hash_count == 1 && Self::is_magiclink_ref(line) {
133 return None;
134 }
135
136 if flavor == MarkdownFlavor::Obsidian && hash_count == 1 && Self::is_obsidian_tag(line) {
139 return None;
140 }
141
142 let fixed = format!("{}{} {}", " ".repeat(indent), "#".repeat(hash_count), after_hashes);
144 return Some((indent + hash_count, fixed));
145 }
146
147 None
148 }
149
150 fn get_line_byte_range(&self, content: &str, line_num: usize) -> std::ops::Range<usize> {
152 let mut current_line = 1;
153 let mut start_byte = 0;
154
155 for (i, c) in content.char_indices() {
156 if current_line == line_num && c == '\n' {
157 return start_byte..i;
158 } else if c == '\n' {
159 current_line += 1;
160 if current_line == line_num {
161 start_byte = i + 1;
162 }
163 }
164 }
165
166 if current_line == line_num {
168 return start_byte..content.len();
169 }
170
171 0..0
173 }
174}
175
176impl Rule for MD018NoMissingSpaceAtx {
177 fn name(&self) -> &'static str {
178 "MD018"
179 }
180
181 fn description(&self) -> &'static str {
182 "No space after hash in heading"
183 }
184
185 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
186 let mut warnings = Vec::new();
187
188 for (line_num, line_info) in ctx.lines.iter().enumerate() {
190 if line_info.in_html_block || line_info.in_html_comment || line_info.in_pymdown_block {
192 continue;
193 }
194
195 if let Some(heading) = &line_info.heading {
196 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
198 if line_info.indent > 0 {
201 continue;
202 }
203
204 let line = line_info.content(ctx.content);
206 let trimmed = line.trim_start();
207
208 let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
210 .map(|re| re.is_match(trimmed))
211 .unwrap_or(false);
212 let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
213 .map(|re| re.is_match(trimmed))
214 .unwrap_or(false);
215 if is_emoji || is_unicode {
216 continue;
217 }
218
219 if self.config.magiclink && heading.level == 1 && Self::is_magiclink_ref(line) {
221 continue;
222 }
223
224 if ctx.flavor == MarkdownFlavor::Obsidian && heading.level == 1 && Self::is_obsidian_tag(line) {
226 continue;
227 }
228
229 if trimmed.len() > heading.marker.len() {
230 let after_marker = &trimmed[heading.marker.len()..];
231 if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
232 {
233 let hash_end_col = line_info.indent + heading.marker.len() + 1; let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
236 line_num + 1, hash_end_col,
238 0, );
240
241 warnings.push(LintWarning {
242 rule_name: Some(self.name().to_string()),
243 message: format!("No space after {} in heading", "#".repeat(heading.level as usize)),
244 line: start_line,
245 column: start_col,
246 end_line,
247 end_column: end_col,
248 severity: Severity::Warning,
249 fix: Some(Fix {
250 range: self.get_line_byte_range(ctx.content, line_num + 1),
251 replacement: {
252 let line = line_info.content(ctx.content);
254 let original_indent = &line[..line_info.indent];
255 format!("{original_indent}{} {after_marker}", heading.marker)
256 },
257 }),
258 });
259 }
260 }
261 }
262 } else if !line_info.in_code_block
263 && !line_info.in_front_matter
264 && !line_info.in_html_comment
265 && !line_info.is_blank
266 {
267 if let Some((hash_end_pos, fixed_line)) =
269 self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor)
270 {
271 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
272 line_num + 1, hash_end_pos + 1, 0, );
276
277 warnings.push(LintWarning {
278 rule_name: Some(self.name().to_string()),
279 message: "No space after hash in heading".to_string(),
280 line: start_line,
281 column: start_col,
282 end_line,
283 end_column: end_col,
284 severity: Severity::Warning,
285 fix: Some(Fix {
286 range: self.get_line_byte_range(ctx.content, line_num + 1),
287 replacement: fixed_line,
288 }),
289 });
290 }
291 }
292 }
293
294 Ok(warnings)
295 }
296
297 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
298 let mut lines = Vec::new();
299
300 for line_info in ctx.lines.iter() {
301 let mut fixed = false;
302
303 if let Some(heading) = &line_info.heading {
304 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
306 let line = line_info.content(ctx.content);
307 let trimmed = line.trim_start();
308
309 let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
311 .map(|re| re.is_match(trimmed))
312 .unwrap_or(false);
313 let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
314 .map(|re| re.is_match(trimmed))
315 .unwrap_or(false);
316
317 let is_magiclink = self.config.magiclink && heading.level == 1 && Self::is_magiclink_ref(line);
319
320 let is_obsidian_tag =
322 ctx.flavor == MarkdownFlavor::Obsidian && heading.level == 1 && Self::is_obsidian_tag(line);
323
324 if !is_emoji
326 && !is_unicode
327 && !is_magiclink
328 && !is_obsidian_tag
329 && trimmed.len() > heading.marker.len()
330 {
331 let after_marker = &trimmed[heading.marker.len()..];
332 if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
333 {
334 let line = line_info.content(ctx.content);
336 let original_indent = &line[..line_info.indent];
337 lines.push(format!("{original_indent}{} {after_marker}", heading.marker));
338 fixed = true;
339 }
340 }
341 }
342 } else if !line_info.in_code_block
343 && !line_info.in_front_matter
344 && !line_info.in_html_comment
345 && !line_info.is_blank
346 {
347 if let Some((_, fixed_line)) = self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor) {
349 lines.push(fixed_line);
350 fixed = true;
351 }
352 }
353
354 if !fixed {
355 lines.push(line_info.content(ctx.content).to_string());
356 }
357 }
358
359 let mut result = lines.join("\n");
361 if ctx.content.ends_with('\n') && !result.ends_with('\n') {
362 result.push('\n');
363 }
364
365 Ok(result)
366 }
367
368 fn category(&self) -> RuleCategory {
370 RuleCategory::Heading
371 }
372
373 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
375 !ctx.likely_has_headings()
377 }
378
379 fn as_any(&self) -> &dyn std::any::Any {
380 self
381 }
382
383 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
384 where
385 Self: Sized,
386 {
387 let rule_config = crate::rule_config_serde::load_rule_config::<MD018Config>(config);
388 Box::new(MD018NoMissingSpaceAtx::from_config_struct(rule_config))
389 }
390
391 fn default_config_section(&self) -> Option<(String, toml::Value)> {
392 let json_value = serde_json::to_value(&self.config).ok()?;
393 Some((
394 self.name().to_string(),
395 crate::rule_config_serde::json_to_toml_value(&json_value)?,
396 ))
397 }
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403 use crate::lint_context::LintContext;
404
405 #[test]
406 fn test_basic_functionality() {
407 let rule = MD018NoMissingSpaceAtx::new();
408
409 let content = "# Heading 1\n## Heading 2\n### Heading 3";
411 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
412 let result = rule.check(&ctx).unwrap();
413 assert!(result.is_empty());
414
415 let content = "#Heading 1\n## Heading 2\n###Heading 3";
417 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
418 let result = rule.check(&ctx).unwrap();
419 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
421 assert_eq!(result[1].line, 3);
422 }
423
424 #[test]
425 fn test_malformed_heading_detection() {
426 let rule = MD018NoMissingSpaceAtx::new();
427
428 assert!(
430 rule.check_atx_heading_line("##Introduction", MarkdownFlavor::Standard)
431 .is_some()
432 );
433 assert!(
434 rule.check_atx_heading_line("###Background", MarkdownFlavor::Standard)
435 .is_some()
436 );
437 assert!(
438 rule.check_atx_heading_line("####Details", MarkdownFlavor::Standard)
439 .is_some()
440 );
441 assert!(
442 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
443 .is_some()
444 );
445 assert!(
446 rule.check_atx_heading_line("######Conclusion", MarkdownFlavor::Standard)
447 .is_some()
448 );
449 assert!(
450 rule.check_atx_heading_line("##Table of Contents", MarkdownFlavor::Standard)
451 .is_some()
452 );
453
454 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!(
459 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
460 .is_none()
461 ); assert!(
463 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
464 .is_none()
465 ); }
467
468 #[test]
469 fn test_malformed_heading_with_context() {
470 let rule = MD018NoMissingSpaceAtx::new();
471
472 let content = r#"# Test Document
474
475##Introduction
476This should be detected.
477
478 ##CodeBlock
479This should NOT be detected (indented code block).
480
481```
482##FencedCodeBlock
483This should NOT be detected (fenced code block).
484```
485
486##Conclusion
487This should be detected.
488"#;
489
490 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
491 let result = rule.check(&ctx).unwrap();
492
493 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
495 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&14)); assert!(!detected_lines.contains(&6)); assert!(!detected_lines.contains(&10)); }
500
501 #[test]
502 fn test_malformed_heading_fix() {
503 let rule = MD018NoMissingSpaceAtx::new();
504
505 let content = r#"##Introduction
506This is a test.
507
508###Background
509More content."#;
510
511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
512 let fixed = rule.fix(&ctx).unwrap();
513
514 let expected = r#"## Introduction
515This is a test.
516
517### Background
518More content."#;
519
520 assert_eq!(fixed, expected);
521 }
522
523 #[test]
524 fn test_mixed_proper_and_malformed_headings() {
525 let rule = MD018NoMissingSpaceAtx::new();
526
527 let content = r#"# Proper Heading
528
529##Malformed Heading
530
531## Another Proper Heading
532
533###Another Malformed
534
535#### Proper with space
536"#;
537
538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
539 let result = rule.check(&ctx).unwrap();
540
541 assert_eq!(result.len(), 2);
543 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
544 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&7)); }
547
548 #[test]
549 fn test_css_selectors_in_html_blocks() {
550 let rule = MD018NoMissingSpaceAtx::new();
551
552 let content = r#"# Proper Heading
555
556<style>
557#slide-1 ol li {
558 margin-top: 0;
559}
560
561#special-slide ol li {
562 margin-top: 2em;
563}
564</style>
565
566## Another Heading
567"#;
568
569 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
570 let result = rule.check(&ctx).unwrap();
571
572 assert_eq!(
574 result.len(),
575 0,
576 "CSS selectors in <style> blocks should not be flagged as malformed headings"
577 );
578 }
579
580 #[test]
581 fn test_js_code_in_script_blocks() {
582 let rule = MD018NoMissingSpaceAtx::new();
583
584 let content = r#"# Heading
586
587<script>
588const element = document.querySelector('#main-content');
589#another-comment
590</script>
591
592## Another Heading
593"#;
594
595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
596 let result = rule.check(&ctx).unwrap();
597
598 assert_eq!(
600 result.len(),
601 0,
602 "JavaScript code in <script> blocks should not be flagged as malformed headings"
603 );
604 }
605
606 #[test]
607 fn test_all_malformed_headings_detected() {
608 let rule = MD018NoMissingSpaceAtx::new();
609
610 assert!(
615 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
616 .is_some(),
617 "#hello SHOULD be detected as malformed heading"
618 );
619 assert!(
620 rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
621 "#tag SHOULD be detected as malformed heading"
622 );
623 assert!(
624 rule.check_atx_heading_line("#hashtag", MarkdownFlavor::Standard)
625 .is_some(),
626 "#hashtag SHOULD be detected as malformed heading"
627 );
628 assert!(
629 rule.check_atx_heading_line("#javascript", MarkdownFlavor::Standard)
630 .is_some(),
631 "#javascript SHOULD be detected as malformed heading"
632 );
633
634 assert!(
636 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
637 "#123 SHOULD be detected as malformed heading"
638 );
639 assert!(
640 rule.check_atx_heading_line("#12345", MarkdownFlavor::Standard)
641 .is_some(),
642 "#12345 SHOULD be detected as malformed heading"
643 );
644 assert!(
645 rule.check_atx_heading_line("#29039)", MarkdownFlavor::Standard)
646 .is_some(),
647 "#29039) SHOULD be detected as malformed heading"
648 );
649
650 assert!(
652 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
653 .is_some(),
654 "#Summary SHOULD be detected as malformed heading"
655 );
656 assert!(
657 rule.check_atx_heading_line("#Introduction", MarkdownFlavor::Standard)
658 .is_some(),
659 "#Introduction SHOULD be detected as malformed heading"
660 );
661 assert!(
662 rule.check_atx_heading_line("#API", MarkdownFlavor::Standard).is_some(),
663 "#API SHOULD be detected as malformed heading"
664 );
665
666 assert!(
668 rule.check_atx_heading_line("##introduction", MarkdownFlavor::Standard)
669 .is_some(),
670 "##introduction SHOULD be detected as malformed heading"
671 );
672 assert!(
673 rule.check_atx_heading_line("###section", MarkdownFlavor::Standard)
674 .is_some(),
675 "###section SHOULD be detected as malformed heading"
676 );
677 assert!(
678 rule.check_atx_heading_line("###fer", MarkdownFlavor::Standard)
679 .is_some(),
680 "###fer SHOULD be detected as malformed heading"
681 );
682 assert!(
683 rule.check_atx_heading_line("##123", MarkdownFlavor::Standard).is_some(),
684 "##123 SHOULD be detected as malformed heading"
685 );
686 }
687
688 #[test]
689 fn test_patterns_that_should_not_be_flagged() {
690 let rule = MD018NoMissingSpaceAtx::new();
691
692 assert!(rule.check_atx_heading_line("###", MarkdownFlavor::Standard).is_none());
694 assert!(rule.check_atx_heading_line("#", MarkdownFlavor::Standard).is_none());
695
696 assert!(rule.check_atx_heading_line("##a", MarkdownFlavor::Standard).is_none());
698
699 assert!(
701 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
702 .is_none()
703 );
704
705 assert!(
707 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
708 .is_none()
709 );
710
711 assert!(
713 rule.check_atx_heading_line("# Hello", MarkdownFlavor::Standard)
714 .is_none()
715 );
716 assert!(
717 rule.check_atx_heading_line("## World", MarkdownFlavor::Standard)
718 .is_none()
719 );
720 assert!(
721 rule.check_atx_heading_line("### Section", MarkdownFlavor::Standard)
722 .is_none()
723 );
724 }
725
726 #[test]
727 fn test_inline_issue_refs_not_at_line_start() {
728 let rule = MD018NoMissingSpaceAtx::new();
729
730 assert!(
735 rule.check_atx_heading_line("See issue #123", MarkdownFlavor::Standard)
736 .is_none()
737 );
738 assert!(
739 rule.check_atx_heading_line("Check #trending on Twitter", MarkdownFlavor::Standard)
740 .is_none()
741 );
742 assert!(
743 rule.check_atx_heading_line("- fix: issue #29039", MarkdownFlavor::Standard)
744 .is_none()
745 );
746 }
747
748 #[test]
749 fn test_lowercase_patterns_full_check() {
750 let rule = MD018NoMissingSpaceAtx::new();
752
753 let content = "#hello\n\n#world\n\n#tag";
754 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
755 let result = rule.check(&ctx).unwrap();
756
757 assert_eq!(result.len(), 3, "All three lowercase patterns should be flagged");
758 assert_eq!(result[0].line, 1);
759 assert_eq!(result[1].line, 3);
760 assert_eq!(result[2].line, 5);
761 }
762
763 #[test]
764 fn test_numeric_patterns_full_check() {
765 let rule = MD018NoMissingSpaceAtx::new();
767
768 let content = "#123\n\n#456\n\n#29039";
769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770 let result = rule.check(&ctx).unwrap();
771
772 assert_eq!(result.len(), 3, "All three numeric patterns should be flagged");
773 }
774
775 #[test]
776 fn test_fix_lowercase_patterns() {
777 let rule = MD018NoMissingSpaceAtx::new();
779
780 let content = "#hello\nSome text.\n\n#world";
781 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
782 let fixed = rule.fix(&ctx).unwrap();
783
784 let expected = "# hello\nSome text.\n\n# world";
785 assert_eq!(fixed, expected);
786 }
787
788 #[test]
789 fn test_fix_numeric_patterns() {
790 let rule = MD018NoMissingSpaceAtx::new();
792
793 let content = "#123\nContent.\n\n##456";
794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
795 let fixed = rule.fix(&ctx).unwrap();
796
797 let expected = "# 123\nContent.\n\n## 456";
798 assert_eq!(fixed, expected);
799 }
800
801 #[test]
802 fn test_indented_malformed_headings() {
803 let rule = MD018NoMissingSpaceAtx::new();
807
808 assert!(
810 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
811 .is_none(),
812 "1-space indented #hello should be skipped"
813 );
814 assert!(
815 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
816 .is_none(),
817 "2-space indented #hello should be skipped"
818 );
819 assert!(
820 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
821 .is_none(),
822 "3-space indented #hello should be skipped"
823 );
824
825 assert!(
830 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
831 .is_some(),
832 "Non-indented #hello should be detected"
833 );
834 }
835
836 #[test]
837 fn test_tab_after_hash_is_valid() {
838 let rule = MD018NoMissingSpaceAtx::new();
840
841 assert!(
842 rule.check_atx_heading_line("#\tHello", MarkdownFlavor::Standard)
843 .is_none(),
844 "Tab after # should be valid"
845 );
846 assert!(
847 rule.check_atx_heading_line("##\tWorld", MarkdownFlavor::Standard)
848 .is_none(),
849 "Tab after ## should be valid"
850 );
851 }
852
853 #[test]
854 fn test_mixed_case_patterns() {
855 let rule = MD018NoMissingSpaceAtx::new();
856
857 assert!(
859 rule.check_atx_heading_line("#hELLO", MarkdownFlavor::Standard)
860 .is_some()
861 );
862 assert!(
863 rule.check_atx_heading_line("#Hello", MarkdownFlavor::Standard)
864 .is_some()
865 );
866 assert!(
867 rule.check_atx_heading_line("#HELLO", MarkdownFlavor::Standard)
868 .is_some()
869 );
870 assert!(
871 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
872 .is_some()
873 );
874 }
875
876 #[test]
877 fn test_unicode_lowercase() {
878 let rule = MD018NoMissingSpaceAtx::new();
879
880 assert!(
882 rule.check_atx_heading_line("#über", MarkdownFlavor::Standard).is_some(),
883 "Unicode lowercase #über should be detected"
884 );
885 assert!(
886 rule.check_atx_heading_line("#café", MarkdownFlavor::Standard).is_some(),
887 "Unicode lowercase #café should be detected"
888 );
889 assert!(
890 rule.check_atx_heading_line("#日本語", MarkdownFlavor::Standard)
891 .is_some(),
892 "Japanese #日本語 should be detected"
893 );
894 }
895
896 #[test]
897 fn test_matches_markdownlint_behavior() {
898 let rule = MD018NoMissingSpaceAtx::new();
900
901 let content = r#"#hello
902
903## world
904
905###fer
906
907#123
908
909#Tag
910"#;
911
912 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
913 let result = rule.check(&ctx).unwrap();
914
915 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
918
919 assert!(flagged_lines.contains(&1), "#hello should be flagged");
920 assert!(!flagged_lines.contains(&3), "## world should NOT be flagged");
921 assert!(flagged_lines.contains(&5), "###fer should be flagged");
922 assert!(flagged_lines.contains(&7), "#123 should be flagged");
923 assert!(flagged_lines.contains(&9), "#Tag should be flagged");
924
925 assert_eq!(result.len(), 4, "Should have exactly 4 warnings");
926 }
927
928 #[test]
929 fn test_skip_frontmatter_yaml_comments() {
930 let rule = MD018NoMissingSpaceAtx::new();
932
933 let content = r#"---
934#reviewers:
935#- sig-api-machinery
936#another_comment: value
937title: Test Document
938---
939
940# Valid heading
941
942#invalid heading without space
943"#;
944
945 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
946 let result = rule.check(&ctx).unwrap();
947
948 assert_eq!(
951 result.len(),
952 1,
953 "Should only flag the malformed heading outside frontmatter"
954 );
955 assert_eq!(result[0].line, 10, "Should flag line 10");
956 }
957
958 #[test]
959 fn test_skip_html_comments() {
960 let rule = MD018NoMissingSpaceAtx::new();
963
964 let content = r#"# Real Heading
965
966Some text.
967
968<!--
969```
970#%% Cell marker
971import matplotlib.pyplot as plt
972
973#%% Another cell
974data = [1, 2, 3]
975```
976-->
977
978More content.
979"#;
980
981 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
982 let result = rule.check(&ctx).unwrap();
983
984 assert!(
986 result.is_empty(),
987 "Should not flag content inside HTML comments, found {} issues",
988 result.len()
989 );
990 }
991
992 #[test]
993 fn test_mkdocs_magiclink_skips_numeric_refs() {
994 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { magiclink: true });
996
997 assert!(
999 rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_none(),
1000 "#10 should be skipped with magiclink config (MagicLink issue ref)"
1001 );
1002 assert!(
1003 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_none(),
1004 "#123 should be skipped with magiclink config (MagicLink issue ref)"
1005 );
1006 assert!(
1007 rule.check_atx_heading_line("#10 discusses the issue", MarkdownFlavor::Standard)
1008 .is_none(),
1009 "#10 followed by text should be skipped with magiclink config"
1010 );
1011 assert!(
1012 rule.check_atx_heading_line("#37.", MarkdownFlavor::Standard).is_none(),
1013 "#37 followed by punctuation should be skipped with magiclink config"
1014 );
1015 }
1016
1017 #[test]
1018 fn test_mkdocs_magiclink_still_flags_non_numeric() {
1019 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { magiclink: true });
1021
1022 assert!(
1024 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
1025 .is_some(),
1026 "#Summary should still be flagged with magiclink config"
1027 );
1028 assert!(
1029 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
1030 .is_some(),
1031 "#hello should still be flagged with magiclink config"
1032 );
1033 assert!(
1034 rule.check_atx_heading_line("#10abc", MarkdownFlavor::Standard)
1035 .is_some(),
1036 "#10abc (mixed) should still be flagged with magiclink config"
1037 );
1038 }
1039
1040 #[test]
1041 fn test_mkdocs_magiclink_only_single_hash() {
1042 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { magiclink: true });
1044
1045 assert!(
1046 rule.check_atx_heading_line("##10", MarkdownFlavor::Standard).is_some(),
1047 "##10 should be flagged with magiclink config (only single # is MagicLink)"
1048 );
1049 assert!(
1050 rule.check_atx_heading_line("###123", MarkdownFlavor::Standard)
1051 .is_some(),
1052 "###123 should be flagged with magiclink config"
1053 );
1054 }
1055
1056 #[test]
1057 fn test_standard_flavor_flags_numeric_refs() {
1058 let rule = MD018NoMissingSpaceAtx::new();
1060
1061 assert!(
1062 rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_some(),
1063 "#10 should be flagged in Standard flavor"
1064 );
1065 assert!(
1066 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
1067 "#123 should be flagged in Standard flavor"
1068 );
1069 }
1070
1071 #[test]
1072 fn test_mkdocs_magiclink_full_check() {
1073 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { magiclink: true });
1075
1076 let content = r#"# PRs that are helpful for context
1077
1078#10 discusses the philosophy behind the project, and #37 shows a good example.
1079
1080#Summary
1081
1082##Introduction
1083"#;
1084
1085 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1087 let result = rule.check(&ctx).unwrap();
1088
1089 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1090 assert!(
1091 !flagged_lines.contains(&3),
1092 "#10 should NOT be flagged with magiclink config"
1093 );
1094 assert!(
1095 flagged_lines.contains(&5),
1096 "#Summary SHOULD be flagged with magiclink config"
1097 );
1098 assert!(
1099 flagged_lines.contains(&7),
1100 "##Introduction SHOULD be flagged with magiclink config"
1101 );
1102 }
1103
1104 #[test]
1105 fn test_mkdocs_magiclink_fix_exact_output() {
1106 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { magiclink: true });
1108
1109 let content = "#10 discusses the issue.\n\n#Summary";
1110 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1111 let fixed = rule.fix(&ctx).unwrap();
1112
1113 let expected = "#10 discusses the issue.\n\n# Summary";
1115 assert_eq!(
1116 fixed, expected,
1117 "magiclink config fix should preserve MagicLink refs and fix non-numeric headings"
1118 );
1119 }
1120
1121 #[test]
1122 fn test_mkdocs_magiclink_edge_cases() {
1123 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { magiclink: true });
1125
1126 let valid_refs = [
1129 "#10", "#999999", "#10 text after", "#10\ttext after", "#10.", "#10,", "#10!", "#10?", "#10)", "#10]", "#10;", "#10:", ];
1142
1143 for ref_str in valid_refs {
1144 assert!(
1145 rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_none(),
1146 "{ref_str:?} should be skipped as MagicLink ref with magiclink config"
1147 );
1148 }
1149
1150 let invalid_refs = [
1152 "#10abc", "#10a", "#abc10", "#10ABC", "#Summary", "#hello", ];
1159
1160 for ref_str in invalid_refs {
1161 assert!(
1162 rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_some(),
1163 "{ref_str:?} should be flagged with magiclink config (not a valid MagicLink ref)"
1164 );
1165 }
1166 }
1167
1168 #[test]
1169 fn test_mkdocs_magiclink_hyphenated_continuation() {
1170 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { magiclink: true });
1173
1174 assert!(
1179 rule.check_atx_heading_line("#10-", MarkdownFlavor::Standard).is_none(),
1180 "#10- should be skipped with magiclink config (hyphen is non-alphanumeric terminator)"
1181 );
1182 }
1183
1184 #[test]
1185 fn test_mkdocs_magiclink_standalone_number() {
1186 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { magiclink: true });
1188
1189 let content = "See issue:\n\n#10\n\nFor details.";
1190 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1191 let result = rule.check(&ctx).unwrap();
1192
1193 assert!(
1195 result.is_empty(),
1196 "Standalone #10 should not be flagged with magiclink config"
1197 );
1198
1199 let fixed = rule.fix(&ctx).unwrap();
1201 assert_eq!(fixed, content, "fix() should not modify standalone MagicLink ref");
1202 }
1203
1204 #[test]
1205 fn test_standard_flavor_flags_all_numeric() {
1206 let rule = MD018NoMissingSpaceAtx::new();
1209
1210 let numeric_patterns = ["#10", "#123", "#999999", "#10 text"];
1211
1212 for pattern in numeric_patterns {
1213 assert!(
1214 rule.check_atx_heading_line(pattern, MarkdownFlavor::Standard).is_some(),
1215 "{pattern:?} should be flagged in Standard flavor"
1216 );
1217 }
1218
1219 assert!(
1221 rule.check_atx_heading_line("#1", MarkdownFlavor::Standard).is_none(),
1222 "#1 should be skipped (content too short, existing behavior)"
1223 );
1224 }
1225
1226 #[test]
1227 fn test_mkdocs_vs_standard_fix_comparison() {
1228 let content = "#10 is an issue\n#Summary";
1230 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1231
1232 let rule_magiclink = MD018NoMissingSpaceAtx::from_config_struct(MD018Config { magiclink: true });
1234 let fixed_magiclink = rule_magiclink.fix(&ctx).unwrap();
1235 assert_eq!(fixed_magiclink, "#10 is an issue\n# Summary");
1236
1237 let rule_default = MD018NoMissingSpaceAtx::new();
1239 let fixed_default = rule_default.fix(&ctx).unwrap();
1240 assert_eq!(fixed_default, "# 10 is an issue\n# Summary");
1241 }
1242
1243 #[test]
1246 fn test_obsidian_tag_skips_simple_tags() {
1247 let rule = MD018NoMissingSpaceAtx::new();
1249
1250 assert!(
1252 rule.check_atx_heading_line("#hey", MarkdownFlavor::Obsidian).is_none(),
1253 "#hey should be skipped in Obsidian flavor (tag syntax)"
1254 );
1255 assert!(
1256 rule.check_atx_heading_line("#tag", MarkdownFlavor::Obsidian).is_none(),
1257 "#tag should be skipped in Obsidian flavor"
1258 );
1259 assert!(
1260 rule.check_atx_heading_line("#hello", MarkdownFlavor::Obsidian)
1261 .is_none(),
1262 "#hello should be skipped in Obsidian flavor"
1263 );
1264 assert!(
1265 rule.check_atx_heading_line("#myTag", MarkdownFlavor::Obsidian)
1266 .is_none(),
1267 "#myTag should be skipped in Obsidian flavor"
1268 );
1269 }
1270
1271 #[test]
1272 fn test_obsidian_tag_skips_complex_tags() {
1273 let rule = MD018NoMissingSpaceAtx::new();
1275
1276 assert!(
1278 rule.check_atx_heading_line("#project/active", MarkdownFlavor::Obsidian)
1279 .is_none(),
1280 "#project/active should be skipped in Obsidian flavor (nested tag)"
1281 );
1282 assert!(
1283 rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1284 .is_none(),
1285 "#my-tag should be skipped in Obsidian flavor"
1286 );
1287 assert!(
1288 rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1289 .is_none(),
1290 "#my_tag should be skipped in Obsidian flavor"
1291 );
1292 assert!(
1293 rule.check_atx_heading_line("#tag2023", MarkdownFlavor::Obsidian)
1294 .is_none(),
1295 "#tag2023 should be skipped in Obsidian flavor"
1296 );
1297 assert!(
1298 rule.check_atx_heading_line("#project/sub/task", MarkdownFlavor::Obsidian)
1299 .is_none(),
1300 "#project/sub/task should be skipped in Obsidian flavor"
1301 );
1302 }
1303
1304 #[test]
1305 fn test_obsidian_tag_with_trailing_content() {
1306 let rule = MD018NoMissingSpaceAtx::new();
1308
1309 assert!(
1310 rule.check_atx_heading_line("#hey ", MarkdownFlavor::Obsidian).is_none(),
1311 "#hey followed by space should be skipped"
1312 );
1313 assert!(
1314 rule.check_atx_heading_line("#tag some text", MarkdownFlavor::Obsidian)
1315 .is_none(),
1316 "#tag followed by text should be skipped"
1317 );
1318 }
1319
1320 #[test]
1321 fn test_obsidian_tag_still_flags_multi_hash() {
1322 let rule = MD018NoMissingSpaceAtx::new();
1324
1325 assert!(
1326 rule.check_atx_heading_line("##tag", MarkdownFlavor::Obsidian).is_some(),
1327 "##tag should be flagged in Obsidian flavor (only single # is a tag)"
1328 );
1329 assert!(
1330 rule.check_atx_heading_line("###hello", MarkdownFlavor::Obsidian)
1331 .is_some(),
1332 "###hello should be flagged in Obsidian flavor"
1333 );
1334 }
1335
1336 #[test]
1337 fn test_obsidian_tag_numeric_still_flagged() {
1338 let rule = MD018NoMissingSpaceAtx::new();
1340
1341 assert!(
1342 rule.check_atx_heading_line("#123", MarkdownFlavor::Obsidian).is_some(),
1343 "#123 should be flagged in Obsidian flavor (tags cannot start with digit)"
1344 );
1345 assert!(
1346 rule.check_atx_heading_line("#10", MarkdownFlavor::Obsidian).is_some(),
1347 "#10 should be flagged in Obsidian flavor"
1348 );
1349 }
1350
1351 #[test]
1352 fn test_obsidian_flavor_full_check() {
1353 let rule = MD018NoMissingSpaceAtx::new();
1355
1356 let content = r#"# Real Heading
1357
1358#hey this is a tag
1359
1360#project/active also a tag
1361
1362##Introduction
1363
1364#123
1365"#;
1366
1367 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1369 let result = rule.check(&ctx).unwrap();
1370
1371 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1372 assert!(
1373 !flagged_lines.contains(&3),
1374 "#hey should NOT be flagged in Obsidian flavor"
1375 );
1376 assert!(
1377 !flagged_lines.contains(&5),
1378 "#project/active should NOT be flagged in Obsidian flavor"
1379 );
1380 assert!(
1381 flagged_lines.contains(&7),
1382 "##Introduction SHOULD be flagged in Obsidian flavor"
1383 );
1384 assert!(flagged_lines.contains(&9), "#123 SHOULD be flagged in Obsidian flavor");
1385 }
1386
1387 #[test]
1388 fn test_obsidian_flavor_fix_exact_output() {
1389 let rule = MD018NoMissingSpaceAtx::new();
1391
1392 let content = "#hey is a tag.\n\n##Introduction";
1395 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1396 let fixed = rule.fix(&ctx).unwrap();
1397
1398 let expected = "#hey is a tag.\n\n## Introduction";
1400 assert_eq!(
1401 fixed, expected,
1402 "Obsidian fix should preserve tags and fix multi-hash headings"
1403 );
1404 }
1405
1406 #[test]
1407 fn test_standard_flavor_flags_obsidian_tags() {
1408 let rule = MD018NoMissingSpaceAtx::new();
1410
1411 assert!(
1412 rule.check_atx_heading_line("#hey", MarkdownFlavor::Standard).is_some(),
1413 "#hey should be flagged in Standard flavor"
1414 );
1415 assert!(
1416 rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
1417 "#tag should be flagged in Standard flavor"
1418 );
1419 assert!(
1420 rule.check_atx_heading_line("#project/active", MarkdownFlavor::Standard)
1421 .is_some(),
1422 "#project/active should be flagged in Standard flavor"
1423 );
1424 }
1425
1426 #[test]
1427 fn test_obsidian_vs_standard_fix_comparison() {
1428 let rule = MD018NoMissingSpaceAtx::new();
1430
1431 let content = "#hey tag\n##Introduction";
1435
1436 let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1438 let fixed_obsidian = rule.fix(&ctx_obsidian).unwrap();
1439 assert_eq!(fixed_obsidian, "#hey tag\n## Introduction");
1440
1441 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1443 let fixed_standard = rule.fix(&ctx_standard).unwrap();
1444 assert_eq!(fixed_standard, "# hey tag\n## Introduction");
1445 }
1446
1447 #[test]
1448 fn test_obsidian_tag_edge_cases() {
1449 let rule = MD018NoMissingSpaceAtx::new();
1451
1452 let valid_tags = [
1454 "#a", "#tag", "#Tag", "#TAG", "#my-tag", "#my_tag", "#tag123", "#a1", "#日本語", "#über", ];
1465
1466 for tag in valid_tags {
1467 let result = rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian);
1469 let _ = result;
1472 }
1473
1474 let invalid_tags = ["#1tag", "#123", "#2023-project"];
1476
1477 for tag in invalid_tags {
1478 assert!(
1479 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_some(),
1480 "{tag:?} should be flagged in Obsidian flavor (starts with digit)"
1481 );
1482 }
1483 }
1484
1485 #[test]
1486 fn test_obsidian_tag_alone_on_line() {
1487 let rule = MD018NoMissingSpaceAtx::new();
1489
1490 let content = "Some text\n\n#todo\n\nMore text.";
1491 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1492 let result = rule.check(&ctx).unwrap();
1493
1494 assert!(
1496 result.is_empty(),
1497 "Standalone #todo should not be flagged in Obsidian flavor"
1498 );
1499
1500 let fixed = rule.fix(&ctx).unwrap();
1502 assert_eq!(fixed, content, "fix() should not modify standalone Obsidian tag");
1503 }
1504
1505 #[test]
1506 fn test_obsidian_deeply_nested_tags() {
1507 let rule = MD018NoMissingSpaceAtx::new();
1509
1510 let nested_tags = [
1511 "#a/b",
1512 "#a/b/c",
1513 "#project/2023/q1/task",
1514 "#work/meetings/weekly",
1515 "#life/health/exercise/running",
1516 ];
1517
1518 for tag in nested_tags {
1519 assert!(
1520 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1521 "{tag:?} should be skipped in Obsidian flavor (nested tag)"
1522 );
1523 }
1524 }
1525
1526 #[test]
1527 fn test_obsidian_unicode_tags() {
1528 let rule = MD018NoMissingSpaceAtx::new();
1530
1531 let unicode_tags = [
1532 "#日本語", "#中文", "#한국어", "#über", "#café", "#ñoño", "#Москва", "#αβγ", ];
1541
1542 for tag in unicode_tags {
1543 assert!(
1544 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1545 "{tag:?} should be skipped in Obsidian flavor (Unicode tag)"
1546 );
1547 }
1548 }
1549
1550 #[test]
1551 fn test_obsidian_tags_with_special_endings() {
1552 let rule = MD018NoMissingSpaceAtx::new();
1554
1555 assert!(
1557 rule.check_atx_heading_line("#tag followed by text", MarkdownFlavor::Obsidian)
1558 .is_none(),
1559 "#tag followed by text should be skipped"
1560 );
1561
1562 let content = "#todo";
1564 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1565 let result = rule.check(&ctx).unwrap();
1566 assert!(result.is_empty(), "#todo at end of line should be skipped");
1567 }
1568
1569 #[test]
1570 fn test_obsidian_combined_with_other_skip_contexts() {
1571 let rule = MD018NoMissingSpaceAtx::new();
1573
1574 let content = "```\n#todo\n```";
1576 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1577 let result = rule.check(&ctx).unwrap();
1578 assert!(result.is_empty(), "Tag in code block should be skipped");
1579
1580 let content = "<!-- #todo -->";
1582 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1583 let result = rule.check(&ctx).unwrap();
1584 assert!(result.is_empty(), "Tag in HTML comment should be skipped");
1585 }
1586
1587 #[test]
1588 fn test_obsidian_boundary_cases() {
1589 let rule = MD018NoMissingSpaceAtx::new();
1591
1592 assert!(
1596 rule.check_atx_heading_line("#ab", MarkdownFlavor::Obsidian).is_none(),
1597 "#ab should be skipped in Obsidian flavor"
1598 );
1599
1600 assert!(
1602 rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1603 .is_none(),
1604 "#my_tag should be skipped"
1605 );
1606
1607 assert!(
1609 rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1610 .is_none(),
1611 "#my-tag should be skipped"
1612 );
1613
1614 assert!(
1616 rule.check_atx_heading_line("#MyTag", MarkdownFlavor::Obsidian)
1617 .is_none(),
1618 "#MyTag should be skipped"
1619 );
1620
1621 assert!(
1623 rule.check_atx_heading_line("#TODO", MarkdownFlavor::Obsidian).is_none(),
1624 "#TODO should be skipped in Obsidian flavor"
1625 );
1626 }
1627}