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