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 name(&self) -> &'static str {
136 "MD042"
137 }
138
139 fn description(&self) -> &'static str {
140 "No empty links"
141 }
142
143 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
144 let mut warnings = Vec::new();
145
146 let mkdocs_mode = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
148 let pandoc_mode = ctx.flavor.is_pandoc_compatible();
149
150 for link in &ctx.links {
152 if ctx.line_info(link.line).is_some_and(|info| info.in_front_matter) {
154 continue;
155 }
156
157 if ctx.is_in_jinja_range(link.byte_offset) {
159 continue;
160 }
161
162 if pandoc_mode && ctx.is_in_citation(link.byte_offset) {
165 continue;
166 }
167
168 if ctx.is_in_shortcode(link.byte_offset) {
171 continue;
172 }
173
174 let in_html_tag = ctx
177 .html_tags()
178 .iter()
179 .any(|html_tag| html_tag.byte_offset <= link.byte_offset && link.byte_offset < html_tag.byte_end);
180 if in_html_tag {
181 continue;
182 }
183
184 let (effective_url, is_undefined_reference): (&str, bool) = if link.is_reference {
191 if let Some(ref_id) = &link.reference_id {
192 match ctx.get_reference_url(ref_id.as_ref()) {
193 Some(url) => (url, false),
194 None => ("", true), }
196 } else {
197 ("", false) }
199 } else {
200 (&link.url, false)
201 };
202
203 if mkdocs_mode && link.is_reference {
208 if let Some(ref_id) = &link.reference_id {
210 let stripped_ref = Self::strip_backticks(ref_id);
211 if is_mkdocs_auto_reference(stripped_ref)
214 || (ref_id != stripped_ref && Self::is_valid_python_identifier(stripped_ref))
215 {
216 continue;
217 }
218 }
219 let stripped_text = Self::strip_backticks(&link.text);
221 if is_mkdocs_auto_reference(stripped_text)
223 || (link.text.as_ref() != stripped_text && Self::is_valid_python_identifier(stripped_text))
224 {
225 continue;
226 }
227 }
228
229 let link_markdown = &ctx.content[link.byte_offset..link.byte_end];
233 if link_markdown.starts_with('<') && link_markdown.ends_with('>') {
234 continue;
235 }
236
237 if link_markdown.starts_with("[[")
249 && link_markdown.ends_with(']')
250 && ctx.content.as_bytes().get(link.byte_end) == Some(&b']')
251 {
252 continue;
253 }
254
255 if is_undefined_reference && !link.text.trim().is_empty() {
258 continue;
259 }
260
261 let trimmed_url = effective_url.trim();
265 if trimmed_url.is_empty() || trimmed_url == "#" {
266 if mkdocs_mode
268 && link.text.trim().is_empty()
269 && Self::is_mkdocs_attribute_anchor(ctx.content, link.byte_end)
270 {
271 continue;
273 }
274
275 let replacement = if !link.text.trim().is_empty() {
278 let text_is_url = link.text.starts_with("http://")
279 || link.text.starts_with("https://")
280 || link.text.starts_with("ftp://")
281 || link.text.starts_with("ftps://");
282
283 if text_is_url {
284 Some(format!("[{}]({})", link.text, link.text))
285 } else {
286 None
288 }
289 } else {
290 None
292 };
293
294 let link_display = &ctx.content[link.byte_offset..link.byte_end];
296
297 warnings.push(LintWarning {
298 rule_name: Some(self.name().to_string()),
299 message: format!("Empty link found: {link_display}"),
300 line: link.line,
301 column: link.start_col + 1, end_line: link.line,
303 end_column: link.end_col + 1, severity: Severity::Error,
305 fix: replacement.map(|r| Fix::new(link.byte_offset..link.byte_end, r)),
306 });
307 }
308 }
309
310 Ok(warnings)
311 }
312
313 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
314 let content = ctx.content;
315
316 let warnings = self.check(ctx)?;
318 let warnings =
319 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
320 if warnings.is_empty() {
321 return Ok(content.to_string());
322 }
323
324 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
326 .iter()
327 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.clone(), f.replacement.clone())))
328 .collect();
329
330 fixes.sort_by(|a, b| b.0.start.cmp(&a.0.start));
332
333 let mut result = content.to_string();
334
335 for (range, replacement) in fixes {
337 result.replace_range(range, &replacement);
338 }
339
340 Ok(result)
341 }
342
343 fn category(&self) -> RuleCategory {
345 RuleCategory::Link
346 }
347
348 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
350 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
351 }
352
353 fn as_any(&self) -> &dyn std::any::Any {
354 self
355 }
356
357 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
358 where
359 Self: Sized,
360 {
361 Box::new(MD042NoEmptyLinks::new())
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use crate::lint_context::LintContext;
370
371 #[test]
372 fn test_links_with_text_should_pass() {
373 let ctx = LintContext::new(
374 "[valid link](https://example.com)",
375 crate::config::MarkdownFlavor::Standard,
376 None,
377 );
378 let rule = MD042NoEmptyLinks::new();
379 let result = rule.check(&ctx).unwrap();
380 assert!(result.is_empty(), "Links with text should pass");
381
382 let ctx = LintContext::new(
383 "[another valid link](path/to/page.html)",
384 crate::config::MarkdownFlavor::Standard,
385 None,
386 );
387 let result = rule.check(&ctx).unwrap();
388 assert!(result.is_empty(), "Links with text and relative URLs should pass");
389 }
390
391 #[test]
392 fn test_links_with_empty_text_but_valid_url_pass() {
393 let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
396 let rule = MD042NoEmptyLinks::new();
397 let result = rule.check(&ctx).unwrap();
398 assert!(
399 result.is_empty(),
400 "Empty text with valid URL should NOT be flagged by MD042. Got: {result:?}"
401 );
402 }
403
404 #[test]
405 fn test_links_with_only_whitespace_but_valid_url_pass() {
406 let ctx = LintContext::new(
408 "[ ](https://example.com)",
409 crate::config::MarkdownFlavor::Standard,
410 None,
411 );
412 let rule = MD042NoEmptyLinks::new();
413 let result = rule.check(&ctx).unwrap();
414 assert!(
415 result.is_empty(),
416 "Whitespace text with valid URL should NOT be flagged. Got: {result:?}"
417 );
418
419 let ctx = LintContext::new(
420 "[\t\n](https://example.com)",
421 crate::config::MarkdownFlavor::Standard,
422 None,
423 );
424 let result = rule.check(&ctx).unwrap();
425 assert!(
426 result.is_empty(),
427 "Whitespace text with valid URL should NOT be flagged. Got: {result:?}"
428 );
429 }
430
431 #[test]
432 fn test_reference_links_with_empty_text_but_valid_ref() {
433 let ctx = LintContext::new(
436 "[][ref]\n\n[ref]: https://example.com",
437 crate::config::MarkdownFlavor::Standard,
438 None,
439 );
440 let rule = MD042NoEmptyLinks::new();
441 let result = rule.check(&ctx).unwrap();
442 assert!(
443 result.is_empty(),
444 "Empty text with valid reference should NOT be flagged. Got: {result:?}"
445 );
446
447 }
450
451 #[test]
452 fn test_images_should_be_ignored() {
453 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
455 let rule = MD042NoEmptyLinks::new();
456 let result = rule.check(&ctx).unwrap();
457 assert!(result.is_empty(), "Images with empty alt text should be ignored");
458
459 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
460 let result = rule.check(&ctx).unwrap();
461 assert!(result.is_empty(), "Images with whitespace alt text should be ignored");
462 }
463
464 #[test]
465 fn test_links_with_nested_formatting() {
466 let rule = MD042NoEmptyLinks::new();
468
469 let ctx = LintContext::new(
471 "[**](https://example.com)",
472 crate::config::MarkdownFlavor::Standard,
473 None,
474 );
475 let result = rule.check(&ctx).unwrap();
476 assert!(result.is_empty(), "[**](url) has URL so should pass");
477
478 let ctx = LintContext::new(
480 "[__](https://example.com)",
481 crate::config::MarkdownFlavor::Standard,
482 None,
483 );
484 let result = rule.check(&ctx).unwrap();
485 assert!(result.is_empty(), "[__](url) has URL so should pass");
486
487 let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
489 let result = rule.check(&ctx).unwrap();
490 assert!(result.is_empty(), "[](url) has URL so should pass");
491
492 let ctx = LintContext::new(
494 "[**bold text**](https://example.com)",
495 crate::config::MarkdownFlavor::Standard,
496 None,
497 );
498 let result = rule.check(&ctx).unwrap();
499 assert!(result.is_empty(), "Links with nested formatting and text should pass");
500
501 let ctx = LintContext::new(
503 "[*italic* and **bold**](https://example.com)",
504 crate::config::MarkdownFlavor::Standard,
505 None,
506 );
507 let result = rule.check(&ctx).unwrap();
508 assert!(result.is_empty(), "Links with multiple nested formatting should pass");
509 }
510
511 #[test]
512 fn test_multiple_empty_links_on_same_line() {
513 let ctx = LintContext::new(
515 "[](url1) and [](url2) and [valid](url3)",
516 crate::config::MarkdownFlavor::Standard,
517 None,
518 );
519 let rule = MD042NoEmptyLinks::new();
520 let result = rule.check(&ctx).unwrap();
521 assert!(
522 result.is_empty(),
523 "Empty text with valid URL should NOT be flagged. Got: {result:?}"
524 );
525
526 let ctx = LintContext::new(
528 "[text1]() and [text2]() and [text3](url)",
529 crate::config::MarkdownFlavor::Standard,
530 None,
531 );
532 let result = rule.check(&ctx).unwrap();
533 assert_eq!(result.len(), 2, "Should detect both empty URL links");
534 assert_eq!(result[0].column, 1); assert_eq!(result[1].column, 15); }
537
538 #[test]
539 fn test_escaped_brackets() {
540 let ctx = LintContext::new(
542 "\\[\\](https://example.com)",
543 crate::config::MarkdownFlavor::Standard,
544 None,
545 );
546 let rule = MD042NoEmptyLinks::new();
547 let result = rule.check(&ctx).unwrap();
548 assert!(result.is_empty(), "Escaped brackets should not be treated as links");
549
550 let ctx = LintContext::new(
552 "[\\[\\]](https://example.com)",
553 crate::config::MarkdownFlavor::Standard,
554 None,
555 );
556 let result = rule.check(&ctx).unwrap();
557 assert!(result.is_empty(), "Link with escaped brackets in text should pass");
558 }
559
560 #[test]
561 fn test_links_in_lists_and_blockquotes() {
562 let rule = MD042NoEmptyLinks::new();
564
565 let ctx = LintContext::new(
567 "- [](https://example.com)\n- [valid](https://example.com)",
568 crate::config::MarkdownFlavor::Standard,
569 None,
570 );
571 let result = rule.check(&ctx).unwrap();
572 assert!(result.is_empty(), "[](url) in lists should pass");
573
574 let ctx = LintContext::new(
576 "> [](https://example.com)\n> [valid](https://example.com)",
577 crate::config::MarkdownFlavor::Standard,
578 None,
579 );
580 let result = rule.check(&ctx).unwrap();
581 assert!(result.is_empty(), "[](url) in blockquotes should pass");
582
583 let ctx = LintContext::new(
585 "- [text]()\n- [valid](url)",
586 crate::config::MarkdownFlavor::Standard,
587 None,
588 );
589 let result = rule.check(&ctx).unwrap();
590 assert_eq!(result.len(), 1, "Empty URL should be flagged");
591 assert_eq!(result[0].line, 1);
592 }
593
594 #[test]
595 fn test_unicode_whitespace_characters() {
596 let rule = MD042NoEmptyLinks::new();
599
600 let ctx = LintContext::new(
602 "[\u{00A0}](https://example.com)",
603 crate::config::MarkdownFlavor::Standard,
604 None,
605 );
606 let result = rule.check(&ctx).unwrap();
607 assert!(result.is_empty(), "Has URL, should pass regardless of text");
608
609 let ctx = LintContext::new(
611 "[\u{2003}](https://example.com)",
612 crate::config::MarkdownFlavor::Standard,
613 None,
614 );
615 let result = rule.check(&ctx).unwrap();
616 assert!(result.is_empty(), "Has URL, should pass regardless of text");
617
618 let ctx = LintContext::new(
620 "[\u{200B}](https://example.com)",
621 crate::config::MarkdownFlavor::Standard,
622 None,
623 );
624 let result = rule.check(&ctx).unwrap();
625 assert!(result.is_empty(), "Has URL, should pass regardless of text");
626
627 let ctx = LintContext::new(
629 "[ \u{200B} ](https://example.com)",
630 crate::config::MarkdownFlavor::Standard,
631 None,
632 );
633 let result = rule.check(&ctx).unwrap();
634 assert!(result.is_empty(), "Has URL, should pass regardless of text");
635 }
636
637 #[test]
638 fn test_empty_url_with_text() {
639 let ctx = LintContext::new("[some text]()", crate::config::MarkdownFlavor::Standard, None);
640 let rule = MD042NoEmptyLinks::new();
641 let result = rule.check(&ctx).unwrap();
642 assert_eq!(result.len(), 1);
643 assert_eq!(result[0].message, "Empty link found: [some text]()");
644 }
645
646 #[test]
647 fn test_both_empty_text_and_url() {
648 let ctx = LintContext::new("[]()", crate::config::MarkdownFlavor::Standard, None);
649 let rule = MD042NoEmptyLinks::new();
650 let result = rule.check(&ctx).unwrap();
651 assert_eq!(result.len(), 1);
652 assert_eq!(result[0].message, "Empty link found: []()");
653 }
654
655 #[test]
656 fn test_bare_hash_treated_as_empty_url() {
657 let rule = MD042NoEmptyLinks::new();
658
659 let ctx = LintContext::new("# Title\n\n[](#)\n", crate::config::MarkdownFlavor::Standard, None);
661 let result = rule.check(&ctx).unwrap();
662 assert_eq!(
663 result.len(),
664 1,
665 "[](#) should be flagged as empty link. Got: {result:?}"
666 );
667 assert!(result[0].message.contains("[](#)"));
668
669 let ctx = LintContext::new("# Title\n\n[text](#)\n", crate::config::MarkdownFlavor::Standard, None);
671 let result = rule.check(&ctx).unwrap();
672 assert_eq!(
673 result.len(),
674 1,
675 "[text](#) should be flagged as empty link. Got: {result:?}"
676 );
677 assert!(result[0].message.contains("[text](#)"));
678
679 let ctx = LintContext::new(
681 "# Title\n\n[text]( # )\n",
682 crate::config::MarkdownFlavor::Standard,
683 None,
684 );
685 let result = rule.check(&ctx).unwrap();
686 assert_eq!(
687 result.len(),
688 1,
689 "[text]( # ) should be flagged as empty link. Got: {result:?}"
690 );
691
692 let ctx = LintContext::new(
694 "# Title\n\n[text](#foo)\n",
695 crate::config::MarkdownFlavor::Standard,
696 None,
697 );
698 let result = rule.check(&ctx).unwrap();
699 assert!(
700 result.is_empty(),
701 "[text](#foo) has a real fragment, should NOT be flagged. Got: {result:?}"
702 );
703
704 let ctx = LintContext::new(
706 "# Title\n\n[](#section)\n",
707 crate::config::MarkdownFlavor::Standard,
708 None,
709 );
710 let result = rule.check(&ctx).unwrap();
711 assert!(
712 result.is_empty(),
713 "[](#section) has a real URL, should NOT be flagged. Got: {result:?}"
714 );
715 }
716
717 #[test]
718 fn test_reference_link_with_undefined_reference() {
719 let ctx = LintContext::new("[text][undefined]", crate::config::MarkdownFlavor::Standard, None);
722 let rule = MD042NoEmptyLinks::new();
723 let result = rule.check(&ctx).unwrap();
724 assert!(
725 result.is_empty(),
726 "MD042 should NOT flag [text][undefined] - undefined refs are MD052's job. Got: {result:?}"
727 );
728
729 let ctx = LintContext::new("[][undefined]", crate::config::MarkdownFlavor::Standard, None);
731 let result = rule.check(&ctx).unwrap();
732 assert_eq!(result.len(), 1, "Empty text in reference link should still be flagged");
733 }
734
735 #[test]
736 fn test_shortcut_reference_links() {
737 let ctx = LintContext::new(
741 "[example][]\n\n[example]: https://example.com",
742 crate::config::MarkdownFlavor::Standard,
743 None,
744 );
745 let rule = MD042NoEmptyLinks::new();
746 let result = rule.check(&ctx).unwrap();
747 assert!(result.is_empty(), "Valid implicit reference link should pass");
748
749 let ctx = LintContext::new(
754 "[example]\n\n[example]: https://example.com",
755 crate::config::MarkdownFlavor::Standard,
756 None,
757 );
758 let result = rule.check(&ctx).unwrap();
759 assert!(
760 result.is_empty(),
761 "Shortcut links without [] or () are not parsed as links"
762 );
763 }
764
765 #[test]
766 fn test_fix_suggestions() {
767 let rule = MD042NoEmptyLinks::new();
769
770 let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
772 let result = rule.check(&ctx).unwrap();
773 assert!(result.is_empty(), "Empty text with URL should NOT be flagged");
774
775 let ctx = LintContext::new("[text]()", crate::config::MarkdownFlavor::Standard, None);
777 let result = rule.check(&ctx).unwrap();
778 assert_eq!(result.len(), 1, "Empty URL should be flagged");
779 assert!(
780 result[0].fix.is_none(),
781 "Non-URL text with empty URL should NOT be fixable"
782 );
783
784 let ctx = LintContext::new("[https://example.com]()", crate::config::MarkdownFlavor::Standard, None);
786 let result = rule.check(&ctx).unwrap();
787 assert_eq!(result.len(), 1, "Empty URL should be flagged");
788 assert!(result[0].fix.is_some(), "URL text with empty URL should be fixable");
789 let fix = result[0].fix.as_ref().unwrap();
790 assert_eq!(fix.replacement, "[https://example.com](https://example.com)");
791
792 let ctx = LintContext::new("[]()", crate::config::MarkdownFlavor::Standard, None);
794 let result = rule.check(&ctx).unwrap();
795 assert_eq!(result.len(), 1, "Empty URL should be flagged");
796 assert!(result[0].fix.is_none(), "Both empty should NOT be fixable");
797 }
798
799 #[test]
800 fn test_complex_markdown_document() {
801 let content = r#"# Document with various links
803
804[Valid link](https://example.com) followed by [](empty.com).
805
806## Lists with links
807- [Good link](url1)
808- [](url2)
809- Item with [inline empty]() link
810
811> Quote with [](quoted-empty.com)
812> And [valid quoted](quoted-valid.com)
813
814Code block should be ignored:
815```
816[](this-is-code)
817```
818
819[Reference style][ref1] and [][ref2]
820
821[ref1]: https://ref1.com
822[ref2]: https://ref2.com
823"#;
824
825 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
826 let rule = MD042NoEmptyLinks::new();
827 let result = rule.check(&ctx).unwrap();
828
829 assert_eq!(result.len(), 1, "Should only flag empty URL links. Got: {result:?}");
833 assert_eq!(result[0].line, 8, "Only [inline empty]() should be flagged");
834 assert!(result[0].message.contains("[inline empty]()"));
835 }
836
837 #[test]
838 fn test_issue_29_code_block_with_tildes() {
839 let content = r#"In addition to the [local scope][] and the [global scope][], Python also has a **built-in scope**.
841
842```pycon
843>>> @count_calls
844... def greet(name):
845... print("Hi", name)
846...
847>>> greet("Trey")
848Traceback (most recent call last):
849 File "<python-input-2>", line 1, in <module>
850 greet("Trey")
851 ~~~~~^^^^^^^^
852 File "<python-input-0>", line 4, in wrapper
853 calls += 1
854 ^^^^^
855UnboundLocalError: cannot access local variable 'calls' where it is not associated with a value
856```
857
858
859[local scope]: https://www.pythonmorsels.com/local-and-global-variables/
860[global scope]: https://www.pythonmorsels.com/assigning-global-variables/"#;
861
862 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
863 let rule = MD042NoEmptyLinks::new();
864 let result = rule.check(&ctx).unwrap();
865
866 assert!(
868 result.is_empty(),
869 "Should not flag reference links as empty when code blocks contain tildes (issue #29). Got: {result:?}"
870 );
871 }
872
873 #[test]
874 fn test_link_with_inline_code_in_text() {
875 let ctx = LintContext::new(
877 "[`#[derive(Serialize, Deserialize)`](https://serde.rs/derive.html)",
878 crate::config::MarkdownFlavor::Standard,
879 None,
880 );
881 let rule = MD042NoEmptyLinks::new();
882 let result = rule.check(&ctx).unwrap();
883 assert!(
884 result.is_empty(),
885 "Links with inline code should not be flagged as empty. Got: {result:?}"
886 );
887 }
888
889 #[test]
890 fn test_frontmatter_not_flagged() {
891 let rule = MD042NoEmptyLinks::new();
892
893 let content = "---\ntitle: \"[Symbol.dispose]()\"\n---\n\n# Hello\n";
895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
896 let result = rule.check(&ctx).unwrap();
897 assert!(
898 result.is_empty(),
899 "Should not flag [Symbol.dispose]() inside YAML frontmatter. Got: {result:?}"
900 );
901
902 let content = "# Hello\n\n[Symbol.dispose]()\n";
904 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
905 let result = rule.check(&ctx).unwrap();
906 assert_eq!(result.len(), 1, "Should flag [Symbol.dispose]() in regular content");
907
908 let content = "---\ntags: [\"[foo]()\", \"[bar]()\"]\n---\n\n# Hello\n";
910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
911 let result = rule.check(&ctx).unwrap();
912 assert!(
913 result.is_empty(),
914 "Should not flag link-like patterns inside frontmatter. Got: {result:?}"
915 );
916 }
917
918 #[test]
919 fn test_mkdocs_backtick_wrapped_references() {
920 let rule = MD042NoEmptyLinks::new();
922
923 let ctx = LintContext::new("[`module.Class`][]", crate::config::MarkdownFlavor::MkDocs, None);
925 let result = rule.check(&ctx).unwrap();
926 assert!(
927 result.is_empty(),
928 "Should not flag [`module.Class`][] as empty in MkDocs mode (issue #97). Got: {result:?}"
929 );
930
931 let ctx = LintContext::new("[`module.Class`][ref]", crate::config::MarkdownFlavor::MkDocs, None);
933 let result = rule.check(&ctx).unwrap();
934 assert!(
935 result.is_empty(),
936 "Should not flag [`module.Class`][ref] as empty in MkDocs mode (issue #97). Got: {result:?}"
937 );
938
939 let ctx = LintContext::new("[`api/endpoint`][]", crate::config::MarkdownFlavor::MkDocs, None);
941 let result = rule.check(&ctx).unwrap();
942 assert!(
943 result.is_empty(),
944 "Should not flag [`api/endpoint`][] as empty in MkDocs mode (issue #97). Got: {result:?}"
945 );
946
947 let ctx = LintContext::new("[`module.Class`][]", crate::config::MarkdownFlavor::Standard, None);
950 let result = rule.check(&ctx).unwrap();
951 assert!(
952 result.is_empty(),
953 "MD042 should NOT flag [`module.Class`][] - undefined refs are MD052's job. Got: {result:?}"
954 );
955
956 let ctx = LintContext::new("[][]", crate::config::MarkdownFlavor::MkDocs, None);
958 let result = rule.check(&ctx).unwrap();
959 assert_eq!(
960 result.len(),
961 1,
962 "Should still flag [][] as empty in MkDocs mode. Got: {result:?}"
963 );
964 }
965
966 #[test]
967 fn test_pandoc_flavor_skips_citations() {
968 use crate::config::MarkdownFlavor;
971 let rule = MD042NoEmptyLinks::new();
972 let content = "See [@smith2020] for details.\n";
973 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
974 let result = rule.check(&ctx).unwrap();
975 assert!(
976 result.is_empty(),
977 "MD042 should skip Pandoc citations under Pandoc flavor: {result:?}"
978 );
979 }
980
981 #[test]
990 fn test_pandoc_flavor_skips_inline_footnotes() {
991 use crate::config::MarkdownFlavor;
992 let rule = MD042NoEmptyLinks::new();
993 let content = "Text ^[a footnote] more.\n";
994 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
995 let result = rule.check(&ctx).unwrap();
996 assert!(result.is_empty(), "MD042 must not flag ^[footnote]: {result:?}");
997 }
998
999 #[test]
1005 fn test_pandoc_flavor_skips_example_references() {
1006 use crate::config::MarkdownFlavor;
1007 let rule = MD042NoEmptyLinks::new();
1008 let content = "(@good) Example.\n\nAs shown in (@good), this works.\n";
1009 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1010 let result = rule.check(&ctx).unwrap();
1011 assert!(result.is_empty(), "MD042 must not flag (@label) refs: {result:?}");
1012 }
1013
1014 #[test]
1026 fn test_pandoc_flavor_skips_implicit_header_refs() {
1027 use crate::config::MarkdownFlavor;
1028 let rule = MD042NoEmptyLinks::new();
1029 let content = "# My Section\n\nSee [My Section] for details.\n";
1030 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1031 let result = rule.check(&ctx).unwrap();
1032 assert!(
1033 result.is_empty(),
1034 "MD042 must not flag implicit header refs: {result:?}"
1035 );
1036 }
1037
1038 #[test]
1043 fn test_pandoc_mode_still_flags_ordinary_empty_links() {
1044 use crate::config::MarkdownFlavor;
1045 let rule = MD042NoEmptyLinks::new();
1046 let content = "[some text]()\n";
1048 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1049 let result = rule.check(&ctx).unwrap();
1050 assert!(
1051 !result.is_empty(),
1052 "MD042 must still flag [text]() as empty link under Pandoc flavor: {result:?}"
1053 );
1054 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1056 let result_std = rule.check(&ctx_std).unwrap();
1057 assert!(
1058 !result_std.is_empty(),
1059 "MD042 must flag [text]() under Standard flavor: {result_std:?}"
1060 );
1061 }
1062
1063 #[test]
1067 fn test_pandoc_mode_flags_empty_link_with_email_in_text() {
1068 use crate::config::MarkdownFlavor;
1069 let rule = MD042NoEmptyLinks::new();
1070 let content = "[contact user@example.com]()\n";
1071
1072 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1073 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1074 assert!(
1075 !result_pandoc.is_empty(),
1076 "MD042 must flag empty link with email in text under Pandoc: {result_pandoc:?}"
1077 );
1078
1079 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1080 let result_std = rule.check(&ctx_std).unwrap();
1081 assert!(
1082 !result_std.is_empty(),
1083 "MD042 must flag the same empty link under Standard: {result_std:?}"
1084 );
1085 }
1086
1087 #[test]
1092 fn test_pandoc_mode_flags_empty_link_with_citation_text() {
1093 use crate::config::MarkdownFlavor;
1094 let rule = MD042NoEmptyLinks::new();
1095 let content = "[see @smith2020]()\n";
1096
1097 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1098 let result_pandoc = rule.check(&ctx_pandoc).unwrap();
1099 assert!(
1100 !result_pandoc.is_empty(),
1101 "MD042 must flag empty link `[see @key]()` under Pandoc — label is a link, not a citation: {result_pandoc:?}"
1102 );
1103
1104 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1105 let result_std = rule.check(&ctx_std).unwrap();
1106 assert!(
1107 !result_std.is_empty(),
1108 "MD042 must flag the same empty link under Standard: {result_std:?}"
1109 );
1110 }
1111}