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::range_utils::{byte_to_char_count, calculate_single_line_range};
11use regex::Regex;
12use std::sync::LazyLock;
13
14const EMOJI_HASHTAG_PATTERN_STR: &str = r"^#️⃣|^#⃣";
16const UNICODE_HASHTAG_PATTERN_STR: &str = r"^#[\u{FE0F}\u{20E3}]";
17static EMOJI_HASHTAG_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(EMOJI_HASHTAG_PATTERN_STR).unwrap());
18static UNICODE_HASHTAG_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(UNICODE_HASHTAG_PATTERN_STR).unwrap());
19
20const MAGICLINK_REF_PATTERN_STR: &str = r"^#\d+(?:\s|[^a-zA-Z0-9]|$)";
24static MAGICLINK_REF_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(MAGICLINK_REF_PATTERN_STR).unwrap());
25
26const TAG_PATTERN_STR: &str = r"^#(?:[^\d\s#]|\d+[\p{L}\p{M}\p{So}_/-])[^\s#]*(?:\s|$)";
34static TAG_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(TAG_PATTERN_STR).unwrap());
35
36#[derive(Clone)]
37pub struct MD018NoMissingSpaceAtx {
38 config: MD018Config,
39}
40
41impl Default for MD018NoMissingSpaceAtx {
42 fn default() -> Self {
43 Self::new()
44 }
45}
46
47impl MD018NoMissingSpaceAtx {
48 pub fn new() -> Self {
49 Self {
50 config: MD018Config::default(),
51 }
52 }
53
54 pub fn from_config_struct(config: MD018Config) -> Self {
55 Self { config }
56 }
57
58 fn is_magiclink_ref(line: &str) -> bool {
61 MAGICLINK_REF_PATTERN.is_match(line.trim_start())
62 }
63
64 fn is_tag(line: &str) -> bool {
66 TAG_PATTERN.is_match(line.trim_start())
67 }
68
69 fn tags_enabled(&self, flavor: MarkdownFlavor) -> bool {
71 self.config.tags_enabled(flavor)
72 }
73
74 fn check_atx_heading_line(&self, line: &str, flavor: MarkdownFlavor) -> Option<(usize, String)> {
76 let trimmed_line = line.trim_start();
78 let indent = line.len() - trimmed_line.len();
79
80 if !trimmed_line.starts_with('#') {
81 return None;
82 }
83
84 if indent > 0 {
90 return None;
91 }
92
93 let is_emoji = EMOJI_HASHTAG_PATTERN.is_match(trimmed_line);
95 let is_unicode = UNICODE_HASHTAG_PATTERN.is_match(trimmed_line);
96 if is_emoji || is_unicode {
97 return None;
98 }
99
100 let hash_count = trimmed_line.chars().take_while(|&c| c == '#').count();
102 if hash_count == 0 || hash_count > 6 {
103 return None;
104 }
105
106 let after_hashes = &trimmed_line[hash_count..];
108
109 if after_hashes
111 .chars()
112 .next()
113 .is_some_and(|ch| matches!(ch, '\u{FE0F}' | '\u{20E3}' | '\u{FE0E}'))
114 {
115 return None;
116 }
117
118 if !after_hashes.is_empty() && !after_hashes.starts_with(' ') && !after_hashes.starts_with('\t') {
120 let content = after_hashes.trim();
122
123 if content.chars().all(|c| c == '#') {
125 return None;
126 }
127
128 if content.len() < 2 {
130 return None;
131 }
132
133 if content.starts_with('*') || content.starts_with('_') {
135 return None;
136 }
137
138 if self.config.magiclink && hash_count == 1 && Self::is_magiclink_ref(line) {
141 return None;
142 }
143
144 if self.tags_enabled(flavor) && hash_count == 1 && Self::is_tag(line) {
147 return None;
148 }
149
150 let fixed = format!("{}{} {}", " ".repeat(indent), "#".repeat(hash_count), after_hashes);
152 return Some((indent + hash_count, fixed));
153 }
154
155 None
156 }
157
158 fn get_line_byte_range(&self, content: &str, line_num: usize) -> std::ops::Range<usize> {
160 let mut current_line = 1;
161 let mut start_byte = 0;
162
163 for (i, c) in content.char_indices() {
164 if current_line == line_num && c == '\n' {
165 return start_byte..i;
166 } else if c == '\n' {
167 current_line += 1;
168 if current_line == line_num {
169 start_byte = i + 1;
170 }
171 }
172 }
173
174 if current_line == line_num {
176 return start_byte..content.len();
177 }
178
179 0..0
181 }
182}
183
184impl Rule for MD018NoMissingSpaceAtx {
185 fn name(&self) -> &'static str {
186 "MD018"
187 }
188
189 fn description(&self) -> &'static str {
190 "No space after hash in heading"
191 }
192
193 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
194 let mut warnings = Vec::new();
195
196 for (line_num, line_info) in ctx.lines.iter().enumerate() {
198 if line_info.in_html_block
200 || line_info.in_html_comment
201 || line_info.in_mdx_comment
202 || line_info.in_pymdown_block
203 {
204 continue;
205 }
206
207 if let Some(heading) = &line_info.heading {
208 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
210 if line_info.indent > 0 {
213 continue;
214 }
215
216 let line = line_info.content(ctx.content);
218 let trimmed = line.trim_start();
219
220 let is_emoji = EMOJI_HASHTAG_PATTERN.is_match(trimmed);
222 let is_unicode = UNICODE_HASHTAG_PATTERN.is_match(trimmed);
223 if is_emoji || is_unicode {
224 continue;
225 }
226
227 if self.config.magiclink && heading.level == 1 && Self::is_magiclink_ref(line) {
229 continue;
230 }
231
232 if self.tags_enabled(ctx.flavor) && heading.level == 1 && Self::is_tag(line) {
234 continue;
235 }
236
237 if trimmed.len() > heading.marker.len() {
238 let after_marker = &trimmed[heading.marker.len()..];
239 if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
240 {
241 let hash_end_col = byte_to_char_count(line, line_info.indent + heading.marker.len());
244 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
245 line_num + 1, hash_end_col,
247 0, );
249
250 warnings.push(LintWarning {
251 rule_name: Some(self.name().to_string()),
252 message: format!("No space after {} in heading", "#".repeat(heading.level as usize)),
253 line: start_line,
254 column: start_col,
255 end_line,
256 end_column: end_col,
257 severity: Severity::Warning,
258 fix: Some(Fix::new(self.get_line_byte_range(ctx.content, line_num + 1), {
259 let line = line_info.content(ctx.content);
261 let original_indent = &line[..line_info.indent];
262 format!("{original_indent}{} {after_marker}", heading.marker)
263 })),
264 });
265 }
266 }
267 }
268 } else if !line_info.in_code_block
269 && !line_info.in_front_matter
270 && !line_info.in_html_comment
271 && !line_info.in_mdx_comment
272 && !line_info.is_blank
273 {
274 if let Some((hash_end_pos, fixed_line)) =
276 self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor)
277 {
278 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
279 line_num + 1, hash_end_pos + 1, 0, );
283
284 warnings.push(LintWarning {
285 rule_name: Some(self.name().to_string()),
286 message: "No space after hash in heading".to_string(),
287 line: start_line,
288 column: start_col,
289 end_line,
290 end_column: end_col,
291 severity: Severity::Warning,
292 fix: Some(Fix::new(
293 self.get_line_byte_range(ctx.content, line_num + 1),
294 fixed_line,
295 )),
296 });
297 }
298 }
299 }
300
301 Ok(warnings)
302 }
303
304 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
305 let warnings = self.check(ctx)?;
306 let warnings =
307 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
308 let warning_lines: std::collections::HashSet<usize> = warnings.iter().map(|w| w.line).collect();
309
310 let mut lines = Vec::new();
311
312 for (idx, line_info) in ctx.lines.iter().enumerate() {
313 let mut fixed = false;
314
315 if !warning_lines.contains(&(idx + 1)) {
316 lines.push(line_info.content(ctx.content).to_string());
317 continue;
318 }
319
320 if let Some(heading) = &line_info.heading {
321 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
323 let line = line_info.content(ctx.content);
324 let trimmed = line.trim_start();
325
326 let is_emoji = EMOJI_HASHTAG_PATTERN.is_match(trimmed);
328 let is_unicode = UNICODE_HASHTAG_PATTERN.is_match(trimmed);
329
330 let is_magiclink = self.config.magiclink && heading.level == 1 && Self::is_magiclink_ref(line);
332
333 let is_tag = self.tags_enabled(ctx.flavor) && heading.level == 1 && Self::is_tag(line);
335
336 if !is_emoji && !is_unicode && !is_magiclink && !is_tag && trimmed.len() > heading.marker.len() {
338 let after_marker = &trimmed[heading.marker.len()..];
339 if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
340 {
341 let line = line_info.content(ctx.content);
343 let original_indent = &line[..line_info.indent];
344 lines.push(format!("{original_indent}{} {after_marker}", heading.marker));
345 fixed = true;
346 }
347 }
348 }
349 } else if !line_info.in_code_block
350 && !line_info.in_front_matter
351 && !line_info.in_html_comment
352 && !line_info.in_mdx_comment
353 && !line_info.is_blank
354 {
355 if let Some((_, fixed_line)) = self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor) {
357 lines.push(fixed_line);
358 fixed = true;
359 }
360 }
361
362 if !fixed {
363 lines.push(line_info.content(ctx.content).to_string());
364 }
365 }
366
367 let mut result = lines.join("\n");
369 if ctx.content.ends_with('\n') && !result.ends_with('\n') {
370 result.push('\n');
371 }
372
373 Ok(result)
374 }
375
376 fn category(&self) -> RuleCategory {
378 RuleCategory::Heading
379 }
380
381 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
383 !ctx.likely_has_headings()
385 }
386
387 fn as_any(&self) -> &dyn std::any::Any {
388 self
389 }
390
391 crate::impl_rule_config_methods!(MD018Config);
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use crate::lint_context::LintContext;
398
399 #[test]
400 fn test_basic_functionality() {
401 let rule = MD018NoMissingSpaceAtx::new();
402
403 let content = "# Heading 1\n## Heading 2\n### Heading 3";
405 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
406 let result = rule.check(&ctx).unwrap();
407 assert!(result.is_empty());
408
409 let content = "#Heading 1\n## Heading 2\n###Heading 3";
411 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
412 let result = rule.check(&ctx).unwrap();
413 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
415 assert_eq!(result[1].line, 3);
416 }
417
418 #[test]
419 fn test_malformed_heading_detection() {
420 let rule = MD018NoMissingSpaceAtx::new();
421
422 assert!(
424 rule.check_atx_heading_line("##Introduction", MarkdownFlavor::Standard)
425 .is_some()
426 );
427 assert!(
428 rule.check_atx_heading_line("###Background", MarkdownFlavor::Standard)
429 .is_some()
430 );
431 assert!(
432 rule.check_atx_heading_line("####Details", MarkdownFlavor::Standard)
433 .is_some()
434 );
435 assert!(
436 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
437 .is_some()
438 );
439 assert!(
440 rule.check_atx_heading_line("######Conclusion", MarkdownFlavor::Standard)
441 .is_some()
442 );
443 assert!(
444 rule.check_atx_heading_line("##Table of Contents", MarkdownFlavor::Standard)
445 .is_some()
446 );
447
448 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!(
453 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
454 .is_none()
455 ); assert!(
457 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
458 .is_none()
459 ); }
461
462 #[test]
463 fn test_malformed_heading_with_context() {
464 let rule = MD018NoMissingSpaceAtx::new();
465
466 let content = r#"# Test Document
468
469##Introduction
470This should be detected.
471
472 ##CodeBlock
473This should NOT be detected (indented code block).
474
475```
476##FencedCodeBlock
477This should NOT be detected (fenced code block).
478```
479
480##Conclusion
481This should be detected.
482"#;
483
484 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
485 let result = rule.check(&ctx).unwrap();
486
487 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
489 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&14)); assert!(!detected_lines.contains(&6)); assert!(!detected_lines.contains(&10)); }
494
495 #[test]
496 fn test_malformed_heading_fix() {
497 let rule = MD018NoMissingSpaceAtx::new();
498
499 let content = r#"##Introduction
500This is a test.
501
502###Background
503More content."#;
504
505 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
506 let fixed = rule.fix(&ctx).unwrap();
507
508 let expected = r#"## Introduction
509This is a test.
510
511### Background
512More content."#;
513
514 assert_eq!(fixed, expected);
515 }
516
517 #[test]
518 fn test_mixed_proper_and_malformed_headings() {
519 let rule = MD018NoMissingSpaceAtx::new();
520
521 let content = r#"# Proper Heading
522
523##Malformed Heading
524
525## Another Proper Heading
526
527###Another Malformed
528
529#### Proper with space
530"#;
531
532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
533 let result = rule.check(&ctx).unwrap();
534
535 assert_eq!(result.len(), 2);
537 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
538 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&7)); }
541
542 #[test]
543 fn test_css_selectors_in_html_blocks() {
544 let rule = MD018NoMissingSpaceAtx::new();
545
546 let content = r#"# Proper Heading
549
550<style>
551#slide-1 ol li {
552 margin-top: 0;
553}
554
555#special-slide ol li {
556 margin-top: 2em;
557}
558</style>
559
560## Another Heading
561"#;
562
563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
564 let result = rule.check(&ctx).unwrap();
565
566 assert_eq!(
568 result.len(),
569 0,
570 "CSS selectors in <style> blocks should not be flagged as malformed headings"
571 );
572 }
573
574 #[test]
575 fn test_js_code_in_script_blocks() {
576 let rule = MD018NoMissingSpaceAtx::new();
577
578 let content = r#"# Heading
580
581<script>
582const element = document.querySelector('#main-content');
583#another-comment
584</script>
585
586## Another Heading
587"#;
588
589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
590 let result = rule.check(&ctx).unwrap();
591
592 assert_eq!(
594 result.len(),
595 0,
596 "JavaScript code in <script> blocks should not be flagged as malformed headings"
597 );
598 }
599
600 #[test]
601 fn test_all_malformed_headings_detected() {
602 let rule = MD018NoMissingSpaceAtx::new();
603
604 assert!(
609 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
610 .is_some(),
611 "#hello SHOULD be detected as malformed heading"
612 );
613 assert!(
614 rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
615 "#tag SHOULD be detected as malformed heading"
616 );
617 assert!(
618 rule.check_atx_heading_line("#hashtag", MarkdownFlavor::Standard)
619 .is_some(),
620 "#hashtag SHOULD be detected as malformed heading"
621 );
622 assert!(
623 rule.check_atx_heading_line("#javascript", MarkdownFlavor::Standard)
624 .is_some(),
625 "#javascript SHOULD be detected as malformed heading"
626 );
627
628 assert!(
630 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
631 "#123 SHOULD be detected as malformed heading"
632 );
633 assert!(
634 rule.check_atx_heading_line("#12345", MarkdownFlavor::Standard)
635 .is_some(),
636 "#12345 SHOULD be detected as malformed heading"
637 );
638 assert!(
639 rule.check_atx_heading_line("#29039)", MarkdownFlavor::Standard)
640 .is_some(),
641 "#29039) SHOULD be detected as malformed heading"
642 );
643
644 assert!(
646 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
647 .is_some(),
648 "#Summary SHOULD be detected as malformed heading"
649 );
650 assert!(
651 rule.check_atx_heading_line("#Introduction", MarkdownFlavor::Standard)
652 .is_some(),
653 "#Introduction SHOULD be detected as malformed heading"
654 );
655 assert!(
656 rule.check_atx_heading_line("#API", MarkdownFlavor::Standard).is_some(),
657 "#API SHOULD be detected as malformed heading"
658 );
659
660 assert!(
662 rule.check_atx_heading_line("##introduction", MarkdownFlavor::Standard)
663 .is_some(),
664 "##introduction SHOULD be detected as malformed heading"
665 );
666 assert!(
667 rule.check_atx_heading_line("###section", MarkdownFlavor::Standard)
668 .is_some(),
669 "###section SHOULD be detected as malformed heading"
670 );
671 assert!(
672 rule.check_atx_heading_line("###fer", MarkdownFlavor::Standard)
673 .is_some(),
674 "###fer SHOULD be detected as malformed heading"
675 );
676 assert!(
677 rule.check_atx_heading_line("##123", MarkdownFlavor::Standard).is_some(),
678 "##123 SHOULD be detected as malformed heading"
679 );
680 }
681
682 #[test]
683 fn test_patterns_that_should_not_be_flagged() {
684 let rule = MD018NoMissingSpaceAtx::new();
685
686 assert!(rule.check_atx_heading_line("###", MarkdownFlavor::Standard).is_none());
688 assert!(rule.check_atx_heading_line("#", MarkdownFlavor::Standard).is_none());
689
690 assert!(rule.check_atx_heading_line("##a", MarkdownFlavor::Standard).is_none());
692
693 assert!(
695 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
696 .is_none()
697 );
698
699 assert!(
701 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
702 .is_none()
703 );
704
705 assert!(
707 rule.check_atx_heading_line("# Hello", MarkdownFlavor::Standard)
708 .is_none()
709 );
710 assert!(
711 rule.check_atx_heading_line("## World", MarkdownFlavor::Standard)
712 .is_none()
713 );
714 assert!(
715 rule.check_atx_heading_line("### Section", MarkdownFlavor::Standard)
716 .is_none()
717 );
718 }
719
720 #[test]
721 fn test_inline_issue_refs_not_at_line_start() {
722 let rule = MD018NoMissingSpaceAtx::new();
723
724 assert!(
729 rule.check_atx_heading_line("See issue #123", MarkdownFlavor::Standard)
730 .is_none()
731 );
732 assert!(
733 rule.check_atx_heading_line("Check #trending on Twitter", MarkdownFlavor::Standard)
734 .is_none()
735 );
736 assert!(
737 rule.check_atx_heading_line("- fix: issue #29039", MarkdownFlavor::Standard)
738 .is_none()
739 );
740 }
741
742 #[test]
743 fn test_lowercase_patterns_full_check() {
744 let rule = MD018NoMissingSpaceAtx::new();
746
747 let content = "#hello\n\n#world\n\n#tag";
748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749 let result = rule.check(&ctx).unwrap();
750
751 assert_eq!(result.len(), 3, "All three lowercase patterns should be flagged");
752 assert_eq!(result[0].line, 1);
753 assert_eq!(result[1].line, 3);
754 assert_eq!(result[2].line, 5);
755 }
756
757 #[test]
758 fn test_numeric_patterns_full_check() {
759 let rule = MD018NoMissingSpaceAtx::new();
761
762 let content = "#123\n\n#456\n\n#29039";
763 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
764 let result = rule.check(&ctx).unwrap();
765
766 assert_eq!(result.len(), 3, "All three numeric patterns should be flagged");
767 }
768
769 #[test]
770 fn test_fix_lowercase_patterns() {
771 let rule = MD018NoMissingSpaceAtx::new();
773
774 let content = "#hello\nSome text.\n\n#world";
775 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
776 let fixed = rule.fix(&ctx).unwrap();
777
778 let expected = "# hello\nSome text.\n\n# world";
779 assert_eq!(fixed, expected);
780 }
781
782 #[test]
783 fn test_fix_numeric_patterns() {
784 let rule = MD018NoMissingSpaceAtx::new();
786
787 let content = "#123\nContent.\n\n##456";
788 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
789 let fixed = rule.fix(&ctx).unwrap();
790
791 let expected = "# 123\nContent.\n\n## 456";
792 assert_eq!(fixed, expected);
793 }
794
795 #[test]
796 fn test_indented_malformed_headings() {
797 let rule = MD018NoMissingSpaceAtx::new();
801
802 assert!(
804 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
805 .is_none(),
806 "1-space indented #hello should be skipped"
807 );
808 assert!(
809 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
810 .is_none(),
811 "2-space indented #hello should be skipped"
812 );
813 assert!(
814 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
815 .is_none(),
816 "3-space indented #hello should be skipped"
817 );
818
819 assert!(
824 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
825 .is_some(),
826 "Non-indented #hello should be detected"
827 );
828 }
829
830 #[test]
831 fn test_tab_after_hash_is_valid() {
832 let rule = MD018NoMissingSpaceAtx::new();
834
835 assert!(
836 rule.check_atx_heading_line("#\tHello", MarkdownFlavor::Standard)
837 .is_none(),
838 "Tab after # should be valid"
839 );
840 assert!(
841 rule.check_atx_heading_line("##\tWorld", MarkdownFlavor::Standard)
842 .is_none(),
843 "Tab after ## should be valid"
844 );
845 }
846
847 #[test]
848 fn test_mixed_case_patterns() {
849 let rule = MD018NoMissingSpaceAtx::new();
850
851 assert!(
853 rule.check_atx_heading_line("#hELLO", MarkdownFlavor::Standard)
854 .is_some()
855 );
856 assert!(
857 rule.check_atx_heading_line("#Hello", MarkdownFlavor::Standard)
858 .is_some()
859 );
860 assert!(
861 rule.check_atx_heading_line("#HELLO", MarkdownFlavor::Standard)
862 .is_some()
863 );
864 assert!(
865 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
866 .is_some()
867 );
868 }
869
870 #[test]
871 fn test_unicode_lowercase() {
872 let rule = MD018NoMissingSpaceAtx::new();
873
874 assert!(
876 rule.check_atx_heading_line("#über", MarkdownFlavor::Standard).is_some(),
877 "Unicode lowercase #über should be detected"
878 );
879 assert!(
880 rule.check_atx_heading_line("#café", MarkdownFlavor::Standard).is_some(),
881 "Unicode lowercase #café should be detected"
882 );
883 assert!(
884 rule.check_atx_heading_line("#日本語", MarkdownFlavor::Standard)
885 .is_some(),
886 "Japanese #日本語 should be detected"
887 );
888 }
889
890 #[test]
891 fn test_matches_markdownlint_behavior() {
892 let rule = MD018NoMissingSpaceAtx::new();
894
895 let content = r#"#hello
896
897## world
898
899###fer
900
901#123
902
903#Tag
904"#;
905
906 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
907 let result = rule.check(&ctx).unwrap();
908
909 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
912
913 assert!(flagged_lines.contains(&1), "#hello should be flagged");
914 assert!(!flagged_lines.contains(&3), "## world should NOT be flagged");
915 assert!(flagged_lines.contains(&5), "###fer should be flagged");
916 assert!(flagged_lines.contains(&7), "#123 should be flagged");
917 assert!(flagged_lines.contains(&9), "#Tag should be flagged");
918
919 assert_eq!(result.len(), 4, "Should have exactly 4 warnings");
920 }
921
922 #[test]
923 fn test_skip_frontmatter_yaml_comments() {
924 let rule = MD018NoMissingSpaceAtx::new();
926
927 let content = r#"---
928#reviewers:
929#- sig-api-machinery
930#another_comment: value
931title: Test Document
932---
933
934# Valid heading
935
936#invalid heading without space
937"#;
938
939 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
940 let result = rule.check(&ctx).unwrap();
941
942 assert_eq!(
945 result.len(),
946 1,
947 "Should only flag the malformed heading outside frontmatter"
948 );
949 assert_eq!(result[0].line, 10, "Should flag line 10");
950 }
951
952 #[test]
953 fn test_skip_html_comments() {
954 let rule = MD018NoMissingSpaceAtx::new();
957
958 let content = r#"# Real Heading
959
960Some text.
961
962<!--
963```
964#%% Cell marker
965import matplotlib.pyplot as plt
966
967#%% Another cell
968data = [1, 2, 3]
969```
970-->
971
972More content.
973"#;
974
975 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
976 let result = rule.check(&ctx).unwrap();
977
978 assert!(
980 result.is_empty(),
981 "Should not flag content inside HTML comments, found {} issues",
982 result.len()
983 );
984 }
985
986 #[test]
987 fn test_mkdocs_magiclink_skips_numeric_refs() {
988 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
990 magiclink: true,
991 ..Default::default()
992 });
993
994 assert!(
996 rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_none(),
997 "#10 should be skipped with magiclink config (MagicLink issue ref)"
998 );
999 assert!(
1000 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_none(),
1001 "#123 should be skipped with magiclink config (MagicLink issue ref)"
1002 );
1003 assert!(
1004 rule.check_atx_heading_line("#10 discusses the issue", MarkdownFlavor::Standard)
1005 .is_none(),
1006 "#10 followed by text should be skipped with magiclink config"
1007 );
1008 assert!(
1009 rule.check_atx_heading_line("#37.", MarkdownFlavor::Standard).is_none(),
1010 "#37 followed by punctuation should be skipped with magiclink config"
1011 );
1012 }
1013
1014 #[test]
1015 fn test_mkdocs_magiclink_still_flags_non_numeric() {
1016 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1018 magiclink: true,
1019 ..Default::default()
1020 });
1021
1022 assert!(
1024 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
1025 .is_some(),
1026 "#Summary should still be flagged with magiclink config"
1027 );
1028 assert!(
1029 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
1030 .is_some(),
1031 "#hello should still be flagged with magiclink config"
1032 );
1033 assert!(
1034 rule.check_atx_heading_line("#10abc", MarkdownFlavor::Standard)
1035 .is_some(),
1036 "#10abc (mixed) should still be flagged with magiclink config"
1037 );
1038 }
1039
1040 #[test]
1041 fn test_mkdocs_magiclink_only_single_hash() {
1042 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1044 magiclink: true,
1045 ..Default::default()
1046 });
1047
1048 assert!(
1049 rule.check_atx_heading_line("##10", MarkdownFlavor::Standard).is_some(),
1050 "##10 should be flagged with magiclink config (only single # is MagicLink)"
1051 );
1052 assert!(
1053 rule.check_atx_heading_line("###123", MarkdownFlavor::Standard)
1054 .is_some(),
1055 "###123 should be flagged with magiclink config"
1056 );
1057 }
1058
1059 #[test]
1060 fn test_standard_flavor_flags_numeric_refs() {
1061 let rule = MD018NoMissingSpaceAtx::new();
1063
1064 assert!(
1065 rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_some(),
1066 "#10 should be flagged in Standard flavor"
1067 );
1068 assert!(
1069 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
1070 "#123 should be flagged in Standard flavor"
1071 );
1072 }
1073
1074 #[test]
1075 fn test_mkdocs_magiclink_full_check() {
1076 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1078 magiclink: true,
1079 ..Default::default()
1080 });
1081
1082 let content = r#"# PRs that are helpful for context
1083
1084#10 discusses the philosophy behind the project, and #37 shows a good example.
1085
1086#Summary
1087
1088##Introduction
1089"#;
1090
1091 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1093 let result = rule.check(&ctx).unwrap();
1094
1095 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1096 assert!(
1097 !flagged_lines.contains(&3),
1098 "#10 should NOT be flagged with magiclink config"
1099 );
1100 assert!(
1101 flagged_lines.contains(&5),
1102 "#Summary SHOULD be flagged with magiclink config"
1103 );
1104 assert!(
1105 flagged_lines.contains(&7),
1106 "##Introduction SHOULD be flagged with magiclink config"
1107 );
1108 }
1109
1110 #[test]
1111 fn test_mkdocs_magiclink_fix_exact_output() {
1112 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1114 magiclink: true,
1115 ..Default::default()
1116 });
1117
1118 let content = "#10 discusses the issue.\n\n#Summary";
1119 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1120 let fixed = rule.fix(&ctx).unwrap();
1121
1122 let expected = "#10 discusses the issue.\n\n# Summary";
1124 assert_eq!(
1125 fixed, expected,
1126 "magiclink config fix should preserve MagicLink refs and fix non-numeric headings"
1127 );
1128 }
1129
1130 #[test]
1131 fn test_mkdocs_magiclink_edge_cases() {
1132 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1134 magiclink: true,
1135 ..Default::default()
1136 });
1137
1138 let valid_refs = [
1141 "#10", "#999999", "#10 text after", "#10\ttext after", "#10.", "#10,", "#10!", "#10?", "#10)", "#10]", "#10;", "#10:", ];
1154
1155 for ref_str in valid_refs {
1156 assert!(
1157 rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_none(),
1158 "{ref_str:?} should be skipped as MagicLink ref with magiclink config"
1159 );
1160 }
1161
1162 let invalid_refs = [
1164 "#10abc", "#10a", "#abc10", "#10ABC", "#Summary", "#hello", ];
1171
1172 for ref_str in invalid_refs {
1173 assert!(
1174 rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_some(),
1175 "{ref_str:?} should be flagged with magiclink config (not a valid MagicLink ref)"
1176 );
1177 }
1178 }
1179
1180 #[test]
1181 fn test_mkdocs_magiclink_hyphenated_continuation() {
1182 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1185 magiclink: true,
1186 ..Default::default()
1187 });
1188
1189 assert!(
1194 rule.check_atx_heading_line("#10-", MarkdownFlavor::Standard).is_none(),
1195 "#10- should be skipped with magiclink config (hyphen is non-alphanumeric terminator)"
1196 );
1197 }
1198
1199 #[test]
1200 fn test_mkdocs_magiclink_standalone_number() {
1201 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1203 magiclink: true,
1204 ..Default::default()
1205 });
1206
1207 let content = "See issue:\n\n#10\n\nFor details.";
1208 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1209 let result = rule.check(&ctx).unwrap();
1210
1211 assert!(
1213 result.is_empty(),
1214 "Standalone #10 should not be flagged with magiclink config"
1215 );
1216
1217 let fixed = rule.fix(&ctx).unwrap();
1219 assert_eq!(fixed, content, "fix() should not modify standalone MagicLink ref");
1220 }
1221
1222 #[test]
1223 fn test_standard_flavor_flags_all_numeric() {
1224 let rule = MD018NoMissingSpaceAtx::new();
1227
1228 let numeric_patterns = ["#10", "#123", "#999999", "#10 text"];
1229
1230 for pattern in numeric_patterns {
1231 assert!(
1232 rule.check_atx_heading_line(pattern, MarkdownFlavor::Standard).is_some(),
1233 "{pattern:?} should be flagged in Standard flavor"
1234 );
1235 }
1236
1237 assert!(
1239 rule.check_atx_heading_line("#1", MarkdownFlavor::Standard).is_none(),
1240 "#1 should be skipped (content too short, existing behavior)"
1241 );
1242 }
1243
1244 #[test]
1245 fn test_mkdocs_vs_standard_fix_comparison() {
1246 let content = "#10 is an issue\n#Summary";
1248 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1249
1250 let rule_magiclink = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1252 magiclink: true,
1253 ..Default::default()
1254 });
1255 let fixed_magiclink = rule_magiclink.fix(&ctx).unwrap();
1256 assert_eq!(fixed_magiclink, "#10 is an issue\n# Summary");
1257
1258 let rule_default = MD018NoMissingSpaceAtx::new();
1260 let fixed_default = rule_default.fix(&ctx).unwrap();
1261 assert_eq!(fixed_default, "# 10 is an issue\n# Summary");
1262 }
1263
1264 #[test]
1267 fn test_tags_config_standard_flavor() {
1268 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1270 magiclink: false,
1271 tags: Some(true),
1272 });
1273
1274 let content = "#tag\n\n#project/active\n\n##Introduction";
1275 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1276 let result = rule.check(&ctx).unwrap();
1277
1278 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1279 assert!(!flagged_lines.contains(&1), "#tag should be skipped with tags = true");
1280 assert!(
1281 !flagged_lines.contains(&3),
1282 "#project/active should be skipped with tags = true"
1283 );
1284 assert!(flagged_lines.contains(&5), "##Introduction should still be flagged");
1285 }
1286
1287 #[test]
1288 fn test_tags_config_fix_standard_flavor() {
1289 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1290 magiclink: false,
1291 tags: Some(true),
1292 });
1293
1294 let content = "#tag\n\n##Introduction";
1295 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1296 let fixed = rule.fix(&ctx).unwrap();
1297 assert_eq!(fixed, "#tag\n\n## Introduction");
1298 }
1299
1300 #[test]
1301 fn test_tags_config_disabled_obsidian_flavor() {
1302 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1304 magiclink: false,
1305 tags: Some(false),
1306 });
1307
1308 let content = "#tag\n\n#project/active";
1309 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1310 let result = rule.check(&ctx).unwrap();
1311
1312 assert_eq!(
1313 result.len(),
1314 2,
1315 "tags = false should flag tag patterns even in Obsidian"
1316 );
1317 }
1318
1319 #[test]
1320 fn test_tags_config_default_follows_flavor() {
1321 let rule = MD018NoMissingSpaceAtx::new(); let content = "#tag";
1326 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1327 let result = rule.check(&ctx).unwrap();
1328 assert!(!result.is_empty(), "Default standard should flag #tag");
1329
1330 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1332 let result = rule.check(&ctx).unwrap();
1333 assert!(result.is_empty(), "Default Obsidian should skip #tag");
1334 }
1335
1336 #[test]
1339 fn test_obsidian_tag_skips_simple_tags() {
1340 let rule = MD018NoMissingSpaceAtx::new();
1342
1343 assert!(
1345 rule.check_atx_heading_line("#hey", MarkdownFlavor::Obsidian).is_none(),
1346 "#hey should be skipped in Obsidian flavor (tag syntax)"
1347 );
1348 assert!(
1349 rule.check_atx_heading_line("#tag", MarkdownFlavor::Obsidian).is_none(),
1350 "#tag should be skipped in Obsidian flavor"
1351 );
1352 assert!(
1353 rule.check_atx_heading_line("#hello", MarkdownFlavor::Obsidian)
1354 .is_none(),
1355 "#hello should be skipped in Obsidian flavor"
1356 );
1357 assert!(
1358 rule.check_atx_heading_line("#myTag", MarkdownFlavor::Obsidian)
1359 .is_none(),
1360 "#myTag should be skipped in Obsidian flavor"
1361 );
1362 }
1363
1364 #[test]
1365 fn test_obsidian_tag_skips_complex_tags() {
1366 let rule = MD018NoMissingSpaceAtx::new();
1368
1369 assert!(
1371 rule.check_atx_heading_line("#project/active", MarkdownFlavor::Obsidian)
1372 .is_none(),
1373 "#project/active should be skipped in Obsidian flavor (nested tag)"
1374 );
1375 assert!(
1376 rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1377 .is_none(),
1378 "#my-tag should be skipped in Obsidian flavor"
1379 );
1380 assert!(
1381 rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1382 .is_none(),
1383 "#my_tag should be skipped in Obsidian flavor"
1384 );
1385 assert!(
1386 rule.check_atx_heading_line("#tag2023", MarkdownFlavor::Obsidian)
1387 .is_none(),
1388 "#tag2023 should be skipped in Obsidian flavor"
1389 );
1390 assert!(
1391 rule.check_atx_heading_line("#project/sub/task", MarkdownFlavor::Obsidian)
1392 .is_none(),
1393 "#project/sub/task should be skipped in Obsidian flavor"
1394 );
1395 }
1396
1397 #[test]
1398 fn test_obsidian_tag_with_trailing_content() {
1399 let rule = MD018NoMissingSpaceAtx::new();
1401
1402 assert!(
1403 rule.check_atx_heading_line("#hey ", MarkdownFlavor::Obsidian).is_none(),
1404 "#hey followed by space should be skipped"
1405 );
1406 assert!(
1407 rule.check_atx_heading_line("#tag some text", MarkdownFlavor::Obsidian)
1408 .is_none(),
1409 "#tag followed by text should be skipped"
1410 );
1411 }
1412
1413 #[test]
1414 fn test_obsidian_tag_still_flags_multi_hash() {
1415 let rule = MD018NoMissingSpaceAtx::new();
1417
1418 assert!(
1419 rule.check_atx_heading_line("##tag", MarkdownFlavor::Obsidian).is_some(),
1420 "##tag should be flagged in Obsidian flavor (only single # is a tag)"
1421 );
1422 assert!(
1423 rule.check_atx_heading_line("###hello", MarkdownFlavor::Obsidian)
1424 .is_some(),
1425 "###hello should be flagged in Obsidian flavor"
1426 );
1427 }
1428
1429 #[test]
1430 fn test_obsidian_tag_numeric_still_flagged() {
1431 let rule = MD018NoMissingSpaceAtx::new();
1434
1435 assert!(
1436 rule.check_atx_heading_line("#123", MarkdownFlavor::Obsidian).is_some(),
1437 "#123 should be flagged in Obsidian flavor (no non-numerical character)"
1438 );
1439 assert!(
1440 rule.check_atx_heading_line("#10", MarkdownFlavor::Obsidian).is_some(),
1441 "#10 should be flagged in Obsidian flavor"
1442 );
1443 }
1444
1445 #[test]
1446 fn test_obsidian_flavor_full_check() {
1447 let rule = MD018NoMissingSpaceAtx::new();
1449
1450 let content = r#"# Real Heading
1451
1452#hey this is a tag
1453
1454#project/active also a tag
1455
1456##Introduction
1457
1458#123
1459"#;
1460
1461 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1463 let result = rule.check(&ctx).unwrap();
1464
1465 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1466 assert!(
1467 !flagged_lines.contains(&3),
1468 "#hey should NOT be flagged in Obsidian flavor"
1469 );
1470 assert!(
1471 !flagged_lines.contains(&5),
1472 "#project/active should NOT be flagged in Obsidian flavor"
1473 );
1474 assert!(
1475 flagged_lines.contains(&7),
1476 "##Introduction SHOULD be flagged in Obsidian flavor"
1477 );
1478 assert!(flagged_lines.contains(&9), "#123 SHOULD be flagged in Obsidian flavor");
1479 }
1480
1481 #[test]
1482 fn test_obsidian_flavor_fix_exact_output() {
1483 let rule = MD018NoMissingSpaceAtx::new();
1485
1486 let content = "#hey is a tag.\n\n##Introduction";
1489 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1490 let fixed = rule.fix(&ctx).unwrap();
1491
1492 let expected = "#hey is a tag.\n\n## Introduction";
1494 assert_eq!(
1495 fixed, expected,
1496 "Obsidian fix should preserve tags and fix multi-hash headings"
1497 );
1498 }
1499
1500 #[test]
1501 fn test_standard_flavor_flags_obsidian_tags() {
1502 let rule = MD018NoMissingSpaceAtx::new();
1504
1505 assert!(
1506 rule.check_atx_heading_line("#hey", MarkdownFlavor::Standard).is_some(),
1507 "#hey should be flagged in Standard flavor"
1508 );
1509 assert!(
1510 rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
1511 "#tag should be flagged in Standard flavor"
1512 );
1513 assert!(
1514 rule.check_atx_heading_line("#project/active", MarkdownFlavor::Standard)
1515 .is_some(),
1516 "#project/active should be flagged in Standard flavor"
1517 );
1518 }
1519
1520 #[test]
1521 fn test_obsidian_vs_standard_fix_comparison() {
1522 let rule = MD018NoMissingSpaceAtx::new();
1524
1525 let content = "#hey tag\n##Introduction";
1529
1530 let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1532 let fixed_obsidian = rule.fix(&ctx_obsidian).unwrap();
1533 assert_eq!(fixed_obsidian, "#hey tag\n## Introduction");
1534
1535 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1537 let fixed_standard = rule.fix(&ctx_standard).unwrap();
1538 assert_eq!(fixed_standard, "# hey tag\n## Introduction");
1539 }
1540
1541 #[test]
1542 fn test_obsidian_tag_edge_cases() {
1543 let rule = MD018NoMissingSpaceAtx::new();
1545
1546 let valid_tags = [
1548 "#a", "#tag", "#Tag", "#TAG", "#my-tag", "#my_tag", "#tag123", "#a1", "#日本語", "#über", ];
1559
1560 for tag in valid_tags {
1561 assert!(
1562 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1563 "{tag:?} should be skipped in Obsidian flavor (valid tag)"
1564 );
1565 }
1566
1567 let invalid_tags = ["#123", "#1984", "#37.", "#42,"];
1570
1571 for tag in invalid_tags {
1572 assert!(
1573 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_some(),
1574 "{tag:?} should be flagged in Obsidian flavor (no non-numerical character)"
1575 );
1576 }
1577 }
1578
1579 #[test]
1580 fn test_obsidian_tag_alone_on_line() {
1581 let rule = MD018NoMissingSpaceAtx::new();
1583
1584 let content = "Some text\n\n#todo\n\nMore text.";
1585 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1586 let result = rule.check(&ctx).unwrap();
1587
1588 assert!(
1590 result.is_empty(),
1591 "Standalone #todo should not be flagged in Obsidian flavor"
1592 );
1593
1594 let fixed = rule.fix(&ctx).unwrap();
1596 assert_eq!(fixed, content, "fix() should not modify standalone Obsidian tag");
1597 }
1598
1599 #[test]
1600 fn test_obsidian_deeply_nested_tags() {
1601 let rule = MD018NoMissingSpaceAtx::new();
1603
1604 let nested_tags = [
1605 "#a/b",
1606 "#a/b/c",
1607 "#project/2023/q1/task",
1608 "#work/meetings/weekly",
1609 "#life/health/exercise/running",
1610 ];
1611
1612 for tag in nested_tags {
1613 assert!(
1614 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1615 "{tag:?} should be skipped in Obsidian flavor (nested tag)"
1616 );
1617 }
1618 }
1619
1620 #[test]
1621 fn test_obsidian_unicode_tags() {
1622 let rule = MD018NoMissingSpaceAtx::new();
1624
1625 let unicode_tags = [
1626 "#日本語", "#中文", "#한국어", "#über", "#café", "#ñoño", "#Москва", "#αβγ", ];
1635
1636 for tag in unicode_tags {
1637 assert!(
1638 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1639 "{tag:?} should be skipped in Obsidian flavor (Unicode tag)"
1640 );
1641 }
1642 }
1643
1644 #[test]
1645 fn test_obsidian_tags_with_special_endings() {
1646 let rule = MD018NoMissingSpaceAtx::new();
1648
1649 assert!(
1651 rule.check_atx_heading_line("#tag followed by text", MarkdownFlavor::Obsidian)
1652 .is_none(),
1653 "#tag followed by text should be skipped"
1654 );
1655
1656 let content = "#todo";
1658 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1659 let result = rule.check(&ctx).unwrap();
1660 assert!(result.is_empty(), "#todo at end of line should be skipped");
1661 }
1662
1663 #[test]
1664 fn test_obsidian_combined_with_other_skip_contexts() {
1665 let rule = MD018NoMissingSpaceAtx::new();
1667
1668 let content = "```\n#todo\n```";
1670 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1671 let result = rule.check(&ctx).unwrap();
1672 assert!(result.is_empty(), "Tag in code block should be skipped");
1673
1674 let content = "<!-- #todo -->";
1676 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1677 let result = rule.check(&ctx).unwrap();
1678 assert!(result.is_empty(), "Tag in HTML comment should be skipped");
1679 }
1680
1681 #[test]
1682 fn test_obsidian_boundary_cases() {
1683 let rule = MD018NoMissingSpaceAtx::new();
1685
1686 assert!(
1690 rule.check_atx_heading_line("#ab", MarkdownFlavor::Obsidian).is_none(),
1691 "#ab should be skipped in Obsidian flavor"
1692 );
1693
1694 assert!(
1696 rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1697 .is_none(),
1698 "#my_tag should be skipped"
1699 );
1700
1701 assert!(
1703 rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1704 .is_none(),
1705 "#my-tag should be skipped"
1706 );
1707
1708 assert!(
1710 rule.check_atx_heading_line("#MyTag", MarkdownFlavor::Obsidian)
1711 .is_none(),
1712 "#MyTag should be skipped"
1713 );
1714
1715 assert!(
1717 rule.check_atx_heading_line("#TODO", MarkdownFlavor::Obsidian).is_none(),
1718 "#TODO should be skipped in Obsidian flavor"
1719 );
1720 }
1721
1722 #[test]
1723 fn test_obsidian_tag_may_start_with_a_digit() {
1724 let rule = MD018NoMissingSpaceAtx::new();
1727
1728 let tags = [
1729 "#3d_printing", "#1tag", "#2023-project", "#100DaysOfCode", "#1on1", "#5S", "#3/4", "#1_2", "#3🔥", "#3\u{FE0F}\u{20E3}", ];
1740
1741 for tag in tags {
1742 assert!(
1743 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1744 "{tag:?} contains a non-numerical character and should be skipped as a tag"
1745 );
1746 }
1747 }
1748
1749 #[test]
1750 fn test_numeric_references_are_not_tags() {
1751 let rule = MD018NoMissingSpaceAtx::new();
1755
1756 let not_tags = [
1757 "#1984", "#123", "#10", "#37.", "#42,", "#42)", "#404 Not Found", "#10 discusses the issue", ];
1766
1767 for line in not_tags {
1768 assert!(
1769 rule.check_atx_heading_line(line, MarkdownFlavor::Obsidian).is_some(),
1770 "{line:?} has no non-numerical tag character and should stay flagged"
1771 );
1772 }
1773 }
1774
1775 #[test]
1776 fn test_digit_leading_tag_survives_check_and_fix() {
1777 let obsidian = MD018NoMissingSpaceAtx::new();
1780 let standard = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1781 magiclink: false,
1782 tags: Some(true),
1783 });
1784
1785 let content = "# Header\n\n#3d_printing\n\n##Heading\n";
1788
1789 for (rule, flavor, label) in [
1790 (&obsidian, MarkdownFlavor::Obsidian, "obsidian flavor"),
1791 (&standard, MarkdownFlavor::Standard, "tags = true"),
1792 ] {
1793 let ctx = LintContext::new(content, flavor, None);
1794 let flagged: Vec<usize> = rule.check(&ctx).unwrap().iter().map(|w| w.line).collect();
1795 assert_eq!(
1796 flagged,
1797 vec![5],
1798 "{label}: only ##Heading should be flagged, got {flagged:?}"
1799 );
1800
1801 let fixed = rule.fix(&ctx).unwrap();
1802 assert_eq!(
1803 fixed, "# Header\n\n#3d_printing\n\n## Heading\n",
1804 "{label}: fix must leave the tag alone and still fix the heading"
1805 );
1806 }
1807 }
1808
1809 #[test]
1810 fn test_digit_leading_tag_is_still_flagged_without_tags_mode() {
1811 let rule = MD018NoMissingSpaceAtx::new();
1814
1815 assert!(
1816 rule.check_atx_heading_line("#3d_printing", MarkdownFlavor::Standard)
1817 .is_some(),
1818 "#3d_printing should be flagged in Standard flavor (tags disabled)"
1819 );
1820 }
1821}