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 crate::impl_rule_config_methods!(MD018Config);
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use crate::lint_context::LintContext;
394
395 #[test]
396 fn test_basic_functionality() {
397 let rule = MD018NoMissingSpaceAtx::new();
398
399 let content = "# Heading 1\n## Heading 2\n### Heading 3";
401 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
402 let result = rule.check(&ctx).unwrap();
403 assert!(result.is_empty());
404
405 let content = "#Heading 1\n## Heading 2\n###Heading 3";
407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
408 let result = rule.check(&ctx).unwrap();
409 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
411 assert_eq!(result[1].line, 3);
412 }
413
414 #[test]
415 fn test_malformed_heading_detection() {
416 let rule = MD018NoMissingSpaceAtx::new();
417
418 assert!(
420 rule.check_atx_heading_line("##Introduction", MarkdownFlavor::Standard)
421 .is_some()
422 );
423 assert!(
424 rule.check_atx_heading_line("###Background", MarkdownFlavor::Standard)
425 .is_some()
426 );
427 assert!(
428 rule.check_atx_heading_line("####Details", MarkdownFlavor::Standard)
429 .is_some()
430 );
431 assert!(
432 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
433 .is_some()
434 );
435 assert!(
436 rule.check_atx_heading_line("######Conclusion", MarkdownFlavor::Standard)
437 .is_some()
438 );
439 assert!(
440 rule.check_atx_heading_line("##Table of Contents", MarkdownFlavor::Standard)
441 .is_some()
442 );
443
444 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!(
449 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
450 .is_none()
451 ); assert!(
453 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
454 .is_none()
455 ); }
457
458 #[test]
459 fn test_malformed_heading_with_context() {
460 let rule = MD018NoMissingSpaceAtx::new();
461
462 let content = r#"# Test Document
464
465##Introduction
466This should be detected.
467
468 ##CodeBlock
469This should NOT be detected (indented code block).
470
471```
472##FencedCodeBlock
473This should NOT be detected (fenced code block).
474```
475
476##Conclusion
477This should be detected.
478"#;
479
480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
481 let result = rule.check(&ctx).unwrap();
482
483 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
485 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&14)); assert!(!detected_lines.contains(&6)); assert!(!detected_lines.contains(&10)); }
490
491 #[test]
492 fn test_malformed_heading_fix() {
493 let rule = MD018NoMissingSpaceAtx::new();
494
495 let content = r#"##Introduction
496This is a test.
497
498###Background
499More content."#;
500
501 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
502 let fixed = rule.fix(&ctx).unwrap();
503
504 let expected = r#"## Introduction
505This is a test.
506
507### Background
508More content."#;
509
510 assert_eq!(fixed, expected);
511 }
512
513 #[test]
514 fn test_mixed_proper_and_malformed_headings() {
515 let rule = MD018NoMissingSpaceAtx::new();
516
517 let content = r#"# Proper Heading
518
519##Malformed Heading
520
521## Another Proper Heading
522
523###Another Malformed
524
525#### Proper with space
526"#;
527
528 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
529 let result = rule.check(&ctx).unwrap();
530
531 assert_eq!(result.len(), 2);
533 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
534 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&7)); }
537
538 #[test]
539 fn test_css_selectors_in_html_blocks() {
540 let rule = MD018NoMissingSpaceAtx::new();
541
542 let content = r#"# Proper Heading
545
546<style>
547#slide-1 ol li {
548 margin-top: 0;
549}
550
551#special-slide ol li {
552 margin-top: 2em;
553}
554</style>
555
556## Another Heading
557"#;
558
559 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
560 let result = rule.check(&ctx).unwrap();
561
562 assert_eq!(
564 result.len(),
565 0,
566 "CSS selectors in <style> blocks should not be flagged as malformed headings"
567 );
568 }
569
570 #[test]
571 fn test_js_code_in_script_blocks() {
572 let rule = MD018NoMissingSpaceAtx::new();
573
574 let content = r#"# Heading
576
577<script>
578const element = document.querySelector('#main-content');
579#another-comment
580</script>
581
582## Another Heading
583"#;
584
585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
586 let result = rule.check(&ctx).unwrap();
587
588 assert_eq!(
590 result.len(),
591 0,
592 "JavaScript code in <script> blocks should not be flagged as malformed headings"
593 );
594 }
595
596 #[test]
597 fn test_all_malformed_headings_detected() {
598 let rule = MD018NoMissingSpaceAtx::new();
599
600 assert!(
605 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
606 .is_some(),
607 "#hello SHOULD be detected as malformed heading"
608 );
609 assert!(
610 rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
611 "#tag SHOULD be detected as malformed heading"
612 );
613 assert!(
614 rule.check_atx_heading_line("#hashtag", MarkdownFlavor::Standard)
615 .is_some(),
616 "#hashtag SHOULD be detected as malformed heading"
617 );
618 assert!(
619 rule.check_atx_heading_line("#javascript", MarkdownFlavor::Standard)
620 .is_some(),
621 "#javascript SHOULD be detected as malformed heading"
622 );
623
624 assert!(
626 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
627 "#123 SHOULD be detected as malformed heading"
628 );
629 assert!(
630 rule.check_atx_heading_line("#12345", MarkdownFlavor::Standard)
631 .is_some(),
632 "#12345 SHOULD be detected as malformed heading"
633 );
634 assert!(
635 rule.check_atx_heading_line("#29039)", MarkdownFlavor::Standard)
636 .is_some(),
637 "#29039) SHOULD be detected as malformed heading"
638 );
639
640 assert!(
642 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
643 .is_some(),
644 "#Summary SHOULD be detected as malformed heading"
645 );
646 assert!(
647 rule.check_atx_heading_line("#Introduction", MarkdownFlavor::Standard)
648 .is_some(),
649 "#Introduction SHOULD be detected as malformed heading"
650 );
651 assert!(
652 rule.check_atx_heading_line("#API", MarkdownFlavor::Standard).is_some(),
653 "#API SHOULD be detected as malformed heading"
654 );
655
656 assert!(
658 rule.check_atx_heading_line("##introduction", MarkdownFlavor::Standard)
659 .is_some(),
660 "##introduction SHOULD be detected as malformed heading"
661 );
662 assert!(
663 rule.check_atx_heading_line("###section", MarkdownFlavor::Standard)
664 .is_some(),
665 "###section SHOULD be detected as malformed heading"
666 );
667 assert!(
668 rule.check_atx_heading_line("###fer", MarkdownFlavor::Standard)
669 .is_some(),
670 "###fer SHOULD be detected as malformed heading"
671 );
672 assert!(
673 rule.check_atx_heading_line("##123", MarkdownFlavor::Standard).is_some(),
674 "##123 SHOULD be detected as malformed heading"
675 );
676 }
677
678 #[test]
679 fn test_patterns_that_should_not_be_flagged() {
680 let rule = MD018NoMissingSpaceAtx::new();
681
682 assert!(rule.check_atx_heading_line("###", MarkdownFlavor::Standard).is_none());
684 assert!(rule.check_atx_heading_line("#", MarkdownFlavor::Standard).is_none());
685
686 assert!(rule.check_atx_heading_line("##a", MarkdownFlavor::Standard).is_none());
688
689 assert!(
691 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
692 .is_none()
693 );
694
695 assert!(
697 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
698 .is_none()
699 );
700
701 assert!(
703 rule.check_atx_heading_line("# Hello", MarkdownFlavor::Standard)
704 .is_none()
705 );
706 assert!(
707 rule.check_atx_heading_line("## World", MarkdownFlavor::Standard)
708 .is_none()
709 );
710 assert!(
711 rule.check_atx_heading_line("### Section", MarkdownFlavor::Standard)
712 .is_none()
713 );
714 }
715
716 #[test]
717 fn test_inline_issue_refs_not_at_line_start() {
718 let rule = MD018NoMissingSpaceAtx::new();
719
720 assert!(
725 rule.check_atx_heading_line("See issue #123", MarkdownFlavor::Standard)
726 .is_none()
727 );
728 assert!(
729 rule.check_atx_heading_line("Check #trending on Twitter", MarkdownFlavor::Standard)
730 .is_none()
731 );
732 assert!(
733 rule.check_atx_heading_line("- fix: issue #29039", MarkdownFlavor::Standard)
734 .is_none()
735 );
736 }
737
738 #[test]
739 fn test_lowercase_patterns_full_check() {
740 let rule = MD018NoMissingSpaceAtx::new();
742
743 let content = "#hello\n\n#world\n\n#tag";
744 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
745 let result = rule.check(&ctx).unwrap();
746
747 assert_eq!(result.len(), 3, "All three lowercase patterns should be flagged");
748 assert_eq!(result[0].line, 1);
749 assert_eq!(result[1].line, 3);
750 assert_eq!(result[2].line, 5);
751 }
752
753 #[test]
754 fn test_numeric_patterns_full_check() {
755 let rule = MD018NoMissingSpaceAtx::new();
757
758 let content = "#123\n\n#456\n\n#29039";
759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
760 let result = rule.check(&ctx).unwrap();
761
762 assert_eq!(result.len(), 3, "All three numeric patterns should be flagged");
763 }
764
765 #[test]
766 fn test_fix_lowercase_patterns() {
767 let rule = MD018NoMissingSpaceAtx::new();
769
770 let content = "#hello\nSome text.\n\n#world";
771 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
772 let fixed = rule.fix(&ctx).unwrap();
773
774 let expected = "# hello\nSome text.\n\n# world";
775 assert_eq!(fixed, expected);
776 }
777
778 #[test]
779 fn test_fix_numeric_patterns() {
780 let rule = MD018NoMissingSpaceAtx::new();
782
783 let content = "#123\nContent.\n\n##456";
784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
785 let fixed = rule.fix(&ctx).unwrap();
786
787 let expected = "# 123\nContent.\n\n## 456";
788 assert_eq!(fixed, expected);
789 }
790
791 #[test]
792 fn test_indented_malformed_headings() {
793 let rule = MD018NoMissingSpaceAtx::new();
797
798 assert!(
800 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
801 .is_none(),
802 "1-space indented #hello should be skipped"
803 );
804 assert!(
805 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
806 .is_none(),
807 "2-space indented #hello should be skipped"
808 );
809 assert!(
810 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
811 .is_none(),
812 "3-space indented #hello should be skipped"
813 );
814
815 assert!(
820 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
821 .is_some(),
822 "Non-indented #hello should be detected"
823 );
824 }
825
826 #[test]
827 fn test_tab_after_hash_is_valid() {
828 let rule = MD018NoMissingSpaceAtx::new();
830
831 assert!(
832 rule.check_atx_heading_line("#\tHello", MarkdownFlavor::Standard)
833 .is_none(),
834 "Tab after # should be valid"
835 );
836 assert!(
837 rule.check_atx_heading_line("##\tWorld", MarkdownFlavor::Standard)
838 .is_none(),
839 "Tab after ## should be valid"
840 );
841 }
842
843 #[test]
844 fn test_mixed_case_patterns() {
845 let rule = MD018NoMissingSpaceAtx::new();
846
847 assert!(
849 rule.check_atx_heading_line("#hELLO", MarkdownFlavor::Standard)
850 .is_some()
851 );
852 assert!(
853 rule.check_atx_heading_line("#Hello", MarkdownFlavor::Standard)
854 .is_some()
855 );
856 assert!(
857 rule.check_atx_heading_line("#HELLO", MarkdownFlavor::Standard)
858 .is_some()
859 );
860 assert!(
861 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
862 .is_some()
863 );
864 }
865
866 #[test]
867 fn test_unicode_lowercase() {
868 let rule = MD018NoMissingSpaceAtx::new();
869
870 assert!(
872 rule.check_atx_heading_line("#über", MarkdownFlavor::Standard).is_some(),
873 "Unicode lowercase #über should be detected"
874 );
875 assert!(
876 rule.check_atx_heading_line("#café", MarkdownFlavor::Standard).is_some(),
877 "Unicode lowercase #café should be detected"
878 );
879 assert!(
880 rule.check_atx_heading_line("#日本語", MarkdownFlavor::Standard)
881 .is_some(),
882 "Japanese #日本語 should be detected"
883 );
884 }
885
886 #[test]
887 fn test_matches_markdownlint_behavior() {
888 let rule = MD018NoMissingSpaceAtx::new();
890
891 let content = r#"#hello
892
893## world
894
895###fer
896
897#123
898
899#Tag
900"#;
901
902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
903 let result = rule.check(&ctx).unwrap();
904
905 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
908
909 assert!(flagged_lines.contains(&1), "#hello should be flagged");
910 assert!(!flagged_lines.contains(&3), "## world should NOT be flagged");
911 assert!(flagged_lines.contains(&5), "###fer should be flagged");
912 assert!(flagged_lines.contains(&7), "#123 should be flagged");
913 assert!(flagged_lines.contains(&9), "#Tag should be flagged");
914
915 assert_eq!(result.len(), 4, "Should have exactly 4 warnings");
916 }
917
918 #[test]
919 fn test_skip_frontmatter_yaml_comments() {
920 let rule = MD018NoMissingSpaceAtx::new();
922
923 let content = r#"---
924#reviewers:
925#- sig-api-machinery
926#another_comment: value
927title: Test Document
928---
929
930# Valid heading
931
932#invalid heading without space
933"#;
934
935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
936 let result = rule.check(&ctx).unwrap();
937
938 assert_eq!(
941 result.len(),
942 1,
943 "Should only flag the malformed heading outside frontmatter"
944 );
945 assert_eq!(result[0].line, 10, "Should flag line 10");
946 }
947
948 #[test]
949 fn test_skip_html_comments() {
950 let rule = MD018NoMissingSpaceAtx::new();
953
954 let content = r#"# Real Heading
955
956Some text.
957
958<!--
959```
960#%% Cell marker
961import matplotlib.pyplot as plt
962
963#%% Another cell
964data = [1, 2, 3]
965```
966-->
967
968More content.
969"#;
970
971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
972 let result = rule.check(&ctx).unwrap();
973
974 assert!(
976 result.is_empty(),
977 "Should not flag content inside HTML comments, found {} issues",
978 result.len()
979 );
980 }
981
982 #[test]
983 fn test_mkdocs_magiclink_skips_numeric_refs() {
984 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
986 magiclink: true,
987 ..Default::default()
988 });
989
990 assert!(
992 rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_none(),
993 "#10 should be skipped with magiclink config (MagicLink issue ref)"
994 );
995 assert!(
996 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_none(),
997 "#123 should be skipped with magiclink config (MagicLink issue ref)"
998 );
999 assert!(
1000 rule.check_atx_heading_line("#10 discusses the issue", MarkdownFlavor::Standard)
1001 .is_none(),
1002 "#10 followed by text should be skipped with magiclink config"
1003 );
1004 assert!(
1005 rule.check_atx_heading_line("#37.", MarkdownFlavor::Standard).is_none(),
1006 "#37 followed by punctuation should be skipped with magiclink config"
1007 );
1008 }
1009
1010 #[test]
1011 fn test_mkdocs_magiclink_still_flags_non_numeric() {
1012 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1014 magiclink: true,
1015 ..Default::default()
1016 });
1017
1018 assert!(
1020 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
1021 .is_some(),
1022 "#Summary should still be flagged with magiclink config"
1023 );
1024 assert!(
1025 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
1026 .is_some(),
1027 "#hello should still be flagged with magiclink config"
1028 );
1029 assert!(
1030 rule.check_atx_heading_line("#10abc", MarkdownFlavor::Standard)
1031 .is_some(),
1032 "#10abc (mixed) should still be flagged with magiclink config"
1033 );
1034 }
1035
1036 #[test]
1037 fn test_mkdocs_magiclink_only_single_hash() {
1038 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1040 magiclink: true,
1041 ..Default::default()
1042 });
1043
1044 assert!(
1045 rule.check_atx_heading_line("##10", MarkdownFlavor::Standard).is_some(),
1046 "##10 should be flagged with magiclink config (only single # is MagicLink)"
1047 );
1048 assert!(
1049 rule.check_atx_heading_line("###123", MarkdownFlavor::Standard)
1050 .is_some(),
1051 "###123 should be flagged with magiclink config"
1052 );
1053 }
1054
1055 #[test]
1056 fn test_standard_flavor_flags_numeric_refs() {
1057 let rule = MD018NoMissingSpaceAtx::new();
1059
1060 assert!(
1061 rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_some(),
1062 "#10 should be flagged in Standard flavor"
1063 );
1064 assert!(
1065 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
1066 "#123 should be flagged in Standard flavor"
1067 );
1068 }
1069
1070 #[test]
1071 fn test_mkdocs_magiclink_full_check() {
1072 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1074 magiclink: true,
1075 ..Default::default()
1076 });
1077
1078 let content = r#"# PRs that are helpful for context
1079
1080#10 discusses the philosophy behind the project, and #37 shows a good example.
1081
1082#Summary
1083
1084##Introduction
1085"#;
1086
1087 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1089 let result = rule.check(&ctx).unwrap();
1090
1091 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1092 assert!(
1093 !flagged_lines.contains(&3),
1094 "#10 should NOT be flagged with magiclink config"
1095 );
1096 assert!(
1097 flagged_lines.contains(&5),
1098 "#Summary SHOULD be flagged with magiclink config"
1099 );
1100 assert!(
1101 flagged_lines.contains(&7),
1102 "##Introduction SHOULD be flagged with magiclink config"
1103 );
1104 }
1105
1106 #[test]
1107 fn test_mkdocs_magiclink_fix_exact_output() {
1108 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1110 magiclink: true,
1111 ..Default::default()
1112 });
1113
1114 let content = "#10 discusses the issue.\n\n#Summary";
1115 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1116 let fixed = rule.fix(&ctx).unwrap();
1117
1118 let expected = "#10 discusses the issue.\n\n# Summary";
1120 assert_eq!(
1121 fixed, expected,
1122 "magiclink config fix should preserve MagicLink refs and fix non-numeric headings"
1123 );
1124 }
1125
1126 #[test]
1127 fn test_mkdocs_magiclink_edge_cases() {
1128 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1130 magiclink: true,
1131 ..Default::default()
1132 });
1133
1134 let valid_refs = [
1137 "#10", "#999999", "#10 text after", "#10\ttext after", "#10.", "#10,", "#10!", "#10?", "#10)", "#10]", "#10;", "#10:", ];
1150
1151 for ref_str in valid_refs {
1152 assert!(
1153 rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_none(),
1154 "{ref_str:?} should be skipped as MagicLink ref with magiclink config"
1155 );
1156 }
1157
1158 let invalid_refs = [
1160 "#10abc", "#10a", "#abc10", "#10ABC", "#Summary", "#hello", ];
1167
1168 for ref_str in invalid_refs {
1169 assert!(
1170 rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_some(),
1171 "{ref_str:?} should be flagged with magiclink config (not a valid MagicLink ref)"
1172 );
1173 }
1174 }
1175
1176 #[test]
1177 fn test_mkdocs_magiclink_hyphenated_continuation() {
1178 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1181 magiclink: true,
1182 ..Default::default()
1183 });
1184
1185 assert!(
1190 rule.check_atx_heading_line("#10-", MarkdownFlavor::Standard).is_none(),
1191 "#10- should be skipped with magiclink config (hyphen is non-alphanumeric terminator)"
1192 );
1193 }
1194
1195 #[test]
1196 fn test_mkdocs_magiclink_standalone_number() {
1197 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1199 magiclink: true,
1200 ..Default::default()
1201 });
1202
1203 let content = "See issue:\n\n#10\n\nFor details.";
1204 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1205 let result = rule.check(&ctx).unwrap();
1206
1207 assert!(
1209 result.is_empty(),
1210 "Standalone #10 should not be flagged with magiclink config"
1211 );
1212
1213 let fixed = rule.fix(&ctx).unwrap();
1215 assert_eq!(fixed, content, "fix() should not modify standalone MagicLink ref");
1216 }
1217
1218 #[test]
1219 fn test_standard_flavor_flags_all_numeric() {
1220 let rule = MD018NoMissingSpaceAtx::new();
1223
1224 let numeric_patterns = ["#10", "#123", "#999999", "#10 text"];
1225
1226 for pattern in numeric_patterns {
1227 assert!(
1228 rule.check_atx_heading_line(pattern, MarkdownFlavor::Standard).is_some(),
1229 "{pattern:?} should be flagged in Standard flavor"
1230 );
1231 }
1232
1233 assert!(
1235 rule.check_atx_heading_line("#1", MarkdownFlavor::Standard).is_none(),
1236 "#1 should be skipped (content too short, existing behavior)"
1237 );
1238 }
1239
1240 #[test]
1241 fn test_mkdocs_vs_standard_fix_comparison() {
1242 let content = "#10 is an issue\n#Summary";
1244 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1245
1246 let rule_magiclink = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1248 magiclink: true,
1249 ..Default::default()
1250 });
1251 let fixed_magiclink = rule_magiclink.fix(&ctx).unwrap();
1252 assert_eq!(fixed_magiclink, "#10 is an issue\n# Summary");
1253
1254 let rule_default = MD018NoMissingSpaceAtx::new();
1256 let fixed_default = rule_default.fix(&ctx).unwrap();
1257 assert_eq!(fixed_default, "# 10 is an issue\n# Summary");
1258 }
1259
1260 #[test]
1263 fn test_tags_config_standard_flavor() {
1264 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1266 magiclink: false,
1267 tags: Some(true),
1268 });
1269
1270 let content = "#tag\n\n#project/active\n\n##Introduction";
1271 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1272 let result = rule.check(&ctx).unwrap();
1273
1274 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1275 assert!(!flagged_lines.contains(&1), "#tag should be skipped with tags = true");
1276 assert!(
1277 !flagged_lines.contains(&3),
1278 "#project/active should be skipped with tags = true"
1279 );
1280 assert!(flagged_lines.contains(&5), "##Introduction should still be flagged");
1281 }
1282
1283 #[test]
1284 fn test_tags_config_fix_standard_flavor() {
1285 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1286 magiclink: false,
1287 tags: Some(true),
1288 });
1289
1290 let content = "#tag\n\n##Introduction";
1291 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1292 let fixed = rule.fix(&ctx).unwrap();
1293 assert_eq!(fixed, "#tag\n\n## Introduction");
1294 }
1295
1296 #[test]
1297 fn test_tags_config_disabled_obsidian_flavor() {
1298 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1300 magiclink: false,
1301 tags: Some(false),
1302 });
1303
1304 let content = "#tag\n\n#project/active";
1305 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1306 let result = rule.check(&ctx).unwrap();
1307
1308 assert_eq!(
1309 result.len(),
1310 2,
1311 "tags = false should flag tag patterns even in Obsidian"
1312 );
1313 }
1314
1315 #[test]
1316 fn test_tags_config_default_follows_flavor() {
1317 let rule = MD018NoMissingSpaceAtx::new(); let content = "#tag";
1322 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1323 let result = rule.check(&ctx).unwrap();
1324 assert!(!result.is_empty(), "Default standard should flag #tag");
1325
1326 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1328 let result = rule.check(&ctx).unwrap();
1329 assert!(result.is_empty(), "Default Obsidian should skip #tag");
1330 }
1331
1332 #[test]
1335 fn test_obsidian_tag_skips_simple_tags() {
1336 let rule = MD018NoMissingSpaceAtx::new();
1338
1339 assert!(
1341 rule.check_atx_heading_line("#hey", MarkdownFlavor::Obsidian).is_none(),
1342 "#hey should be skipped in Obsidian flavor (tag syntax)"
1343 );
1344 assert!(
1345 rule.check_atx_heading_line("#tag", MarkdownFlavor::Obsidian).is_none(),
1346 "#tag should be skipped in Obsidian flavor"
1347 );
1348 assert!(
1349 rule.check_atx_heading_line("#hello", MarkdownFlavor::Obsidian)
1350 .is_none(),
1351 "#hello should be skipped in Obsidian flavor"
1352 );
1353 assert!(
1354 rule.check_atx_heading_line("#myTag", MarkdownFlavor::Obsidian)
1355 .is_none(),
1356 "#myTag should be skipped in Obsidian flavor"
1357 );
1358 }
1359
1360 #[test]
1361 fn test_obsidian_tag_skips_complex_tags() {
1362 let rule = MD018NoMissingSpaceAtx::new();
1364
1365 assert!(
1367 rule.check_atx_heading_line("#project/active", MarkdownFlavor::Obsidian)
1368 .is_none(),
1369 "#project/active should be skipped in Obsidian flavor (nested tag)"
1370 );
1371 assert!(
1372 rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1373 .is_none(),
1374 "#my-tag should be skipped in Obsidian flavor"
1375 );
1376 assert!(
1377 rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1378 .is_none(),
1379 "#my_tag should be skipped in Obsidian flavor"
1380 );
1381 assert!(
1382 rule.check_atx_heading_line("#tag2023", MarkdownFlavor::Obsidian)
1383 .is_none(),
1384 "#tag2023 should be skipped in Obsidian flavor"
1385 );
1386 assert!(
1387 rule.check_atx_heading_line("#project/sub/task", MarkdownFlavor::Obsidian)
1388 .is_none(),
1389 "#project/sub/task should be skipped in Obsidian flavor"
1390 );
1391 }
1392
1393 #[test]
1394 fn test_obsidian_tag_with_trailing_content() {
1395 let rule = MD018NoMissingSpaceAtx::new();
1397
1398 assert!(
1399 rule.check_atx_heading_line("#hey ", MarkdownFlavor::Obsidian).is_none(),
1400 "#hey followed by space should be skipped"
1401 );
1402 assert!(
1403 rule.check_atx_heading_line("#tag some text", MarkdownFlavor::Obsidian)
1404 .is_none(),
1405 "#tag followed by text should be skipped"
1406 );
1407 }
1408
1409 #[test]
1410 fn test_obsidian_tag_still_flags_multi_hash() {
1411 let rule = MD018NoMissingSpaceAtx::new();
1413
1414 assert!(
1415 rule.check_atx_heading_line("##tag", MarkdownFlavor::Obsidian).is_some(),
1416 "##tag should be flagged in Obsidian flavor (only single # is a tag)"
1417 );
1418 assert!(
1419 rule.check_atx_heading_line("###hello", MarkdownFlavor::Obsidian)
1420 .is_some(),
1421 "###hello should be flagged in Obsidian flavor"
1422 );
1423 }
1424
1425 #[test]
1426 fn test_obsidian_tag_numeric_still_flagged() {
1427 let rule = MD018NoMissingSpaceAtx::new();
1429
1430 assert!(
1431 rule.check_atx_heading_line("#123", MarkdownFlavor::Obsidian).is_some(),
1432 "#123 should be flagged in Obsidian flavor (tags cannot start with digit)"
1433 );
1434 assert!(
1435 rule.check_atx_heading_line("#10", MarkdownFlavor::Obsidian).is_some(),
1436 "#10 should be flagged in Obsidian flavor"
1437 );
1438 }
1439
1440 #[test]
1441 fn test_obsidian_flavor_full_check() {
1442 let rule = MD018NoMissingSpaceAtx::new();
1444
1445 let content = r#"# Real Heading
1446
1447#hey this is a tag
1448
1449#project/active also a tag
1450
1451##Introduction
1452
1453#123
1454"#;
1455
1456 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1458 let result = rule.check(&ctx).unwrap();
1459
1460 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1461 assert!(
1462 !flagged_lines.contains(&3),
1463 "#hey should NOT be flagged in Obsidian flavor"
1464 );
1465 assert!(
1466 !flagged_lines.contains(&5),
1467 "#project/active should NOT be flagged in Obsidian flavor"
1468 );
1469 assert!(
1470 flagged_lines.contains(&7),
1471 "##Introduction SHOULD be flagged in Obsidian flavor"
1472 );
1473 assert!(flagged_lines.contains(&9), "#123 SHOULD be flagged in Obsidian flavor");
1474 }
1475
1476 #[test]
1477 fn test_obsidian_flavor_fix_exact_output() {
1478 let rule = MD018NoMissingSpaceAtx::new();
1480
1481 let content = "#hey is a tag.\n\n##Introduction";
1484 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1485 let fixed = rule.fix(&ctx).unwrap();
1486
1487 let expected = "#hey is a tag.\n\n## Introduction";
1489 assert_eq!(
1490 fixed, expected,
1491 "Obsidian fix should preserve tags and fix multi-hash headings"
1492 );
1493 }
1494
1495 #[test]
1496 fn test_standard_flavor_flags_obsidian_tags() {
1497 let rule = MD018NoMissingSpaceAtx::new();
1499
1500 assert!(
1501 rule.check_atx_heading_line("#hey", MarkdownFlavor::Standard).is_some(),
1502 "#hey should be flagged in Standard flavor"
1503 );
1504 assert!(
1505 rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
1506 "#tag should be flagged in Standard flavor"
1507 );
1508 assert!(
1509 rule.check_atx_heading_line("#project/active", MarkdownFlavor::Standard)
1510 .is_some(),
1511 "#project/active should be flagged in Standard flavor"
1512 );
1513 }
1514
1515 #[test]
1516 fn test_obsidian_vs_standard_fix_comparison() {
1517 let rule = MD018NoMissingSpaceAtx::new();
1519
1520 let content = "#hey tag\n##Introduction";
1524
1525 let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1527 let fixed_obsidian = rule.fix(&ctx_obsidian).unwrap();
1528 assert_eq!(fixed_obsidian, "#hey tag\n## Introduction");
1529
1530 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1532 let fixed_standard = rule.fix(&ctx_standard).unwrap();
1533 assert_eq!(fixed_standard, "# hey tag\n## Introduction");
1534 }
1535
1536 #[test]
1537 fn test_obsidian_tag_edge_cases() {
1538 let rule = MD018NoMissingSpaceAtx::new();
1540
1541 let valid_tags = [
1543 "#a", "#tag", "#Tag", "#TAG", "#my-tag", "#my_tag", "#tag123", "#a1", "#日本語", "#über", ];
1554
1555 for tag in valid_tags {
1556 let result = rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian);
1558 let _ = result;
1561 }
1562
1563 let invalid_tags = ["#1tag", "#123", "#2023-project"];
1565
1566 for tag in invalid_tags {
1567 assert!(
1568 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_some(),
1569 "{tag:?} should be flagged in Obsidian flavor (starts with digit)"
1570 );
1571 }
1572 }
1573
1574 #[test]
1575 fn test_obsidian_tag_alone_on_line() {
1576 let rule = MD018NoMissingSpaceAtx::new();
1578
1579 let content = "Some text\n\n#todo\n\nMore text.";
1580 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1581 let result = rule.check(&ctx).unwrap();
1582
1583 assert!(
1585 result.is_empty(),
1586 "Standalone #todo should not be flagged in Obsidian flavor"
1587 );
1588
1589 let fixed = rule.fix(&ctx).unwrap();
1591 assert_eq!(fixed, content, "fix() should not modify standalone Obsidian tag");
1592 }
1593
1594 #[test]
1595 fn test_obsidian_deeply_nested_tags() {
1596 let rule = MD018NoMissingSpaceAtx::new();
1598
1599 let nested_tags = [
1600 "#a/b",
1601 "#a/b/c",
1602 "#project/2023/q1/task",
1603 "#work/meetings/weekly",
1604 "#life/health/exercise/running",
1605 ];
1606
1607 for tag in nested_tags {
1608 assert!(
1609 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1610 "{tag:?} should be skipped in Obsidian flavor (nested tag)"
1611 );
1612 }
1613 }
1614
1615 #[test]
1616 fn test_obsidian_unicode_tags() {
1617 let rule = MD018NoMissingSpaceAtx::new();
1619
1620 let unicode_tags = [
1621 "#日本語", "#中文", "#한국어", "#über", "#café", "#ñoño", "#Москва", "#αβγ", ];
1630
1631 for tag in unicode_tags {
1632 assert!(
1633 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1634 "{tag:?} should be skipped in Obsidian flavor (Unicode tag)"
1635 );
1636 }
1637 }
1638
1639 #[test]
1640 fn test_obsidian_tags_with_special_endings() {
1641 let rule = MD018NoMissingSpaceAtx::new();
1643
1644 assert!(
1646 rule.check_atx_heading_line("#tag followed by text", MarkdownFlavor::Obsidian)
1647 .is_none(),
1648 "#tag followed by text should be skipped"
1649 );
1650
1651 let content = "#todo";
1653 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1654 let result = rule.check(&ctx).unwrap();
1655 assert!(result.is_empty(), "#todo at end of line should be skipped");
1656 }
1657
1658 #[test]
1659 fn test_obsidian_combined_with_other_skip_contexts() {
1660 let rule = MD018NoMissingSpaceAtx::new();
1662
1663 let content = "```\n#todo\n```";
1665 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1666 let result = rule.check(&ctx).unwrap();
1667 assert!(result.is_empty(), "Tag in code block should be skipped");
1668
1669 let content = "<!-- #todo -->";
1671 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1672 let result = rule.check(&ctx).unwrap();
1673 assert!(result.is_empty(), "Tag in HTML comment should be skipped");
1674 }
1675
1676 #[test]
1677 fn test_obsidian_boundary_cases() {
1678 let rule = MD018NoMissingSpaceAtx::new();
1680
1681 assert!(
1685 rule.check_atx_heading_line("#ab", MarkdownFlavor::Obsidian).is_none(),
1686 "#ab should be skipped in Obsidian flavor"
1687 );
1688
1689 assert!(
1691 rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1692 .is_none(),
1693 "#my_tag should be skipped"
1694 );
1695
1696 assert!(
1698 rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1699 .is_none(),
1700 "#my-tag should be skipped"
1701 );
1702
1703 assert!(
1705 rule.check_atx_heading_line("#MyTag", MarkdownFlavor::Obsidian)
1706 .is_none(),
1707 "#MyTag should be skipped"
1708 );
1709
1710 assert!(
1712 rule.check_atx_heading_line("#TODO", MarkdownFlavor::Obsidian).is_none(),
1713 "#TODO should be skipped in Obsidian flavor"
1714 );
1715 }
1716}