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.is_in_jinja_range(link.byte_offset) {
154 continue;
155 }
156
157 if quarto_mode && ctx.is_in_citation(link.byte_offset) {
160 continue;
161 }
162
163 if ctx.is_in_shortcode(link.byte_offset) {
166 continue;
167 }
168
169 let in_html_tag = ctx
172 .html_tags()
173 .iter()
174 .any(|html_tag| html_tag.byte_offset <= link.byte_offset && link.byte_offset < html_tag.byte_end);
175 if in_html_tag {
176 continue;
177 }
178
179 let (effective_url, is_undefined_reference): (&str, bool) = if link.is_reference {
186 if let Some(ref_id) = &link.reference_id {
187 match ctx.get_reference_url(ref_id.as_ref()) {
188 Some(url) => (url, false),
189 None => ("", true), }
191 } else {
192 ("", false) }
194 } else {
195 (&link.url, false)
196 };
197
198 if mkdocs_mode && link.is_reference {
203 if let Some(ref_id) = &link.reference_id {
205 let stripped_ref = Self::strip_backticks(ref_id);
206 if is_mkdocs_auto_reference(stripped_ref)
209 || (ref_id != stripped_ref && Self::is_valid_python_identifier(stripped_ref))
210 {
211 continue;
212 }
213 }
214 let stripped_text = Self::strip_backticks(&link.text);
216 if is_mkdocs_auto_reference(stripped_text)
218 || (link.text.as_ref() != stripped_text && Self::is_valid_python_identifier(stripped_text))
219 {
220 continue;
221 }
222 }
223
224 let link_markdown = &ctx.content[link.byte_offset..link.byte_end];
228 if link_markdown.starts_with('<') && link_markdown.ends_with('>') {
229 continue;
230 }
231
232 if link_markdown.starts_with("[[")
244 && link_markdown.ends_with(']')
245 && ctx.content.as_bytes().get(link.byte_end) == Some(&b']')
246 {
247 continue;
248 }
249
250 if is_undefined_reference && !link.text.trim().is_empty() {
253 continue;
254 }
255
256 if effective_url.trim().is_empty() {
260 if mkdocs_mode
262 && link.text.trim().is_empty()
263 && Self::is_mkdocs_attribute_anchor(ctx.content, link.byte_end)
264 {
265 continue;
267 }
268
269 let replacement = if !link.text.trim().is_empty() {
272 let text_is_url = link.text.starts_with("http://")
273 || link.text.starts_with("https://")
274 || link.text.starts_with("ftp://")
275 || link.text.starts_with("ftps://");
276
277 if text_is_url {
278 Some(format!("[{}]({})", link.text, link.text))
279 } else {
280 None
282 }
283 } else {
284 None
286 };
287
288 let link_display = &ctx.content[link.byte_offset..link.byte_end];
290
291 warnings.push(LintWarning {
292 rule_name: Some(self.name().to_string()),
293 message: format!("Empty link found: {link_display}"),
294 line: link.line,
295 column: link.start_col + 1, end_line: link.line,
297 end_column: link.end_col + 1, severity: Severity::Error,
299 fix: replacement.map(|r| Fix {
300 range: link.byte_offset..link.byte_end,
301 replacement: r,
302 }),
303 });
304 }
305 }
306
307 Ok(warnings)
308 }
309
310 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
311 let content = ctx.content;
312
313 let warnings = self.check(ctx)?;
315 if warnings.is_empty() {
316 return Ok(content.to_string());
317 }
318
319 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
321 .iter()
322 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.clone(), f.replacement.clone())))
323 .collect();
324
325 fixes.sort_by(|a, b| b.0.start.cmp(&a.0.start));
327
328 let mut result = content.to_string();
329
330 for (range, replacement) in fixes {
332 result.replace_range(range, &replacement);
333 }
334
335 Ok(result)
336 }
337
338 fn category(&self) -> RuleCategory {
340 RuleCategory::Link
341 }
342
343 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
345 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
346 }
347
348 fn as_any(&self) -> &dyn std::any::Any {
349 self
350 }
351
352 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
353 where
354 Self: Sized,
355 {
356 Box::new(MD042NoEmptyLinks::new())
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use crate::lint_context::LintContext;
365
366 #[test]
367 fn test_links_with_text_should_pass() {
368 let ctx = LintContext::new(
369 "[valid link](https://example.com)",
370 crate::config::MarkdownFlavor::Standard,
371 None,
372 );
373 let rule = MD042NoEmptyLinks::new();
374 let result = rule.check(&ctx).unwrap();
375 assert!(result.is_empty(), "Links with text should pass");
376
377 let ctx = LintContext::new(
378 "[another valid link](path/to/page.html)",
379 crate::config::MarkdownFlavor::Standard,
380 None,
381 );
382 let result = rule.check(&ctx).unwrap();
383 assert!(result.is_empty(), "Links with text and relative URLs should pass");
384 }
385
386 #[test]
387 fn test_links_with_empty_text_but_valid_url_pass() {
388 let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
391 let rule = MD042NoEmptyLinks::new();
392 let result = rule.check(&ctx).unwrap();
393 assert!(
394 result.is_empty(),
395 "Empty text with valid URL should NOT be flagged by MD042. Got: {result:?}"
396 );
397 }
398
399 #[test]
400 fn test_links_with_only_whitespace_but_valid_url_pass() {
401 let ctx = LintContext::new(
403 "[ ](https://example.com)",
404 crate::config::MarkdownFlavor::Standard,
405 None,
406 );
407 let rule = MD042NoEmptyLinks::new();
408 let result = rule.check(&ctx).unwrap();
409 assert!(
410 result.is_empty(),
411 "Whitespace text with valid URL should NOT be flagged. Got: {result:?}"
412 );
413
414 let ctx = LintContext::new(
415 "[\t\n](https://example.com)",
416 crate::config::MarkdownFlavor::Standard,
417 None,
418 );
419 let result = rule.check(&ctx).unwrap();
420 assert!(
421 result.is_empty(),
422 "Whitespace text with valid URL should NOT be flagged. Got: {result:?}"
423 );
424 }
425
426 #[test]
427 fn test_reference_links_with_empty_text_but_valid_ref() {
428 let ctx = LintContext::new(
431 "[][ref]\n\n[ref]: https://example.com",
432 crate::config::MarkdownFlavor::Standard,
433 None,
434 );
435 let rule = MD042NoEmptyLinks::new();
436 let result = rule.check(&ctx).unwrap();
437 assert!(
438 result.is_empty(),
439 "Empty text with valid reference should NOT be flagged. Got: {result:?}"
440 );
441
442 }
445
446 #[test]
447 fn test_images_should_be_ignored() {
448 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
450 let rule = MD042NoEmptyLinks::new();
451 let result = rule.check(&ctx).unwrap();
452 assert!(result.is_empty(), "Images with empty alt text should be ignored");
453
454 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
455 let result = rule.check(&ctx).unwrap();
456 assert!(result.is_empty(), "Images with whitespace alt text should be ignored");
457 }
458
459 #[test]
460 fn test_links_with_nested_formatting() {
461 let rule = MD042NoEmptyLinks::new();
463
464 let ctx = LintContext::new(
466 "[**](https://example.com)",
467 crate::config::MarkdownFlavor::Standard,
468 None,
469 );
470 let result = rule.check(&ctx).unwrap();
471 assert!(result.is_empty(), "[**](url) has URL so should pass");
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("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
484 let result = rule.check(&ctx).unwrap();
485 assert!(result.is_empty(), "[](url) has URL so should pass");
486
487 let ctx = LintContext::new(
489 "[**bold text**](https://example.com)",
490 crate::config::MarkdownFlavor::Standard,
491 None,
492 );
493 let result = rule.check(&ctx).unwrap();
494 assert!(result.is_empty(), "Links with nested formatting and text should pass");
495
496 let ctx = LintContext::new(
498 "[*italic* and **bold**](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 multiple nested formatting should pass");
504 }
505
506 #[test]
507 fn test_multiple_empty_links_on_same_line() {
508 let ctx = LintContext::new(
510 "[](url1) and [](url2) and [valid](url3)",
511 crate::config::MarkdownFlavor::Standard,
512 None,
513 );
514 let rule = MD042NoEmptyLinks::new();
515 let result = rule.check(&ctx).unwrap();
516 assert!(
517 result.is_empty(),
518 "Empty text with valid URL should NOT be flagged. Got: {result:?}"
519 );
520
521 let ctx = LintContext::new(
523 "[text1]() and [text2]() and [text3](url)",
524 crate::config::MarkdownFlavor::Standard,
525 None,
526 );
527 let result = rule.check(&ctx).unwrap();
528 assert_eq!(result.len(), 2, "Should detect both empty URL links");
529 assert_eq!(result[0].column, 1); assert_eq!(result[1].column, 15); }
532
533 #[test]
534 fn test_escaped_brackets() {
535 let ctx = LintContext::new(
537 "\\[\\](https://example.com)",
538 crate::config::MarkdownFlavor::Standard,
539 None,
540 );
541 let rule = MD042NoEmptyLinks::new();
542 let result = rule.check(&ctx).unwrap();
543 assert!(result.is_empty(), "Escaped brackets should not be treated as links");
544
545 let ctx = LintContext::new(
547 "[\\[\\]](https://example.com)",
548 crate::config::MarkdownFlavor::Standard,
549 None,
550 );
551 let result = rule.check(&ctx).unwrap();
552 assert!(result.is_empty(), "Link with escaped brackets in text should pass");
553 }
554
555 #[test]
556 fn test_links_in_lists_and_blockquotes() {
557 let rule = MD042NoEmptyLinks::new();
559
560 let ctx = LintContext::new(
562 "- [](https://example.com)\n- [valid](https://example.com)",
563 crate::config::MarkdownFlavor::Standard,
564 None,
565 );
566 let result = rule.check(&ctx).unwrap();
567 assert!(result.is_empty(), "[](url) in lists should pass");
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 blockquotes should pass");
577
578 let ctx = LintContext::new(
580 "- [text]()\n- [valid](url)",
581 crate::config::MarkdownFlavor::Standard,
582 None,
583 );
584 let result = rule.check(&ctx).unwrap();
585 assert_eq!(result.len(), 1, "Empty URL should be flagged");
586 assert_eq!(result[0].line, 1);
587 }
588
589 #[test]
590 fn test_unicode_whitespace_characters() {
591 let rule = MD042NoEmptyLinks::new();
594
595 let ctx = LintContext::new(
597 "[\u{00A0}](https://example.com)",
598 crate::config::MarkdownFlavor::Standard,
599 None,
600 );
601 let result = rule.check(&ctx).unwrap();
602 assert!(result.is_empty(), "Has URL, should pass regardless of text");
603
604 let ctx = LintContext::new(
606 "[\u{2003}](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{200B}](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
632 #[test]
633 fn test_empty_url_with_text() {
634 let ctx = LintContext::new("[some text]()", crate::config::MarkdownFlavor::Standard, None);
635 let rule = MD042NoEmptyLinks::new();
636 let result = rule.check(&ctx).unwrap();
637 assert_eq!(result.len(), 1);
638 assert_eq!(result[0].message, "Empty link found: [some text]()");
639 }
640
641 #[test]
642 fn test_both_empty_text_and_url() {
643 let ctx = LintContext::new("[]()", 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: []()");
648 }
649
650 #[test]
651 fn test_reference_link_with_undefined_reference() {
652 let ctx = LintContext::new("[text][undefined]", crate::config::MarkdownFlavor::Standard, None);
655 let rule = MD042NoEmptyLinks::new();
656 let result = rule.check(&ctx).unwrap();
657 assert!(
658 result.is_empty(),
659 "MD042 should NOT flag [text][undefined] - undefined refs are MD052's job. Got: {result:?}"
660 );
661
662 let ctx = LintContext::new("[][undefined]", crate::config::MarkdownFlavor::Standard, None);
664 let result = rule.check(&ctx).unwrap();
665 assert_eq!(result.len(), 1, "Empty text in reference link should still be flagged");
666 }
667
668 #[test]
669 fn test_shortcut_reference_links() {
670 let ctx = LintContext::new(
674 "[example][]\n\n[example]: https://example.com",
675 crate::config::MarkdownFlavor::Standard,
676 None,
677 );
678 let rule = MD042NoEmptyLinks::new();
679 let result = rule.check(&ctx).unwrap();
680 assert!(result.is_empty(), "Valid implicit reference link should pass");
681
682 let ctx = LintContext::new(
687 "[example]\n\n[example]: https://example.com",
688 crate::config::MarkdownFlavor::Standard,
689 None,
690 );
691 let result = rule.check(&ctx).unwrap();
692 assert!(
693 result.is_empty(),
694 "Shortcut links without [] or () are not parsed as links"
695 );
696 }
697
698 #[test]
699 fn test_fix_suggestions() {
700 let rule = MD042NoEmptyLinks::new();
702
703 let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
705 let result = rule.check(&ctx).unwrap();
706 assert!(result.is_empty(), "Empty text with URL should NOT be flagged");
707
708 let ctx = LintContext::new("[text]()", crate::config::MarkdownFlavor::Standard, None);
710 let result = rule.check(&ctx).unwrap();
711 assert_eq!(result.len(), 1, "Empty URL should be flagged");
712 assert!(
713 result[0].fix.is_none(),
714 "Non-URL text with empty URL should NOT be fixable"
715 );
716
717 let ctx = LintContext::new("[https://example.com]()", crate::config::MarkdownFlavor::Standard, None);
719 let result = rule.check(&ctx).unwrap();
720 assert_eq!(result.len(), 1, "Empty URL should be flagged");
721 assert!(result[0].fix.is_some(), "URL text with empty URL should be fixable");
722 let fix = result[0].fix.as_ref().unwrap();
723 assert_eq!(fix.replacement, "[https://example.com](https://example.com)");
724
725 let ctx = LintContext::new("[]()", crate::config::MarkdownFlavor::Standard, None);
727 let result = rule.check(&ctx).unwrap();
728 assert_eq!(result.len(), 1, "Empty URL should be flagged");
729 assert!(result[0].fix.is_none(), "Both empty should NOT be fixable");
730 }
731
732 #[test]
733 fn test_complex_markdown_document() {
734 let content = r#"# Document with various links
736
737[Valid link](https://example.com) followed by [](empty.com).
738
739## Lists with links
740- [Good link](url1)
741- [](url2)
742- Item with [inline empty]() link
743
744> Quote with [](quoted-empty.com)
745> And [valid quoted](quoted-valid.com)
746
747Code block should be ignored:
748```
749[](this-is-code)
750```
751
752[Reference style][ref1] and [][ref2]
753
754[ref1]: https://ref1.com
755[ref2]: https://ref2.com
756"#;
757
758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
759 let rule = MD042NoEmptyLinks::new();
760 let result = rule.check(&ctx).unwrap();
761
762 assert_eq!(result.len(), 1, "Should only flag empty URL links. Got: {result:?}");
766 assert_eq!(result[0].line, 8, "Only [inline empty]() should be flagged");
767 assert!(result[0].message.contains("[inline empty]()"));
768 }
769
770 #[test]
771 fn test_issue_29_code_block_with_tildes() {
772 let content = r#"In addition to the [local scope][] and the [global scope][], Python also has a **built-in scope**.
774
775```pycon
776>>> @count_calls
777... def greet(name):
778... print("Hi", name)
779...
780>>> greet("Trey")
781Traceback (most recent call last):
782 File "<python-input-2>", line 1, in <module>
783 greet("Trey")
784 ~~~~~^^^^^^^^
785 File "<python-input-0>", line 4, in wrapper
786 calls += 1
787 ^^^^^
788UnboundLocalError: cannot access local variable 'calls' where it is not associated with a value
789```
790
791
792[local scope]: https://www.pythonmorsels.com/local-and-global-variables/
793[global scope]: https://www.pythonmorsels.com/assigning-global-variables/"#;
794
795 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
796 let rule = MD042NoEmptyLinks::new();
797 let result = rule.check(&ctx).unwrap();
798
799 assert!(
801 result.is_empty(),
802 "Should not flag reference links as empty when code blocks contain tildes (issue #29). Got: {result:?}"
803 );
804 }
805
806 #[test]
807 fn test_link_with_inline_code_in_text() {
808 let ctx = LintContext::new(
810 "[`#[derive(Serialize, Deserialize)`](https://serde.rs/derive.html)",
811 crate::config::MarkdownFlavor::Standard,
812 None,
813 );
814 let rule = MD042NoEmptyLinks::new();
815 let result = rule.check(&ctx).unwrap();
816 assert!(
817 result.is_empty(),
818 "Links with inline code should not be flagged as empty. Got: {result:?}"
819 );
820 }
821
822 #[test]
823 fn test_mkdocs_backtick_wrapped_references() {
824 let rule = MD042NoEmptyLinks::new();
826
827 let ctx = LintContext::new("[`module.Class`][]", crate::config::MarkdownFlavor::MkDocs, None);
829 let result = rule.check(&ctx).unwrap();
830 assert!(
831 result.is_empty(),
832 "Should not flag [`module.Class`][] as empty in MkDocs mode (issue #97). Got: {result:?}"
833 );
834
835 let ctx = LintContext::new("[`module.Class`][ref]", crate::config::MarkdownFlavor::MkDocs, None);
837 let result = rule.check(&ctx).unwrap();
838 assert!(
839 result.is_empty(),
840 "Should not flag [`module.Class`][ref] as empty in MkDocs mode (issue #97). Got: {result:?}"
841 );
842
843 let ctx = LintContext::new("[`api/endpoint`][]", crate::config::MarkdownFlavor::MkDocs, None);
845 let result = rule.check(&ctx).unwrap();
846 assert!(
847 result.is_empty(),
848 "Should not flag [`api/endpoint`][] as empty in MkDocs mode (issue #97). Got: {result:?}"
849 );
850
851 let ctx = LintContext::new("[`module.Class`][]", crate::config::MarkdownFlavor::Standard, None);
854 let result = rule.check(&ctx).unwrap();
855 assert!(
856 result.is_empty(),
857 "MD042 should NOT flag [`module.Class`][] - undefined refs are MD052's job. Got: {result:?}"
858 );
859
860 let ctx = LintContext::new("[][]", crate::config::MarkdownFlavor::MkDocs, None);
862 let result = rule.check(&ctx).unwrap();
863 assert_eq!(
864 result.len(),
865 1,
866 "Should still flag [][] as empty in MkDocs mode. Got: {result:?}"
867 );
868 }
869}