Skip to main content

rumdl_lib/rules/
md062_link_destination_whitespace.rs

1use crate::lint_context::LintContext;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use pulldown_cmark::LinkType;
4
5/// Describes what type of whitespace issue was found
6#[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/// Rule MD062: No whitespace in link destinations
31///
32/// See [docs/md062.md](../../docs/md062.md) for full documentation, configuration, and examples.
33///
34/// This rule is triggered when link destinations have leading or trailing whitespace
35/// inside the parentheses, which is a common copy-paste error.
36///
37/// Examples that trigger this rule:
38/// - `[text]( url)` - leading space
39/// - `[text](url )` - trailing space
40/// - `[text]( url )` - both
41///
42/// The fix trims the whitespace: `[text](url)`
43#[derive(Debug, Default, Clone)]
44pub struct MD062LinkDestinationWhitespace;
45
46impl MD062LinkDestinationWhitespace {
47    pub fn new() -> Self {
48        Self
49    }
50
51    /// Extract the destination portion from a link's raw text
52    /// Returns (dest_start_offset, dest_end_offset, raw_dest) relative to link start
53    fn extract_destination_info<'a>(&self, raw_link: &'a str) -> Option<(usize, usize, &'a str)> {
54        // Find the opening parenthesis for the destination
55        // Handle nested brackets in link text: [text [nested]](url)
56        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                        // Next char should be '(' for inline links
66                        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        // Find matching closing parenthesis
80        let dest_content_start = paren_start + 1; // After '('
81        let rest = &raw_link[dest_content_start..];
82
83        // Find the closing paren, handling nested parens and angle brackets
84        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        // If we couldn't find the matching closing paren, the link structure
105        // is too complex to parse (e.g., unmatched angle brackets masking the
106        // closing paren). Bail out rather than producing a broken fix.
107        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    /// Check if destination has leading/trailing whitespace
115    /// Returns the type of whitespace issue found, if any
116    fn check_destination_whitespace(&self, full_dest: &str) -> Option<WhitespaceIssue> {
117        if full_dest.is_empty() {
118            return None;
119        }
120
121        let first_char = full_dest.chars().next();
122        let last_char = full_dest.chars().last();
123
124        let has_leading = first_char.is_some_and(|c| c.is_whitespace());
125
126        // Check for trailing whitespace - either at the end or before title
127        let has_trailing = if last_char.is_some_and(|c| c.is_whitespace()) {
128            true
129        } else if let Some(title_start) = full_dest.find(['"', '\'']) {
130            let url_portion = &full_dest[..title_start];
131            url_portion.ends_with(char::is_whitespace)
132        } else {
133            false
134        };
135
136        match (has_leading, has_trailing) {
137            (true, true) => Some(WhitespaceIssue::Both),
138            (true, false) => Some(WhitespaceIssue::Leading),
139            (false, true) => Some(WhitespaceIssue::Trailing),
140            (false, false) => None,
141        }
142    }
143
144    /// Create the fixed link text
145    fn create_fix(&self, raw_link: &str) -> Option<String> {
146        let (dest_start, dest_end, _) = self.extract_destination_info(raw_link)?;
147
148        // Get the full destination content (may include title)
149        let full_dest_content = &raw_link[dest_start..dest_end];
150
151        // Split into URL and optional title
152        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        // Reconstruct: text part + ( + trimmed_url + optional_title + )
161        let text_part = &raw_link[..dest_start]; // Includes '[text]('
162
163        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        // Only return fix if it actually changed something
173        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        // Process links
198        for link in &ctx.links {
199            // Only check inline links, not reference links
200            if link.is_reference || !matches!(link.link_type, LinkType::Inline) {
201                continue;
202            }
203
204            // Skip links inside Jinja templates
205            if ctx.is_in_jinja_range(link.byte_offset) {
206                continue;
207            }
208
209            // Get raw link text from content
210            let raw_link = &ctx.content[link.byte_offset..link.byte_end];
211
212            // Extract destination info and check for whitespace issues
213            if let Some((_, _, raw_dest)) = self.extract_destination_info(raw_link)
214                && let Some(issue) = self.check_destination_whitespace(raw_dest)
215                && let Some(fixed) = self.create_fix(raw_link)
216            {
217                warnings.push(LintWarning {
218                    rule_name: Some(self.name().to_string()),
219                    line: link.line,
220                    column: link.start_col + 1,
221                    end_line: link.line,
222                    end_column: link.end_col + 1,
223                    message: issue.message(false),
224                    severity: Severity::Warning,
225                    fix: Some(Fix {
226                        range: link.byte_offset..link.byte_end,
227                        replacement: fixed,
228                    }),
229                });
230            }
231        }
232
233        // Process images
234        for image in &ctx.images {
235            // Only check inline images, not reference images
236            if image.is_reference || !matches!(image.link_type, LinkType::Inline) {
237                continue;
238            }
239
240            // Skip images inside Jinja templates
241            if ctx.is_in_jinja_range(image.byte_offset) {
242                continue;
243            }
244
245            // Get raw image text from content
246            let raw_image = &ctx.content[image.byte_offset..image.byte_end];
247
248            // For images, skip the leading '!'
249            let link_portion = raw_image.strip_prefix('!').unwrap_or(raw_image);
250
251            // Extract destination info and check for whitespace issues
252            if let Some((_, _, raw_dest)) = self.extract_destination_info(link_portion)
253                && let Some(issue) = self.check_destination_whitespace(raw_dest)
254                && let Some(fixed_link) = self.create_fix(link_portion)
255            {
256                let fixed = format!("!{fixed_link}");
257                warnings.push(LintWarning {
258                    rule_name: Some(self.name().to_string()),
259                    line: image.line,
260                    column: image.start_col + 1,
261                    end_line: image.line,
262                    end_column: image.end_col + 1,
263                    message: issue.message(true),
264                    severity: Severity::Warning,
265                    fix: Some(Fix {
266                        range: image.byte_offset..image.byte_end,
267                        replacement: fixed,
268                    }),
269                });
270            }
271        }
272
273        Ok(warnings)
274    }
275
276    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
277        let warnings = self.check(ctx)?;
278        let warnings =
279            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
280
281        if warnings.is_empty() {
282            return Ok(ctx.content.to_string());
283        }
284
285        let mut content = ctx.content.to_string();
286        let mut fixes: Vec<_> = warnings
287            .into_iter()
288            .filter_map(|w| w.fix.map(|f| (f.range.start, f.range.end, f.replacement)))
289            .collect();
290
291        // Sort by position and apply in reverse order
292        fixes.sort_by_key(|(start, _, _)| *start);
293
294        for (start, end, replacement) in fixes.into_iter().rev() {
295            content.replace_range(start..end, &replacement);
296        }
297
298        Ok(content)
299    }
300
301    fn as_any(&self) -> &dyn std::any::Any {
302        self
303    }
304
305    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
306    where
307        Self: Sized,
308    {
309        Box::new(Self)
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use crate::config::MarkdownFlavor;
317
318    #[test]
319    fn test_no_whitespace() {
320        let rule = MD062LinkDestinationWhitespace::new();
321        let content = "[link](https://example.com)";
322        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
323        let warnings = rule.check(&ctx).unwrap();
324        assert!(warnings.is_empty());
325    }
326
327    #[test]
328    fn test_leading_whitespace() {
329        let rule = MD062LinkDestinationWhitespace::new();
330        let content = "[link]( https://example.com)";
331        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
332        let warnings = rule.check(&ctx).unwrap();
333        assert_eq!(warnings.len(), 1);
334        assert_eq!(
335            warnings[0].fix.as_ref().unwrap().replacement,
336            "[link](https://example.com)"
337        );
338    }
339
340    #[test]
341    fn test_trailing_whitespace() {
342        let rule = MD062LinkDestinationWhitespace::new();
343        let content = "[link](https://example.com )";
344        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
345        let warnings = rule.check(&ctx).unwrap();
346        assert_eq!(warnings.len(), 1);
347        assert_eq!(
348            warnings[0].fix.as_ref().unwrap().replacement,
349            "[link](https://example.com)"
350        );
351    }
352
353    #[test]
354    fn test_both_whitespace() {
355        let rule = MD062LinkDestinationWhitespace::new();
356        let content = "[link]( https://example.com )";
357        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
358        let warnings = rule.check(&ctx).unwrap();
359        assert_eq!(warnings.len(), 1);
360        assert_eq!(
361            warnings[0].fix.as_ref().unwrap().replacement,
362            "[link](https://example.com)"
363        );
364    }
365
366    #[test]
367    fn test_multiple_spaces() {
368        let rule = MD062LinkDestinationWhitespace::new();
369        let content = "[link](   https://example.com   )";
370        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
371        let warnings = rule.check(&ctx).unwrap();
372        assert_eq!(warnings.len(), 1);
373        assert_eq!(
374            warnings[0].fix.as_ref().unwrap().replacement,
375            "[link](https://example.com)"
376        );
377    }
378
379    #[test]
380    fn test_with_title() {
381        let rule = MD062LinkDestinationWhitespace::new();
382        let content = "[link]( https://example.com \"title\")";
383        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
384        let warnings = rule.check(&ctx).unwrap();
385        assert_eq!(warnings.len(), 1);
386        assert_eq!(
387            warnings[0].fix.as_ref().unwrap().replacement,
388            "[link](https://example.com \"title\")"
389        );
390    }
391
392    #[test]
393    fn test_image_leading_whitespace() {
394        let rule = MD062LinkDestinationWhitespace::new();
395        let content = "![alt]( https://example.com/image.png)";
396        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
397        let warnings = rule.check(&ctx).unwrap();
398        assert_eq!(warnings.len(), 1);
399        assert_eq!(
400            warnings[0].fix.as_ref().unwrap().replacement,
401            "![alt](https://example.com/image.png)"
402        );
403    }
404
405    #[test]
406    fn test_multiple_links() {
407        let rule = MD062LinkDestinationWhitespace::new();
408        let content = "[a]( url1) and [b](url2 ) here";
409        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
410        let warnings = rule.check(&ctx).unwrap();
411        assert_eq!(warnings.len(), 2);
412    }
413
414    #[test]
415    fn test_fix() {
416        let rule = MD062LinkDestinationWhitespace::new();
417        let content = "[link]( https://example.com ) and ![img]( /path/to/img.png )";
418        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
419        let fixed = rule.fix(&ctx).unwrap();
420        assert_eq!(fixed, "[link](https://example.com) and ![img](/path/to/img.png)");
421    }
422
423    #[test]
424    fn test_reference_links_skipped() {
425        let rule = MD062LinkDestinationWhitespace::new();
426        let content = "[link][ref]\n\n[ref]: https://example.com";
427        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
428        let warnings = rule.check(&ctx).unwrap();
429        assert!(warnings.is_empty());
430    }
431
432    #[test]
433    fn test_nested_brackets() {
434        let rule = MD062LinkDestinationWhitespace::new();
435        let content = "[text [nested]]( https://example.com)";
436        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
437        let warnings = rule.check(&ctx).unwrap();
438        assert_eq!(warnings.len(), 1);
439    }
440
441    #[test]
442    fn test_empty_destination() {
443        let rule = MD062LinkDestinationWhitespace::new();
444        let content = "[link]()";
445        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
446        let warnings = rule.check(&ctx).unwrap();
447        assert!(warnings.is_empty());
448    }
449
450    #[test]
451    fn test_tabs_and_newlines() {
452        let rule = MD062LinkDestinationWhitespace::new();
453        let content = "[link](\thttps://example.com\t)";
454        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
455        let warnings = rule.check(&ctx).unwrap();
456        assert_eq!(warnings.len(), 1);
457        assert_eq!(
458            warnings[0].fix.as_ref().unwrap().replacement,
459            "[link](https://example.com)"
460        );
461    }
462
463    // Edge case tests for comprehensive coverage
464
465    #[test]
466    fn test_trailing_whitespace_after_title() {
467        let rule = MD062LinkDestinationWhitespace::new();
468        let content = "[link](https://example.com \"title\" )";
469        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
470        let warnings = rule.check(&ctx).unwrap();
471        assert_eq!(warnings.len(), 1);
472        assert_eq!(
473            warnings[0].fix.as_ref().unwrap().replacement,
474            "[link](https://example.com \"title\")"
475        );
476    }
477
478    #[test]
479    fn test_leading_and_trailing_with_title() {
480        let rule = MD062LinkDestinationWhitespace::new();
481        let content = "[link]( https://example.com \"title\" )";
482        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
483        let warnings = rule.check(&ctx).unwrap();
484        assert_eq!(warnings.len(), 1);
485        assert_eq!(
486            warnings[0].fix.as_ref().unwrap().replacement,
487            "[link](https://example.com \"title\")"
488        );
489    }
490
491    #[test]
492    fn test_multiple_spaces_before_title() {
493        let rule = MD062LinkDestinationWhitespace::new();
494        let content = "[link](https://example.com  \"title\")";
495        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
496        let warnings = rule.check(&ctx).unwrap();
497        assert_eq!(warnings.len(), 1);
498        assert_eq!(
499            warnings[0].fix.as_ref().unwrap().replacement,
500            "[link](https://example.com \"title\")"
501        );
502    }
503
504    #[test]
505    fn test_single_quote_title() {
506        let rule = MD062LinkDestinationWhitespace::new();
507        let content = "[link]( https://example.com 'title')";
508        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
509        let warnings = rule.check(&ctx).unwrap();
510        assert_eq!(warnings.len(), 1);
511        assert_eq!(
512            warnings[0].fix.as_ref().unwrap().replacement,
513            "[link](https://example.com 'title')"
514        );
515    }
516
517    #[test]
518    fn test_single_quote_title_trailing_space() {
519        let rule = MD062LinkDestinationWhitespace::new();
520        let content = "[link](https://example.com 'title' )";
521        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
522        let warnings = rule.check(&ctx).unwrap();
523        assert_eq!(warnings.len(), 1);
524        assert_eq!(
525            warnings[0].fix.as_ref().unwrap().replacement,
526            "[link](https://example.com 'title')"
527        );
528    }
529
530    #[test]
531    fn test_wikipedia_style_url() {
532        // Wikipedia URLs with parentheses should work correctly
533        let rule = MD062LinkDestinationWhitespace::new();
534        let content = "[wiki]( https://en.wikipedia.org/wiki/Rust_(programming_language) )";
535        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
536        let warnings = rule.check(&ctx).unwrap();
537        assert_eq!(warnings.len(), 1);
538        assert_eq!(
539            warnings[0].fix.as_ref().unwrap().replacement,
540            "[wiki](https://en.wikipedia.org/wiki/Rust_(programming_language))"
541        );
542    }
543
544    #[test]
545    fn test_angle_bracket_url_no_warning() {
546        // Angle bracket URLs can contain spaces per CommonMark, so we should skip them
547        let rule = MD062LinkDestinationWhitespace::new();
548        let content = "[link](<https://example.com/path with spaces>)";
549        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
550        let warnings = rule.check(&ctx).unwrap();
551        // Angle bracket URLs are allowed to have spaces, no warning expected
552        assert!(warnings.is_empty());
553    }
554
555    #[test]
556    fn test_image_with_title() {
557        let rule = MD062LinkDestinationWhitespace::new();
558        let content = "![alt]( https://example.com/img.png \"Image title\" )";
559        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
560        let warnings = rule.check(&ctx).unwrap();
561        assert_eq!(warnings.len(), 1);
562        assert_eq!(
563            warnings[0].fix.as_ref().unwrap().replacement,
564            "![alt](https://example.com/img.png \"Image title\")"
565        );
566    }
567
568    #[test]
569    fn test_only_whitespace_in_destination() {
570        let rule = MD062LinkDestinationWhitespace::new();
571        let content = "[link](   )";
572        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
573        let warnings = rule.check(&ctx).unwrap();
574        assert_eq!(warnings.len(), 1);
575        assert_eq!(warnings[0].fix.as_ref().unwrap().replacement, "[link]()");
576    }
577
578    #[test]
579    fn test_code_block_skipped() {
580        let rule = MD062LinkDestinationWhitespace::new();
581        let content = "```\n[link]( https://example.com )\n```";
582        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
583        let warnings = rule.check(&ctx).unwrap();
584        assert!(warnings.is_empty());
585    }
586
587    #[test]
588    fn test_inline_code_not_skipped() {
589        // Links in inline code are not valid markdown anyway
590        let rule = MD062LinkDestinationWhitespace::new();
591        let content = "text `[link]( url )` more text";
592        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
593        let warnings = rule.check(&ctx).unwrap();
594        // pulldown-cmark doesn't parse this as a link since it's in code
595        assert!(warnings.is_empty());
596    }
597
598    #[test]
599    fn test_valid_link_with_title_no_warning() {
600        let rule = MD062LinkDestinationWhitespace::new();
601        let content = "[link](https://example.com \"Title\")";
602        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
603        let warnings = rule.check(&ctx).unwrap();
604        assert!(warnings.is_empty());
605    }
606
607    #[test]
608    fn test_mixed_links_on_same_line() {
609        let rule = MD062LinkDestinationWhitespace::new();
610        let content = "[good](https://example.com) and [bad]( https://example.com ) here";
611        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
612        let warnings = rule.check(&ctx).unwrap();
613        assert_eq!(warnings.len(), 1);
614        assert_eq!(
615            warnings[0].fix.as_ref().unwrap().replacement,
616            "[bad](https://example.com)"
617        );
618    }
619
620    #[test]
621    fn test_fix_multiple_on_same_line() {
622        let rule = MD062LinkDestinationWhitespace::new();
623        let content = "[a]( url1 ) and [b]( url2 )";
624        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
625        let fixed = rule.fix(&ctx).unwrap();
626        assert_eq!(fixed, "[a](url1) and [b](url2)");
627    }
628
629    #[test]
630    fn test_complex_nested_brackets() {
631        let rule = MD062LinkDestinationWhitespace::new();
632        let content = "[text [with [deeply] nested] brackets]( https://example.com )";
633        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
634        let warnings = rule.check(&ctx).unwrap();
635        assert_eq!(warnings.len(), 1);
636    }
637
638    #[test]
639    fn test_url_with_query_params() {
640        let rule = MD062LinkDestinationWhitespace::new();
641        let content = "[link]( https://example.com?foo=bar&baz=qux )";
642        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
643        let warnings = rule.check(&ctx).unwrap();
644        assert_eq!(warnings.len(), 1);
645        assert_eq!(
646            warnings[0].fix.as_ref().unwrap().replacement,
647            "[link](https://example.com?foo=bar&baz=qux)"
648        );
649    }
650
651    #[test]
652    fn test_url_with_fragment() {
653        let rule = MD062LinkDestinationWhitespace::new();
654        let content = "[link]( https://example.com#section )";
655        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
656        let warnings = rule.check(&ctx).unwrap();
657        assert_eq!(warnings.len(), 1);
658        assert_eq!(
659            warnings[0].fix.as_ref().unwrap().replacement,
660            "[link](https://example.com#section)"
661        );
662    }
663
664    #[test]
665    fn test_relative_path() {
666        let rule = MD062LinkDestinationWhitespace::new();
667        let content = "[link]( ./path/to/file.md )";
668        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
669        let warnings = rule.check(&ctx).unwrap();
670        assert_eq!(warnings.len(), 1);
671        assert_eq!(
672            warnings[0].fix.as_ref().unwrap().replacement,
673            "[link](./path/to/file.md)"
674        );
675    }
676
677    #[test]
678    fn test_unmatched_angle_bracket_in_destination() {
679        // When `<` inside the destination masks the closing `)`, the rule
680        // should not produce a warning or fix, since it cannot reliably
681        // determine the destination boundaries.
682        let rule = MD062LinkDestinationWhitespace::new();
683        let content = "[](  \"<)";
684        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
685        let warnings = rule.check(&ctx).unwrap();
686        assert!(
687            warnings.is_empty(),
688            "Should not warn when closing paren is masked by angle bracket"
689        );
690
691        // Verify idempotency: fix should not modify unparseable links
692        let fixed = rule.fix(&ctx).unwrap();
693        assert_eq!(fixed, content);
694    }
695
696    #[test]
697    fn test_unicode_whitespace_in_destination() {
698        // Unicode whitespace (EN QUAD U+2000) in link destination
699        let rule = MD062LinkDestinationWhitespace::new();
700        let content = "[](\u{2000}\"<)";
701        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
702        let warnings = rule.check(&ctx).unwrap();
703        assert!(
704            warnings.is_empty(),
705            "Should not warn when angle bracket masks closing paren"
706        );
707
708        let fixed = rule.fix(&ctx).unwrap();
709        assert_eq!(fixed, content, "Fix must be idempotent for unparseable links");
710    }
711
712    #[test]
713    fn test_autolink_not_affected() {
714        // Autolinks use <> syntax and are different from inline links
715        let rule = MD062LinkDestinationWhitespace::new();
716        let content = "<https://example.com>";
717        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
718        let warnings = rule.check(&ctx).unwrap();
719        assert!(warnings.is_empty());
720    }
721}