1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::mkdocs_patterns::is_mkdocs_auto_reference;
3
4#[derive(Clone, Default)]
48pub struct MD042NoEmptyLinks {}
49
50impl MD042NoEmptyLinks {
51 pub fn new() -> Self {
52 Self {}
53 }
54
55 fn strip_backticks(s: &str) -> &str {
58 s.trim_start_matches('`').trim_end_matches('`')
59 }
60
61 fn is_valid_python_identifier(s: &str) -> bool {
64 if s.is_empty() {
65 return false;
66 }
67
68 let first_char = s.chars().next().unwrap();
69 if !first_char.is_ascii_alphabetic() && first_char != '_' {
70 return false;
71 }
72
73 s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
74 }
75
76 fn is_mkdocs_attribute_anchor(content: &str, link_end: usize) -> bool {
83 if !content.is_char_boundary(link_end) {
85 return false;
86 }
87
88 if let Some(rest) = content.get(link_end..) {
90 let trimmed = rest.trim_start();
94
95 let stripped = if let Some(s) = trimmed.strip_prefix("{:") {
97 s
98 } else if let Some(s) = trimmed.strip_prefix('{') {
99 s
100 } else {
101 return false;
102 };
103
104 if let Some(end_brace) = stripped.find('}') {
106 if end_brace > 500 {
108 return false;
109 }
110
111 let attrs = stripped[..end_brace].trim();
112
113 if attrs.is_empty() {
115 return false;
116 }
117
118 return attrs
122 .split_whitespace()
123 .any(|part| part.starts_with('#') || part.starts_with('.'));
124 }
125 }
126 false
127 }
128}
129
130impl Rule for MD042NoEmptyLinks {
131 fn name(&self) -> &'static str {
132 "MD042"
133 }
134
135 fn description(&self) -> &'static str {
136 "No empty links"
137 }
138
139 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
140 let mut warnings = Vec::new();
141
142 let mkdocs_mode = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
144
145 for link in &ctx.links {
147 if ctx.is_in_jinja_range(link.byte_offset) {
149 continue;
150 }
151
152 let in_html_tag = ctx
155 .html_tags()
156 .iter()
157 .any(|html_tag| html_tag.byte_offset <= link.byte_offset && link.byte_offset < html_tag.byte_end);
158 if in_html_tag {
159 continue;
160 }
161
162 let effective_url: &str = if link.is_reference {
164 if let Some(ref_id) = &link.reference_id {
165 ctx.get_reference_url(ref_id.as_ref()).unwrap_or("")
166 } else {
167 ""
168 }
169 } else {
170 &link.url
171 };
172
173 if mkdocs_mode && link.is_reference {
178 if let Some(ref_id) = &link.reference_id {
180 let stripped_ref = Self::strip_backticks(ref_id);
181 if is_mkdocs_auto_reference(stripped_ref)
184 || (ref_id != stripped_ref && Self::is_valid_python_identifier(stripped_ref))
185 {
186 continue;
187 }
188 }
189 let stripped_text = Self::strip_backticks(&link.text);
191 if is_mkdocs_auto_reference(stripped_text)
193 || (link.text.as_ref() != stripped_text && Self::is_valid_python_identifier(stripped_text))
194 {
195 continue;
196 }
197 }
198
199 let link_markdown = &ctx.content[link.byte_offset..link.byte_end];
203 if link_markdown.starts_with('<') && link_markdown.ends_with('>') {
204 continue;
205 }
206
207 if link_markdown.starts_with("[[")
219 && link_markdown.ends_with(']')
220 && ctx.content.as_bytes().get(link.byte_end) == Some(&b']')
221 {
222 continue;
223 }
224
225 if link.text.trim().is_empty() || effective_url.trim().is_empty() {
227 if mkdocs_mode
229 && link.text.trim().is_empty()
230 && effective_url.trim().is_empty()
231 && Self::is_mkdocs_attribute_anchor(ctx.content, link.byte_end)
232 {
233 continue;
235 }
236
237 let replacement = if link.text.trim().is_empty() {
239 if !effective_url.trim().is_empty() {
241 if link.is_reference {
243 Some(format!(
244 "[Link text]{}",
245 &ctx.content[link.byte_offset + 1..link.byte_end]
246 ))
247 } else {
248 Some(format!("[Link text]({effective_url})"))
249 }
250 } else {
251 None
253 }
254 } else if link.is_reference {
255 let ref_part = &ctx.content[link.byte_offset + link.text.len() + 2..link.byte_end];
257 Some(format!("[{}]{}", link.text, ref_part))
258 } else {
259 let text_is_url = link.text.starts_with("http://")
262 || link.text.starts_with("https://")
263 || link.text.starts_with("ftp://")
264 || link.text.starts_with("ftps://");
265
266 if text_is_url {
267 Some(format!("[{}]({})", link.text, link.text))
268 } else {
269 None
271 }
272 };
273
274 let link_display = &ctx.content[link.byte_offset..link.byte_end];
276
277 warnings.push(LintWarning {
278 rule_name: Some(self.name().to_string()),
279 message: format!("Empty link found: {link_display}"),
280 line: link.line,
281 column: link.start_col + 1, end_line: link.line,
283 end_column: link.end_col + 1, severity: Severity::Warning,
285 fix: replacement.map(|r| Fix {
286 range: link.byte_offset..link.byte_end,
287 replacement: r,
288 }),
289 });
290 }
291 }
292
293 Ok(warnings)
294 }
295
296 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
297 let content = ctx.content;
298
299 let warnings = self.check(ctx)?;
301 if warnings.is_empty() {
302 return Ok(content.to_string());
303 }
304
305 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
307 .iter()
308 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.clone(), f.replacement.clone())))
309 .collect();
310
311 fixes.sort_by(|a, b| b.0.start.cmp(&a.0.start));
313
314 let mut result = content.to_string();
315
316 for (range, replacement) in fixes {
318 result.replace_range(range, &replacement);
319 }
320
321 Ok(result)
322 }
323
324 fn category(&self) -> RuleCategory {
326 RuleCategory::Link
327 }
328
329 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
331 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
332 }
333
334 fn as_any(&self) -> &dyn std::any::Any {
335 self
336 }
337
338 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
339 where
340 Self: Sized,
341 {
342 Box::new(MD042NoEmptyLinks::new())
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350 use crate::lint_context::LintContext;
351
352 #[test]
353 fn test_links_with_text_should_pass() {
354 let ctx = LintContext::new(
355 "[valid link](https://example.com)",
356 crate::config::MarkdownFlavor::Standard,
357 None,
358 );
359 let rule = MD042NoEmptyLinks::new();
360 let result = rule.check(&ctx).unwrap();
361 assert!(result.is_empty(), "Links with text should pass");
362
363 let ctx = LintContext::new(
364 "[another valid link](path/to/page.html)",
365 crate::config::MarkdownFlavor::Standard,
366 None,
367 );
368 let result = rule.check(&ctx).unwrap();
369 assert!(result.is_empty(), "Links with text and relative URLs should pass");
370 }
371
372 #[test]
373 fn test_links_with_empty_text_should_fail() {
374 let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
375 let rule = MD042NoEmptyLinks::new();
376 let result = rule.check(&ctx).unwrap();
377 assert_eq!(result.len(), 1);
378 assert_eq!(result[0].message, "Empty link found: [](https://example.com)");
379 assert_eq!(result[0].line, 1);
380 assert_eq!(result[0].column, 1);
381 }
382
383 #[test]
384 fn test_links_with_only_whitespace_should_fail() {
385 let ctx = LintContext::new(
386 "[ ](https://example.com)",
387 crate::config::MarkdownFlavor::Standard,
388 None,
389 );
390 let rule = MD042NoEmptyLinks::new();
391 let result = rule.check(&ctx).unwrap();
392 assert_eq!(result.len(), 1);
393 assert_eq!(result[0].message, "Empty link found: [ ](https://example.com)");
394
395 let ctx = LintContext::new(
396 "[\t\n](https://example.com)",
397 crate::config::MarkdownFlavor::Standard,
398 None,
399 );
400 let result = rule.check(&ctx).unwrap();
401 assert_eq!(result.len(), 1);
402 assert_eq!(result[0].message, "Empty link found: [\t\n](https://example.com)");
403 }
404
405 #[test]
406 fn test_reference_links_with_empty_text() {
407 let ctx = LintContext::new(
408 "[][ref]\n\n[ref]: 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_eq!(result.len(), 1);
415 assert_eq!(result[0].message, "Empty link found: [][ref]");
416 assert_eq!(result[0].line, 1);
417
418 let ctx = LintContext::new(
420 "[][]\n\n[]: https://example.com",
421 crate::config::MarkdownFlavor::Standard,
422 None,
423 );
424 let result = rule.check(&ctx).unwrap();
425 assert_eq!(result.len(), 1);
426 }
427
428 #[test]
429 fn test_images_should_be_ignored() {
430 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
432 let rule = MD042NoEmptyLinks::new();
433 let result = rule.check(&ctx).unwrap();
434 assert!(result.is_empty(), "Images with empty alt text should be ignored");
435
436 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
437 let result = rule.check(&ctx).unwrap();
438 assert!(result.is_empty(), "Images with whitespace alt text should be ignored");
439 }
440
441 #[test]
442 fn test_links_with_nested_formatting() {
443 let ctx = LintContext::new(
446 "[**](https://example.com)",
447 crate::config::MarkdownFlavor::Standard,
448 None,
449 );
450 let rule = MD042NoEmptyLinks::new();
451 let result = rule.check(&ctx).unwrap();
452 assert!(result.is_empty(), "[**] is not considered empty since ** is text");
453
454 let ctx = LintContext::new(
455 "[__](https://example.com)",
456 crate::config::MarkdownFlavor::Standard,
457 None,
458 );
459 let result = rule.check(&ctx).unwrap();
460 assert!(result.is_empty(), "[__] is not considered empty since __ is text");
461
462 let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
464 let result = rule.check(&ctx).unwrap();
465 assert_eq!(result.len(), 1);
466
467 let ctx = LintContext::new(
469 "[**bold text**](https://example.com)",
470 crate::config::MarkdownFlavor::Standard,
471 None,
472 );
473 let result = rule.check(&ctx).unwrap();
474 assert!(result.is_empty(), "Links with nested formatting and text should pass");
475
476 let ctx = LintContext::new(
477 "[*italic* and **bold**](https://example.com)",
478 crate::config::MarkdownFlavor::Standard,
479 None,
480 );
481 let result = rule.check(&ctx).unwrap();
482 assert!(result.is_empty(), "Links with multiple nested formatting should pass");
483 }
484
485 #[test]
486 fn test_multiple_empty_links_on_same_line() {
487 let ctx = LintContext::new(
488 "[](url1) and [](url2) and [valid](url3)",
489 crate::config::MarkdownFlavor::Standard,
490 None,
491 );
492 let rule = MD042NoEmptyLinks::new();
493 let result = rule.check(&ctx).unwrap();
494 assert_eq!(result.len(), 2, "Should detect both empty links");
495 assert_eq!(result[0].column, 1);
496 assert_eq!(result[1].column, 14);
497 }
498
499 #[test]
500 fn test_escaped_brackets() {
501 let ctx = LintContext::new(
503 "\\[\\](https://example.com)",
504 crate::config::MarkdownFlavor::Standard,
505 None,
506 );
507 let rule = MD042NoEmptyLinks::new();
508 let result = rule.check(&ctx).unwrap();
509 assert!(result.is_empty(), "Escaped brackets should not be treated as links");
510
511 let ctx = LintContext::new(
513 "[\\[\\]](https://example.com)",
514 crate::config::MarkdownFlavor::Standard,
515 None,
516 );
517 let result = rule.check(&ctx).unwrap();
518 assert!(result.is_empty(), "Link with escaped brackets in text should pass");
519 }
520
521 #[test]
522 fn test_links_in_lists_and_blockquotes() {
523 let ctx = LintContext::new(
525 "- [](https://example.com)\n- [valid](https://example.com)",
526 crate::config::MarkdownFlavor::Standard,
527 None,
528 );
529 let rule = MD042NoEmptyLinks::new();
530 let result = rule.check(&ctx).unwrap();
531 assert_eq!(result.len(), 1);
532 assert_eq!(result[0].line, 1);
533
534 let ctx = LintContext::new(
536 "> [](https://example.com)\n> [valid](https://example.com)",
537 crate::config::MarkdownFlavor::Standard,
538 None,
539 );
540 let result = rule.check(&ctx).unwrap();
541 assert_eq!(result.len(), 1);
542 assert_eq!(result[0].line, 1);
543
544 let ctx = LintContext::new(
546 "> - [](url1)\n> - [text](url2)",
547 crate::config::MarkdownFlavor::Standard,
548 None,
549 );
550 let result = rule.check(&ctx).unwrap();
551 assert_eq!(result.len(), 1);
552 }
553
554 #[test]
555 fn test_unicode_whitespace_characters() {
556 let ctx = LintContext::new(
558 "[\u{00A0}](https://example.com)",
559 crate::config::MarkdownFlavor::Standard,
560 None,
561 );
562 let rule = MD042NoEmptyLinks::new();
563 let result = rule.check(&ctx).unwrap();
564 assert_eq!(result.len(), 1, "Non-breaking space should be treated as whitespace");
565
566 let ctx = LintContext::new(
568 "[\u{2003}](https://example.com)",
569 crate::config::MarkdownFlavor::Standard,
570 None,
571 );
572 let result = rule.check(&ctx).unwrap();
573 assert_eq!(result.len(), 1, "Em space should be treated as whitespace");
574
575 let ctx = LintContext::new(
578 "[\u{200B}](https://example.com)",
579 crate::config::MarkdownFlavor::Standard,
580 None,
581 );
582 let result = rule.check(&ctx).unwrap();
583 assert!(
584 result.is_empty(),
585 "Zero-width space is not considered whitespace by trim()"
586 );
587
588 let ctx = LintContext::new(
592 "[ \u{200B} ](https://example.com)",
593 crate::config::MarkdownFlavor::Standard,
594 None,
595 );
596 let result = rule.check(&ctx).unwrap();
597 assert!(
598 result.is_empty(),
599 "Zero-width space remains after trim(), so link is not empty"
600 );
601 }
602
603 #[test]
604 fn test_empty_url_with_text() {
605 let ctx = LintContext::new("[some text]()", crate::config::MarkdownFlavor::Standard, None);
606 let rule = MD042NoEmptyLinks::new();
607 let result = rule.check(&ctx).unwrap();
608 assert_eq!(result.len(), 1);
609 assert_eq!(result[0].message, "Empty link found: [some text]()");
610 }
611
612 #[test]
613 fn test_both_empty_text_and_url() {
614 let ctx = LintContext::new("[]()", crate::config::MarkdownFlavor::Standard, None);
615 let rule = MD042NoEmptyLinks::new();
616 let result = rule.check(&ctx).unwrap();
617 assert_eq!(result.len(), 1);
618 assert_eq!(result[0].message, "Empty link found: []()");
619 }
620
621 #[test]
622 fn test_reference_link_with_undefined_reference() {
623 let ctx = LintContext::new("[text][undefined]", crate::config::MarkdownFlavor::Standard, None);
624 let rule = MD042NoEmptyLinks::new();
625 let result = rule.check(&ctx).unwrap();
626 assert_eq!(result.len(), 1, "Undefined reference should be treated as empty URL");
627 }
628
629 #[test]
630 fn test_shortcut_reference_links() {
631 let ctx = LintContext::new(
635 "[example][]\n\n[example]: https://example.com",
636 crate::config::MarkdownFlavor::Standard,
637 None,
638 );
639 let rule = MD042NoEmptyLinks::new();
640 let result = rule.check(&ctx).unwrap();
641 assert!(result.is_empty(), "Valid implicit reference link should pass");
642
643 let ctx = LintContext::new(
645 "[][]\n\n[]: https://example.com",
646 crate::config::MarkdownFlavor::Standard,
647 None,
648 );
649 let result = rule.check(&ctx).unwrap();
650 assert_eq!(result.len(), 1, "Empty implicit reference link should fail");
651
652 let ctx = LintContext::new(
654 "[example]\n\n[example]: https://example.com",
655 crate::config::MarkdownFlavor::Standard,
656 None,
657 );
658 let result = rule.check(&ctx).unwrap();
659 assert!(
660 result.is_empty(),
661 "Shortcut links without [] or () are not parsed as links"
662 );
663 }
664
665 #[test]
666 fn test_fix_suggestions() {
667 let rule = MD042NoEmptyLinks::new();
668
669 let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
671 let result = rule.check(&ctx).unwrap();
672 assert!(result[0].fix.is_some(), "Empty text with URL should be fixable");
673 let fix = result[0].fix.as_ref().unwrap();
674 assert_eq!(fix.replacement, "[Link text](https://example.com)");
675
676 let ctx = LintContext::new("[text]()", crate::config::MarkdownFlavor::Standard, None);
678 let result = rule.check(&ctx).unwrap();
679 assert!(
680 result[0].fix.is_none(),
681 "Non-URL text with empty URL should NOT be fixable"
682 );
683
684 let ctx = LintContext::new("[https://example.com]()", crate::config::MarkdownFlavor::Standard, None);
686 let result = rule.check(&ctx).unwrap();
687 assert!(result[0].fix.is_some(), "URL text with empty URL should be fixable");
688 let fix = result[0].fix.as_ref().unwrap();
689 assert_eq!(fix.replacement, "[https://example.com](https://example.com)");
690
691 let ctx = LintContext::new("[]()", crate::config::MarkdownFlavor::Standard, None);
693 let result = rule.check(&ctx).unwrap();
694 assert!(result[0].fix.is_none(), "Both empty should NOT be fixable");
695 }
696
697 #[test]
698 fn test_complex_markdown_document() {
699 let content = r#"# Document with various links
700
701[Valid link](https://example.com) followed by [](empty.com).
702
703## Lists with links
704- [Good link](url1)
705- [](url2)
706- Item with [inline empty]() link
707
708> Quote with [](quoted-empty.com)
709> And [valid quoted](quoted-valid.com)
710
711Code block should be ignored:
712```
713[](this-is-code)
714```
715
716[Reference style][ref1] and [][ref2]
717
718[ref1]: https://ref1.com
719[ref2]: https://ref2.com
720"#;
721
722 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
723 let rule = MD042NoEmptyLinks::new();
724 let result = rule.check(&ctx).unwrap();
725
726 let empty_link_lines = [3, 7, 8, 10, 18];
728 assert_eq!(result.len(), empty_link_lines.len(), "Should find all empty links");
729
730 for (i, &expected_line) in empty_link_lines.iter().enumerate() {
732 assert_eq!(
733 result[i].line, expected_line,
734 "Empty link {i} should be on line {expected_line}"
735 );
736 }
737 }
738
739 #[test]
740 fn test_issue_29_code_block_with_tildes() {
741 let content = r#"In addition to the [local scope][] and the [global scope][], Python also has a **built-in scope**.
743
744```pycon
745>>> @count_calls
746... def greet(name):
747... print("Hi", name)
748...
749>>> greet("Trey")
750Traceback (most recent call last):
751 File "<python-input-2>", line 1, in <module>
752 greet("Trey")
753 ~~~~~^^^^^^^^
754 File "<python-input-0>", line 4, in wrapper
755 calls += 1
756 ^^^^^
757UnboundLocalError: cannot access local variable 'calls' where it is not associated with a value
758```
759
760
761[local scope]: https://www.pythonmorsels.com/local-and-global-variables/
762[global scope]: https://www.pythonmorsels.com/assigning-global-variables/"#;
763
764 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
765 let rule = MD042NoEmptyLinks::new();
766 let result = rule.check(&ctx).unwrap();
767
768 assert!(
770 result.is_empty(),
771 "Should not flag reference links as empty when code blocks contain tildes (issue #29). Got: {result:?}"
772 );
773 }
774
775 #[test]
776 fn test_link_with_inline_code_in_text() {
777 let ctx = LintContext::new(
779 "[`#[derive(Serialize, Deserialize)`](https://serde.rs/derive.html)",
780 crate::config::MarkdownFlavor::Standard,
781 None,
782 );
783 let rule = MD042NoEmptyLinks::new();
784 let result = rule.check(&ctx).unwrap();
785 assert!(
786 result.is_empty(),
787 "Links with inline code should not be flagged as empty. Got: {result:?}"
788 );
789 }
790
791 #[test]
792 fn test_mkdocs_backtick_wrapped_references() {
793 let rule = MD042NoEmptyLinks::new();
795
796 let ctx = LintContext::new("[`module.Class`][]", crate::config::MarkdownFlavor::MkDocs, None);
798 let result = rule.check(&ctx).unwrap();
799 assert!(
800 result.is_empty(),
801 "Should not flag [`module.Class`][] as empty in MkDocs mode (issue #97). Got: {result:?}"
802 );
803
804 let ctx = LintContext::new("[`module.Class`][ref]", crate::config::MarkdownFlavor::MkDocs, None);
806 let result = rule.check(&ctx).unwrap();
807 assert!(
808 result.is_empty(),
809 "Should not flag [`module.Class`][ref] as empty in MkDocs mode (issue #97). Got: {result:?}"
810 );
811
812 let ctx = LintContext::new("[`api/endpoint`][]", crate::config::MarkdownFlavor::MkDocs, None);
814 let result = rule.check(&ctx).unwrap();
815 assert!(
816 result.is_empty(),
817 "Should not flag [`api/endpoint`][] as empty in MkDocs mode (issue #97). Got: {result:?}"
818 );
819
820 let ctx = LintContext::new("[`module.Class`][]", crate::config::MarkdownFlavor::Standard, None);
822 let result = rule.check(&ctx).unwrap();
823 assert_eq!(
824 result.len(),
825 1,
826 "Should flag [`module.Class`][] as empty in Standard mode (no auto-refs). Got: {result:?}"
827 );
828
829 let ctx = LintContext::new("[][]", crate::config::MarkdownFlavor::MkDocs, None);
831 let result = rule.check(&ctx).unwrap();
832 assert_eq!(
833 result.len(),
834 1,
835 "Should still flag [][] as empty in MkDocs mode. Got: {result:?}"
836 );
837 }
838}