1mod md018_config;
5
6pub(super) use md018_config::MD018Config;
7
8use crate::config::MarkdownFlavor;
9use crate::lint_context::{AtxMissingSpace, LineInfo};
10use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
11use crate::utils::obsidian_tag::TAG_PATTERN;
12use crate::utils::range_utils::{byte_to_char_count, calculate_single_line_range};
13use regex::Regex;
14use std::sync::LazyLock;
15
16const EMOJI_HASHTAG_PATTERN_STR: &str = r"^#️⃣|^#⃣";
18const UNICODE_HASHTAG_PATTERN_STR: &str = r"^#[\u{FE0F}\u{20E3}]";
19static EMOJI_HASHTAG_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(EMOJI_HASHTAG_PATTERN_STR).unwrap());
20static UNICODE_HASHTAG_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(UNICODE_HASHTAG_PATTERN_STR).unwrap());
21
22const MAGICLINK_REF_PATTERN_STR: &str = r"^#\d+(?:\s|[^a-zA-Z0-9]|$)";
26static MAGICLINK_REF_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(MAGICLINK_REF_PATTERN_STR).unwrap());
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 MAGICLINK_REF_PATTERN.is_match(line.trim_start())
54 }
55
56 fn is_tag(line: &str) -> bool {
58 TAG_PATTERN.is_match(line.trim_start())
59 }
60
61 fn tags_enabled(&self, flavor: MarkdownFlavor) -> bool {
63 self.config.tags_enabled(flavor)
64 }
65
66 fn check_atx_heading_line(&self, line: &str, flavor: MarkdownFlavor) -> Option<(usize, String)> {
68 let trimmed_line = line.trim_start();
70 let indent = line.len() - trimmed_line.len();
71
72 if !trimmed_line.starts_with('#') {
73 return None;
74 }
75
76 if indent > 0 {
82 return None;
83 }
84
85 let is_emoji = EMOJI_HASHTAG_PATTERN.is_match(trimmed_line);
87 let is_unicode = UNICODE_HASHTAG_PATTERN.is_match(trimmed_line);
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 self.tags_enabled(flavor) && hash_count == 1 && Self::is_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 missing_space_split<'a>(
155 &self,
156 line: &'a str,
157 indent: usize,
158 missing: AtxMissingSpace,
159 flavor: MarkdownFlavor,
160 ) -> Option<(&'a str, &'a str)> {
161 if indent > 0 {
162 return None;
163 }
164 let trimmed = &line[indent..];
165 if EMOJI_HASHTAG_PATTERN.is_match(trimmed) || UNICODE_HASHTAG_PATTERN.is_match(trimmed) {
166 return None;
167 }
168 if missing.level == 1
169 && ((self.config.magiclink && Self::is_magiclink_ref(line))
170 || (self.tags_enabled(flavor) && Self::is_tag(line)))
171 {
172 return None;
173 }
174 let (marker, after_marker) = trimmed.split_at_checked(usize::from(missing.level))?;
175 (!after_marker.is_empty()).then_some((marker, after_marker))
176 }
177
178 fn may_be_unrecorded_missing_space(line_info: &LineInfo) -> bool {
183 line_info.heading.is_none()
184 && !line_info.is_setext_heading_text
185 && !line_info.in_code_block
186 && !line_info.in_front_matter
187 && !line_info.in_html_comment
188 && !line_info.in_mdx_comment
189 && !line_info.is_blank
190 }
191
192 fn get_line_byte_range(&self, content: &str, line_num: usize) -> std::ops::Range<usize> {
194 let mut current_line = 1;
195 let mut start_byte = 0;
196
197 for (i, c) in content.char_indices() {
198 if current_line == line_num && c == '\n' {
199 return start_byte..i;
200 } else if c == '\n' {
201 current_line += 1;
202 if current_line == line_num {
203 start_byte = i + 1;
204 }
205 }
206 }
207
208 if current_line == line_num {
210 return start_byte..content.len();
211 }
212
213 0..0
215 }
216}
217
218impl Rule for MD018NoMissingSpaceAtx {
219 fn name(&self) -> &'static str {
220 "MD018"
221 }
222
223 fn description(&self) -> &'static str {
224 "No space after hash in heading"
225 }
226
227 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
228 let mut warnings = Vec::new();
229
230 for (line_num, line_info) in ctx.lines.iter().enumerate() {
232 if line_info.in_html_block
234 || line_info.in_html_comment
235 || line_info.in_mdx_comment
236 || line_info.in_pymdown_block
237 {
238 continue;
239 }
240
241 if let Some(missing) = line_info.atx_missing_space {
242 let line = line_info.content(ctx.content);
243 if let Some((marker, after_marker)) =
244 self.missing_space_split(line, line_info.indent, missing, ctx.flavor)
245 {
246 let hash_end_col = byte_to_char_count(line, line_info.indent + marker.len());
249 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
250 line_num + 1, hash_end_col,
252 0, );
254
255 warnings.push(LintWarning {
256 rule_name: Some(self.name().to_string()),
257 message: format!("No space after {marker} in heading"),
258 line: start_line,
259 column: start_col,
260 end_line,
261 end_column: end_col,
262 severity: Severity::Warning,
263 fix: Some(Fix::new(self.get_line_byte_range(ctx.content, line_num + 1), {
264 let original_indent = &line[..line_info.indent];
266 format!("{original_indent}{marker} {after_marker}")
267 })),
268 });
269 }
270 } else if Self::may_be_unrecorded_missing_space(line_info) {
271 if let Some((hash_end_pos, fixed_line)) =
273 self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor)
274 {
275 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
276 line_num + 1, hash_end_pos + 1, 0, );
280
281 warnings.push(LintWarning {
282 rule_name: Some(self.name().to_string()),
283 message: "No space after hash in heading".to_string(),
284 line: start_line,
285 column: start_col,
286 end_line,
287 end_column: end_col,
288 severity: Severity::Warning,
289 fix: Some(Fix::new(
290 self.get_line_byte_range(ctx.content, line_num + 1),
291 fixed_line,
292 )),
293 });
294 }
295 }
296 }
297
298 Ok(warnings)
299 }
300
301 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
302 let warnings = self.check(ctx)?;
303 let warnings =
304 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
305 let warning_lines: std::collections::HashSet<usize> = warnings.iter().map(|w| w.line).collect();
306
307 let mut lines = Vec::new();
308
309 for (idx, line_info) in ctx.lines.iter().enumerate() {
310 let mut fixed = false;
311
312 if !warning_lines.contains(&(idx + 1)) {
313 lines.push(line_info.content(ctx.content).to_string());
314 continue;
315 }
316
317 if let Some(missing) = line_info.atx_missing_space {
318 let line = line_info.content(ctx.content);
319 if let Some((marker, after_marker)) =
320 self.missing_space_split(line, line_info.indent, missing, ctx.flavor)
321 {
322 let original_indent = &line[..line_info.indent];
324 lines.push(format!("{original_indent}{marker} {after_marker}"));
325 fixed = true;
326 }
327 } else if Self::may_be_unrecorded_missing_space(line_info) {
328 if let Some((_, fixed_line)) = self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor) {
330 lines.push(fixed_line);
331 fixed = true;
332 }
333 }
334
335 if !fixed {
336 lines.push(line_info.content(ctx.content).to_string());
337 }
338 }
339
340 let mut result = lines.join("\n");
344 if ctx.content.ends_with('\n') {
345 result.push('\n');
346 }
347
348 Ok(result)
349 }
350
351 fn category(&self) -> RuleCategory {
353 RuleCategory::Heading
354 }
355
356 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
358 !ctx.likely_has_headings()
360 }
361
362 fn as_any(&self) -> &dyn std::any::Any {
363 self
364 }
365
366 crate::impl_rule_config_methods!(MD018Config);
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372 use crate::lint_context::LintContext;
373
374 #[test]
375 fn test_basic_functionality() {
376 let rule = MD018NoMissingSpaceAtx::new();
377
378 let content = "# Heading 1\n## Heading 2\n### Heading 3";
380 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
381 let result = rule.check(&ctx).unwrap();
382 assert!(result.is_empty());
383
384 let content = "#Heading 1\n## Heading 2\n###Heading 3";
386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
387 let result = rule.check(&ctx).unwrap();
388 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
390 assert_eq!(result[1].line, 3);
391 }
392
393 #[test]
394 fn test_malformed_heading_detection() {
395 let rule = MD018NoMissingSpaceAtx::new();
396
397 assert!(
399 rule.check_atx_heading_line("##Introduction", MarkdownFlavor::Standard)
400 .is_some()
401 );
402 assert!(
403 rule.check_atx_heading_line("###Background", MarkdownFlavor::Standard)
404 .is_some()
405 );
406 assert!(
407 rule.check_atx_heading_line("####Details", MarkdownFlavor::Standard)
408 .is_some()
409 );
410 assert!(
411 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
412 .is_some()
413 );
414 assert!(
415 rule.check_atx_heading_line("######Conclusion", MarkdownFlavor::Standard)
416 .is_some()
417 );
418 assert!(
419 rule.check_atx_heading_line("##Table of Contents", MarkdownFlavor::Standard)
420 .is_some()
421 );
422
423 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!(
428 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
429 .is_none()
430 ); assert!(
432 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
433 .is_none()
434 ); }
436
437 #[test]
438 fn test_malformed_heading_with_context() {
439 let rule = MD018NoMissingSpaceAtx::new();
440
441 let content = r#"# Test Document
443
444##Introduction
445This should be detected.
446
447 ##CodeBlock
448This should NOT be detected (indented code block).
449
450```
451##FencedCodeBlock
452This should NOT be detected (fenced code block).
453```
454
455##Conclusion
456This should be detected.
457"#;
458
459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
460 let result = rule.check(&ctx).unwrap();
461
462 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
464 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&14)); assert!(!detected_lines.contains(&6)); assert!(!detected_lines.contains(&10)); }
469
470 #[test]
471 fn test_malformed_heading_fix() {
472 let rule = MD018NoMissingSpaceAtx::new();
473
474 let content = r#"##Introduction
475This is a test.
476
477###Background
478More content."#;
479
480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
481 let fixed = rule.fix(&ctx).unwrap();
482
483 let expected = r#"## Introduction
484This is a test.
485
486### Background
487More content."#;
488
489 assert_eq!(fixed, expected);
490 }
491
492 #[test]
493 fn test_mixed_proper_and_malformed_headings() {
494 let rule = MD018NoMissingSpaceAtx::new();
495
496 let content = r#"# Proper Heading
497
498##Malformed Heading
499
500## Another Proper Heading
501
502###Another Malformed
503
504#### Proper with space
505"#;
506
507 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
508 let result = rule.check(&ctx).unwrap();
509
510 assert_eq!(result.len(), 2);
512 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
513 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&7)); }
516
517 #[test]
518 fn test_css_selectors_in_html_blocks() {
519 let rule = MD018NoMissingSpaceAtx::new();
520
521 let content = r#"# Proper Heading
524
525<style>
526#slide-1 ol li {
527 margin-top: 0;
528}
529
530#special-slide ol li {
531 margin-top: 2em;
532}
533</style>
534
535## Another Heading
536"#;
537
538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
539 let result = rule.check(&ctx).unwrap();
540
541 assert_eq!(
543 result.len(),
544 0,
545 "CSS selectors in <style> blocks should not be flagged as malformed headings"
546 );
547 }
548
549 #[test]
550 fn test_js_code_in_script_blocks() {
551 let rule = MD018NoMissingSpaceAtx::new();
552
553 let content = r#"# Heading
555
556<script>
557const element = document.querySelector('#main-content');
558#another-comment
559</script>
560
561## Another Heading
562"#;
563
564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565 let result = rule.check(&ctx).unwrap();
566
567 assert_eq!(
569 result.len(),
570 0,
571 "JavaScript code in <script> blocks should not be flagged as malformed headings"
572 );
573 }
574
575 #[test]
576 fn test_all_malformed_headings_detected() {
577 let rule = MD018NoMissingSpaceAtx::new();
578
579 assert!(
584 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
585 .is_some(),
586 "#hello SHOULD be detected as malformed heading"
587 );
588 assert!(
589 rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
590 "#tag SHOULD be detected as malformed heading"
591 );
592 assert!(
593 rule.check_atx_heading_line("#hashtag", MarkdownFlavor::Standard)
594 .is_some(),
595 "#hashtag SHOULD be detected as malformed heading"
596 );
597 assert!(
598 rule.check_atx_heading_line("#javascript", MarkdownFlavor::Standard)
599 .is_some(),
600 "#javascript SHOULD be detected as malformed heading"
601 );
602
603 assert!(
605 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
606 "#123 SHOULD be detected as malformed heading"
607 );
608 assert!(
609 rule.check_atx_heading_line("#12345", MarkdownFlavor::Standard)
610 .is_some(),
611 "#12345 SHOULD be detected as malformed heading"
612 );
613 assert!(
614 rule.check_atx_heading_line("#29039)", MarkdownFlavor::Standard)
615 .is_some(),
616 "#29039) SHOULD be detected as malformed heading"
617 );
618
619 assert!(
621 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
622 .is_some(),
623 "#Summary SHOULD be detected as malformed heading"
624 );
625 assert!(
626 rule.check_atx_heading_line("#Introduction", MarkdownFlavor::Standard)
627 .is_some(),
628 "#Introduction SHOULD be detected as malformed heading"
629 );
630 assert!(
631 rule.check_atx_heading_line("#API", MarkdownFlavor::Standard).is_some(),
632 "#API SHOULD be detected as malformed heading"
633 );
634
635 assert!(
637 rule.check_atx_heading_line("##introduction", MarkdownFlavor::Standard)
638 .is_some(),
639 "##introduction SHOULD be detected as malformed heading"
640 );
641 assert!(
642 rule.check_atx_heading_line("###section", MarkdownFlavor::Standard)
643 .is_some(),
644 "###section SHOULD be detected as malformed heading"
645 );
646 assert!(
647 rule.check_atx_heading_line("###fer", MarkdownFlavor::Standard)
648 .is_some(),
649 "###fer SHOULD be detected as malformed heading"
650 );
651 assert!(
652 rule.check_atx_heading_line("##123", MarkdownFlavor::Standard).is_some(),
653 "##123 SHOULD be detected as malformed heading"
654 );
655 }
656
657 #[test]
658 fn test_patterns_that_should_not_be_flagged() {
659 let rule = MD018NoMissingSpaceAtx::new();
660
661 assert!(rule.check_atx_heading_line("###", MarkdownFlavor::Standard).is_none());
663 assert!(rule.check_atx_heading_line("#", MarkdownFlavor::Standard).is_none());
664
665 assert!(rule.check_atx_heading_line("##a", MarkdownFlavor::Standard).is_none());
667
668 assert!(
670 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
671 .is_none()
672 );
673
674 assert!(
676 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
677 .is_none()
678 );
679
680 assert!(
682 rule.check_atx_heading_line("# Hello", MarkdownFlavor::Standard)
683 .is_none()
684 );
685 assert!(
686 rule.check_atx_heading_line("## World", MarkdownFlavor::Standard)
687 .is_none()
688 );
689 assert!(
690 rule.check_atx_heading_line("### Section", MarkdownFlavor::Standard)
691 .is_none()
692 );
693 }
694
695 #[test]
696 fn test_inline_issue_refs_not_at_line_start() {
697 let rule = MD018NoMissingSpaceAtx::new();
698
699 assert!(
704 rule.check_atx_heading_line("See issue #123", MarkdownFlavor::Standard)
705 .is_none()
706 );
707 assert!(
708 rule.check_atx_heading_line("Check #trending on Twitter", MarkdownFlavor::Standard)
709 .is_none()
710 );
711 assert!(
712 rule.check_atx_heading_line("- fix: issue #29039", MarkdownFlavor::Standard)
713 .is_none()
714 );
715 }
716
717 #[test]
718 fn test_lowercase_patterns_full_check() {
719 let rule = MD018NoMissingSpaceAtx::new();
721
722 let content = "#hello\n\n#world\n\n#tag";
723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
724 let result = rule.check(&ctx).unwrap();
725
726 assert_eq!(result.len(), 3, "All three lowercase patterns should be flagged");
727 assert_eq!(result[0].line, 1);
728 assert_eq!(result[1].line, 3);
729 assert_eq!(result[2].line, 5);
730 }
731
732 #[test]
733 fn test_numeric_patterns_full_check() {
734 let rule = MD018NoMissingSpaceAtx::new();
736
737 let content = "#123\n\n#456\n\n#29039";
738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
739 let result = rule.check(&ctx).unwrap();
740
741 assert_eq!(result.len(), 3, "All three numeric patterns should be flagged");
742 }
743
744 #[test]
745 fn test_fix_lowercase_patterns() {
746 let rule = MD018NoMissingSpaceAtx::new();
748
749 let content = "#hello\nSome text.\n\n#world";
750 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
751 let fixed = rule.fix(&ctx).unwrap();
752
753 let expected = "# hello\nSome text.\n\n# world";
754 assert_eq!(fixed, expected);
755 }
756
757 #[test]
758 fn test_fix_numeric_patterns() {
759 let rule = MD018NoMissingSpaceAtx::new();
761
762 let content = "#123\nContent.\n\n##456";
763 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
764 let fixed = rule.fix(&ctx).unwrap();
765
766 let expected = "# 123\nContent.\n\n## 456";
767 assert_eq!(fixed, expected);
768 }
769
770 #[test]
771 fn test_indented_malformed_headings() {
772 let rule = MD018NoMissingSpaceAtx::new();
776
777 assert!(
779 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
780 .is_none(),
781 "1-space indented #hello should be skipped"
782 );
783 assert!(
784 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
785 .is_none(),
786 "2-space indented #hello should be skipped"
787 );
788 assert!(
789 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
790 .is_none(),
791 "3-space indented #hello should be skipped"
792 );
793
794 assert!(
799 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
800 .is_some(),
801 "Non-indented #hello should be detected"
802 );
803 }
804
805 #[test]
806 fn test_tab_after_hash_is_valid() {
807 let rule = MD018NoMissingSpaceAtx::new();
809
810 assert!(
811 rule.check_atx_heading_line("#\tHello", MarkdownFlavor::Standard)
812 .is_none(),
813 "Tab after # should be valid"
814 );
815 assert!(
816 rule.check_atx_heading_line("##\tWorld", MarkdownFlavor::Standard)
817 .is_none(),
818 "Tab after ## should be valid"
819 );
820 }
821
822 #[test]
823 fn test_mixed_case_patterns() {
824 let rule = MD018NoMissingSpaceAtx::new();
825
826 assert!(
828 rule.check_atx_heading_line("#hELLO", MarkdownFlavor::Standard)
829 .is_some()
830 );
831 assert!(
832 rule.check_atx_heading_line("#Hello", MarkdownFlavor::Standard)
833 .is_some()
834 );
835 assert!(
836 rule.check_atx_heading_line("#HELLO", MarkdownFlavor::Standard)
837 .is_some()
838 );
839 assert!(
840 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
841 .is_some()
842 );
843 }
844
845 #[test]
846 fn test_unicode_lowercase() {
847 let rule = MD018NoMissingSpaceAtx::new();
848
849 assert!(
851 rule.check_atx_heading_line("#über", MarkdownFlavor::Standard).is_some(),
852 "Unicode lowercase #über should be detected"
853 );
854 assert!(
855 rule.check_atx_heading_line("#café", MarkdownFlavor::Standard).is_some(),
856 "Unicode lowercase #café should be detected"
857 );
858 assert!(
859 rule.check_atx_heading_line("#日本語", MarkdownFlavor::Standard)
860 .is_some(),
861 "Japanese #日本語 should be detected"
862 );
863 }
864
865 #[test]
866 fn test_matches_markdownlint_behavior() {
867 let rule = MD018NoMissingSpaceAtx::new();
869
870 let content = r#"#hello
871
872## world
873
874###fer
875
876#123
877
878#Tag
879"#;
880
881 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
882 let result = rule.check(&ctx).unwrap();
883
884 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
887
888 assert!(flagged_lines.contains(&1), "#hello should be flagged");
889 assert!(!flagged_lines.contains(&3), "## world should NOT be flagged");
890 assert!(flagged_lines.contains(&5), "###fer should be flagged");
891 assert!(flagged_lines.contains(&7), "#123 should be flagged");
892 assert!(flagged_lines.contains(&9), "#Tag should be flagged");
893
894 assert_eq!(result.len(), 4, "Should have exactly 4 warnings");
895 }
896
897 #[test]
898 fn test_skip_frontmatter_yaml_comments() {
899 let rule = MD018NoMissingSpaceAtx::new();
901
902 let content = r#"---
903#reviewers:
904#- sig-api-machinery
905#another_comment: value
906title: Test Document
907---
908
909# Valid heading
910
911#invalid heading without space
912"#;
913
914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
915 let result = rule.check(&ctx).unwrap();
916
917 assert_eq!(
920 result.len(),
921 1,
922 "Should only flag the malformed heading outside frontmatter"
923 );
924 assert_eq!(result[0].line, 10, "Should flag line 10");
925 }
926
927 #[test]
928 fn test_skip_html_comments() {
929 let rule = MD018NoMissingSpaceAtx::new();
932
933 let content = r#"# Real Heading
934
935Some text.
936
937<!--
938```
939#%% Cell marker
940import matplotlib.pyplot as plt
941
942#%% Another cell
943data = [1, 2, 3]
944```
945-->
946
947More content.
948"#;
949
950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
951 let result = rule.check(&ctx).unwrap();
952
953 assert!(
955 result.is_empty(),
956 "Should not flag content inside HTML comments, found {} issues",
957 result.len()
958 );
959 }
960
961 #[test]
962 fn test_mkdocs_magiclink_skips_numeric_refs() {
963 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
965 magiclink: true,
966 ..Default::default()
967 });
968
969 assert!(
971 rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_none(),
972 "#10 should be skipped with magiclink config (MagicLink issue ref)"
973 );
974 assert!(
975 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_none(),
976 "#123 should be skipped with magiclink config (MagicLink issue ref)"
977 );
978 assert!(
979 rule.check_atx_heading_line("#10 discusses the issue", MarkdownFlavor::Standard)
980 .is_none(),
981 "#10 followed by text should be skipped with magiclink config"
982 );
983 assert!(
984 rule.check_atx_heading_line("#37.", MarkdownFlavor::Standard).is_none(),
985 "#37 followed by punctuation should be skipped with magiclink config"
986 );
987 }
988
989 #[test]
990 fn test_mkdocs_magiclink_still_flags_non_numeric() {
991 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
993 magiclink: true,
994 ..Default::default()
995 });
996
997 assert!(
999 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
1000 .is_some(),
1001 "#Summary should still be flagged with magiclink config"
1002 );
1003 assert!(
1004 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
1005 .is_some(),
1006 "#hello should still be flagged with magiclink config"
1007 );
1008 assert!(
1009 rule.check_atx_heading_line("#10abc", MarkdownFlavor::Standard)
1010 .is_some(),
1011 "#10abc (mixed) should still be flagged with magiclink config"
1012 );
1013 }
1014
1015 #[test]
1016 fn test_mkdocs_magiclink_only_single_hash() {
1017 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1019 magiclink: true,
1020 ..Default::default()
1021 });
1022
1023 assert!(
1024 rule.check_atx_heading_line("##10", MarkdownFlavor::Standard).is_some(),
1025 "##10 should be flagged with magiclink config (only single # is MagicLink)"
1026 );
1027 assert!(
1028 rule.check_atx_heading_line("###123", MarkdownFlavor::Standard)
1029 .is_some(),
1030 "###123 should be flagged with magiclink config"
1031 );
1032 }
1033
1034 #[test]
1035 fn test_standard_flavor_flags_numeric_refs() {
1036 let rule = MD018NoMissingSpaceAtx::new();
1038
1039 assert!(
1040 rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_some(),
1041 "#10 should be flagged in Standard flavor"
1042 );
1043 assert!(
1044 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
1045 "#123 should be flagged in Standard flavor"
1046 );
1047 }
1048
1049 #[test]
1050 fn test_mkdocs_magiclink_full_check() {
1051 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1053 magiclink: true,
1054 ..Default::default()
1055 });
1056
1057 let content = r#"# PRs that are helpful for context
1058
1059#10 discusses the philosophy behind the project, and #37 shows a good example.
1060
1061#Summary
1062
1063##Introduction
1064"#;
1065
1066 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1068 let result = rule.check(&ctx).unwrap();
1069
1070 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1071 assert!(
1072 !flagged_lines.contains(&3),
1073 "#10 should NOT be flagged with magiclink config"
1074 );
1075 assert!(
1076 flagged_lines.contains(&5),
1077 "#Summary SHOULD be flagged with magiclink config"
1078 );
1079 assert!(
1080 flagged_lines.contains(&7),
1081 "##Introduction SHOULD be flagged with magiclink config"
1082 );
1083 }
1084
1085 #[test]
1086 fn test_mkdocs_magiclink_fix_exact_output() {
1087 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1089 magiclink: true,
1090 ..Default::default()
1091 });
1092
1093 let content = "#10 discusses the issue.\n\n#Summary";
1094 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1095 let fixed = rule.fix(&ctx).unwrap();
1096
1097 let expected = "#10 discusses the issue.\n\n# Summary";
1099 assert_eq!(
1100 fixed, expected,
1101 "magiclink config fix should preserve MagicLink refs and fix non-numeric headings"
1102 );
1103 }
1104
1105 #[test]
1106 fn test_mkdocs_magiclink_edge_cases() {
1107 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1109 magiclink: true,
1110 ..Default::default()
1111 });
1112
1113 let valid_refs = [
1116 "#10", "#999999", "#10 text after", "#10\ttext after", "#10.", "#10,", "#10!", "#10?", "#10)", "#10]", "#10;", "#10:", ];
1129
1130 for ref_str in valid_refs {
1131 assert!(
1132 rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_none(),
1133 "{ref_str:?} should be skipped as MagicLink ref with magiclink config"
1134 );
1135 }
1136
1137 let invalid_refs = [
1139 "#10abc", "#10a", "#abc10", "#10ABC", "#Summary", "#hello", ];
1146
1147 for ref_str in invalid_refs {
1148 assert!(
1149 rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_some(),
1150 "{ref_str:?} should be flagged with magiclink config (not a valid MagicLink ref)"
1151 );
1152 }
1153 }
1154
1155 #[test]
1156 fn test_mkdocs_magiclink_hyphenated_continuation() {
1157 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1160 magiclink: true,
1161 ..Default::default()
1162 });
1163
1164 assert!(
1169 rule.check_atx_heading_line("#10-", MarkdownFlavor::Standard).is_none(),
1170 "#10- should be skipped with magiclink config (hyphen is non-alphanumeric terminator)"
1171 );
1172 }
1173
1174 #[test]
1175 fn test_mkdocs_magiclink_standalone_number() {
1176 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1178 magiclink: true,
1179 ..Default::default()
1180 });
1181
1182 let content = "See issue:\n\n#10\n\nFor details.";
1183 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1184 let result = rule.check(&ctx).unwrap();
1185
1186 assert!(
1188 result.is_empty(),
1189 "Standalone #10 should not be flagged with magiclink config"
1190 );
1191
1192 let fixed = rule.fix(&ctx).unwrap();
1194 assert_eq!(fixed, content, "fix() should not modify standalone MagicLink ref");
1195 }
1196
1197 #[test]
1198 fn test_standard_flavor_flags_all_numeric() {
1199 let rule = MD018NoMissingSpaceAtx::new();
1202
1203 let numeric_patterns = ["#10", "#123", "#999999", "#10 text"];
1204
1205 for pattern in numeric_patterns {
1206 assert!(
1207 rule.check_atx_heading_line(pattern, MarkdownFlavor::Standard).is_some(),
1208 "{pattern:?} should be flagged in Standard flavor"
1209 );
1210 }
1211
1212 assert!(
1214 rule.check_atx_heading_line("#1", MarkdownFlavor::Standard).is_none(),
1215 "#1 should be skipped (content too short, existing behavior)"
1216 );
1217 }
1218
1219 #[test]
1220 fn test_mkdocs_vs_standard_fix_comparison() {
1221 let content = "#10 is an issue\n#Summary";
1223 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1224
1225 let rule_magiclink = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1227 magiclink: true,
1228 ..Default::default()
1229 });
1230 let fixed_magiclink = rule_magiclink.fix(&ctx).unwrap();
1231 assert_eq!(fixed_magiclink, "#10 is an issue\n# Summary");
1232
1233 let rule_default = MD018NoMissingSpaceAtx::new();
1235 let fixed_default = rule_default.fix(&ctx).unwrap();
1236 assert_eq!(fixed_default, "# 10 is an issue\n# Summary");
1237 }
1238
1239 #[test]
1242 fn test_tags_config_standard_flavor() {
1243 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1245 magiclink: false,
1246 tags: Some(true),
1247 });
1248
1249 let content = "#tag\n\n#project/active\n\n##Introduction";
1250 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1251 let result = rule.check(&ctx).unwrap();
1252
1253 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1254 assert!(!flagged_lines.contains(&1), "#tag should be skipped with tags = true");
1255 assert!(
1256 !flagged_lines.contains(&3),
1257 "#project/active should be skipped with tags = true"
1258 );
1259 assert!(flagged_lines.contains(&5), "##Introduction should still be flagged");
1260 }
1261
1262 #[test]
1263 fn test_tags_config_fix_standard_flavor() {
1264 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1265 magiclink: false,
1266 tags: Some(true),
1267 });
1268
1269 let content = "#tag\n\n##Introduction";
1270 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1271 let fixed = rule.fix(&ctx).unwrap();
1272 assert_eq!(fixed, "#tag\n\n## Introduction");
1273 }
1274
1275 #[test]
1276 fn test_tags_config_disabled_obsidian_flavor() {
1277 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1279 magiclink: false,
1280 tags: Some(false),
1281 });
1282
1283 let content = "#tag\n\n#project/active";
1284 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1285 let result = rule.check(&ctx).unwrap();
1286
1287 assert_eq!(
1288 result.len(),
1289 2,
1290 "tags = false should flag tag patterns even in Obsidian"
1291 );
1292 }
1293
1294 #[test]
1295 fn test_tags_config_default_follows_flavor() {
1296 let rule = MD018NoMissingSpaceAtx::new(); let content = "#tag";
1301 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1302 let result = rule.check(&ctx).unwrap();
1303 assert!(!result.is_empty(), "Default standard should flag #tag");
1304
1305 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1307 let result = rule.check(&ctx).unwrap();
1308 assert!(result.is_empty(), "Default Obsidian should skip #tag");
1309 }
1310
1311 #[test]
1314 fn test_obsidian_tag_skips_simple_tags() {
1315 let rule = MD018NoMissingSpaceAtx::new();
1317
1318 assert!(
1320 rule.check_atx_heading_line("#hey", MarkdownFlavor::Obsidian).is_none(),
1321 "#hey should be skipped in Obsidian flavor (tag syntax)"
1322 );
1323 assert!(
1324 rule.check_atx_heading_line("#tag", MarkdownFlavor::Obsidian).is_none(),
1325 "#tag should be skipped in Obsidian flavor"
1326 );
1327 assert!(
1328 rule.check_atx_heading_line("#hello", MarkdownFlavor::Obsidian)
1329 .is_none(),
1330 "#hello should be skipped in Obsidian flavor"
1331 );
1332 assert!(
1333 rule.check_atx_heading_line("#myTag", MarkdownFlavor::Obsidian)
1334 .is_none(),
1335 "#myTag should be skipped in Obsidian flavor"
1336 );
1337 }
1338
1339 #[test]
1340 fn test_obsidian_tag_skips_complex_tags() {
1341 let rule = MD018NoMissingSpaceAtx::new();
1343
1344 assert!(
1346 rule.check_atx_heading_line("#project/active", MarkdownFlavor::Obsidian)
1347 .is_none(),
1348 "#project/active should be skipped in Obsidian flavor (nested tag)"
1349 );
1350 assert!(
1351 rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1352 .is_none(),
1353 "#my-tag should be skipped in Obsidian flavor"
1354 );
1355 assert!(
1356 rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1357 .is_none(),
1358 "#my_tag should be skipped in Obsidian flavor"
1359 );
1360 assert!(
1361 rule.check_atx_heading_line("#tag2023", MarkdownFlavor::Obsidian)
1362 .is_none(),
1363 "#tag2023 should be skipped in Obsidian flavor"
1364 );
1365 assert!(
1366 rule.check_atx_heading_line("#project/sub/task", MarkdownFlavor::Obsidian)
1367 .is_none(),
1368 "#project/sub/task should be skipped in Obsidian flavor"
1369 );
1370 }
1371
1372 #[test]
1373 fn test_obsidian_tag_with_trailing_content() {
1374 let rule = MD018NoMissingSpaceAtx::new();
1376
1377 assert!(
1378 rule.check_atx_heading_line("#hey ", MarkdownFlavor::Obsidian).is_none(),
1379 "#hey followed by space should be skipped"
1380 );
1381 assert!(
1382 rule.check_atx_heading_line("#tag some text", MarkdownFlavor::Obsidian)
1383 .is_none(),
1384 "#tag followed by text should be skipped"
1385 );
1386 }
1387
1388 #[test]
1389 fn test_obsidian_tag_still_flags_multi_hash() {
1390 let rule = MD018NoMissingSpaceAtx::new();
1392
1393 assert!(
1394 rule.check_atx_heading_line("##tag", MarkdownFlavor::Obsidian).is_some(),
1395 "##tag should be flagged in Obsidian flavor (only single # is a tag)"
1396 );
1397 assert!(
1398 rule.check_atx_heading_line("###hello", MarkdownFlavor::Obsidian)
1399 .is_some(),
1400 "###hello should be flagged in Obsidian flavor"
1401 );
1402 }
1403
1404 #[test]
1405 fn test_obsidian_tag_numeric_still_flagged() {
1406 let rule = MD018NoMissingSpaceAtx::new();
1409
1410 assert!(
1411 rule.check_atx_heading_line("#123", MarkdownFlavor::Obsidian).is_some(),
1412 "#123 should be flagged in Obsidian flavor (no non-numerical character)"
1413 );
1414 assert!(
1415 rule.check_atx_heading_line("#10", MarkdownFlavor::Obsidian).is_some(),
1416 "#10 should be flagged in Obsidian flavor"
1417 );
1418 }
1419
1420 #[test]
1421 fn test_obsidian_flavor_full_check() {
1422 let rule = MD018NoMissingSpaceAtx::new();
1424
1425 let content = r#"# Real Heading
1426
1427#hey this is a tag
1428
1429#project/active also a tag
1430
1431##Introduction
1432
1433#123
1434"#;
1435
1436 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1438 let result = rule.check(&ctx).unwrap();
1439
1440 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1441 assert!(
1442 !flagged_lines.contains(&3),
1443 "#hey should NOT be flagged in Obsidian flavor"
1444 );
1445 assert!(
1446 !flagged_lines.contains(&5),
1447 "#project/active should NOT be flagged in Obsidian flavor"
1448 );
1449 assert!(
1450 flagged_lines.contains(&7),
1451 "##Introduction SHOULD be flagged in Obsidian flavor"
1452 );
1453 assert!(flagged_lines.contains(&9), "#123 SHOULD be flagged in Obsidian flavor");
1454 }
1455
1456 #[test]
1457 fn test_obsidian_flavor_fix_exact_output() {
1458 let rule = MD018NoMissingSpaceAtx::new();
1460
1461 let content = "#hey is a tag.\n\n##Introduction";
1464 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1465 let fixed = rule.fix(&ctx).unwrap();
1466
1467 let expected = "#hey is a tag.\n\n## Introduction";
1469 assert_eq!(
1470 fixed, expected,
1471 "Obsidian fix should preserve tags and fix multi-hash headings"
1472 );
1473 }
1474
1475 #[test]
1476 fn test_standard_flavor_flags_obsidian_tags() {
1477 let rule = MD018NoMissingSpaceAtx::new();
1479
1480 assert!(
1481 rule.check_atx_heading_line("#hey", MarkdownFlavor::Standard).is_some(),
1482 "#hey should be flagged in Standard flavor"
1483 );
1484 assert!(
1485 rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
1486 "#tag should be flagged in Standard flavor"
1487 );
1488 assert!(
1489 rule.check_atx_heading_line("#project/active", MarkdownFlavor::Standard)
1490 .is_some(),
1491 "#project/active should be flagged in Standard flavor"
1492 );
1493 }
1494
1495 #[test]
1496 fn test_obsidian_vs_standard_fix_comparison() {
1497 let rule = MD018NoMissingSpaceAtx::new();
1499
1500 let content = "#hey tag\n##Introduction";
1504
1505 let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1507 let fixed_obsidian = rule.fix(&ctx_obsidian).unwrap();
1508 assert_eq!(fixed_obsidian, "#hey tag\n## Introduction");
1509
1510 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1512 let fixed_standard = rule.fix(&ctx_standard).unwrap();
1513 assert_eq!(fixed_standard, "# hey tag\n## Introduction");
1514 }
1515
1516 #[test]
1517 fn test_obsidian_tag_edge_cases() {
1518 let rule = MD018NoMissingSpaceAtx::new();
1520
1521 let valid_tags = [
1523 "#a", "#tag", "#Tag", "#TAG", "#my-tag", "#my_tag", "#tag123", "#a1", "#日本語", "#über", ];
1534
1535 for tag in valid_tags {
1536 assert!(
1537 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1538 "{tag:?} should be skipped in Obsidian flavor (valid tag)"
1539 );
1540 }
1541
1542 let invalid_tags = ["#123", "#1984", "#37.", "#42,"];
1545
1546 for tag in invalid_tags {
1547 assert!(
1548 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_some(),
1549 "{tag:?} should be flagged in Obsidian flavor (no non-numerical character)"
1550 );
1551 }
1552 }
1553
1554 #[test]
1555 fn test_obsidian_tag_alone_on_line() {
1556 let rule = MD018NoMissingSpaceAtx::new();
1558
1559 let content = "Some text\n\n#todo\n\nMore text.";
1560 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1561 let result = rule.check(&ctx).unwrap();
1562
1563 assert!(
1565 result.is_empty(),
1566 "Standalone #todo should not be flagged in Obsidian flavor"
1567 );
1568
1569 let fixed = rule.fix(&ctx).unwrap();
1571 assert_eq!(fixed, content, "fix() should not modify standalone Obsidian tag");
1572 }
1573
1574 #[test]
1575 fn test_obsidian_deeply_nested_tags() {
1576 let rule = MD018NoMissingSpaceAtx::new();
1578
1579 let nested_tags = [
1580 "#a/b",
1581 "#a/b/c",
1582 "#project/2023/q1/task",
1583 "#work/meetings/weekly",
1584 "#life/health/exercise/running",
1585 ];
1586
1587 for tag in nested_tags {
1588 assert!(
1589 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1590 "{tag:?} should be skipped in Obsidian flavor (nested tag)"
1591 );
1592 }
1593 }
1594
1595 #[test]
1596 fn test_obsidian_unicode_tags() {
1597 let rule = MD018NoMissingSpaceAtx::new();
1599
1600 let unicode_tags = [
1601 "#日本語", "#中文", "#한국어", "#über", "#café", "#ñoño", "#Москва", "#αβγ", ];
1610
1611 for tag in unicode_tags {
1612 assert!(
1613 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1614 "{tag:?} should be skipped in Obsidian flavor (Unicode tag)"
1615 );
1616 }
1617 }
1618
1619 #[test]
1620 fn test_obsidian_tags_with_special_endings() {
1621 let rule = MD018NoMissingSpaceAtx::new();
1623
1624 assert!(
1626 rule.check_atx_heading_line("#tag followed by text", MarkdownFlavor::Obsidian)
1627 .is_none(),
1628 "#tag followed by text should be skipped"
1629 );
1630
1631 let content = "#todo";
1633 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1634 let result = rule.check(&ctx).unwrap();
1635 assert!(result.is_empty(), "#todo at end of line should be skipped");
1636 }
1637
1638 #[test]
1639 fn test_obsidian_combined_with_other_skip_contexts() {
1640 let rule = MD018NoMissingSpaceAtx::new();
1642
1643 let content = "```\n#todo\n```";
1645 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1646 let result = rule.check(&ctx).unwrap();
1647 assert!(result.is_empty(), "Tag in code block should be skipped");
1648
1649 let content = "<!-- #todo -->";
1651 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1652 let result = rule.check(&ctx).unwrap();
1653 assert!(result.is_empty(), "Tag in HTML comment should be skipped");
1654 }
1655
1656 #[test]
1657 fn test_obsidian_boundary_cases() {
1658 let rule = MD018NoMissingSpaceAtx::new();
1660
1661 assert!(
1665 rule.check_atx_heading_line("#ab", MarkdownFlavor::Obsidian).is_none(),
1666 "#ab should be skipped in Obsidian flavor"
1667 );
1668
1669 assert!(
1671 rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1672 .is_none(),
1673 "#my_tag should be skipped"
1674 );
1675
1676 assert!(
1678 rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1679 .is_none(),
1680 "#my-tag should be skipped"
1681 );
1682
1683 assert!(
1685 rule.check_atx_heading_line("#MyTag", MarkdownFlavor::Obsidian)
1686 .is_none(),
1687 "#MyTag should be skipped"
1688 );
1689
1690 assert!(
1692 rule.check_atx_heading_line("#TODO", MarkdownFlavor::Obsidian).is_none(),
1693 "#TODO should be skipped in Obsidian flavor"
1694 );
1695 }
1696
1697 #[test]
1698 fn test_obsidian_tag_may_start_with_a_digit() {
1699 let rule = MD018NoMissingSpaceAtx::new();
1702
1703 let tags = [
1704 "#3d_printing", "#1tag", "#2023-project", "#100DaysOfCode", "#1on1", "#5S", "#3/4", "#1_2", "#3🔥", "#3\u{FE0F}\u{20E3}", ];
1715
1716 for tag in tags {
1717 assert!(
1718 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1719 "{tag:?} contains a non-numerical character and should be skipped as a tag"
1720 );
1721 }
1722 }
1723
1724 #[test]
1725 fn test_numeric_references_are_not_tags() {
1726 let rule = MD018NoMissingSpaceAtx::new();
1730
1731 let not_tags = [
1732 "#1984", "#123", "#10", "#37.", "#42,", "#42)", "#404 Not Found", "#10 discusses the issue", ];
1741
1742 for line in not_tags {
1743 assert!(
1744 rule.check_atx_heading_line(line, MarkdownFlavor::Obsidian).is_some(),
1745 "{line:?} has no non-numerical tag character and should stay flagged"
1746 );
1747 }
1748 }
1749
1750 #[test]
1751 fn test_digit_leading_tag_survives_check_and_fix() {
1752 let obsidian = MD018NoMissingSpaceAtx::new();
1755 let standard = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1756 magiclink: false,
1757 tags: Some(true),
1758 });
1759
1760 let content = "# Header\n\n#3d_printing\n\n##Heading\n";
1763
1764 for (rule, flavor, label) in [
1765 (&obsidian, MarkdownFlavor::Obsidian, "obsidian flavor"),
1766 (&standard, MarkdownFlavor::Standard, "tags = true"),
1767 ] {
1768 let ctx = LintContext::new(content, flavor, None);
1769 let flagged: Vec<usize> = rule.check(&ctx).unwrap().iter().map(|w| w.line).collect();
1770 assert_eq!(
1771 flagged,
1772 vec![5],
1773 "{label}: only ##Heading should be flagged, got {flagged:?}"
1774 );
1775
1776 let fixed = rule.fix(&ctx).unwrap();
1777 assert_eq!(
1778 fixed, "# Header\n\n#3d_printing\n\n## Heading\n",
1779 "{label}: fix must leave the tag alone and still fix the heading"
1780 );
1781 }
1782 }
1783
1784 #[test]
1785 fn test_digit_leading_tag_is_still_flagged_without_tags_mode() {
1786 let rule = MD018NoMissingSpaceAtx::new();
1789
1790 assert!(
1791 rule.check_atx_heading_line("#3d_printing", MarkdownFlavor::Standard)
1792 .is_some(),
1793 "#3d_printing should be flagged in Standard flavor (tags disabled)"
1794 );
1795 }
1796}