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 );
358 let rule = MD042NoEmptyLinks::new();
359 let result = rule.check(&ctx).unwrap();
360 assert!(result.is_empty(), "Links with text should pass");
361
362 let ctx = LintContext::new(
363 "[another valid link](path/to/page.html)",
364 crate::config::MarkdownFlavor::Standard,
365 );
366 let result = rule.check(&ctx).unwrap();
367 assert!(result.is_empty(), "Links with text and relative URLs should pass");
368 }
369
370 #[test]
371 fn test_links_with_empty_text_should_fail() {
372 let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard);
373 let rule = MD042NoEmptyLinks::new();
374 let result = rule.check(&ctx).unwrap();
375 assert_eq!(result.len(), 1);
376 assert_eq!(result[0].message, "Empty link found: [](https://example.com)");
377 assert_eq!(result[0].line, 1);
378 assert_eq!(result[0].column, 1);
379 }
380
381 #[test]
382 fn test_links_with_only_whitespace_should_fail() {
383 let ctx = LintContext::new("[ ](https://example.com)", crate::config::MarkdownFlavor::Standard);
384 let rule = MD042NoEmptyLinks::new();
385 let result = rule.check(&ctx).unwrap();
386 assert_eq!(result.len(), 1);
387 assert_eq!(result[0].message, "Empty link found: [ ](https://example.com)");
388
389 let ctx = LintContext::new("[\t\n](https://example.com)", crate::config::MarkdownFlavor::Standard);
390 let result = rule.check(&ctx).unwrap();
391 assert_eq!(result.len(), 1);
392 assert_eq!(result[0].message, "Empty link found: [\t\n](https://example.com)");
393 }
394
395 #[test]
396 fn test_reference_links_with_empty_text() {
397 let ctx = LintContext::new(
398 "[][ref]\n\n[ref]: https://example.com",
399 crate::config::MarkdownFlavor::Standard,
400 );
401 let rule = MD042NoEmptyLinks::new();
402 let result = rule.check(&ctx).unwrap();
403 assert_eq!(result.len(), 1);
404 assert_eq!(result[0].message, "Empty link found: [][ref]");
405 assert_eq!(result[0].line, 1);
406
407 let ctx = LintContext::new(
409 "[][]\n\n[]: https://example.com",
410 crate::config::MarkdownFlavor::Standard,
411 );
412 let result = rule.check(&ctx).unwrap();
413 assert_eq!(result.len(), 1);
414 }
415
416 #[test]
417 fn test_images_should_be_ignored() {
418 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
420 let rule = MD042NoEmptyLinks::new();
421 let result = rule.check(&ctx).unwrap();
422 assert!(result.is_empty(), "Images with empty alt text should be ignored");
423
424 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
425 let result = rule.check(&ctx).unwrap();
426 assert!(result.is_empty(), "Images with whitespace alt text should be ignored");
427 }
428
429 #[test]
430 fn test_links_with_nested_formatting() {
431 let ctx = LintContext::new("[**](https://example.com)", crate::config::MarkdownFlavor::Standard);
434 let rule = MD042NoEmptyLinks::new();
435 let result = rule.check(&ctx).unwrap();
436 assert!(result.is_empty(), "[**] is not considered empty since ** is text");
437
438 let ctx = LintContext::new("[__](https://example.com)", crate::config::MarkdownFlavor::Standard);
439 let result = rule.check(&ctx).unwrap();
440 assert!(result.is_empty(), "[__] is not considered empty since __ is text");
441
442 let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard);
444 let result = rule.check(&ctx).unwrap();
445 assert_eq!(result.len(), 1);
446
447 let ctx = LintContext::new(
449 "[**bold text**](https://example.com)",
450 crate::config::MarkdownFlavor::Standard,
451 );
452 let result = rule.check(&ctx).unwrap();
453 assert!(result.is_empty(), "Links with nested formatting and text should pass");
454
455 let ctx = LintContext::new(
456 "[*italic* and **bold**](https://example.com)",
457 crate::config::MarkdownFlavor::Standard,
458 );
459 let result = rule.check(&ctx).unwrap();
460 assert!(result.is_empty(), "Links with multiple nested formatting should pass");
461 }
462
463 #[test]
464 fn test_multiple_empty_links_on_same_line() {
465 let ctx = LintContext::new(
466 "[](url1) and [](url2) and [valid](url3)",
467 crate::config::MarkdownFlavor::Standard,
468 );
469 let rule = MD042NoEmptyLinks::new();
470 let result = rule.check(&ctx).unwrap();
471 assert_eq!(result.len(), 2, "Should detect both empty links");
472 assert_eq!(result[0].column, 1);
473 assert_eq!(result[1].column, 14);
474 }
475
476 #[test]
477 fn test_escaped_brackets() {
478 let ctx = LintContext::new("\\[\\](https://example.com)", crate::config::MarkdownFlavor::Standard);
480 let rule = MD042NoEmptyLinks::new();
481 let result = rule.check(&ctx).unwrap();
482 assert!(result.is_empty(), "Escaped brackets should not be treated as links");
483
484 let ctx = LintContext::new("[\\[\\]](https://example.com)", crate::config::MarkdownFlavor::Standard);
486 let result = rule.check(&ctx).unwrap();
487 assert!(result.is_empty(), "Link with escaped brackets in text should pass");
488 }
489
490 #[test]
491 fn test_links_in_lists_and_blockquotes() {
492 let ctx = LintContext::new(
494 "- [](https://example.com)\n- [valid](https://example.com)",
495 crate::config::MarkdownFlavor::Standard,
496 );
497 let rule = MD042NoEmptyLinks::new();
498 let result = rule.check(&ctx).unwrap();
499 assert_eq!(result.len(), 1);
500 assert_eq!(result[0].line, 1);
501
502 let ctx = LintContext::new(
504 "> [](https://example.com)\n> [valid](https://example.com)",
505 crate::config::MarkdownFlavor::Standard,
506 );
507 let result = rule.check(&ctx).unwrap();
508 assert_eq!(result.len(), 1);
509 assert_eq!(result[0].line, 1);
510
511 let ctx = LintContext::new(
513 "> - [](url1)\n> - [text](url2)",
514 crate::config::MarkdownFlavor::Standard,
515 );
516 let result = rule.check(&ctx).unwrap();
517 assert_eq!(result.len(), 1);
518 }
519
520 #[test]
521 fn test_unicode_whitespace_characters() {
522 let ctx = LintContext::new(
524 "[\u{00A0}](https://example.com)",
525 crate::config::MarkdownFlavor::Standard,
526 );
527 let rule = MD042NoEmptyLinks::new();
528 let result = rule.check(&ctx).unwrap();
529 assert_eq!(result.len(), 1, "Non-breaking space should be treated as whitespace");
530
531 let ctx = LintContext::new(
533 "[\u{2003}](https://example.com)",
534 crate::config::MarkdownFlavor::Standard,
535 );
536 let result = rule.check(&ctx).unwrap();
537 assert_eq!(result.len(), 1, "Em space should be treated as whitespace");
538
539 let ctx = LintContext::new(
542 "[\u{200B}](https://example.com)",
543 crate::config::MarkdownFlavor::Standard,
544 );
545 let result = rule.check(&ctx).unwrap();
546 assert!(
547 result.is_empty(),
548 "Zero-width space is not considered whitespace by trim()"
549 );
550
551 let ctx = LintContext::new(
555 "[ \u{200B} ](https://example.com)",
556 crate::config::MarkdownFlavor::Standard,
557 );
558 let result = rule.check(&ctx).unwrap();
559 assert!(
560 result.is_empty(),
561 "Zero-width space remains after trim(), so link is not empty"
562 );
563 }
564
565 #[test]
566 fn test_empty_url_with_text() {
567 let ctx = LintContext::new("[some text]()", crate::config::MarkdownFlavor::Standard);
568 let rule = MD042NoEmptyLinks::new();
569 let result = rule.check(&ctx).unwrap();
570 assert_eq!(result.len(), 1);
571 assert_eq!(result[0].message, "Empty link found: [some text]()");
572 }
573
574 #[test]
575 fn test_both_empty_text_and_url() {
576 let ctx = LintContext::new("[]()", crate::config::MarkdownFlavor::Standard);
577 let rule = MD042NoEmptyLinks::new();
578 let result = rule.check(&ctx).unwrap();
579 assert_eq!(result.len(), 1);
580 assert_eq!(result[0].message, "Empty link found: []()");
581 }
582
583 #[test]
584 fn test_reference_link_with_undefined_reference() {
585 let ctx = LintContext::new("[text][undefined]", crate::config::MarkdownFlavor::Standard);
586 let rule = MD042NoEmptyLinks::new();
587 let result = rule.check(&ctx).unwrap();
588 assert_eq!(result.len(), 1, "Undefined reference should be treated as empty URL");
589 }
590
591 #[test]
592 fn test_shortcut_reference_links() {
593 let ctx = LintContext::new(
597 "[example][]\n\n[example]: https://example.com",
598 crate::config::MarkdownFlavor::Standard,
599 );
600 let rule = MD042NoEmptyLinks::new();
601 let result = rule.check(&ctx).unwrap();
602 assert!(result.is_empty(), "Valid implicit reference link should pass");
603
604 let ctx = LintContext::new(
606 "[][]\n\n[]: https://example.com",
607 crate::config::MarkdownFlavor::Standard,
608 );
609 let result = rule.check(&ctx).unwrap();
610 assert_eq!(result.len(), 1, "Empty implicit reference link should fail");
611
612 let ctx = LintContext::new(
614 "[example]\n\n[example]: https://example.com",
615 crate::config::MarkdownFlavor::Standard,
616 );
617 let result = rule.check(&ctx).unwrap();
618 assert!(
619 result.is_empty(),
620 "Shortcut links without [] or () are not parsed as links"
621 );
622 }
623
624 #[test]
625 fn test_fix_suggestions() {
626 let rule = MD042NoEmptyLinks::new();
627
628 let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard);
630 let result = rule.check(&ctx).unwrap();
631 assert!(result[0].fix.is_some(), "Empty text with URL should be fixable");
632 let fix = result[0].fix.as_ref().unwrap();
633 assert_eq!(fix.replacement, "[Link text](https://example.com)");
634
635 let ctx = LintContext::new("[text]()", crate::config::MarkdownFlavor::Standard);
637 let result = rule.check(&ctx).unwrap();
638 assert!(
639 result[0].fix.is_none(),
640 "Non-URL text with empty URL should NOT be fixable"
641 );
642
643 let ctx = LintContext::new("[https://example.com]()", crate::config::MarkdownFlavor::Standard);
645 let result = rule.check(&ctx).unwrap();
646 assert!(result[0].fix.is_some(), "URL text with empty URL should be fixable");
647 let fix = result[0].fix.as_ref().unwrap();
648 assert_eq!(fix.replacement, "[https://example.com](https://example.com)");
649
650 let ctx = LintContext::new("[]()", crate::config::MarkdownFlavor::Standard);
652 let result = rule.check(&ctx).unwrap();
653 assert!(result[0].fix.is_none(), "Both empty should NOT be fixable");
654 }
655
656 #[test]
657 fn test_complex_markdown_document() {
658 let content = r#"# Document with various links
659
660[Valid link](https://example.com) followed by [](empty.com).
661
662## Lists with links
663- [Good link](url1)
664- [](url2)
665- Item with [inline empty]() link
666
667> Quote with [](quoted-empty.com)
668> And [valid quoted](quoted-valid.com)
669
670Code block should be ignored:
671```
672[](this-is-code)
673```
674
675[Reference style][ref1] and [][ref2]
676
677[ref1]: https://ref1.com
678[ref2]: https://ref2.com
679"#;
680
681 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
682 let rule = MD042NoEmptyLinks::new();
683 let result = rule.check(&ctx).unwrap();
684
685 let empty_link_lines = [3, 7, 8, 10, 18];
687 assert_eq!(result.len(), empty_link_lines.len(), "Should find all empty links");
688
689 for (i, &expected_line) in empty_link_lines.iter().enumerate() {
691 assert_eq!(
692 result[i].line, expected_line,
693 "Empty link {i} should be on line {expected_line}"
694 );
695 }
696 }
697
698 #[test]
699 fn test_issue_29_code_block_with_tildes() {
700 let content = r#"In addition to the [local scope][] and the [global scope][], Python also has a **built-in scope**.
702
703```pycon
704>>> @count_calls
705... def greet(name):
706... print("Hi", name)
707...
708>>> greet("Trey")
709Traceback (most recent call last):
710 File "<python-input-2>", line 1, in <module>
711 greet("Trey")
712 ~~~~~^^^^^^^^
713 File "<python-input-0>", line 4, in wrapper
714 calls += 1
715 ^^^^^
716UnboundLocalError: cannot access local variable 'calls' where it is not associated with a value
717```
718
719
720[local scope]: https://www.pythonmorsels.com/local-and-global-variables/
721[global scope]: https://www.pythonmorsels.com/assigning-global-variables/"#;
722
723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
724 let rule = MD042NoEmptyLinks::new();
725 let result = rule.check(&ctx).unwrap();
726
727 assert!(
729 result.is_empty(),
730 "Should not flag reference links as empty when code blocks contain tildes (issue #29). Got: {result:?}"
731 );
732 }
733
734 #[test]
735 fn test_link_with_inline_code_in_text() {
736 let ctx = LintContext::new(
738 "[`#[derive(Serialize, Deserialize)`](https://serde.rs/derive.html)",
739 crate::config::MarkdownFlavor::Standard,
740 );
741 let rule = MD042NoEmptyLinks::new();
742 let result = rule.check(&ctx).unwrap();
743 assert!(
744 result.is_empty(),
745 "Links with inline code should not be flagged as empty. Got: {result:?}"
746 );
747 }
748
749 #[test]
750 fn test_mkdocs_backtick_wrapped_references() {
751 let rule = MD042NoEmptyLinks::new();
753
754 let ctx = LintContext::new("[`module.Class`][]", crate::config::MarkdownFlavor::MkDocs);
756 let result = rule.check(&ctx).unwrap();
757 assert!(
758 result.is_empty(),
759 "Should not flag [`module.Class`][] as empty in MkDocs mode (issue #97). Got: {result:?}"
760 );
761
762 let ctx = LintContext::new("[`module.Class`][ref]", crate::config::MarkdownFlavor::MkDocs);
764 let result = rule.check(&ctx).unwrap();
765 assert!(
766 result.is_empty(),
767 "Should not flag [`module.Class`][ref] as empty in MkDocs mode (issue #97). Got: {result:?}"
768 );
769
770 let ctx = LintContext::new("[`api/endpoint`][]", crate::config::MarkdownFlavor::MkDocs);
772 let result = rule.check(&ctx).unwrap();
773 assert!(
774 result.is_empty(),
775 "Should not flag [`api/endpoint`][] as empty in MkDocs mode (issue #97). Got: {result:?}"
776 );
777
778 let ctx = LintContext::new("[`module.Class`][]", crate::config::MarkdownFlavor::Standard);
780 let result = rule.check(&ctx).unwrap();
781 assert_eq!(
782 result.len(),
783 1,
784 "Should flag [`module.Class`][] as empty in Standard mode (no auto-refs). Got: {result:?}"
785 );
786
787 let ctx = LintContext::new("[][]", crate::config::MarkdownFlavor::MkDocs);
789 let result = rule.check(&ctx).unwrap();
790 assert_eq!(
791 result.len(),
792 1,
793 "Should still flag [][] as empty in MkDocs mode. Got: {result:?}"
794 );
795 }
796}