1mod md018_config;
5
6pub(super) use md018_config::MD018Config;
7
8use crate::config::MarkdownFlavor;
9use crate::lint_context::{AtxMissingSpace, LineInfo};
10use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
11use crate::utils::obsidian_tag::TAG_PATTERN;
12use crate::utils::range_utils::{byte_to_char_count, calculate_single_line_range};
13use regex::Regex;
14use std::sync::LazyLock;
15
16const EMOJI_HASHTAG_PATTERN_STR: &str = r"^#️⃣|^#⃣";
18const UNICODE_HASHTAG_PATTERN_STR: &str = r"^#[\u{FE0F}\u{20E3}]";
19static EMOJI_HASHTAG_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(EMOJI_HASHTAG_PATTERN_STR).unwrap());
20static UNICODE_HASHTAG_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(UNICODE_HASHTAG_PATTERN_STR).unwrap());
21
22const MAGICLINK_REF_PATTERN_STR: &str = r"^#\d+(?:\s|[^a-zA-Z0-9]|$)";
26static MAGICLINK_REF_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(MAGICLINK_REF_PATTERN_STR).unwrap());
27
28#[derive(Clone)]
29pub struct MD018NoMissingSpaceAtx {
30 config: MD018Config,
31}
32
33impl Default for MD018NoMissingSpaceAtx {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39impl MD018NoMissingSpaceAtx {
40 pub fn new() -> Self {
41 Self {
42 config: MD018Config::default(),
43 }
44 }
45
46 pub fn from_config_struct(config: MD018Config) -> Self {
47 Self { config }
48 }
49
50 fn is_magiclink_ref(line: &str) -> bool {
53 MAGICLINK_REF_PATTERN.is_match(line.trim_start())
54 }
55
56 fn is_tag(line: &str) -> bool {
58 TAG_PATTERN.is_match(line.trim_start())
59 }
60
61 fn tags_enabled(&self, flavor: MarkdownFlavor) -> bool {
63 self.config.tags_enabled(flavor)
64 }
65
66 fn without_leading_unicode_space(text: &str) -> &str {
70 let rest = text.trim_start_matches(char::is_whitespace);
71 if rest.is_empty() { text } else { rest }
72 }
73
74 fn check_atx_heading_line(&self, line: &str, flavor: MarkdownFlavor) -> Option<(usize, String)> {
76 let trimmed_line = line.trim_start();
78 let indent = line.len() - trimmed_line.len();
79
80 if !trimmed_line.starts_with('#') {
81 return None;
82 }
83
84 if indent > 0 {
90 return None;
91 }
92
93 let is_emoji = EMOJI_HASHTAG_PATTERN.is_match(trimmed_line);
95 let is_unicode = UNICODE_HASHTAG_PATTERN.is_match(trimmed_line);
96 if is_emoji || is_unicode {
97 return None;
98 }
99
100 let hash_count = trimmed_line.chars().take_while(|&c| c == '#').count();
102 if hash_count == 0 || hash_count > 6 {
103 return None;
104 }
105
106 let after_hashes = &trimmed_line[hash_count..];
108
109 if after_hashes
111 .chars()
112 .next()
113 .is_some_and(|ch| matches!(ch, '\u{FE0F}' | '\u{20E3}' | '\u{FE0E}'))
114 {
115 return None;
116 }
117
118 if !after_hashes.is_empty() && !after_hashes.starts_with(' ') && !after_hashes.starts_with('\t') {
120 let content = after_hashes.trim();
122
123 if content.chars().all(|c| c == '#') {
125 return None;
126 }
127
128 if content.len() < 2 {
130 return None;
131 }
132
133 if content.starts_with('*') || content.starts_with('_') {
135 return None;
136 }
137
138 if self.config.magiclink && hash_count == 1 && Self::is_magiclink_ref(line) {
141 return None;
142 }
143
144 if self.tags_enabled(flavor) && hash_count == 1 && Self::is_tag(line) {
147 return None;
148 }
149
150 let fixed = format!(
152 "{}{} {}",
153 " ".repeat(indent),
154 "#".repeat(hash_count),
155 Self::without_leading_unicode_space(after_hashes)
156 );
157 return Some((indent + hash_count, fixed));
158 }
159
160 None
161 }
162
163 fn missing_space_split<'a>(
168 &self,
169 line: &'a str,
170 indent: usize,
171 missing: AtxMissingSpace,
172 flavor: MarkdownFlavor,
173 ) -> Option<(&'a str, &'a str)> {
174 if indent > 0 {
175 return None;
176 }
177 let trimmed = &line[indent..];
178 if EMOJI_HASHTAG_PATTERN.is_match(trimmed) || UNICODE_HASHTAG_PATTERN.is_match(trimmed) {
179 return None;
180 }
181 if missing.level == 1
182 && ((self.config.magiclink && Self::is_magiclink_ref(line))
183 || (self.tags_enabled(flavor) && Self::is_tag(line)))
184 {
185 return None;
186 }
187 let (marker, after_marker) = trimmed.split_at_checked(usize::from(missing.level))?;
188 (!after_marker.is_empty()).then_some((marker, after_marker))
189 }
190
191 fn may_be_unrecorded_missing_space(line_info: &LineInfo) -> bool {
196 line_info.heading.is_none()
197 && !line_info.is_setext_heading_text
198 && !line_info.in_code_block
199 && !line_info.in_front_matter
200 && !line_info.in_html_comment
201 && !line_info.in_mdx_comment
202 && !line_info.is_blank
203 }
204}
205
206impl Rule for MD018NoMissingSpaceAtx {
207 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
208 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(18))
209 }
210
211 fn name(&self) -> &'static str {
212 "MD018"
213 }
214
215 fn description(&self) -> &'static str {
216 "No space after hash in heading"
217 }
218
219 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
220 let mut warnings = Vec::new();
221
222 let quoted_setext_lines: std::collections::HashSet<usize> = ctx
224 .headings()
225 .filter(|h| h.is_blockquote() && h.is_setext())
226 .flat_map(|h| (h.first_line_num() - 1)..h.line_num)
227 .collect();
228
229 let edges = crate::utils::blockquote::container_edges(ctx);
231
232 let source = ctx
233 .candidate_line_indices(crate::lint_context::LineCandidateKind::Hash)
234 .iter()
235 .map(|&index| (index, &ctx.lines[index]));
236
237 for (line_num, line_info) in source {
238 if line_info.atx_missing_space.is_none()
239 && (!Self::may_be_unrecorded_missing_space(line_info)
240 || (line_info.blockquote.is_none()
241 && line_info.list_item.is_none()
242 && !line_info.content(ctx.content).trim_start().starts_with('#')))
243 {
244 continue;
245 }
246 let marker_byte = line_info.byte_offset + line_info.indent;
247 if line_info.in_esm_block
248 || ctx.is_in_code_span_byte(marker_byte)
249 || ctx.is_in_parsed_link(marker_byte)
250 || ctx.overlaps_template_code(marker_byte, marker_byte + 1)
251 || ctx.is_in_html_tag(marker_byte)
252 || ctx.overlaps_mdx_inline_code(marker_byte, marker_byte + 1)
253 || ctx.is_in_jsx_component_tag(marker_byte)
254 {
255 continue;
256 }
257 if line_info.in_html_block
259 || line_info.in_html_comment
260 || line_info.in_mdx_comment
261 || line_info.in_pymdown_block
262 {
263 continue;
264 }
265
266 if let Some(missing) = line_info.atx_missing_space {
267 let line = line_info.content(ctx.content);
268 if let Some((marker, after_marker)) =
269 self.missing_space_split(line, line_info.indent, missing, ctx.flavor)
270 {
271 let hash_end_col = byte_to_char_count(line, line_info.indent + marker.len());
274 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
275 line_num + 1, hash_end_col,
277 0, );
279
280 warnings.push(LintWarning {
281 rule_name: Some(self.name().to_string()),
282 message: format!("No space after {marker} in heading"),
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(ctx.line_content_byte_range(line_num + 1), {
289 let original_indent = &line[..line_info.indent];
291 format!(
292 "{original_indent}{marker} {}",
293 Self::without_leading_unicode_space(after_marker)
294 )
295 })),
296 });
297 }
298 } else if Self::may_be_unrecorded_missing_space(line_info) {
299 let line = line_info.content(ctx.content);
302 let (prefix_len, quoted_content) = if line_info.blockquote.is_some() {
303 if quoted_setext_lines.contains(&line_num) {
304 continue;
305 }
306 match crate::utils::blockquote::parse_blockquote_prefix(line) {
307 Some(parsed)
308 if crate::utils::blockquote::quote_markers_are_real(line)
309 && !crate::utils::blockquote::quote_markers_are_paragraph_text(
310 ctx, &edges, line_num, line,
311 ) =>
312 {
313 (line.len() - parsed.content.len(), parsed.content)
314 }
315 _ => continue,
316 }
317 } else {
318 (0, line)
319 };
320 let (prefix_len, quoted_content) = match line_info
322 .list_item
323 .as_ref()
324 .and_then(|_| crate::utils::blockquote::quote_text_start_in_items(line, prefix_len))
325 {
326 Some(_) if quoted_setext_lines.contains(&line_num) => continue,
327 Some(start) => (start, &line[start..]),
328 None => (prefix_len, quoted_content),
329 };
330 if let Some((hash_end_pos, fixed_line)) = self.check_atx_heading_line(quoted_content, ctx.flavor) {
332 let hash_end_col = line[..prefix_len].chars().count() + hash_end_pos;
333 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
334 line_num + 1, hash_end_col + 1, 0, );
338
339 let fixed = format!("{}{}", &line[..prefix_len], fixed_line);
340 warnings.push(LintWarning {
341 rule_name: Some(self.name().to_string()),
342 message: "No space after hash in heading".to_string(),
343 line: start_line,
344 column: start_col,
345 end_line,
346 end_column: end_col,
347 severity: Severity::Warning,
348 fix: Some(Fix::new(ctx.line_content_byte_range(line_num + 1), fixed)),
349 });
350 }
351 }
352 }
353
354 Ok(warnings)
355 }
356
357 fn fixes_from_diagnostics(&self) -> bool {
358 true
359 }
360
361 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
362 let warnings = self.check(ctx)?;
363 let warnings =
364 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
365 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
366 .map_err(crate::rule::LintError::InvalidInput)
367 }
368
369 fn category(&self) -> RuleCategory {
371 RuleCategory::Heading
372 }
373
374 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
376 !ctx.likely_has_headings()
378 }
379
380 fn as_any(&self) -> &dyn std::any::Any {
381 self
382 }
383
384 crate::impl_rule_config_methods!(MD018Config);
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390 use crate::lint_context::LintContext;
391
392 #[test]
393 fn test_basic_functionality() {
394 let rule = MD018NoMissingSpaceAtx::new();
395
396 let content = "# Heading 1\n## Heading 2\n### Heading 3";
398 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
399 let result = rule.check(&ctx).unwrap();
400 assert!(result.is_empty());
401
402 let content = "#Heading 1\n## Heading 2\n###Heading 3";
404 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
405 let result = rule.check(&ctx).unwrap();
406 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
408 assert_eq!(result[1].line, 3);
409 }
410
411 #[test]
412 fn test_malformed_heading_detection() {
413 let rule = MD018NoMissingSpaceAtx::new();
414
415 assert!(
417 rule.check_atx_heading_line("##Introduction", MarkdownFlavor::Standard)
418 .is_some()
419 );
420 assert!(
421 rule.check_atx_heading_line("###Background", MarkdownFlavor::Standard)
422 .is_some()
423 );
424 assert!(
425 rule.check_atx_heading_line("####Details", MarkdownFlavor::Standard)
426 .is_some()
427 );
428 assert!(
429 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
430 .is_some()
431 );
432 assert!(
433 rule.check_atx_heading_line("######Conclusion", MarkdownFlavor::Standard)
434 .is_some()
435 );
436 assert!(
437 rule.check_atx_heading_line("##Table of Contents", MarkdownFlavor::Standard)
438 .is_some()
439 );
440
441 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!(
446 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
447 .is_none()
448 ); assert!(
450 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
451 .is_none()
452 ); }
454
455 #[test]
456 fn test_malformed_heading_with_context() {
457 let rule = MD018NoMissingSpaceAtx::new();
458
459 let content = r#"# Test Document
461
462##Introduction
463This should be detected.
464
465 ##CodeBlock
466This should NOT be detected (indented code block).
467
468```
469##FencedCodeBlock
470This should NOT be detected (fenced code block).
471```
472
473##Conclusion
474This should be detected.
475"#;
476
477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
478 let result = rule.check(&ctx).unwrap();
479
480 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
482 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&14)); assert!(!detected_lines.contains(&6)); assert!(!detected_lines.contains(&10)); }
487
488 #[test]
489 fn test_malformed_heading_fix() {
490 let rule = MD018NoMissingSpaceAtx::new();
491
492 let content = r#"##Introduction
493This is a test.
494
495###Background
496More content."#;
497
498 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
499 let fixed = rule.fix(&ctx).unwrap();
500
501 let expected = r#"## Introduction
502This is a test.
503
504### Background
505More content."#;
506
507 assert_eq!(fixed, expected);
508 }
509
510 #[test]
511 fn test_mixed_proper_and_malformed_headings() {
512 let rule = MD018NoMissingSpaceAtx::new();
513
514 let content = r#"# Proper Heading
515
516##Malformed Heading
517
518## Another Proper Heading
519
520###Another Malformed
521
522#### Proper with space
523"#;
524
525 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
526 let result = rule.check(&ctx).unwrap();
527
528 assert_eq!(result.len(), 2);
530 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
531 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&7)); }
534
535 #[test]
536 fn test_css_selectors_in_html_blocks() {
537 let rule = MD018NoMissingSpaceAtx::new();
538
539 let content = r#"# Proper Heading
542
543<style>
544#slide-1 ol li {
545 margin-top: 0;
546}
547
548#special-slide ol li {
549 margin-top: 2em;
550}
551</style>
552
553## Another Heading
554"#;
555
556 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
557 let result = rule.check(&ctx).unwrap();
558
559 assert_eq!(
561 result.len(),
562 0,
563 "CSS selectors in <style> blocks should not be flagged as malformed headings"
564 );
565 }
566
567 #[test]
568 fn test_js_code_in_script_blocks() {
569 let rule = MD018NoMissingSpaceAtx::new();
570
571 let content = r#"# Heading
573
574<script>
575const element = document.querySelector('#main-content');
576#another-comment
577</script>
578
579## Another Heading
580"#;
581
582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
583 let result = rule.check(&ctx).unwrap();
584
585 assert_eq!(
587 result.len(),
588 0,
589 "JavaScript code in <script> blocks should not be flagged as malformed headings"
590 );
591 }
592
593 #[test]
594 fn test_all_malformed_headings_detected() {
595 let rule = MD018NoMissingSpaceAtx::new();
596
597 assert!(
602 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
603 .is_some(),
604 "#hello SHOULD be detected as malformed heading"
605 );
606 assert!(
607 rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
608 "#tag SHOULD be detected as malformed heading"
609 );
610 assert!(
611 rule.check_atx_heading_line("#hashtag", MarkdownFlavor::Standard)
612 .is_some(),
613 "#hashtag SHOULD be detected as malformed heading"
614 );
615 assert!(
616 rule.check_atx_heading_line("#javascript", MarkdownFlavor::Standard)
617 .is_some(),
618 "#javascript SHOULD be detected as malformed heading"
619 );
620
621 assert!(
623 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
624 "#123 SHOULD be detected as malformed heading"
625 );
626 assert!(
627 rule.check_atx_heading_line("#12345", MarkdownFlavor::Standard)
628 .is_some(),
629 "#12345 SHOULD be detected as malformed heading"
630 );
631 assert!(
632 rule.check_atx_heading_line("#29039)", MarkdownFlavor::Standard)
633 .is_some(),
634 "#29039) SHOULD be detected as malformed heading"
635 );
636
637 assert!(
639 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
640 .is_some(),
641 "#Summary SHOULD be detected as malformed heading"
642 );
643 assert!(
644 rule.check_atx_heading_line("#Introduction", MarkdownFlavor::Standard)
645 .is_some(),
646 "#Introduction SHOULD be detected as malformed heading"
647 );
648 assert!(
649 rule.check_atx_heading_line("#API", MarkdownFlavor::Standard).is_some(),
650 "#API SHOULD be detected as malformed heading"
651 );
652
653 assert!(
655 rule.check_atx_heading_line("##introduction", MarkdownFlavor::Standard)
656 .is_some(),
657 "##introduction SHOULD be detected as malformed heading"
658 );
659 assert!(
660 rule.check_atx_heading_line("###section", MarkdownFlavor::Standard)
661 .is_some(),
662 "###section SHOULD be detected as malformed heading"
663 );
664 assert!(
665 rule.check_atx_heading_line("###fer", MarkdownFlavor::Standard)
666 .is_some(),
667 "###fer SHOULD be detected as malformed heading"
668 );
669 assert!(
670 rule.check_atx_heading_line("##123", MarkdownFlavor::Standard).is_some(),
671 "##123 SHOULD be detected as malformed heading"
672 );
673 }
674
675 #[test]
676 fn test_patterns_that_should_not_be_flagged() {
677 let rule = MD018NoMissingSpaceAtx::new();
678
679 assert!(rule.check_atx_heading_line("###", MarkdownFlavor::Standard).is_none());
681 assert!(rule.check_atx_heading_line("#", MarkdownFlavor::Standard).is_none());
682
683 assert!(rule.check_atx_heading_line("##a", MarkdownFlavor::Standard).is_none());
685
686 assert!(
688 rule.check_atx_heading_line("#*emphasis", MarkdownFlavor::Standard)
689 .is_none()
690 );
691
692 assert!(
694 rule.check_atx_heading_line("#######TooBig", MarkdownFlavor::Standard)
695 .is_none()
696 );
697
698 assert!(
700 rule.check_atx_heading_line("# Hello", MarkdownFlavor::Standard)
701 .is_none()
702 );
703 assert!(
704 rule.check_atx_heading_line("## World", MarkdownFlavor::Standard)
705 .is_none()
706 );
707 assert!(
708 rule.check_atx_heading_line("### Section", MarkdownFlavor::Standard)
709 .is_none()
710 );
711 }
712
713 #[test]
714 fn test_inline_issue_refs_not_at_line_start() {
715 let rule = MD018NoMissingSpaceAtx::new();
716
717 assert!(
722 rule.check_atx_heading_line("See issue #123", MarkdownFlavor::Standard)
723 .is_none()
724 );
725 assert!(
726 rule.check_atx_heading_line("Check #trending on Twitter", MarkdownFlavor::Standard)
727 .is_none()
728 );
729 assert!(
730 rule.check_atx_heading_line("- fix: issue #29039", MarkdownFlavor::Standard)
731 .is_none()
732 );
733 }
734
735 #[test]
736 fn test_lowercase_patterns_full_check() {
737 let rule = MD018NoMissingSpaceAtx::new();
739
740 let content = "#hello\n\n#world\n\n#tag";
741 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
742 let result = rule.check(&ctx).unwrap();
743
744 assert_eq!(result.len(), 3, "All three lowercase patterns should be flagged");
745 assert_eq!(result[0].line, 1);
746 assert_eq!(result[1].line, 3);
747 assert_eq!(result[2].line, 5);
748 }
749
750 #[test]
751 fn test_numeric_patterns_full_check() {
752 let rule = MD018NoMissingSpaceAtx::new();
754
755 let content = "#123\n\n#456\n\n#29039";
756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
757 let result = rule.check(&ctx).unwrap();
758
759 assert_eq!(result.len(), 3, "All three numeric patterns should be flagged");
760 }
761
762 #[test]
763 fn test_fix_lowercase_patterns() {
764 let rule = MD018NoMissingSpaceAtx::new();
766
767 let content = "#hello\nSome text.\n\n#world";
768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
769 let fixed = rule.fix(&ctx).unwrap();
770
771 let expected = "# hello\nSome text.\n\n# world";
772 assert_eq!(fixed, expected);
773 }
774
775 #[test]
776 fn test_fix_numeric_patterns() {
777 let rule = MD018NoMissingSpaceAtx::new();
779
780 let content = "#123\nContent.\n\n##456";
781 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
782 let fixed = rule.fix(&ctx).unwrap();
783
784 let expected = "# 123\nContent.\n\n## 456";
785 assert_eq!(fixed, expected);
786 }
787
788 #[test]
789 fn test_indented_malformed_headings() {
790 let rule = MD018NoMissingSpaceAtx::new();
794
795 assert!(
797 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
798 .is_none(),
799 "1-space indented #hello should be skipped"
800 );
801 assert!(
802 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
803 .is_none(),
804 "2-space indented #hello should be skipped"
805 );
806 assert!(
807 rule.check_atx_heading_line(" #hello", MarkdownFlavor::Standard)
808 .is_none(),
809 "3-space indented #hello should be skipped"
810 );
811
812 assert!(
817 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
818 .is_some(),
819 "Non-indented #hello should be detected"
820 );
821 }
822
823 #[test]
824 fn test_tab_after_hash_is_valid() {
825 let rule = MD018NoMissingSpaceAtx::new();
827
828 assert!(
829 rule.check_atx_heading_line("#\tHello", MarkdownFlavor::Standard)
830 .is_none(),
831 "Tab after # should be valid"
832 );
833 assert!(
834 rule.check_atx_heading_line("##\tWorld", MarkdownFlavor::Standard)
835 .is_none(),
836 "Tab after ## should be valid"
837 );
838 }
839
840 #[test]
841 fn test_mixed_case_patterns() {
842 let rule = MD018NoMissingSpaceAtx::new();
843
844 assert!(
846 rule.check_atx_heading_line("#hELLO", MarkdownFlavor::Standard)
847 .is_some()
848 );
849 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 }
862
863 #[test]
864 fn test_unicode_lowercase() {
865 let rule = MD018NoMissingSpaceAtx::new();
866
867 assert!(
869 rule.check_atx_heading_line("#über", MarkdownFlavor::Standard).is_some(),
870 "Unicode lowercase #über should be detected"
871 );
872 assert!(
873 rule.check_atx_heading_line("#café", MarkdownFlavor::Standard).is_some(),
874 "Unicode lowercase #café should be detected"
875 );
876 assert!(
877 rule.check_atx_heading_line("#日本語", MarkdownFlavor::Standard)
878 .is_some(),
879 "Japanese #日本語 should be detected"
880 );
881 }
882
883 #[test]
884 fn test_matches_markdownlint_behavior() {
885 let rule = MD018NoMissingSpaceAtx::new();
887
888 let content = r#"#hello
889
890## world
891
892###fer
893
894#123
895
896#Tag
897"#;
898
899 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
900 let result = rule.check(&ctx).unwrap();
901
902 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
905
906 assert!(flagged_lines.contains(&1), "#hello should be flagged");
907 assert!(!flagged_lines.contains(&3), "## world should NOT be flagged");
908 assert!(flagged_lines.contains(&5), "###fer should be flagged");
909 assert!(flagged_lines.contains(&7), "#123 should be flagged");
910 assert!(flagged_lines.contains(&9), "#Tag should be flagged");
911
912 assert_eq!(result.len(), 4, "Should have exactly 4 warnings");
913 }
914
915 #[test]
916 fn test_skip_frontmatter_yaml_comments() {
917 let rule = MD018NoMissingSpaceAtx::new();
919
920 let content = r#"---
921#reviewers:
922#- sig-api-machinery
923#another_comment: value
924title: Test Document
925---
926
927# Valid heading
928
929#invalid heading without space
930"#;
931
932 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
933 let result = rule.check(&ctx).unwrap();
934
935 assert_eq!(
938 result.len(),
939 1,
940 "Should only flag the malformed heading outside frontmatter"
941 );
942 assert_eq!(result[0].line, 10, "Should flag line 10");
943 }
944
945 #[test]
946 fn test_skip_html_comments() {
947 let rule = MD018NoMissingSpaceAtx::new();
950
951 let content = r#"# Real Heading
952
953Some text.
954
955<!--
956```
957#%% Cell marker
958import matplotlib.pyplot as plt
959
960#%% Another cell
961data = [1, 2, 3]
962```
963-->
964
965More content.
966"#;
967
968 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
969 let result = rule.check(&ctx).unwrap();
970
971 assert!(
973 result.is_empty(),
974 "Should not flag content inside HTML comments, found {} issues",
975 result.len()
976 );
977 }
978
979 #[test]
980 fn test_mkdocs_magiclink_skips_numeric_refs() {
981 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
983 magiclink: true,
984 ..Default::default()
985 });
986
987 assert!(
989 rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_none(),
990 "#10 should be skipped with magiclink config (MagicLink issue ref)"
991 );
992 assert!(
993 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_none(),
994 "#123 should be skipped with magiclink config (MagicLink issue ref)"
995 );
996 assert!(
997 rule.check_atx_heading_line("#10 discusses the issue", MarkdownFlavor::Standard)
998 .is_none(),
999 "#10 followed by text should be skipped with magiclink config"
1000 );
1001 assert!(
1002 rule.check_atx_heading_line("#37.", MarkdownFlavor::Standard).is_none(),
1003 "#37 followed by punctuation should be skipped with magiclink config"
1004 );
1005 }
1006
1007 #[test]
1008 fn test_mkdocs_magiclink_still_flags_non_numeric() {
1009 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1011 magiclink: true,
1012 ..Default::default()
1013 });
1014
1015 assert!(
1017 rule.check_atx_heading_line("#Summary", MarkdownFlavor::Standard)
1018 .is_some(),
1019 "#Summary should still be flagged with magiclink config"
1020 );
1021 assert!(
1022 rule.check_atx_heading_line("#hello", MarkdownFlavor::Standard)
1023 .is_some(),
1024 "#hello should still be flagged with magiclink config"
1025 );
1026 assert!(
1027 rule.check_atx_heading_line("#10abc", MarkdownFlavor::Standard)
1028 .is_some(),
1029 "#10abc (mixed) should still be flagged with magiclink config"
1030 );
1031 }
1032
1033 #[test]
1034 fn test_mkdocs_magiclink_only_single_hash() {
1035 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1037 magiclink: true,
1038 ..Default::default()
1039 });
1040
1041 assert!(
1042 rule.check_atx_heading_line("##10", MarkdownFlavor::Standard).is_some(),
1043 "##10 should be flagged with magiclink config (only single # is MagicLink)"
1044 );
1045 assert!(
1046 rule.check_atx_heading_line("###123", MarkdownFlavor::Standard)
1047 .is_some(),
1048 "###123 should be flagged with magiclink config"
1049 );
1050 }
1051
1052 #[test]
1053 fn test_standard_flavor_flags_numeric_refs() {
1054 let rule = MD018NoMissingSpaceAtx::new();
1056
1057 assert!(
1058 rule.check_atx_heading_line("#10", MarkdownFlavor::Standard).is_some(),
1059 "#10 should be flagged in Standard flavor"
1060 );
1061 assert!(
1062 rule.check_atx_heading_line("#123", MarkdownFlavor::Standard).is_some(),
1063 "#123 should be flagged in Standard flavor"
1064 );
1065 }
1066
1067 #[test]
1068 fn test_mkdocs_magiclink_full_check() {
1069 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1071 magiclink: true,
1072 ..Default::default()
1073 });
1074
1075 let content = r#"# PRs that are helpful for context
1076
1077#10 discusses the philosophy behind the project, and #37 shows a good example.
1078
1079#Summary
1080
1081##Introduction
1082"#;
1083
1084 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1086 let result = rule.check(&ctx).unwrap();
1087
1088 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1089 assert!(
1090 !flagged_lines.contains(&3),
1091 "#10 should NOT be flagged with magiclink config"
1092 );
1093 assert!(
1094 flagged_lines.contains(&5),
1095 "#Summary SHOULD be flagged with magiclink config"
1096 );
1097 assert!(
1098 flagged_lines.contains(&7),
1099 "##Introduction SHOULD be flagged with magiclink config"
1100 );
1101 }
1102
1103 #[test]
1104 fn test_mkdocs_magiclink_fix_exact_output() {
1105 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1107 magiclink: true,
1108 ..Default::default()
1109 });
1110
1111 let content = "#10 discusses the issue.\n\n#Summary";
1112 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1113 let fixed = rule.fix(&ctx).unwrap();
1114
1115 let expected = "#10 discusses the issue.\n\n# Summary";
1117 assert_eq!(
1118 fixed, expected,
1119 "magiclink config fix should preserve MagicLink refs and fix non-numeric headings"
1120 );
1121 }
1122
1123 #[test]
1124 fn test_mkdocs_magiclink_edge_cases() {
1125 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1127 magiclink: true,
1128 ..Default::default()
1129 });
1130
1131 let valid_refs = [
1134 "#10", "#999999", "#10 text after", "#10\ttext after", "#10.", "#10,", "#10!", "#10?", "#10)", "#10]", "#10;", "#10:", ];
1147
1148 for ref_str in valid_refs {
1149 assert!(
1150 rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_none(),
1151 "{ref_str:?} should be skipped as MagicLink ref with magiclink config"
1152 );
1153 }
1154
1155 let invalid_refs = [
1157 "#10abc", "#10a", "#abc10", "#10ABC", "#Summary", "#hello", ];
1164
1165 for ref_str in invalid_refs {
1166 assert!(
1167 rule.check_atx_heading_line(ref_str, MarkdownFlavor::Standard).is_some(),
1168 "{ref_str:?} should be flagged with magiclink config (not a valid MagicLink ref)"
1169 );
1170 }
1171 }
1172
1173 #[test]
1174 fn test_mkdocs_magiclink_hyphenated_continuation() {
1175 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1178 magiclink: true,
1179 ..Default::default()
1180 });
1181
1182 assert!(
1187 rule.check_atx_heading_line("#10-", MarkdownFlavor::Standard).is_none(),
1188 "#10- should be skipped with magiclink config (hyphen is non-alphanumeric terminator)"
1189 );
1190 }
1191
1192 #[test]
1193 fn test_mkdocs_magiclink_standalone_number() {
1194 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1196 magiclink: true,
1197 ..Default::default()
1198 });
1199
1200 let content = "See issue:\n\n#10\n\nFor details.";
1201 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1202 let result = rule.check(&ctx).unwrap();
1203
1204 assert!(
1206 result.is_empty(),
1207 "Standalone #10 should not be flagged with magiclink config"
1208 );
1209
1210 let fixed = rule.fix(&ctx).unwrap();
1212 assert_eq!(fixed, content, "fix() should not modify standalone MagicLink ref");
1213 }
1214
1215 #[test]
1216 fn test_standard_flavor_flags_all_numeric() {
1217 let rule = MD018NoMissingSpaceAtx::new();
1220
1221 let numeric_patterns = ["#10", "#123", "#999999", "#10 text"];
1222
1223 for pattern in numeric_patterns {
1224 assert!(
1225 rule.check_atx_heading_line(pattern, MarkdownFlavor::Standard).is_some(),
1226 "{pattern:?} should be flagged in Standard flavor"
1227 );
1228 }
1229
1230 assert!(
1232 rule.check_atx_heading_line("#1", MarkdownFlavor::Standard).is_none(),
1233 "#1 should be skipped (content too short, existing behavior)"
1234 );
1235 }
1236
1237 #[test]
1238 fn test_mkdocs_vs_standard_fix_comparison() {
1239 let content = "#10 is an issue\n#Summary";
1241 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1242
1243 let rule_magiclink = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1245 magiclink: true,
1246 ..Default::default()
1247 });
1248 let fixed_magiclink = rule_magiclink.fix(&ctx).unwrap();
1249 assert_eq!(fixed_magiclink, "#10 is an issue\n# Summary");
1250
1251 let rule_default = MD018NoMissingSpaceAtx::new();
1253 let fixed_default = rule_default.fix(&ctx).unwrap();
1254 assert_eq!(fixed_default, "# 10 is an issue\n# Summary");
1255 }
1256
1257 #[test]
1260 fn test_tags_config_standard_flavor() {
1261 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1263 magiclink: false,
1264 tags: Some(true),
1265 });
1266
1267 let content = "#tag\n\n#project/active\n\n##Introduction";
1268 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1269 let result = rule.check(&ctx).unwrap();
1270
1271 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1272 assert!(!flagged_lines.contains(&1), "#tag should be skipped with tags = true");
1273 assert!(
1274 !flagged_lines.contains(&3),
1275 "#project/active should be skipped with tags = true"
1276 );
1277 assert!(flagged_lines.contains(&5), "##Introduction should still be flagged");
1278 }
1279
1280 #[test]
1281 fn test_tags_config_fix_standard_flavor() {
1282 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1283 magiclink: false,
1284 tags: Some(true),
1285 });
1286
1287 let content = "#tag\n\n##Introduction";
1288 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1289 let fixed = rule.fix(&ctx).unwrap();
1290 assert_eq!(fixed, "#tag\n\n## Introduction");
1291 }
1292
1293 #[test]
1294 fn test_tags_config_disabled_obsidian_flavor() {
1295 let rule = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1297 magiclink: false,
1298 tags: Some(false),
1299 });
1300
1301 let content = "#tag\n\n#project/active";
1302 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1303 let result = rule.check(&ctx).unwrap();
1304
1305 assert_eq!(
1306 result.len(),
1307 2,
1308 "tags = false should flag tag patterns even in Obsidian"
1309 );
1310 }
1311
1312 #[test]
1313 fn test_tags_config_default_follows_flavor() {
1314 let rule = MD018NoMissingSpaceAtx::new(); let content = "#tag";
1319 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1320 let result = rule.check(&ctx).unwrap();
1321 assert!(!result.is_empty(), "Default standard should flag #tag");
1322
1323 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1325 let result = rule.check(&ctx).unwrap();
1326 assert!(result.is_empty(), "Default Obsidian should skip #tag");
1327 }
1328
1329 #[test]
1332 fn test_obsidian_tag_skips_simple_tags() {
1333 let rule = MD018NoMissingSpaceAtx::new();
1335
1336 assert!(
1338 rule.check_atx_heading_line("#hey", MarkdownFlavor::Obsidian).is_none(),
1339 "#hey should be skipped in Obsidian flavor (tag syntax)"
1340 );
1341 assert!(
1342 rule.check_atx_heading_line("#tag", MarkdownFlavor::Obsidian).is_none(),
1343 "#tag should be skipped in Obsidian flavor"
1344 );
1345 assert!(
1346 rule.check_atx_heading_line("#hello", MarkdownFlavor::Obsidian)
1347 .is_none(),
1348 "#hello should be skipped in Obsidian flavor"
1349 );
1350 assert!(
1351 rule.check_atx_heading_line("#myTag", MarkdownFlavor::Obsidian)
1352 .is_none(),
1353 "#myTag should be skipped in Obsidian flavor"
1354 );
1355 }
1356
1357 #[test]
1358 fn test_obsidian_tag_skips_complex_tags() {
1359 let rule = MD018NoMissingSpaceAtx::new();
1361
1362 assert!(
1364 rule.check_atx_heading_line("#project/active", MarkdownFlavor::Obsidian)
1365 .is_none(),
1366 "#project/active should be skipped in Obsidian flavor (nested tag)"
1367 );
1368 assert!(
1369 rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1370 .is_none(),
1371 "#my-tag should be skipped in Obsidian flavor"
1372 );
1373 assert!(
1374 rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1375 .is_none(),
1376 "#my_tag should be skipped in Obsidian flavor"
1377 );
1378 assert!(
1379 rule.check_atx_heading_line("#tag2023", MarkdownFlavor::Obsidian)
1380 .is_none(),
1381 "#tag2023 should be skipped in Obsidian flavor"
1382 );
1383 assert!(
1384 rule.check_atx_heading_line("#project/sub/task", MarkdownFlavor::Obsidian)
1385 .is_none(),
1386 "#project/sub/task should be skipped in Obsidian flavor"
1387 );
1388 }
1389
1390 #[test]
1391 fn test_obsidian_tag_with_trailing_content() {
1392 let rule = MD018NoMissingSpaceAtx::new();
1394
1395 assert!(
1396 rule.check_atx_heading_line("#hey ", MarkdownFlavor::Obsidian).is_none(),
1397 "#hey followed by space should be skipped"
1398 );
1399 assert!(
1400 rule.check_atx_heading_line("#tag some text", MarkdownFlavor::Obsidian)
1401 .is_none(),
1402 "#tag followed by text should be skipped"
1403 );
1404 }
1405
1406 #[test]
1407 fn test_obsidian_tag_still_flags_multi_hash() {
1408 let rule = MD018NoMissingSpaceAtx::new();
1410
1411 assert!(
1412 rule.check_atx_heading_line("##tag", MarkdownFlavor::Obsidian).is_some(),
1413 "##tag should be flagged in Obsidian flavor (only single # is a tag)"
1414 );
1415 assert!(
1416 rule.check_atx_heading_line("###hello", MarkdownFlavor::Obsidian)
1417 .is_some(),
1418 "###hello should be flagged in Obsidian flavor"
1419 );
1420 }
1421
1422 #[test]
1423 fn test_obsidian_tag_numeric_still_flagged() {
1424 let rule = MD018NoMissingSpaceAtx::new();
1427
1428 assert!(
1429 rule.check_atx_heading_line("#123", MarkdownFlavor::Obsidian).is_some(),
1430 "#123 should be flagged in Obsidian flavor (no non-numerical character)"
1431 );
1432 assert!(
1433 rule.check_atx_heading_line("#10", MarkdownFlavor::Obsidian).is_some(),
1434 "#10 should be flagged in Obsidian flavor"
1435 );
1436 }
1437
1438 #[test]
1439 fn test_obsidian_flavor_full_check() {
1440 let rule = MD018NoMissingSpaceAtx::new();
1442
1443 let content = r#"# Real Heading
1444
1445#hey this is a tag
1446
1447#project/active also a tag
1448
1449##Introduction
1450
1451#123
1452"#;
1453
1454 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1456 let result = rule.check(&ctx).unwrap();
1457
1458 let flagged_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1459 assert!(
1460 !flagged_lines.contains(&3),
1461 "#hey should NOT be flagged in Obsidian flavor"
1462 );
1463 assert!(
1464 !flagged_lines.contains(&5),
1465 "#project/active should NOT be flagged in Obsidian flavor"
1466 );
1467 assert!(
1468 flagged_lines.contains(&7),
1469 "##Introduction SHOULD be flagged in Obsidian flavor"
1470 );
1471 assert!(flagged_lines.contains(&9), "#123 SHOULD be flagged in Obsidian flavor");
1472 }
1473
1474 #[test]
1475 fn test_obsidian_flavor_fix_exact_output() {
1476 let rule = MD018NoMissingSpaceAtx::new();
1478
1479 let content = "#hey is a tag.\n\n##Introduction";
1482 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1483 let fixed = rule.fix(&ctx).unwrap();
1484
1485 let expected = "#hey is a tag.\n\n## Introduction";
1487 assert_eq!(
1488 fixed, expected,
1489 "Obsidian fix should preserve tags and fix multi-hash headings"
1490 );
1491 }
1492
1493 #[test]
1494 fn test_standard_flavor_flags_obsidian_tags() {
1495 let rule = MD018NoMissingSpaceAtx::new();
1497
1498 assert!(
1499 rule.check_atx_heading_line("#hey", MarkdownFlavor::Standard).is_some(),
1500 "#hey should be flagged in Standard flavor"
1501 );
1502 assert!(
1503 rule.check_atx_heading_line("#tag", MarkdownFlavor::Standard).is_some(),
1504 "#tag should be flagged in Standard flavor"
1505 );
1506 assert!(
1507 rule.check_atx_heading_line("#project/active", MarkdownFlavor::Standard)
1508 .is_some(),
1509 "#project/active should be flagged in Standard flavor"
1510 );
1511 }
1512
1513 #[test]
1514 fn test_obsidian_vs_standard_fix_comparison() {
1515 let rule = MD018NoMissingSpaceAtx::new();
1517
1518 let content = "#hey tag\n##Introduction";
1522
1523 let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1525 let fixed_obsidian = rule.fix(&ctx_obsidian).unwrap();
1526 assert_eq!(fixed_obsidian, "#hey tag\n## Introduction");
1527
1528 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1530 let fixed_standard = rule.fix(&ctx_standard).unwrap();
1531 assert_eq!(fixed_standard, "# hey tag\n## Introduction");
1532 }
1533
1534 #[test]
1535 fn test_obsidian_tag_edge_cases() {
1536 let rule = MD018NoMissingSpaceAtx::new();
1538
1539 let valid_tags = [
1541 "#a", "#tag", "#Tag", "#TAG", "#my-tag", "#my_tag", "#tag123", "#a1", "#日本語", "#über", ];
1552
1553 for tag in valid_tags {
1554 assert!(
1555 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1556 "{tag:?} should be skipped in Obsidian flavor (valid tag)"
1557 );
1558 }
1559
1560 let invalid_tags = ["#123", "#1984", "#37.", "#42,"];
1563
1564 for tag in invalid_tags {
1565 assert!(
1566 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_some(),
1567 "{tag:?} should be flagged in Obsidian flavor (no non-numerical character)"
1568 );
1569 }
1570 }
1571
1572 #[test]
1573 fn test_obsidian_tag_alone_on_line() {
1574 let rule = MD018NoMissingSpaceAtx::new();
1576
1577 let content = "Some text\n\n#todo\n\nMore text.";
1578 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1579 let result = rule.check(&ctx).unwrap();
1580
1581 assert!(
1583 result.is_empty(),
1584 "Standalone #todo should not be flagged in Obsidian flavor"
1585 );
1586
1587 let fixed = rule.fix(&ctx).unwrap();
1589 assert_eq!(fixed, content, "fix() should not modify standalone Obsidian tag");
1590 }
1591
1592 #[test]
1593 fn test_obsidian_deeply_nested_tags() {
1594 let rule = MD018NoMissingSpaceAtx::new();
1596
1597 let nested_tags = [
1598 "#a/b",
1599 "#a/b/c",
1600 "#project/2023/q1/task",
1601 "#work/meetings/weekly",
1602 "#life/health/exercise/running",
1603 ];
1604
1605 for tag in nested_tags {
1606 assert!(
1607 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1608 "{tag:?} should be skipped in Obsidian flavor (nested tag)"
1609 );
1610 }
1611 }
1612
1613 #[test]
1614 fn test_obsidian_unicode_tags() {
1615 let rule = MD018NoMissingSpaceAtx::new();
1617
1618 let unicode_tags = [
1619 "#日本語", "#中文", "#한국어", "#über", "#café", "#ñoño", "#Москва", "#αβγ", ];
1628
1629 for tag in unicode_tags {
1630 assert!(
1631 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1632 "{tag:?} should be skipped in Obsidian flavor (Unicode tag)"
1633 );
1634 }
1635 }
1636
1637 #[test]
1638 fn test_obsidian_tags_with_special_endings() {
1639 let rule = MD018NoMissingSpaceAtx::new();
1641
1642 assert!(
1644 rule.check_atx_heading_line("#tag followed by text", MarkdownFlavor::Obsidian)
1645 .is_none(),
1646 "#tag followed by text should be skipped"
1647 );
1648
1649 let content = "#todo";
1651 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1652 let result = rule.check(&ctx).unwrap();
1653 assert!(result.is_empty(), "#todo at end of line should be skipped");
1654 }
1655
1656 #[test]
1657 fn test_obsidian_combined_with_other_skip_contexts() {
1658 let rule = MD018NoMissingSpaceAtx::new();
1660
1661 let content = "```\n#todo\n```";
1663 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1664 let result = rule.check(&ctx).unwrap();
1665 assert!(result.is_empty(), "Tag in code block should be skipped");
1666
1667 let content = "<!-- #todo -->";
1669 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1670 let result = rule.check(&ctx).unwrap();
1671 assert!(result.is_empty(), "Tag in HTML comment should be skipped");
1672 }
1673
1674 #[test]
1675 fn test_obsidian_boundary_cases() {
1676 let rule = MD018NoMissingSpaceAtx::new();
1678
1679 assert!(
1683 rule.check_atx_heading_line("#ab", MarkdownFlavor::Obsidian).is_none(),
1684 "#ab should be skipped in Obsidian flavor"
1685 );
1686
1687 assert!(
1689 rule.check_atx_heading_line("#my_tag", MarkdownFlavor::Obsidian)
1690 .is_none(),
1691 "#my_tag should be skipped"
1692 );
1693
1694 assert!(
1696 rule.check_atx_heading_line("#my-tag", MarkdownFlavor::Obsidian)
1697 .is_none(),
1698 "#my-tag should be skipped"
1699 );
1700
1701 assert!(
1703 rule.check_atx_heading_line("#MyTag", MarkdownFlavor::Obsidian)
1704 .is_none(),
1705 "#MyTag should be skipped"
1706 );
1707
1708 assert!(
1710 rule.check_atx_heading_line("#TODO", MarkdownFlavor::Obsidian).is_none(),
1711 "#TODO should be skipped in Obsidian flavor"
1712 );
1713 }
1714
1715 #[test]
1716 fn test_obsidian_tag_may_start_with_a_digit() {
1717 let rule = MD018NoMissingSpaceAtx::new();
1720
1721 let tags = [
1722 "#3d_printing", "#1tag", "#2023-project", "#100DaysOfCode", "#1on1", "#5S", "#3/4", "#1_2", "#3🔥", "#3\u{FE0F}\u{20E3}", ];
1733
1734 for tag in tags {
1735 assert!(
1736 rule.check_atx_heading_line(tag, MarkdownFlavor::Obsidian).is_none(),
1737 "{tag:?} contains a non-numerical character and should be skipped as a tag"
1738 );
1739 }
1740 }
1741
1742 #[test]
1743 fn test_numeric_references_are_not_tags() {
1744 let rule = MD018NoMissingSpaceAtx::new();
1748
1749 let not_tags = [
1750 "#1984", "#123", "#10", "#37.", "#42,", "#42)", "#404 Not Found", "#10 discusses the issue", ];
1759
1760 for line in not_tags {
1761 assert!(
1762 rule.check_atx_heading_line(line, MarkdownFlavor::Obsidian).is_some(),
1763 "{line:?} has no non-numerical tag character and should stay flagged"
1764 );
1765 }
1766 }
1767
1768 #[test]
1769 fn test_digit_leading_tag_survives_check_and_fix() {
1770 let obsidian = MD018NoMissingSpaceAtx::new();
1773 let standard = MD018NoMissingSpaceAtx::from_config_struct(MD018Config {
1774 magiclink: false,
1775 tags: Some(true),
1776 });
1777
1778 let content = "# Header\n\n#3d_printing\n\n##Heading\n";
1781
1782 for (rule, flavor, label) in [
1783 (&obsidian, MarkdownFlavor::Obsidian, "obsidian flavor"),
1784 (&standard, MarkdownFlavor::Standard, "tags = true"),
1785 ] {
1786 let ctx = LintContext::new(content, flavor, None);
1787 let flagged: Vec<usize> = rule.check(&ctx).unwrap().iter().map(|w| w.line).collect();
1788 assert_eq!(
1789 flagged,
1790 vec![5],
1791 "{label}: only ##Heading should be flagged, got {flagged:?}"
1792 );
1793
1794 let fixed = rule.fix(&ctx).unwrap();
1795 assert_eq!(
1796 fixed, "# Header\n\n#3d_printing\n\n## Heading\n",
1797 "{label}: fix must leave the tag alone and still fix the heading"
1798 );
1799 }
1800 }
1801
1802 #[test]
1803 fn test_digit_leading_tag_is_still_flagged_without_tags_mode() {
1804 let rule = MD018NoMissingSpaceAtx::new();
1807
1808 assert!(
1809 rule.check_atx_heading_line("#3d_printing", MarkdownFlavor::Standard)
1810 .is_some(),
1811 "#3d_printing should be flagged in Standard flavor (tags disabled)"
1812 );
1813 }
1814
1815 fn check_and_fix(content: &str) -> (Vec<(usize, usize)>, String) {
1816 let rule = MD018NoMissingSpaceAtx::new();
1817 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1818 let warnings = rule.check(&ctx).unwrap();
1819 let fixed = rule.fix(&ctx).unwrap();
1820 (warnings.iter().map(|w| (w.line, w.column)).collect(), fixed)
1821 }
1822
1823 #[test]
1824 fn test_quoted_missing_space_is_flagged_and_fixed_inside_the_quote() {
1825 let cases = [
1828 ("> #Bad\n", vec![(1, 4)], "> # Bad\n"),
1829 ("> > ##Bad\n", vec![(1, 7)], "> > ## Bad\n"),
1830 (">#Bad\n", vec![(1, 3)], "># Bad\n"),
1831 ("> text\n> #Bad\n", vec![(2, 4)], "> text\n> # Bad\n"),
1832 ("> > #Bad\n> > text\n", vec![(1, 6)], "> > # Bad\n> > text\n"),
1833 ("> é #x\n> #Bad\n", vec![(2, 4)], "> é #x\n> # Bad\n"),
1834 ("- text\n\n > #Bad\n", vec![(3, 8)], "- text\n\n > # Bad\n"),
1835 ];
1836 for (input, flagged, fixed) in cases {
1837 let (got, out) = check_and_fix(input);
1838 assert_eq!(got, flagged, "flagged positions for {input:?}");
1839 assert_eq!(out, fixed, "fix for {input:?}");
1840 }
1841 }
1842
1843 #[test]
1844 fn test_quoted_missing_space_leaves_non_candidates_alone() {
1845 for input in [
1846 "> # Good\n",
1847 "> #1\n",
1848 "> ##\n",
1849 "> #*emphasis\n",
1850 "> #\u{fe0f}\u{20e3} x\n",
1851 "> > #Bad\n",
1852 "> text\n> #Bad\n",
1853 "> ```\n> #Bad\n> ```\n",
1854 "> `#Bad` text\n",
1855 "> Title\n> ===\n",
1856 "> #Bad\n> ===\n",
1857 "> <!--\n> #Bad\n> -->\n",
1858 "> [text\n> #sig](url)\n",
1859 "> `code\n> #Bad` text\n",
1860 "> <a\n> #href>text</a>\n",
1861 ] {
1862 let (got, out) = check_and_fix(input);
1863 assert!(got.is_empty(), "{input:?} must not be flagged, got {got:?}");
1864 assert_eq!(out, input, "{input:?} must not change");
1865 }
1866 }
1867
1868 #[test]
1869 fn test_quote_inside_list_item_is_checked_and_fix_keeps_the_prefix() {
1870 let rule = MD018NoMissingSpaceAtx::new();
1871 for (prefix, col) in [
1872 ("- > ", 6),
1873 ("1. > ", 7),
1874 ("- - > ", 8),
1875 ("> - > ", 8),
1876 ("> 1. - > ", 11),
1877 ] {
1878 let content = format!("# T\n\n{prefix}#Bad\n");
1879 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1880 let result = rule.check(&ctx).unwrap();
1881 assert_eq!(result.len(), 1, "{content:?}: {result:?}");
1882 assert_eq!((result[0].line, result[0].column), (3, col), "{content:?}");
1883 assert_eq!(rule.fix(&ctx).unwrap(), format!("# T\n\n{prefix}# Bad\n"));
1884 }
1885 }
1886
1887 #[test]
1888 fn test_list_item_content_that_is_not_a_quote_heading_candidate_is_left_alone() {
1889 let rule = MD018NoMissingSpaceAtx::new();
1890 for content in [
1891 "# T\n\n- > #Bad\n",
1892 "# T\n\n- > # Good\n",
1893 "# T\n\n- #Bad\n",
1894 "# T\n\nx\n2. > #Bad\n",
1895 ] {
1896 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1897 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1898 }
1899 }
1900
1901 #[test]
1902 fn test_quote_in_list_item_allows_four_spaces_after_the_marker_and_spares_setext_text() {
1903 let rule = MD018NoMissingSpaceAtx::new();
1904 let content = "# T\n\n- > #Bad\n";
1905 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1906 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1907 assert_eq!(rule.fix(&ctx).unwrap(), "# T\n\n- > # Bad\n");
1908 let content = "# T\n\n- > #Bad\n > ---\n";
1910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1911 assert!(rule.check(&ctx).unwrap().is_empty());
1912 }
1913
1914 #[test]
1915 fn test_quote_in_list_item_measures_a_tab_after_the_marker_at_its_tab_stop() {
1916 let rule = MD018NoMissingSpaceAtx::new();
1917 for content in [" - > #Bad\n", " -\t> #Bad\n", " -\t > #Bad\n"] {
1918 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1919 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{content:?}");
1920 }
1921 }
1922
1923 #[test]
1924 fn test_indented_quote_after_a_list_ended_by_an_unindented_quote_is_paragraph_text() {
1925 let rule = MD018NoMissingSpaceAtx::new();
1926 let content = "- item\n> text\n > #Bad\n";
1927 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1928 assert!(rule.check(&ctx).unwrap().is_empty());
1929 }
1930}