1mod md018_config;
5
6pub 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 {
258 range: self.get_line_byte_range(ctx.content, line_num + 1),
259 replacement: {
260 let line = line_info.content(ctx.content);
262 let original_indent = &line[..line_info.indent];
263 format!("{original_indent}{} {after_marker}", heading.marker)
264 },
265 }),
266 });
267 }
268 }
269 }
270 } else if !line_info.in_code_block
271 && !line_info.in_front_matter
272 && !line_info.in_html_comment
273 && !line_info.in_mdx_comment
274 && !line_info.is_blank
275 {
276 if let Some((hash_end_pos, fixed_line)) =
278 self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor)
279 {
280 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
281 line_num + 1, hash_end_pos + 1, 0, );
285
286 warnings.push(LintWarning {
287 rule_name: Some(self.name().to_string()),
288 message: "No space after hash in heading".to_string(),
289 line: start_line,
290 column: start_col,
291 end_line,
292 end_column: end_col,
293 severity: Severity::Warning,
294 fix: Some(Fix {
295 range: self.get_line_byte_range(ctx.content, line_num + 1),
296 replacement: fixed_line,
297 }),
298 });
299 }
300 }
301 }
302
303 Ok(warnings)
304 }
305
306 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
307 let warnings = self.check(ctx)?;
308 let warnings =
309 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
310 let warning_lines: std::collections::HashSet<usize> = warnings.iter().map(|w| w.line).collect();
311
312 let mut lines = Vec::new();
313
314 for (idx, line_info) in ctx.lines.iter().enumerate() {
315 let mut fixed = false;
316
317 if !warning_lines.contains(&(idx + 1)) {
318 lines.push(line_info.content(ctx.content).to_string());
319 continue;
320 }
321
322 if let Some(heading) = &line_info.heading {
323 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
325 let line = line_info.content(ctx.content);
326 let trimmed = line.trim_start();
327
328 let is_emoji = get_cached_regex(EMOJI_HASHTAG_PATTERN_STR)
330 .map(|re| re.is_match(trimmed))
331 .unwrap_or(false);
332 let is_unicode = get_cached_regex(UNICODE_HASHTAG_PATTERN_STR)
333 .map(|re| re.is_match(trimmed))
334 .unwrap_or(false);
335
336 let is_magiclink = self.config.magiclink && heading.level == 1 && Self::is_magiclink_ref(line);
338
339 let is_tag = self.tags_enabled(ctx.flavor) && heading.level == 1 && Self::is_tag(line);
341
342 if !is_emoji && !is_unicode && !is_magiclink && !is_tag && trimmed.len() > heading.marker.len() {
344 let after_marker = &trimmed[heading.marker.len()..];
345 if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
346 {
347 let line = line_info.content(ctx.content);
349 let original_indent = &line[..line_info.indent];
350 lines.push(format!("{original_indent}{} {after_marker}", heading.marker));
351 fixed = true;
352 }
353 }
354 }
355 } else if !line_info.in_code_block
356 && !line_info.in_front_matter
357 && !line_info.in_html_comment
358 && !line_info.in_mdx_comment
359 && !line_info.is_blank
360 {
361 if let Some((_, fixed_line)) = self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor) {
363 lines.push(fixed_line);
364 fixed = true;
365 }
366 }
367
368 if !fixed {
369 lines.push(line_info.content(ctx.content).to_string());
370 }
371 }
372
373 let mut result = lines.join("\n");
375 if ctx.content.ends_with('\n') && !result.ends_with('\n') {
376 result.push('\n');
377 }
378
379 Ok(result)
380 }
381
382 fn category(&self) -> RuleCategory {
384 RuleCategory::Heading
385 }
386
387 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
389 !ctx.likely_has_headings()
391 }
392
393 fn as_any(&self) -> &dyn std::any::Any {
394 self
395 }
396
397 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
398 where
399 Self: Sized,
400 {
401 let rule_config = crate::rule_config_serde::load_rule_config::<MD018Config>(config);
402 Box::new(MD018NoMissingSpaceAtx::from_config_struct(rule_config))
403 }
404
405 fn default_config_section(&self) -> Option<(String, toml::Value)> {
406 let json_value = serde_json::to_value(&self.config).ok()?;
407 Some((
408 self.name().to_string(),
409 crate::rule_config_serde::json_to_toml_value(&json_value)?,
410 ))
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use crate::lint_context::LintContext;
418
419 #[test]
420 fn test_basic_functionality() {
421 let rule = MD018NoMissingSpaceAtx::new();
422
423 let content = "# Heading 1\n## Heading 2\n### Heading 3";
425 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
426 let result = rule.check(&ctx).unwrap();
427 assert!(result.is_empty());
428
429 let content = "#Heading 1\n## Heading 2\n###Heading 3";
431 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
432 let result = rule.check(&ctx).unwrap();
433 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
435 assert_eq!(result[1].line, 3);
436 }
437
438 #[test]
439 fn test_malformed_heading_detection() {
440 let rule = MD018NoMissingSpaceAtx::new();
441
442 assert!(
444 rule.check_atx_heading_line("##Introduction", MarkdownFlavor::Standard)
445 .is_some()
446 );
447 assert!(
448 rule.check_atx_heading_line("###Background", MarkdownFlavor::Standard)
449 .is_some()
450 );
451 assert!(
452 rule.check_atx_heading_line("####Details", MarkdownFlavor::Standard)
453 .is_some()
454 );
455 assert!(
456 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
457 .is_some()
458 );
459 assert!(
460 rule.check_atx_heading_line("######Conclusion", MarkdownFlavor::Standard)
461 .is_some()
462 );
463 assert!(
464 rule.check_atx_heading_line("##Table of Contents", MarkdownFlavor::Standard)
465 .is_some()
466 );
467
468 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!(
473 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
474 .is_none()
475 ); assert!(
477 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
478 .is_none()
479 ); }
481
482 #[test]
483 fn test_malformed_heading_with_context() {
484 let rule = MD018NoMissingSpaceAtx::new();
485
486 let content = r#"# Test Document
488
489##Introduction
490This should be detected.
491
492 ##CodeBlock
493This should NOT be detected (indented code block).
494
495```
496##FencedCodeBlock
497This should NOT be detected (fenced code block).
498```
499
500##Conclusion
501This should be detected.
502"#;
503
504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
505 let result = rule.check(&ctx).unwrap();
506
507 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
509 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&14)); assert!(!detected_lines.contains(&6)); assert!(!detected_lines.contains(&10)); }
514
515 #[test]
516 fn test_malformed_heading_fix() {
517 let rule = MD018NoMissingSpaceAtx::new();
518
519 let content = r#"##Introduction
520This is a test.
521
522###Background
523More content."#;
524
525 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
526 let fixed = rule.fix(&ctx).unwrap();
527
528 let expected = r#"## Introduction
529This is a test.
530
531### Background
532More content."#;
533
534 assert_eq!(fixed, expected);
535 }
536
537 #[test]
538 fn test_mixed_proper_and_malformed_headings() {
539 let rule = MD018NoMissingSpaceAtx::new();
540
541 let content = r#"# Proper Heading
542
543##Malformed Heading
544
545## Another Proper Heading
546
547###Another Malformed
548
549#### Proper with space
550"#;
551
552 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
553 let result = rule.check(&ctx).unwrap();
554
555 assert_eq!(result.len(), 2);
557 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
558 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&7)); }
561
562 #[test]
563 fn test_css_selectors_in_html_blocks() {
564 let rule = MD018NoMissingSpaceAtx::new();
565
566 let content = r#"# Proper Heading
569
570<style>
571#slide-1 ol li {
572 margin-top: 0;
573}
574
575#special-slide ol li {
576 margin-top: 2em;
577}
578</style>
579
580## Another Heading
581"#;
582
583 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
584 let result = rule.check(&ctx).unwrap();
585
586 assert_eq!(
588 result.len(),
589 0,
590 "CSS selectors in <style> blocks should not be flagged as malformed headings"
591 );
592 }
593
594 #[test]
595 fn test_js_code_in_script_blocks() {
596 let rule = MD018NoMissingSpaceAtx::new();
597
598 let content = r#"# Heading
600
601<script>
602const element = document.querySelector('#main-content');
603#another-comment
604</script>
605
606## Another Heading
607"#;
608
609 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
610 let result = rule.check(&ctx).unwrap();
611
612 assert_eq!(
614 result.len(),
615 0,
616 "JavaScript code in <script> blocks should not be flagged as malformed headings"
617 );
618 }
619
620 #[test]
621 fn test_all_malformed_headings_detected() {
622 let rule = MD018NoMissingSpaceAtx::new();
623
624 assert!(
629 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
630 .is_some(),
631 "#hello SHOULD be detected as malformed heading"
632 );
633 assert!(
634 rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
635 "#tag SHOULD be detected as malformed heading"
636 );
637 assert!(
638 rule.check_atx_heading_line("#hashtag", MarkdownFlavor::Standard)
639 .is_some(),
640 "#hashtag SHOULD be detected as malformed heading"
641 );
642 assert!(
643 rule.check_atx_heading_line("#javascript", MarkdownFlavor::Standard)
644 .is_some(),
645 "#javascript SHOULD be detected as malformed heading"
646 );
647
648 assert!(
650 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
651 "#123 SHOULD be detected as malformed heading"
652 );
653 assert!(
654 rule.check_atx_heading_line("#12345", MarkdownFlavor::Standard)
655 .is_some(),
656 "#12345 SHOULD be detected as malformed heading"
657 );
658 assert!(
659 rule.check_atx_heading_line("#29039)", MarkdownFlavor::Standard)
660 .is_some(),
661 "#29039) SHOULD be detected as malformed heading"
662 );
663
664 assert!(
666 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
667 .is_some(),
668 "#Summary SHOULD be detected as malformed heading"
669 );
670 assert!(
671 rule.check_atx_heading_line("#Introduction", MarkdownFlavor::Standard)
672 .is_some(),
673 "#Introduction SHOULD be detected as malformed heading"
674 );
675 assert!(
676 rule.check_atx_heading_line("#API", MarkdownFlavor::Standard).is_some(),
677 "#API SHOULD be detected as malformed heading"
678 );
679
680 assert!(
682 rule.check_atx_heading_line("##introduction", MarkdownFlavor::Standard)
683 .is_some(),
684 "##introduction SHOULD be detected as malformed heading"
685 );
686 assert!(
687 rule.check_atx_heading_line("###section", MarkdownFlavor::Standard)
688 .is_some(),
689 "###section SHOULD be detected as malformed heading"
690 );
691 assert!(
692 rule.check_atx_heading_line("###fer", MarkdownFlavor::Standard)
693 .is_some(),
694 "###fer SHOULD be detected as malformed heading"
695 );
696 assert!(
697 rule.check_atx_heading_line("##123", MarkdownFlavor::Standard).is_some(),
698 "##123 SHOULD be detected as malformed heading"
699 );
700 }
701
702 #[test]
703 fn test_patterns_that_should_not_be_flagged() {
704 let rule = MD018NoMissingSpaceAtx::new();
705
706 assert!(rule.check_atx_heading_line("###", MarkdownFlavor::Standard).is_none());
708 assert!(rule.check_atx_heading_line("#", MarkdownFlavor::Standard).is_none());
709
710 assert!(rule.check_atx_heading_line("##a", MarkdownFlavor::Standard).is_none());
712
713 assert!(
715 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
716 .is_none()
717 );
718
719 assert!(
721 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
722 .is_none()
723 );
724
725 assert!(
727 rule.check_atx_heading_line("# Hello", MarkdownFlavor::Standard)
728 .is_none()
729 );
730 assert!(
731 rule.check_atx_heading_line("## World", MarkdownFlavor::Standard)
732 .is_none()
733 );
734 assert!(
735 rule.check_atx_heading_line("### Section", MarkdownFlavor::Standard)
736 .is_none()
737 );
738 }
739
740 #[test]
741 fn test_inline_issue_refs_not_at_line_start() {
742 let rule = MD018NoMissingSpaceAtx::new();
743
744 assert!(
749 rule.check_atx_heading_line("See issue #123", MarkdownFlavor::Standard)
750 .is_none()
751 );
752 assert!(
753 rule.check_atx_heading_line("Check #trending on Twitter", MarkdownFlavor::Standard)
754 .is_none()
755 );
756 assert!(
757 rule.check_atx_heading_line("- fix: issue #29039", MarkdownFlavor::Standard)
758 .is_none()
759 );
760 }
761
762 #[test]
763 fn test_lowercase_patterns_full_check() {
764 let rule = MD018NoMissingSpaceAtx::new();
766
767 let content = "#hello\n\n#world\n\n#tag";
768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
769 let result = rule.check(&ctx).unwrap();
770
771 assert_eq!(result.len(), 3, "All three lowercase patterns should be flagged");
772 assert_eq!(result[0].line, 1);
773 assert_eq!(result[1].line, 3);
774 assert_eq!(result[2].line, 5);
775 }
776
777 #[test]
778 fn test_numeric_patterns_full_check() {
779 let rule = MD018NoMissingSpaceAtx::new();
781
782 let content = "#123\n\n#456\n\n#29039";
783 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
784 let result = rule.check(&ctx).unwrap();
785
786 assert_eq!(result.len(), 3, "All three numeric patterns should be flagged");
787 }
788
789 #[test]
790 fn test_fix_lowercase_patterns() {
791 let rule = MD018NoMissingSpaceAtx::new();
793
794 let content = "#hello\nSome text.\n\n#world";
795 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
796 let fixed = rule.fix(&ctx).unwrap();
797
798 let expected = "# hello\nSome text.\n\n# world";
799 assert_eq!(fixed, expected);
800 }
801
802 #[test]
803 fn test_fix_numeric_patterns() {
804 let rule = MD018NoMissingSpaceAtx::new();
806
807 let content = "#123\nContent.\n\n##456";
808 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
809 let fixed = rule.fix(&ctx).unwrap();
810
811 let expected = "# 123\nContent.\n\n## 456";
812 assert_eq!(fixed, expected);
813 }
814
815 #[test]
816 fn test_indented_malformed_headings() {
817 let rule = MD018NoMissingSpaceAtx::new();
821
822 assert!(
824 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
825 .is_none(),
826 "1-space indented #hello should be skipped"
827 );
828 assert!(
829 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
830 .is_none(),
831 "2-space indented #hello should be skipped"
832 );
833 assert!(
834 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
835 .is_none(),
836 "3-space indented #hello should be skipped"
837 );
838
839 assert!(
844 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
845 .is_some(),
846 "Non-indented #hello should be detected"
847 );
848 }
849
850 #[test]
851 fn test_tab_after_hash_is_valid() {
852 let rule = MD018NoMissingSpaceAtx::new();
854
855 assert!(
856 rule.check_atx_heading_line("#\tHello", MarkdownFlavor::Standard)
857 .is_none(),
858 "Tab after # should be valid"
859 );
860 assert!(
861 rule.check_atx_heading_line("##\tWorld", MarkdownFlavor::Standard)
862 .is_none(),
863 "Tab after ## should be valid"
864 );
865 }
866
867 #[test]
868 fn test_mixed_case_patterns() {
869 let rule = MD018NoMissingSpaceAtx::new();
870
871 assert!(
873 rule.check_atx_heading_line("#hELLO", MarkdownFlavor::Standard)
874 .is_some()
875 );
876 assert!(
877 rule.check_atx_heading_line("#Hello", MarkdownFlavor::Standard)
878 .is_some()
879 );
880 assert!(
881 rule.check_atx_heading_line("#HELLO", MarkdownFlavor::Standard)
882 .is_some()
883 );
884 assert!(
885 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
886 .is_some()
887 );
888 }
889
890 #[test]
891 fn test_unicode_lowercase() {
892 let rule = MD018NoMissingSpaceAtx::new();
893
894 assert!(
896 rule.check_atx_heading_line("#über", MarkdownFlavor::Standard).is_some(),
897 "Unicode lowercase #über should be detected"
898 );
899 assert!(
900 rule.check_atx_heading_line("#café", MarkdownFlavor::Standard).is_some(),
901 "Unicode lowercase #café should be detected"
902 );
903 assert!(
904 rule.check_atx_heading_line("#日本語", MarkdownFlavor::Standard)
905 .is_some(),
906 "Japanese #日本語 should be detected"
907 );
908 }
909
910 #[test]
911 fn test_matches_markdownlint_behavior() {
912 let rule = MD018NoMissingSpaceAtx::new();
914
915 let content = r#"#hello
916
917## world
918
919###fer
920
921#123
922
923#Tag
924"#;
925
926 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
927 let result = rule.check(&ctx).unwrap();
928
929 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
932
933 assert!(flagged_lines.contains(&1), "#hello should be flagged");
934 assert!(!flagged_lines.contains(&3), "## world should NOT be flagged");
935 assert!(flagged_lines.contains(&5), "###fer should be flagged");
936 assert!(flagged_lines.contains(&7), "#123 should be flagged");
937 assert!(flagged_lines.contains(&9), "#Tag should be flagged");
938
939 assert_eq!(result.len(), 4, "Should have exactly 4 warnings");
940 }
941
942 #[test]
943 fn test_skip_frontmatter_yaml_comments() {
944 let rule = MD018NoMissingSpaceAtx::new();
946
947 let content = r#"---
948#reviewers:
949#- sig-api-machinery
950#another_comment: value
951title: Test Document
952---
953
954# Valid heading
955
956#invalid heading without space
957"#;
958
959 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
960 let result = rule.check(&ctx).unwrap();
961
962 assert_eq!(
965 result.len(),
966 1,
967 "Should only flag the malformed heading outside frontmatter"
968 );
969 assert_eq!(result[0].line, 10, "Should flag line 10");
970 }
971
972 #[test]
973 fn test_skip_html_comments() {
974 let rule = MD018NoMissingSpaceAtx::new();
977
978 let content = r#"# Real Heading
979
980Some text.
981
982<!--
983```
984#%% Cell marker
985import matplotlib.pyplot as plt
986
987#%% Another cell
988data = [1, 2, 3]
989```
990-->
991
992More content.
993"#;
994
995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
996 let result = rule.check(&ctx).unwrap();
997
998 assert!(
1000 result.is_empty(),
1001 "Should not flag content inside HTML comments, found {} issues",
1002 result.len()
1003 );
1004 }
1005
1006 #[test]
1007 fn test_mkdocs_magiclink_skips_numeric_refs() {
1008 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1010 magiclink: true,
1011 ..Default::default()
1012 });
1013
1014 assert!(
1016 rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_none(),
1017 "#10 should be skipped with magiclink config (MagicLink issue ref)"
1018 );
1019 assert!(
1020 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_none(),
1021 "#123 should be skipped with magiclink config (MagicLink issue ref)"
1022 );
1023 assert!(
1024 rule.check_atx_heading_line("#10 discusses the issue", MarkdownFlavor::Standard)
1025 .is_none(),
1026 "#10 followed by text should be skipped with magiclink config"
1027 );
1028 assert!(
1029 rule.check_atx_heading_line("#37.", MarkdownFlavor::Standard).is_none(),
1030 "#37 followed by punctuation should be skipped with magiclink config"
1031 );
1032 }
1033
1034 #[test]
1035 fn test_mkdocs_magiclink_still_flags_non_numeric() {
1036 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1038 magiclink: true,
1039 ..Default::default()
1040 });
1041
1042 assert!(
1044 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
1045 .is_some(),
1046 "#Summary should still be flagged with magiclink config"
1047 );
1048 assert!(
1049 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
1050 .is_some(),
1051 "#hello should still be flagged with magiclink config"
1052 );
1053 assert!(
1054 rule.check_atx_heading_line("#10abc", MarkdownFlavor::Standard)
1055 .is_some(),
1056 "#10abc (mixed) should still be flagged with magiclink config"
1057 );
1058 }
1059
1060 #[test]
1061 fn test_mkdocs_magiclink_only_single_hash() {
1062 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1064 magiclink: true,
1065 ..Default::default()
1066 });
1067
1068 assert!(
1069 rule.check_atx_heading_line("##10", MarkdownFlavor::Standard).is_some(),
1070 "##10 should be flagged with magiclink config (only single # is MagicLink)"
1071 );
1072 assert!(
1073 rule.check_atx_heading_line("###123", MarkdownFlavor::Standard)
1074 .is_some(),
1075 "###123 should be flagged with magiclink config"
1076 );
1077 }
1078
1079 #[test]
1080 fn test_standard_flavor_flags_numeric_refs() {
1081 let rule = MD018NoMissingSpaceAtx::new();
1083
1084 assert!(
1085 rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_some(),
1086 "#10 should be flagged in Standard flavor"
1087 );
1088 assert!(
1089 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
1090 "#123 should be flagged in Standard flavor"
1091 );
1092 }
1093
1094 #[test]
1095 fn test_mkdocs_magiclink_full_check() {
1096 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1098 magiclink: true,
1099 ..Default::default()
1100 });
1101
1102 let content = r#"# PRs that are helpful for context
1103
1104#10 discusses the philosophy behind the project, and #37 shows a good example.
1105
1106#Summary
1107
1108##Introduction
1109"#;
1110
1111 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1113 let result = rule.check(&ctx).unwrap();
1114
1115 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1116 assert!(
1117 !flagged_lines.contains(&3),
1118 "#10 should NOT be flagged with magiclink config"
1119 );
1120 assert!(
1121 flagged_lines.contains(&5),
1122 "#Summary SHOULD be flagged with magiclink config"
1123 );
1124 assert!(
1125 flagged_lines.contains(&7),
1126 "##Introduction SHOULD be flagged with magiclink config"
1127 );
1128 }
1129
1130 #[test]
1131 fn test_mkdocs_magiclink_fix_exact_output() {
1132 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1134 magiclink: true,
1135 ..Default::default()
1136 });
1137
1138 let content = "#10 discusses the issue.\n\n#Summary";
1139 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1140 let fixed = rule.fix(&ctx).unwrap();
1141
1142 let expected = "#10 discusses the issue.\n\n# Summary";
1144 assert_eq!(
1145 fixed, expected,
1146 "magiclink config fix should preserve MagicLink refs and fix non-numeric headings"
1147 );
1148 }
1149
1150 #[test]
1151 fn test_mkdocs_magiclink_edge_cases() {
1152 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1154 magiclink: true,
1155 ..Default::default()
1156 });
1157
1158 let valid_refs = [
1161 "#10", "#999999", "#10 text after", "#10\ttext after", "#10.", "#10,", "#10!", "#10?", "#10)", "#10]", "#10;", "#10:", ];
1174
1175 for ref_str in valid_refs {
1176 assert!(
1177 rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_none(),
1178 "{ref_str:?} should be skipped as MagicLink ref with magiclink config"
1179 );
1180 }
1181
1182 let invalid_refs = [
1184 "#10abc", "#10a", "#abc10", "#10ABC", "#Summary", "#hello", ];
1191
1192 for ref_str in invalid_refs {
1193 assert!(
1194 rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_some(),
1195 "{ref_str:?} should be flagged with magiclink config (not a valid MagicLink ref)"
1196 );
1197 }
1198 }
1199
1200 #[test]
1201 fn test_mkdocs_magiclink_hyphenated_continuation() {
1202 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1205 magiclink: true,
1206 ..Default::default()
1207 });
1208
1209 assert!(
1214 rule.check_atx_heading_line("#10-", MarkdownFlavor::Standard).is_none(),
1215 "#10- should be skipped with magiclink config (hyphen is non-alphanumeric terminator)"
1216 );
1217 }
1218
1219 #[test]
1220 fn test_mkdocs_magiclink_standalone_number() {
1221 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1223 magiclink: true,
1224 ..Default::default()
1225 });
1226
1227 let content = "See issue:\n\n#10\n\nFor details.";
1228 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1229 let result = rule.check(&ctx).unwrap();
1230
1231 assert!(
1233 result.is_empty(),
1234 "Standalone #10 should not be flagged with magiclink config"
1235 );
1236
1237 let fixed = rule.fix(&ctx).unwrap();
1239 assert_eq!(fixed, content, "fix() should not modify standalone MagicLink ref");
1240 }
1241
1242 #[test]
1243 fn test_standard_flavor_flags_all_numeric() {
1244 let rule = MD018NoMissingSpaceAtx::new();
1247
1248 let numeric_patterns = ["#10", "#123", "#999999", "#10 text"];
1249
1250 for pattern in numeric_patterns {
1251 assert!(
1252 rule.check_atx_heading_line(pattern, MarkdownFlavor::Standard).is_some(),
1253 "{pattern:?} should be flagged in Standard flavor"
1254 );
1255 }
1256
1257 assert!(
1259 rule.check_atx_heading_line("#1", MarkdownFlavor::Standard).is_none(),
1260 "#1 should be skipped (content too short, existing behavior)"
1261 );
1262 }
1263
1264 #[test]
1265 fn test_mkdocs_vs_standard_fix_comparison() {
1266 let content = "#10 is an issue\n#Summary";
1268 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1269
1270 let rule_magiclink = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1272 magiclink: true,
1273 ..Default::default()
1274 });
1275 let fixed_magiclink = rule_magiclink.fix(&ctx).unwrap();
1276 assert_eq!(fixed_magiclink, "#10 is an issue\n# Summary");
1277
1278 let rule_default = MD018NoMissingSpaceAtx::new();
1280 let fixed_default = rule_default.fix(&ctx).unwrap();
1281 assert_eq!(fixed_default, "# 10 is an issue\n# Summary");
1282 }
1283
1284 #[test]
1287 fn test_tags_config_standard_flavor() {
1288 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1290 magiclink: false,
1291 tags: Some(true),
1292 });
1293
1294 let content = "#tag\n\n#project/active\n\n##Introduction";
1295 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1296 let result = rule.check(&ctx).unwrap();
1297
1298 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1299 assert!(!flagged_lines.contains(&1), "#tag should be skipped with tags = true");
1300 assert!(
1301 !flagged_lines.contains(&3),
1302 "#project/active should be skipped with tags = true"
1303 );
1304 assert!(flagged_lines.contains(&5), "##Introduction should still be flagged");
1305 }
1306
1307 #[test]
1308 fn test_tags_config_fix_standard_flavor() {
1309 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1310 magiclink: false,
1311 tags: Some(true),
1312 });
1313
1314 let content = "#tag\n\n##Introduction";
1315 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1316 let fixed = rule.fix(&ctx).unwrap();
1317 assert_eq!(fixed, "#tag\n\n## Introduction");
1318 }
1319
1320 #[test]
1321 fn test_tags_config_disabled_obsidian_flavor() {
1322 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1324 magiclink: false,
1325 tags: Some(false),
1326 });
1327
1328 let content = "#tag\n\n#project/active";
1329 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1330 let result = rule.check(&ctx).unwrap();
1331
1332 assert_eq!(
1333 result.len(),
1334 2,
1335 "tags = false should flag tag patterns even in Obsidian"
1336 );
1337 }
1338
1339 #[test]
1340 fn test_tags_config_default_follows_flavor() {
1341 let rule = MD018NoMissingSpaceAtx::new(); let content = "#tag";
1346 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1347 let result = rule.check(&ctx).unwrap();
1348 assert!(!result.is_empty(), "Default standard should flag #tag");
1349
1350 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1352 let result = rule.check(&ctx).unwrap();
1353 assert!(result.is_empty(), "Default Obsidian should skip #tag");
1354 }
1355
1356 #[test]
1359 fn test_obsidian_tag_skips_simple_tags() {
1360 let rule = MD018NoMissingSpaceAtx::new();
1362
1363 assert!(
1365 rule.check_atx_heading_line("#hey", MarkdownFlavor::Obsidian).is_none(),
1366 "#hey should be skipped in Obsidian flavor (tag syntax)"
1367 );
1368 assert!(
1369 rule.check_atx_heading_line("#tag", MarkdownFlavor::Obsidian).is_none(),
1370 "#tag should be skipped in Obsidian flavor"
1371 );
1372 assert!(
1373 rule.check_atx_heading_line("#hello", MarkdownFlavor::Obsidian)
1374 .is_none(),
1375 "#hello should be skipped in Obsidian flavor"
1376 );
1377 assert!(
1378 rule.check_atx_heading_line("#myTag", MarkdownFlavor::Obsidian)
1379 .is_none(),
1380 "#myTag should be skipped in Obsidian flavor"
1381 );
1382 }
1383
1384 #[test]
1385 fn test_obsidian_tag_skips_complex_tags() {
1386 let rule = MD018NoMissingSpaceAtx::new();
1388
1389 assert!(
1391 rule.check_atx_heading_line("#project/active", MarkdownFlavor::Obsidian)
1392 .is_none(),
1393 "#project/active should be skipped in Obsidian flavor (nested tag)"
1394 );
1395 assert!(
1396 rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1397 .is_none(),
1398 "#my-tag should be skipped in Obsidian flavor"
1399 );
1400 assert!(
1401 rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1402 .is_none(),
1403 "#my_tag should be skipped in Obsidian flavor"
1404 );
1405 assert!(
1406 rule.check_atx_heading_line("#tag2023", MarkdownFlavor::Obsidian)
1407 .is_none(),
1408 "#tag2023 should be skipped in Obsidian flavor"
1409 );
1410 assert!(
1411 rule.check_atx_heading_line("#project/sub/task", MarkdownFlavor::Obsidian)
1412 .is_none(),
1413 "#project/sub/task should be skipped in Obsidian flavor"
1414 );
1415 }
1416
1417 #[test]
1418 fn test_obsidian_tag_with_trailing_content() {
1419 let rule = MD018NoMissingSpaceAtx::new();
1421
1422 assert!(
1423 rule.check_atx_heading_line("#hey ", MarkdownFlavor::Obsidian).is_none(),
1424 "#hey followed by space should be skipped"
1425 );
1426 assert!(
1427 rule.check_atx_heading_line("#tag some text", MarkdownFlavor::Obsidian)
1428 .is_none(),
1429 "#tag followed by text should be skipped"
1430 );
1431 }
1432
1433 #[test]
1434 fn test_obsidian_tag_still_flags_multi_hash() {
1435 let rule = MD018NoMissingSpaceAtx::new();
1437
1438 assert!(
1439 rule.check_atx_heading_line("##tag", MarkdownFlavor::Obsidian).is_some(),
1440 "##tag should be flagged in Obsidian flavor (only single # is a tag)"
1441 );
1442 assert!(
1443 rule.check_atx_heading_line("###hello", MarkdownFlavor::Obsidian)
1444 .is_some(),
1445 "###hello should be flagged in Obsidian flavor"
1446 );
1447 }
1448
1449 #[test]
1450 fn test_obsidian_tag_numeric_still_flagged() {
1451 let rule = MD018NoMissingSpaceAtx::new();
1453
1454 assert!(
1455 rule.check_atx_heading_line("#123", MarkdownFlavor::Obsidian).is_some(),
1456 "#123 should be flagged in Obsidian flavor (tags cannot start with digit)"
1457 );
1458 assert!(
1459 rule.check_atx_heading_line("#10", MarkdownFlavor::Obsidian).is_some(),
1460 "#10 should be flagged in Obsidian flavor"
1461 );
1462 }
1463
1464 #[test]
1465 fn test_obsidian_flavor_full_check() {
1466 let rule = MD018NoMissingSpaceAtx::new();
1468
1469 let content = r#"# Real Heading
1470
1471#hey this is a tag
1472
1473#project/active also a tag
1474
1475##Introduction
1476
1477#123
1478"#;
1479
1480 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1482 let result = rule.check(&ctx).unwrap();
1483
1484 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1485 assert!(
1486 !flagged_lines.contains(&3),
1487 "#hey should NOT be flagged in Obsidian flavor"
1488 );
1489 assert!(
1490 !flagged_lines.contains(&5),
1491 "#project/active should NOT be flagged in Obsidian flavor"
1492 );
1493 assert!(
1494 flagged_lines.contains(&7),
1495 "##Introduction SHOULD be flagged in Obsidian flavor"
1496 );
1497 assert!(flagged_lines.contains(&9), "#123 SHOULD be flagged in Obsidian flavor");
1498 }
1499
1500 #[test]
1501 fn test_obsidian_flavor_fix_exact_output() {
1502 let rule = MD018NoMissingSpaceAtx::new();
1504
1505 let content = "#hey is a tag.\n\n##Introduction";
1508 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1509 let fixed = rule.fix(&ctx).unwrap();
1510
1511 let expected = "#hey is a tag.\n\n## Introduction";
1513 assert_eq!(
1514 fixed, expected,
1515 "Obsidian fix should preserve tags and fix multi-hash headings"
1516 );
1517 }
1518
1519 #[test]
1520 fn test_standard_flavor_flags_obsidian_tags() {
1521 let rule = MD018NoMissingSpaceAtx::new();
1523
1524 assert!(
1525 rule.check_atx_heading_line("#hey", MarkdownFlavor::Standard).is_some(),
1526 "#hey should be flagged in Standard flavor"
1527 );
1528 assert!(
1529 rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
1530 "#tag should be flagged in Standard flavor"
1531 );
1532 assert!(
1533 rule.check_atx_heading_line("#project/active", MarkdownFlavor::Standard)
1534 .is_some(),
1535 "#project/active should be flagged in Standard flavor"
1536 );
1537 }
1538
1539 #[test]
1540 fn test_obsidian_vs_standard_fix_comparison() {
1541 let rule = MD018NoMissingSpaceAtx::new();
1543
1544 let content = "#hey tag\n##Introduction";
1548
1549 let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1551 let fixed_obsidian = rule.fix(&ctx_obsidian).unwrap();
1552 assert_eq!(fixed_obsidian, "#hey tag\n## Introduction");
1553
1554 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1556 let fixed_standard = rule.fix(&ctx_standard).unwrap();
1557 assert_eq!(fixed_standard, "# hey tag\n## Introduction");
1558 }
1559
1560 #[test]
1561 fn test_obsidian_tag_edge_cases() {
1562 let rule = MD018NoMissingSpaceAtx::new();
1564
1565 let valid_tags = [
1567 "#a", "#tag", "#Tag", "#TAG", "#my-tag", "#my_tag", "#tag123", "#a1", "#日本語", "#über", ];
1578
1579 for tag in valid_tags {
1580 let result = rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian);
1582 let _ = result;
1585 }
1586
1587 let invalid_tags = ["#1tag", "#123", "#2023-project"];
1589
1590 for tag in invalid_tags {
1591 assert!(
1592 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_some(),
1593 "{tag:?} should be flagged in Obsidian flavor (starts with digit)"
1594 );
1595 }
1596 }
1597
1598 #[test]
1599 fn test_obsidian_tag_alone_on_line() {
1600 let rule = MD018NoMissingSpaceAtx::new();
1602
1603 let content = "Some text\n\n#todo\n\nMore text.";
1604 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1605 let result = rule.check(&ctx).unwrap();
1606
1607 assert!(
1609 result.is_empty(),
1610 "Standalone #todo should not be flagged in Obsidian flavor"
1611 );
1612
1613 let fixed = rule.fix(&ctx).unwrap();
1615 assert_eq!(fixed, content, "fix() should not modify standalone Obsidian tag");
1616 }
1617
1618 #[test]
1619 fn test_obsidian_deeply_nested_tags() {
1620 let rule = MD018NoMissingSpaceAtx::new();
1622
1623 let nested_tags = [
1624 "#a/b",
1625 "#a/b/c",
1626 "#project/2023/q1/task",
1627 "#work/meetings/weekly",
1628 "#life/health/exercise/running",
1629 ];
1630
1631 for tag in nested_tags {
1632 assert!(
1633 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1634 "{tag:?} should be skipped in Obsidian flavor (nested tag)"
1635 );
1636 }
1637 }
1638
1639 #[test]
1640 fn test_obsidian_unicode_tags() {
1641 let rule = MD018NoMissingSpaceAtx::new();
1643
1644 let unicode_tags = [
1645 "#日本語", "#中文", "#한국어", "#über", "#café", "#ñoño", "#Москва", "#αβγ", ];
1654
1655 for tag in unicode_tags {
1656 assert!(
1657 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1658 "{tag:?} should be skipped in Obsidian flavor (Unicode tag)"
1659 );
1660 }
1661 }
1662
1663 #[test]
1664 fn test_obsidian_tags_with_special_endings() {
1665 let rule = MD018NoMissingSpaceAtx::new();
1667
1668 assert!(
1670 rule.check_atx_heading_line("#tag followed by text", MarkdownFlavor::Obsidian)
1671 .is_none(),
1672 "#tag followed by text should be skipped"
1673 );
1674
1675 let content = "#todo";
1677 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1678 let result = rule.check(&ctx).unwrap();
1679 assert!(result.is_empty(), "#todo at end of line should be skipped");
1680 }
1681
1682 #[test]
1683 fn test_obsidian_combined_with_other_skip_contexts() {
1684 let rule = MD018NoMissingSpaceAtx::new();
1686
1687 let content = "```\n#todo\n```";
1689 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1690 let result = rule.check(&ctx).unwrap();
1691 assert!(result.is_empty(), "Tag in code block should be skipped");
1692
1693 let content = "<!-- #todo -->";
1695 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1696 let result = rule.check(&ctx).unwrap();
1697 assert!(result.is_empty(), "Tag in HTML comment should be skipped");
1698 }
1699
1700 #[test]
1701 fn test_obsidian_boundary_cases() {
1702 let rule = MD018NoMissingSpaceAtx::new();
1704
1705 assert!(
1709 rule.check_atx_heading_line("#ab", MarkdownFlavor::Obsidian).is_none(),
1710 "#ab should be skipped in Obsidian flavor"
1711 );
1712
1713 assert!(
1715 rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1716 .is_none(),
1717 "#my_tag should be skipped"
1718 );
1719
1720 assert!(
1722 rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1723 .is_none(),
1724 "#my-tag should be skipped"
1725 );
1726
1727 assert!(
1729 rule.check_atx_heading_line("#MyTag", MarkdownFlavor::Obsidian)
1730 .is_none(),
1731 "#MyTag should be skipped"
1732 );
1733
1734 assert!(
1736 rule.check_atx_heading_line("#TODO", MarkdownFlavor::Obsidian).is_none(),
1737 "#TODO should be skipped in Obsidian flavor"
1738 );
1739 }
1740}