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(char::is_whitespace);
125
126 let has_trailing = if last_char.is_some_and(char::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::new(link.byte_offset..link.byte_end, fixed)),
226 });
227 }
228 }
229
230 for image in &ctx.images {
232 if image.is_reference || !matches!(image.link_type, LinkType::Inline) {
234 continue;
235 }
236
237 if ctx.is_in_jinja_range(image.byte_offset) {
239 continue;
240 }
241
242 let raw_image = &ctx.content[image.byte_offset..image.byte_end];
244
245 let link_portion = raw_image.strip_prefix('!').unwrap_or(raw_image);
247
248 if let Some((_, _, raw_dest)) = self.extract_destination_info(link_portion)
250 && let Some(issue) = self.check_destination_whitespace(raw_dest)
251 && let Some(fixed_link) = self.create_fix(link_portion)
252 {
253 let fixed = format!("!{fixed_link}");
254 warnings.push(LintWarning {
255 rule_name: Some(self.name().to_string()),
256 line: image.line,
257 column: image.start_col + 1,
258 end_line: image.line,
259 end_column: image.end_col + 1,
260 message: issue.message(true),
261 severity: Severity::Warning,
262 fix: Some(Fix::new(image.byte_offset..image.byte_end, fixed)),
263 });
264 }
265 }
266
267 Ok(warnings)
268 }
269
270 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
271 let warnings = self.check(ctx)?;
272 let warnings =
273 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
274
275 if warnings.is_empty() {
276 return Ok(ctx.content.to_string());
277 }
278
279 let mut content = ctx.content.to_string();
280 let mut fixes: Vec<_> = warnings
281 .into_iter()
282 .filter_map(|w| w.fix.map(|f| (f.range.start, f.range.end, f.replacement)))
283 .collect();
284
285 fixes.sort_by_key(|(start, _, _)| *start);
287
288 for (start, end, replacement) in fixes.into_iter().rev() {
289 content.replace_range(start..end, &replacement);
290 }
291
292 Ok(content)
293 }
294
295 fn as_any(&self) -> &dyn std::any::Any {
296 self
297 }
298
299 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
300 where
301 Self: Sized,
302 {
303 Box::new(Self)
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use crate::config::MarkdownFlavor;
311
312 #[test]
313 fn test_no_whitespace() {
314 let rule = MD062LinkDestinationWhitespace::new();
315 let content = "[link](https://example.com)";
316 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
317 let warnings = rule.check(&ctx).unwrap();
318 assert!(warnings.is_empty());
319 }
320
321 #[test]
322 fn test_leading_whitespace() {
323 let rule = MD062LinkDestinationWhitespace::new();
324 let content = "[link]( https://example.com)";
325 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
326 let warnings = rule.check(&ctx).unwrap();
327 assert_eq!(warnings.len(), 1);
328 assert_eq!(
329 warnings[0].fix.as_ref().unwrap().replacement,
330 "[link](https://example.com)"
331 );
332 }
333
334 #[test]
335 fn test_trailing_whitespace() {
336 let rule = MD062LinkDestinationWhitespace::new();
337 let content = "[link](https://example.com )";
338 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
339 let warnings = rule.check(&ctx).unwrap();
340 assert_eq!(warnings.len(), 1);
341 assert_eq!(
342 warnings[0].fix.as_ref().unwrap().replacement,
343 "[link](https://example.com)"
344 );
345 }
346
347 #[test]
348 fn test_both_whitespace() {
349 let rule = MD062LinkDestinationWhitespace::new();
350 let content = "[link]( https://example.com )";
351 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
352 let warnings = rule.check(&ctx).unwrap();
353 assert_eq!(warnings.len(), 1);
354 assert_eq!(
355 warnings[0].fix.as_ref().unwrap().replacement,
356 "[link](https://example.com)"
357 );
358 }
359
360 #[test]
361 fn test_multiple_spaces() {
362 let rule = MD062LinkDestinationWhitespace::new();
363 let content = "[link]( https://example.com )";
364 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
365 let warnings = rule.check(&ctx).unwrap();
366 assert_eq!(warnings.len(), 1);
367 assert_eq!(
368 warnings[0].fix.as_ref().unwrap().replacement,
369 "[link](https://example.com)"
370 );
371 }
372
373 #[test]
374 fn test_with_title() {
375 let rule = MD062LinkDestinationWhitespace::new();
376 let content = "[link]( https://example.com \"title\")";
377 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
378 let warnings = rule.check(&ctx).unwrap();
379 assert_eq!(warnings.len(), 1);
380 assert_eq!(
381 warnings[0].fix.as_ref().unwrap().replacement,
382 "[link](https://example.com \"title\")"
383 );
384 }
385
386 #[test]
387 fn test_image_leading_whitespace() {
388 let rule = MD062LinkDestinationWhitespace::new();
389 let content = "";
390 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
391 let warnings = rule.check(&ctx).unwrap();
392 assert_eq!(warnings.len(), 1);
393 assert_eq!(
394 warnings[0].fix.as_ref().unwrap().replacement,
395 ""
396 );
397 }
398
399 #[test]
400 fn test_multiple_links() {
401 let rule = MD062LinkDestinationWhitespace::new();
402 let content = "[a]( url1) and [b](url2 ) here";
403 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
404 let warnings = rule.check(&ctx).unwrap();
405 assert_eq!(warnings.len(), 2);
406 }
407
408 #[test]
409 fn test_fix() {
410 let rule = MD062LinkDestinationWhitespace::new();
411 let content = "[link]( https://example.com ) and ";
412 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
413 let fixed = rule.fix(&ctx).unwrap();
414 assert_eq!(fixed, "[link](https://example.com) and ");
415 }
416
417 #[test]
418 fn test_reference_links_skipped() {
419 let rule = MD062LinkDestinationWhitespace::new();
420 let content = "[link][ref]\n\n[ref]: https://example.com";
421 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
422 let warnings = rule.check(&ctx).unwrap();
423 assert!(warnings.is_empty());
424 }
425
426 #[test]
427 fn test_nested_brackets() {
428 let rule = MD062LinkDestinationWhitespace::new();
429 let content = "[text [nested]]( https://example.com)";
430 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
431 let warnings = rule.check(&ctx).unwrap();
432 assert_eq!(warnings.len(), 1);
433 }
434
435 #[test]
436 fn test_empty_destination() {
437 let rule = MD062LinkDestinationWhitespace::new();
438 let content = "[link]()";
439 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
440 let warnings = rule.check(&ctx).unwrap();
441 assert!(warnings.is_empty());
442 }
443
444 #[test]
445 fn test_tabs_and_newlines() {
446 let rule = MD062LinkDestinationWhitespace::new();
447 let content = "[link](\thttps://example.com\t)";
448 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
449 let warnings = rule.check(&ctx).unwrap();
450 assert_eq!(warnings.len(), 1);
451 assert_eq!(
452 warnings[0].fix.as_ref().unwrap().replacement,
453 "[link](https://example.com)"
454 );
455 }
456
457 #[test]
460 fn test_trailing_whitespace_after_title() {
461 let rule = MD062LinkDestinationWhitespace::new();
462 let content = "[link](https://example.com \"title\" )";
463 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
464 let warnings = rule.check(&ctx).unwrap();
465 assert_eq!(warnings.len(), 1);
466 assert_eq!(
467 warnings[0].fix.as_ref().unwrap().replacement,
468 "[link](https://example.com \"title\")"
469 );
470 }
471
472 #[test]
473 fn test_leading_and_trailing_with_title() {
474 let rule = MD062LinkDestinationWhitespace::new();
475 let content = "[link]( https://example.com \"title\" )";
476 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
477 let warnings = rule.check(&ctx).unwrap();
478 assert_eq!(warnings.len(), 1);
479 assert_eq!(
480 warnings[0].fix.as_ref().unwrap().replacement,
481 "[link](https://example.com \"title\")"
482 );
483 }
484
485 #[test]
486 fn test_multiple_spaces_before_title() {
487 let rule = MD062LinkDestinationWhitespace::new();
488 let content = "[link](https://example.com \"title\")";
489 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
490 let warnings = rule.check(&ctx).unwrap();
491 assert_eq!(warnings.len(), 1);
492 assert_eq!(
493 warnings[0].fix.as_ref().unwrap().replacement,
494 "[link](https://example.com \"title\")"
495 );
496 }
497
498 #[test]
499 fn test_single_quote_title() {
500 let rule = MD062LinkDestinationWhitespace::new();
501 let content = "[link]( https://example.com 'title')";
502 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
503 let warnings = rule.check(&ctx).unwrap();
504 assert_eq!(warnings.len(), 1);
505 assert_eq!(
506 warnings[0].fix.as_ref().unwrap().replacement,
507 "[link](https://example.com 'title')"
508 );
509 }
510
511 #[test]
512 fn test_single_quote_title_trailing_space() {
513 let rule = MD062LinkDestinationWhitespace::new();
514 let content = "[link](https://example.com 'title' )";
515 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
516 let warnings = rule.check(&ctx).unwrap();
517 assert_eq!(warnings.len(), 1);
518 assert_eq!(
519 warnings[0].fix.as_ref().unwrap().replacement,
520 "[link](https://example.com 'title')"
521 );
522 }
523
524 #[test]
525 fn test_wikipedia_style_url() {
526 let rule = MD062LinkDestinationWhitespace::new();
528 let content = "[wiki]( https://en.wikipedia.org/wiki/Rust_(programming_language) )";
529 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
530 let warnings = rule.check(&ctx).unwrap();
531 assert_eq!(warnings.len(), 1);
532 assert_eq!(
533 warnings[0].fix.as_ref().unwrap().replacement,
534 "[wiki](https://en.wikipedia.org/wiki/Rust_(programming_language))"
535 );
536 }
537
538 #[test]
539 fn test_angle_bracket_url_no_warning() {
540 let rule = MD062LinkDestinationWhitespace::new();
542 let content = "[link](<https://example.com/path with spaces>)";
543 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
544 let warnings = rule.check(&ctx).unwrap();
545 assert!(warnings.is_empty());
547 }
548
549 #[test]
550 fn test_image_with_title() {
551 let rule = MD062LinkDestinationWhitespace::new();
552 let content = "";
553 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
554 let warnings = rule.check(&ctx).unwrap();
555 assert_eq!(warnings.len(), 1);
556 assert_eq!(
557 warnings[0].fix.as_ref().unwrap().replacement,
558 ""
559 );
560 }
561
562 #[test]
563 fn test_only_whitespace_in_destination() {
564 let rule = MD062LinkDestinationWhitespace::new();
565 let content = "[link]( )";
566 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
567 let warnings = rule.check(&ctx).unwrap();
568 assert_eq!(warnings.len(), 1);
569 assert_eq!(warnings[0].fix.as_ref().unwrap().replacement, "[link]()");
570 }
571
572 #[test]
573 fn test_code_block_skipped() {
574 let rule = MD062LinkDestinationWhitespace::new();
575 let content = "```\n[link]( https://example.com )\n```";
576 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
577 let warnings = rule.check(&ctx).unwrap();
578 assert!(warnings.is_empty());
579 }
580
581 #[test]
582 fn test_inline_code_not_skipped() {
583 let rule = MD062LinkDestinationWhitespace::new();
585 let content = "text `[link]( url )` more text";
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_valid_link_with_title_no_warning() {
594 let rule = MD062LinkDestinationWhitespace::new();
595 let content = "[link](https://example.com \"Title\")";
596 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
597 let warnings = rule.check(&ctx).unwrap();
598 assert!(warnings.is_empty());
599 }
600
601 #[test]
602 fn test_mixed_links_on_same_line() {
603 let rule = MD062LinkDestinationWhitespace::new();
604 let content = "[good](https://example.com) and [bad]( https://example.com ) here";
605 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
606 let warnings = rule.check(&ctx).unwrap();
607 assert_eq!(warnings.len(), 1);
608 assert_eq!(
609 warnings[0].fix.as_ref().unwrap().replacement,
610 "[bad](https://example.com)"
611 );
612 }
613
614 #[test]
615 fn test_fix_multiple_on_same_line() {
616 let rule = MD062LinkDestinationWhitespace::new();
617 let content = "[a]( url1 ) and [b]( url2 )";
618 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
619 let fixed = rule.fix(&ctx).unwrap();
620 assert_eq!(fixed, "[a](url1) and [b](url2)");
621 }
622
623 #[test]
624 fn test_complex_nested_brackets() {
625 let rule = MD062LinkDestinationWhitespace::new();
626 let content = "[text [with [deeply] nested] brackets]( https://example.com )";
627 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
628 let warnings = rule.check(&ctx).unwrap();
629 assert_eq!(warnings.len(), 1);
630 }
631
632 #[test]
633 fn test_url_with_query_params() {
634 let rule = MD062LinkDestinationWhitespace::new();
635 let content = "[link]( https://example.com?foo=bar&baz=qux )";
636 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
637 let warnings = rule.check(&ctx).unwrap();
638 assert_eq!(warnings.len(), 1);
639 assert_eq!(
640 warnings[0].fix.as_ref().unwrap().replacement,
641 "[link](https://example.com?foo=bar&baz=qux)"
642 );
643 }
644
645 #[test]
646 fn test_url_with_fragment() {
647 let rule = MD062LinkDestinationWhitespace::new();
648 let content = "[link]( https://example.com#section )";
649 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
650 let warnings = rule.check(&ctx).unwrap();
651 assert_eq!(warnings.len(), 1);
652 assert_eq!(
653 warnings[0].fix.as_ref().unwrap().replacement,
654 "[link](https://example.com#section)"
655 );
656 }
657
658 #[test]
659 fn test_relative_path() {
660 let rule = MD062LinkDestinationWhitespace::new();
661 let content = "[link]( ./path/to/file.md )";
662 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
663 let warnings = rule.check(&ctx).unwrap();
664 assert_eq!(warnings.len(), 1);
665 assert_eq!(
666 warnings[0].fix.as_ref().unwrap().replacement,
667 "[link](./path/to/file.md)"
668 );
669 }
670
671 #[test]
672 fn test_unmatched_angle_bracket_in_destination() {
673 let rule = MD062LinkDestinationWhitespace::new();
677 let content = "[]( \"<)";
678 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
679 let warnings = rule.check(&ctx).unwrap();
680 assert!(
681 warnings.is_empty(),
682 "Should not warn when closing paren is masked by angle bracket"
683 );
684
685 let fixed = rule.fix(&ctx).unwrap();
687 assert_eq!(fixed, content);
688 }
689
690 #[test]
691 fn test_unicode_whitespace_in_destination() {
692 let rule = MD062LinkDestinationWhitespace::new();
694 let content = "[](\u{2000}\"<)";
695 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
696 let warnings = rule.check(&ctx).unwrap();
697 assert!(
698 warnings.is_empty(),
699 "Should not warn when angle bracket masks closing paren"
700 );
701
702 let fixed = rule.fix(&ctx).unwrap();
703 assert_eq!(fixed, content, "Fix must be idempotent for unparseable links");
704 }
705
706 #[test]
707 fn test_autolink_not_affected() {
708 let rule = MD062LinkDestinationWhitespace::new();
710 let content = "<https://example.com>";
711 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
712 let warnings = rule.check(&ctx).unwrap();
713 assert!(warnings.is_empty());
714 }
715}