1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::mkdocs_patterns::is_mkdocs_auto_reference;
3
4#[derive(Clone, Default)]
52pub struct MD042NoEmptyLinks {}
53
54impl MD042NoEmptyLinks {
55 pub fn new() -> Self {
56 Self {}
57 }
58
59 fn strip_backticks(s: &str) -> &str {
62 s.trim_start_matches('`').trim_end_matches('`')
63 }
64
65 fn is_valid_python_identifier(s: &str) -> bool {
68 if s.is_empty() {
69 return false;
70 }
71
72 let first_char = s.chars().next().unwrap();
73 if !first_char.is_ascii_alphabetic() && first_char != '_' {
74 return false;
75 }
76
77 s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
78 }
79
80 fn is_mkdocs_attribute_anchor(content: &str, link_end: usize) -> bool {
87 if !content.is_char_boundary(link_end) {
89 return false;
90 }
91
92 if let Some(rest) = content.get(link_end..) {
94 let trimmed = rest.trim_start();
98
99 let stripped = if let Some(s) = trimmed.strip_prefix("{:") {
101 s
102 } else if let Some(s) = trimmed.strip_prefix('{') {
103 s
104 } else {
105 return false;
106 };
107
108 if let Some(end_brace) = stripped.find('}') {
110 if end_brace > 500 {
112 return false;
113 }
114
115 let attrs = stripped[..end_brace].trim();
116
117 if attrs.is_empty() {
119 return false;
120 }
121
122 return attrs
126 .split_whitespace()
127 .any(|part| part.starts_with('#') || part.starts_with('.'));
128 }
129 }
130 false
131 }
132}
133
134impl Rule for MD042NoEmptyLinks {
135 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
136 Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(42))
137 }
138
139 fn name(&self) -> &'static str {
140 "MD042"
141 }
142
143 fn description(&self) -> &'static str {
144 "No empty links"
145 }
146
147 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
148 let mut warnings = Vec::new();
149
150 let mkdocs_mode = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
152 let pandoc_mode = ctx.flavor.is_pandoc_compatible();
153
154 for link in ctx.links() {
156 if ctx.line_info(link.line).is_some_and(|info| info.in_front_matter) {
158 continue;
159 }
160
161 if ctx.is_in_jinja_range(link.byte_offset) {
163 continue;
164 }
165
166 if pandoc_mode && ctx.is_in_citation(link.byte_offset) {
169 continue;
170 }
171
172 if ctx.is_in_shortcode(link.byte_offset) {
175 continue;
176 }
177
178 let in_html_tag = ctx
181 .html_tags()
182 .iter()
183 .any(|html_tag| html_tag.byte_offset <= link.byte_offset && link.byte_offset < html_tag.byte_end);
184 if in_html_tag {
185 continue;
186 }
187
188 let (effective_url, is_undefined_reference): (&str, bool) = if link.is_reference {
195 if let Some(ref_id) = &link.reference_id {
196 match ctx.get_reference_url(ref_id.as_ref()) {
197 Some(url) => (url, false),
198 None => ("", true), }
200 } else {
201 ("", false) }
203 } else {
204 (&link.url, false)
205 };
206
207 if mkdocs_mode && link.is_reference {
212 if let Some(ref_id) = &link.reference_id {
214 let stripped_ref = Self::strip_backticks(ref_id);
215 if is_mkdocs_auto_reference(stripped_ref)
218 || (ref_id != stripped_ref && Self::is_valid_python_identifier(stripped_ref))
219 {
220 continue;
221 }
222 }
223 let stripped_text = Self::strip_backticks(&link.text);
225 if is_mkdocs_auto_reference(stripped_text)
227 || (link.text.as_ref() != stripped_text && Self::is_valid_python_identifier(stripped_text))
228 {
229 continue;
230 }
231 }
232
233 let link_markdown = &ctx.content[link.byte_offset..link.byte_end];
237 if link_markdown.starts_with('<') && link_markdown.ends_with('>') {
238 continue;
239 }
240
241 if link_markdown.starts_with("[[")
253 && link_markdown.ends_with(']')
254 && ctx.content.as_bytes().get(link.byte_end) == Some(&b']')
255 {
256 continue;
257 }
258
259 if is_undefined_reference && !link.text.trim().is_empty() {
262 continue;
263 }
264
265 let trimmed_url = effective_url.trim();
269 if trimmed_url.is_empty() || trimmed_url == "#" {
270 if mkdocs_mode
272 && link.text.trim().is_empty()
273 && Self::is_mkdocs_attribute_anchor(ctx.content, link.byte_end)
274 {
275 continue;
277 }
278
279 let replacement = if !link.text.trim().is_empty() {
282 let text_is_url = link.text.starts_with("http://")
283 || link.text.starts_with("https://")
284 || link.text.starts_with("ftp://")
285 || link.text.starts_with("ftps://");
286
287 if text_is_url {
288 Some(format!("[{}]({})", link.text, link.text))
289 } else {
290 None
292 }
293 } else {
294 None
296 };
297
298 let link_display = &ctx.content[link.byte_offset..link.byte_end];
300
301 warnings.push(LintWarning {
302 rule_name: Some(self.name().to_string()),
303 message: format!("Empty link found: {link_display}"),
304 line: link.line,
305 column: link.start_col + 1, end_line: link.end_line,
307 end_column: link.end_col + 1, severity: Severity::Error,
309 fix: replacement.map(|r| Fix::new(link.byte_offset..link.byte_end, r)),
310 });
311 }
312 }
313
314 Ok(warnings)
315 }
316
317 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
318 let content = ctx.content;
319
320 let warnings = self.check(ctx)?;
322 let warnings =
323 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
324 if warnings.is_empty() {
325 return Ok(content.to_string());
326 }
327
328 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
330 .iter()
331 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.clone(), f.replacement.clone())))
332 .collect();
333
334 fixes.sort_by_key(|f| std::cmp::Reverse(f.0.start));
336
337 let mut result = content.to_string();
338
339 for (range, replacement) in fixes {
341 result.replace_range(range, &replacement);
342 }
343
344 Ok(result)
345 }
346
347 fn category(&self) -> RuleCategory {
349 RuleCategory::Link
350 }
351
352 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
354 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
355 }
356
357 fn as_any(&self) -> &dyn std::any::Any {
358 self
359 }
360
361 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
362 where
363 Self: Sized,
364 {
365 Box::new(MD042NoEmptyLinks::new())
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use crate::lint_context::LintContext;
374
375 #[test]
376 fn test_links_with_text_should_pass() {
377 let ctx = LintContext::new(
378 "[valid link](https://example.com)",
379 crate::config::MarkdownFlavor::Standard,
380 None,
381 );
382 let rule = MD042NoEmptyLinks::new();
383 let result = rule.check(&ctx).unwrap();
384 assert!(result.is_empty(), "Links with text should pass");
385
386 let ctx = LintContext::new(
387 "[another valid link](path/to/page.html)",
388 crate::config::MarkdownFlavor::Standard,
389 None,
390 );
391 let result = rule.check(&ctx).unwrap();
392 assert!(result.is_empty(), "Links with text and relative URLs should pass");
393 }
394
395 #[test]
396 fn test_links_with_empty_text_but_valid_url_pass() {
397 let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
400 let rule = MD042NoEmptyLinks::new();
401 let result = rule.check(&ctx).unwrap();
402 assert!(
403 result.is_empty(),
404 "Empty text with valid URL should NOT be flagged by MD042. Got: {result:?}"
405 );
406 }
407
408 #[test]
409 fn test_links_with_only_whitespace_but_valid_url_pass() {
410 let ctx = LintContext::new(
412 "[ ](https://example.com)",
413 crate::config::MarkdownFlavor::Standard,
414 None,
415 );
416 let rule = MD042NoEmptyLinks::new();
417 let result = rule.check(&ctx).unwrap();
418 assert!(
419 result.is_empty(),
420 "Whitespace text with valid URL should NOT be flagged. Got: {result:?}"
421 );
422
423 let ctx = LintContext::new(
424 "[\t\n](https://example.com)",
425 crate::config::MarkdownFlavor::Standard,
426 None,
427 );
428 let result = rule.check(&ctx).unwrap();
429 assert!(
430 result.is_empty(),
431 "Whitespace text with valid URL should NOT be flagged. Got: {result:?}"
432 );
433 }
434
435 #[test]
436 fn test_reference_links_with_empty_text_but_valid_ref() {
437 let ctx = LintContext::new(
440 "[][ref]\n\n[ref]: https://example.com",
441 crate::config::MarkdownFlavor::Standard,
442 None,
443 );
444 let rule = MD042NoEmptyLinks::new();
445 let result = rule.check(&ctx).unwrap();
446 assert!(
447 result.is_empty(),
448 "Empty text with valid reference should NOT be flagged. Got: {result:?}"
449 );
450
451 }
454
455 #[test]
456 fn test_images_should_be_ignored() {
457 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
459 let rule = MD042NoEmptyLinks::new();
460 let result = rule.check(&ctx).unwrap();
461 assert!(result.is_empty(), "Images with empty alt text should be ignored");
462
463 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
464 let result = rule.check(&ctx).unwrap();
465 assert!(result.is_empty(), "Images with whitespace alt text should be ignored");
466 }
467
468 #[test]
469 fn test_links_with_nested_formatting() {
470 let rule = MD042NoEmptyLinks::new();
472
473 let ctx = LintContext::new(
475 "[**](https://example.com)",
476 crate::config::MarkdownFlavor::Standard,
477 None,
478 );
479 let result = rule.check(&ctx).unwrap();
480 assert!(result.is_empty(), "[**](url) has URL so should pass");
481
482 let ctx = LintContext::new(
484 "[__](https://example.com)",
485 crate::config::MarkdownFlavor::Standard,
486 None,
487 );
488 let result = rule.check(&ctx).unwrap();
489 assert!(result.is_empty(), "[__](url) has URL so should pass");
490
491 let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
493 let result = rule.check(&ctx).unwrap();
494 assert!(result.is_empty(), "[](url) has URL so should pass");
495
496 let ctx = LintContext::new(
498 "[**bold text**](https://example.com)",
499 crate::config::MarkdownFlavor::Standard,
500 None,
501 );
502 let result = rule.check(&ctx).unwrap();
503 assert!(result.is_empty(), "Links with nested formatting and text should pass");
504
505 let ctx = LintContext::new(
507 "[*italic* and **bold**](https://example.com)",
508 crate::config::MarkdownFlavor::Standard,
509 None,
510 );
511 let result = rule.check(&ctx).unwrap();
512 assert!(result.is_empty(), "Links with multiple nested formatting should pass");
513 }
514
515 #[test]
516 fn test_multiple_empty_links_on_same_line() {
517 let ctx = LintContext::new(
519 "[](url1) and [](url2) and [valid](url3)",
520 crate::config::MarkdownFlavor::Standard,
521 None,
522 );
523 let rule = MD042NoEmptyLinks::new();
524 let result = rule.check(&ctx).unwrap();
525 assert!(
526 result.is_empty(),
527 "Empty text with valid URL should NOT be flagged. Got: {result:?}"
528 );
529
530 let ctx = LintContext::new(
532 "[text1]() and [text2]() and [text3](url)",
533 crate::config::MarkdownFlavor::Standard,
534 None,
535 );
536 let result = rule.check(&ctx).unwrap();
537 assert_eq!(result.len(), 2, "Should detect both empty URL links");
538 assert_eq!(result[0].column, 1); assert_eq!(result[1].column, 15); }
541
542 #[test]
543 fn test_escaped_brackets() {
544 let ctx = LintContext::new(
546 "\\[\\](https://example.com)",
547 crate::config::MarkdownFlavor::Standard,
548 None,
549 );
550 let rule = MD042NoEmptyLinks::new();
551 let result = rule.check(&ctx).unwrap();
552 assert!(result.is_empty(), "Escaped brackets should not be treated as links");
553
554 let ctx = LintContext::new(
556 "[\\[\\]](https://example.com)",
557 crate::config::MarkdownFlavor::Standard,
558 None,
559 );
560 let result = rule.check(&ctx).unwrap();
561 assert!(result.is_empty(), "Link with escaped brackets in text should pass");
562 }
563
564 #[test]
565 fn test_links_in_lists_and_blockquotes() {
566 let rule = MD042NoEmptyLinks::new();
568
569 let ctx = LintContext::new(
571 "- [](https://example.com)\n- [valid](https://example.com)",
572 crate::config::MarkdownFlavor::Standard,
573 None,
574 );
575 let result = rule.check(&ctx).unwrap();
576 assert!(result.is_empty(), "[](url) in lists should pass");
577
578 let ctx = LintContext::new(
580 "> [](https://example.com)\n> [valid](https://example.com)",
581 crate::config::MarkdownFlavor::Standard,
582 None,
583 );
584 let result = rule.check(&ctx).unwrap();
585 assert!(result.is_empty(), "[](url) in blockquotes should pass");
586
587 let ctx = LintContext::new(
589 "- [text]()\n- [valid](url)",
590 crate::config::MarkdownFlavor::Standard,
591 None,
592 );
593 let result = rule.check(&ctx).unwrap();
594 assert_eq!(result.len(), 1, "Empty URL should be flagged");
595 assert_eq!(result[0].line, 1);
596 }
597
598 #[test]
599 fn test_unicode_whitespace_characters() {
600 let rule = MD042NoEmptyLinks::new();
603
604 let ctx = LintContext::new(
606 "[\u{00A0}](https://example.com)",
607 crate::config::MarkdownFlavor::Standard,
608 None,
609 );
610 let result = rule.check(&ctx).unwrap();
611 assert!(result.is_empty(), "Has URL, should pass regardless of text");
612
613 let ctx = LintContext::new(
615 "[\u{2003}](https://example.com)",
616 crate::config::MarkdownFlavor::Standard,
617 None,
618 );
619 let result = rule.check(&ctx).unwrap();
620 assert!(result.is_empty(), "Has URL, should pass regardless of text");
621
622 let ctx = LintContext::new(
624 "[\u{200B}](https://example.com)",
625 crate::config::MarkdownFlavor::Standard,
626 None,
627 );
628 let result = rule.check(&ctx).unwrap();
629 assert!(result.is_empty(), "Has URL, should pass regardless of text");
630
631 let ctx = LintContext::new(
633 "[ \u{200B} ](https://example.com)",
634 crate::config::MarkdownFlavor::Standard,
635 None,
636 );
637 let result = rule.check(&ctx).unwrap();
638 assert!(result.is_empty(), "Has URL, should pass regardless of text");
639 }
640
641 #[test]
642 fn test_empty_url_with_text() {
643 let ctx = LintContext::new("[some text]()", crate::config::MarkdownFlavor::Standard, None);
644 let rule = MD042NoEmptyLinks::new();
645 let result = rule.check(&ctx).unwrap();
646 assert_eq!(result.len(), 1);
647 assert_eq!(result[0].message, "Empty link found: [some text]()");
648 }
649
650 #[test]
651 fn test_both_empty_text_and_url() {
652 let ctx = LintContext::new("[]()", crate::config::MarkdownFlavor::Standard, None);
653 let rule = MD042NoEmptyLinks::new();
654 let result = rule.check(&ctx).unwrap();
655 assert_eq!(result.len(), 1);
656 assert_eq!(result[0].message, "Empty link found: []()");
657 }
658
659 #[test]
660 fn test_bare_hash_treated_as_empty_url() {
661 let rule = MD042NoEmptyLinks::new();
662
663 let ctx = LintContext::new("# Title\n\n[](#)\n", crate::config::MarkdownFlavor::Standard, None);
665 let result = rule.check(&ctx).unwrap();
666 assert_eq!(
667 result.len(),
668 1,
669 "[](#) should be flagged as empty link. Got: {result:?}"
670 );
671 assert!(result[0].message.contains("[](#)"));
672
673 let ctx = LintContext::new("# Title\n\n[text](#)\n", crate::config::MarkdownFlavor::Standard, None);
675 let result = rule.check(&ctx).unwrap();
676 assert_eq!(
677 result.len(),
678 1,
679 "[text](#) should be flagged as empty link. Got: {result:?}"
680 );
681 assert!(result[0].message.contains("[text](#)"));
682
683 let ctx = LintContext::new(
685 "# Title\n\n[text]( # )\n",
686 crate::config::MarkdownFlavor::Standard,
687 None,
688 );
689 let result = rule.check(&ctx).unwrap();
690 assert_eq!(
691 result.len(),
692 1,
693 "[text]( # ) should be flagged as empty link. Got: {result:?}"
694 );
695
696 let ctx = LintContext::new(
698 "# Title\n\n[text](#foo)\n",
699 crate::config::MarkdownFlavor::Standard,
700 None,
701 );
702 let result = rule.check(&ctx).unwrap();
703 assert!(
704 result.is_empty(),
705 "[text](#foo) has a real fragment, should NOT be flagged. Got: {result:?}"
706 );
707
708 let ctx = LintContext::new(
710 "# Title\n\n[](#section)\n",
711 crate::config::MarkdownFlavor::Standard,
712 None,
713 );
714 let result = rule.check(&ctx).unwrap();
715 assert!(
716 result.is_empty(),
717 "[](#section) has a real URL, should NOT be flagged. Got: {result:?}"
718 );
719 }
720
721 #[test]
722 fn test_reference_link_with_undefined_reference() {
723 let ctx = LintContext::new("[text][undefined]", crate::config::MarkdownFlavor::Standard, None);
726 let rule = MD042NoEmptyLinks::new();
727 let result = rule.check(&ctx).unwrap();
728 assert!(
729 result.is_empty(),
730 "MD042 should NOT flag [text][undefined] - undefined refs are MD052's job. Got: {result:?}"
731 );
732
733 let ctx = LintContext::new("[][undefined]", crate::config::MarkdownFlavor::Standard, None);
735 let result = rule.check(&ctx).unwrap();
736 assert_eq!(result.len(), 1, "Empty text in reference link should still be flagged");
737 }
738
739 #[test]
740 fn test_shortcut_reference_links() {
741 let ctx = LintContext::new(
745 "[example][]\n\n[example]: https://example.com",
746 crate::config::MarkdownFlavor::Standard,
747 None,
748 );
749 let rule = MD042NoEmptyLinks::new();
750 let result = rule.check(&ctx).unwrap();
751 assert!(result.is_empty(), "Valid implicit reference link should pass");
752
753 let ctx = LintContext::new(
758 "[example]\n\n[example]: https://example.com",
759 crate::config::MarkdownFlavor::Standard,
760 None,
761 );
762 let result = rule.check(&ctx).unwrap();
763 assert!(
764 result.is_empty(),
765 "Shortcut links without [] or () are not parsed as links"
766 );
767 }
768
769 #[test]
770 fn test_fix_suggestions() {
771 let rule = MD042NoEmptyLinks::new();
773
774 let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
776 let result = rule.check(&ctx).unwrap();
777 assert!(result.is_empty(), "Empty text with URL should NOT be flagged");
778
779 let ctx = LintContext::new("[text]()", crate::config::MarkdownFlavor::Standard, None);
781 let result = rule.check(&ctx).unwrap();
782 assert_eq!(result.len(), 1, "Empty URL should be flagged");
783 assert!(
784 result[0].fix.is_none(),
785 "Non-URL text with empty URL should NOT be fixable"
786 );
787
788 let ctx = LintContext::new("[https://example.com]()", crate::config::MarkdownFlavor::Standard, None);
790 let result = rule.check(&ctx).unwrap();
791 assert_eq!(result.len(), 1, "Empty URL should be flagged");
792 assert!(result[0].fix.is_some(), "URL text with empty URL should be fixable");
793 let fix = result[0].fix.as_ref().unwrap();
794 assert_eq!(fix.replacement, "[https://example.com](https://example.com)");
795
796 let ctx = LintContext::new("[]()", crate::config::MarkdownFlavor::Standard, None);
798 let result = rule.check(&ctx).unwrap();
799 assert_eq!(result.len(), 1, "Empty URL should be flagged");
800 assert!(result[0].fix.is_none(), "Both empty should NOT be fixable");
801 }
802
803 #[test]
804 fn test_complex_markdown_document() {
805 let content = r#"# Document with various links
807
808[Valid link](https://example.com) followed by [](empty.com).
809
810## Lists with links
811- [Good link](url1)
812- [](url2)
813- Item with [inline empty]() link
814
815> Quote with [](quoted-empty.com)
816> And [valid quoted](quoted-valid.com)
817
818Code block should be ignored:
819```
820[](this-is-code)
821```
822
823[Reference style][ref1] and [][ref2]
824
825[ref1]: https://ref1.com
826[ref2]: https://ref2.com
827"#;
828
829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
830 let rule = MD042NoEmptyLinks::new();
831 let result = rule.check(&ctx).unwrap();
832
833 assert_eq!(result.len(), 1, "Should only flag empty URL links. Got: {result:?}");
837 assert_eq!(result[0].line, 8, "Only [inline empty]() should be flagged");
838 assert!(result[0].message.contains("[inline empty]()"));
839 }
840
841 #[test]
842 fn test_issue_29_code_block_with_tildes() {
843 let content = r#"In addition to the [local scope][] and the [global scope][], Python also has a **built-in scope**.
845
846```pycon
847>>> @count_calls
848... def greet(name):
849... print("Hi", name)
850...
851>>> greet("Trey")
852Traceback (most recent call last):
853 File "<python-input-2>", line 1, in <module>
854 greet("Trey")
855 ~~~~~^^^^^^^^
856 File "<python-input-0>", line 4, in wrapper
857 calls += 1
858 ^^^^^
859UnboundLocalError: cannot access local variable 'calls' where it is not associated with a value
860```
861
862
863[local scope]: https://www.pythonmorsels.com/local-and-global-variables/
864[global scope]: https://www.pythonmorsels.com/assigning-global-variables/"#;
865
866 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
867 let rule = MD042NoEmptyLinks::new();
868 let result = rule.check(&ctx).unwrap();
869
870 assert!(
872 result.is_empty(),
873 "Should not flag reference links as empty when code blocks contain tildes (issue #29). Got: {result:?}"
874 );
875 }
876
877 #[test]
878 fn test_link_with_inline_code_in_text() {
879 let ctx = LintContext::new(
881 "[`#[derive(Serialize, Deserialize)`](https://serde.rs/derive.html)",
882 crate::config::MarkdownFlavor::Standard,
883 None,
884 );
885 let rule = MD042NoEmptyLinks::new();
886 let result = rule.check(&ctx).unwrap();
887 assert!(
888 result.is_empty(),
889 "Links with inline code should not be flagged as empty. Got: {result:?}"
890 );
891 }
892
893 #[test]
894 fn test_frontmatter_not_flagged() {
895 let rule = MD042NoEmptyLinks::new();
896
897 let content = "---\ntitle: \"[Symbol.dispose]()\"\n---\n\n# Hello\n";
899 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
900 let result = rule.check(&ctx).unwrap();
901 assert!(
902 result.is_empty(),
903 "Should not flag [Symbol.dispose]() inside YAML frontmatter. Got: {result:?}"
904 );
905
906 let content = "# Hello\n\n[Symbol.dispose]()\n";
908 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
909 let result = rule.check(&ctx).unwrap();
910 assert_eq!(result.len(), 1, "Should flag [Symbol.dispose]() in regular content");
911
912 let content = "---\ntags: [\"[foo]()\", \"[bar]()\"]\n---\n\n# Hello\n";
914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
915 let result = rule.check(&ctx).unwrap();
916 assert!(
917 result.is_empty(),
918 "Should not flag link-like patterns inside frontmatter. Got: {result:?}"
919 );
920 }
921
922 #[test]
923 fn test_mkdocs_backtick_wrapped_references() {
924 let rule = MD042NoEmptyLinks::new();
926
927 let ctx = LintContext::new("[`module.Class`][]", crate::config::MarkdownFlavor::MkDocs, None);
929 let result = rule.check(&ctx).unwrap();
930 assert!(
931 result.is_empty(),
932 "Should not flag [`module.Class`][] as empty in MkDocs mode (issue #97). Got: {result:?}"
933 );
934
935 let ctx = LintContext::new("[`module.Class`][ref]", crate::config::MarkdownFlavor::MkDocs, None);
937 let result = rule.check(&ctx).unwrap();
938 assert!(
939 result.is_empty(),
940 "Should not flag [`module.Class`][ref] as empty in MkDocs mode (issue #97). Got: {result:?}"
941 );
942
943 let ctx = LintContext::new("[`api/endpoint`][]", crate::config::MarkdownFlavor::MkDocs, None);
945 let result = rule.check(&ctx).unwrap();
946 assert!(
947 result.is_empty(),
948 "Should not flag [`api/endpoint`][] as empty in MkDocs mode (issue #97). Got: {result:?}"
949 );
950
951 let ctx = LintContext::new("[`module.Class`][]", crate::config::MarkdownFlavor::Standard, None);
954 let result = rule.check(&ctx).unwrap();
955 assert!(
956 result.is_empty(),
957 "MD042 should NOT flag [`module.Class`][] - undefined refs are MD052's job. Got: {result:?}"
958 );
959
960 let ctx = LintContext::new("[][]", crate::config::MarkdownFlavor::MkDocs, None);
962 let result = rule.check(&ctx).unwrap();
963 assert_eq!(
964 result.len(),
965 1,
966 "Should still flag [][] as empty in MkDocs mode. Got: {result:?}"
967 );
968 }
969
970 #[test]
971 fn test_pandoc_flavor_skips_citations() {
972 use crate::config::MarkdownFlavor;
975 let rule = MD042NoEmptyLinks::new();
976 let content = "See [@smith2020] for details.\n";
977 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
978 let result = rule.check(&ctx).unwrap();
979 assert!(
980 result.is_empty(),
981 "MD042 should skip Pandoc citations under Pandoc flavor: {result:?}"
982 );
983 }
984
985 #[test]
994 fn test_pandoc_flavor_skips_inline_footnotes() {
995 use crate::config::MarkdownFlavor;
996 let rule = MD042NoEmptyLinks::new();
997 let content = "Text ^[a footnote] more.\n";
998 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
999 let result = rule.check(&ctx).unwrap();
1000 assert!(result.is_empty(), "MD042 must not flag ^[footnote]: {result:?}");
1001 }
1002
1003 #[test]
1009 fn test_pandoc_flavor_skips_example_references() {
1010 use crate::config::MarkdownFlavor;
1011 let rule = MD042NoEmptyLinks::new();
1012 let content = "(@good) Example.\n\nAs shown in (@good), this works.\n";
1013 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1014 let result = rule.check(&ctx).unwrap();
1015 assert!(result.is_empty(), "MD042 must not flag (@label) refs: {result:?}");
1016 }
1017
1018 #[test]
1030 fn test_pandoc_flavor_skips_implicit_header_refs() {
1031 use crate::config::MarkdownFlavor;
1032 let rule = MD042NoEmptyLinks::new();
1033 let content = "# My Section\n\nSee [My Section] for details.\n";
1034 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1035 let result = rule.check(&ctx).unwrap();
1036 assert!(
1037 result.is_empty(),
1038 "MD042 must not flag implicit header refs: {result:?}"
1039 );
1040 }
1041
1042 #[test]
1047 fn test_pandoc_mode_still_flags_ordinary_empty_links() {
1048 use crate::config::MarkdownFlavor;
1049 let rule = MD042NoEmptyLinks::new();
1050 let content = "[some text]()\n";
1052 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1053 let result = rule.check(&ctx).unwrap();
1054 assert!(
1055 !result.is_empty(),
1056 "MD042 must still flag [text]() as empty link under Pandoc flavor: {result:?}"
1057 );
1058 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1060 let result_std = rule.check(&ctx_std).unwrap();
1061 assert!(
1062 !result_std.is_empty(),
1063 "MD042 must flag [text]() under Standard flavor: {result_std:?}"
1064 );
1065 }
1066
1067 #[test]
1071 fn test_pandoc_mode_flags_empty_link_with_email_in_text() {
1072 use crate::config::MarkdownFlavor;
1073 let rule = MD042NoEmptyLinks::new();
1074 let content = "[contact user@example.com]()\n";
1075
1076 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1077 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1078 assert!(
1079 !result_pandoc.is_empty(),
1080 "MD042 must flag empty link with email in text under Pandoc: {result_pandoc:?}"
1081 );
1082
1083 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1084 let result_std = rule.check(&ctx_std).unwrap();
1085 assert!(
1086 !result_std.is_empty(),
1087 "MD042 must flag the same empty link under Standard: {result_std:?}"
1088 );
1089 }
1090
1091 #[test]
1096 fn test_pandoc_mode_flags_empty_link_with_citation_text() {
1097 use crate::config::MarkdownFlavor;
1098 let rule = MD042NoEmptyLinks::new();
1099 let content = "[see @smith2020]()\n";
1100
1101 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1102 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1103 assert!(
1104 !result_pandoc.is_empty(),
1105 "MD042 must flag empty link `[see @key]()` under Pandoc — label is a link, not a citation: {result_pandoc:?}"
1106 );
1107
1108 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1109 let result_std = rule.check(&ctx_std).unwrap();
1110 assert!(
1111 !result_std.is_empty(),
1112 "MD042 must flag the same empty link under Standard: {result_std:?}"
1113 );
1114 }
1115}