1use crate::lint_context::LintContext;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use pulldown_cmark::LinkType;
4
5fn is_destination_space(c: char) -> bool {
11 matches!(c, ' ' | '\t' | '\n' | '\r')
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16enum WhitespaceIssue {
17 Leading,
18 Trailing,
19 Both,
20}
21
22impl WhitespaceIssue {
23 fn message(self, is_image: bool) -> String {
24 let element = if is_image { "Image" } else { "Link" };
25 match self {
26 WhitespaceIssue::Leading => {
27 format!("{element} destination has leading whitespace")
28 }
29 WhitespaceIssue::Trailing => {
30 format!("{element} destination has trailing whitespace")
31 }
32 WhitespaceIssue::Both => {
33 format!("{element} destination has leading and trailing whitespace")
34 }
35 }
36 }
37}
38
39#[derive(Debug, Default, Clone)]
53pub struct MD062LinkDestinationWhitespace;
54
55impl MD062LinkDestinationWhitespace {
56 pub fn new() -> Self {
57 Self
58 }
59
60 fn is_escaped(text: &str, byte_index: usize) -> bool {
67 text.as_bytes()[..byte_index]
68 .iter()
69 .rev()
70 .take_while(|&&byte| byte == b'\\')
71 .count()
72 % 2
73 != 0
74 }
75
76 fn without_quote_markers<'a>(ctx: &LintContext, line: usize, raw: &'a str) -> std::borrow::Cow<'a, str> {
84 use crate::utils::blockquote::parse_blockquote_prefix;
85 use std::borrow::Cow;
86
87 if !raw.contains('\n') {
88 return Cow::Borrowed(raw);
89 }
90 let mut stripped = String::with_capacity(raw.len());
91 let mut changed = false;
92 for (index, segment) in raw.split_inclusive('\n').enumerate() {
93 let in_quote = index > 0
94 && ctx
95 .lines
96 .get(line - 1 + index)
97 .is_some_and(|info| info.blockquote.is_some());
98 match parse_blockquote_prefix(segment).filter(|_| in_quote) {
99 Some(parsed) => {
100 stripped.push_str(parsed.content);
101 changed = true;
102 }
103 None => stripped.push_str(segment),
104 }
105 }
106 if changed {
107 Cow::Owned(stripped)
108 } else {
109 Cow::Borrowed(raw)
110 }
111 }
112
113 fn fix_for(
124 &self,
125 ctx: &LintContext,
126 line: usize,
127 raw: &str,
128 target: (&str, Option<&str>),
129 ) -> Option<(WhitespaceIssue, String)> {
130 let (dest_start, _, _) = self.extract_destination_info(raw)?;
132 let (label, tail) = raw.split_at(dest_start);
133 let label_lines = label.matches('\n').count();
134 let tail = Self::without_quote_markers(ctx, line + label_lines, tail);
135 let markers_dropped = matches!(tail, std::borrow::Cow::Owned(_));
136 let source = format!("{label}{tail}");
137 let (_, _, raw_dest) = self.extract_destination_info(&source)?;
138 let issue = self.check_destination_whitespace(raw_dest)?;
139 let fixed = self.create_fix(&source)?;
140 if markers_dropped && fixed[dest_start..].contains(['\n', '\r']) {
141 return None;
142 }
143 if markers_dropped && !Self::parses_to_target(&format!("[x]({}", &fixed[dest_start..]), target) {
144 return None;
145 }
146 Some((issue, fixed))
147 }
148
149 fn parses_to_target(link: &str, (url, title): (&str, Option<&str>)) -> bool {
151 use pulldown_cmark::{Event, Parser, Tag};
152
153 let mut found = Parser::new(link).filter_map(|event| match event {
154 Event::Start(Tag::Link {
155 dest_url, title: found, ..
156 }) => Some((dest_url, found)),
157 _ => None,
158 });
159 match (found.next(), found.next()) {
160 (Some((dest, found)), None) => dest.as_ref() == url && (found.as_ref() == title.unwrap_or("")),
161 _ => false,
162 }
163 }
164
165 fn extract_destination_info<'a>(&self, raw_link: &'a str) -> Option<(usize, usize, &'a str)> {
168 let mut bracket_depth = 0;
171 let mut paren_start = None;
172
173 let mut escaped = false;
174 for (i, c) in raw_link.char_indices() {
175 if escaped {
176 escaped = false;
177 continue;
178 }
179
180 match c {
181 '\\' => escaped = true,
182 '[' => bracket_depth += 1,
183 ']' => {
184 bracket_depth -= 1;
185 if bracket_depth == 0 {
186 let rest = &raw_link[i + 1..];
188 if rest.starts_with('(') {
189 paren_start = Some(i + 1);
190 }
191 break;
192 }
193 }
194 _ => {}
195 }
196 }
197
198 let paren_start = paren_start?;
199
200 let dest_content_start = paren_start + 1; let dest_content_end = raw_link.len().checked_sub(1)?;
202 if raw_link.as_bytes().get(dest_content_end) != Some(&b')')
203 || Self::is_escaped(raw_link, dest_content_end)
204 || dest_content_end < dest_content_start
205 {
206 return None;
207 }
208
209 let dest_content = &raw_link[dest_content_start..dest_content_end];
213 let (url, _) = Self::split_url_and_title(dest_content);
214 let mut in_angle_brackets = false;
215 let mut escaped = false;
216 for c in url.chars() {
217 if escaped {
218 escaped = false;
219 continue;
220 }
221 match c {
222 '\\' => escaped = true,
223 '<' if !in_angle_brackets => in_angle_brackets = true,
224 '>' if in_angle_brackets => in_angle_brackets = false,
225 _ => {}
226 }
227 }
228 if in_angle_brackets {
229 return None;
230 }
231
232 Some((dest_content_start, dest_content_end, dest_content))
233 }
234
235 fn split_url_and_title(dest: &str) -> (&str, Option<&str>) {
245 if let Some(q_start) = dest.find(['"', '\'']) {
247 let before = &dest[..q_start];
251 if !before.ends_with(is_destination_space) || before.trim_matches(is_destination_space).is_empty() {
252 return (dest, None);
253 }
254 let quote = dest.as_bytes()[q_start] as char;
255 if let Some(rel_close) = dest[q_start + 1..].find(quote) {
256 let close = q_start + 1 + rel_close;
257 if dest[close + 1..].trim_matches(is_destination_space).is_empty() {
258 return (&dest[..q_start], Some(&dest[q_start..=close]));
259 }
260 }
261 }
262 (dest, None)
263 }
264
265 fn check_destination_whitespace(&self, full_dest: &str) -> Option<WhitespaceIssue> {
268 if full_dest.is_empty() {
269 return None;
270 }
271
272 let first_char = full_dest.chars().next();
273 let last_char = full_dest.chars().last();
274
275 let has_leading = first_char.is_some_and(is_destination_space);
276
277 let has_trailing = if last_char.is_some_and(is_destination_space) {
279 true
280 } else {
281 let (url_portion, title) = Self::split_url_and_title(full_dest);
282 title.is_some() && url_portion.ends_with(is_destination_space)
283 };
284
285 match (has_leading, has_trailing) {
286 (true, true) => Some(WhitespaceIssue::Both),
287 (true, false) => Some(WhitespaceIssue::Leading),
288 (false, true) => Some(WhitespaceIssue::Trailing),
289 (false, false) => None,
290 }
291 }
292
293 fn create_fix(&self, raw_link: &str) -> Option<String> {
295 let (dest_start, dest_end, _) = self.extract_destination_info(raw_link)?;
296
297 let full_dest_content = &raw_link[dest_start..dest_end];
299
300 let (url_raw, title_raw) = Self::split_url_and_title(full_dest_content);
303 let url_part = url_raw.trim_matches(is_destination_space);
304 let title_part = title_raw.map(|title| title.trim_matches(is_destination_space));
305
306 let text_part = &raw_link[..dest_start]; let mut fixed = String::with_capacity(raw_link.len());
310 fixed.push_str(text_part);
311 fixed.push_str(url_part);
312 if let Some(title) = title_part {
313 fixed.push(' ');
314 fixed.push_str(title);
315 }
316 fixed.push(')');
317
318 if fixed != raw_link { Some(fixed) } else { None }
320 }
321}
322
323impl Rule for MD062LinkDestinationWhitespace {
324 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
325 Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(62))
326 }
327
328 fn name(&self) -> &'static str {
329 "MD062"
330 }
331
332 fn description(&self) -> &'static str {
333 "Link destination should not have leading or trailing whitespace"
334 }
335
336 fn category(&self) -> RuleCategory {
337 RuleCategory::Link
338 }
339
340 fn should_skip(&self, ctx: &LintContext) -> bool {
341 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
342 }
343
344 fn check(&self, ctx: &LintContext) -> LintResult {
345 let mut warnings = Vec::new();
346
347 for link in ctx.links() {
349 if link.is_reference || !matches!(link.link_type, LinkType::Inline) {
351 continue;
352 }
353
354 if ctx.is_in_jinja_range(link.byte_offset) {
356 continue;
357 }
358
359 let raw_link = &ctx.content[link.byte_offset..link.byte_end];
361
362 if let Some((issue, fixed)) = self.fix_for(ctx, link.line, raw_link, (&link.url, link.title.as_deref())) {
364 warnings.push(LintWarning {
365 rule_name: Some(self.name().to_string()),
366 line: link.line,
367 column: link.start_col + 1,
368 end_line: link.end_line,
369 end_column: link.end_col + 1,
370 message: issue.message(false),
371 severity: Severity::Warning,
372 fix: Some(Fix::new(link.byte_offset..link.byte_end, fixed)),
373 });
374 }
375 }
376
377 for image in ctx.images() {
379 if image.is_reference || !matches!(image.link_type, LinkType::Inline) {
381 continue;
382 }
383
384 if ctx.is_in_jinja_range(image.byte_offset) {
386 continue;
387 }
388
389 let raw_image = &ctx.content[image.byte_offset..image.byte_end];
391
392 let link_portion = raw_image.strip_prefix('!').unwrap_or(raw_image);
394
395 if let Some((issue, fixed_link)) =
397 self.fix_for(ctx, image.line, link_portion, (&image.url, image.title.as_deref()))
398 {
399 let fixed = format!("!{fixed_link}");
400 warnings.push(LintWarning {
401 rule_name: Some(self.name().to_string()),
402 line: image.line,
403 column: image.start_col + 1,
404 end_line: image.end_line,
405 end_column: image.end_col + 1,
406 message: issue.message(true),
407 severity: Severity::Warning,
408 fix: Some(Fix::new(image.byte_offset..image.byte_end, fixed)),
409 });
410 }
411 }
412
413 Ok(warnings)
414 }
415
416 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
417 let warnings = self.check(ctx)?;
418 let warnings =
419 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
420
421 if warnings.is_empty() {
422 return Ok(ctx.content.to_string());
423 }
424
425 let mut content = ctx.content.to_string();
426 let mut fixes: Vec<_> = warnings
427 .into_iter()
428 .filter_map(|w| w.fix.map(|f| (f.range.start, f.range.end, f.replacement)))
429 .collect();
430
431 fixes.sort_by_key(|(start, _, _)| *start);
433
434 for (start, end, replacement) in fixes.into_iter().rev() {
435 content.replace_range(start..end, &replacement);
436 }
437
438 Ok(content)
439 }
440
441 fn as_any(&self) -> &dyn std::any::Any {
442 self
443 }
444
445 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
446 where
447 Self: Sized,
448 {
449 Box::new(Self)
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use crate::config::MarkdownFlavor;
457
458 #[test]
459 fn test_no_whitespace() {
460 let rule = MD062LinkDestinationWhitespace::new();
461 let content = "[link](https://example.com)";
462 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
463 let warnings = rule.check(&ctx).unwrap();
464 assert!(warnings.is_empty());
465 }
466
467 #[test]
468 fn test_leading_whitespace() {
469 let rule = MD062LinkDestinationWhitespace::new();
470 let content = "[link]( https://example.com)";
471 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
472 let warnings = rule.check(&ctx).unwrap();
473 assert_eq!(warnings.len(), 1);
474 assert_eq!(
475 warnings[0].fix.as_ref().unwrap().replacement,
476 "[link](https://example.com)"
477 );
478 }
479
480 #[test]
481 fn test_trailing_whitespace() {
482 let rule = MD062LinkDestinationWhitespace::new();
483 let content = "[link](https://example.com )";
484 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
485 let warnings = rule.check(&ctx).unwrap();
486 assert_eq!(warnings.len(), 1);
487 assert_eq!(
488 warnings[0].fix.as_ref().unwrap().replacement,
489 "[link](https://example.com)"
490 );
491 }
492
493 #[test]
494 fn test_both_whitespace() {
495 let rule = MD062LinkDestinationWhitespace::new();
496 let content = "[link]( https://example.com )";
497 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
498 let warnings = rule.check(&ctx).unwrap();
499 assert_eq!(warnings.len(), 1);
500 assert_eq!(
501 warnings[0].fix.as_ref().unwrap().replacement,
502 "[link](https://example.com)"
503 );
504 }
505
506 #[test]
507 fn test_multiple_spaces() {
508 let rule = MD062LinkDestinationWhitespace::new();
509 let content = "[link]( https://example.com )";
510 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
511 let warnings = rule.check(&ctx).unwrap();
512 assert_eq!(warnings.len(), 1);
513 assert_eq!(
514 warnings[0].fix.as_ref().unwrap().replacement,
515 "[link](https://example.com)"
516 );
517 }
518
519 #[test]
520 fn test_with_title() {
521 let rule = MD062LinkDestinationWhitespace::new();
522 let content = "[link]( https://example.com \"title\")";
523 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
524 let warnings = rule.check(&ctx).unwrap();
525 assert_eq!(warnings.len(), 1);
526 assert_eq!(
527 warnings[0].fix.as_ref().unwrap().replacement,
528 "[link](https://example.com \"title\")"
529 );
530 }
531
532 #[test]
533 fn test_image_leading_whitespace() {
534 let rule = MD062LinkDestinationWhitespace::new();
535 let content = "";
536 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
537 let warnings = rule.check(&ctx).unwrap();
538 assert_eq!(warnings.len(), 1);
539 assert_eq!(
540 warnings[0].fix.as_ref().unwrap().replacement,
541 ""
542 );
543 }
544
545 #[test]
546 fn test_multiple_links() {
547 let rule = MD062LinkDestinationWhitespace::new();
548 let content = "[a]( url1) and [b](url2 ) here";
549 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
550 let warnings = rule.check(&ctx).unwrap();
551 assert_eq!(warnings.len(), 2);
552 }
553
554 #[test]
555 fn test_fix() {
556 let rule = MD062LinkDestinationWhitespace::new();
557 let content = "[link]( https://example.com ) and ";
558 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
559 let fixed = rule.fix(&ctx).unwrap();
560 assert_eq!(fixed, "[link](https://example.com) and ");
561 }
562
563 #[test]
564 fn test_fix_preserves_parentheses_inside_destination_and_title() {
565 let rule = MD062LinkDestinationWhitespace::new();
566 let cases = [
567 (r"[link]( path\)suffix )", r"[link](path\)suffix)"),
568 (r"[link]( path\(suffix )", r"[link](path\(suffix)"),
569 (r#"[link]( path "title) rest" )"#, r#"[link](path "title) rest")"#),
570 (r"[link]( path 'title) rest' )", r"[link](path 'title) rest')"),
571 (r"suffix )", r"suffix)"),
572 (r"[a \] b]( path )", r"[a \] b](path)"),
573 ];
574
575 for (content, expected) in cases {
576 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
577 let fixed = rule.fix(&ctx).unwrap();
578 assert_eq!(fixed, expected, "unexpected fix for {content:?}");
579
580 let fixed_ctx = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
581 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "second fix changed {content:?}");
582 }
583 }
584
585 #[test]
586 fn test_fix_idempotent_with_unbalanced_quote() {
587 let rule = MD062LinkDestinationWhitespace::new();
594 let content = "\n";
595
596 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
597 let once = rule.fix(&ctx).unwrap();
598
599 let ctx2 = LintContext::new(&once, MarkdownFlavor::Standard, None);
600 let twice = rule.fix(&ctx2).unwrap();
601
602 assert_eq!(once, twice, "MD062 fix must be idempotent for unbalanced quotes");
603 assert_eq!(
604 once.lines().count(),
605 2,
606 "fix must not drop the second image line, got: {once:?}"
607 );
608 }
609
610 #[test]
611 fn test_reference_links_skipped() {
612 let rule = MD062LinkDestinationWhitespace::new();
613 let content = "[link][ref]\n\n[ref]: https://example.com";
614 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
615 let warnings = rule.check(&ctx).unwrap();
616 assert!(warnings.is_empty());
617 }
618
619 #[test]
620 fn test_nested_brackets() {
621 let rule = MD062LinkDestinationWhitespace::new();
622 let content = "[text [nested]]( https://example.com)";
623 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
624 let warnings = rule.check(&ctx).unwrap();
625 assert_eq!(warnings.len(), 1);
626 }
627
628 #[test]
629 fn test_empty_destination() {
630 let rule = MD062LinkDestinationWhitespace::new();
631 let content = "[link]()";
632 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
633 let warnings = rule.check(&ctx).unwrap();
634 assert!(warnings.is_empty());
635 }
636
637 #[test]
638 fn test_tabs_and_newlines() {
639 let rule = MD062LinkDestinationWhitespace::new();
640 let content = "[link](\thttps://example.com\t)";
641 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
642 let warnings = rule.check(&ctx).unwrap();
643 assert_eq!(warnings.len(), 1);
644 assert_eq!(
645 warnings[0].fix.as_ref().unwrap().replacement,
646 "[link](https://example.com)"
647 );
648 }
649
650 #[test]
653 fn test_trailing_whitespace_after_title() {
654 let rule = MD062LinkDestinationWhitespace::new();
655 let content = "[link](https://example.com \"title\" )";
656 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
657 let warnings = rule.check(&ctx).unwrap();
658 assert_eq!(warnings.len(), 1);
659 assert_eq!(
660 warnings[0].fix.as_ref().unwrap().replacement,
661 "[link](https://example.com \"title\")"
662 );
663 }
664
665 #[test]
666 fn test_leading_and_trailing_with_title() {
667 let rule = MD062LinkDestinationWhitespace::new();
668 let content = "[link]( https://example.com \"title\" )";
669 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
670 let warnings = rule.check(&ctx).unwrap();
671 assert_eq!(warnings.len(), 1);
672 assert_eq!(
673 warnings[0].fix.as_ref().unwrap().replacement,
674 "[link](https://example.com \"title\")"
675 );
676 }
677
678 #[test]
679 fn test_multiple_spaces_before_title() {
680 let rule = MD062LinkDestinationWhitespace::new();
681 let content = "[link](https://example.com \"title\")";
682 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
683 let warnings = rule.check(&ctx).unwrap();
684 assert_eq!(warnings.len(), 1);
685 assert_eq!(
686 warnings[0].fix.as_ref().unwrap().replacement,
687 "[link](https://example.com \"title\")"
688 );
689 }
690
691 #[test]
692 fn test_single_quote_title() {
693 let rule = MD062LinkDestinationWhitespace::new();
694 let content = "[link]( https://example.com 'title')";
695 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
696 let warnings = rule.check(&ctx).unwrap();
697 assert_eq!(warnings.len(), 1);
698 assert_eq!(
699 warnings[0].fix.as_ref().unwrap().replacement,
700 "[link](https://example.com 'title')"
701 );
702 }
703
704 #[test]
705 fn test_single_quote_title_trailing_space() {
706 let rule = MD062LinkDestinationWhitespace::new();
707 let content = "[link](https://example.com 'title' )";
708 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
709 let warnings = rule.check(&ctx).unwrap();
710 assert_eq!(warnings.len(), 1);
711 assert_eq!(
712 warnings[0].fix.as_ref().unwrap().replacement,
713 "[link](https://example.com 'title')"
714 );
715 }
716
717 #[test]
718 fn test_wikipedia_style_url() {
719 let rule = MD062LinkDestinationWhitespace::new();
721 let content = "[wiki]( https://en.wikipedia.org/wiki/Rust_(programming_language) )";
722 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
723 let warnings = rule.check(&ctx).unwrap();
724 assert_eq!(warnings.len(), 1);
725 assert_eq!(
726 warnings[0].fix.as_ref().unwrap().replacement,
727 "[wiki](https://en.wikipedia.org/wiki/Rust_(programming_language))"
728 );
729 }
730
731 #[test]
732 fn test_angle_bracket_url_no_warning() {
733 let rule = MD062LinkDestinationWhitespace::new();
735 let content = "[link](<https://example.com/path with spaces>)";
736 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
737 let warnings = rule.check(&ctx).unwrap();
738 assert!(warnings.is_empty());
740 }
741
742 #[test]
743 fn test_image_with_title() {
744 let rule = MD062LinkDestinationWhitespace::new();
745 let content = "";
746 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
747 let warnings = rule.check(&ctx).unwrap();
748 assert_eq!(warnings.len(), 1);
749 assert_eq!(
750 warnings[0].fix.as_ref().unwrap().replacement,
751 ""
752 );
753 }
754
755 #[test]
756 fn test_only_whitespace_in_destination() {
757 let rule = MD062LinkDestinationWhitespace::new();
758 let content = "[link]( )";
759 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
760 let warnings = rule.check(&ctx).unwrap();
761 assert_eq!(warnings.len(), 1);
762 assert_eq!(warnings[0].fix.as_ref().unwrap().replacement, "[link]()");
763 }
764
765 #[test]
766 fn test_code_block_skipped() {
767 let rule = MD062LinkDestinationWhitespace::new();
768 let content = "```\n[link]( https://example.com )\n```";
769 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
770 let warnings = rule.check(&ctx).unwrap();
771 assert!(warnings.is_empty());
772 }
773
774 #[test]
775 fn test_inline_code_not_skipped() {
776 let rule = MD062LinkDestinationWhitespace::new();
778 let content = "text `[link]( url )` more text";
779 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
780 let warnings = rule.check(&ctx).unwrap();
781 assert!(warnings.is_empty());
783 }
784
785 #[test]
786 fn test_valid_link_with_title_no_warning() {
787 let rule = MD062LinkDestinationWhitespace::new();
788 let content = "[link](https://example.com \"Title\")";
789 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
790 let warnings = rule.check(&ctx).unwrap();
791 assert!(warnings.is_empty());
792 }
793
794 #[test]
795 fn test_mixed_links_on_same_line() {
796 let rule = MD062LinkDestinationWhitespace::new();
797 let content = "[good](https://example.com) and [bad]( https://example.com ) here";
798 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
799 let warnings = rule.check(&ctx).unwrap();
800 assert_eq!(warnings.len(), 1);
801 assert_eq!(
802 warnings[0].fix.as_ref().unwrap().replacement,
803 "[bad](https://example.com)"
804 );
805 }
806
807 #[test]
808 fn test_fix_multiple_on_same_line() {
809 let rule = MD062LinkDestinationWhitespace::new();
810 let content = "[a]( url1 ) and [b]( url2 )";
811 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
812 let fixed = rule.fix(&ctx).unwrap();
813 assert_eq!(fixed, "[a](url1) and [b](url2)");
814 }
815
816 #[test]
817 fn test_complex_nested_brackets() {
818 let rule = MD062LinkDestinationWhitespace::new();
819 let content = "[text [with [deeply] nested] brackets]( https://example.com )";
820 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
821 let warnings = rule.check(&ctx).unwrap();
822 assert_eq!(warnings.len(), 1);
823 }
824
825 #[test]
826 fn test_url_with_query_params() {
827 let rule = MD062LinkDestinationWhitespace::new();
828 let content = "[link]( https://example.com?foo=bar&baz=qux )";
829 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
830 let warnings = rule.check(&ctx).unwrap();
831 assert_eq!(warnings.len(), 1);
832 assert_eq!(
833 warnings[0].fix.as_ref().unwrap().replacement,
834 "[link](https://example.com?foo=bar&baz=qux)"
835 );
836 }
837
838 #[test]
839 fn test_url_with_fragment() {
840 let rule = MD062LinkDestinationWhitespace::new();
841 let content = "[link]( https://example.com#section )";
842 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
843 let warnings = rule.check(&ctx).unwrap();
844 assert_eq!(warnings.len(), 1);
845 assert_eq!(
846 warnings[0].fix.as_ref().unwrap().replacement,
847 "[link](https://example.com#section)"
848 );
849 }
850
851 #[test]
852 fn test_relative_path() {
853 let rule = MD062LinkDestinationWhitespace::new();
854 let content = "[link]( ./path/to/file.md )";
855 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
856 let warnings = rule.check(&ctx).unwrap();
857 assert_eq!(warnings.len(), 1);
858 assert_eq!(
859 warnings[0].fix.as_ref().unwrap().replacement,
860 "[link](./path/to/file.md)"
861 );
862 }
863
864 #[test]
865 fn test_unmatched_angle_bracket_in_destination() {
866 let rule = MD062LinkDestinationWhitespace::new();
870 let content = "[]( \"<)";
871 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
872 let warnings = rule.check(&ctx).unwrap();
873 assert!(
874 warnings.is_empty(),
875 "Should not warn when closing paren is masked by angle bracket"
876 );
877
878 let fixed = rule.fix(&ctx).unwrap();
880 assert_eq!(fixed, content);
881 }
882
883 #[test]
884 fn test_unicode_whitespace_in_destination() {
885 let rule = MD062LinkDestinationWhitespace::new();
887 let content = "[](\u{2000}\"<)";
888 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
889 let warnings = rule.check(&ctx).unwrap();
890 assert!(
891 warnings.is_empty(),
892 "Should not warn when angle bracket masks closing paren"
893 );
894
895 let fixed = rule.fix(&ctx).unwrap();
896 assert_eq!(fixed, content, "Fix must be idempotent for unparseable links");
897 }
898
899 #[test]
900 fn test_autolink_not_affected() {
901 let rule = MD062LinkDestinationWhitespace::new();
903 let content = "<https://example.com>";
904 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
905 let warnings = rule.check(&ctx).unwrap();
906 assert!(warnings.is_empty());
907 }
908
909 fn md062_fix(content: &str) -> (usize, String) {
910 let rule = MD062LinkDestinationWhitespace::new();
911 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
912 (rule.check(&ctx).unwrap().len(), rule.fix(&ctx).unwrap())
913 }
914
915 #[test]
916 fn test_non_commonmark_whitespace_is_destination_content() {
917 for ws in ['\u{a0}', '\u{2003}', '\u{3000}'] {
920 for source in [
921 format!("[a]({ws}x)\n"),
922 format!("[a](x{ws})\n"),
923 format!("[a]({ws}x{ws})\n"),
924 format!("[a](x{ws}\"t\")\n"),
925 format!("\n"),
926 ] {
927 let (warnings, fixed) = md062_fix(&source);
928 assert_eq!(warnings, 0, "{source:?} must not warn");
929 assert_eq!(fixed, source, "{source:?} must be unchanged");
930 }
931 }
932 }
933
934 #[test]
935 fn test_ascii_whitespace_around_non_commonmark_whitespace() {
936 let (warnings, fixed) = md062_fix("[a]( \u{a0}x\t)\n");
939 assert_eq!(warnings, 1);
940 assert_eq!(fixed, "[a](\u{a0}x)\n");
941 }
942
943 #[test]
944 fn test_blockquote_multiline_destination_is_fixed_without_the_marker() {
945 for (source, expected) in [
948 ("> [k](\n> )\n", "> [k]()\n"),
949 ("> [k](\n> url)\n", "> [k](url)\n"),
950 ("> [k](url\n> )\n", "> [k](url)\n"),
951 ("> [k](\n> x\n> )\n", "> [k](x)\n"),
952 ("> [k](\n> x\n> \"t\")\n", "> [k](x \"t\")\n"),
953 (">> [k](\n>> )\n", ">> [k]()\n"),
954 ("> > [k](\n> > url\n> > )\n", "> > [k](url)\n"),
955 ("> - [k](\n> )\n", "> - [k]()\n"),
956 ("- > [k](\n > )\n", "- > [k]()\n"),
957 ("> \n", "> ![k]()\n"),
958 ("> [k](\n)\n", "> [k]()\n"),
959 ] {
960 let (warnings, fixed) = md062_fix(source);
961 assert_eq!(warnings, 1, "{source:?} must warn");
962 assert_eq!(fixed, expected, "{source:?}");
963 assert_eq!(md062_fix(expected).0, 0, "{expected:?} must be clean");
964 }
965 }
966
967 #[test]
968 fn test_blockquote_multiline_label_keeps_its_markers() {
969 for (source, expected) in [
971 ("> [multi\n> line]( url )\n", "> [multi\n> line](url)\n"),
972 ("> \n", "> \n"),
973 (
974 "> > [multi\n> > line](\n> > url\n> > )\n",
975 "> > [multi\n> > line](url)\n",
976 ),
977 ("- > [multi\n > line]( url )\n", "- > [multi\n > line](url)\n"),
978 ] {
979 let (warnings, fixed) = md062_fix(source);
980 assert_eq!(warnings, 1, "{source:?} must warn");
981 assert_eq!(fixed, expected, "{source:?}");
982 assert_eq!(md062_fix(expected).0, 0, "{expected:?} must be clean");
983 }
984 }
985
986 #[test]
987 fn test_blockquote_title_spanning_lines_is_left_alone() {
988 let source = "> [k](x \"a\n> b\" )\n";
991 let (warnings, fixed) = md062_fix(source);
992 assert_eq!(warnings, 0);
993 assert_eq!(fixed, source);
994 }
995
996 #[test]
997 fn test_blockquote_single_line_destination_is_still_fixed() {
998 let (warnings, fixed) = md062_fix("> [a]( x )\n>\n> [b]( y \"t\" )\n");
999 assert_eq!(warnings, 2);
1000 assert_eq!(fixed, "> [a](x)\n>\n> [b](y \"t\")\n");
1001 }
1002
1003 #[test]
1004 fn test_list_item_multiline_destination_is_still_fixed() {
1005 let (warnings, fixed) = md062_fix("- [k](\n )\n");
1007 assert_eq!(warnings, 1);
1008 assert_eq!(fixed, "- [k]()\n");
1009 let (warnings, fixed) = md062_fix("- [k](\n url\n )\n");
1010 assert_eq!(warnings, 1);
1011 assert_eq!(fixed, "- [k](url)\n");
1012 }
1013
1014 #[test]
1015 fn test_quote_glued_to_the_destination_is_not_a_title() {
1016 for source in ["[a](x\"t\" )\n", "[a](x\u{a0}\"t\" )\n", "[a]( x'a' )\n"] {
1019 let (_, fixed) = md062_fix(source);
1020 assert!(
1021 !fixed.contains(" \"t\")") && !fixed.contains(" 'a')"),
1022 "{source:?} gained a title: {fixed:?}"
1023 );
1024 }
1025 assert_eq!(md062_fix("[a](x\"t\" )\n").1, "[a](x\"t\")\n");
1026 assert_eq!(md062_fix("[a](x \"t\" )\n").1, "[a](x \"t\")\n");
1028 }
1029
1030 #[test]
1031 fn test_indented_angle_bracket_is_destination_text_not_a_quote_marker() {
1032 for source in ["[a](\n >url)\n", "\n", "[a](\n >url\n )\n"] {
1035 let (_, fixed) = md062_fix(source);
1036 assert!(
1037 fixed.contains(">url") || fixed == source,
1038 "{source:?} lost the > of its destination: {fixed:?}"
1039 );
1040 }
1041 assert_eq!(md062_fix("> [a](\n> url)\n").1, "> [a](url)\n");
1043 }
1044}