1mod md018_config;
5
6pub(super) use md018_config::MD018Config;
7
8use crate::config::MarkdownFlavor;
9use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
10use crate::utils::obsidian_tag::TAG_PATTERN;
11use crate::utils::range_utils::{byte_to_char_count, calculate_single_line_range};
12use regex::Regex;
13use std::sync::LazyLock;
14
15const EMOJI_HASHTAG_PATTERN_STR: &str = r"^#️⃣|^#⃣";
17const UNICODE_HASHTAG_PATTERN_STR: &str = r"^#[\u{FE0F}\u{20E3}]";
18static EMOJI_HASHTAG_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(EMOJI_HASHTAG_PATTERN_STR).unwrap());
19static UNICODE_HASHTAG_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(UNICODE_HASHTAG_PATTERN_STR).unwrap());
20
21const MAGICLINK_REF_PATTERN_STR: &str = r"^#\d+(?:\s|[^a-zA-Z0-9]|$)";
25static MAGICLINK_REF_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(MAGICLINK_REF_PATTERN_STR).unwrap());
26
27#[derive(Clone)]
28pub struct MD018NoMissingSpaceAtx {
29 config: MD018Config,
30}
31
32impl Default for MD018NoMissingSpaceAtx {
33 fn default() -> Self {
34 Self::new()
35 }
36}
37
38impl MD018NoMissingSpaceAtx {
39 pub fn new() -> Self {
40 Self {
41 config: MD018Config::default(),
42 }
43 }
44
45 pub fn from_config_struct(config: MD018Config) -> Self {
46 Self { config }
47 }
48
49 fn is_magiclink_ref(line: &str) -> bool {
52 MAGICLINK_REF_PATTERN.is_match(line.trim_start())
53 }
54
55 fn is_tag(line: &str) -> bool {
57 TAG_PATTERN.is_match(line.trim_start())
58 }
59
60 fn tags_enabled(&self, flavor: MarkdownFlavor) -> bool {
62 self.config.tags_enabled(flavor)
63 }
64
65 fn check_atx_heading_line(&self, line: &str, flavor: MarkdownFlavor) -> Option<(usize, String)> {
67 let trimmed_line = line.trim_start();
69 let indent = line.len() - trimmed_line.len();
70
71 if !trimmed_line.starts_with('#') {
72 return None;
73 }
74
75 if indent > 0 {
81 return None;
82 }
83
84 let is_emoji = EMOJI_HASHTAG_PATTERN.is_match(trimmed_line);
86 let is_unicode = UNICODE_HASHTAG_PATTERN.is_match(trimmed_line);
87 if is_emoji || is_unicode {
88 return None;
89 }
90
91 let hash_count = trimmed_line.chars().take_while(|&c| c == '#').count();
93 if hash_count == 0 || hash_count > 6 {
94 return None;
95 }
96
97 let after_hashes = &trimmed_line[hash_count..];
99
100 if after_hashes
102 .chars()
103 .next()
104 .is_some_and(|ch| matches!(ch, '\u{FE0F}' | '\u{20E3}' | '\u{FE0E}'))
105 {
106 return None;
107 }
108
109 if !after_hashes.is_empty() && !after_hashes.starts_with(' ') && !after_hashes.starts_with('\t') {
111 let content = after_hashes.trim();
113
114 if content.chars().all(|c| c == '#') {
116 return None;
117 }
118
119 if content.len() < 2 {
121 return None;
122 }
123
124 if content.starts_with('*') || content.starts_with('_') {
126 return None;
127 }
128
129 if self.config.magiclink && hash_count == 1 && Self::is_magiclink_ref(line) {
132 return None;
133 }
134
135 if self.tags_enabled(flavor) && hash_count == 1 && Self::is_tag(line) {
138 return None;
139 }
140
141 let fixed = format!("{}{} {}", " ".repeat(indent), "#".repeat(hash_count), after_hashes);
143 return Some((indent + hash_count, fixed));
144 }
145
146 None
147 }
148
149 fn get_line_byte_range(&self, content: &str, line_num: usize) -> std::ops::Range<usize> {
151 let mut current_line = 1;
152 let mut start_byte = 0;
153
154 for (i, c) in content.char_indices() {
155 if current_line == line_num && c == '\n' {
156 return start_byte..i;
157 } else if c == '\n' {
158 current_line += 1;
159 if current_line == line_num {
160 start_byte = i + 1;
161 }
162 }
163 }
164
165 if current_line == line_num {
167 return start_byte..content.len();
168 }
169
170 0..0
172 }
173}
174
175impl Rule for MD018NoMissingSpaceAtx {
176 fn name(&self) -> &'static str {
177 "MD018"
178 }
179
180 fn description(&self) -> &'static str {
181 "No space after hash in heading"
182 }
183
184 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
185 let mut warnings = Vec::new();
186
187 for (line_num, line_info) in ctx.lines.iter().enumerate() {
189 if line_info.in_html_block
191 || line_info.in_html_comment
192 || line_info.in_mdx_comment
193 || line_info.in_pymdown_block
194 {
195 continue;
196 }
197
198 if let Some(heading) = &line_info.heading {
199 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
201 if line_info.indent > 0 {
204 continue;
205 }
206
207 let line = line_info.content(ctx.content);
209 let trimmed = line.trim_start();
210
211 let is_emoji = EMOJI_HASHTAG_PATTERN.is_match(trimmed);
213 let is_unicode = UNICODE_HASHTAG_PATTERN.is_match(trimmed);
214 if is_emoji || is_unicode {
215 continue;
216 }
217
218 if self.config.magiclink && heading.level == 1 && Self::is_magiclink_ref(line) {
220 continue;
221 }
222
223 if self.tags_enabled(ctx.flavor) && heading.level == 1 && Self::is_tag(line) {
225 continue;
226 }
227
228 if trimmed.len() > heading.marker.len() {
229 let after_marker = &trimmed[heading.marker.len()..];
230 if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
231 {
232 let hash_end_col = byte_to_char_count(line, line_info.indent + heading.marker.len());
235 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::new(self.get_line_byte_range(ctx.content, line_num + 1), {
250 let line = line_info.content(ctx.content);
252 let original_indent = &line[..line_info.indent];
253 format!("{original_indent}{} {after_marker}", heading.marker)
254 })),
255 });
256 }
257 }
258 }
259 } else if !line_info.in_code_block
260 && !line_info.in_front_matter
261 && !line_info.in_html_comment
262 && !line_info.in_mdx_comment
263 && !line_info.is_blank
264 {
265 if let Some((hash_end_pos, fixed_line)) =
267 self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor)
268 {
269 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
270 line_num + 1, hash_end_pos + 1, 0, );
274
275 warnings.push(LintWarning {
276 rule_name: Some(self.name().to_string()),
277 message: "No space after hash in heading".to_string(),
278 line: start_line,
279 column: start_col,
280 end_line,
281 end_column: end_col,
282 severity: Severity::Warning,
283 fix: Some(Fix::new(
284 self.get_line_byte_range(ctx.content, line_num + 1),
285 fixed_line,
286 )),
287 });
288 }
289 }
290 }
291
292 Ok(warnings)
293 }
294
295 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
296 let warnings = self.check(ctx)?;
297 let warnings =
298 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
299 let warning_lines: std::collections::HashSet<usize> = warnings.iter().map(|w| w.line).collect();
300
301 let mut lines = Vec::new();
302
303 for (idx, line_info) in ctx.lines.iter().enumerate() {
304 let mut fixed = false;
305
306 if !warning_lines.contains(&(idx + 1)) {
307 lines.push(line_info.content(ctx.content).to_string());
308 continue;
309 }
310
311 if let Some(heading) = &line_info.heading {
312 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
314 let line = line_info.content(ctx.content);
315 let trimmed = line.trim_start();
316
317 let is_emoji = EMOJI_HASHTAG_PATTERN.is_match(trimmed);
319 let is_unicode = UNICODE_HASHTAG_PATTERN.is_match(trimmed);
320
321 let is_magiclink = self.config.magiclink && heading.level == 1 && Self::is_magiclink_ref(line);
323
324 let is_tag = self.tags_enabled(ctx.flavor) && heading.level == 1 && Self::is_tag(line);
326
327 if !is_emoji && !is_unicode && !is_magiclink && !is_tag && trimmed.len() > heading.marker.len() {
329 let after_marker = &trimmed[heading.marker.len()..];
330 if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
331 {
332 let line = line_info.content(ctx.content);
334 let original_indent = &line[..line_info.indent];
335 lines.push(format!("{original_indent}{} {after_marker}", heading.marker));
336 fixed = true;
337 }
338 }
339 }
340 } else if !line_info.in_code_block
341 && !line_info.in_front_matter
342 && !line_info.in_html_comment
343 && !line_info.in_mdx_comment
344 && !line_info.is_blank
345 {
346 if let Some((_, fixed_line)) = self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor) {
348 lines.push(fixed_line);
349 fixed = true;
350 }
351 }
352
353 if !fixed {
354 lines.push(line_info.content(ctx.content).to_string());
355 }
356 }
357
358 let mut result = lines.join("\n");
360 if ctx.content.ends_with('\n') && !result.ends_with('\n') {
361 result.push('\n');
362 }
363
364 Ok(result)
365 }
366
367 fn category(&self) -> RuleCategory {
369 RuleCategory::Heading
370 }
371
372 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
374 !ctx.likely_has_headings()
376 }
377
378 fn as_any(&self) -> &dyn std::any::Any {
379 self
380 }
381
382 crate::impl_rule_config_methods!(MD018Config);
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use crate::lint_context::LintContext;
389
390 #[test]
391 fn test_basic_functionality() {
392 let rule = MD018NoMissingSpaceAtx::new();
393
394 let content = "# Heading 1\n## Heading 2\n### Heading 3";
396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
397 let result = rule.check(&ctx).unwrap();
398 assert!(result.is_empty());
399
400 let content = "#Heading 1\n## Heading 2\n###Heading 3";
402 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
403 let result = rule.check(&ctx).unwrap();
404 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
406 assert_eq!(result[1].line, 3);
407 }
408
409 #[test]
410 fn test_malformed_heading_detection() {
411 let rule = MD018NoMissingSpaceAtx::new();
412
413 assert!(
415 rule.check_atx_heading_line("##Introduction", MarkdownFlavor::Standard)
416 .is_some()
417 );
418 assert!(
419 rule.check_atx_heading_line("###Background", MarkdownFlavor::Standard)
420 .is_some()
421 );
422 assert!(
423 rule.check_atx_heading_line("####Details", MarkdownFlavor::Standard)
424 .is_some()
425 );
426 assert!(
427 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
428 .is_some()
429 );
430 assert!(
431 rule.check_atx_heading_line("######Conclusion", MarkdownFlavor::Standard)
432 .is_some()
433 );
434 assert!(
435 rule.check_atx_heading_line("##Table of Contents", MarkdownFlavor::Standard)
436 .is_some()
437 );
438
439 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!(
444 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
445 .is_none()
446 ); assert!(
448 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
449 .is_none()
450 ); }
452
453 #[test]
454 fn test_malformed_heading_with_context() {
455 let rule = MD018NoMissingSpaceAtx::new();
456
457 let content = r#"# Test Document
459
460##Introduction
461This should be detected.
462
463 ##CodeBlock
464This should NOT be detected (indented code block).
465
466```
467##FencedCodeBlock
468This should NOT be detected (fenced code block).
469```
470
471##Conclusion
472This should be detected.
473"#;
474
475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
476 let result = rule.check(&ctx).unwrap();
477
478 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
480 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&14)); assert!(!detected_lines.contains(&6)); assert!(!detected_lines.contains(&10)); }
485
486 #[test]
487 fn test_malformed_heading_fix() {
488 let rule = MD018NoMissingSpaceAtx::new();
489
490 let content = r#"##Introduction
491This is a test.
492
493###Background
494More content."#;
495
496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
497 let fixed = rule.fix(&ctx).unwrap();
498
499 let expected = r#"## Introduction
500This is a test.
501
502### Background
503More content."#;
504
505 assert_eq!(fixed, expected);
506 }
507
508 #[test]
509 fn test_mixed_proper_and_malformed_headings() {
510 let rule = MD018NoMissingSpaceAtx::new();
511
512 let content = r#"# Proper Heading
513
514##Malformed Heading
515
516## Another Proper Heading
517
518###Another Malformed
519
520#### Proper with space
521"#;
522
523 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
524 let result = rule.check(&ctx).unwrap();
525
526 assert_eq!(result.len(), 2);
528 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
529 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&7)); }
532
533 #[test]
534 fn test_css_selectors_in_html_blocks() {
535 let rule = MD018NoMissingSpaceAtx::new();
536
537 let content = r#"# Proper Heading
540
541<style>
542#slide-1 ol li {
543 margin-top: 0;
544}
545
546#special-slide ol li {
547 margin-top: 2em;
548}
549</style>
550
551## Another Heading
552"#;
553
554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
555 let result = rule.check(&ctx).unwrap();
556
557 assert_eq!(
559 result.len(),
560 0,
561 "CSS selectors in <style> blocks should not be flagged as malformed headings"
562 );
563 }
564
565 #[test]
566 fn test_js_code_in_script_blocks() {
567 let rule = MD018NoMissingSpaceAtx::new();
568
569 let content = r#"# Heading
571
572<script>
573const element = document.querySelector('#main-content');
574#another-comment
575</script>
576
577## Another Heading
578"#;
579
580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
581 let result = rule.check(&ctx).unwrap();
582
583 assert_eq!(
585 result.len(),
586 0,
587 "JavaScript code in <script> blocks should not be flagged as malformed headings"
588 );
589 }
590
591 #[test]
592 fn test_all_malformed_headings_detected() {
593 let rule = MD018NoMissingSpaceAtx::new();
594
595 assert!(
600 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
601 .is_some(),
602 "#hello SHOULD be detected as malformed heading"
603 );
604 assert!(
605 rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
606 "#tag SHOULD be detected as malformed heading"
607 );
608 assert!(
609 rule.check_atx_heading_line("#hashtag", MarkdownFlavor::Standard)
610 .is_some(),
611 "#hashtag SHOULD be detected as malformed heading"
612 );
613 assert!(
614 rule.check_atx_heading_line("#javascript", MarkdownFlavor::Standard)
615 .is_some(),
616 "#javascript SHOULD be detected as malformed heading"
617 );
618
619 assert!(
621 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
622 "#123 SHOULD be detected as malformed heading"
623 );
624 assert!(
625 rule.check_atx_heading_line("#12345", MarkdownFlavor::Standard)
626 .is_some(),
627 "#12345 SHOULD be detected as malformed heading"
628 );
629 assert!(
630 rule.check_atx_heading_line("#29039)", MarkdownFlavor::Standard)
631 .is_some(),
632 "#29039) SHOULD be detected as malformed heading"
633 );
634
635 assert!(
637 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
638 .is_some(),
639 "#Summary SHOULD be detected as malformed heading"
640 );
641 assert!(
642 rule.check_atx_heading_line("#Introduction", MarkdownFlavor::Standard)
643 .is_some(),
644 "#Introduction SHOULD be detected as malformed heading"
645 );
646 assert!(
647 rule.check_atx_heading_line("#API", MarkdownFlavor::Standard).is_some(),
648 "#API SHOULD be detected as malformed heading"
649 );
650
651 assert!(
653 rule.check_atx_heading_line("##introduction", MarkdownFlavor::Standard)
654 .is_some(),
655 "##introduction SHOULD be detected as malformed heading"
656 );
657 assert!(
658 rule.check_atx_heading_line("###section", MarkdownFlavor::Standard)
659 .is_some(),
660 "###section SHOULD be detected as malformed heading"
661 );
662 assert!(
663 rule.check_atx_heading_line("###fer", MarkdownFlavor::Standard)
664 .is_some(),
665 "###fer SHOULD be detected as malformed heading"
666 );
667 assert!(
668 rule.check_atx_heading_line("##123", MarkdownFlavor::Standard).is_some(),
669 "##123 SHOULD be detected as malformed heading"
670 );
671 }
672
673 #[test]
674 fn test_patterns_that_should_not_be_flagged() {
675 let rule = MD018NoMissingSpaceAtx::new();
676
677 assert!(rule.check_atx_heading_line("###", MarkdownFlavor::Standard).is_none());
679 assert!(rule.check_atx_heading_line("#", MarkdownFlavor::Standard).is_none());
680
681 assert!(rule.check_atx_heading_line("##a", MarkdownFlavor::Standard).is_none());
683
684 assert!(
686 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
687 .is_none()
688 );
689
690 assert!(
692 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
693 .is_none()
694 );
695
696 assert!(
698 rule.check_atx_heading_line("# Hello", MarkdownFlavor::Standard)
699 .is_none()
700 );
701 assert!(
702 rule.check_atx_heading_line("## World", MarkdownFlavor::Standard)
703 .is_none()
704 );
705 assert!(
706 rule.check_atx_heading_line("### Section", MarkdownFlavor::Standard)
707 .is_none()
708 );
709 }
710
711 #[test]
712 fn test_inline_issue_refs_not_at_line_start() {
713 let rule = MD018NoMissingSpaceAtx::new();
714
715 assert!(
720 rule.check_atx_heading_line("See issue #123", MarkdownFlavor::Standard)
721 .is_none()
722 );
723 assert!(
724 rule.check_atx_heading_line("Check #trending on Twitter", MarkdownFlavor::Standard)
725 .is_none()
726 );
727 assert!(
728 rule.check_atx_heading_line("- fix: issue #29039", MarkdownFlavor::Standard)
729 .is_none()
730 );
731 }
732
733 #[test]
734 fn test_lowercase_patterns_full_check() {
735 let rule = MD018NoMissingSpaceAtx::new();
737
738 let content = "#hello\n\n#world\n\n#tag";
739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
740 let result = rule.check(&ctx).unwrap();
741
742 assert_eq!(result.len(), 3, "All three lowercase patterns should be flagged");
743 assert_eq!(result[0].line, 1);
744 assert_eq!(result[1].line, 3);
745 assert_eq!(result[2].line, 5);
746 }
747
748 #[test]
749 fn test_numeric_patterns_full_check() {
750 let rule = MD018NoMissingSpaceAtx::new();
752
753 let content = "#123\n\n#456\n\n#29039";
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 numeric patterns should be flagged");
758 }
759
760 #[test]
761 fn test_fix_lowercase_patterns() {
762 let rule = MD018NoMissingSpaceAtx::new();
764
765 let content = "#hello\nSome text.\n\n#world";
766 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
767 let fixed = rule.fix(&ctx).unwrap();
768
769 let expected = "# hello\nSome text.\n\n# world";
770 assert_eq!(fixed, expected);
771 }
772
773 #[test]
774 fn test_fix_numeric_patterns() {
775 let rule = MD018NoMissingSpaceAtx::new();
777
778 let content = "#123\nContent.\n\n##456";
779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
780 let fixed = rule.fix(&ctx).unwrap();
781
782 let expected = "# 123\nContent.\n\n## 456";
783 assert_eq!(fixed, expected);
784 }
785
786 #[test]
787 fn test_indented_malformed_headings() {
788 let rule = MD018NoMissingSpaceAtx::new();
792
793 assert!(
795 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
796 .is_none(),
797 "1-space indented #hello should be skipped"
798 );
799 assert!(
800 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
801 .is_none(),
802 "2-space indented #hello should be skipped"
803 );
804 assert!(
805 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
806 .is_none(),
807 "3-space indented #hello should be skipped"
808 );
809
810 assert!(
815 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
816 .is_some(),
817 "Non-indented #hello should be detected"
818 );
819 }
820
821 #[test]
822 fn test_tab_after_hash_is_valid() {
823 let rule = MD018NoMissingSpaceAtx::new();
825
826 assert!(
827 rule.check_atx_heading_line("#\tHello", MarkdownFlavor::Standard)
828 .is_none(),
829 "Tab after # should be valid"
830 );
831 assert!(
832 rule.check_atx_heading_line("##\tWorld", MarkdownFlavor::Standard)
833 .is_none(),
834 "Tab after ## should be valid"
835 );
836 }
837
838 #[test]
839 fn test_mixed_case_patterns() {
840 let rule = MD018NoMissingSpaceAtx::new();
841
842 assert!(
844 rule.check_atx_heading_line("#hELLO", MarkdownFlavor::Standard)
845 .is_some()
846 );
847 assert!(
848 rule.check_atx_heading_line("#Hello", MarkdownFlavor::Standard)
849 .is_some()
850 );
851 assert!(
852 rule.check_atx_heading_line("#HELLO", MarkdownFlavor::Standard)
853 .is_some()
854 );
855 assert!(
856 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
857 .is_some()
858 );
859 }
860
861 #[test]
862 fn test_unicode_lowercase() {
863 let rule = MD018NoMissingSpaceAtx::new();
864
865 assert!(
867 rule.check_atx_heading_line("#über", MarkdownFlavor::Standard).is_some(),
868 "Unicode lowercase #über should be detected"
869 );
870 assert!(
871 rule.check_atx_heading_line("#café", MarkdownFlavor::Standard).is_some(),
872 "Unicode lowercase #café should be detected"
873 );
874 assert!(
875 rule.check_atx_heading_line("#日本語", MarkdownFlavor::Standard)
876 .is_some(),
877 "Japanese #日本語 should be detected"
878 );
879 }
880
881 #[test]
882 fn test_matches_markdownlint_behavior() {
883 let rule = MD018NoMissingSpaceAtx::new();
885
886 let content = r#"#hello
887
888## world
889
890###fer
891
892#123
893
894#Tag
895"#;
896
897 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898 let result = rule.check(&ctx).unwrap();
899
900 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
903
904 assert!(flagged_lines.contains(&1), "#hello should be flagged");
905 assert!(!flagged_lines.contains(&3), "## world should NOT be flagged");
906 assert!(flagged_lines.contains(&5), "###fer should be flagged");
907 assert!(flagged_lines.contains(&7), "#123 should be flagged");
908 assert!(flagged_lines.contains(&9), "#Tag should be flagged");
909
910 assert_eq!(result.len(), 4, "Should have exactly 4 warnings");
911 }
912
913 #[test]
914 fn test_skip_frontmatter_yaml_comments() {
915 let rule = MD018NoMissingSpaceAtx::new();
917
918 let content = r#"---
919#reviewers:
920#- sig-api-machinery
921#another_comment: value
922title: Test Document
923---
924
925# Valid heading
926
927#invalid heading without space
928"#;
929
930 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
931 let result = rule.check(&ctx).unwrap();
932
933 assert_eq!(
936 result.len(),
937 1,
938 "Should only flag the malformed heading outside frontmatter"
939 );
940 assert_eq!(result[0].line, 10, "Should flag line 10");
941 }
942
943 #[test]
944 fn test_skip_html_comments() {
945 let rule = MD018NoMissingSpaceAtx::new();
948
949 let content = r#"# Real Heading
950
951Some text.
952
953<!--
954```
955#%% Cell marker
956import matplotlib.pyplot as plt
957
958#%% Another cell
959data = [1, 2, 3]
960```
961-->
962
963More content.
964"#;
965
966 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
967 let result = rule.check(&ctx).unwrap();
968
969 assert!(
971 result.is_empty(),
972 "Should not flag content inside HTML comments, found {} issues",
973 result.len()
974 );
975 }
976
977 #[test]
978 fn test_mkdocs_magiclink_skips_numeric_refs() {
979 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
981 magiclink: true,
982 ..Default::default()
983 });
984
985 assert!(
987 rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_none(),
988 "#10 should be skipped with magiclink config (MagicLink issue ref)"
989 );
990 assert!(
991 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_none(),
992 "#123 should be skipped with magiclink config (MagicLink issue ref)"
993 );
994 assert!(
995 rule.check_atx_heading_line("#10 discusses the issue", MarkdownFlavor::Standard)
996 .is_none(),
997 "#10 followed by text should be skipped with magiclink config"
998 );
999 assert!(
1000 rule.check_atx_heading_line("#37.", MarkdownFlavor::Standard).is_none(),
1001 "#37 followed by punctuation should be skipped with magiclink config"
1002 );
1003 }
1004
1005 #[test]
1006 fn test_mkdocs_magiclink_still_flags_non_numeric() {
1007 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1009 magiclink: true,
1010 ..Default::default()
1011 });
1012
1013 assert!(
1015 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
1016 .is_some(),
1017 "#Summary should still be flagged with magiclink config"
1018 );
1019 assert!(
1020 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
1021 .is_some(),
1022 "#hello should still be flagged with magiclink config"
1023 );
1024 assert!(
1025 rule.check_atx_heading_line("#10abc", MarkdownFlavor::Standard)
1026 .is_some(),
1027 "#10abc (mixed) should still be flagged with magiclink config"
1028 );
1029 }
1030
1031 #[test]
1032 fn test_mkdocs_magiclink_only_single_hash() {
1033 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1035 magiclink: true,
1036 ..Default::default()
1037 });
1038
1039 assert!(
1040 rule.check_atx_heading_line("##10", MarkdownFlavor::Standard).is_some(),
1041 "##10 should be flagged with magiclink config (only single # is MagicLink)"
1042 );
1043 assert!(
1044 rule.check_atx_heading_line("###123", MarkdownFlavor::Standard)
1045 .is_some(),
1046 "###123 should be flagged with magiclink config"
1047 );
1048 }
1049
1050 #[test]
1051 fn test_standard_flavor_flags_numeric_refs() {
1052 let rule = MD018NoMissingSpaceAtx::new();
1054
1055 assert!(
1056 rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_some(),
1057 "#10 should be flagged in Standard flavor"
1058 );
1059 assert!(
1060 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
1061 "#123 should be flagged in Standard flavor"
1062 );
1063 }
1064
1065 #[test]
1066 fn test_mkdocs_magiclink_full_check() {
1067 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1069 magiclink: true,
1070 ..Default::default()
1071 });
1072
1073 let content = r#"# PRs that are helpful for context
1074
1075#10 discusses the philosophy behind the project, and #37 shows a good example.
1076
1077#Summary
1078
1079##Introduction
1080"#;
1081
1082 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1084 let result = rule.check(&ctx).unwrap();
1085
1086 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1087 assert!(
1088 !flagged_lines.contains(&3),
1089 "#10 should NOT be flagged with magiclink config"
1090 );
1091 assert!(
1092 flagged_lines.contains(&5),
1093 "#Summary SHOULD be flagged with magiclink config"
1094 );
1095 assert!(
1096 flagged_lines.contains(&7),
1097 "##Introduction SHOULD be flagged with magiclink config"
1098 );
1099 }
1100
1101 #[test]
1102 fn test_mkdocs_magiclink_fix_exact_output() {
1103 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1105 magiclink: true,
1106 ..Default::default()
1107 });
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 {
1125 magiclink: true,
1126 ..Default::default()
1127 });
1128
1129 let valid_refs = [
1132 "#10", "#999999", "#10 text after", "#10\ttext after", "#10.", "#10,", "#10!", "#10?", "#10)", "#10]", "#10;", "#10:", ];
1145
1146 for ref_str in valid_refs {
1147 assert!(
1148 rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_none(),
1149 "{ref_str:?} should be skipped as MagicLink ref with magiclink config"
1150 );
1151 }
1152
1153 let invalid_refs = [
1155 "#10abc", "#10a", "#abc10", "#10ABC", "#Summary", "#hello", ];
1162
1163 for ref_str in invalid_refs {
1164 assert!(
1165 rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_some(),
1166 "{ref_str:?} should be flagged with magiclink config (not a valid MagicLink ref)"
1167 );
1168 }
1169 }
1170
1171 #[test]
1172 fn test_mkdocs_magiclink_hyphenated_continuation() {
1173 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1176 magiclink: true,
1177 ..Default::default()
1178 });
1179
1180 assert!(
1185 rule.check_atx_heading_line("#10-", MarkdownFlavor::Standard).is_none(),
1186 "#10- should be skipped with magiclink config (hyphen is non-alphanumeric terminator)"
1187 );
1188 }
1189
1190 #[test]
1191 fn test_mkdocs_magiclink_standalone_number() {
1192 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1194 magiclink: true,
1195 ..Default::default()
1196 });
1197
1198 let content = "See issue:\n\n#10\n\nFor details.";
1199 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1200 let result = rule.check(&ctx).unwrap();
1201
1202 assert!(
1204 result.is_empty(),
1205 "Standalone #10 should not be flagged with magiclink config"
1206 );
1207
1208 let fixed = rule.fix(&ctx).unwrap();
1210 assert_eq!(fixed, content, "fix() should not modify standalone MagicLink ref");
1211 }
1212
1213 #[test]
1214 fn test_standard_flavor_flags_all_numeric() {
1215 let rule = MD018NoMissingSpaceAtx::new();
1218
1219 let numeric_patterns = ["#10", "#123", "#999999", "#10 text"];
1220
1221 for pattern in numeric_patterns {
1222 assert!(
1223 rule.check_atx_heading_line(pattern, MarkdownFlavor::Standard).is_some(),
1224 "{pattern:?} should be flagged in Standard flavor"
1225 );
1226 }
1227
1228 assert!(
1230 rule.check_atx_heading_line("#1", MarkdownFlavor::Standard).is_none(),
1231 "#1 should be skipped (content too short, existing behavior)"
1232 );
1233 }
1234
1235 #[test]
1236 fn test_mkdocs_vs_standard_fix_comparison() {
1237 let content = "#10 is an issue\n#Summary";
1239 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1240
1241 let rule_magiclink = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1243 magiclink: true,
1244 ..Default::default()
1245 });
1246 let fixed_magiclink = rule_magiclink.fix(&ctx).unwrap();
1247 assert_eq!(fixed_magiclink, "#10 is an issue\n# Summary");
1248
1249 let rule_default = MD018NoMissingSpaceAtx::new();
1251 let fixed_default = rule_default.fix(&ctx).unwrap();
1252 assert_eq!(fixed_default, "# 10 is an issue\n# Summary");
1253 }
1254
1255 #[test]
1258 fn test_tags_config_standard_flavor() {
1259 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1261 magiclink: false,
1262 tags: Some(true),
1263 });
1264
1265 let content = "#tag\n\n#project/active\n\n##Introduction";
1266 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1267 let result = rule.check(&ctx).unwrap();
1268
1269 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1270 assert!(!flagged_lines.contains(&1), "#tag should be skipped with tags = true");
1271 assert!(
1272 !flagged_lines.contains(&3),
1273 "#project/active should be skipped with tags = true"
1274 );
1275 assert!(flagged_lines.contains(&5), "##Introduction should still be flagged");
1276 }
1277
1278 #[test]
1279 fn test_tags_config_fix_standard_flavor() {
1280 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1281 magiclink: false,
1282 tags: Some(true),
1283 });
1284
1285 let content = "#tag\n\n##Introduction";
1286 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1287 let fixed = rule.fix(&ctx).unwrap();
1288 assert_eq!(fixed, "#tag\n\n## Introduction");
1289 }
1290
1291 #[test]
1292 fn test_tags_config_disabled_obsidian_flavor() {
1293 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1295 magiclink: false,
1296 tags: Some(false),
1297 });
1298
1299 let content = "#tag\n\n#project/active";
1300 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1301 let result = rule.check(&ctx).unwrap();
1302
1303 assert_eq!(
1304 result.len(),
1305 2,
1306 "tags = false should flag tag patterns even in Obsidian"
1307 );
1308 }
1309
1310 #[test]
1311 fn test_tags_config_default_follows_flavor() {
1312 let rule = MD018NoMissingSpaceAtx::new(); let content = "#tag";
1317 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1318 let result = rule.check(&ctx).unwrap();
1319 assert!(!result.is_empty(), "Default standard should flag #tag");
1320
1321 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1323 let result = rule.check(&ctx).unwrap();
1324 assert!(result.is_empty(), "Default Obsidian should skip #tag");
1325 }
1326
1327 #[test]
1330 fn test_obsidian_tag_skips_simple_tags() {
1331 let rule = MD018NoMissingSpaceAtx::new();
1333
1334 assert!(
1336 rule.check_atx_heading_line("#hey", MarkdownFlavor::Obsidian).is_none(),
1337 "#hey should be skipped in Obsidian flavor (tag syntax)"
1338 );
1339 assert!(
1340 rule.check_atx_heading_line("#tag", MarkdownFlavor::Obsidian).is_none(),
1341 "#tag should be skipped in Obsidian flavor"
1342 );
1343 assert!(
1344 rule.check_atx_heading_line("#hello", MarkdownFlavor::Obsidian)
1345 .is_none(),
1346 "#hello should be skipped in Obsidian flavor"
1347 );
1348 assert!(
1349 rule.check_atx_heading_line("#myTag", MarkdownFlavor::Obsidian)
1350 .is_none(),
1351 "#myTag should be skipped in Obsidian flavor"
1352 );
1353 }
1354
1355 #[test]
1356 fn test_obsidian_tag_skips_complex_tags() {
1357 let rule = MD018NoMissingSpaceAtx::new();
1359
1360 assert!(
1362 rule.check_atx_heading_line("#project/active", MarkdownFlavor::Obsidian)
1363 .is_none(),
1364 "#project/active should be skipped in Obsidian flavor (nested tag)"
1365 );
1366 assert!(
1367 rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1368 .is_none(),
1369 "#my-tag should be skipped in Obsidian flavor"
1370 );
1371 assert!(
1372 rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1373 .is_none(),
1374 "#my_tag should be skipped in Obsidian flavor"
1375 );
1376 assert!(
1377 rule.check_atx_heading_line("#tag2023", MarkdownFlavor::Obsidian)
1378 .is_none(),
1379 "#tag2023 should be skipped in Obsidian flavor"
1380 );
1381 assert!(
1382 rule.check_atx_heading_line("#project/sub/task", MarkdownFlavor::Obsidian)
1383 .is_none(),
1384 "#project/sub/task should be skipped in Obsidian flavor"
1385 );
1386 }
1387
1388 #[test]
1389 fn test_obsidian_tag_with_trailing_content() {
1390 let rule = MD018NoMissingSpaceAtx::new();
1392
1393 assert!(
1394 rule.check_atx_heading_line("#hey ", MarkdownFlavor::Obsidian).is_none(),
1395 "#hey followed by space should be skipped"
1396 );
1397 assert!(
1398 rule.check_atx_heading_line("#tag some text", MarkdownFlavor::Obsidian)
1399 .is_none(),
1400 "#tag followed by text should be skipped"
1401 );
1402 }
1403
1404 #[test]
1405 fn test_obsidian_tag_still_flags_multi_hash() {
1406 let rule = MD018NoMissingSpaceAtx::new();
1408
1409 assert!(
1410 rule.check_atx_heading_line("##tag", MarkdownFlavor::Obsidian).is_some(),
1411 "##tag should be flagged in Obsidian flavor (only single # is a tag)"
1412 );
1413 assert!(
1414 rule.check_atx_heading_line("###hello", MarkdownFlavor::Obsidian)
1415 .is_some(),
1416 "###hello should be flagged in Obsidian flavor"
1417 );
1418 }
1419
1420 #[test]
1421 fn test_obsidian_tag_numeric_still_flagged() {
1422 let rule = MD018NoMissingSpaceAtx::new();
1425
1426 assert!(
1427 rule.check_atx_heading_line("#123", MarkdownFlavor::Obsidian).is_some(),
1428 "#123 should be flagged in Obsidian flavor (no non-numerical character)"
1429 );
1430 assert!(
1431 rule.check_atx_heading_line("#10", MarkdownFlavor::Obsidian).is_some(),
1432 "#10 should be flagged in Obsidian flavor"
1433 );
1434 }
1435
1436 #[test]
1437 fn test_obsidian_flavor_full_check() {
1438 let rule = MD018NoMissingSpaceAtx::new();
1440
1441 let content = r#"# Real Heading
1442
1443#hey this is a tag
1444
1445#project/active also a tag
1446
1447##Introduction
1448
1449#123
1450"#;
1451
1452 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1454 let result = rule.check(&ctx).unwrap();
1455
1456 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1457 assert!(
1458 !flagged_lines.contains(&3),
1459 "#hey should NOT be flagged in Obsidian flavor"
1460 );
1461 assert!(
1462 !flagged_lines.contains(&5),
1463 "#project/active should NOT be flagged in Obsidian flavor"
1464 );
1465 assert!(
1466 flagged_lines.contains(&7),
1467 "##Introduction SHOULD be flagged in Obsidian flavor"
1468 );
1469 assert!(flagged_lines.contains(&9), "#123 SHOULD be flagged in Obsidian flavor");
1470 }
1471
1472 #[test]
1473 fn test_obsidian_flavor_fix_exact_output() {
1474 let rule = MD018NoMissingSpaceAtx::new();
1476
1477 let content = "#hey is a tag.\n\n##Introduction";
1480 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1481 let fixed = rule.fix(&ctx).unwrap();
1482
1483 let expected = "#hey is a tag.\n\n## Introduction";
1485 assert_eq!(
1486 fixed, expected,
1487 "Obsidian fix should preserve tags and fix multi-hash headings"
1488 );
1489 }
1490
1491 #[test]
1492 fn test_standard_flavor_flags_obsidian_tags() {
1493 let rule = MD018NoMissingSpaceAtx::new();
1495
1496 assert!(
1497 rule.check_atx_heading_line("#hey", MarkdownFlavor::Standard).is_some(),
1498 "#hey should be flagged in Standard flavor"
1499 );
1500 assert!(
1501 rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
1502 "#tag should be flagged in Standard flavor"
1503 );
1504 assert!(
1505 rule.check_atx_heading_line("#project/active", MarkdownFlavor::Standard)
1506 .is_some(),
1507 "#project/active should be flagged in Standard flavor"
1508 );
1509 }
1510
1511 #[test]
1512 fn test_obsidian_vs_standard_fix_comparison() {
1513 let rule = MD018NoMissingSpaceAtx::new();
1515
1516 let content = "#hey tag\n##Introduction";
1520
1521 let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1523 let fixed_obsidian = rule.fix(&ctx_obsidian).unwrap();
1524 assert_eq!(fixed_obsidian, "#hey tag\n## Introduction");
1525
1526 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1528 let fixed_standard = rule.fix(&ctx_standard).unwrap();
1529 assert_eq!(fixed_standard, "# hey tag\n## Introduction");
1530 }
1531
1532 #[test]
1533 fn test_obsidian_tag_edge_cases() {
1534 let rule = MD018NoMissingSpaceAtx::new();
1536
1537 let valid_tags = [
1539 "#a", "#tag", "#Tag", "#TAG", "#my-tag", "#my_tag", "#tag123", "#a1", "#日本語", "#über", ];
1550
1551 for tag in valid_tags {
1552 assert!(
1553 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1554 "{tag:?} should be skipped in Obsidian flavor (valid tag)"
1555 );
1556 }
1557
1558 let invalid_tags = ["#123", "#1984", "#37.", "#42,"];
1561
1562 for tag in invalid_tags {
1563 assert!(
1564 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_some(),
1565 "{tag:?} should be flagged in Obsidian flavor (no non-numerical character)"
1566 );
1567 }
1568 }
1569
1570 #[test]
1571 fn test_obsidian_tag_alone_on_line() {
1572 let rule = MD018NoMissingSpaceAtx::new();
1574
1575 let content = "Some text\n\n#todo\n\nMore text.";
1576 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1577 let result = rule.check(&ctx).unwrap();
1578
1579 assert!(
1581 result.is_empty(),
1582 "Standalone #todo should not be flagged in Obsidian flavor"
1583 );
1584
1585 let fixed = rule.fix(&ctx).unwrap();
1587 assert_eq!(fixed, content, "fix() should not modify standalone Obsidian tag");
1588 }
1589
1590 #[test]
1591 fn test_obsidian_deeply_nested_tags() {
1592 let rule = MD018NoMissingSpaceAtx::new();
1594
1595 let nested_tags = [
1596 "#a/b",
1597 "#a/b/c",
1598 "#project/2023/q1/task",
1599 "#work/meetings/weekly",
1600 "#life/health/exercise/running",
1601 ];
1602
1603 for tag in nested_tags {
1604 assert!(
1605 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1606 "{tag:?} should be skipped in Obsidian flavor (nested tag)"
1607 );
1608 }
1609 }
1610
1611 #[test]
1612 fn test_obsidian_unicode_tags() {
1613 let rule = MD018NoMissingSpaceAtx::new();
1615
1616 let unicode_tags = [
1617 "#日本語", "#中文", "#한국어", "#über", "#café", "#ñoño", "#Москва", "#αβγ", ];
1626
1627 for tag in unicode_tags {
1628 assert!(
1629 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1630 "{tag:?} should be skipped in Obsidian flavor (Unicode tag)"
1631 );
1632 }
1633 }
1634
1635 #[test]
1636 fn test_obsidian_tags_with_special_endings() {
1637 let rule = MD018NoMissingSpaceAtx::new();
1639
1640 assert!(
1642 rule.check_atx_heading_line("#tag followed by text", MarkdownFlavor::Obsidian)
1643 .is_none(),
1644 "#tag followed by text should be skipped"
1645 );
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(), "#todo at end of line should be skipped");
1652 }
1653
1654 #[test]
1655 fn test_obsidian_combined_with_other_skip_contexts() {
1656 let rule = MD018NoMissingSpaceAtx::new();
1658
1659 let content = "```\n#todo\n```";
1661 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1662 let result = rule.check(&ctx).unwrap();
1663 assert!(result.is_empty(), "Tag in code block should be skipped");
1664
1665 let content = "<!-- #todo -->";
1667 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1668 let result = rule.check(&ctx).unwrap();
1669 assert!(result.is_empty(), "Tag in HTML comment should be skipped");
1670 }
1671
1672 #[test]
1673 fn test_obsidian_boundary_cases() {
1674 let rule = MD018NoMissingSpaceAtx::new();
1676
1677 assert!(
1681 rule.check_atx_heading_line("#ab", MarkdownFlavor::Obsidian).is_none(),
1682 "#ab should be skipped in Obsidian flavor"
1683 );
1684
1685 assert!(
1687 rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1688 .is_none(),
1689 "#my_tag should be skipped"
1690 );
1691
1692 assert!(
1694 rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1695 .is_none(),
1696 "#my-tag should be skipped"
1697 );
1698
1699 assert!(
1701 rule.check_atx_heading_line("#MyTag", MarkdownFlavor::Obsidian)
1702 .is_none(),
1703 "#MyTag should be skipped"
1704 );
1705
1706 assert!(
1708 rule.check_atx_heading_line("#TODO", MarkdownFlavor::Obsidian).is_none(),
1709 "#TODO should be skipped in Obsidian flavor"
1710 );
1711 }
1712
1713 #[test]
1714 fn test_obsidian_tag_may_start_with_a_digit() {
1715 let rule = MD018NoMissingSpaceAtx::new();
1718
1719 let tags = [
1720 "#3d_printing", "#1tag", "#2023-project", "#100DaysOfCode", "#1on1", "#5S", "#3/4", "#1_2", "#3🔥", "#3\u{FE0F}\u{20E3}", ];
1731
1732 for tag in tags {
1733 assert!(
1734 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1735 "{tag:?} contains a non-numerical character and should be skipped as a tag"
1736 );
1737 }
1738 }
1739
1740 #[test]
1741 fn test_numeric_references_are_not_tags() {
1742 let rule = MD018NoMissingSpaceAtx::new();
1746
1747 let not_tags = [
1748 "#1984", "#123", "#10", "#37.", "#42,", "#42)", "#404 Not Found", "#10 discusses the issue", ];
1757
1758 for line in not_tags {
1759 assert!(
1760 rule.check_atx_heading_line(line, MarkdownFlavor::Obsidian).is_some(),
1761 "{line:?} has no non-numerical tag character and should stay flagged"
1762 );
1763 }
1764 }
1765
1766 #[test]
1767 fn test_digit_leading_tag_survives_check_and_fix() {
1768 let obsidian = MD018NoMissingSpaceAtx::new();
1771 let standard = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1772 magiclink: false,
1773 tags: Some(true),
1774 });
1775
1776 let content = "# Header\n\n#3d_printing\n\n##Heading\n";
1779
1780 for (rule, flavor, label) in [
1781 (&obsidian, MarkdownFlavor::Obsidian, "obsidian flavor"),
1782 (&standard, MarkdownFlavor::Standard, "tags = true"),
1783 ] {
1784 let ctx = LintContext::new(content, flavor, None);
1785 let flagged: Vec<usize> = rule.check(&ctx).unwrap().iter().map(|w| w.line).collect();
1786 assert_eq!(
1787 flagged,
1788 vec![5],
1789 "{label}: only ##Heading should be flagged, got {flagged:?}"
1790 );
1791
1792 let fixed = rule.fix(&ctx).unwrap();
1793 assert_eq!(
1794 fixed, "# Header\n\n#3d_printing\n\n## Heading\n",
1795 "{label}: fix must leave the tag alone and still fix the heading"
1796 );
1797 }
1798 }
1799
1800 #[test]
1801 fn test_digit_leading_tag_is_still_flagged_without_tags_mode() {
1802 let rule = MD018NoMissingSpaceAtx::new();
1805
1806 assert!(
1807 rule.check_atx_heading_line("#3d_printing", MarkdownFlavor::Standard)
1808 .is_some(),
1809 "#3d_printing should be flagged in Standard flavor (tags disabled)"
1810 );
1811 }
1812}