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 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#][^\s#]*(?:\s|$)";
31static TAG_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(TAG_PATTERN_STR).unwrap());
32
33#[derive(Clone)]
34pub struct MD018NoMissingSpaceAtx {
35 config: MD018Config,
36}
37
38impl Default for MD018NoMissingSpaceAtx {
39 fn default() -> Self {
40 Self::new()
41 }
42}
43
44impl MD018NoMissingSpaceAtx {
45 pub fn new() -> Self {
46 Self {
47 config: MD018Config::default(),
48 }
49 }
50
51 pub fn from_config_struct(config: MD018Config) -> Self {
52 Self { config }
53 }
54
55 fn is_magiclink_ref(line: &str) -> bool {
58 MAGICLINK_REF_PATTERN.is_match(line.trim_start())
59 }
60
61 fn is_tag(line: &str) -> bool {
63 TAG_PATTERN.is_match(line.trim_start())
64 }
65
66 fn tags_enabled(&self, flavor: MarkdownFlavor) -> bool {
68 self.config.tags_enabled(flavor)
69 }
70
71 fn check_atx_heading_line(&self, line: &str, flavor: MarkdownFlavor) -> Option<(usize, String)> {
73 let trimmed_line = line.trim_start();
75 let indent = line.len() - trimmed_line.len();
76
77 if !trimmed_line.starts_with('#') {
78 return None;
79 }
80
81 if indent > 0 {
87 return None;
88 }
89
90 let is_emoji = EMOJI_HASHTAG_PATTERN.is_match(trimmed_line);
92 let is_unicode = UNICODE_HASHTAG_PATTERN.is_match(trimmed_line);
93 if is_emoji || is_unicode {
94 return None;
95 }
96
97 let hash_count = trimmed_line.chars().take_while(|&c| c == '#').count();
99 if hash_count == 0 || hash_count > 6 {
100 return None;
101 }
102
103 let after_hashes = &trimmed_line[hash_count..];
105
106 if after_hashes
108 .chars()
109 .next()
110 .is_some_and(|ch| matches!(ch, '\u{FE0F}' | '\u{20E3}' | '\u{FE0E}'))
111 {
112 return None;
113 }
114
115 if !after_hashes.is_empty() && !after_hashes.starts_with(' ') && !after_hashes.starts_with('\t') {
117 let content = after_hashes.trim();
119
120 if content.chars().all(|c| c == '#') {
122 return None;
123 }
124
125 if content.len() < 2 {
127 return None;
128 }
129
130 if content.starts_with('*') || content.starts_with('_') {
132 return None;
133 }
134
135 if self.config.magiclink && hash_count == 1 && Self::is_magiclink_ref(line) {
138 return None;
139 }
140
141 if self.tags_enabled(flavor) && hash_count == 1 && Self::is_tag(line) {
144 return None;
145 }
146
147 let fixed = format!("{}{} {}", " ".repeat(indent), "#".repeat(hash_count), after_hashes);
149 return Some((indent + hash_count, fixed));
150 }
151
152 None
153 }
154
155 fn get_line_byte_range(&self, content: &str, line_num: usize) -> std::ops::Range<usize> {
157 let mut current_line = 1;
158 let mut start_byte = 0;
159
160 for (i, c) in content.char_indices() {
161 if current_line == line_num && c == '\n' {
162 return start_byte..i;
163 } else if c == '\n' {
164 current_line += 1;
165 if current_line == line_num {
166 start_byte = i + 1;
167 }
168 }
169 }
170
171 if current_line == line_num {
173 return start_byte..content.len();
174 }
175
176 0..0
178 }
179}
180
181impl Rule for MD018NoMissingSpaceAtx {
182 fn name(&self) -> &'static str {
183 "MD018"
184 }
185
186 fn description(&self) -> &'static str {
187 "No space after hash in heading"
188 }
189
190 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
191 let mut warnings = Vec::new();
192
193 for (line_num, line_info) in ctx.lines.iter().enumerate() {
195 if line_info.in_html_block
197 || line_info.in_html_comment
198 || line_info.in_mdx_comment
199 || line_info.in_pymdown_block
200 {
201 continue;
202 }
203
204 if let Some(heading) = &line_info.heading {
205 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
207 if line_info.indent > 0 {
210 continue;
211 }
212
213 let line = line_info.content(ctx.content);
215 let trimmed = line.trim_start();
216
217 let is_emoji = EMOJI_HASHTAG_PATTERN.is_match(trimmed);
219 let is_unicode = UNICODE_HASHTAG_PATTERN.is_match(trimmed);
220 if is_emoji || is_unicode {
221 continue;
222 }
223
224 if self.config.magiclink && heading.level == 1 && Self::is_magiclink_ref(line) {
226 continue;
227 }
228
229 if self.tags_enabled(ctx.flavor) && heading.level == 1 && Self::is_tag(line) {
231 continue;
232 }
233
234 if trimmed.len() > heading.marker.len() {
235 let after_marker = &trimmed[heading.marker.len()..];
236 if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
237 {
238 let hash_end_col = line_info.indent + heading.marker.len() + 1; let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
241 line_num + 1, hash_end_col,
243 0, );
245
246 warnings.push(LintWarning {
247 rule_name: Some(self.name().to_string()),
248 message: format!("No space after {} in heading", "#".repeat(heading.level as usize)),
249 line: start_line,
250 column: start_col,
251 end_line,
252 end_column: end_col,
253 severity: Severity::Warning,
254 fix: Some(Fix::new(self.get_line_byte_range(ctx.content, line_num + 1), {
255 let line = line_info.content(ctx.content);
257 let original_indent = &line[..line_info.indent];
258 format!("{original_indent}{} {after_marker}", heading.marker)
259 })),
260 });
261 }
262 }
263 }
264 } else if !line_info.in_code_block
265 && !line_info.in_front_matter
266 && !line_info.in_html_comment
267 && !line_info.in_mdx_comment
268 && !line_info.is_blank
269 {
270 if let Some((hash_end_pos, fixed_line)) =
272 self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor)
273 {
274 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
275 line_num + 1, hash_end_pos + 1, 0, );
279
280 warnings.push(LintWarning {
281 rule_name: Some(self.name().to_string()),
282 message: "No space after hash in heading".to_string(),
283 line: start_line,
284 column: start_col,
285 end_line,
286 end_column: end_col,
287 severity: Severity::Warning,
288 fix: Some(Fix::new(
289 self.get_line_byte_range(ctx.content, line_num + 1),
290 fixed_line,
291 )),
292 });
293 }
294 }
295 }
296
297 Ok(warnings)
298 }
299
300 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
301 let warnings = self.check(ctx)?;
302 let warnings =
303 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
304 let warning_lines: std::collections::HashSet<usize> = warnings.iter().map(|w| w.line).collect();
305
306 let mut lines = Vec::new();
307
308 for (idx, line_info) in ctx.lines.iter().enumerate() {
309 let mut fixed = false;
310
311 if !warning_lines.contains(&(idx + 1)) {
312 lines.push(line_info.content(ctx.content).to_string());
313 continue;
314 }
315
316 if let Some(heading) = &line_info.heading {
317 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
319 let line = line_info.content(ctx.content);
320 let trimmed = line.trim_start();
321
322 let is_emoji = EMOJI_HASHTAG_PATTERN.is_match(trimmed);
324 let is_unicode = UNICODE_HASHTAG_PATTERN.is_match(trimmed);
325
326 let is_magiclink = self.config.magiclink && heading.level == 1 && Self::is_magiclink_ref(line);
328
329 let is_tag = self.tags_enabled(ctx.flavor) && heading.level == 1 && Self::is_tag(line);
331
332 if !is_emoji && !is_unicode && !is_magiclink && !is_tag && trimmed.len() > heading.marker.len() {
334 let after_marker = &trimmed[heading.marker.len()..];
335 if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
336 {
337 let line = line_info.content(ctx.content);
339 let original_indent = &line[..line_info.indent];
340 lines.push(format!("{original_indent}{} {after_marker}", heading.marker));
341 fixed = true;
342 }
343 }
344 }
345 } else if !line_info.in_code_block
346 && !line_info.in_front_matter
347 && !line_info.in_html_comment
348 && !line_info.in_mdx_comment
349 && !line_info.is_blank
350 {
351 if let Some((_, fixed_line)) = self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor) {
353 lines.push(fixed_line);
354 fixed = true;
355 }
356 }
357
358 if !fixed {
359 lines.push(line_info.content(ctx.content).to_string());
360 }
361 }
362
363 let mut result = lines.join("\n");
365 if ctx.content.ends_with('\n') && !result.ends_with('\n') {
366 result.push('\n');
367 }
368
369 Ok(result)
370 }
371
372 fn category(&self) -> RuleCategory {
374 RuleCategory::Heading
375 }
376
377 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
379 !ctx.likely_has_headings()
381 }
382
383 fn as_any(&self) -> &dyn std::any::Any {
384 self
385 }
386
387 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
388 where
389 Self: Sized,
390 {
391 let rule_config = crate::rule_config_serde::load_rule_config::<MD018Config>(config);
392 Box::new(MD018NoMissingSpaceAtx::from_config_struct(rule_config))
393 }
394
395 fn default_config_section(&self) -> Option<(String, toml::Value)> {
396 let json_value = serde_json::to_value(&self.config).ok()?;
397 Some((
398 self.name().to_string(),
399 crate::rule_config_serde::json_to_toml_value(&json_value)?,
400 ))
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use crate::lint_context::LintContext;
408
409 #[test]
410 fn test_basic_functionality() {
411 let rule = MD018NoMissingSpaceAtx::new();
412
413 let content = "# Heading 1\n## Heading 2\n### Heading 3";
415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
416 let result = rule.check(&ctx).unwrap();
417 assert!(result.is_empty());
418
419 let content = "#Heading 1\n## Heading 2\n###Heading 3";
421 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
422 let result = rule.check(&ctx).unwrap();
423 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
425 assert_eq!(result[1].line, 3);
426 }
427
428 #[test]
429 fn test_malformed_heading_detection() {
430 let rule = MD018NoMissingSpaceAtx::new();
431
432 assert!(
434 rule.check_atx_heading_line("##Introduction", MarkdownFlavor::Standard)
435 .is_some()
436 );
437 assert!(
438 rule.check_atx_heading_line("###Background", MarkdownFlavor::Standard)
439 .is_some()
440 );
441 assert!(
442 rule.check_atx_heading_line("####Details", MarkdownFlavor::Standard)
443 .is_some()
444 );
445 assert!(
446 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
447 .is_some()
448 );
449 assert!(
450 rule.check_atx_heading_line("######Conclusion", MarkdownFlavor::Standard)
451 .is_some()
452 );
453 assert!(
454 rule.check_atx_heading_line("##Table of Contents", MarkdownFlavor::Standard)
455 .is_some()
456 );
457
458 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!(
463 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
464 .is_none()
465 ); assert!(
467 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
468 .is_none()
469 ); }
471
472 #[test]
473 fn test_malformed_heading_with_context() {
474 let rule = MD018NoMissingSpaceAtx::new();
475
476 let content = r#"# Test Document
478
479##Introduction
480This should be detected.
481
482 ##CodeBlock
483This should NOT be detected (indented code block).
484
485```
486##FencedCodeBlock
487This should NOT be detected (fenced code block).
488```
489
490##Conclusion
491This should be detected.
492"#;
493
494 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
495 let result = rule.check(&ctx).unwrap();
496
497 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
499 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&14)); assert!(!detected_lines.contains(&6)); assert!(!detected_lines.contains(&10)); }
504
505 #[test]
506 fn test_malformed_heading_fix() {
507 let rule = MD018NoMissingSpaceAtx::new();
508
509 let content = r#"##Introduction
510This is a test.
511
512###Background
513More content."#;
514
515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
516 let fixed = rule.fix(&ctx).unwrap();
517
518 let expected = r#"## Introduction
519This is a test.
520
521### Background
522More content."#;
523
524 assert_eq!(fixed, expected);
525 }
526
527 #[test]
528 fn test_mixed_proper_and_malformed_headings() {
529 let rule = MD018NoMissingSpaceAtx::new();
530
531 let content = r#"# Proper Heading
532
533##Malformed Heading
534
535## Another Proper Heading
536
537###Another Malformed
538
539#### Proper with space
540"#;
541
542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543 let result = rule.check(&ctx).unwrap();
544
545 assert_eq!(result.len(), 2);
547 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
548 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&7)); }
551
552 #[test]
553 fn test_css_selectors_in_html_blocks() {
554 let rule = MD018NoMissingSpaceAtx::new();
555
556 let content = r#"# Proper Heading
559
560<style>
561#slide-1 ol li {
562 margin-top: 0;
563}
564
565#special-slide ol li {
566 margin-top: 2em;
567}
568</style>
569
570## Another Heading
571"#;
572
573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
574 let result = rule.check(&ctx).unwrap();
575
576 assert_eq!(
578 result.len(),
579 0,
580 "CSS selectors in <style> blocks should not be flagged as malformed headings"
581 );
582 }
583
584 #[test]
585 fn test_js_code_in_script_blocks() {
586 let rule = MD018NoMissingSpaceAtx::new();
587
588 let content = r#"# Heading
590
591<script>
592const element = document.querySelector('#main-content');
593#another-comment
594</script>
595
596## Another Heading
597"#;
598
599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
600 let result = rule.check(&ctx).unwrap();
601
602 assert_eq!(
604 result.len(),
605 0,
606 "JavaScript code in <script> blocks should not be flagged as malformed headings"
607 );
608 }
609
610 #[test]
611 fn test_all_malformed_headings_detected() {
612 let rule = MD018NoMissingSpaceAtx::new();
613
614 assert!(
619 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
620 .is_some(),
621 "#hello SHOULD be detected as malformed heading"
622 );
623 assert!(
624 rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
625 "#tag SHOULD be detected as malformed heading"
626 );
627 assert!(
628 rule.check_atx_heading_line("#hashtag", MarkdownFlavor::Standard)
629 .is_some(),
630 "#hashtag SHOULD be detected as malformed heading"
631 );
632 assert!(
633 rule.check_atx_heading_line("#javascript", MarkdownFlavor::Standard)
634 .is_some(),
635 "#javascript SHOULD be detected as malformed heading"
636 );
637
638 assert!(
640 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
641 "#123 SHOULD be detected as malformed heading"
642 );
643 assert!(
644 rule.check_atx_heading_line("#12345", MarkdownFlavor::Standard)
645 .is_some(),
646 "#12345 SHOULD be detected as malformed heading"
647 );
648 assert!(
649 rule.check_atx_heading_line("#29039)", MarkdownFlavor::Standard)
650 .is_some(),
651 "#29039) SHOULD be detected as malformed heading"
652 );
653
654 assert!(
656 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
657 .is_some(),
658 "#Summary SHOULD be detected as malformed heading"
659 );
660 assert!(
661 rule.check_atx_heading_line("#Introduction", MarkdownFlavor::Standard)
662 .is_some(),
663 "#Introduction SHOULD be detected as malformed heading"
664 );
665 assert!(
666 rule.check_atx_heading_line("#API", MarkdownFlavor::Standard).is_some(),
667 "#API SHOULD be detected as malformed heading"
668 );
669
670 assert!(
672 rule.check_atx_heading_line("##introduction", MarkdownFlavor::Standard)
673 .is_some(),
674 "##introduction SHOULD be detected as malformed heading"
675 );
676 assert!(
677 rule.check_atx_heading_line("###section", MarkdownFlavor::Standard)
678 .is_some(),
679 "###section SHOULD be detected as malformed heading"
680 );
681 assert!(
682 rule.check_atx_heading_line("###fer", MarkdownFlavor::Standard)
683 .is_some(),
684 "###fer SHOULD be detected as malformed heading"
685 );
686 assert!(
687 rule.check_atx_heading_line("##123", MarkdownFlavor::Standard).is_some(),
688 "##123 SHOULD be detected as malformed heading"
689 );
690 }
691
692 #[test]
693 fn test_patterns_that_should_not_be_flagged() {
694 let rule = MD018NoMissingSpaceAtx::new();
695
696 assert!(rule.check_atx_heading_line("###", MarkdownFlavor::Standard).is_none());
698 assert!(rule.check_atx_heading_line("#", MarkdownFlavor::Standard).is_none());
699
700 assert!(rule.check_atx_heading_line("##a", MarkdownFlavor::Standard).is_none());
702
703 assert!(
705 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
706 .is_none()
707 );
708
709 assert!(
711 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
712 .is_none()
713 );
714
715 assert!(
717 rule.check_atx_heading_line("# Hello", MarkdownFlavor::Standard)
718 .is_none()
719 );
720 assert!(
721 rule.check_atx_heading_line("## World", MarkdownFlavor::Standard)
722 .is_none()
723 );
724 assert!(
725 rule.check_atx_heading_line("### Section", MarkdownFlavor::Standard)
726 .is_none()
727 );
728 }
729
730 #[test]
731 fn test_inline_issue_refs_not_at_line_start() {
732 let rule = MD018NoMissingSpaceAtx::new();
733
734 assert!(
739 rule.check_atx_heading_line("See issue #123", MarkdownFlavor::Standard)
740 .is_none()
741 );
742 assert!(
743 rule.check_atx_heading_line("Check #trending on Twitter", MarkdownFlavor::Standard)
744 .is_none()
745 );
746 assert!(
747 rule.check_atx_heading_line("- fix: issue #29039", MarkdownFlavor::Standard)
748 .is_none()
749 );
750 }
751
752 #[test]
753 fn test_lowercase_patterns_full_check() {
754 let rule = MD018NoMissingSpaceAtx::new();
756
757 let content = "#hello\n\n#world\n\n#tag";
758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
759 let result = rule.check(&ctx).unwrap();
760
761 assert_eq!(result.len(), 3, "All three lowercase patterns should be flagged");
762 assert_eq!(result[0].line, 1);
763 assert_eq!(result[1].line, 3);
764 assert_eq!(result[2].line, 5);
765 }
766
767 #[test]
768 fn test_numeric_patterns_full_check() {
769 let rule = MD018NoMissingSpaceAtx::new();
771
772 let content = "#123\n\n#456\n\n#29039";
773 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
774 let result = rule.check(&ctx).unwrap();
775
776 assert_eq!(result.len(), 3, "All three numeric patterns should be flagged");
777 }
778
779 #[test]
780 fn test_fix_lowercase_patterns() {
781 let rule = MD018NoMissingSpaceAtx::new();
783
784 let content = "#hello\nSome text.\n\n#world";
785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786 let fixed = rule.fix(&ctx).unwrap();
787
788 let expected = "# hello\nSome text.\n\n# world";
789 assert_eq!(fixed, expected);
790 }
791
792 #[test]
793 fn test_fix_numeric_patterns() {
794 let rule = MD018NoMissingSpaceAtx::new();
796
797 let content = "#123\nContent.\n\n##456";
798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
799 let fixed = rule.fix(&ctx).unwrap();
800
801 let expected = "# 123\nContent.\n\n## 456";
802 assert_eq!(fixed, expected);
803 }
804
805 #[test]
806 fn test_indented_malformed_headings() {
807 let rule = MD018NoMissingSpaceAtx::new();
811
812 assert!(
814 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
815 .is_none(),
816 "1-space indented #hello should be skipped"
817 );
818 assert!(
819 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
820 .is_none(),
821 "2-space indented #hello should be skipped"
822 );
823 assert!(
824 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
825 .is_none(),
826 "3-space indented #hello should be skipped"
827 );
828
829 assert!(
834 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
835 .is_some(),
836 "Non-indented #hello should be detected"
837 );
838 }
839
840 #[test]
841 fn test_tab_after_hash_is_valid() {
842 let rule = MD018NoMissingSpaceAtx::new();
844
845 assert!(
846 rule.check_atx_heading_line("#\tHello", MarkdownFlavor::Standard)
847 .is_none(),
848 "Tab after # should be valid"
849 );
850 assert!(
851 rule.check_atx_heading_line("##\tWorld", MarkdownFlavor::Standard)
852 .is_none(),
853 "Tab after ## should be valid"
854 );
855 }
856
857 #[test]
858 fn test_mixed_case_patterns() {
859 let rule = MD018NoMissingSpaceAtx::new();
860
861 assert!(
863 rule.check_atx_heading_line("#hELLO", MarkdownFlavor::Standard)
864 .is_some()
865 );
866 assert!(
867 rule.check_atx_heading_line("#Hello", MarkdownFlavor::Standard)
868 .is_some()
869 );
870 assert!(
871 rule.check_atx_heading_line("#HELLO", MarkdownFlavor::Standard)
872 .is_some()
873 );
874 assert!(
875 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
876 .is_some()
877 );
878 }
879
880 #[test]
881 fn test_unicode_lowercase() {
882 let rule = MD018NoMissingSpaceAtx::new();
883
884 assert!(
886 rule.check_atx_heading_line("#über", MarkdownFlavor::Standard).is_some(),
887 "Unicode lowercase #über should be detected"
888 );
889 assert!(
890 rule.check_atx_heading_line("#café", MarkdownFlavor::Standard).is_some(),
891 "Unicode lowercase #café should be detected"
892 );
893 assert!(
894 rule.check_atx_heading_line("#日本語", MarkdownFlavor::Standard)
895 .is_some(),
896 "Japanese #日本語 should be detected"
897 );
898 }
899
900 #[test]
901 fn test_matches_markdownlint_behavior() {
902 let rule = MD018NoMissingSpaceAtx::new();
904
905 let content = r#"#hello
906
907## world
908
909###fer
910
911#123
912
913#Tag
914"#;
915
916 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
917 let result = rule.check(&ctx).unwrap();
918
919 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
922
923 assert!(flagged_lines.contains(&1), "#hello should be flagged");
924 assert!(!flagged_lines.contains(&3), "## world should NOT be flagged");
925 assert!(flagged_lines.contains(&5), "###fer should be flagged");
926 assert!(flagged_lines.contains(&7), "#123 should be flagged");
927 assert!(flagged_lines.contains(&9), "#Tag should be flagged");
928
929 assert_eq!(result.len(), 4, "Should have exactly 4 warnings");
930 }
931
932 #[test]
933 fn test_skip_frontmatter_yaml_comments() {
934 let rule = MD018NoMissingSpaceAtx::new();
936
937 let content = r#"---
938#reviewers:
939#- sig-api-machinery
940#another_comment: value
941title: Test Document
942---
943
944# Valid heading
945
946#invalid heading without space
947"#;
948
949 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
950 let result = rule.check(&ctx).unwrap();
951
952 assert_eq!(
955 result.len(),
956 1,
957 "Should only flag the malformed heading outside frontmatter"
958 );
959 assert_eq!(result[0].line, 10, "Should flag line 10");
960 }
961
962 #[test]
963 fn test_skip_html_comments() {
964 let rule = MD018NoMissingSpaceAtx::new();
967
968 let content = r#"# Real Heading
969
970Some text.
971
972<!--
973```
974#%% Cell marker
975import matplotlib.pyplot as plt
976
977#%% Another cell
978data = [1, 2, 3]
979```
980-->
981
982More content.
983"#;
984
985 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
986 let result = rule.check(&ctx).unwrap();
987
988 assert!(
990 result.is_empty(),
991 "Should not flag content inside HTML comments, found {} issues",
992 result.len()
993 );
994 }
995
996 #[test]
997 fn test_mkdocs_magiclink_skips_numeric_refs() {
998 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1000 magiclink: true,
1001 ..Default::default()
1002 });
1003
1004 assert!(
1006 rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_none(),
1007 "#10 should be skipped with magiclink config (MagicLink issue ref)"
1008 );
1009 assert!(
1010 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_none(),
1011 "#123 should be skipped with magiclink config (MagicLink issue ref)"
1012 );
1013 assert!(
1014 rule.check_atx_heading_line("#10 discusses the issue", MarkdownFlavor::Standard)
1015 .is_none(),
1016 "#10 followed by text should be skipped with magiclink config"
1017 );
1018 assert!(
1019 rule.check_atx_heading_line("#37.", MarkdownFlavor::Standard).is_none(),
1020 "#37 followed by punctuation should be skipped with magiclink config"
1021 );
1022 }
1023
1024 #[test]
1025 fn test_mkdocs_magiclink_still_flags_non_numeric() {
1026 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1028 magiclink: true,
1029 ..Default::default()
1030 });
1031
1032 assert!(
1034 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
1035 .is_some(),
1036 "#Summary should still be flagged with magiclink config"
1037 );
1038 assert!(
1039 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
1040 .is_some(),
1041 "#hello should still be flagged with magiclink config"
1042 );
1043 assert!(
1044 rule.check_atx_heading_line("#10abc", MarkdownFlavor::Standard)
1045 .is_some(),
1046 "#10abc (mixed) should still be flagged with magiclink config"
1047 );
1048 }
1049
1050 #[test]
1051 fn test_mkdocs_magiclink_only_single_hash() {
1052 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1054 magiclink: true,
1055 ..Default::default()
1056 });
1057
1058 assert!(
1059 rule.check_atx_heading_line("##10", MarkdownFlavor::Standard).is_some(),
1060 "##10 should be flagged with magiclink config (only single # is MagicLink)"
1061 );
1062 assert!(
1063 rule.check_atx_heading_line("###123", MarkdownFlavor::Standard)
1064 .is_some(),
1065 "###123 should be flagged with magiclink config"
1066 );
1067 }
1068
1069 #[test]
1070 fn test_standard_flavor_flags_numeric_refs() {
1071 let rule = MD018NoMissingSpaceAtx::new();
1073
1074 assert!(
1075 rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_some(),
1076 "#10 should be flagged in Standard flavor"
1077 );
1078 assert!(
1079 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
1080 "#123 should be flagged in Standard flavor"
1081 );
1082 }
1083
1084 #[test]
1085 fn test_mkdocs_magiclink_full_check() {
1086 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1088 magiclink: true,
1089 ..Default::default()
1090 });
1091
1092 let content = r#"# PRs that are helpful for context
1093
1094#10 discusses the philosophy behind the project, and #37 shows a good example.
1095
1096#Summary
1097
1098##Introduction
1099"#;
1100
1101 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1103 let result = rule.check(&ctx).unwrap();
1104
1105 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1106 assert!(
1107 !flagged_lines.contains(&3),
1108 "#10 should NOT be flagged with magiclink config"
1109 );
1110 assert!(
1111 flagged_lines.contains(&5),
1112 "#Summary SHOULD be flagged with magiclink config"
1113 );
1114 assert!(
1115 flagged_lines.contains(&7),
1116 "##Introduction SHOULD be flagged with magiclink config"
1117 );
1118 }
1119
1120 #[test]
1121 fn test_mkdocs_magiclink_fix_exact_output() {
1122 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1124 magiclink: true,
1125 ..Default::default()
1126 });
1127
1128 let content = "#10 discusses the issue.\n\n#Summary";
1129 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1130 let fixed = rule.fix(&ctx).unwrap();
1131
1132 let expected = "#10 discusses the issue.\n\n# Summary";
1134 assert_eq!(
1135 fixed, expected,
1136 "magiclink config fix should preserve MagicLink refs and fix non-numeric headings"
1137 );
1138 }
1139
1140 #[test]
1141 fn test_mkdocs_magiclink_edge_cases() {
1142 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1144 magiclink: true,
1145 ..Default::default()
1146 });
1147
1148 let valid_refs = [
1151 "#10", "#999999", "#10 text after", "#10\ttext after", "#10.", "#10,", "#10!", "#10?", "#10)", "#10]", "#10;", "#10:", ];
1164
1165 for ref_str in valid_refs {
1166 assert!(
1167 rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_none(),
1168 "{ref_str:?} should be skipped as MagicLink ref with magiclink config"
1169 );
1170 }
1171
1172 let invalid_refs = [
1174 "#10abc", "#10a", "#abc10", "#10ABC", "#Summary", "#hello", ];
1181
1182 for ref_str in invalid_refs {
1183 assert!(
1184 rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_some(),
1185 "{ref_str:?} should be flagged with magiclink config (not a valid MagicLink ref)"
1186 );
1187 }
1188 }
1189
1190 #[test]
1191 fn test_mkdocs_magiclink_hyphenated_continuation() {
1192 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1195 magiclink: true,
1196 ..Default::default()
1197 });
1198
1199 assert!(
1204 rule.check_atx_heading_line("#10-", MarkdownFlavor::Standard).is_none(),
1205 "#10- should be skipped with magiclink config (hyphen is non-alphanumeric terminator)"
1206 );
1207 }
1208
1209 #[test]
1210 fn test_mkdocs_magiclink_standalone_number() {
1211 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1213 magiclink: true,
1214 ..Default::default()
1215 });
1216
1217 let content = "See issue:\n\n#10\n\nFor details.";
1218 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1219 let result = rule.check(&ctx).unwrap();
1220
1221 assert!(
1223 result.is_empty(),
1224 "Standalone #10 should not be flagged with magiclink config"
1225 );
1226
1227 let fixed = rule.fix(&ctx).unwrap();
1229 assert_eq!(fixed, content, "fix() should not modify standalone MagicLink ref");
1230 }
1231
1232 #[test]
1233 fn test_standard_flavor_flags_all_numeric() {
1234 let rule = MD018NoMissingSpaceAtx::new();
1237
1238 let numeric_patterns = ["#10", "#123", "#999999", "#10 text"];
1239
1240 for pattern in numeric_patterns {
1241 assert!(
1242 rule.check_atx_heading_line(pattern, MarkdownFlavor::Standard).is_some(),
1243 "{pattern:?} should be flagged in Standard flavor"
1244 );
1245 }
1246
1247 assert!(
1249 rule.check_atx_heading_line("#1", MarkdownFlavor::Standard).is_none(),
1250 "#1 should be skipped (content too short, existing behavior)"
1251 );
1252 }
1253
1254 #[test]
1255 fn test_mkdocs_vs_standard_fix_comparison() {
1256 let content = "#10 is an issue\n#Summary";
1258 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1259
1260 let rule_magiclink = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1262 magiclink: true,
1263 ..Default::default()
1264 });
1265 let fixed_magiclink = rule_magiclink.fix(&ctx).unwrap();
1266 assert_eq!(fixed_magiclink, "#10 is an issue\n# Summary");
1267
1268 let rule_default = MD018NoMissingSpaceAtx::new();
1270 let fixed_default = rule_default.fix(&ctx).unwrap();
1271 assert_eq!(fixed_default, "# 10 is an issue\n# Summary");
1272 }
1273
1274 #[test]
1277 fn test_tags_config_standard_flavor() {
1278 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1280 magiclink: false,
1281 tags: Some(true),
1282 });
1283
1284 let content = "#tag\n\n#project/active\n\n##Introduction";
1285 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1286 let result = rule.check(&ctx).unwrap();
1287
1288 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1289 assert!(!flagged_lines.contains(&1), "#tag should be skipped with tags = true");
1290 assert!(
1291 !flagged_lines.contains(&3),
1292 "#project/active should be skipped with tags = true"
1293 );
1294 assert!(flagged_lines.contains(&5), "##Introduction should still be flagged");
1295 }
1296
1297 #[test]
1298 fn test_tags_config_fix_standard_flavor() {
1299 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1300 magiclink: false,
1301 tags: Some(true),
1302 });
1303
1304 let content = "#tag\n\n##Introduction";
1305 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1306 let fixed = rule.fix(&ctx).unwrap();
1307 assert_eq!(fixed, "#tag\n\n## Introduction");
1308 }
1309
1310 #[test]
1311 fn test_tags_config_disabled_obsidian_flavor() {
1312 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1314 magiclink: false,
1315 tags: Some(false),
1316 });
1317
1318 let content = "#tag\n\n#project/active";
1319 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1320 let result = rule.check(&ctx).unwrap();
1321
1322 assert_eq!(
1323 result.len(),
1324 2,
1325 "tags = false should flag tag patterns even in Obsidian"
1326 );
1327 }
1328
1329 #[test]
1330 fn test_tags_config_default_follows_flavor() {
1331 let rule = MD018NoMissingSpaceAtx::new(); let content = "#tag";
1336 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1337 let result = rule.check(&ctx).unwrap();
1338 assert!(!result.is_empty(), "Default standard should flag #tag");
1339
1340 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1342 let result = rule.check(&ctx).unwrap();
1343 assert!(result.is_empty(), "Default Obsidian should skip #tag");
1344 }
1345
1346 #[test]
1349 fn test_obsidian_tag_skips_simple_tags() {
1350 let rule = MD018NoMissingSpaceAtx::new();
1352
1353 assert!(
1355 rule.check_atx_heading_line("#hey", MarkdownFlavor::Obsidian).is_none(),
1356 "#hey should be skipped in Obsidian flavor (tag syntax)"
1357 );
1358 assert!(
1359 rule.check_atx_heading_line("#tag", MarkdownFlavor::Obsidian).is_none(),
1360 "#tag should be skipped in Obsidian flavor"
1361 );
1362 assert!(
1363 rule.check_atx_heading_line("#hello", MarkdownFlavor::Obsidian)
1364 .is_none(),
1365 "#hello should be skipped in Obsidian flavor"
1366 );
1367 assert!(
1368 rule.check_atx_heading_line("#myTag", MarkdownFlavor::Obsidian)
1369 .is_none(),
1370 "#myTag should be skipped in Obsidian flavor"
1371 );
1372 }
1373
1374 #[test]
1375 fn test_obsidian_tag_skips_complex_tags() {
1376 let rule = MD018NoMissingSpaceAtx::new();
1378
1379 assert!(
1381 rule.check_atx_heading_line("#project/active", MarkdownFlavor::Obsidian)
1382 .is_none(),
1383 "#project/active should be skipped in Obsidian flavor (nested tag)"
1384 );
1385 assert!(
1386 rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1387 .is_none(),
1388 "#my-tag should be skipped in Obsidian flavor"
1389 );
1390 assert!(
1391 rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1392 .is_none(),
1393 "#my_tag should be skipped in Obsidian flavor"
1394 );
1395 assert!(
1396 rule.check_atx_heading_line("#tag2023", MarkdownFlavor::Obsidian)
1397 .is_none(),
1398 "#tag2023 should be skipped in Obsidian flavor"
1399 );
1400 assert!(
1401 rule.check_atx_heading_line("#project/sub/task", MarkdownFlavor::Obsidian)
1402 .is_none(),
1403 "#project/sub/task should be skipped in Obsidian flavor"
1404 );
1405 }
1406
1407 #[test]
1408 fn test_obsidian_tag_with_trailing_content() {
1409 let rule = MD018NoMissingSpaceAtx::new();
1411
1412 assert!(
1413 rule.check_atx_heading_line("#hey ", MarkdownFlavor::Obsidian).is_none(),
1414 "#hey followed by space should be skipped"
1415 );
1416 assert!(
1417 rule.check_atx_heading_line("#tag some text", MarkdownFlavor::Obsidian)
1418 .is_none(),
1419 "#tag followed by text should be skipped"
1420 );
1421 }
1422
1423 #[test]
1424 fn test_obsidian_tag_still_flags_multi_hash() {
1425 let rule = MD018NoMissingSpaceAtx::new();
1427
1428 assert!(
1429 rule.check_atx_heading_line("##tag", MarkdownFlavor::Obsidian).is_some(),
1430 "##tag should be flagged in Obsidian flavor (only single # is a tag)"
1431 );
1432 assert!(
1433 rule.check_atx_heading_line("###hello", MarkdownFlavor::Obsidian)
1434 .is_some(),
1435 "###hello should be flagged in Obsidian flavor"
1436 );
1437 }
1438
1439 #[test]
1440 fn test_obsidian_tag_numeric_still_flagged() {
1441 let rule = MD018NoMissingSpaceAtx::new();
1443
1444 assert!(
1445 rule.check_atx_heading_line("#123", MarkdownFlavor::Obsidian).is_some(),
1446 "#123 should be flagged in Obsidian flavor (tags cannot start with digit)"
1447 );
1448 assert!(
1449 rule.check_atx_heading_line("#10", MarkdownFlavor::Obsidian).is_some(),
1450 "#10 should be flagged in Obsidian flavor"
1451 );
1452 }
1453
1454 #[test]
1455 fn test_obsidian_flavor_full_check() {
1456 let rule = MD018NoMissingSpaceAtx::new();
1458
1459 let content = r#"# Real Heading
1460
1461#hey this is a tag
1462
1463#project/active also a tag
1464
1465##Introduction
1466
1467#123
1468"#;
1469
1470 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1472 let result = rule.check(&ctx).unwrap();
1473
1474 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1475 assert!(
1476 !flagged_lines.contains(&3),
1477 "#hey should NOT be flagged in Obsidian flavor"
1478 );
1479 assert!(
1480 !flagged_lines.contains(&5),
1481 "#project/active should NOT be flagged in Obsidian flavor"
1482 );
1483 assert!(
1484 flagged_lines.contains(&7),
1485 "##Introduction SHOULD be flagged in Obsidian flavor"
1486 );
1487 assert!(flagged_lines.contains(&9), "#123 SHOULD be flagged in Obsidian flavor");
1488 }
1489
1490 #[test]
1491 fn test_obsidian_flavor_fix_exact_output() {
1492 let rule = MD018NoMissingSpaceAtx::new();
1494
1495 let content = "#hey is a tag.\n\n##Introduction";
1498 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1499 let fixed = rule.fix(&ctx).unwrap();
1500
1501 let expected = "#hey is a tag.\n\n## Introduction";
1503 assert_eq!(
1504 fixed, expected,
1505 "Obsidian fix should preserve tags and fix multi-hash headings"
1506 );
1507 }
1508
1509 #[test]
1510 fn test_standard_flavor_flags_obsidian_tags() {
1511 let rule = MD018NoMissingSpaceAtx::new();
1513
1514 assert!(
1515 rule.check_atx_heading_line("#hey", MarkdownFlavor::Standard).is_some(),
1516 "#hey should be flagged in Standard flavor"
1517 );
1518 assert!(
1519 rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
1520 "#tag should be flagged in Standard flavor"
1521 );
1522 assert!(
1523 rule.check_atx_heading_line("#project/active", MarkdownFlavor::Standard)
1524 .is_some(),
1525 "#project/active should be flagged in Standard flavor"
1526 );
1527 }
1528
1529 #[test]
1530 fn test_obsidian_vs_standard_fix_comparison() {
1531 let rule = MD018NoMissingSpaceAtx::new();
1533
1534 let content = "#hey tag\n##Introduction";
1538
1539 let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1541 let fixed_obsidian = rule.fix(&ctx_obsidian).unwrap();
1542 assert_eq!(fixed_obsidian, "#hey tag\n## Introduction");
1543
1544 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1546 let fixed_standard = rule.fix(&ctx_standard).unwrap();
1547 assert_eq!(fixed_standard, "# hey tag\n## Introduction");
1548 }
1549
1550 #[test]
1551 fn test_obsidian_tag_edge_cases() {
1552 let rule = MD018NoMissingSpaceAtx::new();
1554
1555 let valid_tags = [
1557 "#a", "#tag", "#Tag", "#TAG", "#my-tag", "#my_tag", "#tag123", "#a1", "#日本語", "#über", ];
1568
1569 for tag in valid_tags {
1570 let result = rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian);
1572 let _ = result;
1575 }
1576
1577 let invalid_tags = ["#1tag", "#123", "#2023-project"];
1579
1580 for tag in invalid_tags {
1581 assert!(
1582 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_some(),
1583 "{tag:?} should be flagged in Obsidian flavor (starts with digit)"
1584 );
1585 }
1586 }
1587
1588 #[test]
1589 fn test_obsidian_tag_alone_on_line() {
1590 let rule = MD018NoMissingSpaceAtx::new();
1592
1593 let content = "Some text\n\n#todo\n\nMore text.";
1594 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1595 let result = rule.check(&ctx).unwrap();
1596
1597 assert!(
1599 result.is_empty(),
1600 "Standalone #todo should not be flagged in Obsidian flavor"
1601 );
1602
1603 let fixed = rule.fix(&ctx).unwrap();
1605 assert_eq!(fixed, content, "fix() should not modify standalone Obsidian tag");
1606 }
1607
1608 #[test]
1609 fn test_obsidian_deeply_nested_tags() {
1610 let rule = MD018NoMissingSpaceAtx::new();
1612
1613 let nested_tags = [
1614 "#a/b",
1615 "#a/b/c",
1616 "#project/2023/q1/task",
1617 "#work/meetings/weekly",
1618 "#life/health/exercise/running",
1619 ];
1620
1621 for tag in nested_tags {
1622 assert!(
1623 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1624 "{tag:?} should be skipped in Obsidian flavor (nested tag)"
1625 );
1626 }
1627 }
1628
1629 #[test]
1630 fn test_obsidian_unicode_tags() {
1631 let rule = MD018NoMissingSpaceAtx::new();
1633
1634 let unicode_tags = [
1635 "#日本語", "#中文", "#한국어", "#über", "#café", "#ñoño", "#Москва", "#αβγ", ];
1644
1645 for tag in unicode_tags {
1646 assert!(
1647 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1648 "{tag:?} should be skipped in Obsidian flavor (Unicode tag)"
1649 );
1650 }
1651 }
1652
1653 #[test]
1654 fn test_obsidian_tags_with_special_endings() {
1655 let rule = MD018NoMissingSpaceAtx::new();
1657
1658 assert!(
1660 rule.check_atx_heading_line("#tag followed by text", MarkdownFlavor::Obsidian)
1661 .is_none(),
1662 "#tag followed by text should be skipped"
1663 );
1664
1665 let content = "#todo";
1667 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1668 let result = rule.check(&ctx).unwrap();
1669 assert!(result.is_empty(), "#todo at end of line should be skipped");
1670 }
1671
1672 #[test]
1673 fn test_obsidian_combined_with_other_skip_contexts() {
1674 let rule = MD018NoMissingSpaceAtx::new();
1676
1677 let content = "```\n#todo\n```";
1679 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1680 let result = rule.check(&ctx).unwrap();
1681 assert!(result.is_empty(), "Tag in code block should be skipped");
1682
1683 let content = "<!-- #todo -->";
1685 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1686 let result = rule.check(&ctx).unwrap();
1687 assert!(result.is_empty(), "Tag in HTML comment should be skipped");
1688 }
1689
1690 #[test]
1691 fn test_obsidian_boundary_cases() {
1692 let rule = MD018NoMissingSpaceAtx::new();
1694
1695 assert!(
1699 rule.check_atx_heading_line("#ab", MarkdownFlavor::Obsidian).is_none(),
1700 "#ab should be skipped in Obsidian flavor"
1701 );
1702
1703 assert!(
1705 rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1706 .is_none(),
1707 "#my_tag should be skipped"
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("#MyTag", MarkdownFlavor::Obsidian)
1720 .is_none(),
1721 "#MyTag should be skipped"
1722 );
1723
1724 assert!(
1726 rule.check_atx_heading_line("#TODO", MarkdownFlavor::Obsidian).is_none(),
1727 "#TODO should be skipped in Obsidian flavor"
1728 );
1729 }
1730}