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 let effective_url = if link.is_reference {
149 if let Some(ref_id) = &link.reference_id {
150 ctx.get_reference_url(ref_id).unwrap_or("").to_string()
151 } else {
152 String::new()
153 }
154 } else {
155 link.url.clone()
156 };
157
158 if mkdocs_mode && link.is_reference {
163 if let Some(ref_id) = &link.reference_id {
165 let stripped_ref = Self::strip_backticks(ref_id);
166 if is_mkdocs_auto_reference(stripped_ref)
169 || (ref_id != stripped_ref && Self::is_valid_python_identifier(stripped_ref))
170 {
171 continue;
172 }
173 }
174 let stripped_text = Self::strip_backticks(&link.text);
176 if is_mkdocs_auto_reference(stripped_text)
178 || (link.text.as_str() != stripped_text && Self::is_valid_python_identifier(stripped_text))
179 {
180 continue;
181 }
182 }
183
184 if link.text.trim().is_empty() || effective_url.trim().is_empty() {
186 if mkdocs_mode
188 && link.text.trim().is_empty()
189 && effective_url.trim().is_empty()
190 && Self::is_mkdocs_attribute_anchor(ctx.content, link.byte_end)
191 {
192 continue;
194 }
195
196 let replacement = if link.text.trim().is_empty() {
198 if !effective_url.trim().is_empty() {
200 if link.is_reference {
202 Some(format!(
203 "[Link text]{}",
204 &ctx.content[link.byte_offset + 1..link.byte_end]
205 ))
206 } else {
207 Some(format!("[Link text]({effective_url})"))
208 }
209 } else {
210 None
212 }
213 } else if link.is_reference {
214 let ref_part = &ctx.content[link.byte_offset + link.text.len() + 2..link.byte_end];
216 Some(format!("[{}]{}", link.text, ref_part))
217 } else {
218 let text_is_url = link.text.starts_with("http://")
221 || link.text.starts_with("https://")
222 || link.text.starts_with("ftp://")
223 || link.text.starts_with("ftps://");
224
225 if text_is_url {
226 Some(format!("[{}]({})", link.text, link.text))
227 } else {
228 None
230 }
231 };
232
233 let link_display = &ctx.content[link.byte_offset..link.byte_end];
235
236 warnings.push(LintWarning {
237 rule_name: Some(self.name().to_string()),
238 message: format!("Empty link found: {link_display}"),
239 line: link.line,
240 column: link.start_col + 1, end_line: link.line,
242 end_column: link.end_col + 1, severity: Severity::Warning,
244 fix: replacement.map(|r| Fix {
245 range: link.byte_offset..link.byte_end,
246 replacement: r,
247 }),
248 });
249 }
250 }
251
252 Ok(warnings)
253 }
254
255 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
256 let content = ctx.content;
257
258 let warnings = self.check(ctx)?;
260 if warnings.is_empty() {
261 return Ok(content.to_string());
262 }
263
264 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
266 .iter()
267 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.clone(), f.replacement.clone())))
268 .collect();
269
270 fixes.sort_by(|a, b| b.0.start.cmp(&a.0.start));
272
273 let mut result = content.to_string();
274
275 for (range, replacement) in fixes {
277 result.replace_range(range, &replacement);
278 }
279
280 Ok(result)
281 }
282
283 fn category(&self) -> RuleCategory {
285 RuleCategory::Link
286 }
287
288 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
290 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
291 }
292
293 fn as_any(&self) -> &dyn std::any::Any {
294 self
295 }
296
297 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
298 where
299 Self: Sized,
300 {
301 Box::new(MD042NoEmptyLinks::new())
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use crate::lint_context::LintContext;
310
311 #[test]
312 fn test_links_with_text_should_pass() {
313 let ctx = LintContext::new(
314 "[valid link](https://example.com)",
315 crate::config::MarkdownFlavor::Standard,
316 );
317 let rule = MD042NoEmptyLinks::new();
318 let result = rule.check(&ctx).unwrap();
319 assert!(result.is_empty(), "Links with text should pass");
320
321 let ctx = LintContext::new(
322 "[another valid link](path/to/page.html)",
323 crate::config::MarkdownFlavor::Standard,
324 );
325 let result = rule.check(&ctx).unwrap();
326 assert!(result.is_empty(), "Links with text and relative URLs should pass");
327 }
328
329 #[test]
330 fn test_links_with_empty_text_should_fail() {
331 let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard);
332 let rule = MD042NoEmptyLinks::new();
333 let result = rule.check(&ctx).unwrap();
334 assert_eq!(result.len(), 1);
335 assert_eq!(result[0].message, "Empty link found: [](https://example.com)");
336 assert_eq!(result[0].line, 1);
337 assert_eq!(result[0].column, 1);
338 }
339
340 #[test]
341 fn test_links_with_only_whitespace_should_fail() {
342 let ctx = LintContext::new("[ ](https://example.com)", crate::config::MarkdownFlavor::Standard);
343 let rule = MD042NoEmptyLinks::new();
344 let result = rule.check(&ctx).unwrap();
345 assert_eq!(result.len(), 1);
346 assert_eq!(result[0].message, "Empty link found: [ ](https://example.com)");
347
348 let ctx = LintContext::new("[\t\n](https://example.com)", crate::config::MarkdownFlavor::Standard);
349 let result = rule.check(&ctx).unwrap();
350 assert_eq!(result.len(), 1);
351 assert_eq!(result[0].message, "Empty link found: [\t\n](https://example.com)");
352 }
353
354 #[test]
355 fn test_reference_links_with_empty_text() {
356 let ctx = LintContext::new(
357 "[][ref]\n\n[ref]: https://example.com",
358 crate::config::MarkdownFlavor::Standard,
359 );
360 let rule = MD042NoEmptyLinks::new();
361 let result = rule.check(&ctx).unwrap();
362 assert_eq!(result.len(), 1);
363 assert_eq!(result[0].message, "Empty link found: [][ref]");
364 assert_eq!(result[0].line, 1);
365
366 let ctx = LintContext::new(
368 "[][]\n\n[]: https://example.com",
369 crate::config::MarkdownFlavor::Standard,
370 );
371 let result = rule.check(&ctx).unwrap();
372 assert_eq!(result.len(), 1);
373 }
374
375 #[test]
376 fn test_images_should_be_ignored() {
377 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
379 let rule = MD042NoEmptyLinks::new();
380 let result = rule.check(&ctx).unwrap();
381 assert!(result.is_empty(), "Images with empty alt text should be ignored");
382
383 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
384 let result = rule.check(&ctx).unwrap();
385 assert!(result.is_empty(), "Images with whitespace alt text should be ignored");
386 }
387
388 #[test]
389 fn test_links_with_nested_formatting() {
390 let ctx = LintContext::new("[**](https://example.com)", crate::config::MarkdownFlavor::Standard);
393 let rule = MD042NoEmptyLinks::new();
394 let result = rule.check(&ctx).unwrap();
395 assert!(result.is_empty(), "[**] is not considered empty since ** is text");
396
397 let ctx = LintContext::new("[__](https://example.com)", crate::config::MarkdownFlavor::Standard);
398 let result = rule.check(&ctx).unwrap();
399 assert!(result.is_empty(), "[__] is not considered empty since __ is text");
400
401 let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard);
403 let result = rule.check(&ctx).unwrap();
404 assert_eq!(result.len(), 1);
405
406 let ctx = LintContext::new(
408 "[**bold text**](https://example.com)",
409 crate::config::MarkdownFlavor::Standard,
410 );
411 let result = rule.check(&ctx).unwrap();
412 assert!(result.is_empty(), "Links with nested formatting and text should pass");
413
414 let ctx = LintContext::new(
415 "[*italic* and **bold**](https://example.com)",
416 crate::config::MarkdownFlavor::Standard,
417 );
418 let result = rule.check(&ctx).unwrap();
419 assert!(result.is_empty(), "Links with multiple nested formatting should pass");
420 }
421
422 #[test]
423 fn test_multiple_empty_links_on_same_line() {
424 let ctx = LintContext::new(
425 "[](url1) and [](url2) and [valid](url3)",
426 crate::config::MarkdownFlavor::Standard,
427 );
428 let rule = MD042NoEmptyLinks::new();
429 let result = rule.check(&ctx).unwrap();
430 assert_eq!(result.len(), 2, "Should detect both empty links");
431 assert_eq!(result[0].column, 1);
432 assert_eq!(result[1].column, 14);
433 }
434
435 #[test]
436 fn test_escaped_brackets() {
437 let ctx = LintContext::new("\\[\\](https://example.com)", crate::config::MarkdownFlavor::Standard);
439 let rule = MD042NoEmptyLinks::new();
440 let result = rule.check(&ctx).unwrap();
441 assert!(result.is_empty(), "Escaped brackets should not be treated as links");
442
443 let ctx = LintContext::new("[\\[\\]](https://example.com)", crate::config::MarkdownFlavor::Standard);
445 let result = rule.check(&ctx).unwrap();
446 assert!(result.is_empty(), "Link with escaped brackets in text should pass");
447 }
448
449 #[test]
450 fn test_links_in_lists_and_blockquotes() {
451 let ctx = LintContext::new(
453 "- [](https://example.com)\n- [valid](https://example.com)",
454 crate::config::MarkdownFlavor::Standard,
455 );
456 let rule = MD042NoEmptyLinks::new();
457 let result = rule.check(&ctx).unwrap();
458 assert_eq!(result.len(), 1);
459 assert_eq!(result[0].line, 1);
460
461 let ctx = LintContext::new(
463 "> [](https://example.com)\n> [valid](https://example.com)",
464 crate::config::MarkdownFlavor::Standard,
465 );
466 let result = rule.check(&ctx).unwrap();
467 assert_eq!(result.len(), 1);
468 assert_eq!(result[0].line, 1);
469
470 let ctx = LintContext::new(
472 "> - [](url1)\n> - [text](url2)",
473 crate::config::MarkdownFlavor::Standard,
474 );
475 let result = rule.check(&ctx).unwrap();
476 assert_eq!(result.len(), 1);
477 }
478
479 #[test]
480 fn test_unicode_whitespace_characters() {
481 let ctx = LintContext::new(
483 "[\u{00A0}](https://example.com)",
484 crate::config::MarkdownFlavor::Standard,
485 );
486 let rule = MD042NoEmptyLinks::new();
487 let result = rule.check(&ctx).unwrap();
488 assert_eq!(result.len(), 1, "Non-breaking space should be treated as whitespace");
489
490 let ctx = LintContext::new(
492 "[\u{2003}](https://example.com)",
493 crate::config::MarkdownFlavor::Standard,
494 );
495 let result = rule.check(&ctx).unwrap();
496 assert_eq!(result.len(), 1, "Em space should be treated as whitespace");
497
498 let ctx = LintContext::new(
501 "[\u{200B}](https://example.com)",
502 crate::config::MarkdownFlavor::Standard,
503 );
504 let result = rule.check(&ctx).unwrap();
505 assert!(
506 result.is_empty(),
507 "Zero-width space is not considered whitespace by trim()"
508 );
509
510 let ctx = LintContext::new(
514 "[ \u{200B} ](https://example.com)",
515 crate::config::MarkdownFlavor::Standard,
516 );
517 let result = rule.check(&ctx).unwrap();
518 assert!(
519 result.is_empty(),
520 "Zero-width space remains after trim(), so link is not empty"
521 );
522 }
523
524 #[test]
525 fn test_empty_url_with_text() {
526 let ctx = LintContext::new("[some text]()", crate::config::MarkdownFlavor::Standard);
527 let rule = MD042NoEmptyLinks::new();
528 let result = rule.check(&ctx).unwrap();
529 assert_eq!(result.len(), 1);
530 assert_eq!(result[0].message, "Empty link found: [some text]()");
531 }
532
533 #[test]
534 fn test_both_empty_text_and_url() {
535 let ctx = LintContext::new("[]()", crate::config::MarkdownFlavor::Standard);
536 let rule = MD042NoEmptyLinks::new();
537 let result = rule.check(&ctx).unwrap();
538 assert_eq!(result.len(), 1);
539 assert_eq!(result[0].message, "Empty link found: []()");
540 }
541
542 #[test]
543 fn test_reference_link_with_undefined_reference() {
544 let ctx = LintContext::new("[text][undefined]", crate::config::MarkdownFlavor::Standard);
545 let rule = MD042NoEmptyLinks::new();
546 let result = rule.check(&ctx).unwrap();
547 assert_eq!(result.len(), 1, "Undefined reference should be treated as empty URL");
548 }
549
550 #[test]
551 fn test_shortcut_reference_links() {
552 let ctx = LintContext::new(
556 "[example][]\n\n[example]: https://example.com",
557 crate::config::MarkdownFlavor::Standard,
558 );
559 let rule = MD042NoEmptyLinks::new();
560 let result = rule.check(&ctx).unwrap();
561 assert!(result.is_empty(), "Valid implicit reference link should pass");
562
563 let ctx = LintContext::new(
565 "[][]\n\n[]: https://example.com",
566 crate::config::MarkdownFlavor::Standard,
567 );
568 let result = rule.check(&ctx).unwrap();
569 assert_eq!(result.len(), 1, "Empty implicit reference link should fail");
570
571 let ctx = LintContext::new(
573 "[example]\n\n[example]: https://example.com",
574 crate::config::MarkdownFlavor::Standard,
575 );
576 let result = rule.check(&ctx).unwrap();
577 assert!(
578 result.is_empty(),
579 "Shortcut links without [] or () are not parsed as links"
580 );
581 }
582
583 #[test]
584 fn test_fix_suggestions() {
585 let rule = MD042NoEmptyLinks::new();
586
587 let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard);
589 let result = rule.check(&ctx).unwrap();
590 assert!(result[0].fix.is_some(), "Empty text with URL should be fixable");
591 let fix = result[0].fix.as_ref().unwrap();
592 assert_eq!(fix.replacement, "[Link text](https://example.com)");
593
594 let ctx = LintContext::new("[text]()", crate::config::MarkdownFlavor::Standard);
596 let result = rule.check(&ctx).unwrap();
597 assert!(
598 result[0].fix.is_none(),
599 "Non-URL text with empty URL should NOT be fixable"
600 );
601
602 let ctx = LintContext::new("[https://example.com]()", crate::config::MarkdownFlavor::Standard);
604 let result = rule.check(&ctx).unwrap();
605 assert!(result[0].fix.is_some(), "URL text with empty URL should be fixable");
606 let fix = result[0].fix.as_ref().unwrap();
607 assert_eq!(fix.replacement, "[https://example.com](https://example.com)");
608
609 let ctx = LintContext::new("[]()", crate::config::MarkdownFlavor::Standard);
611 let result = rule.check(&ctx).unwrap();
612 assert!(result[0].fix.is_none(), "Both empty should NOT be fixable");
613 }
614
615 #[test]
616 fn test_complex_markdown_document() {
617 let content = r#"# Document with various links
618
619[Valid link](https://example.com) followed by [](empty.com).
620
621## Lists with links
622- [Good link](url1)
623- [](url2)
624- Item with [inline empty]() link
625
626> Quote with [](quoted-empty.com)
627> And [valid quoted](quoted-valid.com)
628
629Code block should be ignored:
630```
631[](this-is-code)
632```
633
634[Reference style][ref1] and [][ref2]
635
636[ref1]: https://ref1.com
637[ref2]: https://ref2.com
638"#;
639
640 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
641 let rule = MD042NoEmptyLinks::new();
642 let result = rule.check(&ctx).unwrap();
643
644 let empty_link_lines = [3, 7, 8, 10, 18];
646 assert_eq!(result.len(), empty_link_lines.len(), "Should find all empty links");
647
648 for (i, &expected_line) in empty_link_lines.iter().enumerate() {
650 assert_eq!(
651 result[i].line, expected_line,
652 "Empty link {i} should be on line {expected_line}"
653 );
654 }
655 }
656
657 #[test]
658 fn test_issue_29_code_block_with_tildes() {
659 let content = r#"In addition to the [local scope][] and the [global scope][], Python also has a **built-in scope**.
661
662```pycon
663>>> @count_calls
664... def greet(name):
665... print("Hi", name)
666...
667>>> greet("Trey")
668Traceback (most recent call last):
669 File "<python-input-2>", line 1, in <module>
670 greet("Trey")
671 ~~~~~^^^^^^^^
672 File "<python-input-0>", line 4, in wrapper
673 calls += 1
674 ^^^^^
675UnboundLocalError: cannot access local variable 'calls' where it is not associated with a value
676```
677
678
679[local scope]: https://www.pythonmorsels.com/local-and-global-variables/
680[global scope]: https://www.pythonmorsels.com/assigning-global-variables/"#;
681
682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
683 let rule = MD042NoEmptyLinks::new();
684 let result = rule.check(&ctx).unwrap();
685
686 assert!(
688 result.is_empty(),
689 "Should not flag reference links as empty when code blocks contain tildes (issue #29). Got: {result:?}"
690 );
691 }
692
693 #[test]
694 fn test_mkdocs_backtick_wrapped_references() {
695 let rule = MD042NoEmptyLinks::new();
697
698 let ctx = LintContext::new("[`module.Class`][]", crate::config::MarkdownFlavor::MkDocs);
700 let result = rule.check(&ctx).unwrap();
701 assert!(
702 result.is_empty(),
703 "Should not flag [`module.Class`][] as empty in MkDocs mode (issue #97). Got: {result:?}"
704 );
705
706 let ctx = LintContext::new("[`module.Class`][ref]", crate::config::MarkdownFlavor::MkDocs);
708 let result = rule.check(&ctx).unwrap();
709 assert!(
710 result.is_empty(),
711 "Should not flag [`module.Class`][ref] as empty in MkDocs mode (issue #97). Got: {result:?}"
712 );
713
714 let ctx = LintContext::new("[`api/endpoint`][]", crate::config::MarkdownFlavor::MkDocs);
716 let result = rule.check(&ctx).unwrap();
717 assert!(
718 result.is_empty(),
719 "Should not flag [`api/endpoint`][] as empty in MkDocs mode (issue #97). Got: {result:?}"
720 );
721
722 let ctx = LintContext::new("[`module.Class`][]", crate::config::MarkdownFlavor::Standard);
724 let result = rule.check(&ctx).unwrap();
725 assert_eq!(
726 result.len(),
727 1,
728 "Should flag [`module.Class`][] as empty in Standard mode (no auto-refs). Got: {result:?}"
729 );
730
731 let ctx = LintContext::new("[][]", crate::config::MarkdownFlavor::MkDocs);
733 let result = rule.check(&ctx).unwrap();
734 assert_eq!(
735 result.len(),
736 1,
737 "Should still flag [][] as empty in MkDocs mode. Got: {result:?}"
738 );
739 }
740}