1use crate::lint_context::LintContext;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use pulldown_cmark::LinkType;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7enum WhitespaceIssue {
8 Leading,
9 Trailing,
10 Both,
11}
12
13impl WhitespaceIssue {
14 fn message(self, is_image: bool) -> String {
15 let element = if is_image { "Image" } else { "Link" };
16 match self {
17 WhitespaceIssue::Leading => {
18 format!("{element} destination has leading whitespace")
19 }
20 WhitespaceIssue::Trailing => {
21 format!("{element} destination has trailing whitespace")
22 }
23 WhitespaceIssue::Both => {
24 format!("{element} destination has leading and trailing whitespace")
25 }
26 }
27 }
28}
29
30#[derive(Debug, Default, Clone)]
44pub struct MD062LinkDestinationWhitespace;
45
46impl MD062LinkDestinationWhitespace {
47 pub fn new() -> Self {
48 Self
49 }
50
51 fn extract_destination_info<'a>(&self, raw_link: &'a str) -> Option<(usize, usize, &'a str)> {
54 let mut bracket_depth = 0;
57 let mut paren_start = None;
58
59 for (i, c) in raw_link.char_indices() {
60 match c {
61 '[' => bracket_depth += 1,
62 ']' => {
63 bracket_depth -= 1;
64 if bracket_depth == 0 {
65 let rest = &raw_link[i + 1..];
67 if rest.starts_with('(') {
68 paren_start = Some(i + 1);
69 }
70 break;
71 }
72 }
73 _ => {}
74 }
75 }
76
77 let paren_start = paren_start?;
78
79 let dest_content_start = paren_start + 1; let rest = &raw_link[dest_content_start..];
82
83 let mut depth = 1;
85 let mut in_angle_brackets = false;
86 let mut dest_content_end = None;
87
88 for (i, c) in rest.char_indices() {
89 match c {
90 '<' if !in_angle_brackets => in_angle_brackets = true,
91 '>' if in_angle_brackets => in_angle_brackets = false,
92 '(' if !in_angle_brackets => depth += 1,
93 ')' if !in_angle_brackets => {
94 depth -= 1;
95 if depth == 0 {
96 dest_content_end = Some(i);
97 break;
98 }
99 }
100 _ => {}
101 }
102 }
103
104 let dest_content_end = dest_content_end?;
108
109 let dest_content = &rest[..dest_content_end];
110
111 Some((dest_content_start, dest_content_start + dest_content_end, dest_content))
112 }
113
114 fn split_url_and_title(dest: &str) -> (&str, Option<&str>) {
124 if let Some(q_start) = dest.find(['"', '\'']) {
126 let quote = dest.as_bytes()[q_start] as char;
127 if let Some(rel_close) = dest[q_start + 1..].find(quote) {
128 let close = q_start + 1 + rel_close;
129 if dest[close + 1..].trim().is_empty() {
130 return (&dest[..q_start], Some(&dest[q_start..=close]));
131 }
132 }
133 }
134 (dest, None)
135 }
136
137 fn check_destination_whitespace(&self, full_dest: &str) -> Option<WhitespaceIssue> {
140 if full_dest.is_empty() {
141 return None;
142 }
143
144 let first_char = full_dest.chars().next();
145 let last_char = full_dest.chars().last();
146
147 let has_leading = first_char.is_some_and(char::is_whitespace);
148
149 let has_trailing = if last_char.is_some_and(char::is_whitespace) {
151 true
152 } else {
153 let (url_portion, title) = Self::split_url_and_title(full_dest);
154 title.is_some() && url_portion.ends_with(char::is_whitespace)
155 };
156
157 match (has_leading, has_trailing) {
158 (true, true) => Some(WhitespaceIssue::Both),
159 (true, false) => Some(WhitespaceIssue::Leading),
160 (false, true) => Some(WhitespaceIssue::Trailing),
161 (false, false) => None,
162 }
163 }
164
165 fn create_fix(&self, raw_link: &str) -> Option<String> {
167 let (dest_start, dest_end, _) = self.extract_destination_info(raw_link)?;
168
169 let full_dest_content = &raw_link[dest_start..dest_end];
171
172 let (url_raw, title_raw) = Self::split_url_and_title(full_dest_content);
175 let url_part = url_raw.trim();
176 let title_part = title_raw.map(str::trim);
177
178 let text_part = &raw_link[..dest_start]; let mut fixed = String::with_capacity(raw_link.len());
182 fixed.push_str(text_part);
183 fixed.push_str(url_part);
184 if let Some(title) = title_part {
185 fixed.push(' ');
186 fixed.push_str(title);
187 }
188 fixed.push(')');
189
190 if fixed != raw_link { Some(fixed) } else { None }
192 }
193}
194
195impl Rule for MD062LinkDestinationWhitespace {
196 fn name(&self) -> &'static str {
197 "MD062"
198 }
199
200 fn description(&self) -> &'static str {
201 "Link destination should not have leading or trailing whitespace"
202 }
203
204 fn category(&self) -> RuleCategory {
205 RuleCategory::Link
206 }
207
208 fn should_skip(&self, ctx: &LintContext) -> bool {
209 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
210 }
211
212 fn check(&self, ctx: &LintContext) -> LintResult {
213 let mut warnings = Vec::new();
214
215 for link in &ctx.links {
217 if link.is_reference || !matches!(link.link_type, LinkType::Inline) {
219 continue;
220 }
221
222 if ctx.is_in_jinja_range(link.byte_offset) {
224 continue;
225 }
226
227 let raw_link = &ctx.content[link.byte_offset..link.byte_end];
229
230 if let Some((_, _, raw_dest)) = self.extract_destination_info(raw_link)
232 && let Some(issue) = self.check_destination_whitespace(raw_dest)
233 && let Some(fixed) = self.create_fix(raw_link)
234 {
235 warnings.push(LintWarning {
236 rule_name: Some(self.name().to_string()),
237 line: link.line,
238 column: link.start_col + 1,
239 end_line: link.line,
240 end_column: link.end_col + 1,
241 message: issue.message(false),
242 severity: Severity::Warning,
243 fix: Some(Fix::new(link.byte_offset..link.byte_end, fixed)),
244 });
245 }
246 }
247
248 for image in &ctx.images {
250 if image.is_reference || !matches!(image.link_type, LinkType::Inline) {
252 continue;
253 }
254
255 if ctx.is_in_jinja_range(image.byte_offset) {
257 continue;
258 }
259
260 let raw_image = &ctx.content[image.byte_offset..image.byte_end];
262
263 let link_portion = raw_image.strip_prefix('!').unwrap_or(raw_image);
265
266 if let Some((_, _, raw_dest)) = self.extract_destination_info(link_portion)
268 && let Some(issue) = self.check_destination_whitespace(raw_dest)
269 && let Some(fixed_link) = self.create_fix(link_portion)
270 {
271 let fixed = format!("!{fixed_link}");
272 warnings.push(LintWarning {
273 rule_name: Some(self.name().to_string()),
274 line: image.line,
275 column: image.start_col + 1,
276 end_line: image.line,
277 end_column: image.end_col + 1,
278 message: issue.message(true),
279 severity: Severity::Warning,
280 fix: Some(Fix::new(image.byte_offset..image.byte_end, fixed)),
281 });
282 }
283 }
284
285 Ok(warnings)
286 }
287
288 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
289 let warnings = self.check(ctx)?;
290 let warnings =
291 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
292
293 if warnings.is_empty() {
294 return Ok(ctx.content.to_string());
295 }
296
297 let mut content = ctx.content.to_string();
298 let mut fixes: Vec<_> = warnings
299 .into_iter()
300 .filter_map(|w| w.fix.map(|f| (f.range.start, f.range.end, f.replacement)))
301 .collect();
302
303 fixes.sort_by_key(|(start, _, _)| *start);
305
306 for (start, end, replacement) in fixes.into_iter().rev() {
307 content.replace_range(start..end, &replacement);
308 }
309
310 Ok(content)
311 }
312
313 fn as_any(&self) -> &dyn std::any::Any {
314 self
315 }
316
317 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
318 where
319 Self: Sized,
320 {
321 Box::new(Self)
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use crate::config::MarkdownFlavor;
329
330 #[test]
331 fn test_no_whitespace() {
332 let rule = MD062LinkDestinationWhitespace::new();
333 let content = "[link](https://example.com)";
334 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
335 let warnings = rule.check(&ctx).unwrap();
336 assert!(warnings.is_empty());
337 }
338
339 #[test]
340 fn test_leading_whitespace() {
341 let rule = MD062LinkDestinationWhitespace::new();
342 let content = "[link]( https://example.com)";
343 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
344 let warnings = rule.check(&ctx).unwrap();
345 assert_eq!(warnings.len(), 1);
346 assert_eq!(
347 warnings[0].fix.as_ref().unwrap().replacement,
348 "[link](https://example.com)"
349 );
350 }
351
352 #[test]
353 fn test_trailing_whitespace() {
354 let rule = MD062LinkDestinationWhitespace::new();
355 let content = "[link](https://example.com )";
356 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
357 let warnings = rule.check(&ctx).unwrap();
358 assert_eq!(warnings.len(), 1);
359 assert_eq!(
360 warnings[0].fix.as_ref().unwrap().replacement,
361 "[link](https://example.com)"
362 );
363 }
364
365 #[test]
366 fn test_both_whitespace() {
367 let rule = MD062LinkDestinationWhitespace::new();
368 let content = "[link]( https://example.com )";
369 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
370 let warnings = rule.check(&ctx).unwrap();
371 assert_eq!(warnings.len(), 1);
372 assert_eq!(
373 warnings[0].fix.as_ref().unwrap().replacement,
374 "[link](https://example.com)"
375 );
376 }
377
378 #[test]
379 fn test_multiple_spaces() {
380 let rule = MD062LinkDestinationWhitespace::new();
381 let content = "[link]( https://example.com )";
382 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
383 let warnings = rule.check(&ctx).unwrap();
384 assert_eq!(warnings.len(), 1);
385 assert_eq!(
386 warnings[0].fix.as_ref().unwrap().replacement,
387 "[link](https://example.com)"
388 );
389 }
390
391 #[test]
392 fn test_with_title() {
393 let rule = MD062LinkDestinationWhitespace::new();
394 let content = "[link]( https://example.com \"title\")";
395 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
396 let warnings = rule.check(&ctx).unwrap();
397 assert_eq!(warnings.len(), 1);
398 assert_eq!(
399 warnings[0].fix.as_ref().unwrap().replacement,
400 "[link](https://example.com \"title\")"
401 );
402 }
403
404 #[test]
405 fn test_image_leading_whitespace() {
406 let rule = MD062LinkDestinationWhitespace::new();
407 let content = "";
408 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
409 let warnings = rule.check(&ctx).unwrap();
410 assert_eq!(warnings.len(), 1);
411 assert_eq!(
412 warnings[0].fix.as_ref().unwrap().replacement,
413 ""
414 );
415 }
416
417 #[test]
418 fn test_multiple_links() {
419 let rule = MD062LinkDestinationWhitespace::new();
420 let content = "[a]( url1) and [b](url2 ) here";
421 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
422 let warnings = rule.check(&ctx).unwrap();
423 assert_eq!(warnings.len(), 2);
424 }
425
426 #[test]
427 fn test_fix() {
428 let rule = MD062LinkDestinationWhitespace::new();
429 let content = "[link]( https://example.com ) and ";
430 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
431 let fixed = rule.fix(&ctx).unwrap();
432 assert_eq!(fixed, "[link](https://example.com) and ");
433 }
434
435 #[test]
436 fn test_fix_idempotent_with_unbalanced_quote() {
437 let rule = MD062LinkDestinationWhitespace::new();
444 let content = "\n";
445
446 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
447 let once = rule.fix(&ctx).unwrap();
448
449 let ctx2 = LintContext::new(&once, MarkdownFlavor::Standard, None);
450 let twice = rule.fix(&ctx2).unwrap();
451
452 assert_eq!(once, twice, "MD062 fix must be idempotent for unbalanced quotes");
453 assert_eq!(
454 once.lines().count(),
455 2,
456 "fix must not drop the second image line, got: {once:?}"
457 );
458 }
459
460 #[test]
461 fn test_reference_links_skipped() {
462 let rule = MD062LinkDestinationWhitespace::new();
463 let content = "[link][ref]\n\n[ref]: https://example.com";
464 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
465 let warnings = rule.check(&ctx).unwrap();
466 assert!(warnings.is_empty());
467 }
468
469 #[test]
470 fn test_nested_brackets() {
471 let rule = MD062LinkDestinationWhitespace::new();
472 let content = "[text [nested]]( https://example.com)";
473 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
474 let warnings = rule.check(&ctx).unwrap();
475 assert_eq!(warnings.len(), 1);
476 }
477
478 #[test]
479 fn test_empty_destination() {
480 let rule = MD062LinkDestinationWhitespace::new();
481 let content = "[link]()";
482 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
483 let warnings = rule.check(&ctx).unwrap();
484 assert!(warnings.is_empty());
485 }
486
487 #[test]
488 fn test_tabs_and_newlines() {
489 let rule = MD062LinkDestinationWhitespace::new();
490 let content = "[link](\thttps://example.com\t)";
491 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
492 let warnings = rule.check(&ctx).unwrap();
493 assert_eq!(warnings.len(), 1);
494 assert_eq!(
495 warnings[0].fix.as_ref().unwrap().replacement,
496 "[link](https://example.com)"
497 );
498 }
499
500 #[test]
503 fn test_trailing_whitespace_after_title() {
504 let rule = MD062LinkDestinationWhitespace::new();
505 let content = "[link](https://example.com \"title\" )";
506 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
507 let warnings = rule.check(&ctx).unwrap();
508 assert_eq!(warnings.len(), 1);
509 assert_eq!(
510 warnings[0].fix.as_ref().unwrap().replacement,
511 "[link](https://example.com \"title\")"
512 );
513 }
514
515 #[test]
516 fn test_leading_and_trailing_with_title() {
517 let rule = MD062LinkDestinationWhitespace::new();
518 let content = "[link]( https://example.com \"title\" )";
519 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
520 let warnings = rule.check(&ctx).unwrap();
521 assert_eq!(warnings.len(), 1);
522 assert_eq!(
523 warnings[0].fix.as_ref().unwrap().replacement,
524 "[link](https://example.com \"title\")"
525 );
526 }
527
528 #[test]
529 fn test_multiple_spaces_before_title() {
530 let rule = MD062LinkDestinationWhitespace::new();
531 let content = "[link](https://example.com \"title\")";
532 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
533 let warnings = rule.check(&ctx).unwrap();
534 assert_eq!(warnings.len(), 1);
535 assert_eq!(
536 warnings[0].fix.as_ref().unwrap().replacement,
537 "[link](https://example.com \"title\")"
538 );
539 }
540
541 #[test]
542 fn test_single_quote_title() {
543 let rule = MD062LinkDestinationWhitespace::new();
544 let content = "[link]( https://example.com 'title')";
545 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
546 let warnings = rule.check(&ctx).unwrap();
547 assert_eq!(warnings.len(), 1);
548 assert_eq!(
549 warnings[0].fix.as_ref().unwrap().replacement,
550 "[link](https://example.com 'title')"
551 );
552 }
553
554 #[test]
555 fn test_single_quote_title_trailing_space() {
556 let rule = MD062LinkDestinationWhitespace::new();
557 let content = "[link](https://example.com 'title' )";
558 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
559 let warnings = rule.check(&ctx).unwrap();
560 assert_eq!(warnings.len(), 1);
561 assert_eq!(
562 warnings[0].fix.as_ref().unwrap().replacement,
563 "[link](https://example.com 'title')"
564 );
565 }
566
567 #[test]
568 fn test_wikipedia_style_url() {
569 let rule = MD062LinkDestinationWhitespace::new();
571 let content = "[wiki]( https://en.wikipedia.org/wiki/Rust_(programming_language) )";
572 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
573 let warnings = rule.check(&ctx).unwrap();
574 assert_eq!(warnings.len(), 1);
575 assert_eq!(
576 warnings[0].fix.as_ref().unwrap().replacement,
577 "[wiki](https://en.wikipedia.org/wiki/Rust_(programming_language))"
578 );
579 }
580
581 #[test]
582 fn test_angle_bracket_url_no_warning() {
583 let rule = MD062LinkDestinationWhitespace::new();
585 let content = "[link](<https://example.com/path with spaces>)";
586 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
587 let warnings = rule.check(&ctx).unwrap();
588 assert!(warnings.is_empty());
590 }
591
592 #[test]
593 fn test_image_with_title() {
594 let rule = MD062LinkDestinationWhitespace::new();
595 let content = "";
596 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
597 let warnings = rule.check(&ctx).unwrap();
598 assert_eq!(warnings.len(), 1);
599 assert_eq!(
600 warnings[0].fix.as_ref().unwrap().replacement,
601 ""
602 );
603 }
604
605 #[test]
606 fn test_only_whitespace_in_destination() {
607 let rule = MD062LinkDestinationWhitespace::new();
608 let content = "[link]( )";
609 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
610 let warnings = rule.check(&ctx).unwrap();
611 assert_eq!(warnings.len(), 1);
612 assert_eq!(warnings[0].fix.as_ref().unwrap().replacement, "[link]()");
613 }
614
615 #[test]
616 fn test_code_block_skipped() {
617 let rule = MD062LinkDestinationWhitespace::new();
618 let content = "```\n[link]( https://example.com )\n```";
619 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
620 let warnings = rule.check(&ctx).unwrap();
621 assert!(warnings.is_empty());
622 }
623
624 #[test]
625 fn test_inline_code_not_skipped() {
626 let rule = MD062LinkDestinationWhitespace::new();
628 let content = "text `[link]( url )` more text";
629 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
630 let warnings = rule.check(&ctx).unwrap();
631 assert!(warnings.is_empty());
633 }
634
635 #[test]
636 fn test_valid_link_with_title_no_warning() {
637 let rule = MD062LinkDestinationWhitespace::new();
638 let content = "[link](https://example.com \"Title\")";
639 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
640 let warnings = rule.check(&ctx).unwrap();
641 assert!(warnings.is_empty());
642 }
643
644 #[test]
645 fn test_mixed_links_on_same_line() {
646 let rule = MD062LinkDestinationWhitespace::new();
647 let content = "[good](https://example.com) and [bad]( https://example.com ) here";
648 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
649 let warnings = rule.check(&ctx).unwrap();
650 assert_eq!(warnings.len(), 1);
651 assert_eq!(
652 warnings[0].fix.as_ref().unwrap().replacement,
653 "[bad](https://example.com)"
654 );
655 }
656
657 #[test]
658 fn test_fix_multiple_on_same_line() {
659 let rule = MD062LinkDestinationWhitespace::new();
660 let content = "[a]( url1 ) and [b]( url2 )";
661 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
662 let fixed = rule.fix(&ctx).unwrap();
663 assert_eq!(fixed, "[a](url1) and [b](url2)");
664 }
665
666 #[test]
667 fn test_complex_nested_brackets() {
668 let rule = MD062LinkDestinationWhitespace::new();
669 let content = "[text [with [deeply] nested] brackets]( https://example.com )";
670 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
671 let warnings = rule.check(&ctx).unwrap();
672 assert_eq!(warnings.len(), 1);
673 }
674
675 #[test]
676 fn test_url_with_query_params() {
677 let rule = MD062LinkDestinationWhitespace::new();
678 let content = "[link]( https://example.com?foo=bar&baz=qux )";
679 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
680 let warnings = rule.check(&ctx).unwrap();
681 assert_eq!(warnings.len(), 1);
682 assert_eq!(
683 warnings[0].fix.as_ref().unwrap().replacement,
684 "[link](https://example.com?foo=bar&baz=qux)"
685 );
686 }
687
688 #[test]
689 fn test_url_with_fragment() {
690 let rule = MD062LinkDestinationWhitespace::new();
691 let content = "[link]( https://example.com#section )";
692 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
693 let warnings = rule.check(&ctx).unwrap();
694 assert_eq!(warnings.len(), 1);
695 assert_eq!(
696 warnings[0].fix.as_ref().unwrap().replacement,
697 "[link](https://example.com#section)"
698 );
699 }
700
701 #[test]
702 fn test_relative_path() {
703 let rule = MD062LinkDestinationWhitespace::new();
704 let content = "[link]( ./path/to/file.md )";
705 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
706 let warnings = rule.check(&ctx).unwrap();
707 assert_eq!(warnings.len(), 1);
708 assert_eq!(
709 warnings[0].fix.as_ref().unwrap().replacement,
710 "[link](./path/to/file.md)"
711 );
712 }
713
714 #[test]
715 fn test_unmatched_angle_bracket_in_destination() {
716 let rule = MD062LinkDestinationWhitespace::new();
720 let content = "[]( \"<)";
721 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
722 let warnings = rule.check(&ctx).unwrap();
723 assert!(
724 warnings.is_empty(),
725 "Should not warn when closing paren is masked by angle bracket"
726 );
727
728 let fixed = rule.fix(&ctx).unwrap();
730 assert_eq!(fixed, content);
731 }
732
733 #[test]
734 fn test_unicode_whitespace_in_destination() {
735 let rule = MD062LinkDestinationWhitespace::new();
737 let content = "[](\u{2000}\"<)";
738 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
739 let warnings = rule.check(&ctx).unwrap();
740 assert!(
741 warnings.is_empty(),
742 "Should not warn when angle bracket masks closing paren"
743 );
744
745 let fixed = rule.fix(&ctx).unwrap();
746 assert_eq!(fixed, content, "Fix must be idempotent for unparseable links");
747 }
748
749 #[test]
750 fn test_autolink_not_affected() {
751 let rule = MD062LinkDestinationWhitespace::new();
753 let content = "<https://example.com>";
754 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
755 let warnings = rule.check(&ctx).unwrap();
756 assert!(warnings.is_empty());
757 }
758}