1mod md018_config;
5
6pub(super) use md018_config::MD018Config;
7
8use crate::config::MarkdownFlavor;
9use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
10use crate::utils::range_utils::{byte_to_char_count, calculate_single_line_range};
11use regex::Regex;
12use std::sync::LazyLock;
13
14const EMOJI_HASHTAG_PATTERN_STR: &str = r"^#️⃣|^#⃣";
16const UNICODE_HASHTAG_PATTERN_STR: &str = r"^#[\u{FE0F}\u{20E3}]";
17static EMOJI_HASHTAG_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(EMOJI_HASHTAG_PATTERN_STR).unwrap());
18static UNICODE_HASHTAG_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(UNICODE_HASHTAG_PATTERN_STR).unwrap());
19
20const MAGICLINK_REF_PATTERN_STR: &str = r"^#\d+(?:\s|[^a-zA-Z0-9]|$)";
24static MAGICLINK_REF_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(MAGICLINK_REF_PATTERN_STR).unwrap());
25
26const TAG_PATTERN_STR: &str = r"^#[^\d\s#][^\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 = byte_to_char_count(line, line_info.indent + heading.marker.len());
241 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
242 line_num + 1, hash_end_col,
244 0, );
246
247 warnings.push(LintWarning {
248 rule_name: Some(self.name().to_string()),
249 message: format!("No space after {} in heading", "#".repeat(heading.level as usize)),
250 line: start_line,
251 column: start_col,
252 end_line,
253 end_column: end_col,
254 severity: Severity::Warning,
255 fix: Some(Fix::new(self.get_line_byte_range(ctx.content, line_num + 1), {
256 let line = line_info.content(ctx.content);
258 let original_indent = &line[..line_info.indent];
259 format!("{original_indent}{} {after_marker}", heading.marker)
260 })),
261 });
262 }
263 }
264 }
265 } else if !line_info.in_code_block
266 && !line_info.in_front_matter
267 && !line_info.in_html_comment
268 && !line_info.in_mdx_comment
269 && !line_info.is_blank
270 {
271 if let Some((hash_end_pos, fixed_line)) =
273 self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor)
274 {
275 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
276 line_num + 1, hash_end_pos + 1, 0, );
280
281 warnings.push(LintWarning {
282 rule_name: Some(self.name().to_string()),
283 message: "No space after hash in heading".to_string(),
284 line: start_line,
285 column: start_col,
286 end_line,
287 end_column: end_col,
288 severity: Severity::Warning,
289 fix: Some(Fix::new(
290 self.get_line_byte_range(ctx.content, line_num + 1),
291 fixed_line,
292 )),
293 });
294 }
295 }
296 }
297
298 Ok(warnings)
299 }
300
301 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
302 let warnings = self.check(ctx)?;
303 let warnings =
304 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
305 let warning_lines: std::collections::HashSet<usize> = warnings.iter().map(|w| w.line).collect();
306
307 let mut lines = Vec::new();
308
309 for (idx, line_info) in ctx.lines.iter().enumerate() {
310 let mut fixed = false;
311
312 if !warning_lines.contains(&(idx + 1)) {
313 lines.push(line_info.content(ctx.content).to_string());
314 continue;
315 }
316
317 if let Some(heading) = &line_info.heading {
318 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
320 let line = line_info.content(ctx.content);
321 let trimmed = line.trim_start();
322
323 let is_emoji = EMOJI_HASHTAG_PATTERN.is_match(trimmed);
325 let is_unicode = UNICODE_HASHTAG_PATTERN.is_match(trimmed);
326
327 let is_magiclink = self.config.magiclink && heading.level == 1 && Self::is_magiclink_ref(line);
329
330 let is_tag = self.tags_enabled(ctx.flavor) && heading.level == 1 && Self::is_tag(line);
332
333 if !is_emoji && !is_unicode && !is_magiclink && !is_tag && trimmed.len() > heading.marker.len() {
335 let after_marker = &trimmed[heading.marker.len()..];
336 if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
337 {
338 let line = line_info.content(ctx.content);
340 let original_indent = &line[..line_info.indent];
341 lines.push(format!("{original_indent}{} {after_marker}", heading.marker));
342 fixed = true;
343 }
344 }
345 }
346 } else if !line_info.in_code_block
347 && !line_info.in_front_matter
348 && !line_info.in_html_comment
349 && !line_info.in_mdx_comment
350 && !line_info.is_blank
351 {
352 if let Some((_, fixed_line)) = self.check_atx_heading_line(line_info.content(ctx.content), ctx.flavor) {
354 lines.push(fixed_line);
355 fixed = true;
356 }
357 }
358
359 if !fixed {
360 lines.push(line_info.content(ctx.content).to_string());
361 }
362 }
363
364 let mut result = lines.join("\n");
366 if ctx.content.ends_with('\n') && !result.ends_with('\n') {
367 result.push('\n');
368 }
369
370 Ok(result)
371 }
372
373 fn category(&self) -> RuleCategory {
375 RuleCategory::Heading
376 }
377
378 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
380 !ctx.likely_has_headings()
382 }
383
384 fn as_any(&self) -> &dyn std::any::Any {
385 self
386 }
387
388 crate::impl_rule_config_methods!(MD018Config);
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use crate::lint_context::LintContext;
395
396 #[test]
397 fn test_basic_functionality() {
398 let rule = MD018NoMissingSpaceAtx::new();
399
400 let content = "# Heading 1\n## Heading 2\n### Heading 3";
402 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
403 let result = rule.check(&ctx).unwrap();
404 assert!(result.is_empty());
405
406 let content = "#Heading 1\n## Heading 2\n###Heading 3";
408 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
409 let result = rule.check(&ctx).unwrap();
410 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
412 assert_eq!(result[1].line, 3);
413 }
414
415 #[test]
416 fn test_malformed_heading_detection() {
417 let rule = MD018NoMissingSpaceAtx::new();
418
419 assert!(
421 rule.check_atx_heading_line("##Introduction", MarkdownFlavor::Standard)
422 .is_some()
423 );
424 assert!(
425 rule.check_atx_heading_line("###Background", MarkdownFlavor::Standard)
426 .is_some()
427 );
428 assert!(
429 rule.check_atx_heading_line("####Details", MarkdownFlavor::Standard)
430 .is_some()
431 );
432 assert!(
433 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
434 .is_some()
435 );
436 assert!(
437 rule.check_atx_heading_line("######Conclusion", MarkdownFlavor::Standard)
438 .is_some()
439 );
440 assert!(
441 rule.check_atx_heading_line("##Table of Contents", MarkdownFlavor::Standard)
442 .is_some()
443 );
444
445 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!(
450 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
451 .is_none()
452 ); assert!(
454 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
455 .is_none()
456 ); }
458
459 #[test]
460 fn test_malformed_heading_with_context() {
461 let rule = MD018NoMissingSpaceAtx::new();
462
463 let content = r#"# Test Document
465
466##Introduction
467This should be detected.
468
469 ##CodeBlock
470This should NOT be detected (indented code block).
471
472```
473##FencedCodeBlock
474This should NOT be detected (fenced code block).
475```
476
477##Conclusion
478This should be detected.
479"#;
480
481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
482 let result = rule.check(&ctx).unwrap();
483
484 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
486 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&14)); assert!(!detected_lines.contains(&6)); assert!(!detected_lines.contains(&10)); }
491
492 #[test]
493 fn test_malformed_heading_fix() {
494 let rule = MD018NoMissingSpaceAtx::new();
495
496 let content = r#"##Introduction
497This is a test.
498
499###Background
500More content."#;
501
502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
503 let fixed = rule.fix(&ctx).unwrap();
504
505 let expected = r#"## Introduction
506This is a test.
507
508### Background
509More content."#;
510
511 assert_eq!(fixed, expected);
512 }
513
514 #[test]
515 fn test_mixed_proper_and_malformed_headings() {
516 let rule = MD018NoMissingSpaceAtx::new();
517
518 let content = r#"# Proper Heading
519
520##Malformed Heading
521
522## Another Proper Heading
523
524###Another Malformed
525
526#### Proper with space
527"#;
528
529 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
530 let result = rule.check(&ctx).unwrap();
531
532 assert_eq!(result.len(), 2);
534 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
535 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&7)); }
538
539 #[test]
540 fn test_css_selectors_in_html_blocks() {
541 let rule = MD018NoMissingSpaceAtx::new();
542
543 let content = r#"# Proper Heading
546
547<style>
548#slide-1 ol li {
549 margin-top: 0;
550}
551
552#special-slide ol li {
553 margin-top: 2em;
554}
555</style>
556
557## Another Heading
558"#;
559
560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
561 let result = rule.check(&ctx).unwrap();
562
563 assert_eq!(
565 result.len(),
566 0,
567 "CSS selectors in <style> blocks should not be flagged as malformed headings"
568 );
569 }
570
571 #[test]
572 fn test_js_code_in_script_blocks() {
573 let rule = MD018NoMissingSpaceAtx::new();
574
575 let content = r#"# Heading
577
578<script>
579const element = document.querySelector('#main-content');
580#another-comment
581</script>
582
583## Another Heading
584"#;
585
586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587 let result = rule.check(&ctx).unwrap();
588
589 assert_eq!(
591 result.len(),
592 0,
593 "JavaScript code in <script> blocks should not be flagged as malformed headings"
594 );
595 }
596
597 #[test]
598 fn test_all_malformed_headings_detected() {
599 let rule = MD018NoMissingSpaceAtx::new();
600
601 assert!(
606 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
607 .is_some(),
608 "#hello SHOULD be detected as malformed heading"
609 );
610 assert!(
611 rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
612 "#tag SHOULD be detected as malformed heading"
613 );
614 assert!(
615 rule.check_atx_heading_line("#hashtag", MarkdownFlavor::Standard)
616 .is_some(),
617 "#hashtag SHOULD be detected as malformed heading"
618 );
619 assert!(
620 rule.check_atx_heading_line("#javascript", MarkdownFlavor::Standard)
621 .is_some(),
622 "#javascript SHOULD be detected as malformed heading"
623 );
624
625 assert!(
627 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
628 "#123 SHOULD be detected as malformed heading"
629 );
630 assert!(
631 rule.check_atx_heading_line("#12345", MarkdownFlavor::Standard)
632 .is_some(),
633 "#12345 SHOULD be detected as malformed heading"
634 );
635 assert!(
636 rule.check_atx_heading_line("#29039)", MarkdownFlavor::Standard)
637 .is_some(),
638 "#29039) SHOULD be detected as malformed heading"
639 );
640
641 assert!(
643 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
644 .is_some(),
645 "#Summary SHOULD be detected as malformed heading"
646 );
647 assert!(
648 rule.check_atx_heading_line("#Introduction", MarkdownFlavor::Standard)
649 .is_some(),
650 "#Introduction SHOULD be detected as malformed heading"
651 );
652 assert!(
653 rule.check_atx_heading_line("#API", MarkdownFlavor::Standard).is_some(),
654 "#API SHOULD be detected as malformed heading"
655 );
656
657 assert!(
659 rule.check_atx_heading_line("##introduction", MarkdownFlavor::Standard)
660 .is_some(),
661 "##introduction SHOULD be detected as malformed heading"
662 );
663 assert!(
664 rule.check_atx_heading_line("###section", MarkdownFlavor::Standard)
665 .is_some(),
666 "###section SHOULD be detected as malformed heading"
667 );
668 assert!(
669 rule.check_atx_heading_line("###fer", MarkdownFlavor::Standard)
670 .is_some(),
671 "###fer SHOULD be detected as malformed heading"
672 );
673 assert!(
674 rule.check_atx_heading_line("##123", MarkdownFlavor::Standard).is_some(),
675 "##123 SHOULD be detected as malformed heading"
676 );
677 }
678
679 #[test]
680 fn test_patterns_that_should_not_be_flagged() {
681 let rule = MD018NoMissingSpaceAtx::new();
682
683 assert!(rule.check_atx_heading_line("###", MarkdownFlavor::Standard).is_none());
685 assert!(rule.check_atx_heading_line("#", MarkdownFlavor::Standard).is_none());
686
687 assert!(rule.check_atx_heading_line("##a", MarkdownFlavor::Standard).is_none());
689
690 assert!(
692 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
693 .is_none()
694 );
695
696 assert!(
698 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
699 .is_none()
700 );
701
702 assert!(
704 rule.check_atx_heading_line("# Hello", MarkdownFlavor::Standard)
705 .is_none()
706 );
707 assert!(
708 rule.check_atx_heading_line("## World", MarkdownFlavor::Standard)
709 .is_none()
710 );
711 assert!(
712 rule.check_atx_heading_line("### Section", MarkdownFlavor::Standard)
713 .is_none()
714 );
715 }
716
717 #[test]
718 fn test_inline_issue_refs_not_at_line_start() {
719 let rule = MD018NoMissingSpaceAtx::new();
720
721 assert!(
726 rule.check_atx_heading_line("See issue #123", MarkdownFlavor::Standard)
727 .is_none()
728 );
729 assert!(
730 rule.check_atx_heading_line("Check #trending on Twitter", MarkdownFlavor::Standard)
731 .is_none()
732 );
733 assert!(
734 rule.check_atx_heading_line("- fix: issue #29039", MarkdownFlavor::Standard)
735 .is_none()
736 );
737 }
738
739 #[test]
740 fn test_lowercase_patterns_full_check() {
741 let rule = MD018NoMissingSpaceAtx::new();
743
744 let content = "#hello\n\n#world\n\n#tag";
745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
746 let result = rule.check(&ctx).unwrap();
747
748 assert_eq!(result.len(), 3, "All three lowercase patterns should be flagged");
749 assert_eq!(result[0].line, 1);
750 assert_eq!(result[1].line, 3);
751 assert_eq!(result[2].line, 5);
752 }
753
754 #[test]
755 fn test_numeric_patterns_full_check() {
756 let rule = MD018NoMissingSpaceAtx::new();
758
759 let content = "#123\n\n#456\n\n#29039";
760 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
761 let result = rule.check(&ctx).unwrap();
762
763 assert_eq!(result.len(), 3, "All three numeric patterns should be flagged");
764 }
765
766 #[test]
767 fn test_fix_lowercase_patterns() {
768 let rule = MD018NoMissingSpaceAtx::new();
770
771 let content = "#hello\nSome text.\n\n#world";
772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773 let fixed = rule.fix(&ctx).unwrap();
774
775 let expected = "# hello\nSome text.\n\n# world";
776 assert_eq!(fixed, expected);
777 }
778
779 #[test]
780 fn test_fix_numeric_patterns() {
781 let rule = MD018NoMissingSpaceAtx::new();
783
784 let content = "#123\nContent.\n\n##456";
785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786 let fixed = rule.fix(&ctx).unwrap();
787
788 let expected = "# 123\nContent.\n\n## 456";
789 assert_eq!(fixed, expected);
790 }
791
792 #[test]
793 fn test_indented_malformed_headings() {
794 let rule = MD018NoMissingSpaceAtx::new();
798
799 assert!(
801 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
802 .is_none(),
803 "1-space indented #hello should be skipped"
804 );
805 assert!(
806 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
807 .is_none(),
808 "2-space indented #hello should be skipped"
809 );
810 assert!(
811 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
812 .is_none(),
813 "3-space indented #hello should be skipped"
814 );
815
816 assert!(
821 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
822 .is_some(),
823 "Non-indented #hello should be detected"
824 );
825 }
826
827 #[test]
828 fn test_tab_after_hash_is_valid() {
829 let rule = MD018NoMissingSpaceAtx::new();
831
832 assert!(
833 rule.check_atx_heading_line("#\tHello", MarkdownFlavor::Standard)
834 .is_none(),
835 "Tab after # should be valid"
836 );
837 assert!(
838 rule.check_atx_heading_line("##\tWorld", MarkdownFlavor::Standard)
839 .is_none(),
840 "Tab after ## should be valid"
841 );
842 }
843
844 #[test]
845 fn test_mixed_case_patterns() {
846 let rule = MD018NoMissingSpaceAtx::new();
847
848 assert!(
850 rule.check_atx_heading_line("#hELLO", MarkdownFlavor::Standard)
851 .is_some()
852 );
853 assert!(
854 rule.check_atx_heading_line("#Hello", MarkdownFlavor::Standard)
855 .is_some()
856 );
857 assert!(
858 rule.check_atx_heading_line("#HELLO", MarkdownFlavor::Standard)
859 .is_some()
860 );
861 assert!(
862 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
863 .is_some()
864 );
865 }
866
867 #[test]
868 fn test_unicode_lowercase() {
869 let rule = MD018NoMissingSpaceAtx::new();
870
871 assert!(
873 rule.check_atx_heading_line("#über", MarkdownFlavor::Standard).is_some(),
874 "Unicode lowercase #über should be detected"
875 );
876 assert!(
877 rule.check_atx_heading_line("#café", MarkdownFlavor::Standard).is_some(),
878 "Unicode lowercase #café should be detected"
879 );
880 assert!(
881 rule.check_atx_heading_line("#日本語", MarkdownFlavor::Standard)
882 .is_some(),
883 "Japanese #日本語 should be detected"
884 );
885 }
886
887 #[test]
888 fn test_matches_markdownlint_behavior() {
889 let rule = MD018NoMissingSpaceAtx::new();
891
892 let content = r#"#hello
893
894## world
895
896###fer
897
898#123
899
900#Tag
901"#;
902
903 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
904 let result = rule.check(&ctx).unwrap();
905
906 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
909
910 assert!(flagged_lines.contains(&1), "#hello should be flagged");
911 assert!(!flagged_lines.contains(&3), "## world should NOT be flagged");
912 assert!(flagged_lines.contains(&5), "###fer should be flagged");
913 assert!(flagged_lines.contains(&7), "#123 should be flagged");
914 assert!(flagged_lines.contains(&9), "#Tag should be flagged");
915
916 assert_eq!(result.len(), 4, "Should have exactly 4 warnings");
917 }
918
919 #[test]
920 fn test_skip_frontmatter_yaml_comments() {
921 let rule = MD018NoMissingSpaceAtx::new();
923
924 let content = r#"---
925#reviewers:
926#- sig-api-machinery
927#another_comment: value
928title: Test Document
929---
930
931# Valid heading
932
933#invalid heading without space
934"#;
935
936 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
937 let result = rule.check(&ctx).unwrap();
938
939 assert_eq!(
942 result.len(),
943 1,
944 "Should only flag the malformed heading outside frontmatter"
945 );
946 assert_eq!(result[0].line, 10, "Should flag line 10");
947 }
948
949 #[test]
950 fn test_skip_html_comments() {
951 let rule = MD018NoMissingSpaceAtx::new();
954
955 let content = r#"# Real Heading
956
957Some text.
958
959<!--
960```
961#%% Cell marker
962import matplotlib.pyplot as plt
963
964#%% Another cell
965data = [1, 2, 3]
966```
967-->
968
969More content.
970"#;
971
972 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
973 let result = rule.check(&ctx).unwrap();
974
975 assert!(
977 result.is_empty(),
978 "Should not flag content inside HTML comments, found {} issues",
979 result.len()
980 );
981 }
982
983 #[test]
984 fn test_mkdocs_magiclink_skips_numeric_refs() {
985 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
987 magiclink: true,
988 ..Default::default()
989 });
990
991 assert!(
993 rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_none(),
994 "#10 should be skipped with magiclink config (MagicLink issue ref)"
995 );
996 assert!(
997 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_none(),
998 "#123 should be skipped with magiclink config (MagicLink issue ref)"
999 );
1000 assert!(
1001 rule.check_atx_heading_line("#10 discusses the issue", MarkdownFlavor::Standard)
1002 .is_none(),
1003 "#10 followed by text should be skipped with magiclink config"
1004 );
1005 assert!(
1006 rule.check_atx_heading_line("#37.", MarkdownFlavor::Standard).is_none(),
1007 "#37 followed by punctuation should be skipped with magiclink config"
1008 );
1009 }
1010
1011 #[test]
1012 fn test_mkdocs_magiclink_still_flags_non_numeric() {
1013 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1015 magiclink: true,
1016 ..Default::default()
1017 });
1018
1019 assert!(
1021 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
1022 .is_some(),
1023 "#Summary should still be flagged with magiclink config"
1024 );
1025 assert!(
1026 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
1027 .is_some(),
1028 "#hello should still be flagged with magiclink config"
1029 );
1030 assert!(
1031 rule.check_atx_heading_line("#10abc", MarkdownFlavor::Standard)
1032 .is_some(),
1033 "#10abc (mixed) should still be flagged with magiclink config"
1034 );
1035 }
1036
1037 #[test]
1038 fn test_mkdocs_magiclink_only_single_hash() {
1039 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1041 magiclink: true,
1042 ..Default::default()
1043 });
1044
1045 assert!(
1046 rule.check_atx_heading_line("##10", MarkdownFlavor::Standard).is_some(),
1047 "##10 should be flagged with magiclink config (only single # is MagicLink)"
1048 );
1049 assert!(
1050 rule.check_atx_heading_line("###123", MarkdownFlavor::Standard)
1051 .is_some(),
1052 "###123 should be flagged with magiclink config"
1053 );
1054 }
1055
1056 #[test]
1057 fn test_standard_flavor_flags_numeric_refs() {
1058 let rule = MD018NoMissingSpaceAtx::new();
1060
1061 assert!(
1062 rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_some(),
1063 "#10 should be flagged in Standard flavor"
1064 );
1065 assert!(
1066 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
1067 "#123 should be flagged in Standard flavor"
1068 );
1069 }
1070
1071 #[test]
1072 fn test_mkdocs_magiclink_full_check() {
1073 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1075 magiclink: true,
1076 ..Default::default()
1077 });
1078
1079 let content = r#"# PRs that are helpful for context
1080
1081#10 discusses the philosophy behind the project, and #37 shows a good example.
1082
1083#Summary
1084
1085##Introduction
1086"#;
1087
1088 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1090 let result = rule.check(&ctx).unwrap();
1091
1092 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1093 assert!(
1094 !flagged_lines.contains(&3),
1095 "#10 should NOT be flagged with magiclink config"
1096 );
1097 assert!(
1098 flagged_lines.contains(&5),
1099 "#Summary SHOULD be flagged with magiclink config"
1100 );
1101 assert!(
1102 flagged_lines.contains(&7),
1103 "##Introduction SHOULD be flagged with magiclink config"
1104 );
1105 }
1106
1107 #[test]
1108 fn test_mkdocs_magiclink_fix_exact_output() {
1109 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1111 magiclink: true,
1112 ..Default::default()
1113 });
1114
1115 let content = "#10 discusses the issue.\n\n#Summary";
1116 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1117 let fixed = rule.fix(&ctx).unwrap();
1118
1119 let expected = "#10 discusses the issue.\n\n# Summary";
1121 assert_eq!(
1122 fixed, expected,
1123 "magiclink config fix should preserve MagicLink refs and fix non-numeric headings"
1124 );
1125 }
1126
1127 #[test]
1128 fn test_mkdocs_magiclink_edge_cases() {
1129 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1131 magiclink: true,
1132 ..Default::default()
1133 });
1134
1135 let valid_refs = [
1138 "#10", "#999999", "#10 text after", "#10\ttext after", "#10.", "#10,", "#10!", "#10?", "#10)", "#10]", "#10;", "#10:", ];
1151
1152 for ref_str in valid_refs {
1153 assert!(
1154 rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_none(),
1155 "{ref_str:?} should be skipped as MagicLink ref with magiclink config"
1156 );
1157 }
1158
1159 let invalid_refs = [
1161 "#10abc", "#10a", "#abc10", "#10ABC", "#Summary", "#hello", ];
1168
1169 for ref_str in invalid_refs {
1170 assert!(
1171 rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_some(),
1172 "{ref_str:?} should be flagged with magiclink config (not a valid MagicLink ref)"
1173 );
1174 }
1175 }
1176
1177 #[test]
1178 fn test_mkdocs_magiclink_hyphenated_continuation() {
1179 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1182 magiclink: true,
1183 ..Default::default()
1184 });
1185
1186 assert!(
1191 rule.check_atx_heading_line("#10-", MarkdownFlavor::Standard).is_none(),
1192 "#10- should be skipped with magiclink config (hyphen is non-alphanumeric terminator)"
1193 );
1194 }
1195
1196 #[test]
1197 fn test_mkdocs_magiclink_standalone_number() {
1198 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1200 magiclink: true,
1201 ..Default::default()
1202 });
1203
1204 let content = "See issue:\n\n#10\n\nFor details.";
1205 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1206 let result = rule.check(&ctx).unwrap();
1207
1208 assert!(
1210 result.is_empty(),
1211 "Standalone #10 should not be flagged with magiclink config"
1212 );
1213
1214 let fixed = rule.fix(&ctx).unwrap();
1216 assert_eq!(fixed, content, "fix() should not modify standalone MagicLink ref");
1217 }
1218
1219 #[test]
1220 fn test_standard_flavor_flags_all_numeric() {
1221 let rule = MD018NoMissingSpaceAtx::new();
1224
1225 let numeric_patterns = ["#10", "#123", "#999999", "#10 text"];
1226
1227 for pattern in numeric_patterns {
1228 assert!(
1229 rule.check_atx_heading_line(pattern, MarkdownFlavor::Standard).is_some(),
1230 "{pattern:?} should be flagged in Standard flavor"
1231 );
1232 }
1233
1234 assert!(
1236 rule.check_atx_heading_line("#1", MarkdownFlavor::Standard).is_none(),
1237 "#1 should be skipped (content too short, existing behavior)"
1238 );
1239 }
1240
1241 #[test]
1242 fn test_mkdocs_vs_standard_fix_comparison() {
1243 let content = "#10 is an issue\n#Summary";
1245 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1246
1247 let rule_magiclink = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1249 magiclink: true,
1250 ..Default::default()
1251 });
1252 let fixed_magiclink = rule_magiclink.fix(&ctx).unwrap();
1253 assert_eq!(fixed_magiclink, "#10 is an issue\n# Summary");
1254
1255 let rule_default = MD018NoMissingSpaceAtx::new();
1257 let fixed_default = rule_default.fix(&ctx).unwrap();
1258 assert_eq!(fixed_default, "# 10 is an issue\n# Summary");
1259 }
1260
1261 #[test]
1264 fn test_tags_config_standard_flavor() {
1265 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1267 magiclink: false,
1268 tags: Some(true),
1269 });
1270
1271 let content = "#tag\n\n#project/active\n\n##Introduction";
1272 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1273 let result = rule.check(&ctx).unwrap();
1274
1275 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1276 assert!(!flagged_lines.contains(&1), "#tag should be skipped with tags = true");
1277 assert!(
1278 !flagged_lines.contains(&3),
1279 "#project/active should be skipped with tags = true"
1280 );
1281 assert!(flagged_lines.contains(&5), "##Introduction should still be flagged");
1282 }
1283
1284 #[test]
1285 fn test_tags_config_fix_standard_flavor() {
1286 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1287 magiclink: false,
1288 tags: Some(true),
1289 });
1290
1291 let content = "#tag\n\n##Introduction";
1292 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1293 let fixed = rule.fix(&ctx).unwrap();
1294 assert_eq!(fixed, "#tag\n\n## Introduction");
1295 }
1296
1297 #[test]
1298 fn test_tags_config_disabled_obsidian_flavor() {
1299 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1301 magiclink: false,
1302 tags: Some(false),
1303 });
1304
1305 let content = "#tag\n\n#project/active";
1306 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1307 let result = rule.check(&ctx).unwrap();
1308
1309 assert_eq!(
1310 result.len(),
1311 2,
1312 "tags = false should flag tag patterns even in Obsidian"
1313 );
1314 }
1315
1316 #[test]
1317 fn test_tags_config_default_follows_flavor() {
1318 let rule = MD018NoMissingSpaceAtx::new(); let content = "#tag";
1323 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1324 let result = rule.check(&ctx).unwrap();
1325 assert!(!result.is_empty(), "Default standard should flag #tag");
1326
1327 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1329 let result = rule.check(&ctx).unwrap();
1330 assert!(result.is_empty(), "Default Obsidian should skip #tag");
1331 }
1332
1333 #[test]
1336 fn test_obsidian_tag_skips_simple_tags() {
1337 let rule = MD018NoMissingSpaceAtx::new();
1339
1340 assert!(
1342 rule.check_atx_heading_line("#hey", MarkdownFlavor::Obsidian).is_none(),
1343 "#hey should be skipped in Obsidian flavor (tag syntax)"
1344 );
1345 assert!(
1346 rule.check_atx_heading_line("#tag", MarkdownFlavor::Obsidian).is_none(),
1347 "#tag should be skipped in Obsidian flavor"
1348 );
1349 assert!(
1350 rule.check_atx_heading_line("#hello", MarkdownFlavor::Obsidian)
1351 .is_none(),
1352 "#hello should be skipped in Obsidian flavor"
1353 );
1354 assert!(
1355 rule.check_atx_heading_line("#myTag", MarkdownFlavor::Obsidian)
1356 .is_none(),
1357 "#myTag should be skipped in Obsidian flavor"
1358 );
1359 }
1360
1361 #[test]
1362 fn test_obsidian_tag_skips_complex_tags() {
1363 let rule = MD018NoMissingSpaceAtx::new();
1365
1366 assert!(
1368 rule.check_atx_heading_line("#project/active", MarkdownFlavor::Obsidian)
1369 .is_none(),
1370 "#project/active should be skipped in Obsidian flavor (nested tag)"
1371 );
1372 assert!(
1373 rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1374 .is_none(),
1375 "#my-tag should be skipped in Obsidian flavor"
1376 );
1377 assert!(
1378 rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1379 .is_none(),
1380 "#my_tag should be skipped in Obsidian flavor"
1381 );
1382 assert!(
1383 rule.check_atx_heading_line("#tag2023", MarkdownFlavor::Obsidian)
1384 .is_none(),
1385 "#tag2023 should be skipped in Obsidian flavor"
1386 );
1387 assert!(
1388 rule.check_atx_heading_line("#project/sub/task", MarkdownFlavor::Obsidian)
1389 .is_none(),
1390 "#project/sub/task should be skipped in Obsidian flavor"
1391 );
1392 }
1393
1394 #[test]
1395 fn test_obsidian_tag_with_trailing_content() {
1396 let rule = MD018NoMissingSpaceAtx::new();
1398
1399 assert!(
1400 rule.check_atx_heading_line("#hey ", MarkdownFlavor::Obsidian).is_none(),
1401 "#hey followed by space should be skipped"
1402 );
1403 assert!(
1404 rule.check_atx_heading_line("#tag some text", MarkdownFlavor::Obsidian)
1405 .is_none(),
1406 "#tag followed by text should be skipped"
1407 );
1408 }
1409
1410 #[test]
1411 fn test_obsidian_tag_still_flags_multi_hash() {
1412 let rule = MD018NoMissingSpaceAtx::new();
1414
1415 assert!(
1416 rule.check_atx_heading_line("##tag", MarkdownFlavor::Obsidian).is_some(),
1417 "##tag should be flagged in Obsidian flavor (only single # is a tag)"
1418 );
1419 assert!(
1420 rule.check_atx_heading_line("###hello", MarkdownFlavor::Obsidian)
1421 .is_some(),
1422 "###hello should be flagged in Obsidian flavor"
1423 );
1424 }
1425
1426 #[test]
1427 fn test_obsidian_tag_numeric_still_flagged() {
1428 let rule = MD018NoMissingSpaceAtx::new();
1430
1431 assert!(
1432 rule.check_atx_heading_line("#123", MarkdownFlavor::Obsidian).is_some(),
1433 "#123 should be flagged in Obsidian flavor (tags cannot start with digit)"
1434 );
1435 assert!(
1436 rule.check_atx_heading_line("#10", MarkdownFlavor::Obsidian).is_some(),
1437 "#10 should be flagged in Obsidian flavor"
1438 );
1439 }
1440
1441 #[test]
1442 fn test_obsidian_flavor_full_check() {
1443 let rule = MD018NoMissingSpaceAtx::new();
1445
1446 let content = r#"# Real Heading
1447
1448#hey this is a tag
1449
1450#project/active also a tag
1451
1452##Introduction
1453
1454#123
1455"#;
1456
1457 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1459 let result = rule.check(&ctx).unwrap();
1460
1461 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1462 assert!(
1463 !flagged_lines.contains(&3),
1464 "#hey should NOT be flagged in Obsidian flavor"
1465 );
1466 assert!(
1467 !flagged_lines.contains(&5),
1468 "#project/active should NOT be flagged in Obsidian flavor"
1469 );
1470 assert!(
1471 flagged_lines.contains(&7),
1472 "##Introduction SHOULD be flagged in Obsidian flavor"
1473 );
1474 assert!(flagged_lines.contains(&9), "#123 SHOULD be flagged in Obsidian flavor");
1475 }
1476
1477 #[test]
1478 fn test_obsidian_flavor_fix_exact_output() {
1479 let rule = MD018NoMissingSpaceAtx::new();
1481
1482 let content = "#hey is a tag.\n\n##Introduction";
1485 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1486 let fixed = rule.fix(&ctx).unwrap();
1487
1488 let expected = "#hey is a tag.\n\n## Introduction";
1490 assert_eq!(
1491 fixed, expected,
1492 "Obsidian fix should preserve tags and fix multi-hash headings"
1493 );
1494 }
1495
1496 #[test]
1497 fn test_standard_flavor_flags_obsidian_tags() {
1498 let rule = MD018NoMissingSpaceAtx::new();
1500
1501 assert!(
1502 rule.check_atx_heading_line("#hey", MarkdownFlavor::Standard).is_some(),
1503 "#hey should be flagged in Standard flavor"
1504 );
1505 assert!(
1506 rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
1507 "#tag should be flagged in Standard flavor"
1508 );
1509 assert!(
1510 rule.check_atx_heading_line("#project/active", MarkdownFlavor::Standard)
1511 .is_some(),
1512 "#project/active should be flagged in Standard flavor"
1513 );
1514 }
1515
1516 #[test]
1517 fn test_obsidian_vs_standard_fix_comparison() {
1518 let rule = MD018NoMissingSpaceAtx::new();
1520
1521 let content = "#hey tag\n##Introduction";
1525
1526 let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1528 let fixed_obsidian = rule.fix(&ctx_obsidian).unwrap();
1529 assert_eq!(fixed_obsidian, "#hey tag\n## Introduction");
1530
1531 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1533 let fixed_standard = rule.fix(&ctx_standard).unwrap();
1534 assert_eq!(fixed_standard, "# hey tag\n## Introduction");
1535 }
1536
1537 #[test]
1538 fn test_obsidian_tag_edge_cases() {
1539 let rule = MD018NoMissingSpaceAtx::new();
1541
1542 let valid_tags = [
1544 "#a", "#tag", "#Tag", "#TAG", "#my-tag", "#my_tag", "#tag123", "#a1", "#日本語", "#über", ];
1555
1556 for tag in valid_tags {
1557 let result = rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian);
1559 let _ = result;
1562 }
1563
1564 let invalid_tags = ["#1tag", "#123", "#2023-project"];
1566
1567 for tag in invalid_tags {
1568 assert!(
1569 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_some(),
1570 "{tag:?} should be flagged in Obsidian flavor (starts with digit)"
1571 );
1572 }
1573 }
1574
1575 #[test]
1576 fn test_obsidian_tag_alone_on_line() {
1577 let rule = MD018NoMissingSpaceAtx::new();
1579
1580 let content = "Some text\n\n#todo\n\nMore text.";
1581 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1582 let result = rule.check(&ctx).unwrap();
1583
1584 assert!(
1586 result.is_empty(),
1587 "Standalone #todo should not be flagged in Obsidian flavor"
1588 );
1589
1590 let fixed = rule.fix(&ctx).unwrap();
1592 assert_eq!(fixed, content, "fix() should not modify standalone Obsidian tag");
1593 }
1594
1595 #[test]
1596 fn test_obsidian_deeply_nested_tags() {
1597 let rule = MD018NoMissingSpaceAtx::new();
1599
1600 let nested_tags = [
1601 "#a/b",
1602 "#a/b/c",
1603 "#project/2023/q1/task",
1604 "#work/meetings/weekly",
1605 "#life/health/exercise/running",
1606 ];
1607
1608 for tag in nested_tags {
1609 assert!(
1610 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1611 "{tag:?} should be skipped in Obsidian flavor (nested tag)"
1612 );
1613 }
1614 }
1615
1616 #[test]
1617 fn test_obsidian_unicode_tags() {
1618 let rule = MD018NoMissingSpaceAtx::new();
1620
1621 let unicode_tags = [
1622 "#日本語", "#中文", "#한국어", "#über", "#café", "#ñoño", "#Москва", "#αβγ", ];
1631
1632 for tag in unicode_tags {
1633 assert!(
1634 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1635 "{tag:?} should be skipped in Obsidian flavor (Unicode tag)"
1636 );
1637 }
1638 }
1639
1640 #[test]
1641 fn test_obsidian_tags_with_special_endings() {
1642 let rule = MD018NoMissingSpaceAtx::new();
1644
1645 assert!(
1647 rule.check_atx_heading_line("#tag followed by text", MarkdownFlavor::Obsidian)
1648 .is_none(),
1649 "#tag followed by text should be skipped"
1650 );
1651
1652 let content = "#todo";
1654 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1655 let result = rule.check(&ctx).unwrap();
1656 assert!(result.is_empty(), "#todo at end of line should be skipped");
1657 }
1658
1659 #[test]
1660 fn test_obsidian_combined_with_other_skip_contexts() {
1661 let rule = MD018NoMissingSpaceAtx::new();
1663
1664 let content = "```\n#todo\n```";
1666 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1667 let result = rule.check(&ctx).unwrap();
1668 assert!(result.is_empty(), "Tag in code block should be skipped");
1669
1670 let content = "<!-- #todo -->";
1672 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1673 let result = rule.check(&ctx).unwrap();
1674 assert!(result.is_empty(), "Tag in HTML comment should be skipped");
1675 }
1676
1677 #[test]
1678 fn test_obsidian_boundary_cases() {
1679 let rule = MD018NoMissingSpaceAtx::new();
1681
1682 assert!(
1686 rule.check_atx_heading_line("#ab", MarkdownFlavor::Obsidian).is_none(),
1687 "#ab should be skipped in Obsidian flavor"
1688 );
1689
1690 assert!(
1692 rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1693 .is_none(),
1694 "#my_tag should be skipped"
1695 );
1696
1697 assert!(
1699 rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1700 .is_none(),
1701 "#my-tag should be skipped"
1702 );
1703
1704 assert!(
1706 rule.check_atx_heading_line("#MyTag", MarkdownFlavor::Obsidian)
1707 .is_none(),
1708 "#MyTag should be skipped"
1709 );
1710
1711 assert!(
1713 rule.check_atx_heading_line("#TODO", MarkdownFlavor::Obsidian).is_none(),
1714 "#TODO should be skipped in Obsidian flavor"
1715 );
1716 }
1717}