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::calculate_single_line_range;
11use crate::utils::regex_cache::get_cached_regex;
12
13const EMOJI_HASHTAG_PATTERN_STR: &str = r"^#️⃣|^#⃣";
15const UNICODE_HASHTAG_PATTERN_STR: &str = r"^#[\u{FE0F}\u{20E3}]";
16
17const MAGICLINK_REF_PATTERN_STR: &str = r"^#\d+(?:\s|[^a-zA-Z0-9]|$)";
21
22const TAG_PATTERN_STR: &str = r"^#[^\d\s#][^\s#]*(?:\s|$)";
27
28#[derive(Clone)]
29pub struct MD018NoMissingSpaceAtx {
30 config: MD018Config,
31}
32
33impl Default for MD018NoMissingSpaceAtx {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39impl MD018NoMissingSpaceAtx {
40 pub fn new() -> Self {
41 Self {
42 config: MD018Config::default(),
43 }
44 }
45
46 pub fn from_config_struct(config: MD018Config) -> Self {
47 Self { config }
48 }
49
50 fn is_magiclink_ref(line: &str) -> bool {
53 get_cached_regex(MAGICLINK_REF_PATTERN_STR).is_ok_and(|re| re.is_match(line.trim_start()))
54 }
55
56 fn is_tag(line: &str) -> bool {
58 get_cached_regex(TAG_PATTERN_STR).is_ok_and(|re| re.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 = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
87 .map(|re| re.is_match(trimmed_line))
88 .unwrap_or(false);
89 let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
90 .map(|re| re.is_match(trimmed_line))
91 .unwrap_or(false);
92 if is_emoji || is_unicode {
93 return None;
94 }
95
96 let hash_count = trimmed_line.chars().take_while(|&c| c == '#').count();
98 if hash_count == 0 || hash_count > 6 {
99 return None;
100 }
101
102 let after_hashes = &trimmed_line[hash_count..];
104
105 if after_hashes
107 .chars()
108 .next()
109 .is_some_and(|ch| matches!(ch, '\u{FE0F}' | '\u{20E3}' | '\u{FE0E}'))
110 {
111 return None;
112 }
113
114 if !after_hashes.is_empty() && !after_hashes.starts_with(' ') && !after_hashes.starts_with('\t') {
116 let content = after_hashes.trim();
118
119 if content.chars().all(|c| c == '#') {
121 return None;
122 }
123
124 if content.len() < 2 {
126 return None;
127 }
128
129 if content.starts_with('*') || content.starts_with('_') {
131 return None;
132 }
133
134 if self.config.magiclink && hash_count == 1 && Self::is_magiclink_ref(line) {
137 return None;
138 }
139
140 if self.tags_enabled(flavor) && hash_count == 1 && Self::is_tag(line) {
143 return None;
144 }
145
146 let fixed = format!("{}{} {}", " ".repeat(indent), "#".repeat(hash_count), after_hashes);
148 return Some((indent + hash_count, fixed));
149 }
150
151 None
152 }
153
154 fn get_line_byte_range(&self, content: &str, line_num: usize) -> std::ops::Range<usize> {
156 let mut current_line = 1;
157 let mut start_byte = 0;
158
159 for (i, c) in content.char_indices() {
160 if current_line == line_num && c == '\n' {
161 return start_byte..i;
162 } else if c == '\n' {
163 current_line += 1;
164 if current_line == line_num {
165 start_byte = i + 1;
166 }
167 }
168 }
169
170 if current_line == line_num {
172 return start_byte..content.len();
173 }
174
175 0..0
177 }
178}
179
180impl Rule for MD018NoMissingSpaceAtx {
181 fn name(&self) -> &'static str {
182 "MD018"
183 }
184
185 fn description(&self) -> &'static str {
186 "No space after hash in heading"
187 }
188
189 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
190 let mut warnings = Vec::new();
191
192 for (line_num, line_info) in ctx.lines.iter().enumerate() {
194 if line_info.in_html_block
196 || line_info.in_html_comment
197 || line_info.in_mdx_comment
198 || line_info.in_pymdown_block
199 {
200 continue;
201 }
202
203 if let Some(heading) = &line_info.heading {
204 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
206 if line_info.indent > 0 {
209 continue;
210 }
211
212 let line = line_info.content(ctx.content);
214 let trimmed = line.trim_start();
215
216 let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
218 .map(|re| re.is_match(trimmed))
219 .unwrap_or(false);
220 let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
221 .map(|re| re.is_match(trimmed))
222 .unwrap_or(false);
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 = line_info.indent + heading.marker.len() + 1; let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
244 line_num + 1, hash_end_col,
246 0, );
248
249 warnings.push(LintWarning {
250 rule_name: Some(self.name().to_string()),
251 message: format!("No space after {} in heading", "#".repeat(heading.level as usize)),
252 line: start_line,
253 column: start_col,
254 end_line,
255 end_column: end_col,
256 severity: Severity::Warning,
257 fix: Some(Fix::new(self.get_line_byte_range(ctx.content, line_num + 1), {
258 let line = line_info.content(ctx.content);
260 let original_indent = &line[..line_info.indent];
261 format!("{original_indent}{} {after_marker}", heading.marker)
262 })),
263 });
264 }
265 }
266 }
267 } else if !line_info.in_code_block
268 && !line_info.in_front_matter
269 && !line_info.in_html_comment
270 && !line_info.in_mdx_comment
271 && !line_info.is_blank
272 {
273 if let Some((hash_end_pos, fixed_line)) =
275 self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor)
276 {
277 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
278 line_num + 1, hash_end_pos + 1, 0, );
282
283 warnings.push(LintWarning {
284 rule_name: Some(self.name().to_string()),
285 message: "No space after hash in heading".to_string(),
286 line: start_line,
287 column: start_col,
288 end_line,
289 end_column: end_col,
290 severity: Severity::Warning,
291 fix: Some(Fix::new(
292 self.get_line_byte_range(ctx.content, line_num + 1),
293 fixed_line,
294 )),
295 });
296 }
297 }
298 }
299
300 Ok(warnings)
301 }
302
303 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
304 let warnings = self.check(ctx)?;
305 let warnings =
306 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
307 let warning_lines: std::collections::HashSet<usize> = warnings.iter().map(|w| w.line).collect();
308
309 let mut lines = Vec::new();
310
311 for (idx, line_info) in ctx.lines.iter().enumerate() {
312 let mut fixed = false;
313
314 if !warning_lines.contains(&(idx + 1)) {
315 lines.push(line_info.content(ctx.content).to_string());
316 continue;
317 }
318
319 if let Some(heading) = &line_info.heading {
320 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
322 let line = line_info.content(ctx.content);
323 let trimmed = line.trim_start();
324
325 let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
327 .map(|re| re.is_match(trimmed))
328 .unwrap_or(false);
329 let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
330 .map(|re| re.is_match(trimmed))
331 .unwrap_or(false);
332
333 let is_magiclink = self.config.magiclink && heading.level == 1 && Self::is_magiclink_ref(line);
335
336 let is_tag = self.tags_enabled(ctx.flavor) && heading.level == 1 && Self::is_tag(line);
338
339 if !is_emoji && !is_unicode && !is_magiclink && !is_tag && trimmed.len() > heading.marker.len() {
341 let after_marker = &trimmed[heading.marker.len()..];
342 if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
343 {
344 let line = line_info.content(ctx.content);
346 let original_indent = &line[..line_info.indent];
347 lines.push(format!("{original_indent}{} {after_marker}", heading.marker));
348 fixed = true;
349 }
350 }
351 }
352 } else if !line_info.in_code_block
353 && !line_info.in_front_matter
354 && !line_info.in_html_comment
355 && !line_info.in_mdx_comment
356 && !line_info.is_blank
357 {
358 if let Some((_, fixed_line)) = self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor) {
360 lines.push(fixed_line);
361 fixed = true;
362 }
363 }
364
365 if !fixed {
366 lines.push(line_info.content(ctx.content).to_string());
367 }
368 }
369
370 let mut result = lines.join("\n");
372 if ctx.content.ends_with('\n') && !result.ends_with('\n') {
373 result.push('\n');
374 }
375
376 Ok(result)
377 }
378
379 fn category(&self) -> RuleCategory {
381 RuleCategory::Heading
382 }
383
384 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
386 !ctx.likely_has_headings()
388 }
389
390 fn as_any(&self) -> &dyn std::any::Any {
391 self
392 }
393
394 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
395 where
396 Self: Sized,
397 {
398 let rule_config = crate::rule_config_serde::load_rule_config::<MD018Config>(config);
399 Box::new(MD018NoMissingSpaceAtx::from_config_struct(rule_config))
400 }
401
402 fn default_config_section(&self) -> Option<(String, toml::Value)> {
403 let json_value = serde_json::to_value(&self.config).ok()?;
404 Some((
405 self.name().to_string(),
406 crate::rule_config_serde::json_to_toml_value(&json_value)?,
407 ))
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use crate::lint_context::LintContext;
415
416 #[test]
417 fn test_basic_functionality() {
418 let rule = MD018NoMissingSpaceAtx::new();
419
420 let content = "# Heading 1\n## Heading 2\n### Heading 3";
422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
423 let result = rule.check(&ctx).unwrap();
424 assert!(result.is_empty());
425
426 let content = "#Heading 1\n## Heading 2\n###Heading 3";
428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
429 let result = rule.check(&ctx).unwrap();
430 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
432 assert_eq!(result[1].line, 3);
433 }
434
435 #[test]
436 fn test_malformed_heading_detection() {
437 let rule = MD018NoMissingSpaceAtx::new();
438
439 assert!(
441 rule.check_atx_heading_line("##Introduction", MarkdownFlavor::Standard)
442 .is_some()
443 );
444 assert!(
445 rule.check_atx_heading_line("###Background", MarkdownFlavor::Standard)
446 .is_some()
447 );
448 assert!(
449 rule.check_atx_heading_line("####Details", MarkdownFlavor::Standard)
450 .is_some()
451 );
452 assert!(
453 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
454 .is_some()
455 );
456 assert!(
457 rule.check_atx_heading_line("######Conclusion", MarkdownFlavor::Standard)
458 .is_some()
459 );
460 assert!(
461 rule.check_atx_heading_line("##Table of Contents", MarkdownFlavor::Standard)
462 .is_some()
463 );
464
465 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!(
470 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
471 .is_none()
472 ); assert!(
474 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
475 .is_none()
476 ); }
478
479 #[test]
480 fn test_malformed_heading_with_context() {
481 let rule = MD018NoMissingSpaceAtx::new();
482
483 let content = r#"# Test Document
485
486##Introduction
487This should be detected.
488
489 ##CodeBlock
490This should NOT be detected (indented code block).
491
492```
493##FencedCodeBlock
494This should NOT be detected (fenced code block).
495```
496
497##Conclusion
498This should be detected.
499"#;
500
501 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
502 let result = rule.check(&ctx).unwrap();
503
504 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
506 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&14)); assert!(!detected_lines.contains(&6)); assert!(!detected_lines.contains(&10)); }
511
512 #[test]
513 fn test_malformed_heading_fix() {
514 let rule = MD018NoMissingSpaceAtx::new();
515
516 let content = r#"##Introduction
517This is a test.
518
519###Background
520More content."#;
521
522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
523 let fixed = rule.fix(&ctx).unwrap();
524
525 let expected = r#"## Introduction
526This is a test.
527
528### Background
529More content."#;
530
531 assert_eq!(fixed, expected);
532 }
533
534 #[test]
535 fn test_mixed_proper_and_malformed_headings() {
536 let rule = MD018NoMissingSpaceAtx::new();
537
538 let content = r#"# Proper Heading
539
540##Malformed Heading
541
542## Another Proper Heading
543
544###Another Malformed
545
546#### Proper with space
547"#;
548
549 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
550 let result = rule.check(&ctx).unwrap();
551
552 assert_eq!(result.len(), 2);
554 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
555 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&7)); }
558
559 #[test]
560 fn test_css_selectors_in_html_blocks() {
561 let rule = MD018NoMissingSpaceAtx::new();
562
563 let content = r#"# Proper Heading
566
567<style>
568#slide-1 ol li {
569 margin-top: 0;
570}
571
572#special-slide ol li {
573 margin-top: 2em;
574}
575</style>
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 "CSS selectors in <style> blocks should not be flagged as malformed headings"
588 );
589 }
590
591 #[test]
592 fn test_js_code_in_script_blocks() {
593 let rule = MD018NoMissingSpaceAtx::new();
594
595 let content = r#"# Heading
597
598<script>
599const element = document.querySelector('#main-content');
600#another-comment
601</script>
602
603## Another Heading
604"#;
605
606 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
607 let result = rule.check(&ctx).unwrap();
608
609 assert_eq!(
611 result.len(),
612 0,
613 "JavaScript code in <script> blocks should not be flagged as malformed headings"
614 );
615 }
616
617 #[test]
618 fn test_all_malformed_headings_detected() {
619 let rule = MD018NoMissingSpaceAtx::new();
620
621 assert!(
626 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
627 .is_some(),
628 "#hello SHOULD be detected as malformed heading"
629 );
630 assert!(
631 rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
632 "#tag SHOULD be detected as malformed heading"
633 );
634 assert!(
635 rule.check_atx_heading_line("#hashtag", MarkdownFlavor::Standard)
636 .is_some(),
637 "#hashtag SHOULD be detected as malformed heading"
638 );
639 assert!(
640 rule.check_atx_heading_line("#javascript", MarkdownFlavor::Standard)
641 .is_some(),
642 "#javascript SHOULD be detected as malformed heading"
643 );
644
645 assert!(
647 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
648 "#123 SHOULD be detected as malformed heading"
649 );
650 assert!(
651 rule.check_atx_heading_line("#12345", MarkdownFlavor::Standard)
652 .is_some(),
653 "#12345 SHOULD be detected as malformed heading"
654 );
655 assert!(
656 rule.check_atx_heading_line("#29039)", MarkdownFlavor::Standard)
657 .is_some(),
658 "#29039) SHOULD be detected as malformed heading"
659 );
660
661 assert!(
663 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
664 .is_some(),
665 "#Summary SHOULD be detected as malformed heading"
666 );
667 assert!(
668 rule.check_atx_heading_line("#Introduction", MarkdownFlavor::Standard)
669 .is_some(),
670 "#Introduction SHOULD be detected as malformed heading"
671 );
672 assert!(
673 rule.check_atx_heading_line("#API", MarkdownFlavor::Standard).is_some(),
674 "#API SHOULD be detected as malformed heading"
675 );
676
677 assert!(
679 rule.check_atx_heading_line("##introduction", MarkdownFlavor::Standard)
680 .is_some(),
681 "##introduction SHOULD be detected as malformed heading"
682 );
683 assert!(
684 rule.check_atx_heading_line("###section", MarkdownFlavor::Standard)
685 .is_some(),
686 "###section SHOULD be detected as malformed heading"
687 );
688 assert!(
689 rule.check_atx_heading_line("###fer", MarkdownFlavor::Standard)
690 .is_some(),
691 "###fer SHOULD be detected as malformed heading"
692 );
693 assert!(
694 rule.check_atx_heading_line("##123", MarkdownFlavor::Standard).is_some(),
695 "##123 SHOULD be detected as malformed heading"
696 );
697 }
698
699 #[test]
700 fn test_patterns_that_should_not_be_flagged() {
701 let rule = MD018NoMissingSpaceAtx::new();
702
703 assert!(rule.check_atx_heading_line("###", MarkdownFlavor::Standard).is_none());
705 assert!(rule.check_atx_heading_line("#", MarkdownFlavor::Standard).is_none());
706
707 assert!(rule.check_atx_heading_line("##a", MarkdownFlavor::Standard).is_none());
709
710 assert!(
712 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
713 .is_none()
714 );
715
716 assert!(
718 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
719 .is_none()
720 );
721
722 assert!(
724 rule.check_atx_heading_line("# Hello", MarkdownFlavor::Standard)
725 .is_none()
726 );
727 assert!(
728 rule.check_atx_heading_line("## World", MarkdownFlavor::Standard)
729 .is_none()
730 );
731 assert!(
732 rule.check_atx_heading_line("### Section", MarkdownFlavor::Standard)
733 .is_none()
734 );
735 }
736
737 #[test]
738 fn test_inline_issue_refs_not_at_line_start() {
739 let rule = MD018NoMissingSpaceAtx::new();
740
741 assert!(
746 rule.check_atx_heading_line("See issue #123", MarkdownFlavor::Standard)
747 .is_none()
748 );
749 assert!(
750 rule.check_atx_heading_line("Check #trending on Twitter", MarkdownFlavor::Standard)
751 .is_none()
752 );
753 assert!(
754 rule.check_atx_heading_line("- fix: issue #29039", MarkdownFlavor::Standard)
755 .is_none()
756 );
757 }
758
759 #[test]
760 fn test_lowercase_patterns_full_check() {
761 let rule = MD018NoMissingSpaceAtx::new();
763
764 let content = "#hello\n\n#world\n\n#tag";
765 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
766 let result = rule.check(&ctx).unwrap();
767
768 assert_eq!(result.len(), 3, "All three lowercase patterns should be flagged");
769 assert_eq!(result[0].line, 1);
770 assert_eq!(result[1].line, 3);
771 assert_eq!(result[2].line, 5);
772 }
773
774 #[test]
775 fn test_numeric_patterns_full_check() {
776 let rule = MD018NoMissingSpaceAtx::new();
778
779 let content = "#123\n\n#456\n\n#29039";
780 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
781 let result = rule.check(&ctx).unwrap();
782
783 assert_eq!(result.len(), 3, "All three numeric patterns should be flagged");
784 }
785
786 #[test]
787 fn test_fix_lowercase_patterns() {
788 let rule = MD018NoMissingSpaceAtx::new();
790
791 let content = "#hello\nSome text.\n\n#world";
792 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
793 let fixed = rule.fix(&ctx).unwrap();
794
795 let expected = "# hello\nSome text.\n\n# world";
796 assert_eq!(fixed, expected);
797 }
798
799 #[test]
800 fn test_fix_numeric_patterns() {
801 let rule = MD018NoMissingSpaceAtx::new();
803
804 let content = "#123\nContent.\n\n##456";
805 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
806 let fixed = rule.fix(&ctx).unwrap();
807
808 let expected = "# 123\nContent.\n\n## 456";
809 assert_eq!(fixed, expected);
810 }
811
812 #[test]
813 fn test_indented_malformed_headings() {
814 let rule = MD018NoMissingSpaceAtx::new();
818
819 assert!(
821 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
822 .is_none(),
823 "1-space indented #hello should be skipped"
824 );
825 assert!(
826 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
827 .is_none(),
828 "2-space indented #hello should be skipped"
829 );
830 assert!(
831 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
832 .is_none(),
833 "3-space indented #hello should be skipped"
834 );
835
836 assert!(
841 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
842 .is_some(),
843 "Non-indented #hello should be detected"
844 );
845 }
846
847 #[test]
848 fn test_tab_after_hash_is_valid() {
849 let rule = MD018NoMissingSpaceAtx::new();
851
852 assert!(
853 rule.check_atx_heading_line("#\tHello", MarkdownFlavor::Standard)
854 .is_none(),
855 "Tab after # should be valid"
856 );
857 assert!(
858 rule.check_atx_heading_line("##\tWorld", MarkdownFlavor::Standard)
859 .is_none(),
860 "Tab after ## should be valid"
861 );
862 }
863
864 #[test]
865 fn test_mixed_case_patterns() {
866 let rule = MD018NoMissingSpaceAtx::new();
867
868 assert!(
870 rule.check_atx_heading_line("#hELLO", MarkdownFlavor::Standard)
871 .is_some()
872 );
873 assert!(
874 rule.check_atx_heading_line("#Hello", MarkdownFlavor::Standard)
875 .is_some()
876 );
877 assert!(
878 rule.check_atx_heading_line("#HELLO", MarkdownFlavor::Standard)
879 .is_some()
880 );
881 assert!(
882 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
883 .is_some()
884 );
885 }
886
887 #[test]
888 fn test_unicode_lowercase() {
889 let rule = MD018NoMissingSpaceAtx::new();
890
891 assert!(
893 rule.check_atx_heading_line("#über", MarkdownFlavor::Standard).is_some(),
894 "Unicode lowercase #über should be detected"
895 );
896 assert!(
897 rule.check_atx_heading_line("#café", MarkdownFlavor::Standard).is_some(),
898 "Unicode lowercase #café should be detected"
899 );
900 assert!(
901 rule.check_atx_heading_line("#日本語", MarkdownFlavor::Standard)
902 .is_some(),
903 "Japanese #日本語 should be detected"
904 );
905 }
906
907 #[test]
908 fn test_matches_markdownlint_behavior() {
909 let rule = MD018NoMissingSpaceAtx::new();
911
912 let content = r#"#hello
913
914## world
915
916###fer
917
918#123
919
920#Tag
921"#;
922
923 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
924 let result = rule.check(&ctx).unwrap();
925
926 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
929
930 assert!(flagged_lines.contains(&1), "#hello should be flagged");
931 assert!(!flagged_lines.contains(&3), "## world should NOT be flagged");
932 assert!(flagged_lines.contains(&5), "###fer should be flagged");
933 assert!(flagged_lines.contains(&7), "#123 should be flagged");
934 assert!(flagged_lines.contains(&9), "#Tag should be flagged");
935
936 assert_eq!(result.len(), 4, "Should have exactly 4 warnings");
937 }
938
939 #[test]
940 fn test_skip_frontmatter_yaml_comments() {
941 let rule = MD018NoMissingSpaceAtx::new();
943
944 let content = r#"---
945#reviewers:
946#- sig-api-machinery
947#another_comment: value
948title: Test Document
949---
950
951# Valid heading
952
953#invalid heading without space
954"#;
955
956 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
957 let result = rule.check(&ctx).unwrap();
958
959 assert_eq!(
962 result.len(),
963 1,
964 "Should only flag the malformed heading outside frontmatter"
965 );
966 assert_eq!(result[0].line, 10, "Should flag line 10");
967 }
968
969 #[test]
970 fn test_skip_html_comments() {
971 let rule = MD018NoMissingSpaceAtx::new();
974
975 let content = r#"# Real Heading
976
977Some text.
978
979<!--
980```
981#%% Cell marker
982import matplotlib.pyplot as plt
983
984#%% Another cell
985data = [1, 2, 3]
986```
987-->
988
989More content.
990"#;
991
992 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
993 let result = rule.check(&ctx).unwrap();
994
995 assert!(
997 result.is_empty(),
998 "Should not flag content inside HTML comments, found {} issues",
999 result.len()
1000 );
1001 }
1002
1003 #[test]
1004 fn test_mkdocs_magiclink_skips_numeric_refs() {
1005 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1007 magiclink: true,
1008 ..Default::default()
1009 });
1010
1011 assert!(
1013 rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_none(),
1014 "#10 should be skipped with magiclink config (MagicLink issue ref)"
1015 );
1016 assert!(
1017 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_none(),
1018 "#123 should be skipped with magiclink config (MagicLink issue ref)"
1019 );
1020 assert!(
1021 rule.check_atx_heading_line("#10 discusses the issue", MarkdownFlavor::Standard)
1022 .is_none(),
1023 "#10 followed by text should be skipped with magiclink config"
1024 );
1025 assert!(
1026 rule.check_atx_heading_line("#37.", MarkdownFlavor::Standard).is_none(),
1027 "#37 followed by punctuation should be skipped with magiclink config"
1028 );
1029 }
1030
1031 #[test]
1032 fn test_mkdocs_magiclink_still_flags_non_numeric() {
1033 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1035 magiclink: true,
1036 ..Default::default()
1037 });
1038
1039 assert!(
1041 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
1042 .is_some(),
1043 "#Summary should still be flagged with magiclink config"
1044 );
1045 assert!(
1046 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
1047 .is_some(),
1048 "#hello should still be flagged with magiclink config"
1049 );
1050 assert!(
1051 rule.check_atx_heading_line("#10abc", MarkdownFlavor::Standard)
1052 .is_some(),
1053 "#10abc (mixed) should still be flagged with magiclink config"
1054 );
1055 }
1056
1057 #[test]
1058 fn test_mkdocs_magiclink_only_single_hash() {
1059 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1061 magiclink: true,
1062 ..Default::default()
1063 });
1064
1065 assert!(
1066 rule.check_atx_heading_line("##10", MarkdownFlavor::Standard).is_some(),
1067 "##10 should be flagged with magiclink config (only single # is MagicLink)"
1068 );
1069 assert!(
1070 rule.check_atx_heading_line("###123", MarkdownFlavor::Standard)
1071 .is_some(),
1072 "###123 should be flagged with magiclink config"
1073 );
1074 }
1075
1076 #[test]
1077 fn test_standard_flavor_flags_numeric_refs() {
1078 let rule = MD018NoMissingSpaceAtx::new();
1080
1081 assert!(
1082 rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_some(),
1083 "#10 should be flagged in Standard flavor"
1084 );
1085 assert!(
1086 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
1087 "#123 should be flagged in Standard flavor"
1088 );
1089 }
1090
1091 #[test]
1092 fn test_mkdocs_magiclink_full_check() {
1093 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1095 magiclink: true,
1096 ..Default::default()
1097 });
1098
1099 let content = r#"# PRs that are helpful for context
1100
1101#10 discusses the philosophy behind the project, and #37 shows a good example.
1102
1103#Summary
1104
1105##Introduction
1106"#;
1107
1108 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1110 let result = rule.check(&ctx).unwrap();
1111
1112 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1113 assert!(
1114 !flagged_lines.contains(&3),
1115 "#10 should NOT be flagged with magiclink config"
1116 );
1117 assert!(
1118 flagged_lines.contains(&5),
1119 "#Summary SHOULD be flagged with magiclink config"
1120 );
1121 assert!(
1122 flagged_lines.contains(&7),
1123 "##Introduction SHOULD be flagged with magiclink config"
1124 );
1125 }
1126
1127 #[test]
1128 fn test_mkdocs_magiclink_fix_exact_output() {
1129 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1131 magiclink: true,
1132 ..Default::default()
1133 });
1134
1135 let content = "#10 discusses the issue.\n\n#Summary";
1136 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1137 let fixed = rule.fix(&ctx).unwrap();
1138
1139 let expected = "#10 discusses the issue.\n\n# Summary";
1141 assert_eq!(
1142 fixed, expected,
1143 "magiclink config fix should preserve MagicLink refs and fix non-numeric headings"
1144 );
1145 }
1146
1147 #[test]
1148 fn test_mkdocs_magiclink_edge_cases() {
1149 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1151 magiclink: true,
1152 ..Default::default()
1153 });
1154
1155 let valid_refs = [
1158 "#10", "#999999", "#10 text after", "#10\ttext after", "#10.", "#10,", "#10!", "#10?", "#10)", "#10]", "#10;", "#10:", ];
1171
1172 for ref_str in valid_refs {
1173 assert!(
1174 rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_none(),
1175 "{ref_str:?} should be skipped as MagicLink ref with magiclink config"
1176 );
1177 }
1178
1179 let invalid_refs = [
1181 "#10abc", "#10a", "#abc10", "#10ABC", "#Summary", "#hello", ];
1188
1189 for ref_str in invalid_refs {
1190 assert!(
1191 rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_some(),
1192 "{ref_str:?} should be flagged with magiclink config (not a valid MagicLink ref)"
1193 );
1194 }
1195 }
1196
1197 #[test]
1198 fn test_mkdocs_magiclink_hyphenated_continuation() {
1199 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1202 magiclink: true,
1203 ..Default::default()
1204 });
1205
1206 assert!(
1211 rule.check_atx_heading_line("#10-", MarkdownFlavor::Standard).is_none(),
1212 "#10- should be skipped with magiclink config (hyphen is non-alphanumeric terminator)"
1213 );
1214 }
1215
1216 #[test]
1217 fn test_mkdocs_magiclink_standalone_number() {
1218 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1220 magiclink: true,
1221 ..Default::default()
1222 });
1223
1224 let content = "See issue:\n\n#10\n\nFor details.";
1225 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1226 let result = rule.check(&ctx).unwrap();
1227
1228 assert!(
1230 result.is_empty(),
1231 "Standalone #10 should not be flagged with magiclink config"
1232 );
1233
1234 let fixed = rule.fix(&ctx).unwrap();
1236 assert_eq!(fixed, content, "fix() should not modify standalone MagicLink ref");
1237 }
1238
1239 #[test]
1240 fn test_standard_flavor_flags_all_numeric() {
1241 let rule = MD018NoMissingSpaceAtx::new();
1244
1245 let numeric_patterns = ["#10", "#123", "#999999", "#10 text"];
1246
1247 for pattern in numeric_patterns {
1248 assert!(
1249 rule.check_atx_heading_line(pattern, MarkdownFlavor::Standard).is_some(),
1250 "{pattern:?} should be flagged in Standard flavor"
1251 );
1252 }
1253
1254 assert!(
1256 rule.check_atx_heading_line("#1", MarkdownFlavor::Standard).is_none(),
1257 "#1 should be skipped (content too short, existing behavior)"
1258 );
1259 }
1260
1261 #[test]
1262 fn test_mkdocs_vs_standard_fix_comparison() {
1263 let content = "#10 is an issue\n#Summary";
1265 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1266
1267 let rule_magiclink = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1269 magiclink: true,
1270 ..Default::default()
1271 });
1272 let fixed_magiclink = rule_magiclink.fix(&ctx).unwrap();
1273 assert_eq!(fixed_magiclink, "#10 is an issue\n# Summary");
1274
1275 let rule_default = MD018NoMissingSpaceAtx::new();
1277 let fixed_default = rule_default.fix(&ctx).unwrap();
1278 assert_eq!(fixed_default, "# 10 is an issue\n# Summary");
1279 }
1280
1281 #[test]
1284 fn test_tags_config_standard_flavor() {
1285 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1287 magiclink: false,
1288 tags: Some(true),
1289 });
1290
1291 let content = "#tag\n\n#project/active\n\n##Introduction";
1292 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1293 let result = rule.check(&ctx).unwrap();
1294
1295 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1296 assert!(!flagged_lines.contains(&1), "#tag should be skipped with tags = true");
1297 assert!(
1298 !flagged_lines.contains(&3),
1299 "#project/active should be skipped with tags = true"
1300 );
1301 assert!(flagged_lines.contains(&5), "##Introduction should still be flagged");
1302 }
1303
1304 #[test]
1305 fn test_tags_config_fix_standard_flavor() {
1306 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1307 magiclink: false,
1308 tags: Some(true),
1309 });
1310
1311 let content = "#tag\n\n##Introduction";
1312 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1313 let fixed = rule.fix(&ctx).unwrap();
1314 assert_eq!(fixed, "#tag\n\n## Introduction");
1315 }
1316
1317 #[test]
1318 fn test_tags_config_disabled_obsidian_flavor() {
1319 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1321 magiclink: false,
1322 tags: Some(false),
1323 });
1324
1325 let content = "#tag\n\n#project/active";
1326 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1327 let result = rule.check(&ctx).unwrap();
1328
1329 assert_eq!(
1330 result.len(),
1331 2,
1332 "tags = false should flag tag patterns even in Obsidian"
1333 );
1334 }
1335
1336 #[test]
1337 fn test_tags_config_default_follows_flavor() {
1338 let rule = MD018NoMissingSpaceAtx::new(); let content = "#tag";
1343 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1344 let result = rule.check(&ctx).unwrap();
1345 assert!(!result.is_empty(), "Default standard should flag #tag");
1346
1347 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1349 let result = rule.check(&ctx).unwrap();
1350 assert!(result.is_empty(), "Default Obsidian should skip #tag");
1351 }
1352
1353 #[test]
1356 fn test_obsidian_tag_skips_simple_tags() {
1357 let rule = MD018NoMissingSpaceAtx::new();
1359
1360 assert!(
1362 rule.check_atx_heading_line("#hey", MarkdownFlavor::Obsidian).is_none(),
1363 "#hey should be skipped in Obsidian flavor (tag syntax)"
1364 );
1365 assert!(
1366 rule.check_atx_heading_line("#tag", MarkdownFlavor::Obsidian).is_none(),
1367 "#tag should be skipped in Obsidian flavor"
1368 );
1369 assert!(
1370 rule.check_atx_heading_line("#hello", MarkdownFlavor::Obsidian)
1371 .is_none(),
1372 "#hello should be skipped in Obsidian flavor"
1373 );
1374 assert!(
1375 rule.check_atx_heading_line("#myTag", MarkdownFlavor::Obsidian)
1376 .is_none(),
1377 "#myTag should be skipped in Obsidian flavor"
1378 );
1379 }
1380
1381 #[test]
1382 fn test_obsidian_tag_skips_complex_tags() {
1383 let rule = MD018NoMissingSpaceAtx::new();
1385
1386 assert!(
1388 rule.check_atx_heading_line("#project/active", MarkdownFlavor::Obsidian)
1389 .is_none(),
1390 "#project/active should be skipped in Obsidian flavor (nested tag)"
1391 );
1392 assert!(
1393 rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1394 .is_none(),
1395 "#my-tag should be skipped in Obsidian flavor"
1396 );
1397 assert!(
1398 rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1399 .is_none(),
1400 "#my_tag should be skipped in Obsidian flavor"
1401 );
1402 assert!(
1403 rule.check_atx_heading_line("#tag2023", MarkdownFlavor::Obsidian)
1404 .is_none(),
1405 "#tag2023 should be skipped in Obsidian flavor"
1406 );
1407 assert!(
1408 rule.check_atx_heading_line("#project/sub/task", MarkdownFlavor::Obsidian)
1409 .is_none(),
1410 "#project/sub/task should be skipped in Obsidian flavor"
1411 );
1412 }
1413
1414 #[test]
1415 fn test_obsidian_tag_with_trailing_content() {
1416 let rule = MD018NoMissingSpaceAtx::new();
1418
1419 assert!(
1420 rule.check_atx_heading_line("#hey ", MarkdownFlavor::Obsidian).is_none(),
1421 "#hey followed by space should be skipped"
1422 );
1423 assert!(
1424 rule.check_atx_heading_line("#tag some text", MarkdownFlavor::Obsidian)
1425 .is_none(),
1426 "#tag followed by text should be skipped"
1427 );
1428 }
1429
1430 #[test]
1431 fn test_obsidian_tag_still_flags_multi_hash() {
1432 let rule = MD018NoMissingSpaceAtx::new();
1434
1435 assert!(
1436 rule.check_atx_heading_line("##tag", MarkdownFlavor::Obsidian).is_some(),
1437 "##tag should be flagged in Obsidian flavor (only single # is a tag)"
1438 );
1439 assert!(
1440 rule.check_atx_heading_line("###hello", MarkdownFlavor::Obsidian)
1441 .is_some(),
1442 "###hello should be flagged in Obsidian flavor"
1443 );
1444 }
1445
1446 #[test]
1447 fn test_obsidian_tag_numeric_still_flagged() {
1448 let rule = MD018NoMissingSpaceAtx::new();
1450
1451 assert!(
1452 rule.check_atx_heading_line("#123", MarkdownFlavor::Obsidian).is_some(),
1453 "#123 should be flagged in Obsidian flavor (tags cannot start with digit)"
1454 );
1455 assert!(
1456 rule.check_atx_heading_line("#10", MarkdownFlavor::Obsidian).is_some(),
1457 "#10 should be flagged in Obsidian flavor"
1458 );
1459 }
1460
1461 #[test]
1462 fn test_obsidian_flavor_full_check() {
1463 let rule = MD018NoMissingSpaceAtx::new();
1465
1466 let content = r#"# Real Heading
1467
1468#hey this is a tag
1469
1470#project/active also a tag
1471
1472##Introduction
1473
1474#123
1475"#;
1476
1477 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1479 let result = rule.check(&ctx).unwrap();
1480
1481 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1482 assert!(
1483 !flagged_lines.contains(&3),
1484 "#hey should NOT be flagged in Obsidian flavor"
1485 );
1486 assert!(
1487 !flagged_lines.contains(&5),
1488 "#project/active should NOT be flagged in Obsidian flavor"
1489 );
1490 assert!(
1491 flagged_lines.contains(&7),
1492 "##Introduction SHOULD be flagged in Obsidian flavor"
1493 );
1494 assert!(flagged_lines.contains(&9), "#123 SHOULD be flagged in Obsidian flavor");
1495 }
1496
1497 #[test]
1498 fn test_obsidian_flavor_fix_exact_output() {
1499 let rule = MD018NoMissingSpaceAtx::new();
1501
1502 let content = "#hey is a tag.\n\n##Introduction";
1505 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1506 let fixed = rule.fix(&ctx).unwrap();
1507
1508 let expected = "#hey is a tag.\n\n## Introduction";
1510 assert_eq!(
1511 fixed, expected,
1512 "Obsidian fix should preserve tags and fix multi-hash headings"
1513 );
1514 }
1515
1516 #[test]
1517 fn test_standard_flavor_flags_obsidian_tags() {
1518 let rule = MD018NoMissingSpaceAtx::new();
1520
1521 assert!(
1522 rule.check_atx_heading_line("#hey", MarkdownFlavor::Standard).is_some(),
1523 "#hey should be flagged in Standard flavor"
1524 );
1525 assert!(
1526 rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
1527 "#tag should be flagged in Standard flavor"
1528 );
1529 assert!(
1530 rule.check_atx_heading_line("#project/active", MarkdownFlavor::Standard)
1531 .is_some(),
1532 "#project/active should be flagged in Standard flavor"
1533 );
1534 }
1535
1536 #[test]
1537 fn test_obsidian_vs_standard_fix_comparison() {
1538 let rule = MD018NoMissingSpaceAtx::new();
1540
1541 let content = "#hey tag\n##Introduction";
1545
1546 let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1548 let fixed_obsidian = rule.fix(&ctx_obsidian).unwrap();
1549 assert_eq!(fixed_obsidian, "#hey tag\n## Introduction");
1550
1551 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1553 let fixed_standard = rule.fix(&ctx_standard).unwrap();
1554 assert_eq!(fixed_standard, "# hey tag\n## Introduction");
1555 }
1556
1557 #[test]
1558 fn test_obsidian_tag_edge_cases() {
1559 let rule = MD018NoMissingSpaceAtx::new();
1561
1562 let valid_tags = [
1564 "#a", "#tag", "#Tag", "#TAG", "#my-tag", "#my_tag", "#tag123", "#a1", "#日本語", "#über", ];
1575
1576 for tag in valid_tags {
1577 let result = rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian);
1579 let _ = result;
1582 }
1583
1584 let invalid_tags = ["#1tag", "#123", "#2023-project"];
1586
1587 for tag in invalid_tags {
1588 assert!(
1589 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_some(),
1590 "{tag:?} should be flagged in Obsidian flavor (starts with digit)"
1591 );
1592 }
1593 }
1594
1595 #[test]
1596 fn test_obsidian_tag_alone_on_line() {
1597 let rule = MD018NoMissingSpaceAtx::new();
1599
1600 let content = "Some text\n\n#todo\n\nMore text.";
1601 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1602 let result = rule.check(&ctx).unwrap();
1603
1604 assert!(
1606 result.is_empty(),
1607 "Standalone #todo should not be flagged in Obsidian flavor"
1608 );
1609
1610 let fixed = rule.fix(&ctx).unwrap();
1612 assert_eq!(fixed, content, "fix() should not modify standalone Obsidian tag");
1613 }
1614
1615 #[test]
1616 fn test_obsidian_deeply_nested_tags() {
1617 let rule = MD018NoMissingSpaceAtx::new();
1619
1620 let nested_tags = [
1621 "#a/b",
1622 "#a/b/c",
1623 "#project/2023/q1/task",
1624 "#work/meetings/weekly",
1625 "#life/health/exercise/running",
1626 ];
1627
1628 for tag in nested_tags {
1629 assert!(
1630 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1631 "{tag:?} should be skipped in Obsidian flavor (nested tag)"
1632 );
1633 }
1634 }
1635
1636 #[test]
1637 fn test_obsidian_unicode_tags() {
1638 let rule = MD018NoMissingSpaceAtx::new();
1640
1641 let unicode_tags = [
1642 "#日本語", "#中文", "#한국어", "#über", "#café", "#ñoño", "#Москва", "#αβγ", ];
1651
1652 for tag in unicode_tags {
1653 assert!(
1654 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1655 "{tag:?} should be skipped in Obsidian flavor (Unicode tag)"
1656 );
1657 }
1658 }
1659
1660 #[test]
1661 fn test_obsidian_tags_with_special_endings() {
1662 let rule = MD018NoMissingSpaceAtx::new();
1664
1665 assert!(
1667 rule.check_atx_heading_line("#tag followed by text", MarkdownFlavor::Obsidian)
1668 .is_none(),
1669 "#tag followed by text should be skipped"
1670 );
1671
1672 let content = "#todo";
1674 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1675 let result = rule.check(&ctx).unwrap();
1676 assert!(result.is_empty(), "#todo at end of line should be skipped");
1677 }
1678
1679 #[test]
1680 fn test_obsidian_combined_with_other_skip_contexts() {
1681 let rule = MD018NoMissingSpaceAtx::new();
1683
1684 let content = "```\n#todo\n```";
1686 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1687 let result = rule.check(&ctx).unwrap();
1688 assert!(result.is_empty(), "Tag in code block should be skipped");
1689
1690 let content = "<!-- #todo -->";
1692 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1693 let result = rule.check(&ctx).unwrap();
1694 assert!(result.is_empty(), "Tag in HTML comment should be skipped");
1695 }
1696
1697 #[test]
1698 fn test_obsidian_boundary_cases() {
1699 let rule = MD018NoMissingSpaceAtx::new();
1701
1702 assert!(
1706 rule.check_atx_heading_line("#ab", MarkdownFlavor::Obsidian).is_none(),
1707 "#ab should be skipped in Obsidian flavor"
1708 );
1709
1710 assert!(
1712 rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1713 .is_none(),
1714 "#my_tag should be skipped"
1715 );
1716
1717 assert!(
1719 rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1720 .is_none(),
1721 "#my-tag should be skipped"
1722 );
1723
1724 assert!(
1726 rule.check_atx_heading_line("#MyTag", MarkdownFlavor::Obsidian)
1727 .is_none(),
1728 "#MyTag should be skipped"
1729 );
1730
1731 assert!(
1733 rule.check_atx_heading_line("#TODO", MarkdownFlavor::Obsidian).is_none(),
1734 "#TODO should be skipped in Obsidian flavor"
1735 );
1736 }
1737}