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 check_destination_whitespace(&self, full_dest: &str) -> Option<WhitespaceIssue> {
117 if full_dest.is_empty() {
118 return None;
119 }
120
121 let first_char = full_dest.chars().next();
122 let last_char = full_dest.chars().last();
123
124 let has_leading = first_char.is_some_and(|c| c.is_whitespace());
125
126 let has_trailing = if last_char.is_some_and(|c| c.is_whitespace()) {
128 true
129 } else if let Some(title_start) = full_dest.find(['"', '\'']) {
130 let url_portion = &full_dest[..title_start];
131 url_portion.ends_with(char::is_whitespace)
132 } else {
133 false
134 };
135
136 match (has_leading, has_trailing) {
137 (true, true) => Some(WhitespaceIssue::Both),
138 (true, false) => Some(WhitespaceIssue::Leading),
139 (false, true) => Some(WhitespaceIssue::Trailing),
140 (false, false) => None,
141 }
142 }
143
144 fn create_fix(&self, raw_link: &str) -> Option<String> {
146 let (dest_start, dest_end, _) = self.extract_destination_info(raw_link)?;
147
148 let full_dest_content = &raw_link[dest_start..dest_end];
150
151 let (url_part, title_part) = if let Some(title_start) = full_dest_content.find(['"', '\'']) {
153 let url = full_dest_content[..title_start].trim();
154 let title = &full_dest_content[title_start..];
155 (url, Some(title.trim()))
156 } else {
157 (full_dest_content.trim(), None)
158 };
159
160 let text_part = &raw_link[..dest_start]; let mut fixed = String::with_capacity(raw_link.len());
164 fixed.push_str(text_part);
165 fixed.push_str(url_part);
166 if let Some(title) = title_part {
167 fixed.push(' ');
168 fixed.push_str(title);
169 }
170 fixed.push(')');
171
172 if fixed != raw_link { Some(fixed) } else { None }
174 }
175}
176
177impl Rule for MD062LinkDestinationWhitespace {
178 fn name(&self) -> &'static str {
179 "MD062"
180 }
181
182 fn description(&self) -> &'static str {
183 "Link destination should not have leading or trailing whitespace"
184 }
185
186 fn category(&self) -> RuleCategory {
187 RuleCategory::Link
188 }
189
190 fn should_skip(&self, ctx: &LintContext) -> bool {
191 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
192 }
193
194 fn check(&self, ctx: &LintContext) -> LintResult {
195 let mut warnings = Vec::new();
196
197 for link in &ctx.links {
199 if link.is_reference || !matches!(link.link_type, LinkType::Inline) {
201 continue;
202 }
203
204 if ctx.is_in_jinja_range(link.byte_offset) {
206 continue;
207 }
208
209 let raw_link = &ctx.content[link.byte_offset..link.byte_end];
211
212 if let Some((_, _, raw_dest)) = self.extract_destination_info(raw_link)
214 && let Some(issue) = self.check_destination_whitespace(raw_dest)
215 && let Some(fixed) = self.create_fix(raw_link)
216 {
217 warnings.push(LintWarning {
218 rule_name: Some(self.name().to_string()),
219 line: link.line,
220 column: link.start_col + 1,
221 end_line: link.line,
222 end_column: link.end_col + 1,
223 message: issue.message(false),
224 severity: Severity::Warning,
225 fix: Some(Fix {
226 range: link.byte_offset..link.byte_end,
227 replacement: fixed,
228 }),
229 });
230 }
231 }
232
233 for image in &ctx.images {
235 if image.is_reference || !matches!(image.link_type, LinkType::Inline) {
237 continue;
238 }
239
240 if ctx.is_in_jinja_range(image.byte_offset) {
242 continue;
243 }
244
245 let raw_image = &ctx.content[image.byte_offset..image.byte_end];
247
248 let link_portion = raw_image.strip_prefix('!').unwrap_or(raw_image);
250
251 if let Some((_, _, raw_dest)) = self.extract_destination_info(link_portion)
253 && let Some(issue) = self.check_destination_whitespace(raw_dest)
254 && let Some(fixed_link) = self.create_fix(link_portion)
255 {
256 let fixed = format!("!{fixed_link}");
257 warnings.push(LintWarning {
258 rule_name: Some(self.name().to_string()),
259 line: image.line,
260 column: image.start_col + 1,
261 end_line: image.line,
262 end_column: image.end_col + 1,
263 message: issue.message(true),
264 severity: Severity::Warning,
265 fix: Some(Fix {
266 range: image.byte_offset..image.byte_end,
267 replacement: fixed,
268 }),
269 });
270 }
271 }
272
273 Ok(warnings)
274 }
275
276 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
277 let warnings = self.check(ctx)?;
278 let warnings =
279 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
280
281 if warnings.is_empty() {
282 return Ok(ctx.content.to_string());
283 }
284
285 let mut content = ctx.content.to_string();
286 let mut fixes: Vec<_> = warnings
287 .into_iter()
288 .filter_map(|w| w.fix.map(|f| (f.range.start, f.range.end, f.replacement)))
289 .collect();
290
291 fixes.sort_by_key(|(start, _, _)| *start);
293
294 for (start, end, replacement) in fixes.into_iter().rev() {
295 content.replace_range(start..end, &replacement);
296 }
297
298 Ok(content)
299 }
300
301 fn as_any(&self) -> &dyn std::any::Any {
302 self
303 }
304
305 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
306 where
307 Self: Sized,
308 {
309 Box::new(Self)
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use crate::config::MarkdownFlavor;
317
318 #[test]
319 fn test_no_whitespace() {
320 let rule = MD062LinkDestinationWhitespace::new();
321 let content = "[link](https://example.com)";
322 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
323 let warnings = rule.check(&ctx).unwrap();
324 assert!(warnings.is_empty());
325 }
326
327 #[test]
328 fn test_leading_whitespace() {
329 let rule = MD062LinkDestinationWhitespace::new();
330 let content = "[link]( https://example.com)";
331 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
332 let warnings = rule.check(&ctx).unwrap();
333 assert_eq!(warnings.len(), 1);
334 assert_eq!(
335 warnings[0].fix.as_ref().unwrap().replacement,
336 "[link](https://example.com)"
337 );
338 }
339
340 #[test]
341 fn test_trailing_whitespace() {
342 let rule = MD062LinkDestinationWhitespace::new();
343 let content = "[link](https://example.com )";
344 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
345 let warnings = rule.check(&ctx).unwrap();
346 assert_eq!(warnings.len(), 1);
347 assert_eq!(
348 warnings[0].fix.as_ref().unwrap().replacement,
349 "[link](https://example.com)"
350 );
351 }
352
353 #[test]
354 fn test_both_whitespace() {
355 let rule = MD062LinkDestinationWhitespace::new();
356 let content = "[link]( https://example.com )";
357 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
358 let warnings = rule.check(&ctx).unwrap();
359 assert_eq!(warnings.len(), 1);
360 assert_eq!(
361 warnings[0].fix.as_ref().unwrap().replacement,
362 "[link](https://example.com)"
363 );
364 }
365
366 #[test]
367 fn test_multiple_spaces() {
368 let rule = MD062LinkDestinationWhitespace::new();
369 let content = "[link]( https://example.com )";
370 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
371 let warnings = rule.check(&ctx).unwrap();
372 assert_eq!(warnings.len(), 1);
373 assert_eq!(
374 warnings[0].fix.as_ref().unwrap().replacement,
375 "[link](https://example.com)"
376 );
377 }
378
379 #[test]
380 fn test_with_title() {
381 let rule = MD062LinkDestinationWhitespace::new();
382 let content = "[link]( https://example.com \"title\")";
383 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
384 let warnings = rule.check(&ctx).unwrap();
385 assert_eq!(warnings.len(), 1);
386 assert_eq!(
387 warnings[0].fix.as_ref().unwrap().replacement,
388 "[link](https://example.com \"title\")"
389 );
390 }
391
392 #[test]
393 fn test_image_leading_whitespace() {
394 let rule = MD062LinkDestinationWhitespace::new();
395 let content = "";
396 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
397 let warnings = rule.check(&ctx).unwrap();
398 assert_eq!(warnings.len(), 1);
399 assert_eq!(
400 warnings[0].fix.as_ref().unwrap().replacement,
401 ""
402 );
403 }
404
405 #[test]
406 fn test_multiple_links() {
407 let rule = MD062LinkDestinationWhitespace::new();
408 let content = "[a]( url1) and [b](url2 ) here";
409 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
410 let warnings = rule.check(&ctx).unwrap();
411 assert_eq!(warnings.len(), 2);
412 }
413
414 #[test]
415 fn test_fix() {
416 let rule = MD062LinkDestinationWhitespace::new();
417 let content = "[link]( https://example.com ) and ";
418 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
419 let fixed = rule.fix(&ctx).unwrap();
420 assert_eq!(fixed, "[link](https://example.com) and ");
421 }
422
423 #[test]
424 fn test_reference_links_skipped() {
425 let rule = MD062LinkDestinationWhitespace::new();
426 let content = "[link][ref]\n\n[ref]: https://example.com";
427 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
428 let warnings = rule.check(&ctx).unwrap();
429 assert!(warnings.is_empty());
430 }
431
432 #[test]
433 fn test_nested_brackets() {
434 let rule = MD062LinkDestinationWhitespace::new();
435 let content = "[text [nested]]( https://example.com)";
436 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
437 let warnings = rule.check(&ctx).unwrap();
438 assert_eq!(warnings.len(), 1);
439 }
440
441 #[test]
442 fn test_empty_destination() {
443 let rule = MD062LinkDestinationWhitespace::new();
444 let content = "[link]()";
445 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
446 let warnings = rule.check(&ctx).unwrap();
447 assert!(warnings.is_empty());
448 }
449
450 #[test]
451 fn test_tabs_and_newlines() {
452 let rule = MD062LinkDestinationWhitespace::new();
453 let content = "[link](\thttps://example.com\t)";
454 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
455 let warnings = rule.check(&ctx).unwrap();
456 assert_eq!(warnings.len(), 1);
457 assert_eq!(
458 warnings[0].fix.as_ref().unwrap().replacement,
459 "[link](https://example.com)"
460 );
461 }
462
463 #[test]
466 fn test_trailing_whitespace_after_title() {
467 let rule = MD062LinkDestinationWhitespace::new();
468 let content = "[link](https://example.com \"title\" )";
469 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
470 let warnings = rule.check(&ctx).unwrap();
471 assert_eq!(warnings.len(), 1);
472 assert_eq!(
473 warnings[0].fix.as_ref().unwrap().replacement,
474 "[link](https://example.com \"title\")"
475 );
476 }
477
478 #[test]
479 fn test_leading_and_trailing_with_title() {
480 let rule = MD062LinkDestinationWhitespace::new();
481 let content = "[link]( https://example.com \"title\" )";
482 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
483 let warnings = rule.check(&ctx).unwrap();
484 assert_eq!(warnings.len(), 1);
485 assert_eq!(
486 warnings[0].fix.as_ref().unwrap().replacement,
487 "[link](https://example.com \"title\")"
488 );
489 }
490
491 #[test]
492 fn test_multiple_spaces_before_title() {
493 let rule = MD062LinkDestinationWhitespace::new();
494 let content = "[link](https://example.com \"title\")";
495 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
496 let warnings = rule.check(&ctx).unwrap();
497 assert_eq!(warnings.len(), 1);
498 assert_eq!(
499 warnings[0].fix.as_ref().unwrap().replacement,
500 "[link](https://example.com \"title\")"
501 );
502 }
503
504 #[test]
505 fn test_single_quote_title() {
506 let rule = MD062LinkDestinationWhitespace::new();
507 let content = "[link]( https://example.com 'title')";
508 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
509 let warnings = rule.check(&ctx).unwrap();
510 assert_eq!(warnings.len(), 1);
511 assert_eq!(
512 warnings[0].fix.as_ref().unwrap().replacement,
513 "[link](https://example.com 'title')"
514 );
515 }
516
517 #[test]
518 fn test_single_quote_title_trailing_space() {
519 let rule = MD062LinkDestinationWhitespace::new();
520 let content = "[link](https://example.com 'title' )";
521 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
522 let warnings = rule.check(&ctx).unwrap();
523 assert_eq!(warnings.len(), 1);
524 assert_eq!(
525 warnings[0].fix.as_ref().unwrap().replacement,
526 "[link](https://example.com 'title')"
527 );
528 }
529
530 #[test]
531 fn test_wikipedia_style_url() {
532 let rule = MD062LinkDestinationWhitespace::new();
534 let content = "[wiki]( https://en.wikipedia.org/wiki/Rust_(programming_language) )";
535 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
536 let warnings = rule.check(&ctx).unwrap();
537 assert_eq!(warnings.len(), 1);
538 assert_eq!(
539 warnings[0].fix.as_ref().unwrap().replacement,
540 "[wiki](https://en.wikipedia.org/wiki/Rust_(programming_language))"
541 );
542 }
543
544 #[test]
545 fn test_angle_bracket_url_no_warning() {
546 let rule = MD062LinkDestinationWhitespace::new();
548 let content = "[link](<https://example.com/path with spaces>)";
549 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
550 let warnings = rule.check(&ctx).unwrap();
551 assert!(warnings.is_empty());
553 }
554
555 #[test]
556 fn test_image_with_title() {
557 let rule = MD062LinkDestinationWhitespace::new();
558 let content = "";
559 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
560 let warnings = rule.check(&ctx).unwrap();
561 assert_eq!(warnings.len(), 1);
562 assert_eq!(
563 warnings[0].fix.as_ref().unwrap().replacement,
564 ""
565 );
566 }
567
568 #[test]
569 fn test_only_whitespace_in_destination() {
570 let rule = MD062LinkDestinationWhitespace::new();
571 let content = "[link]( )";
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!(warnings[0].fix.as_ref().unwrap().replacement, "[link]()");
576 }
577
578 #[test]
579 fn test_code_block_skipped() {
580 let rule = MD062LinkDestinationWhitespace::new();
581 let content = "```\n[link]( https://example.com )\n```";
582 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
583 let warnings = rule.check(&ctx).unwrap();
584 assert!(warnings.is_empty());
585 }
586
587 #[test]
588 fn test_inline_code_not_skipped() {
589 let rule = MD062LinkDestinationWhitespace::new();
591 let content = "text `[link]( url )` more text";
592 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
593 let warnings = rule.check(&ctx).unwrap();
594 assert!(warnings.is_empty());
596 }
597
598 #[test]
599 fn test_valid_link_with_title_no_warning() {
600 let rule = MD062LinkDestinationWhitespace::new();
601 let content = "[link](https://example.com \"Title\")";
602 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
603 let warnings = rule.check(&ctx).unwrap();
604 assert!(warnings.is_empty());
605 }
606
607 #[test]
608 fn test_mixed_links_on_same_line() {
609 let rule = MD062LinkDestinationWhitespace::new();
610 let content = "[good](https://example.com) and [bad]( https://example.com ) here";
611 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
612 let warnings = rule.check(&ctx).unwrap();
613 assert_eq!(warnings.len(), 1);
614 assert_eq!(
615 warnings[0].fix.as_ref().unwrap().replacement,
616 "[bad](https://example.com)"
617 );
618 }
619
620 #[test]
621 fn test_fix_multiple_on_same_line() {
622 let rule = MD062LinkDestinationWhitespace::new();
623 let content = "[a]( url1 ) and [b]( url2 )";
624 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
625 let fixed = rule.fix(&ctx).unwrap();
626 assert_eq!(fixed, "[a](url1) and [b](url2)");
627 }
628
629 #[test]
630 fn test_complex_nested_brackets() {
631 let rule = MD062LinkDestinationWhitespace::new();
632 let content = "[text [with [deeply] nested] brackets]( https://example.com )";
633 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
634 let warnings = rule.check(&ctx).unwrap();
635 assert_eq!(warnings.len(), 1);
636 }
637
638 #[test]
639 fn test_url_with_query_params() {
640 let rule = MD062LinkDestinationWhitespace::new();
641 let content = "[link]( https://example.com?foo=bar&baz=qux )";
642 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
643 let warnings = rule.check(&ctx).unwrap();
644 assert_eq!(warnings.len(), 1);
645 assert_eq!(
646 warnings[0].fix.as_ref().unwrap().replacement,
647 "[link](https://example.com?foo=bar&baz=qux)"
648 );
649 }
650
651 #[test]
652 fn test_url_with_fragment() {
653 let rule = MD062LinkDestinationWhitespace::new();
654 let content = "[link]( https://example.com#section )";
655 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
656 let warnings = rule.check(&ctx).unwrap();
657 assert_eq!(warnings.len(), 1);
658 assert_eq!(
659 warnings[0].fix.as_ref().unwrap().replacement,
660 "[link](https://example.com#section)"
661 );
662 }
663
664 #[test]
665 fn test_relative_path() {
666 let rule = MD062LinkDestinationWhitespace::new();
667 let content = "[link]( ./path/to/file.md )";
668 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
669 let warnings = rule.check(&ctx).unwrap();
670 assert_eq!(warnings.len(), 1);
671 assert_eq!(
672 warnings[0].fix.as_ref().unwrap().replacement,
673 "[link](./path/to/file.md)"
674 );
675 }
676
677 #[test]
678 fn test_unmatched_angle_bracket_in_destination() {
679 let rule = MD062LinkDestinationWhitespace::new();
683 let content = "[]( \"<)";
684 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
685 let warnings = rule.check(&ctx).unwrap();
686 assert!(
687 warnings.is_empty(),
688 "Should not warn when closing paren is masked by angle bracket"
689 );
690
691 let fixed = rule.fix(&ctx).unwrap();
693 assert_eq!(fixed, content);
694 }
695
696 #[test]
697 fn test_unicode_whitespace_in_destination() {
698 let rule = MD062LinkDestinationWhitespace::new();
700 let content = "[](\u{2000}\"<)";
701 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
702 let warnings = rule.check(&ctx).unwrap();
703 assert!(
704 warnings.is_empty(),
705 "Should not warn when angle bracket masks closing paren"
706 );
707
708 let fixed = rule.fix(&ctx).unwrap();
709 assert_eq!(fixed, content, "Fix must be idempotent for unparseable links");
710 }
711
712 #[test]
713 fn test_autolink_not_affected() {
714 let rule = MD062LinkDestinationWhitespace::new();
716 let content = "<https://example.com>";
717 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
718 let warnings = rule.check(&ctx).unwrap();
719 assert!(warnings.is_empty());
720 }
721}