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