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 quarto_mode = ctx.flavor == crate::config::MarkdownFlavor::Quarto;
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 quarto_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 {
306 range: link.byte_offset..link.byte_end,
307 replacement: r,
308 }),
309 });
310 }
311 }
312
313 Ok(warnings)
314 }
315
316 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
317 let content = ctx.content;
318
319 let warnings = self.check(ctx)?;
321 let warnings =
322 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
323 if warnings.is_empty() {
324 return Ok(content.to_string());
325 }
326
327 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
329 .iter()
330 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.clone(), f.replacement.clone())))
331 .collect();
332
333 fixes.sort_by(|a, b| b.0.start.cmp(&a.0.start));
335
336 let mut result = content.to_string();
337
338 for (range, replacement) in fixes {
340 result.replace_range(range, &replacement);
341 }
342
343 Ok(result)
344 }
345
346 fn category(&self) -> RuleCategory {
348 RuleCategory::Link
349 }
350
351 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
353 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
354 }
355
356 fn as_any(&self) -> &dyn std::any::Any {
357 self
358 }
359
360 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
361 where
362 Self: Sized,
363 {
364 Box::new(MD042NoEmptyLinks::new())
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372 use crate::lint_context::LintContext;
373
374 #[test]
375 fn test_links_with_text_should_pass() {
376 let ctx = LintContext::new(
377 "[valid link](https://example.com)",
378 crate::config::MarkdownFlavor::Standard,
379 None,
380 );
381 let rule = MD042NoEmptyLinks::new();
382 let result = rule.check(&ctx).unwrap();
383 assert!(result.is_empty(), "Links with text should pass");
384
385 let ctx = LintContext::new(
386 "[another valid link](path/to/page.html)",
387 crate::config::MarkdownFlavor::Standard,
388 None,
389 );
390 let result = rule.check(&ctx).unwrap();
391 assert!(result.is_empty(), "Links with text and relative URLs should pass");
392 }
393
394 #[test]
395 fn test_links_with_empty_text_but_valid_url_pass() {
396 let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
399 let rule = MD042NoEmptyLinks::new();
400 let result = rule.check(&ctx).unwrap();
401 assert!(
402 result.is_empty(),
403 "Empty text with valid URL should NOT be flagged by MD042. Got: {result:?}"
404 );
405 }
406
407 #[test]
408 fn test_links_with_only_whitespace_but_valid_url_pass() {
409 let ctx = LintContext::new(
411 "[ ](https://example.com)",
412 crate::config::MarkdownFlavor::Standard,
413 None,
414 );
415 let rule = MD042NoEmptyLinks::new();
416 let result = rule.check(&ctx).unwrap();
417 assert!(
418 result.is_empty(),
419 "Whitespace text with valid URL should NOT be flagged. Got: {result:?}"
420 );
421
422 let ctx = LintContext::new(
423 "[\t\n](https://example.com)",
424 crate::config::MarkdownFlavor::Standard,
425 None,
426 );
427 let result = rule.check(&ctx).unwrap();
428 assert!(
429 result.is_empty(),
430 "Whitespace text with valid URL should NOT be flagged. Got: {result:?}"
431 );
432 }
433
434 #[test]
435 fn test_reference_links_with_empty_text_but_valid_ref() {
436 let ctx = LintContext::new(
439 "[][ref]\n\n[ref]: https://example.com",
440 crate::config::MarkdownFlavor::Standard,
441 None,
442 );
443 let rule = MD042NoEmptyLinks::new();
444 let result = rule.check(&ctx).unwrap();
445 assert!(
446 result.is_empty(),
447 "Empty text with valid reference should NOT be flagged. Got: {result:?}"
448 );
449
450 }
453
454 #[test]
455 fn test_images_should_be_ignored() {
456 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
458 let rule = MD042NoEmptyLinks::new();
459 let result = rule.check(&ctx).unwrap();
460 assert!(result.is_empty(), "Images with empty alt text should be ignored");
461
462 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
463 let result = rule.check(&ctx).unwrap();
464 assert!(result.is_empty(), "Images with whitespace alt text should be ignored");
465 }
466
467 #[test]
468 fn test_links_with_nested_formatting() {
469 let rule = MD042NoEmptyLinks::new();
471
472 let ctx = LintContext::new(
474 "[**](https://example.com)",
475 crate::config::MarkdownFlavor::Standard,
476 None,
477 );
478 let result = rule.check(&ctx).unwrap();
479 assert!(result.is_empty(), "[**](url) has URL so should pass");
480
481 let ctx = LintContext::new(
483 "[__](https://example.com)",
484 crate::config::MarkdownFlavor::Standard,
485 None,
486 );
487 let result = rule.check(&ctx).unwrap();
488 assert!(result.is_empty(), "[__](url) has URL so should pass");
489
490 let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
492 let result = rule.check(&ctx).unwrap();
493 assert!(result.is_empty(), "[](url) has URL so should pass");
494
495 let ctx = LintContext::new(
497 "[**bold text**](https://example.com)",
498 crate::config::MarkdownFlavor::Standard,
499 None,
500 );
501 let result = rule.check(&ctx).unwrap();
502 assert!(result.is_empty(), "Links with nested formatting and text should pass");
503
504 let ctx = LintContext::new(
506 "[*italic* and **bold**](https://example.com)",
507 crate::config::MarkdownFlavor::Standard,
508 None,
509 );
510 let result = rule.check(&ctx).unwrap();
511 assert!(result.is_empty(), "Links with multiple nested formatting should pass");
512 }
513
514 #[test]
515 fn test_multiple_empty_links_on_same_line() {
516 let ctx = LintContext::new(
518 "[](url1) and [](url2) and [valid](url3)",
519 crate::config::MarkdownFlavor::Standard,
520 None,
521 );
522 let rule = MD042NoEmptyLinks::new();
523 let result = rule.check(&ctx).unwrap();
524 assert!(
525 result.is_empty(),
526 "Empty text with valid URL should NOT be flagged. Got: {result:?}"
527 );
528
529 let ctx = LintContext::new(
531 "[text1]() and [text2]() and [text3](url)",
532 crate::config::MarkdownFlavor::Standard,
533 None,
534 );
535 let result = rule.check(&ctx).unwrap();
536 assert_eq!(result.len(), 2, "Should detect both empty URL links");
537 assert_eq!(result[0].column, 1); assert_eq!(result[1].column, 15); }
540
541 #[test]
542 fn test_escaped_brackets() {
543 let ctx = LintContext::new(
545 "\\[\\](https://example.com)",
546 crate::config::MarkdownFlavor::Standard,
547 None,
548 );
549 let rule = MD042NoEmptyLinks::new();
550 let result = rule.check(&ctx).unwrap();
551 assert!(result.is_empty(), "Escaped brackets should not be treated as links");
552
553 let ctx = LintContext::new(
555 "[\\[\\]](https://example.com)",
556 crate::config::MarkdownFlavor::Standard,
557 None,
558 );
559 let result = rule.check(&ctx).unwrap();
560 assert!(result.is_empty(), "Link with escaped brackets in text should pass");
561 }
562
563 #[test]
564 fn test_links_in_lists_and_blockquotes() {
565 let rule = MD042NoEmptyLinks::new();
567
568 let ctx = LintContext::new(
570 "- [](https://example.com)\n- [valid](https://example.com)",
571 crate::config::MarkdownFlavor::Standard,
572 None,
573 );
574 let result = rule.check(&ctx).unwrap();
575 assert!(result.is_empty(), "[](url) in lists should pass");
576
577 let ctx = LintContext::new(
579 "> [](https://example.com)\n> [valid](https://example.com)",
580 crate::config::MarkdownFlavor::Standard,
581 None,
582 );
583 let result = rule.check(&ctx).unwrap();
584 assert!(result.is_empty(), "[](url) in blockquotes should pass");
585
586 let ctx = LintContext::new(
588 "- [text]()\n- [valid](url)",
589 crate::config::MarkdownFlavor::Standard,
590 None,
591 );
592 let result = rule.check(&ctx).unwrap();
593 assert_eq!(result.len(), 1, "Empty URL should be flagged");
594 assert_eq!(result[0].line, 1);
595 }
596
597 #[test]
598 fn test_unicode_whitespace_characters() {
599 let rule = MD042NoEmptyLinks::new();
602
603 let ctx = LintContext::new(
605 "[\u{00A0}](https://example.com)",
606 crate::config::MarkdownFlavor::Standard,
607 None,
608 );
609 let result = rule.check(&ctx).unwrap();
610 assert!(result.is_empty(), "Has URL, should pass regardless of text");
611
612 let ctx = LintContext::new(
614 "[\u{2003}](https://example.com)",
615 crate::config::MarkdownFlavor::Standard,
616 None,
617 );
618 let result = rule.check(&ctx).unwrap();
619 assert!(result.is_empty(), "Has URL, should pass regardless of text");
620
621 let ctx = LintContext::new(
623 "[\u{200B}](https://example.com)",
624 crate::config::MarkdownFlavor::Standard,
625 None,
626 );
627 let result = rule.check(&ctx).unwrap();
628 assert!(result.is_empty(), "Has URL, should pass regardless of text");
629
630 let ctx = LintContext::new(
632 "[ \u{200B} ](https://example.com)",
633 crate::config::MarkdownFlavor::Standard,
634 None,
635 );
636 let result = rule.check(&ctx).unwrap();
637 assert!(result.is_empty(), "Has URL, should pass regardless of text");
638 }
639
640 #[test]
641 fn test_empty_url_with_text() {
642 let ctx = LintContext::new("[some text]()", crate::config::MarkdownFlavor::Standard, None);
643 let rule = MD042NoEmptyLinks::new();
644 let result = rule.check(&ctx).unwrap();
645 assert_eq!(result.len(), 1);
646 assert_eq!(result[0].message, "Empty link found: [some text]()");
647 }
648
649 #[test]
650 fn test_both_empty_text_and_url() {
651 let ctx = LintContext::new("[]()", crate::config::MarkdownFlavor::Standard, None);
652 let rule = MD042NoEmptyLinks::new();
653 let result = rule.check(&ctx).unwrap();
654 assert_eq!(result.len(), 1);
655 assert_eq!(result[0].message, "Empty link found: []()");
656 }
657
658 #[test]
659 fn test_bare_hash_treated_as_empty_url() {
660 let rule = MD042NoEmptyLinks::new();
661
662 let ctx = LintContext::new("# Title\n\n[](#)\n", crate::config::MarkdownFlavor::Standard, None);
664 let result = rule.check(&ctx).unwrap();
665 assert_eq!(
666 result.len(),
667 1,
668 "[](#) should be flagged as empty link. Got: {result:?}"
669 );
670 assert!(result[0].message.contains("[](#)"));
671
672 let ctx = LintContext::new("# Title\n\n[text](#)\n", crate::config::MarkdownFlavor::Standard, None);
674 let result = rule.check(&ctx).unwrap();
675 assert_eq!(
676 result.len(),
677 1,
678 "[text](#) should be flagged as empty link. Got: {result:?}"
679 );
680 assert!(result[0].message.contains("[text](#)"));
681
682 let ctx = LintContext::new(
684 "# Title\n\n[text]( # )\n",
685 crate::config::MarkdownFlavor::Standard,
686 None,
687 );
688 let result = rule.check(&ctx).unwrap();
689 assert_eq!(
690 result.len(),
691 1,
692 "[text]( # ) should be flagged as empty link. Got: {result:?}"
693 );
694
695 let ctx = LintContext::new(
697 "# Title\n\n[text](#foo)\n",
698 crate::config::MarkdownFlavor::Standard,
699 None,
700 );
701 let result = rule.check(&ctx).unwrap();
702 assert!(
703 result.is_empty(),
704 "[text](#foo) has a real fragment, should NOT be flagged. Got: {result:?}"
705 );
706
707 let ctx = LintContext::new(
709 "# Title\n\n[](#section)\n",
710 crate::config::MarkdownFlavor::Standard,
711 None,
712 );
713 let result = rule.check(&ctx).unwrap();
714 assert!(
715 result.is_empty(),
716 "[](#section) has a real URL, should NOT be flagged. Got: {result:?}"
717 );
718 }
719
720 #[test]
721 fn test_reference_link_with_undefined_reference() {
722 let ctx = LintContext::new("[text][undefined]", crate::config::MarkdownFlavor::Standard, None);
725 let rule = MD042NoEmptyLinks::new();
726 let result = rule.check(&ctx).unwrap();
727 assert!(
728 result.is_empty(),
729 "MD042 should NOT flag [text][undefined] - undefined refs are MD052's job. Got: {result:?}"
730 );
731
732 let ctx = LintContext::new("[][undefined]", crate::config::MarkdownFlavor::Standard, None);
734 let result = rule.check(&ctx).unwrap();
735 assert_eq!(result.len(), 1, "Empty text in reference link should still be flagged");
736 }
737
738 #[test]
739 fn test_shortcut_reference_links() {
740 let ctx = LintContext::new(
744 "[example][]\n\n[example]: https://example.com",
745 crate::config::MarkdownFlavor::Standard,
746 None,
747 );
748 let rule = MD042NoEmptyLinks::new();
749 let result = rule.check(&ctx).unwrap();
750 assert!(result.is_empty(), "Valid implicit reference link should pass");
751
752 let ctx = LintContext::new(
757 "[example]\n\n[example]: https://example.com",
758 crate::config::MarkdownFlavor::Standard,
759 None,
760 );
761 let result = rule.check(&ctx).unwrap();
762 assert!(
763 result.is_empty(),
764 "Shortcut links without [] or () are not parsed as links"
765 );
766 }
767
768 #[test]
769 fn test_fix_suggestions() {
770 let rule = MD042NoEmptyLinks::new();
772
773 let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
775 let result = rule.check(&ctx).unwrap();
776 assert!(result.is_empty(), "Empty text with URL should NOT be flagged");
777
778 let ctx = LintContext::new("[text]()", crate::config::MarkdownFlavor::Standard, None);
780 let result = rule.check(&ctx).unwrap();
781 assert_eq!(result.len(), 1, "Empty URL should be flagged");
782 assert!(
783 result[0].fix.is_none(),
784 "Non-URL text with empty URL should NOT be fixable"
785 );
786
787 let ctx = LintContext::new("[https://example.com]()", crate::config::MarkdownFlavor::Standard, None);
789 let result = rule.check(&ctx).unwrap();
790 assert_eq!(result.len(), 1, "Empty URL should be flagged");
791 assert!(result[0].fix.is_some(), "URL text with empty URL should be fixable");
792 let fix = result[0].fix.as_ref().unwrap();
793 assert_eq!(fix.replacement, "[https://example.com](https://example.com)");
794
795 let ctx = LintContext::new("[]()", crate::config::MarkdownFlavor::Standard, None);
797 let result = rule.check(&ctx).unwrap();
798 assert_eq!(result.len(), 1, "Empty URL should be flagged");
799 assert!(result[0].fix.is_none(), "Both empty should NOT be fixable");
800 }
801
802 #[test]
803 fn test_complex_markdown_document() {
804 let content = r#"# Document with various links
806
807[Valid link](https://example.com) followed by [](empty.com).
808
809## Lists with links
810- [Good link](url1)
811- [](url2)
812- Item with [inline empty]() link
813
814> Quote with [](quoted-empty.com)
815> And [valid quoted](quoted-valid.com)
816
817Code block should be ignored:
818```
819[](this-is-code)
820```
821
822[Reference style][ref1] and [][ref2]
823
824[ref1]: https://ref1.com
825[ref2]: https://ref2.com
826"#;
827
828 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
829 let rule = MD042NoEmptyLinks::new();
830 let result = rule.check(&ctx).unwrap();
831
832 assert_eq!(result.len(), 1, "Should only flag empty URL links. Got: {result:?}");
836 assert_eq!(result[0].line, 8, "Only [inline empty]() should be flagged");
837 assert!(result[0].message.contains("[inline empty]()"));
838 }
839
840 #[test]
841 fn test_issue_29_code_block_with_tildes() {
842 let content = r#"In addition to the [local scope][] and the [global scope][], Python also has a **built-in scope**.
844
845```pycon
846>>> @count_calls
847... def greet(name):
848... print("Hi", name)
849...
850>>> greet("Trey")
851Traceback (most recent call last):
852 File "<python-input-2>", line 1, in <module>
853 greet("Trey")
854 ~~~~~^^^^^^^^
855 File "<python-input-0>", line 4, in wrapper
856 calls += 1
857 ^^^^^
858UnboundLocalError: cannot access local variable 'calls' where it is not associated with a value
859```
860
861
862[local scope]: https://www.pythonmorsels.com/local-and-global-variables/
863[global scope]: https://www.pythonmorsels.com/assigning-global-variables/"#;
864
865 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866 let rule = MD042NoEmptyLinks::new();
867 let result = rule.check(&ctx).unwrap();
868
869 assert!(
871 result.is_empty(),
872 "Should not flag reference links as empty when code blocks contain tildes (issue #29). Got: {result:?}"
873 );
874 }
875
876 #[test]
877 fn test_link_with_inline_code_in_text() {
878 let ctx = LintContext::new(
880 "[`#[derive(Serialize, Deserialize)`](https://serde.rs/derive.html)",
881 crate::config::MarkdownFlavor::Standard,
882 None,
883 );
884 let rule = MD042NoEmptyLinks::new();
885 let result = rule.check(&ctx).unwrap();
886 assert!(
887 result.is_empty(),
888 "Links with inline code should not be flagged as empty. Got: {result:?}"
889 );
890 }
891
892 #[test]
893 fn test_frontmatter_not_flagged() {
894 let rule = MD042NoEmptyLinks::new();
895
896 let content = "---\ntitle: \"[Symbol.dispose]()\"\n---\n\n# Hello\n";
898 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
899 let result = rule.check(&ctx).unwrap();
900 assert!(
901 result.is_empty(),
902 "Should not flag [Symbol.dispose]() inside YAML frontmatter. Got: {result:?}"
903 );
904
905 let content = "# Hello\n\n[Symbol.dispose]()\n";
907 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
908 let result = rule.check(&ctx).unwrap();
909 assert_eq!(result.len(), 1, "Should flag [Symbol.dispose]() in regular content");
910
911 let content = "---\ntags: [\"[foo]()\", \"[bar]()\"]\n---\n\n# Hello\n";
913 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
914 let result = rule.check(&ctx).unwrap();
915 assert!(
916 result.is_empty(),
917 "Should not flag link-like patterns inside frontmatter. Got: {result:?}"
918 );
919 }
920
921 #[test]
922 fn test_mkdocs_backtick_wrapped_references() {
923 let rule = MD042NoEmptyLinks::new();
925
926 let ctx = LintContext::new("[`module.Class`][]", crate::config::MarkdownFlavor::MkDocs, None);
928 let result = rule.check(&ctx).unwrap();
929 assert!(
930 result.is_empty(),
931 "Should not flag [`module.Class`][] as empty in MkDocs mode (issue #97). Got: {result:?}"
932 );
933
934 let ctx = LintContext::new("[`module.Class`][ref]", crate::config::MarkdownFlavor::MkDocs, None);
936 let result = rule.check(&ctx).unwrap();
937 assert!(
938 result.is_empty(),
939 "Should not flag [`module.Class`][ref] as empty in MkDocs mode (issue #97). Got: {result:?}"
940 );
941
942 let ctx = LintContext::new("[`api/endpoint`][]", crate::config::MarkdownFlavor::MkDocs, None);
944 let result = rule.check(&ctx).unwrap();
945 assert!(
946 result.is_empty(),
947 "Should not flag [`api/endpoint`][] as empty in MkDocs mode (issue #97). Got: {result:?}"
948 );
949
950 let ctx = LintContext::new("[`module.Class`][]", crate::config::MarkdownFlavor::Standard, None);
953 let result = rule.check(&ctx).unwrap();
954 assert!(
955 result.is_empty(),
956 "MD042 should NOT flag [`module.Class`][] - undefined refs are MD052's job. Got: {result:?}"
957 );
958
959 let ctx = LintContext::new("[][]", crate::config::MarkdownFlavor::MkDocs, None);
961 let result = rule.check(&ctx).unwrap();
962 assert_eq!(
963 result.len(),
964 1,
965 "Should still flag [][] as empty in MkDocs mode. Got: {result:?}"
966 );
967 }
968}