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