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 is_escaped(text: &str, byte_index: usize) -> bool {
58 text.as_bytes()[..byte_index]
59 .iter()
60 .rev()
61 .take_while(|&&byte| byte == b'\\')
62 .count()
63 % 2
64 != 0
65 }
66
67 fn extract_destination_info<'a>(&self, raw_link: &'a str) -> Option<(usize, usize, &'a str)> {
70 let mut bracket_depth = 0;
73 let mut paren_start = None;
74
75 let mut escaped = false;
76 for (i, c) in raw_link.char_indices() {
77 if escaped {
78 escaped = false;
79 continue;
80 }
81
82 match c {
83 '\\' => escaped = true,
84 '[' => bracket_depth += 1,
85 ']' => {
86 bracket_depth -= 1;
87 if bracket_depth == 0 {
88 let rest = &raw_link[i + 1..];
90 if rest.starts_with('(') {
91 paren_start = Some(i + 1);
92 }
93 break;
94 }
95 }
96 _ => {}
97 }
98 }
99
100 let paren_start = paren_start?;
101
102 let dest_content_start = paren_start + 1; let dest_content_end = raw_link.len().checked_sub(1)?;
104 if raw_link.as_bytes().get(dest_content_end) != Some(&b')')
105 || Self::is_escaped(raw_link, dest_content_end)
106 || dest_content_end < dest_content_start
107 {
108 return None;
109 }
110
111 let dest_content = &raw_link[dest_content_start..dest_content_end];
115 let (url, _) = Self::split_url_and_title(dest_content);
116 let mut in_angle_brackets = false;
117 let mut escaped = false;
118 for c in url.chars() {
119 if escaped {
120 escaped = false;
121 continue;
122 }
123 match c {
124 '\\' => escaped = true,
125 '<' if !in_angle_brackets => in_angle_brackets = true,
126 '>' if in_angle_brackets => in_angle_brackets = false,
127 _ => {}
128 }
129 }
130 if in_angle_brackets {
131 return None;
132 }
133
134 Some((dest_content_start, dest_content_end, dest_content))
135 }
136
137 fn split_url_and_title(dest: &str) -> (&str, Option<&str>) {
147 if let Some(q_start) = dest.find(['"', '\'']) {
149 let quote = dest.as_bytes()[q_start] as char;
150 if let Some(rel_close) = dest[q_start + 1..].find(quote) {
151 let close = q_start + 1 + rel_close;
152 if dest[close + 1..].trim().is_empty() {
153 return (&dest[..q_start], Some(&dest[q_start..=close]));
154 }
155 }
156 }
157 (dest, None)
158 }
159
160 fn check_destination_whitespace(&self, full_dest: &str) -> Option<WhitespaceIssue> {
163 if full_dest.is_empty() {
164 return None;
165 }
166
167 let first_char = full_dest.chars().next();
168 let last_char = full_dest.chars().last();
169
170 let has_leading = first_char.is_some_and(char::is_whitespace);
171
172 let has_trailing = if last_char.is_some_and(char::is_whitespace) {
174 true
175 } else {
176 let (url_portion, title) = Self::split_url_and_title(full_dest);
177 title.is_some() && url_portion.ends_with(char::is_whitespace)
178 };
179
180 match (has_leading, has_trailing) {
181 (true, true) => Some(WhitespaceIssue::Both),
182 (true, false) => Some(WhitespaceIssue::Leading),
183 (false, true) => Some(WhitespaceIssue::Trailing),
184 (false, false) => None,
185 }
186 }
187
188 fn create_fix(&self, raw_link: &str) -> Option<String> {
190 let (dest_start, dest_end, _) = self.extract_destination_info(raw_link)?;
191
192 let full_dest_content = &raw_link[dest_start..dest_end];
194
195 let (url_raw, title_raw) = Self::split_url_and_title(full_dest_content);
198 let url_part = url_raw.trim();
199 let title_part = title_raw.map(str::trim);
200
201 let text_part = &raw_link[..dest_start]; let mut fixed = String::with_capacity(raw_link.len());
205 fixed.push_str(text_part);
206 fixed.push_str(url_part);
207 if let Some(title) = title_part {
208 fixed.push(' ');
209 fixed.push_str(title);
210 }
211 fixed.push(')');
212
213 if fixed != raw_link { Some(fixed) } else { None }
215 }
216}
217
218impl Rule for MD062LinkDestinationWhitespace {
219 fn name(&self) -> &'static str {
220 "MD062"
221 }
222
223 fn description(&self) -> &'static str {
224 "Link destination should not have leading or trailing whitespace"
225 }
226
227 fn category(&self) -> RuleCategory {
228 RuleCategory::Link
229 }
230
231 fn should_skip(&self, ctx: &LintContext) -> bool {
232 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
233 }
234
235 fn check(&self, ctx: &LintContext) -> LintResult {
236 let mut warnings = Vec::new();
237
238 for link in ctx.links() {
240 if link.is_reference || !matches!(link.link_type, LinkType::Inline) {
242 continue;
243 }
244
245 if ctx.is_in_jinja_range(link.byte_offset) {
247 continue;
248 }
249
250 let raw_link = &ctx.content[link.byte_offset..link.byte_end];
252
253 if let Some((_, _, raw_dest)) = self.extract_destination_info(raw_link)
255 && let Some(issue) = self.check_destination_whitespace(raw_dest)
256 && let Some(fixed) = self.create_fix(raw_link)
257 {
258 warnings.push(LintWarning {
259 rule_name: Some(self.name().to_string()),
260 line: link.line,
261 column: link.start_col + 1,
262 end_line: link.end_line,
263 end_column: link.end_col + 1,
264 message: issue.message(false),
265 severity: Severity::Warning,
266 fix: Some(Fix::new(link.byte_offset..link.byte_end, fixed)),
267 });
268 }
269 }
270
271 for image in ctx.images() {
273 if image.is_reference || !matches!(image.link_type, LinkType::Inline) {
275 continue;
276 }
277
278 if ctx.is_in_jinja_range(image.byte_offset) {
280 continue;
281 }
282
283 let raw_image = &ctx.content[image.byte_offset..image.byte_end];
285
286 let link_portion = raw_image.strip_prefix('!').unwrap_or(raw_image);
288
289 if let Some((_, _, raw_dest)) = self.extract_destination_info(link_portion)
291 && let Some(issue) = self.check_destination_whitespace(raw_dest)
292 && let Some(fixed_link) = self.create_fix(link_portion)
293 {
294 let fixed = format!("!{fixed_link}");
295 warnings.push(LintWarning {
296 rule_name: Some(self.name().to_string()),
297 line: image.line,
298 column: image.start_col + 1,
299 end_line: image.end_line,
300 end_column: image.end_col + 1,
301 message: issue.message(true),
302 severity: Severity::Warning,
303 fix: Some(Fix::new(image.byte_offset..image.byte_end, fixed)),
304 });
305 }
306 }
307
308 Ok(warnings)
309 }
310
311 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
312 let warnings = self.check(ctx)?;
313 let warnings =
314 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
315
316 if warnings.is_empty() {
317 return Ok(ctx.content.to_string());
318 }
319
320 let mut content = ctx.content.to_string();
321 let mut fixes: Vec<_> = warnings
322 .into_iter()
323 .filter_map(|w| w.fix.map(|f| (f.range.start, f.range.end, f.replacement)))
324 .collect();
325
326 fixes.sort_by_key(|(start, _, _)| *start);
328
329 for (start, end, replacement) in fixes.into_iter().rev() {
330 content.replace_range(start..end, &replacement);
331 }
332
333 Ok(content)
334 }
335
336 fn as_any(&self) -> &dyn std::any::Any {
337 self
338 }
339
340 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
341 where
342 Self: Sized,
343 {
344 Box::new(Self)
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use crate::config::MarkdownFlavor;
352
353 #[test]
354 fn test_no_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!(warnings.is_empty());
360 }
361
362 #[test]
363 fn test_leading_whitespace() {
364 let rule = MD062LinkDestinationWhitespace::new();
365 let content = "[link]( https://example.com)";
366 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
367 let warnings = rule.check(&ctx).unwrap();
368 assert_eq!(warnings.len(), 1);
369 assert_eq!(
370 warnings[0].fix.as_ref().unwrap().replacement,
371 "[link](https://example.com)"
372 );
373 }
374
375 #[test]
376 fn test_trailing_whitespace() {
377 let rule = MD062LinkDestinationWhitespace::new();
378 let content = "[link](https://example.com )";
379 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
380 let warnings = rule.check(&ctx).unwrap();
381 assert_eq!(warnings.len(), 1);
382 assert_eq!(
383 warnings[0].fix.as_ref().unwrap().replacement,
384 "[link](https://example.com)"
385 );
386 }
387
388 #[test]
389 fn test_both_whitespace() {
390 let rule = MD062LinkDestinationWhitespace::new();
391 let content = "[link]( https://example.com )";
392 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
393 let warnings = rule.check(&ctx).unwrap();
394 assert_eq!(warnings.len(), 1);
395 assert_eq!(
396 warnings[0].fix.as_ref().unwrap().replacement,
397 "[link](https://example.com)"
398 );
399 }
400
401 #[test]
402 fn test_multiple_spaces() {
403 let rule = MD062LinkDestinationWhitespace::new();
404 let content = "[link]( https://example.com )";
405 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
406 let warnings = rule.check(&ctx).unwrap();
407 assert_eq!(warnings.len(), 1);
408 assert_eq!(
409 warnings[0].fix.as_ref().unwrap().replacement,
410 "[link](https://example.com)"
411 );
412 }
413
414 #[test]
415 fn test_with_title() {
416 let rule = MD062LinkDestinationWhitespace::new();
417 let content = "[link]( https://example.com \"title\")";
418 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
419 let warnings = rule.check(&ctx).unwrap();
420 assert_eq!(warnings.len(), 1);
421 assert_eq!(
422 warnings[0].fix.as_ref().unwrap().replacement,
423 "[link](https://example.com \"title\")"
424 );
425 }
426
427 #[test]
428 fn test_image_leading_whitespace() {
429 let rule = MD062LinkDestinationWhitespace::new();
430 let content = "";
431 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
432 let warnings = rule.check(&ctx).unwrap();
433 assert_eq!(warnings.len(), 1);
434 assert_eq!(
435 warnings[0].fix.as_ref().unwrap().replacement,
436 ""
437 );
438 }
439
440 #[test]
441 fn test_multiple_links() {
442 let rule = MD062LinkDestinationWhitespace::new();
443 let content = "[a]( url1) and [b](url2 ) here";
444 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
445 let warnings = rule.check(&ctx).unwrap();
446 assert_eq!(warnings.len(), 2);
447 }
448
449 #[test]
450 fn test_fix() {
451 let rule = MD062LinkDestinationWhitespace::new();
452 let content = "[link]( https://example.com ) and ";
453 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
454 let fixed = rule.fix(&ctx).unwrap();
455 assert_eq!(fixed, "[link](https://example.com) and ");
456 }
457
458 #[test]
459 fn test_fix_preserves_parentheses_inside_destination_and_title() {
460 let rule = MD062LinkDestinationWhitespace::new();
461 let cases = [
462 (r"[link]( path\)suffix )", r"[link](path\)suffix)"),
463 (r"[link]( path\(suffix )", r"[link](path\(suffix)"),
464 (r#"[link]( path "title) rest" )"#, r#"[link](path "title) rest")"#),
465 (r"[link]( path 'title) rest' )", r"[link](path 'title) rest')"),
466 (r"suffix )", r"suffix)"),
467 (r"[a \] b]( path )", r"[a \] b](path)"),
468 ];
469
470 for (content, expected) in cases {
471 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
472 let fixed = rule.fix(&ctx).unwrap();
473 assert_eq!(fixed, expected, "unexpected fix for {content:?}");
474
475 let fixed_ctx = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
476 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "second fix changed {content:?}");
477 }
478 }
479
480 #[test]
481 fn test_fix_idempotent_with_unbalanced_quote() {
482 let rule = MD062LinkDestinationWhitespace::new();
489 let content = "\n";
490
491 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
492 let once = rule.fix(&ctx).unwrap();
493
494 let ctx2 = LintContext::new(&once, MarkdownFlavor::Standard, None);
495 let twice = rule.fix(&ctx2).unwrap();
496
497 assert_eq!(once, twice, "MD062 fix must be idempotent for unbalanced quotes");
498 assert_eq!(
499 once.lines().count(),
500 2,
501 "fix must not drop the second image line, got: {once:?}"
502 );
503 }
504
505 #[test]
506 fn test_reference_links_skipped() {
507 let rule = MD062LinkDestinationWhitespace::new();
508 let content = "[link][ref]\n\n[ref]: https://example.com";
509 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
510 let warnings = rule.check(&ctx).unwrap();
511 assert!(warnings.is_empty());
512 }
513
514 #[test]
515 fn test_nested_brackets() {
516 let rule = MD062LinkDestinationWhitespace::new();
517 let content = "[text [nested]]( https://example.com)";
518 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
519 let warnings = rule.check(&ctx).unwrap();
520 assert_eq!(warnings.len(), 1);
521 }
522
523 #[test]
524 fn test_empty_destination() {
525 let rule = MD062LinkDestinationWhitespace::new();
526 let content = "[link]()";
527 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
528 let warnings = rule.check(&ctx).unwrap();
529 assert!(warnings.is_empty());
530 }
531
532 #[test]
533 fn test_tabs_and_newlines() {
534 let rule = MD062LinkDestinationWhitespace::new();
535 let content = "[link](\thttps://example.com\t)";
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 "[link](https://example.com)"
542 );
543 }
544
545 #[test]
548 fn test_trailing_whitespace_after_title() {
549 let rule = MD062LinkDestinationWhitespace::new();
550 let content = "[link](https://example.com \"title\" )";
551 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
552 let warnings = rule.check(&ctx).unwrap();
553 assert_eq!(warnings.len(), 1);
554 assert_eq!(
555 warnings[0].fix.as_ref().unwrap().replacement,
556 "[link](https://example.com \"title\")"
557 );
558 }
559
560 #[test]
561 fn test_leading_and_trailing_with_title() {
562 let rule = MD062LinkDestinationWhitespace::new();
563 let content = "[link]( https://example.com \"title\" )";
564 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
565 let warnings = rule.check(&ctx).unwrap();
566 assert_eq!(warnings.len(), 1);
567 assert_eq!(
568 warnings[0].fix.as_ref().unwrap().replacement,
569 "[link](https://example.com \"title\")"
570 );
571 }
572
573 #[test]
574 fn test_multiple_spaces_before_title() {
575 let rule = MD062LinkDestinationWhitespace::new();
576 let content = "[link](https://example.com \"title\")";
577 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
578 let warnings = rule.check(&ctx).unwrap();
579 assert_eq!(warnings.len(), 1);
580 assert_eq!(
581 warnings[0].fix.as_ref().unwrap().replacement,
582 "[link](https://example.com \"title\")"
583 );
584 }
585
586 #[test]
587 fn test_single_quote_title() {
588 let rule = MD062LinkDestinationWhitespace::new();
589 let content = "[link]( https://example.com 'title')";
590 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
591 let warnings = rule.check(&ctx).unwrap();
592 assert_eq!(warnings.len(), 1);
593 assert_eq!(
594 warnings[0].fix.as_ref().unwrap().replacement,
595 "[link](https://example.com 'title')"
596 );
597 }
598
599 #[test]
600 fn test_single_quote_title_trailing_space() {
601 let rule = MD062LinkDestinationWhitespace::new();
602 let content = "[link](https://example.com 'title' )";
603 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
604 let warnings = rule.check(&ctx).unwrap();
605 assert_eq!(warnings.len(), 1);
606 assert_eq!(
607 warnings[0].fix.as_ref().unwrap().replacement,
608 "[link](https://example.com 'title')"
609 );
610 }
611
612 #[test]
613 fn test_wikipedia_style_url() {
614 let rule = MD062LinkDestinationWhitespace::new();
616 let content = "[wiki]( https://en.wikipedia.org/wiki/Rust_(programming_language) )";
617 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
618 let warnings = rule.check(&ctx).unwrap();
619 assert_eq!(warnings.len(), 1);
620 assert_eq!(
621 warnings[0].fix.as_ref().unwrap().replacement,
622 "[wiki](https://en.wikipedia.org/wiki/Rust_(programming_language))"
623 );
624 }
625
626 #[test]
627 fn test_angle_bracket_url_no_warning() {
628 let rule = MD062LinkDestinationWhitespace::new();
630 let content = "[link](<https://example.com/path with spaces>)";
631 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
632 let warnings = rule.check(&ctx).unwrap();
633 assert!(warnings.is_empty());
635 }
636
637 #[test]
638 fn test_image_with_title() {
639 let rule = MD062LinkDestinationWhitespace::new();
640 let content = "";
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 ""
647 );
648 }
649
650 #[test]
651 fn test_only_whitespace_in_destination() {
652 let rule = MD062LinkDestinationWhitespace::new();
653 let content = "[link]( )";
654 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
655 let warnings = rule.check(&ctx).unwrap();
656 assert_eq!(warnings.len(), 1);
657 assert_eq!(warnings[0].fix.as_ref().unwrap().replacement, "[link]()");
658 }
659
660 #[test]
661 fn test_code_block_skipped() {
662 let rule = MD062LinkDestinationWhitespace::new();
663 let content = "```\n[link]( https://example.com )\n```";
664 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
665 let warnings = rule.check(&ctx).unwrap();
666 assert!(warnings.is_empty());
667 }
668
669 #[test]
670 fn test_inline_code_not_skipped() {
671 let rule = MD062LinkDestinationWhitespace::new();
673 let content = "text `[link]( url )` more text";
674 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
675 let warnings = rule.check(&ctx).unwrap();
676 assert!(warnings.is_empty());
678 }
679
680 #[test]
681 fn test_valid_link_with_title_no_warning() {
682 let rule = MD062LinkDestinationWhitespace::new();
683 let content = "[link](https://example.com \"Title\")";
684 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
685 let warnings = rule.check(&ctx).unwrap();
686 assert!(warnings.is_empty());
687 }
688
689 #[test]
690 fn test_mixed_links_on_same_line() {
691 let rule = MD062LinkDestinationWhitespace::new();
692 let content = "[good](https://example.com) and [bad]( https://example.com ) here";
693 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
694 let warnings = rule.check(&ctx).unwrap();
695 assert_eq!(warnings.len(), 1);
696 assert_eq!(
697 warnings[0].fix.as_ref().unwrap().replacement,
698 "[bad](https://example.com)"
699 );
700 }
701
702 #[test]
703 fn test_fix_multiple_on_same_line() {
704 let rule = MD062LinkDestinationWhitespace::new();
705 let content = "[a]( url1 ) and [b]( url2 )";
706 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
707 let fixed = rule.fix(&ctx).unwrap();
708 assert_eq!(fixed, "[a](url1) and [b](url2)");
709 }
710
711 #[test]
712 fn test_complex_nested_brackets() {
713 let rule = MD062LinkDestinationWhitespace::new();
714 let content = "[text [with [deeply] nested] brackets]( https://example.com )";
715 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
716 let warnings = rule.check(&ctx).unwrap();
717 assert_eq!(warnings.len(), 1);
718 }
719
720 #[test]
721 fn test_url_with_query_params() {
722 let rule = MD062LinkDestinationWhitespace::new();
723 let content = "[link]( https://example.com?foo=bar&baz=qux )";
724 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
725 let warnings = rule.check(&ctx).unwrap();
726 assert_eq!(warnings.len(), 1);
727 assert_eq!(
728 warnings[0].fix.as_ref().unwrap().replacement,
729 "[link](https://example.com?foo=bar&baz=qux)"
730 );
731 }
732
733 #[test]
734 fn test_url_with_fragment() {
735 let rule = MD062LinkDestinationWhitespace::new();
736 let content = "[link]( https://example.com#section )";
737 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
738 let warnings = rule.check(&ctx).unwrap();
739 assert_eq!(warnings.len(), 1);
740 assert_eq!(
741 warnings[0].fix.as_ref().unwrap().replacement,
742 "[link](https://example.com#section)"
743 );
744 }
745
746 #[test]
747 fn test_relative_path() {
748 let rule = MD062LinkDestinationWhitespace::new();
749 let content = "[link]( ./path/to/file.md )";
750 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
751 let warnings = rule.check(&ctx).unwrap();
752 assert_eq!(warnings.len(), 1);
753 assert_eq!(
754 warnings[0].fix.as_ref().unwrap().replacement,
755 "[link](./path/to/file.md)"
756 );
757 }
758
759 #[test]
760 fn test_unmatched_angle_bracket_in_destination() {
761 let rule = MD062LinkDestinationWhitespace::new();
765 let content = "[]( \"<)";
766 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
767 let warnings = rule.check(&ctx).unwrap();
768 assert!(
769 warnings.is_empty(),
770 "Should not warn when closing paren is masked by angle bracket"
771 );
772
773 let fixed = rule.fix(&ctx).unwrap();
775 assert_eq!(fixed, content);
776 }
777
778 #[test]
779 fn test_unicode_whitespace_in_destination() {
780 let rule = MD062LinkDestinationWhitespace::new();
782 let content = "[](\u{2000}\"<)";
783 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
784 let warnings = rule.check(&ctx).unwrap();
785 assert!(
786 warnings.is_empty(),
787 "Should not warn when angle bracket masks closing paren"
788 );
789
790 let fixed = rule.fix(&ctx).unwrap();
791 assert_eq!(fixed, content, "Fix must be idempotent for unparseable links");
792 }
793
794 #[test]
795 fn test_autolink_not_affected() {
796 let rule = MD062LinkDestinationWhitespace::new();
798 let content = "<https://example.com>";
799 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
800 let warnings = rule.check(&ctx).unwrap();
801 assert!(warnings.is_empty());
802 }
803}