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
149 for link in &ctx.links {
151 if ctx.is_in_jinja_range(link.byte_offset) {
153 continue;
154 }
155
156 let in_html_tag = ctx
159 .html_tags()
160 .iter()
161 .any(|html_tag| html_tag.byte_offset <= link.byte_offset && link.byte_offset < html_tag.byte_end);
162 if in_html_tag {
163 continue;
164 }
165
166 let (effective_url, is_undefined_reference): (&str, bool) = if link.is_reference {
173 if let Some(ref_id) = &link.reference_id {
174 match ctx.get_reference_url(ref_id.as_ref()) {
175 Some(url) => (url, false),
176 None => ("", true), }
178 } else {
179 ("", false) }
181 } else {
182 (&link.url, false)
183 };
184
185 if mkdocs_mode && link.is_reference {
190 if let Some(ref_id) = &link.reference_id {
192 let stripped_ref = Self::strip_backticks(ref_id);
193 if is_mkdocs_auto_reference(stripped_ref)
196 || (ref_id != stripped_ref && Self::is_valid_python_identifier(stripped_ref))
197 {
198 continue;
199 }
200 }
201 let stripped_text = Self::strip_backticks(&link.text);
203 if is_mkdocs_auto_reference(stripped_text)
205 || (link.text.as_ref() != stripped_text && Self::is_valid_python_identifier(stripped_text))
206 {
207 continue;
208 }
209 }
210
211 let link_markdown = &ctx.content[link.byte_offset..link.byte_end];
215 if link_markdown.starts_with('<') && link_markdown.ends_with('>') {
216 continue;
217 }
218
219 if link_markdown.starts_with("[[")
231 && link_markdown.ends_with(']')
232 && ctx.content.as_bytes().get(link.byte_end) == Some(&b']')
233 {
234 continue;
235 }
236
237 if is_undefined_reference && !link.text.trim().is_empty() {
240 continue;
241 }
242
243 if effective_url.trim().is_empty() {
247 if mkdocs_mode
249 && link.text.trim().is_empty()
250 && Self::is_mkdocs_attribute_anchor(ctx.content, link.byte_end)
251 {
252 continue;
254 }
255
256 let replacement = if !link.text.trim().is_empty() {
259 let text_is_url = link.text.starts_with("http://")
260 || link.text.starts_with("https://")
261 || link.text.starts_with("ftp://")
262 || link.text.starts_with("ftps://");
263
264 if text_is_url {
265 Some(format!("[{}]({})", link.text, link.text))
266 } else {
267 None
269 }
270 } else {
271 None
273 };
274
275 let link_display = &ctx.content[link.byte_offset..link.byte_end];
277
278 warnings.push(LintWarning {
279 rule_name: Some(self.name().to_string()),
280 message: format!("Empty link found: {link_display}"),
281 line: link.line,
282 column: link.start_col + 1, end_line: link.line,
284 end_column: link.end_col + 1, severity: Severity::Error,
286 fix: replacement.map(|r| Fix {
287 range: link.byte_offset..link.byte_end,
288 replacement: r,
289 }),
290 });
291 }
292 }
293
294 Ok(warnings)
295 }
296
297 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
298 let content = ctx.content;
299
300 let warnings = self.check(ctx)?;
302 if warnings.is_empty() {
303 return Ok(content.to_string());
304 }
305
306 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
308 .iter()
309 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.clone(), f.replacement.clone())))
310 .collect();
311
312 fixes.sort_by(|a, b| b.0.start.cmp(&a.0.start));
314
315 let mut result = content.to_string();
316
317 for (range, replacement) in fixes {
319 result.replace_range(range, &replacement);
320 }
321
322 Ok(result)
323 }
324
325 fn category(&self) -> RuleCategory {
327 RuleCategory::Link
328 }
329
330 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
332 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
333 }
334
335 fn as_any(&self) -> &dyn std::any::Any {
336 self
337 }
338
339 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
340 where
341 Self: Sized,
342 {
343 Box::new(MD042NoEmptyLinks::new())
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use crate::lint_context::LintContext;
352
353 #[test]
354 fn test_links_with_text_should_pass() {
355 let ctx = LintContext::new(
356 "[valid link](https://example.com)",
357 crate::config::MarkdownFlavor::Standard,
358 None,
359 );
360 let rule = MD042NoEmptyLinks::new();
361 let result = rule.check(&ctx).unwrap();
362 assert!(result.is_empty(), "Links with text should pass");
363
364 let ctx = LintContext::new(
365 "[another valid link](path/to/page.html)",
366 crate::config::MarkdownFlavor::Standard,
367 None,
368 );
369 let result = rule.check(&ctx).unwrap();
370 assert!(result.is_empty(), "Links with text and relative URLs should pass");
371 }
372
373 #[test]
374 fn test_links_with_empty_text_but_valid_url_pass() {
375 let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
378 let rule = MD042NoEmptyLinks::new();
379 let result = rule.check(&ctx).unwrap();
380 assert!(
381 result.is_empty(),
382 "Empty text with valid URL should NOT be flagged by MD042. Got: {result:?}"
383 );
384 }
385
386 #[test]
387 fn test_links_with_only_whitespace_but_valid_url_pass() {
388 let ctx = LintContext::new(
390 "[ ](https://example.com)",
391 crate::config::MarkdownFlavor::Standard,
392 None,
393 );
394 let rule = MD042NoEmptyLinks::new();
395 let result = rule.check(&ctx).unwrap();
396 assert!(
397 result.is_empty(),
398 "Whitespace text with valid URL should NOT be flagged. Got: {result:?}"
399 );
400
401 let ctx = LintContext::new(
402 "[\t\n](https://example.com)",
403 crate::config::MarkdownFlavor::Standard,
404 None,
405 );
406 let result = rule.check(&ctx).unwrap();
407 assert!(
408 result.is_empty(),
409 "Whitespace text with valid URL should NOT be flagged. Got: {result:?}"
410 );
411 }
412
413 #[test]
414 fn test_reference_links_with_empty_text_but_valid_ref() {
415 let ctx = LintContext::new(
418 "[][ref]\n\n[ref]: https://example.com",
419 crate::config::MarkdownFlavor::Standard,
420 None,
421 );
422 let rule = MD042NoEmptyLinks::new();
423 let result = rule.check(&ctx).unwrap();
424 assert!(
425 result.is_empty(),
426 "Empty text with valid reference should NOT be flagged. Got: {result:?}"
427 );
428
429 }
432
433 #[test]
434 fn test_images_should_be_ignored() {
435 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
437 let rule = MD042NoEmptyLinks::new();
438 let result = rule.check(&ctx).unwrap();
439 assert!(result.is_empty(), "Images with empty alt text should be ignored");
440
441 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
442 let result = rule.check(&ctx).unwrap();
443 assert!(result.is_empty(), "Images with whitespace alt text should be ignored");
444 }
445
446 #[test]
447 fn test_links_with_nested_formatting() {
448 let rule = MD042NoEmptyLinks::new();
450
451 let ctx = LintContext::new(
453 "[**](https://example.com)",
454 crate::config::MarkdownFlavor::Standard,
455 None,
456 );
457 let result = rule.check(&ctx).unwrap();
458 assert!(result.is_empty(), "[**](url) has URL so should pass");
459
460 let ctx = LintContext::new(
462 "[__](https://example.com)",
463 crate::config::MarkdownFlavor::Standard,
464 None,
465 );
466 let result = rule.check(&ctx).unwrap();
467 assert!(result.is_empty(), "[__](url) has URL so should pass");
468
469 let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
471 let result = rule.check(&ctx).unwrap();
472 assert!(result.is_empty(), "[](url) has URL so should pass");
473
474 let ctx = LintContext::new(
476 "[**bold text**](https://example.com)",
477 crate::config::MarkdownFlavor::Standard,
478 None,
479 );
480 let result = rule.check(&ctx).unwrap();
481 assert!(result.is_empty(), "Links with nested formatting and text should pass");
482
483 let ctx = LintContext::new(
485 "[*italic* and **bold**](https://example.com)",
486 crate::config::MarkdownFlavor::Standard,
487 None,
488 );
489 let result = rule.check(&ctx).unwrap();
490 assert!(result.is_empty(), "Links with multiple nested formatting should pass");
491 }
492
493 #[test]
494 fn test_multiple_empty_links_on_same_line() {
495 let ctx = LintContext::new(
497 "[](url1) and [](url2) and [valid](url3)",
498 crate::config::MarkdownFlavor::Standard,
499 None,
500 );
501 let rule = MD042NoEmptyLinks::new();
502 let result = rule.check(&ctx).unwrap();
503 assert!(
504 result.is_empty(),
505 "Empty text with valid URL should NOT be flagged. Got: {result:?}"
506 );
507
508 let ctx = LintContext::new(
510 "[text1]() and [text2]() and [text3](url)",
511 crate::config::MarkdownFlavor::Standard,
512 None,
513 );
514 let result = rule.check(&ctx).unwrap();
515 assert_eq!(result.len(), 2, "Should detect both empty URL links");
516 assert_eq!(result[0].column, 1); assert_eq!(result[1].column, 15); }
519
520 #[test]
521 fn test_escaped_brackets() {
522 let ctx = LintContext::new(
524 "\\[\\](https://example.com)",
525 crate::config::MarkdownFlavor::Standard,
526 None,
527 );
528 let rule = MD042NoEmptyLinks::new();
529 let result = rule.check(&ctx).unwrap();
530 assert!(result.is_empty(), "Escaped brackets should not be treated as links");
531
532 let ctx = LintContext::new(
534 "[\\[\\]](https://example.com)",
535 crate::config::MarkdownFlavor::Standard,
536 None,
537 );
538 let result = rule.check(&ctx).unwrap();
539 assert!(result.is_empty(), "Link with escaped brackets in text should pass");
540 }
541
542 #[test]
543 fn test_links_in_lists_and_blockquotes() {
544 let rule = MD042NoEmptyLinks::new();
546
547 let ctx = LintContext::new(
549 "- [](https://example.com)\n- [valid](https://example.com)",
550 crate::config::MarkdownFlavor::Standard,
551 None,
552 );
553 let result = rule.check(&ctx).unwrap();
554 assert!(result.is_empty(), "[](url) in lists should pass");
555
556 let ctx = LintContext::new(
558 "> [](https://example.com)\n> [valid](https://example.com)",
559 crate::config::MarkdownFlavor::Standard,
560 None,
561 );
562 let result = rule.check(&ctx).unwrap();
563 assert!(result.is_empty(), "[](url) in blockquotes should pass");
564
565 let ctx = LintContext::new(
567 "- [text]()\n- [valid](url)",
568 crate::config::MarkdownFlavor::Standard,
569 None,
570 );
571 let result = rule.check(&ctx).unwrap();
572 assert_eq!(result.len(), 1, "Empty URL should be flagged");
573 assert_eq!(result[0].line, 1);
574 }
575
576 #[test]
577 fn test_unicode_whitespace_characters() {
578 let rule = MD042NoEmptyLinks::new();
581
582 let ctx = LintContext::new(
584 "[\u{00A0}](https://example.com)",
585 crate::config::MarkdownFlavor::Standard,
586 None,
587 );
588 let result = rule.check(&ctx).unwrap();
589 assert!(result.is_empty(), "Has URL, should pass regardless of text");
590
591 let ctx = LintContext::new(
593 "[\u{2003}](https://example.com)",
594 crate::config::MarkdownFlavor::Standard,
595 None,
596 );
597 let result = rule.check(&ctx).unwrap();
598 assert!(result.is_empty(), "Has URL, should pass regardless of text");
599
600 let ctx = LintContext::new(
602 "[\u{200B}](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{200B} ](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
619 #[test]
620 fn test_empty_url_with_text() {
621 let ctx = LintContext::new("[some text]()", crate::config::MarkdownFlavor::Standard, None);
622 let rule = MD042NoEmptyLinks::new();
623 let result = rule.check(&ctx).unwrap();
624 assert_eq!(result.len(), 1);
625 assert_eq!(result[0].message, "Empty link found: [some text]()");
626 }
627
628 #[test]
629 fn test_both_empty_text_and_url() {
630 let ctx = LintContext::new("[]()", crate::config::MarkdownFlavor::Standard, None);
631 let rule = MD042NoEmptyLinks::new();
632 let result = rule.check(&ctx).unwrap();
633 assert_eq!(result.len(), 1);
634 assert_eq!(result[0].message, "Empty link found: []()");
635 }
636
637 #[test]
638 fn test_reference_link_with_undefined_reference() {
639 let ctx = LintContext::new("[text][undefined]", crate::config::MarkdownFlavor::Standard, None);
642 let rule = MD042NoEmptyLinks::new();
643 let result = rule.check(&ctx).unwrap();
644 assert!(
645 result.is_empty(),
646 "MD042 should NOT flag [text][undefined] - undefined refs are MD052's job. Got: {result:?}"
647 );
648
649 let ctx = LintContext::new("[][undefined]", crate::config::MarkdownFlavor::Standard, None);
651 let result = rule.check(&ctx).unwrap();
652 assert_eq!(result.len(), 1, "Empty text in reference link should still be flagged");
653 }
654
655 #[test]
656 fn test_shortcut_reference_links() {
657 let ctx = LintContext::new(
661 "[example][]\n\n[example]: https://example.com",
662 crate::config::MarkdownFlavor::Standard,
663 None,
664 );
665 let rule = MD042NoEmptyLinks::new();
666 let result = rule.check(&ctx).unwrap();
667 assert!(result.is_empty(), "Valid implicit reference link should pass");
668
669 let ctx = LintContext::new(
674 "[example]\n\n[example]: https://example.com",
675 crate::config::MarkdownFlavor::Standard,
676 None,
677 );
678 let result = rule.check(&ctx).unwrap();
679 assert!(
680 result.is_empty(),
681 "Shortcut links without [] or () are not parsed as links"
682 );
683 }
684
685 #[test]
686 fn test_fix_suggestions() {
687 let rule = MD042NoEmptyLinks::new();
689
690 let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
692 let result = rule.check(&ctx).unwrap();
693 assert!(result.is_empty(), "Empty text with URL should NOT be flagged");
694
695 let ctx = LintContext::new("[text]()", crate::config::MarkdownFlavor::Standard, None);
697 let result = rule.check(&ctx).unwrap();
698 assert_eq!(result.len(), 1, "Empty URL should be flagged");
699 assert!(
700 result[0].fix.is_none(),
701 "Non-URL text with empty URL should NOT be fixable"
702 );
703
704 let ctx = LintContext::new("[https://example.com]()", crate::config::MarkdownFlavor::Standard, None);
706 let result = rule.check(&ctx).unwrap();
707 assert_eq!(result.len(), 1, "Empty URL should be flagged");
708 assert!(result[0].fix.is_some(), "URL text with empty URL should be fixable");
709 let fix = result[0].fix.as_ref().unwrap();
710 assert_eq!(fix.replacement, "[https://example.com](https://example.com)");
711
712 let ctx = LintContext::new("[]()", crate::config::MarkdownFlavor::Standard, None);
714 let result = rule.check(&ctx).unwrap();
715 assert_eq!(result.len(), 1, "Empty URL should be flagged");
716 assert!(result[0].fix.is_none(), "Both empty should NOT be fixable");
717 }
718
719 #[test]
720 fn test_complex_markdown_document() {
721 let content = r#"# Document with various links
723
724[Valid link](https://example.com) followed by [](empty.com).
725
726## Lists with links
727- [Good link](url1)
728- [](url2)
729- Item with [inline empty]() link
730
731> Quote with [](quoted-empty.com)
732> And [valid quoted](quoted-valid.com)
733
734Code block should be ignored:
735```
736[](this-is-code)
737```
738
739[Reference style][ref1] and [][ref2]
740
741[ref1]: https://ref1.com
742[ref2]: https://ref2.com
743"#;
744
745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
746 let rule = MD042NoEmptyLinks::new();
747 let result = rule.check(&ctx).unwrap();
748
749 assert_eq!(result.len(), 1, "Should only flag empty URL links. Got: {result:?}");
753 assert_eq!(result[0].line, 8, "Only [inline empty]() should be flagged");
754 assert!(result[0].message.contains("[inline empty]()"));
755 }
756
757 #[test]
758 fn test_issue_29_code_block_with_tildes() {
759 let content = r#"In addition to the [local scope][] and the [global scope][], Python also has a **built-in scope**.
761
762```pycon
763>>> @count_calls
764... def greet(name):
765... print("Hi", name)
766...
767>>> greet("Trey")
768Traceback (most recent call last):
769 File "<python-input-2>", line 1, in <module>
770 greet("Trey")
771 ~~~~~^^^^^^^^
772 File "<python-input-0>", line 4, in wrapper
773 calls += 1
774 ^^^^^
775UnboundLocalError: cannot access local variable 'calls' where it is not associated with a value
776```
777
778
779[local scope]: https://www.pythonmorsels.com/local-and-global-variables/
780[global scope]: https://www.pythonmorsels.com/assigning-global-variables/"#;
781
782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783 let rule = MD042NoEmptyLinks::new();
784 let result = rule.check(&ctx).unwrap();
785
786 assert!(
788 result.is_empty(),
789 "Should not flag reference links as empty when code blocks contain tildes (issue #29). Got: {result:?}"
790 );
791 }
792
793 #[test]
794 fn test_link_with_inline_code_in_text() {
795 let ctx = LintContext::new(
797 "[`#[derive(Serialize, Deserialize)`](https://serde.rs/derive.html)",
798 crate::config::MarkdownFlavor::Standard,
799 None,
800 );
801 let rule = MD042NoEmptyLinks::new();
802 let result = rule.check(&ctx).unwrap();
803 assert!(
804 result.is_empty(),
805 "Links with inline code should not be flagged as empty. Got: {result:?}"
806 );
807 }
808
809 #[test]
810 fn test_mkdocs_backtick_wrapped_references() {
811 let rule = MD042NoEmptyLinks::new();
813
814 let ctx = LintContext::new("[`module.Class`][]", crate::config::MarkdownFlavor::MkDocs, None);
816 let result = rule.check(&ctx).unwrap();
817 assert!(
818 result.is_empty(),
819 "Should not flag [`module.Class`][] as empty in MkDocs mode (issue #97). Got: {result:?}"
820 );
821
822 let ctx = LintContext::new("[`module.Class`][ref]", crate::config::MarkdownFlavor::MkDocs, None);
824 let result = rule.check(&ctx).unwrap();
825 assert!(
826 result.is_empty(),
827 "Should not flag [`module.Class`][ref] as empty in MkDocs mode (issue #97). Got: {result:?}"
828 );
829
830 let ctx = LintContext::new("[`api/endpoint`][]", crate::config::MarkdownFlavor::MkDocs, None);
832 let result = rule.check(&ctx).unwrap();
833 assert!(
834 result.is_empty(),
835 "Should not flag [`api/endpoint`][] as empty in MkDocs mode (issue #97). Got: {result:?}"
836 );
837
838 let ctx = LintContext::new("[`module.Class`][]", crate::config::MarkdownFlavor::Standard, None);
841 let result = rule.check(&ctx).unwrap();
842 assert!(
843 result.is_empty(),
844 "MD042 should NOT flag [`module.Class`][] - undefined refs are MD052's job. Got: {result:?}"
845 );
846
847 let ctx = LintContext::new("[][]", crate::config::MarkdownFlavor::MkDocs, None);
849 let result = rule.check(&ctx).unwrap();
850 assert_eq!(
851 result.len(),
852 1,
853 "Should still flag [][] as empty in MkDocs mode. Got: {result:?}"
854 );
855 }
856}