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    /// Returns true when the character at `byte_index` is preceded by an odd
52    /// number of backslashes, which makes it escaped rather than structural.
53    ///
54    /// Callers scanning a whole string track the escape state as they go
55    /// instead: the backwards walk here costs the length of the backslash run
56    /// in front of the index, so calling it per character is quadratic.
57    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    /// Extract the destination portion from a link's raw text
68    /// Returns (dest_start_offset, dest_end_offset, raw_dest) relative to link start
69    fn extract_destination_info<'a>(&self, raw_link: &'a str) -> Option<(usize, usize, &'a str)> {
70        // Find the opening parenthesis for the destination
71        // Handle nested brackets in link text: [text [nested]](url)
72        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                        // Next char should be '(' for inline links
89                        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; // After '('
103        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        // The parser already bounded raw_link at the matching closing
112        // parenthesis. Only retain the conservative unmatched-angle check that
113        // prevents fixes when the destination itself is not safe to interpret.
114        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    /// Split destination content into (url, optional title).
138    ///
139    /// A title is recognized only when it is a *balanced* quoted string
140    /// (`"..."` or `'...'`) that forms the trailing token of the destination
141    /// (only whitespace may follow the closing quote). A lone or unbalanced quote
142    /// is treated as part of the URL. This prevents the fix from turning a stray
143    /// quote into an unterminated title that, when the document is re-parsed,
144    /// swallows the following line - which would make the fix non-idempotent and
145    /// drop content.
146    fn split_url_and_title(dest: &str) -> (&str, Option<&str>) {
147        // Quote characters are ASCII, so byte and char indices coincide here.
148        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    /// Check if destination has leading/trailing whitespace
161    /// Returns the type of whitespace issue found, if any
162    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        // Check for trailing whitespace - either at the end or before a title
173        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    /// Create the fixed link text
189    fn create_fix(&self, raw_link: &str) -> Option<String> {
190        let (dest_start, dest_end, _) = self.extract_destination_info(raw_link)?;
191
192        // Get the full destination content (may include title)
193        let full_dest_content = &raw_link[dest_start..dest_end];
194
195        // Split into URL and optional title. Only a balanced quoted title is
196        // recognized; a stray quote stays part of the URL (see split_url_and_title).
197        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        // Reconstruct: text part + ( + trimmed_url + optional_title + )
202        let text_part = &raw_link[..dest_start]; // Includes '[text]('
203
204        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        // Only return fix if it actually changed something
214        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        // Process links
239        for link in ctx.links() {
240            // Only check inline links, not reference links
241            if link.is_reference || !matches!(link.link_type, LinkType::Inline) {
242                continue;
243            }
244
245            // Skip links inside Jinja templates
246            if ctx.is_in_jinja_range(link.byte_offset) {
247                continue;
248            }
249
250            // Get raw link text from content
251            let raw_link = &ctx.content[link.byte_offset..link.byte_end];
252
253            // Extract destination info and check for whitespace issues
254            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        // Process images
272        for image in ctx.images() {
273            // Only check inline images, not reference images
274            if image.is_reference || !matches!(image.link_type, LinkType::Inline) {
275                continue;
276            }
277
278            // Skip images inside Jinja templates
279            if ctx.is_in_jinja_range(image.byte_offset) {
280                continue;
281            }
282
283            // Get raw image text from content
284            let raw_image = &ctx.content[image.byte_offset..image.byte_end];
285
286            // For images, skip the leading '!'
287            let link_portion = raw_image.strip_prefix('!').unwrap_or(raw_image);
288
289            // Extract destination info and check for whitespace issues
290            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        // Sort by position and apply in reverse order
327        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 = "![alt]( https://example.com/image.png)";
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            "![alt](https://example.com/image.png)"
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 ![img]( /path/to/img.png )";
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 ![img](/path/to/img.png)");
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"![alt]( path\)suffix )", r"![alt](path\)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        // Regression: a lone quote inside a destination must not be treated as a
483        // title. Previously the fix inserted a space before the stray quote
484        // (`![](X" )` -> `![](X ")`), which re-parsed as an unterminated title
485        // that swallowed the following line, making the fix non-idempotent and
486        // dropping content. The character is U+2A700 (4 bytes in UTF-8) to also
487        // guard the byte/char offset handling.
488        let rule = MD062LinkDestinationWhitespace::new();
489        let content = "![](\u{2a700}\" )\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    // Edge case tests for comprehensive coverage
546
547    #[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        // Wikipedia URLs with parentheses should work correctly
615        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        // Angle bracket URLs can contain spaces per CommonMark, so we should skip them
629        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        // Angle bracket URLs are allowed to have spaces, no warning expected
634        assert!(warnings.is_empty());
635    }
636
637    #[test]
638    fn test_image_with_title() {
639        let rule = MD062LinkDestinationWhitespace::new();
640        let content = "![alt]( https://example.com/img.png \"Image title\" )";
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            "![alt](https://example.com/img.png \"Image title\")"
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        // Links in inline code are not valid markdown anyway
672        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        // pulldown-cmark doesn't parse this as a link since it's in code
677        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        // When `<` inside the destination masks the closing `)`, the rule
762        // should not produce a warning or fix, since it cannot reliably
763        // determine the destination boundaries.
764        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        // Verify idempotency: fix should not modify unparseable links
774        let fixed = rule.fix(&ctx).unwrap();
775        assert_eq!(fixed, content);
776    }
777
778    #[test]
779    fn test_unicode_whitespace_in_destination() {
780        // Unicode whitespace (EN QUAD U+2000) in link destination
781        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        // Autolinks use <> syntax and are different from inline links
797        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}