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");
342 if ctx.content.ends_with('\n') && !result.ends_with('\n') {
343 result.push('\n');
344 }
345
346 Ok(result)
347 }
348
349 fn category(&self) -> RuleCategory {
351 RuleCategory::Heading
352 }
353
354 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
356 !ctx.likely_has_headings()
358 }
359
360 fn as_any(&self) -> &dyn std::any::Any {
361 self
362 }
363
364 crate::impl_rule_config_methods!(MD018Config);
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use crate::lint_context::LintContext;
371
372 #[test]
373 fn test_basic_functionality() {
374 let rule = MD018NoMissingSpaceAtx::new();
375
376 let content = "# Heading 1\n## Heading 2\n### Heading 3";
378 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
379 let result = rule.check(&ctx).unwrap();
380 assert!(result.is_empty());
381
382 let content = "#Heading 1\n## Heading 2\n###Heading 3";
384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
385 let result = rule.check(&ctx).unwrap();
386 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
388 assert_eq!(result[1].line, 3);
389 }
390
391 #[test]
392 fn test_malformed_heading_detection() {
393 let rule = MD018NoMissingSpaceAtx::new();
394
395 assert!(
397 rule.check_atx_heading_line("##Introduction", MarkdownFlavor::Standard)
398 .is_some()
399 );
400 assert!(
401 rule.check_atx_heading_line("###Background", MarkdownFlavor::Standard)
402 .is_some()
403 );
404 assert!(
405 rule.check_atx_heading_line("####Details", MarkdownFlavor::Standard)
406 .is_some()
407 );
408 assert!(
409 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
410 .is_some()
411 );
412 assert!(
413 rule.check_atx_heading_line("######Conclusion", MarkdownFlavor::Standard)
414 .is_some()
415 );
416 assert!(
417 rule.check_atx_heading_line("##Table of Contents", MarkdownFlavor::Standard)
418 .is_some()
419 );
420
421 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!(
426 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
427 .is_none()
428 ); assert!(
430 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
431 .is_none()
432 ); }
434
435 #[test]
436 fn test_malformed_heading_with_context() {
437 let rule = MD018NoMissingSpaceAtx::new();
438
439 let content = r#"# Test Document
441
442##Introduction
443This should be detected.
444
445 ##CodeBlock
446This should NOT be detected (indented code block).
447
448```
449##FencedCodeBlock
450This should NOT be detected (fenced code block).
451```
452
453##Conclusion
454This should be detected.
455"#;
456
457 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
458 let result = rule.check(&ctx).unwrap();
459
460 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
462 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&14)); assert!(!detected_lines.contains(&6)); assert!(!detected_lines.contains(&10)); }
467
468 #[test]
469 fn test_malformed_heading_fix() {
470 let rule = MD018NoMissingSpaceAtx::new();
471
472 let content = r#"##Introduction
473This is a test.
474
475###Background
476More content."#;
477
478 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
479 let fixed = rule.fix(&ctx).unwrap();
480
481 let expected = r#"## Introduction
482This is a test.
483
484### Background
485More content."#;
486
487 assert_eq!(fixed, expected);
488 }
489
490 #[test]
491 fn test_mixed_proper_and_malformed_headings() {
492 let rule = MD018NoMissingSpaceAtx::new();
493
494 let content = r#"# Proper Heading
495
496##Malformed Heading
497
498## Another Proper Heading
499
500###Another Malformed
501
502#### Proper with space
503"#;
504
505 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
506 let result = rule.check(&ctx).unwrap();
507
508 assert_eq!(result.len(), 2);
510 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
511 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&7)); }
514
515 #[test]
516 fn test_css_selectors_in_html_blocks() {
517 let rule = MD018NoMissingSpaceAtx::new();
518
519 let content = r#"# Proper Heading
522
523<style>
524#slide-1 ol li {
525 margin-top: 0;
526}
527
528#special-slide ol li {
529 margin-top: 2em;
530}
531</style>
532
533## Another Heading
534"#;
535
536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
537 let result = rule.check(&ctx).unwrap();
538
539 assert_eq!(
541 result.len(),
542 0,
543 "CSS selectors in <style> blocks should not be flagged as malformed headings"
544 );
545 }
546
547 #[test]
548 fn test_js_code_in_script_blocks() {
549 let rule = MD018NoMissingSpaceAtx::new();
550
551 let content = r#"# Heading
553
554<script>
555const element = document.querySelector('#main-content');
556#another-comment
557</script>
558
559## Another Heading
560"#;
561
562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
563 let result = rule.check(&ctx).unwrap();
564
565 assert_eq!(
567 result.len(),
568 0,
569 "JavaScript code in <script> blocks should not be flagged as malformed headings"
570 );
571 }
572
573 #[test]
574 fn test_all_malformed_headings_detected() {
575 let rule = MD018NoMissingSpaceAtx::new();
576
577 assert!(
582 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
583 .is_some(),
584 "#hello SHOULD be detected as malformed heading"
585 );
586 assert!(
587 rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
588 "#tag SHOULD be detected as malformed heading"
589 );
590 assert!(
591 rule.check_atx_heading_line("#hashtag", MarkdownFlavor::Standard)
592 .is_some(),
593 "#hashtag SHOULD be detected as malformed heading"
594 );
595 assert!(
596 rule.check_atx_heading_line("#javascript", MarkdownFlavor::Standard)
597 .is_some(),
598 "#javascript SHOULD be detected as malformed heading"
599 );
600
601 assert!(
603 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
604 "#123 SHOULD be detected as malformed heading"
605 );
606 assert!(
607 rule.check_atx_heading_line("#12345", MarkdownFlavor::Standard)
608 .is_some(),
609 "#12345 SHOULD be detected as malformed heading"
610 );
611 assert!(
612 rule.check_atx_heading_line("#29039)", MarkdownFlavor::Standard)
613 .is_some(),
614 "#29039) SHOULD be detected as malformed heading"
615 );
616
617 assert!(
619 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
620 .is_some(),
621 "#Summary SHOULD be detected as malformed heading"
622 );
623 assert!(
624 rule.check_atx_heading_line("#Introduction", MarkdownFlavor::Standard)
625 .is_some(),
626 "#Introduction SHOULD be detected as malformed heading"
627 );
628 assert!(
629 rule.check_atx_heading_line("#API", MarkdownFlavor::Standard).is_some(),
630 "#API SHOULD be detected as malformed heading"
631 );
632
633 assert!(
635 rule.check_atx_heading_line("##introduction", MarkdownFlavor::Standard)
636 .is_some(),
637 "##introduction SHOULD be detected as malformed heading"
638 );
639 assert!(
640 rule.check_atx_heading_line("###section", MarkdownFlavor::Standard)
641 .is_some(),
642 "###section SHOULD be detected as malformed heading"
643 );
644 assert!(
645 rule.check_atx_heading_line("###fer", MarkdownFlavor::Standard)
646 .is_some(),
647 "###fer SHOULD be detected as malformed heading"
648 );
649 assert!(
650 rule.check_atx_heading_line("##123", MarkdownFlavor::Standard).is_some(),
651 "##123 SHOULD be detected as malformed heading"
652 );
653 }
654
655 #[test]
656 fn test_patterns_that_should_not_be_flagged() {
657 let rule = MD018NoMissingSpaceAtx::new();
658
659 assert!(rule.check_atx_heading_line("###", MarkdownFlavor::Standard).is_none());
661 assert!(rule.check_atx_heading_line("#", MarkdownFlavor::Standard).is_none());
662
663 assert!(rule.check_atx_heading_line("##a", MarkdownFlavor::Standard).is_none());
665
666 assert!(
668 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
669 .is_none()
670 );
671
672 assert!(
674 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
675 .is_none()
676 );
677
678 assert!(
680 rule.check_atx_heading_line("# Hello", MarkdownFlavor::Standard)
681 .is_none()
682 );
683 assert!(
684 rule.check_atx_heading_line("## World", MarkdownFlavor::Standard)
685 .is_none()
686 );
687 assert!(
688 rule.check_atx_heading_line("### Section", MarkdownFlavor::Standard)
689 .is_none()
690 );
691 }
692
693 #[test]
694 fn test_inline_issue_refs_not_at_line_start() {
695 let rule = MD018NoMissingSpaceAtx::new();
696
697 assert!(
702 rule.check_atx_heading_line("See issue #123", MarkdownFlavor::Standard)
703 .is_none()
704 );
705 assert!(
706 rule.check_atx_heading_line("Check #trending on Twitter", MarkdownFlavor::Standard)
707 .is_none()
708 );
709 assert!(
710 rule.check_atx_heading_line("- fix: issue #29039", MarkdownFlavor::Standard)
711 .is_none()
712 );
713 }
714
715 #[test]
716 fn test_lowercase_patterns_full_check() {
717 let rule = MD018NoMissingSpaceAtx::new();
719
720 let content = "#hello\n\n#world\n\n#tag";
721 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
722 let result = rule.check(&ctx).unwrap();
723
724 assert_eq!(result.len(), 3, "All three lowercase patterns should be flagged");
725 assert_eq!(result[0].line, 1);
726 assert_eq!(result[1].line, 3);
727 assert_eq!(result[2].line, 5);
728 }
729
730 #[test]
731 fn test_numeric_patterns_full_check() {
732 let rule = MD018NoMissingSpaceAtx::new();
734
735 let content = "#123\n\n#456\n\n#29039";
736 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
737 let result = rule.check(&ctx).unwrap();
738
739 assert_eq!(result.len(), 3, "All three numeric patterns should be flagged");
740 }
741
742 #[test]
743 fn test_fix_lowercase_patterns() {
744 let rule = MD018NoMissingSpaceAtx::new();
746
747 let content = "#hello\nSome text.\n\n#world";
748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749 let fixed = rule.fix(&ctx).unwrap();
750
751 let expected = "# hello\nSome text.\n\n# world";
752 assert_eq!(fixed, expected);
753 }
754
755 #[test]
756 fn test_fix_numeric_patterns() {
757 let rule = MD018NoMissingSpaceAtx::new();
759
760 let content = "#123\nContent.\n\n##456";
761 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
762 let fixed = rule.fix(&ctx).unwrap();
763
764 let expected = "# 123\nContent.\n\n## 456";
765 assert_eq!(fixed, expected);
766 }
767
768 #[test]
769 fn test_indented_malformed_headings() {
770 let rule = MD018NoMissingSpaceAtx::new();
774
775 assert!(
777 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
778 .is_none(),
779 "1-space indented #hello should be skipped"
780 );
781 assert!(
782 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
783 .is_none(),
784 "2-space indented #hello should be skipped"
785 );
786 assert!(
787 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
788 .is_none(),
789 "3-space indented #hello should be skipped"
790 );
791
792 assert!(
797 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
798 .is_some(),
799 "Non-indented #hello should be detected"
800 );
801 }
802
803 #[test]
804 fn test_tab_after_hash_is_valid() {
805 let rule = MD018NoMissingSpaceAtx::new();
807
808 assert!(
809 rule.check_atx_heading_line("#\tHello", MarkdownFlavor::Standard)
810 .is_none(),
811 "Tab after # should be valid"
812 );
813 assert!(
814 rule.check_atx_heading_line("##\tWorld", MarkdownFlavor::Standard)
815 .is_none(),
816 "Tab after ## should be valid"
817 );
818 }
819
820 #[test]
821 fn test_mixed_case_patterns() {
822 let rule = MD018NoMissingSpaceAtx::new();
823
824 assert!(
826 rule.check_atx_heading_line("#hELLO", MarkdownFlavor::Standard)
827 .is_some()
828 );
829 assert!(
830 rule.check_atx_heading_line("#Hello", MarkdownFlavor::Standard)
831 .is_some()
832 );
833 assert!(
834 rule.check_atx_heading_line("#HELLO", MarkdownFlavor::Standard)
835 .is_some()
836 );
837 assert!(
838 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
839 .is_some()
840 );
841 }
842
843 #[test]
844 fn test_unicode_lowercase() {
845 let rule = MD018NoMissingSpaceAtx::new();
846
847 assert!(
849 rule.check_atx_heading_line("#über", MarkdownFlavor::Standard).is_some(),
850 "Unicode lowercase #über should be detected"
851 );
852 assert!(
853 rule.check_atx_heading_line("#café", MarkdownFlavor::Standard).is_some(),
854 "Unicode lowercase #café should be detected"
855 );
856 assert!(
857 rule.check_atx_heading_line("#日本語", MarkdownFlavor::Standard)
858 .is_some(),
859 "Japanese #日本語 should be detected"
860 );
861 }
862
863 #[test]
864 fn test_matches_markdownlint_behavior() {
865 let rule = MD018NoMissingSpaceAtx::new();
867
868 let content = r#"#hello
869
870## world
871
872###fer
873
874#123
875
876#Tag
877"#;
878
879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
880 let result = rule.check(&ctx).unwrap();
881
882 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
885
886 assert!(flagged_lines.contains(&1), "#hello should be flagged");
887 assert!(!flagged_lines.contains(&3), "## world should NOT be flagged");
888 assert!(flagged_lines.contains(&5), "###fer should be flagged");
889 assert!(flagged_lines.contains(&7), "#123 should be flagged");
890 assert!(flagged_lines.contains(&9), "#Tag should be flagged");
891
892 assert_eq!(result.len(), 4, "Should have exactly 4 warnings");
893 }
894
895 #[test]
896 fn test_skip_frontmatter_yaml_comments() {
897 let rule = MD018NoMissingSpaceAtx::new();
899
900 let content = r#"---
901#reviewers:
902#- sig-api-machinery
903#another_comment: value
904title: Test Document
905---
906
907# Valid heading
908
909#invalid heading without space
910"#;
911
912 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
913 let result = rule.check(&ctx).unwrap();
914
915 assert_eq!(
918 result.len(),
919 1,
920 "Should only flag the malformed heading outside frontmatter"
921 );
922 assert_eq!(result[0].line, 10, "Should flag line 10");
923 }
924
925 #[test]
926 fn test_skip_html_comments() {
927 let rule = MD018NoMissingSpaceAtx::new();
930
931 let content = r#"# Real Heading
932
933Some text.
934
935<!--
936```
937#%% Cell marker
938import matplotlib.pyplot as plt
939
940#%% Another cell
941data = [1, 2, 3]
942```
943-->
944
945More content.
946"#;
947
948 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
949 let result = rule.check(&ctx).unwrap();
950
951 assert!(
953 result.is_empty(),
954 "Should not flag content inside HTML comments, found {} issues",
955 result.len()
956 );
957 }
958
959 #[test]
960 fn test_mkdocs_magiclink_skips_numeric_refs() {
961 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
963 magiclink: true,
964 ..Default::default()
965 });
966
967 assert!(
969 rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_none(),
970 "#10 should be skipped with magiclink config (MagicLink issue ref)"
971 );
972 assert!(
973 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_none(),
974 "#123 should be skipped with magiclink config (MagicLink issue ref)"
975 );
976 assert!(
977 rule.check_atx_heading_line("#10 discusses the issue", MarkdownFlavor::Standard)
978 .is_none(),
979 "#10 followed by text should be skipped with magiclink config"
980 );
981 assert!(
982 rule.check_atx_heading_line("#37.", MarkdownFlavor::Standard).is_none(),
983 "#37 followed by punctuation should be skipped with magiclink config"
984 );
985 }
986
987 #[test]
988 fn test_mkdocs_magiclink_still_flags_non_numeric() {
989 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
991 magiclink: true,
992 ..Default::default()
993 });
994
995 assert!(
997 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
998 .is_some(),
999 "#Summary should still be flagged with magiclink config"
1000 );
1001 assert!(
1002 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
1003 .is_some(),
1004 "#hello should still be flagged with magiclink config"
1005 );
1006 assert!(
1007 rule.check_atx_heading_line("#10abc", MarkdownFlavor::Standard)
1008 .is_some(),
1009 "#10abc (mixed) should still be flagged with magiclink config"
1010 );
1011 }
1012
1013 #[test]
1014 fn test_mkdocs_magiclink_only_single_hash() {
1015 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1017 magiclink: true,
1018 ..Default::default()
1019 });
1020
1021 assert!(
1022 rule.check_atx_heading_line("##10", MarkdownFlavor::Standard).is_some(),
1023 "##10 should be flagged with magiclink config (only single # is MagicLink)"
1024 );
1025 assert!(
1026 rule.check_atx_heading_line("###123", MarkdownFlavor::Standard)
1027 .is_some(),
1028 "###123 should be flagged with magiclink config"
1029 );
1030 }
1031
1032 #[test]
1033 fn test_standard_flavor_flags_numeric_refs() {
1034 let rule = MD018NoMissingSpaceAtx::new();
1036
1037 assert!(
1038 rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_some(),
1039 "#10 should be flagged in Standard flavor"
1040 );
1041 assert!(
1042 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
1043 "#123 should be flagged in Standard flavor"
1044 );
1045 }
1046
1047 #[test]
1048 fn test_mkdocs_magiclink_full_check() {
1049 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1051 magiclink: true,
1052 ..Default::default()
1053 });
1054
1055 let content = r#"# PRs that are helpful for context
1056
1057#10 discusses the philosophy behind the project, and #37 shows a good example.
1058
1059#Summary
1060
1061##Introduction
1062"#;
1063
1064 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1066 let result = rule.check(&ctx).unwrap();
1067
1068 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1069 assert!(
1070 !flagged_lines.contains(&3),
1071 "#10 should NOT be flagged with magiclink config"
1072 );
1073 assert!(
1074 flagged_lines.contains(&5),
1075 "#Summary SHOULD be flagged with magiclink config"
1076 );
1077 assert!(
1078 flagged_lines.contains(&7),
1079 "##Introduction SHOULD be flagged with magiclink config"
1080 );
1081 }
1082
1083 #[test]
1084 fn test_mkdocs_magiclink_fix_exact_output() {
1085 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1087 magiclink: true,
1088 ..Default::default()
1089 });
1090
1091 let content = "#10 discusses the issue.\n\n#Summary";
1092 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1093 let fixed = rule.fix(&ctx).unwrap();
1094
1095 let expected = "#10 discusses the issue.\n\n# Summary";
1097 assert_eq!(
1098 fixed, expected,
1099 "magiclink config fix should preserve MagicLink refs and fix non-numeric headings"
1100 );
1101 }
1102
1103 #[test]
1104 fn test_mkdocs_magiclink_edge_cases() {
1105 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1107 magiclink: true,
1108 ..Default::default()
1109 });
1110
1111 let valid_refs = [
1114 "#10", "#999999", "#10 text after", "#10\ttext after", "#10.", "#10,", "#10!", "#10?", "#10)", "#10]", "#10;", "#10:", ];
1127
1128 for ref_str in valid_refs {
1129 assert!(
1130 rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_none(),
1131 "{ref_str:?} should be skipped as MagicLink ref with magiclink config"
1132 );
1133 }
1134
1135 let invalid_refs = [
1137 "#10abc", "#10a", "#abc10", "#10ABC", "#Summary", "#hello", ];
1144
1145 for ref_str in invalid_refs {
1146 assert!(
1147 rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_some(),
1148 "{ref_str:?} should be flagged with magiclink config (not a valid MagicLink ref)"
1149 );
1150 }
1151 }
1152
1153 #[test]
1154 fn test_mkdocs_magiclink_hyphenated_continuation() {
1155 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1158 magiclink: true,
1159 ..Default::default()
1160 });
1161
1162 assert!(
1167 rule.check_atx_heading_line("#10-", MarkdownFlavor::Standard).is_none(),
1168 "#10- should be skipped with magiclink config (hyphen is non-alphanumeric terminator)"
1169 );
1170 }
1171
1172 #[test]
1173 fn test_mkdocs_magiclink_standalone_number() {
1174 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1176 magiclink: true,
1177 ..Default::default()
1178 });
1179
1180 let content = "See issue:\n\n#10\n\nFor details.";
1181 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1182 let result = rule.check(&ctx).unwrap();
1183
1184 assert!(
1186 result.is_empty(),
1187 "Standalone #10 should not be flagged with magiclink config"
1188 );
1189
1190 let fixed = rule.fix(&ctx).unwrap();
1192 assert_eq!(fixed, content, "fix() should not modify standalone MagicLink ref");
1193 }
1194
1195 #[test]
1196 fn test_standard_flavor_flags_all_numeric() {
1197 let rule = MD018NoMissingSpaceAtx::new();
1200
1201 let numeric_patterns = ["#10", "#123", "#999999", "#10 text"];
1202
1203 for pattern in numeric_patterns {
1204 assert!(
1205 rule.check_atx_heading_line(pattern, MarkdownFlavor::Standard).is_some(),
1206 "{pattern:?} should be flagged in Standard flavor"
1207 );
1208 }
1209
1210 assert!(
1212 rule.check_atx_heading_line("#1", MarkdownFlavor::Standard).is_none(),
1213 "#1 should be skipped (content too short, existing behavior)"
1214 );
1215 }
1216
1217 #[test]
1218 fn test_mkdocs_vs_standard_fix_comparison() {
1219 let content = "#10 is an issue\n#Summary";
1221 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1222
1223 let rule_magiclink = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1225 magiclink: true,
1226 ..Default::default()
1227 });
1228 let fixed_magiclink = rule_magiclink.fix(&ctx).unwrap();
1229 assert_eq!(fixed_magiclink, "#10 is an issue\n# Summary");
1230
1231 let rule_default = MD018NoMissingSpaceAtx::new();
1233 let fixed_default = rule_default.fix(&ctx).unwrap();
1234 assert_eq!(fixed_default, "# 10 is an issue\n# Summary");
1235 }
1236
1237 #[test]
1240 fn test_tags_config_standard_flavor() {
1241 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1243 magiclink: false,
1244 tags: Some(true),
1245 });
1246
1247 let content = "#tag\n\n#project/active\n\n##Introduction";
1248 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1249 let result = rule.check(&ctx).unwrap();
1250
1251 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1252 assert!(!flagged_lines.contains(&1), "#tag should be skipped with tags = true");
1253 assert!(
1254 !flagged_lines.contains(&3),
1255 "#project/active should be skipped with tags = true"
1256 );
1257 assert!(flagged_lines.contains(&5), "##Introduction should still be flagged");
1258 }
1259
1260 #[test]
1261 fn test_tags_config_fix_standard_flavor() {
1262 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1263 magiclink: false,
1264 tags: Some(true),
1265 });
1266
1267 let content = "#tag\n\n##Introduction";
1268 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1269 let fixed = rule.fix(&ctx).unwrap();
1270 assert_eq!(fixed, "#tag\n\n## Introduction");
1271 }
1272
1273 #[test]
1274 fn test_tags_config_disabled_obsidian_flavor() {
1275 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1277 magiclink: false,
1278 tags: Some(false),
1279 });
1280
1281 let content = "#tag\n\n#project/active";
1282 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1283 let result = rule.check(&ctx).unwrap();
1284
1285 assert_eq!(
1286 result.len(),
1287 2,
1288 "tags = false should flag tag patterns even in Obsidian"
1289 );
1290 }
1291
1292 #[test]
1293 fn test_tags_config_default_follows_flavor() {
1294 let rule = MD018NoMissingSpaceAtx::new(); let content = "#tag";
1299 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1300 let result = rule.check(&ctx).unwrap();
1301 assert!(!result.is_empty(), "Default standard should flag #tag");
1302
1303 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1305 let result = rule.check(&ctx).unwrap();
1306 assert!(result.is_empty(), "Default Obsidian should skip #tag");
1307 }
1308
1309 #[test]
1312 fn test_obsidian_tag_skips_simple_tags() {
1313 let rule = MD018NoMissingSpaceAtx::new();
1315
1316 assert!(
1318 rule.check_atx_heading_line("#hey", MarkdownFlavor::Obsidian).is_none(),
1319 "#hey should be skipped in Obsidian flavor (tag syntax)"
1320 );
1321 assert!(
1322 rule.check_atx_heading_line("#tag", MarkdownFlavor::Obsidian).is_none(),
1323 "#tag should be skipped in Obsidian flavor"
1324 );
1325 assert!(
1326 rule.check_atx_heading_line("#hello", MarkdownFlavor::Obsidian)
1327 .is_none(),
1328 "#hello should be skipped in Obsidian flavor"
1329 );
1330 assert!(
1331 rule.check_atx_heading_line("#myTag", MarkdownFlavor::Obsidian)
1332 .is_none(),
1333 "#myTag should be skipped in Obsidian flavor"
1334 );
1335 }
1336
1337 #[test]
1338 fn test_obsidian_tag_skips_complex_tags() {
1339 let rule = MD018NoMissingSpaceAtx::new();
1341
1342 assert!(
1344 rule.check_atx_heading_line("#project/active", MarkdownFlavor::Obsidian)
1345 .is_none(),
1346 "#project/active should be skipped in Obsidian flavor (nested tag)"
1347 );
1348 assert!(
1349 rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1350 .is_none(),
1351 "#my-tag should be skipped in Obsidian flavor"
1352 );
1353 assert!(
1354 rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1355 .is_none(),
1356 "#my_tag should be skipped in Obsidian flavor"
1357 );
1358 assert!(
1359 rule.check_atx_heading_line("#tag2023", MarkdownFlavor::Obsidian)
1360 .is_none(),
1361 "#tag2023 should be skipped in Obsidian flavor"
1362 );
1363 assert!(
1364 rule.check_atx_heading_line("#project/sub/task", MarkdownFlavor::Obsidian)
1365 .is_none(),
1366 "#project/sub/task should be skipped in Obsidian flavor"
1367 );
1368 }
1369
1370 #[test]
1371 fn test_obsidian_tag_with_trailing_content() {
1372 let rule = MD018NoMissingSpaceAtx::new();
1374
1375 assert!(
1376 rule.check_atx_heading_line("#hey ", MarkdownFlavor::Obsidian).is_none(),
1377 "#hey followed by space should be skipped"
1378 );
1379 assert!(
1380 rule.check_atx_heading_line("#tag some text", MarkdownFlavor::Obsidian)
1381 .is_none(),
1382 "#tag followed by text should be skipped"
1383 );
1384 }
1385
1386 #[test]
1387 fn test_obsidian_tag_still_flags_multi_hash() {
1388 let rule = MD018NoMissingSpaceAtx::new();
1390
1391 assert!(
1392 rule.check_atx_heading_line("##tag", MarkdownFlavor::Obsidian).is_some(),
1393 "##tag should be flagged in Obsidian flavor (only single # is a tag)"
1394 );
1395 assert!(
1396 rule.check_atx_heading_line("###hello", MarkdownFlavor::Obsidian)
1397 .is_some(),
1398 "###hello should be flagged in Obsidian flavor"
1399 );
1400 }
1401
1402 #[test]
1403 fn test_obsidian_tag_numeric_still_flagged() {
1404 let rule = MD018NoMissingSpaceAtx::new();
1407
1408 assert!(
1409 rule.check_atx_heading_line("#123", MarkdownFlavor::Obsidian).is_some(),
1410 "#123 should be flagged in Obsidian flavor (no non-numerical character)"
1411 );
1412 assert!(
1413 rule.check_atx_heading_line("#10", MarkdownFlavor::Obsidian).is_some(),
1414 "#10 should be flagged in Obsidian flavor"
1415 );
1416 }
1417
1418 #[test]
1419 fn test_obsidian_flavor_full_check() {
1420 let rule = MD018NoMissingSpaceAtx::new();
1422
1423 let content = r#"# Real Heading
1424
1425#hey this is a tag
1426
1427#project/active also a tag
1428
1429##Introduction
1430
1431#123
1432"#;
1433
1434 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1436 let result = rule.check(&ctx).unwrap();
1437
1438 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1439 assert!(
1440 !flagged_lines.contains(&3),
1441 "#hey should NOT be flagged in Obsidian flavor"
1442 );
1443 assert!(
1444 !flagged_lines.contains(&5),
1445 "#project/active should NOT be flagged in Obsidian flavor"
1446 );
1447 assert!(
1448 flagged_lines.contains(&7),
1449 "##Introduction SHOULD be flagged in Obsidian flavor"
1450 );
1451 assert!(flagged_lines.contains(&9), "#123 SHOULD be flagged in Obsidian flavor");
1452 }
1453
1454 #[test]
1455 fn test_obsidian_flavor_fix_exact_output() {
1456 let rule = MD018NoMissingSpaceAtx::new();
1458
1459 let content = "#hey is a tag.\n\n##Introduction";
1462 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1463 let fixed = rule.fix(&ctx).unwrap();
1464
1465 let expected = "#hey is a tag.\n\n## Introduction";
1467 assert_eq!(
1468 fixed, expected,
1469 "Obsidian fix should preserve tags and fix multi-hash headings"
1470 );
1471 }
1472
1473 #[test]
1474 fn test_standard_flavor_flags_obsidian_tags() {
1475 let rule = MD018NoMissingSpaceAtx::new();
1477
1478 assert!(
1479 rule.check_atx_heading_line("#hey", MarkdownFlavor::Standard).is_some(),
1480 "#hey should be flagged in Standard flavor"
1481 );
1482 assert!(
1483 rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
1484 "#tag should be flagged in Standard flavor"
1485 );
1486 assert!(
1487 rule.check_atx_heading_line("#project/active", MarkdownFlavor::Standard)
1488 .is_some(),
1489 "#project/active should be flagged in Standard flavor"
1490 );
1491 }
1492
1493 #[test]
1494 fn test_obsidian_vs_standard_fix_comparison() {
1495 let rule = MD018NoMissingSpaceAtx::new();
1497
1498 let content = "#hey tag\n##Introduction";
1502
1503 let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1505 let fixed_obsidian = rule.fix(&ctx_obsidian).unwrap();
1506 assert_eq!(fixed_obsidian, "#hey tag\n## Introduction");
1507
1508 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1510 let fixed_standard = rule.fix(&ctx_standard).unwrap();
1511 assert_eq!(fixed_standard, "# hey tag\n## Introduction");
1512 }
1513
1514 #[test]
1515 fn test_obsidian_tag_edge_cases() {
1516 let rule = MD018NoMissingSpaceAtx::new();
1518
1519 let valid_tags = [
1521 "#a", "#tag", "#Tag", "#TAG", "#my-tag", "#my_tag", "#tag123", "#a1", "#日本語", "#über", ];
1532
1533 for tag in valid_tags {
1534 assert!(
1535 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1536 "{tag:?} should be skipped in Obsidian flavor (valid tag)"
1537 );
1538 }
1539
1540 let invalid_tags = ["#123", "#1984", "#37.", "#42,"];
1543
1544 for tag in invalid_tags {
1545 assert!(
1546 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_some(),
1547 "{tag:?} should be flagged in Obsidian flavor (no non-numerical character)"
1548 );
1549 }
1550 }
1551
1552 #[test]
1553 fn test_obsidian_tag_alone_on_line() {
1554 let rule = MD018NoMissingSpaceAtx::new();
1556
1557 let content = "Some text\n\n#todo\n\nMore text.";
1558 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1559 let result = rule.check(&ctx).unwrap();
1560
1561 assert!(
1563 result.is_empty(),
1564 "Standalone #todo should not be flagged in Obsidian flavor"
1565 );
1566
1567 let fixed = rule.fix(&ctx).unwrap();
1569 assert_eq!(fixed, content, "fix() should not modify standalone Obsidian tag");
1570 }
1571
1572 #[test]
1573 fn test_obsidian_deeply_nested_tags() {
1574 let rule = MD018NoMissingSpaceAtx::new();
1576
1577 let nested_tags = [
1578 "#a/b",
1579 "#a/b/c",
1580 "#project/2023/q1/task",
1581 "#work/meetings/weekly",
1582 "#life/health/exercise/running",
1583 ];
1584
1585 for tag in nested_tags {
1586 assert!(
1587 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1588 "{tag:?} should be skipped in Obsidian flavor (nested tag)"
1589 );
1590 }
1591 }
1592
1593 #[test]
1594 fn test_obsidian_unicode_tags() {
1595 let rule = MD018NoMissingSpaceAtx::new();
1597
1598 let unicode_tags = [
1599 "#日本語", "#中文", "#한국어", "#über", "#café", "#ñoño", "#Москва", "#αβγ", ];
1608
1609 for tag in unicode_tags {
1610 assert!(
1611 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1612 "{tag:?} should be skipped in Obsidian flavor (Unicode tag)"
1613 );
1614 }
1615 }
1616
1617 #[test]
1618 fn test_obsidian_tags_with_special_endings() {
1619 let rule = MD018NoMissingSpaceAtx::new();
1621
1622 assert!(
1624 rule.check_atx_heading_line("#tag followed by text", MarkdownFlavor::Obsidian)
1625 .is_none(),
1626 "#tag followed by text should be skipped"
1627 );
1628
1629 let content = "#todo";
1631 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1632 let result = rule.check(&ctx).unwrap();
1633 assert!(result.is_empty(), "#todo at end of line should be skipped");
1634 }
1635
1636 #[test]
1637 fn test_obsidian_combined_with_other_skip_contexts() {
1638 let rule = MD018NoMissingSpaceAtx::new();
1640
1641 let content = "```\n#todo\n```";
1643 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1644 let result = rule.check(&ctx).unwrap();
1645 assert!(result.is_empty(), "Tag in code block should be skipped");
1646
1647 let content = "<!-- #todo -->";
1649 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1650 let result = rule.check(&ctx).unwrap();
1651 assert!(result.is_empty(), "Tag in HTML comment should be skipped");
1652 }
1653
1654 #[test]
1655 fn test_obsidian_boundary_cases() {
1656 let rule = MD018NoMissingSpaceAtx::new();
1658
1659 assert!(
1663 rule.check_atx_heading_line("#ab", MarkdownFlavor::Obsidian).is_none(),
1664 "#ab should be skipped in Obsidian flavor"
1665 );
1666
1667 assert!(
1669 rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1670 .is_none(),
1671 "#my_tag should be skipped"
1672 );
1673
1674 assert!(
1676 rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1677 .is_none(),
1678 "#my-tag should be skipped"
1679 );
1680
1681 assert!(
1683 rule.check_atx_heading_line("#MyTag", MarkdownFlavor::Obsidian)
1684 .is_none(),
1685 "#MyTag should be skipped"
1686 );
1687
1688 assert!(
1690 rule.check_atx_heading_line("#TODO", MarkdownFlavor::Obsidian).is_none(),
1691 "#TODO should be skipped in Obsidian flavor"
1692 );
1693 }
1694
1695 #[test]
1696 fn test_obsidian_tag_may_start_with_a_digit() {
1697 let rule = MD018NoMissingSpaceAtx::new();
1700
1701 let tags = [
1702 "#3d_printing", "#1tag", "#2023-project", "#100DaysOfCode", "#1on1", "#5S", "#3/4", "#1_2", "#3🔥", "#3\u{FE0F}\u{20E3}", ];
1713
1714 for tag in tags {
1715 assert!(
1716 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1717 "{tag:?} contains a non-numerical character and should be skipped as a tag"
1718 );
1719 }
1720 }
1721
1722 #[test]
1723 fn test_numeric_references_are_not_tags() {
1724 let rule = MD018NoMissingSpaceAtx::new();
1728
1729 let not_tags = [
1730 "#1984", "#123", "#10", "#37.", "#42,", "#42)", "#404 Not Found", "#10 discusses the issue", ];
1739
1740 for line in not_tags {
1741 assert!(
1742 rule.check_atx_heading_line(line, MarkdownFlavor::Obsidian).is_some(),
1743 "{line:?} has no non-numerical tag character and should stay flagged"
1744 );
1745 }
1746 }
1747
1748 #[test]
1749 fn test_digit_leading_tag_survives_check_and_fix() {
1750 let obsidian = MD018NoMissingSpaceAtx::new();
1753 let standard = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1754 magiclink: false,
1755 tags: Some(true),
1756 });
1757
1758 let content = "# Header\n\n#3d_printing\n\n##Heading\n";
1761
1762 for (rule, flavor, label) in [
1763 (&obsidian, MarkdownFlavor::Obsidian, "obsidian flavor"),
1764 (&standard, MarkdownFlavor::Standard, "tags = true"),
1765 ] {
1766 let ctx = LintContext::new(content, flavor, None);
1767 let flagged: Vec<usize> = rule.check(&ctx).unwrap().iter().map(|w| w.line).collect();
1768 assert_eq!(
1769 flagged,
1770 vec![5],
1771 "{label}: only ##Heading should be flagged, got {flagged:?}"
1772 );
1773
1774 let fixed = rule.fix(&ctx).unwrap();
1775 assert_eq!(
1776 fixed, "# Header\n\n#3d_printing\n\n## Heading\n",
1777 "{label}: fix must leave the tag alone and still fix the heading"
1778 );
1779 }
1780 }
1781
1782 #[test]
1783 fn test_digit_leading_tag_is_still_flagged_without_tags_mode() {
1784 let rule = MD018NoMissingSpaceAtx::new();
1787
1788 assert!(
1789 rule.check_atx_heading_line("#3d_printing", MarkdownFlavor::Standard)
1790 .is_some(),
1791 "#3d_printing should be flagged in Standard flavor (tags disabled)"
1792 );
1793 }
1794}