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    /// Split destination content into (url, optional title).
115    ///
116    /// A title is recognized only when it is a *balanced* quoted string
117    /// (`"..."` or `'...'`) that forms the trailing token of the destination
118    /// (only whitespace may follow the closing quote). A lone or unbalanced quote
119    /// is treated as part of the URL. This prevents the fix from turning a stray
120    /// quote into an unterminated title that, when the document is re-parsed,
121    /// swallows the following line - which would make the fix non-idempotent and
122    /// drop content.
123    fn split_url_and_title(dest: &str) -> (&str, Option<&str>) {
124        // Quote characters are ASCII, so byte and char indices coincide here.
125        if let Some(q_start) = dest.find(['"', '\'']) {
126            let quote = dest.as_bytes()[q_start] as char;
127            if let Some(rel_close) = dest[q_start + 1..].find(quote) {
128                let close = q_start + 1 + rel_close;
129                if dest[close + 1..].trim().is_empty() {
130                    return (&dest[..q_start], Some(&dest[q_start..=close]));
131                }
132            }
133        }
134        (dest, None)
135    }
136
137    /// Check if destination has leading/trailing whitespace
138    /// Returns the type of whitespace issue found, if any
139    fn check_destination_whitespace(&self, full_dest: &str) -> Option<WhitespaceIssue> {
140        if full_dest.is_empty() {
141            return None;
142        }
143
144        let first_char = full_dest.chars().next();
145        let last_char = full_dest.chars().last();
146
147        let has_leading = first_char.is_some_and(char::is_whitespace);
148
149        // Check for trailing whitespace - either at the end or before a title
150        let has_trailing = if last_char.is_some_and(char::is_whitespace) {
151            true
152        } else {
153            let (url_portion, title) = Self::split_url_and_title(full_dest);
154            title.is_some() && url_portion.ends_with(char::is_whitespace)
155        };
156
157        match (has_leading, has_trailing) {
158            (true, true) => Some(WhitespaceIssue::Both),
159            (true, false) => Some(WhitespaceIssue::Leading),
160            (false, true) => Some(WhitespaceIssue::Trailing),
161            (false, false) => None,
162        }
163    }
164
165    /// Create the fixed link text
166    fn create_fix(&self, raw_link: &str) -> Option<String> {
167        let (dest_start, dest_end, _) = self.extract_destination_info(raw_link)?;
168
169        // Get the full destination content (may include title)
170        let full_dest_content = &raw_link[dest_start..dest_end];
171
172        // Split into URL and optional title. Only a balanced quoted title is
173        // recognized; a stray quote stays part of the URL (see split_url_and_title).
174        let (url_raw, title_raw) = Self::split_url_and_title(full_dest_content);
175        let url_part = url_raw.trim();
176        let title_part = title_raw.map(str::trim);
177
178        // Reconstruct: text part + ( + trimmed_url + optional_title + )
179        let text_part = &raw_link[..dest_start]; // Includes '[text]('
180
181        let mut fixed = String::with_capacity(raw_link.len());
182        fixed.push_str(text_part);
183        fixed.push_str(url_part);
184        if let Some(title) = title_part {
185            fixed.push(' ');
186            fixed.push_str(title);
187        }
188        fixed.push(')');
189
190        // Only return fix if it actually changed something
191        if fixed != raw_link { Some(fixed) } else { None }
192    }
193}
194
195impl Rule for MD062LinkDestinationWhitespace {
196    fn name(&self) -> &'static str {
197        "MD062"
198    }
199
200    fn description(&self) -> &'static str {
201        "Link destination should not have leading or trailing whitespace"
202    }
203
204    fn category(&self) -> RuleCategory {
205        RuleCategory::Link
206    }
207
208    fn should_skip(&self, ctx: &LintContext) -> bool {
209        ctx.content.is_empty() || !ctx.likely_has_links_or_images()
210    }
211
212    fn check(&self, ctx: &LintContext) -> LintResult {
213        let mut warnings = Vec::new();
214
215        // Process links
216        for link in &ctx.links {
217            // Only check inline links, not reference links
218            if link.is_reference || !matches!(link.link_type, LinkType::Inline) {
219                continue;
220            }
221
222            // Skip links inside Jinja templates
223            if ctx.is_in_jinja_range(link.byte_offset) {
224                continue;
225            }
226
227            // Get raw link text from content
228            let raw_link = &ctx.content[link.byte_offset..link.byte_end];
229
230            // Extract destination info and check for whitespace issues
231            if let Some((_, _, raw_dest)) = self.extract_destination_info(raw_link)
232                && let Some(issue) = self.check_destination_whitespace(raw_dest)
233                && let Some(fixed) = self.create_fix(raw_link)
234            {
235                warnings.push(LintWarning {
236                    rule_name: Some(self.name().to_string()),
237                    line: link.line,
238                    column: link.start_col + 1,
239                    end_line: link.line,
240                    end_column: link.end_col + 1,
241                    message: issue.message(false),
242                    severity: Severity::Warning,
243                    fix: Some(Fix::new(link.byte_offset..link.byte_end, fixed)),
244                });
245            }
246        }
247
248        // Process images
249        for image in &ctx.images {
250            // Only check inline images, not reference images
251            if image.is_reference || !matches!(image.link_type, LinkType::Inline) {
252                continue;
253            }
254
255            // Skip images inside Jinja templates
256            if ctx.is_in_jinja_range(image.byte_offset) {
257                continue;
258            }
259
260            // Get raw image text from content
261            let raw_image = &ctx.content[image.byte_offset..image.byte_end];
262
263            // For images, skip the leading '!'
264            let link_portion = raw_image.strip_prefix('!').unwrap_or(raw_image);
265
266            // Extract destination info and check for whitespace issues
267            if let Some((_, _, raw_dest)) = self.extract_destination_info(link_portion)
268                && let Some(issue) = self.check_destination_whitespace(raw_dest)
269                && let Some(fixed_link) = self.create_fix(link_portion)
270            {
271                let fixed = format!("!{fixed_link}");
272                warnings.push(LintWarning {
273                    rule_name: Some(self.name().to_string()),
274                    line: image.line,
275                    column: image.start_col + 1,
276                    end_line: image.line,
277                    end_column: image.end_col + 1,
278                    message: issue.message(true),
279                    severity: Severity::Warning,
280                    fix: Some(Fix::new(image.byte_offset..image.byte_end, fixed)),
281                });
282            }
283        }
284
285        Ok(warnings)
286    }
287
288    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
289        let warnings = self.check(ctx)?;
290        let warnings =
291            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
292
293        if warnings.is_empty() {
294            return Ok(ctx.content.to_string());
295        }
296
297        let mut content = ctx.content.to_string();
298        let mut fixes: Vec<_> = warnings
299            .into_iter()
300            .filter_map(|w| w.fix.map(|f| (f.range.start, f.range.end, f.replacement)))
301            .collect();
302
303        // Sort by position and apply in reverse order
304        fixes.sort_by_key(|(start, _, _)| *start);
305
306        for (start, end, replacement) in fixes.into_iter().rev() {
307            content.replace_range(start..end, &replacement);
308        }
309
310        Ok(content)
311    }
312
313    fn as_any(&self) -> &dyn std::any::Any {
314        self
315    }
316
317    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
318    where
319        Self: Sized,
320    {
321        Box::new(Self)
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use crate::config::MarkdownFlavor;
329
330    #[test]
331    fn test_no_whitespace() {
332        let rule = MD062LinkDestinationWhitespace::new();
333        let content = "[link](https://example.com)";
334        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
335        let warnings = rule.check(&ctx).unwrap();
336        assert!(warnings.is_empty());
337    }
338
339    #[test]
340    fn test_leading_whitespace() {
341        let rule = MD062LinkDestinationWhitespace::new();
342        let content = "[link]( https://example.com)";
343        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
344        let warnings = rule.check(&ctx).unwrap();
345        assert_eq!(warnings.len(), 1);
346        assert_eq!(
347            warnings[0].fix.as_ref().unwrap().replacement,
348            "[link](https://example.com)"
349        );
350    }
351
352    #[test]
353    fn test_trailing_whitespace() {
354        let rule = MD062LinkDestinationWhitespace::new();
355        let content = "[link](https://example.com )";
356        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
357        let warnings = rule.check(&ctx).unwrap();
358        assert_eq!(warnings.len(), 1);
359        assert_eq!(
360            warnings[0].fix.as_ref().unwrap().replacement,
361            "[link](https://example.com)"
362        );
363    }
364
365    #[test]
366    fn test_both_whitespace() {
367        let rule = MD062LinkDestinationWhitespace::new();
368        let content = "[link]( https://example.com )";
369        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
370        let warnings = rule.check(&ctx).unwrap();
371        assert_eq!(warnings.len(), 1);
372        assert_eq!(
373            warnings[0].fix.as_ref().unwrap().replacement,
374            "[link](https://example.com)"
375        );
376    }
377
378    #[test]
379    fn test_multiple_spaces() {
380        let rule = MD062LinkDestinationWhitespace::new();
381        let content = "[link](   https://example.com   )";
382        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
383        let warnings = rule.check(&ctx).unwrap();
384        assert_eq!(warnings.len(), 1);
385        assert_eq!(
386            warnings[0].fix.as_ref().unwrap().replacement,
387            "[link](https://example.com)"
388        );
389    }
390
391    #[test]
392    fn test_with_title() {
393        let rule = MD062LinkDestinationWhitespace::new();
394        let content = "[link]( https://example.com \"title\")";
395        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
396        let warnings = rule.check(&ctx).unwrap();
397        assert_eq!(warnings.len(), 1);
398        assert_eq!(
399            warnings[0].fix.as_ref().unwrap().replacement,
400            "[link](https://example.com \"title\")"
401        );
402    }
403
404    #[test]
405    fn test_image_leading_whitespace() {
406        let rule = MD062LinkDestinationWhitespace::new();
407        let content = "![alt]( https://example.com/image.png)";
408        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
409        let warnings = rule.check(&ctx).unwrap();
410        assert_eq!(warnings.len(), 1);
411        assert_eq!(
412            warnings[0].fix.as_ref().unwrap().replacement,
413            "![alt](https://example.com/image.png)"
414        );
415    }
416
417    #[test]
418    fn test_multiple_links() {
419        let rule = MD062LinkDestinationWhitespace::new();
420        let content = "[a]( url1) and [b](url2 ) here";
421        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
422        let warnings = rule.check(&ctx).unwrap();
423        assert_eq!(warnings.len(), 2);
424    }
425
426    #[test]
427    fn test_fix() {
428        let rule = MD062LinkDestinationWhitespace::new();
429        let content = "[link]( https://example.com ) and ![img]( /path/to/img.png )";
430        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
431        let fixed = rule.fix(&ctx).unwrap();
432        assert_eq!(fixed, "[link](https://example.com) and ![img](/path/to/img.png)");
433    }
434
435    #[test]
436    fn test_fix_idempotent_with_unbalanced_quote() {
437        // Regression: a lone quote inside a destination must not be treated as a
438        // title. Previously the fix inserted a space before the stray quote
439        // (`![](X" )` -> `![](X ")`), which re-parsed as an unterminated title
440        // that swallowed the following line, making the fix non-idempotent and
441        // dropping content. The character is U+2A700 (4 bytes in UTF-8) to also
442        // guard the byte/char offset handling.
443        let rule = MD062LinkDestinationWhitespace::new();
444        let content = "![](\u{2a700}\" )\n![](\")";
445
446        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
447        let once = rule.fix(&ctx).unwrap();
448
449        let ctx2 = LintContext::new(&once, MarkdownFlavor::Standard, None);
450        let twice = rule.fix(&ctx2).unwrap();
451
452        assert_eq!(once, twice, "MD062 fix must be idempotent for unbalanced quotes");
453        assert_eq!(
454            once.lines().count(),
455            2,
456            "fix must not drop the second image line, got: {once:?}"
457        );
458    }
459
460    #[test]
461    fn test_reference_links_skipped() {
462        let rule = MD062LinkDestinationWhitespace::new();
463        let content = "[link][ref]\n\n[ref]: https://example.com";
464        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
465        let warnings = rule.check(&ctx).unwrap();
466        assert!(warnings.is_empty());
467    }
468
469    #[test]
470    fn test_nested_brackets() {
471        let rule = MD062LinkDestinationWhitespace::new();
472        let content = "[text [nested]]( https://example.com)";
473        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
474        let warnings = rule.check(&ctx).unwrap();
475        assert_eq!(warnings.len(), 1);
476    }
477
478    #[test]
479    fn test_empty_destination() {
480        let rule = MD062LinkDestinationWhitespace::new();
481        let content = "[link]()";
482        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
483        let warnings = rule.check(&ctx).unwrap();
484        assert!(warnings.is_empty());
485    }
486
487    #[test]
488    fn test_tabs_and_newlines() {
489        let rule = MD062LinkDestinationWhitespace::new();
490        let content = "[link](\thttps://example.com\t)";
491        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
492        let warnings = rule.check(&ctx).unwrap();
493        assert_eq!(warnings.len(), 1);
494        assert_eq!(
495            warnings[0].fix.as_ref().unwrap().replacement,
496            "[link](https://example.com)"
497        );
498    }
499
500    // Edge case tests for comprehensive coverage
501
502    #[test]
503    fn test_trailing_whitespace_after_title() {
504        let rule = MD062LinkDestinationWhitespace::new();
505        let content = "[link](https://example.com \"title\" )";
506        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
507        let warnings = rule.check(&ctx).unwrap();
508        assert_eq!(warnings.len(), 1);
509        assert_eq!(
510            warnings[0].fix.as_ref().unwrap().replacement,
511            "[link](https://example.com \"title\")"
512        );
513    }
514
515    #[test]
516    fn test_leading_and_trailing_with_title() {
517        let rule = MD062LinkDestinationWhitespace::new();
518        let content = "[link]( https://example.com \"title\" )";
519        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
520        let warnings = rule.check(&ctx).unwrap();
521        assert_eq!(warnings.len(), 1);
522        assert_eq!(
523            warnings[0].fix.as_ref().unwrap().replacement,
524            "[link](https://example.com \"title\")"
525        );
526    }
527
528    #[test]
529    fn test_multiple_spaces_before_title() {
530        let rule = MD062LinkDestinationWhitespace::new();
531        let content = "[link](https://example.com  \"title\")";
532        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
533        let warnings = rule.check(&ctx).unwrap();
534        assert_eq!(warnings.len(), 1);
535        assert_eq!(
536            warnings[0].fix.as_ref().unwrap().replacement,
537            "[link](https://example.com \"title\")"
538        );
539    }
540
541    #[test]
542    fn test_single_quote_title() {
543        let rule = MD062LinkDestinationWhitespace::new();
544        let content = "[link]( https://example.com 'title')";
545        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
546        let warnings = rule.check(&ctx).unwrap();
547        assert_eq!(warnings.len(), 1);
548        assert_eq!(
549            warnings[0].fix.as_ref().unwrap().replacement,
550            "[link](https://example.com 'title')"
551        );
552    }
553
554    #[test]
555    fn test_single_quote_title_trailing_space() {
556        let rule = MD062LinkDestinationWhitespace::new();
557        let content = "[link](https://example.com 'title' )";
558        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
559        let warnings = rule.check(&ctx).unwrap();
560        assert_eq!(warnings.len(), 1);
561        assert_eq!(
562            warnings[0].fix.as_ref().unwrap().replacement,
563            "[link](https://example.com 'title')"
564        );
565    }
566
567    #[test]
568    fn test_wikipedia_style_url() {
569        // Wikipedia URLs with parentheses should work correctly
570        let rule = MD062LinkDestinationWhitespace::new();
571        let content = "[wiki]( https://en.wikipedia.org/wiki/Rust_(programming_language) )";
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!(
576            warnings[0].fix.as_ref().unwrap().replacement,
577            "[wiki](https://en.wikipedia.org/wiki/Rust_(programming_language))"
578        );
579    }
580
581    #[test]
582    fn test_angle_bracket_url_no_warning() {
583        // Angle bracket URLs can contain spaces per CommonMark, so we should skip them
584        let rule = MD062LinkDestinationWhitespace::new();
585        let content = "[link](<https://example.com/path with spaces>)";
586        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
587        let warnings = rule.check(&ctx).unwrap();
588        // Angle bracket URLs are allowed to have spaces, no warning expected
589        assert!(warnings.is_empty());
590    }
591
592    #[test]
593    fn test_image_with_title() {
594        let rule = MD062LinkDestinationWhitespace::new();
595        let content = "![alt]( https://example.com/img.png \"Image title\" )";
596        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
597        let warnings = rule.check(&ctx).unwrap();
598        assert_eq!(warnings.len(), 1);
599        assert_eq!(
600            warnings[0].fix.as_ref().unwrap().replacement,
601            "![alt](https://example.com/img.png \"Image title\")"
602        );
603    }
604
605    #[test]
606    fn test_only_whitespace_in_destination() {
607        let rule = MD062LinkDestinationWhitespace::new();
608        let content = "[link](   )";
609        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
610        let warnings = rule.check(&ctx).unwrap();
611        assert_eq!(warnings.len(), 1);
612        assert_eq!(warnings[0].fix.as_ref().unwrap().replacement, "[link]()");
613    }
614
615    #[test]
616    fn test_code_block_skipped() {
617        let rule = MD062LinkDestinationWhitespace::new();
618        let content = "```\n[link]( https://example.com )\n```";
619        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
620        let warnings = rule.check(&ctx).unwrap();
621        assert!(warnings.is_empty());
622    }
623
624    #[test]
625    fn test_inline_code_not_skipped() {
626        // Links in inline code are not valid markdown anyway
627        let rule = MD062LinkDestinationWhitespace::new();
628        let content = "text `[link]( url )` more text";
629        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
630        let warnings = rule.check(&ctx).unwrap();
631        // pulldown-cmark doesn't parse this as a link since it's in code
632        assert!(warnings.is_empty());
633    }
634
635    #[test]
636    fn test_valid_link_with_title_no_warning() {
637        let rule = MD062LinkDestinationWhitespace::new();
638        let content = "[link](https://example.com \"Title\")";
639        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
640        let warnings = rule.check(&ctx).unwrap();
641        assert!(warnings.is_empty());
642    }
643
644    #[test]
645    fn test_mixed_links_on_same_line() {
646        let rule = MD062LinkDestinationWhitespace::new();
647        let content = "[good](https://example.com) and [bad]( https://example.com ) here";
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            "[bad](https://example.com)"
654        );
655    }
656
657    #[test]
658    fn test_fix_multiple_on_same_line() {
659        let rule = MD062LinkDestinationWhitespace::new();
660        let content = "[a]( url1 ) and [b]( url2 )";
661        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
662        let fixed = rule.fix(&ctx).unwrap();
663        assert_eq!(fixed, "[a](url1) and [b](url2)");
664    }
665
666    #[test]
667    fn test_complex_nested_brackets() {
668        let rule = MD062LinkDestinationWhitespace::new();
669        let content = "[text [with [deeply] nested] brackets]( https://example.com )";
670        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
671        let warnings = rule.check(&ctx).unwrap();
672        assert_eq!(warnings.len(), 1);
673    }
674
675    #[test]
676    fn test_url_with_query_params() {
677        let rule = MD062LinkDestinationWhitespace::new();
678        let content = "[link]( https://example.com?foo=bar&baz=qux )";
679        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
680        let warnings = rule.check(&ctx).unwrap();
681        assert_eq!(warnings.len(), 1);
682        assert_eq!(
683            warnings[0].fix.as_ref().unwrap().replacement,
684            "[link](https://example.com?foo=bar&baz=qux)"
685        );
686    }
687
688    #[test]
689    fn test_url_with_fragment() {
690        let rule = MD062LinkDestinationWhitespace::new();
691        let content = "[link]( https://example.com#section )";
692        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
693        let warnings = rule.check(&ctx).unwrap();
694        assert_eq!(warnings.len(), 1);
695        assert_eq!(
696            warnings[0].fix.as_ref().unwrap().replacement,
697            "[link](https://example.com#section)"
698        );
699    }
700
701    #[test]
702    fn test_relative_path() {
703        let rule = MD062LinkDestinationWhitespace::new();
704        let content = "[link]( ./path/to/file.md )";
705        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
706        let warnings = rule.check(&ctx).unwrap();
707        assert_eq!(warnings.len(), 1);
708        assert_eq!(
709            warnings[0].fix.as_ref().unwrap().replacement,
710            "[link](./path/to/file.md)"
711        );
712    }
713
714    #[test]
715    fn test_unmatched_angle_bracket_in_destination() {
716        // When `<` inside the destination masks the closing `)`, the rule
717        // should not produce a warning or fix, since it cannot reliably
718        // determine the destination boundaries.
719        let rule = MD062LinkDestinationWhitespace::new();
720        let content = "[](  \"<)";
721        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
722        let warnings = rule.check(&ctx).unwrap();
723        assert!(
724            warnings.is_empty(),
725            "Should not warn when closing paren is masked by angle bracket"
726        );
727
728        // Verify idempotency: fix should not modify unparseable links
729        let fixed = rule.fix(&ctx).unwrap();
730        assert_eq!(fixed, content);
731    }
732
733    #[test]
734    fn test_unicode_whitespace_in_destination() {
735        // Unicode whitespace (EN QUAD U+2000) in link destination
736        let rule = MD062LinkDestinationWhitespace::new();
737        let content = "[](\u{2000}\"<)";
738        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
739        let warnings = rule.check(&ctx).unwrap();
740        assert!(
741            warnings.is_empty(),
742            "Should not warn when angle bracket masks closing paren"
743        );
744
745        let fixed = rule.fix(&ctx).unwrap();
746        assert_eq!(fixed, content, "Fix must be idempotent for unparseable links");
747    }
748
749    #[test]
750    fn test_autolink_not_affected() {
751        // Autolinks use <> syntax and are different from inline links
752        let rule = MD062LinkDestinationWhitespace::new();
753        let content = "<https://example.com>";
754        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
755        let warnings = rule.check(&ctx).unwrap();
756        assert!(warnings.is_empty());
757    }
758}