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/// Whether `c` is whitespace between a link's parentheses and its destination.
6///
7/// CommonMark allows spaces, tabs and line endings there. Every other Unicode
8/// space (U+00A0, U+2003, ...) is part of the destination, so it is neither
9/// reported nor stripped.
10fn is_destination_space(c: char) -> bool {
11    matches!(c, ' ' | '\t' | '\n' | '\r')
12}
13
14/// Describes what type of whitespace issue was found
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16enum WhitespaceIssue {
17    Leading,
18    Trailing,
19    Both,
20}
21
22impl WhitespaceIssue {
23    fn message(self, is_image: bool) -> String {
24        let element = if is_image { "Image" } else { "Link" };
25        match self {
26            WhitespaceIssue::Leading => {
27                format!("{element} destination has leading whitespace")
28            }
29            WhitespaceIssue::Trailing => {
30                format!("{element} destination has trailing whitespace")
31            }
32            WhitespaceIssue::Both => {
33                format!("{element} destination has leading and trailing whitespace")
34            }
35        }
36    }
37}
38
39/// Rule MD062: No whitespace in link destinations
40///
41/// See [docs/md062.md](../../docs/md062.md) for full documentation, configuration, and examples.
42///
43/// This rule is triggered when link destinations have leading or trailing whitespace
44/// inside the parentheses, which is a common copy-paste error.
45///
46/// Examples that trigger this rule:
47/// - `[text]( url)` - leading space
48/// - `[text](url )` - trailing space
49/// - `[text]( url )` - both
50///
51/// The fix trims the whitespace: `[text](url)`
52#[derive(Debug, Default, Clone)]
53pub struct MD062LinkDestinationWhitespace;
54
55impl MD062LinkDestinationWhitespace {
56    pub fn new() -> Self {
57        Self
58    }
59
60    /// Returns true when the character at `byte_index` is preceded by an odd
61    /// number of backslashes, which makes it escaped rather than structural.
62    ///
63    /// Callers scanning a whole string track the escape state as they go
64    /// instead: the backwards walk here costs the length of the backslash run
65    /// in front of the index, so calling it per character is quadratic.
66    fn is_escaped(text: &str, byte_index: usize) -> bool {
67        text.as_bytes()[..byte_index]
68            .iter()
69            .rev()
70            .take_while(|&&byte| byte == b'\\')
71            .count()
72            % 2
73            != 0
74    }
75
76    /// The link source with the blockquote markers of its continuation lines removed.
77    ///
78    /// The raw text of a link spanning lines carries the `>` markers of the lines
79    /// after the first. They belong to the container, not to the destination, so
80    /// they are dropped before the destination is read. Lines outside a blockquote
81    /// (list item indentation, lazy continuation) are kept as they are; that
82    /// indentation is whitespace and is trimmed with the rest.
83    fn without_quote_markers<'a>(ctx: &LintContext, line: usize, raw: &'a str) -> std::borrow::Cow<'a, str> {
84        use crate::utils::blockquote::parse_blockquote_prefix;
85        use std::borrow::Cow;
86
87        if !raw.contains('\n') {
88            return Cow::Borrowed(raw);
89        }
90        let mut stripped = String::with_capacity(raw.len());
91        let mut changed = false;
92        for (index, segment) in raw.split_inclusive('\n').enumerate() {
93            let in_quote = index > 0
94                && ctx
95                    .lines
96                    .get(line - 1 + index)
97                    .is_some_and(|info| info.blockquote.is_some());
98            match parse_blockquote_prefix(segment).filter(|_| in_quote) {
99                Some(parsed) => {
100                    stripped.push_str(parsed.content);
101                    changed = true;
102                }
103                None => stripped.push_str(segment),
104            }
105        }
106        if changed {
107            Cow::Owned(stripped)
108        } else {
109            Cow::Borrowed(raw)
110        }
111    }
112
113    /// The fix for a link whose source is `raw`, when it has one.
114    ///
115    /// A fix computed from marker-free text replaces the whole link, so it can only
116    /// be used when it is a single line: a destination or title that still spans
117    /// lines would need its markers put back.
118    ///
119    /// `target` is the destination and title the parser read from `raw`. When quote
120    /// markers were dropped, the fix is kept only if parsing it gives back exactly
121    /// that target: a `>` that opens no quote (for instance one indented four
122    /// spaces) is destination text, and dropping it would change the URL.
123    fn fix_for(
124        &self,
125        ctx: &LintContext,
126        line: usize,
127        raw: &str,
128        target: (&str, Option<&str>),
129    ) -> Option<(WhitespaceIssue, String)> {
130        // The label keeps its line breaks and markers; only what follows `](` is rewritten.
131        let (dest_start, _, _) = self.extract_destination_info(raw)?;
132        let (label, tail) = raw.split_at(dest_start);
133        let label_lines = label.matches('\n').count();
134        let tail = Self::without_quote_markers(ctx, line + label_lines, tail);
135        let markers_dropped = matches!(tail, std::borrow::Cow::Owned(_));
136        let source = format!("{label}{tail}");
137        let (_, _, raw_dest) = self.extract_destination_info(&source)?;
138        let issue = self.check_destination_whitespace(raw_dest)?;
139        let fixed = self.create_fix(&source)?;
140        if markers_dropped && fixed[dest_start..].contains(['\n', '\r']) {
141            return None;
142        }
143        if markers_dropped && !Self::parses_to_target(&format!("[x]({}", &fixed[dest_start..]), target) {
144            return None;
145        }
146        Some((issue, fixed))
147    }
148
149    /// Whether `link` (a label followed by the destination part) is a single inline link with the given destination and title.
150    fn parses_to_target(link: &str, (url, title): (&str, Option<&str>)) -> bool {
151        use pulldown_cmark::{Event, Parser, Tag};
152
153        let mut found = Parser::new(link).filter_map(|event| match event {
154            Event::Start(Tag::Link {
155                dest_url, title: found, ..
156            }) => Some((dest_url, found)),
157            _ => None,
158        });
159        match (found.next(), found.next()) {
160            (Some((dest, found)), None) => dest.as_ref() == url && (found.as_ref() == title.unwrap_or("")),
161            _ => false,
162        }
163    }
164
165    /// Extract the destination portion from a link's raw text
166    /// Returns (dest_start_offset, dest_end_offset, raw_dest) relative to link start
167    fn extract_destination_info<'a>(&self, raw_link: &'a str) -> Option<(usize, usize, &'a str)> {
168        // Find the opening parenthesis for the destination
169        // Handle nested brackets in link text: [text [nested]](url)
170        let mut bracket_depth = 0;
171        let mut paren_start = None;
172
173        let mut escaped = false;
174        for (i, c) in raw_link.char_indices() {
175            if escaped {
176                escaped = false;
177                continue;
178            }
179
180            match c {
181                '\\' => escaped = true,
182                '[' => bracket_depth += 1,
183                ']' => {
184                    bracket_depth -= 1;
185                    if bracket_depth == 0 {
186                        // Next char should be '(' for inline links
187                        let rest = &raw_link[i + 1..];
188                        if rest.starts_with('(') {
189                            paren_start = Some(i + 1);
190                        }
191                        break;
192                    }
193                }
194                _ => {}
195            }
196        }
197
198        let paren_start = paren_start?;
199
200        let dest_content_start = paren_start + 1; // After '('
201        let dest_content_end = raw_link.len().checked_sub(1)?;
202        if raw_link.as_bytes().get(dest_content_end) != Some(&b')')
203            || Self::is_escaped(raw_link, dest_content_end)
204            || dest_content_end < dest_content_start
205        {
206            return None;
207        }
208
209        // The parser already bounded raw_link at the matching closing
210        // parenthesis. Only retain the conservative unmatched-angle check that
211        // prevents fixes when the destination itself is not safe to interpret.
212        let dest_content = &raw_link[dest_content_start..dest_content_end];
213        let (url, _) = Self::split_url_and_title(dest_content);
214        let mut in_angle_brackets = false;
215        let mut escaped = false;
216        for c in url.chars() {
217            if escaped {
218                escaped = false;
219                continue;
220            }
221            match c {
222                '\\' => escaped = true,
223                '<' if !in_angle_brackets => in_angle_brackets = true,
224                '>' if in_angle_brackets => in_angle_brackets = false,
225                _ => {}
226            }
227        }
228        if in_angle_brackets {
229            return None;
230        }
231
232        Some((dest_content_start, dest_content_end, dest_content))
233    }
234
235    /// Split destination content into (url, optional title).
236    ///
237    /// A title is recognized only when it is a *balanced* quoted string
238    /// (`"..."` or `'...'`) that forms the trailing token of the destination
239    /// (only whitespace may follow the closing quote). A lone or unbalanced quote
240    /// is treated as part of the URL. This prevents the fix from turning a stray
241    /// quote into an unterminated title that, when the document is re-parsed,
242    /// swallows the following line - which would make the fix non-idempotent and
243    /// drop content.
244    fn split_url_and_title(dest: &str) -> (&str, Option<&str>) {
245        // Quote characters are ASCII, so byte and char indices coincide here.
246        if let Some(q_start) = dest.find(['"', '\'']) {
247            // A title is separated from the destination by whitespace, and a destination
248            // has to come first. A quote glued to the URL (`x"t"`, or after a U+00A0 that
249            // is not whitespace to CommonMark) is part of the URL.
250            let before = &dest[..q_start];
251            if !before.ends_with(is_destination_space) || before.trim_matches(is_destination_space).is_empty() {
252                return (dest, None);
253            }
254            let quote = dest.as_bytes()[q_start] as char;
255            if let Some(rel_close) = dest[q_start + 1..].find(quote) {
256                let close = q_start + 1 + rel_close;
257                if dest[close + 1..].trim_matches(is_destination_space).is_empty() {
258                    return (&dest[..q_start], Some(&dest[q_start..=close]));
259                }
260            }
261        }
262        (dest, None)
263    }
264
265    /// Check if destination has leading/trailing whitespace
266    /// Returns the type of whitespace issue found, if any
267    fn check_destination_whitespace(&self, full_dest: &str) -> Option<WhitespaceIssue> {
268        if full_dest.is_empty() {
269            return None;
270        }
271
272        let first_char = full_dest.chars().next();
273        let last_char = full_dest.chars().last();
274
275        let has_leading = first_char.is_some_and(is_destination_space);
276
277        // Check for trailing whitespace - either at the end or before a title
278        let has_trailing = if last_char.is_some_and(is_destination_space) {
279            true
280        } else {
281            let (url_portion, title) = Self::split_url_and_title(full_dest);
282            title.is_some() && url_portion.ends_with(is_destination_space)
283        };
284
285        match (has_leading, has_trailing) {
286            (true, true) => Some(WhitespaceIssue::Both),
287            (true, false) => Some(WhitespaceIssue::Leading),
288            (false, true) => Some(WhitespaceIssue::Trailing),
289            (false, false) => None,
290        }
291    }
292
293    /// Create the fixed link text
294    fn create_fix(&self, raw_link: &str) -> Option<String> {
295        let (dest_start, dest_end, _) = self.extract_destination_info(raw_link)?;
296
297        // Get the full destination content (may include title)
298        let full_dest_content = &raw_link[dest_start..dest_end];
299
300        // Split into URL and optional title. Only a balanced quoted title is
301        // recognized; a stray quote stays part of the URL (see split_url_and_title).
302        let (url_raw, title_raw) = Self::split_url_and_title(full_dest_content);
303        let url_part = url_raw.trim_matches(is_destination_space);
304        let title_part = title_raw.map(|title| title.trim_matches(is_destination_space));
305
306        // Reconstruct: text part + ( + trimmed_url + optional_title + )
307        let text_part = &raw_link[..dest_start]; // Includes '[text]('
308
309        let mut fixed = String::with_capacity(raw_link.len());
310        fixed.push_str(text_part);
311        fixed.push_str(url_part);
312        if let Some(title) = title_part {
313            fixed.push(' ');
314            fixed.push_str(title);
315        }
316        fixed.push(')');
317
318        // Only return fix if it actually changed something
319        if fixed != raw_link { Some(fixed) } else { None }
320    }
321}
322
323impl Rule for MD062LinkDestinationWhitespace {
324    fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
325        Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(62))
326    }
327
328    fn name(&self) -> &'static str {
329        "MD062"
330    }
331
332    fn description(&self) -> &'static str {
333        "Link destination should not have leading or trailing whitespace"
334    }
335
336    fn category(&self) -> RuleCategory {
337        RuleCategory::Link
338    }
339
340    fn should_skip(&self, ctx: &LintContext) -> bool {
341        ctx.content.is_empty() || !ctx.likely_has_links_or_images()
342    }
343
344    fn check(&self, ctx: &LintContext) -> LintResult {
345        let mut warnings = Vec::new();
346
347        // Process links
348        for link in ctx.links() {
349            // Only check inline links, not reference links
350            if link.is_reference || !matches!(link.link_type, LinkType::Inline) {
351                continue;
352            }
353
354            // Skip links inside Jinja templates
355            if ctx.is_in_jinja_range(link.byte_offset) {
356                continue;
357            }
358
359            // Get raw link text from content
360            let raw_link = &ctx.content[link.byte_offset..link.byte_end];
361
362            // Extract destination info and check for whitespace issues
363            if let Some((issue, fixed)) = self.fix_for(ctx, link.line, raw_link, (&link.url, link.title.as_deref())) {
364                warnings.push(LintWarning {
365                    rule_name: Some(self.name().to_string()),
366                    line: link.line,
367                    column: link.start_col + 1,
368                    end_line: link.end_line,
369                    end_column: link.end_col + 1,
370                    message: issue.message(false),
371                    severity: Severity::Warning,
372                    fix: Some(Fix::new(link.byte_offset..link.byte_end, fixed)),
373                });
374            }
375        }
376
377        // Process images
378        for image in ctx.images() {
379            // Only check inline images, not reference images
380            if image.is_reference || !matches!(image.link_type, LinkType::Inline) {
381                continue;
382            }
383
384            // Skip images inside Jinja templates
385            if ctx.is_in_jinja_range(image.byte_offset) {
386                continue;
387            }
388
389            // Get raw image text from content
390            let raw_image = &ctx.content[image.byte_offset..image.byte_end];
391
392            // For images, skip the leading '!'
393            let link_portion = raw_image.strip_prefix('!').unwrap_or(raw_image);
394
395            // Extract destination info and check for whitespace issues
396            if let Some((issue, fixed_link)) =
397                self.fix_for(ctx, image.line, link_portion, (&image.url, image.title.as_deref()))
398            {
399                let fixed = format!("!{fixed_link}");
400                warnings.push(LintWarning {
401                    rule_name: Some(self.name().to_string()),
402                    line: image.line,
403                    column: image.start_col + 1,
404                    end_line: image.end_line,
405                    end_column: image.end_col + 1,
406                    message: issue.message(true),
407                    severity: Severity::Warning,
408                    fix: Some(Fix::new(image.byte_offset..image.byte_end, fixed)),
409                });
410            }
411        }
412
413        Ok(warnings)
414    }
415
416    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
417        let warnings = self.check(ctx)?;
418        let warnings =
419            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
420
421        if warnings.is_empty() {
422            return Ok(ctx.content.to_string());
423        }
424
425        let mut content = ctx.content.to_string();
426        let mut fixes: Vec<_> = warnings
427            .into_iter()
428            .filter_map(|w| w.fix.map(|f| (f.range.start, f.range.end, f.replacement)))
429            .collect();
430
431        // Sort by position and apply in reverse order
432        fixes.sort_by_key(|(start, _, _)| *start);
433
434        for (start, end, replacement) in fixes.into_iter().rev() {
435            content.replace_range(start..end, &replacement);
436        }
437
438        Ok(content)
439    }
440
441    fn as_any(&self) -> &dyn std::any::Any {
442        self
443    }
444
445    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
446    where
447        Self: Sized,
448    {
449        Box::new(Self)
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use crate::config::MarkdownFlavor;
457
458    #[test]
459    fn test_no_whitespace() {
460        let rule = MD062LinkDestinationWhitespace::new();
461        let content = "[link](https://example.com)";
462        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
463        let warnings = rule.check(&ctx).unwrap();
464        assert!(warnings.is_empty());
465    }
466
467    #[test]
468    fn test_leading_whitespace() {
469        let rule = MD062LinkDestinationWhitespace::new();
470        let content = "[link]( https://example.com)";
471        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
472        let warnings = rule.check(&ctx).unwrap();
473        assert_eq!(warnings.len(), 1);
474        assert_eq!(
475            warnings[0].fix.as_ref().unwrap().replacement,
476            "[link](https://example.com)"
477        );
478    }
479
480    #[test]
481    fn test_trailing_whitespace() {
482        let rule = MD062LinkDestinationWhitespace::new();
483        let content = "[link](https://example.com )";
484        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
485        let warnings = rule.check(&ctx).unwrap();
486        assert_eq!(warnings.len(), 1);
487        assert_eq!(
488            warnings[0].fix.as_ref().unwrap().replacement,
489            "[link](https://example.com)"
490        );
491    }
492
493    #[test]
494    fn test_both_whitespace() {
495        let rule = MD062LinkDestinationWhitespace::new();
496        let content = "[link]( https://example.com )";
497        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
498        let warnings = rule.check(&ctx).unwrap();
499        assert_eq!(warnings.len(), 1);
500        assert_eq!(
501            warnings[0].fix.as_ref().unwrap().replacement,
502            "[link](https://example.com)"
503        );
504    }
505
506    #[test]
507    fn test_multiple_spaces() {
508        let rule = MD062LinkDestinationWhitespace::new();
509        let content = "[link](   https://example.com   )";
510        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
511        let warnings = rule.check(&ctx).unwrap();
512        assert_eq!(warnings.len(), 1);
513        assert_eq!(
514            warnings[0].fix.as_ref().unwrap().replacement,
515            "[link](https://example.com)"
516        );
517    }
518
519    #[test]
520    fn test_with_title() {
521        let rule = MD062LinkDestinationWhitespace::new();
522        let content = "[link]( https://example.com \"title\")";
523        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
524        let warnings = rule.check(&ctx).unwrap();
525        assert_eq!(warnings.len(), 1);
526        assert_eq!(
527            warnings[0].fix.as_ref().unwrap().replacement,
528            "[link](https://example.com \"title\")"
529        );
530    }
531
532    #[test]
533    fn test_image_leading_whitespace() {
534        let rule = MD062LinkDestinationWhitespace::new();
535        let content = "![alt]( https://example.com/image.png)";
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            "![alt](https://example.com/image.png)"
542        );
543    }
544
545    #[test]
546    fn test_multiple_links() {
547        let rule = MD062LinkDestinationWhitespace::new();
548        let content = "[a]( url1) and [b](url2 ) here";
549        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
550        let warnings = rule.check(&ctx).unwrap();
551        assert_eq!(warnings.len(), 2);
552    }
553
554    #[test]
555    fn test_fix() {
556        let rule = MD062LinkDestinationWhitespace::new();
557        let content = "[link]( https://example.com ) and ![img]( /path/to/img.png )";
558        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
559        let fixed = rule.fix(&ctx).unwrap();
560        assert_eq!(fixed, "[link](https://example.com) and ![img](/path/to/img.png)");
561    }
562
563    #[test]
564    fn test_fix_preserves_parentheses_inside_destination_and_title() {
565        let rule = MD062LinkDestinationWhitespace::new();
566        let cases = [
567            (r"[link]( path\)suffix )", r"[link](path\)suffix)"),
568            (r"[link]( path\(suffix )", r"[link](path\(suffix)"),
569            (r#"[link]( path "title) rest" )"#, r#"[link](path "title) rest")"#),
570            (r"[link]( path 'title) rest' )", r"[link](path 'title) rest')"),
571            (r"![alt]( path\)suffix )", r"![alt](path\)suffix)"),
572            (r"[a \] b]( path )", r"[a \] b](path)"),
573        ];
574
575        for (content, expected) in cases {
576            let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
577            let fixed = rule.fix(&ctx).unwrap();
578            assert_eq!(fixed, expected, "unexpected fix for {content:?}");
579
580            let fixed_ctx = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
581            assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "second fix changed {content:?}");
582        }
583    }
584
585    #[test]
586    fn test_fix_idempotent_with_unbalanced_quote() {
587        // Regression: a lone quote inside a destination must not be treated as a
588        // title. Previously the fix inserted a space before the stray quote
589        // (`![](X" )` -> `![](X ")`), which re-parsed as an unterminated title
590        // that swallowed the following line, making the fix non-idempotent and
591        // dropping content. The character is U+2A700 (4 bytes in UTF-8) to also
592        // guard the byte/char offset handling.
593        let rule = MD062LinkDestinationWhitespace::new();
594        let content = "![](\u{2a700}\" )\n![](\")";
595
596        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
597        let once = rule.fix(&ctx).unwrap();
598
599        let ctx2 = LintContext::new(&once, MarkdownFlavor::Standard, None);
600        let twice = rule.fix(&ctx2).unwrap();
601
602        assert_eq!(once, twice, "MD062 fix must be idempotent for unbalanced quotes");
603        assert_eq!(
604            once.lines().count(),
605            2,
606            "fix must not drop the second image line, got: {once:?}"
607        );
608    }
609
610    #[test]
611    fn test_reference_links_skipped() {
612        let rule = MD062LinkDestinationWhitespace::new();
613        let content = "[link][ref]\n\n[ref]: https://example.com";
614        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
615        let warnings = rule.check(&ctx).unwrap();
616        assert!(warnings.is_empty());
617    }
618
619    #[test]
620    fn test_nested_brackets() {
621        let rule = MD062LinkDestinationWhitespace::new();
622        let content = "[text [nested]]( https://example.com)";
623        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
624        let warnings = rule.check(&ctx).unwrap();
625        assert_eq!(warnings.len(), 1);
626    }
627
628    #[test]
629    fn test_empty_destination() {
630        let rule = MD062LinkDestinationWhitespace::new();
631        let content = "[link]()";
632        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
633        let warnings = rule.check(&ctx).unwrap();
634        assert!(warnings.is_empty());
635    }
636
637    #[test]
638    fn test_tabs_and_newlines() {
639        let rule = MD062LinkDestinationWhitespace::new();
640        let content = "[link](\thttps://example.com\t)";
641        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
642        let warnings = rule.check(&ctx).unwrap();
643        assert_eq!(warnings.len(), 1);
644        assert_eq!(
645            warnings[0].fix.as_ref().unwrap().replacement,
646            "[link](https://example.com)"
647        );
648    }
649
650    // Edge case tests for comprehensive coverage
651
652    #[test]
653    fn test_trailing_whitespace_after_title() {
654        let rule = MD062LinkDestinationWhitespace::new();
655        let content = "[link](https://example.com \"title\" )";
656        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
657        let warnings = rule.check(&ctx).unwrap();
658        assert_eq!(warnings.len(), 1);
659        assert_eq!(
660            warnings[0].fix.as_ref().unwrap().replacement,
661            "[link](https://example.com \"title\")"
662        );
663    }
664
665    #[test]
666    fn test_leading_and_trailing_with_title() {
667        let rule = MD062LinkDestinationWhitespace::new();
668        let content = "[link]( https://example.com \"title\" )";
669        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
670        let warnings = rule.check(&ctx).unwrap();
671        assert_eq!(warnings.len(), 1);
672        assert_eq!(
673            warnings[0].fix.as_ref().unwrap().replacement,
674            "[link](https://example.com \"title\")"
675        );
676    }
677
678    #[test]
679    fn test_multiple_spaces_before_title() {
680        let rule = MD062LinkDestinationWhitespace::new();
681        let content = "[link](https://example.com  \"title\")";
682        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
683        let warnings = rule.check(&ctx).unwrap();
684        assert_eq!(warnings.len(), 1);
685        assert_eq!(
686            warnings[0].fix.as_ref().unwrap().replacement,
687            "[link](https://example.com \"title\")"
688        );
689    }
690
691    #[test]
692    fn test_single_quote_title() {
693        let rule = MD062LinkDestinationWhitespace::new();
694        let content = "[link]( https://example.com 'title')";
695        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
696        let warnings = rule.check(&ctx).unwrap();
697        assert_eq!(warnings.len(), 1);
698        assert_eq!(
699            warnings[0].fix.as_ref().unwrap().replacement,
700            "[link](https://example.com 'title')"
701        );
702    }
703
704    #[test]
705    fn test_single_quote_title_trailing_space() {
706        let rule = MD062LinkDestinationWhitespace::new();
707        let content = "[link](https://example.com 'title' )";
708        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
709        let warnings = rule.check(&ctx).unwrap();
710        assert_eq!(warnings.len(), 1);
711        assert_eq!(
712            warnings[0].fix.as_ref().unwrap().replacement,
713            "[link](https://example.com 'title')"
714        );
715    }
716
717    #[test]
718    fn test_wikipedia_style_url() {
719        // Wikipedia URLs with parentheses should work correctly
720        let rule = MD062LinkDestinationWhitespace::new();
721        let content = "[wiki]( https://en.wikipedia.org/wiki/Rust_(programming_language) )";
722        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
723        let warnings = rule.check(&ctx).unwrap();
724        assert_eq!(warnings.len(), 1);
725        assert_eq!(
726            warnings[0].fix.as_ref().unwrap().replacement,
727            "[wiki](https://en.wikipedia.org/wiki/Rust_(programming_language))"
728        );
729    }
730
731    #[test]
732    fn test_angle_bracket_url_no_warning() {
733        // Angle bracket URLs can contain spaces per CommonMark, so we should skip them
734        let rule = MD062LinkDestinationWhitespace::new();
735        let content = "[link](<https://example.com/path with spaces>)";
736        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
737        let warnings = rule.check(&ctx).unwrap();
738        // Angle bracket URLs are allowed to have spaces, no warning expected
739        assert!(warnings.is_empty());
740    }
741
742    #[test]
743    fn test_image_with_title() {
744        let rule = MD062LinkDestinationWhitespace::new();
745        let content = "![alt]( https://example.com/img.png \"Image title\" )";
746        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
747        let warnings = rule.check(&ctx).unwrap();
748        assert_eq!(warnings.len(), 1);
749        assert_eq!(
750            warnings[0].fix.as_ref().unwrap().replacement,
751            "![alt](https://example.com/img.png \"Image title\")"
752        );
753    }
754
755    #[test]
756    fn test_only_whitespace_in_destination() {
757        let rule = MD062LinkDestinationWhitespace::new();
758        let content = "[link](   )";
759        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
760        let warnings = rule.check(&ctx).unwrap();
761        assert_eq!(warnings.len(), 1);
762        assert_eq!(warnings[0].fix.as_ref().unwrap().replacement, "[link]()");
763    }
764
765    #[test]
766    fn test_code_block_skipped() {
767        let rule = MD062LinkDestinationWhitespace::new();
768        let content = "```\n[link]( https://example.com )\n```";
769        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
770        let warnings = rule.check(&ctx).unwrap();
771        assert!(warnings.is_empty());
772    }
773
774    #[test]
775    fn test_inline_code_not_skipped() {
776        // Links in inline code are not valid markdown anyway
777        let rule = MD062LinkDestinationWhitespace::new();
778        let content = "text `[link]( url )` more text";
779        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
780        let warnings = rule.check(&ctx).unwrap();
781        // pulldown-cmark doesn't parse this as a link since it's in code
782        assert!(warnings.is_empty());
783    }
784
785    #[test]
786    fn test_valid_link_with_title_no_warning() {
787        let rule = MD062LinkDestinationWhitespace::new();
788        let content = "[link](https://example.com \"Title\")";
789        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
790        let warnings = rule.check(&ctx).unwrap();
791        assert!(warnings.is_empty());
792    }
793
794    #[test]
795    fn test_mixed_links_on_same_line() {
796        let rule = MD062LinkDestinationWhitespace::new();
797        let content = "[good](https://example.com) and [bad]( https://example.com ) here";
798        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
799        let warnings = rule.check(&ctx).unwrap();
800        assert_eq!(warnings.len(), 1);
801        assert_eq!(
802            warnings[0].fix.as_ref().unwrap().replacement,
803            "[bad](https://example.com)"
804        );
805    }
806
807    #[test]
808    fn test_fix_multiple_on_same_line() {
809        let rule = MD062LinkDestinationWhitespace::new();
810        let content = "[a]( url1 ) and [b]( url2 )";
811        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
812        let fixed = rule.fix(&ctx).unwrap();
813        assert_eq!(fixed, "[a](url1) and [b](url2)");
814    }
815
816    #[test]
817    fn test_complex_nested_brackets() {
818        let rule = MD062LinkDestinationWhitespace::new();
819        let content = "[text [with [deeply] nested] brackets]( https://example.com )";
820        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
821        let warnings = rule.check(&ctx).unwrap();
822        assert_eq!(warnings.len(), 1);
823    }
824
825    #[test]
826    fn test_url_with_query_params() {
827        let rule = MD062LinkDestinationWhitespace::new();
828        let content = "[link]( https://example.com?foo=bar&baz=qux )";
829        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
830        let warnings = rule.check(&ctx).unwrap();
831        assert_eq!(warnings.len(), 1);
832        assert_eq!(
833            warnings[0].fix.as_ref().unwrap().replacement,
834            "[link](https://example.com?foo=bar&baz=qux)"
835        );
836    }
837
838    #[test]
839    fn test_url_with_fragment() {
840        let rule = MD062LinkDestinationWhitespace::new();
841        let content = "[link]( https://example.com#section )";
842        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
843        let warnings = rule.check(&ctx).unwrap();
844        assert_eq!(warnings.len(), 1);
845        assert_eq!(
846            warnings[0].fix.as_ref().unwrap().replacement,
847            "[link](https://example.com#section)"
848        );
849    }
850
851    #[test]
852    fn test_relative_path() {
853        let rule = MD062LinkDestinationWhitespace::new();
854        let content = "[link]( ./path/to/file.md )";
855        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
856        let warnings = rule.check(&ctx).unwrap();
857        assert_eq!(warnings.len(), 1);
858        assert_eq!(
859            warnings[0].fix.as_ref().unwrap().replacement,
860            "[link](./path/to/file.md)"
861        );
862    }
863
864    #[test]
865    fn test_unmatched_angle_bracket_in_destination() {
866        // When `<` inside the destination masks the closing `)`, the rule
867        // should not produce a warning or fix, since it cannot reliably
868        // determine the destination boundaries.
869        let rule = MD062LinkDestinationWhitespace::new();
870        let content = "[](  \"<)";
871        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
872        let warnings = rule.check(&ctx).unwrap();
873        assert!(
874            warnings.is_empty(),
875            "Should not warn when closing paren is masked by angle bracket"
876        );
877
878        // Verify idempotency: fix should not modify unparseable links
879        let fixed = rule.fix(&ctx).unwrap();
880        assert_eq!(fixed, content);
881    }
882
883    #[test]
884    fn test_unicode_whitespace_in_destination() {
885        // Unicode whitespace (EN QUAD U+2000) in link destination
886        let rule = MD062LinkDestinationWhitespace::new();
887        let content = "[](\u{2000}\"<)";
888        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
889        let warnings = rule.check(&ctx).unwrap();
890        assert!(
891            warnings.is_empty(),
892            "Should not warn when angle bracket masks closing paren"
893        );
894
895        let fixed = rule.fix(&ctx).unwrap();
896        assert_eq!(fixed, content, "Fix must be idempotent for unparseable links");
897    }
898
899    #[test]
900    fn test_autolink_not_affected() {
901        // Autolinks use <> syntax and are different from inline links
902        let rule = MD062LinkDestinationWhitespace::new();
903        let content = "<https://example.com>";
904        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
905        let warnings = rule.check(&ctx).unwrap();
906        assert!(warnings.is_empty());
907    }
908
909    fn md062_fix(content: &str) -> (usize, String) {
910        let rule = MD062LinkDestinationWhitespace::new();
911        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
912        (rule.check(&ctx).unwrap().len(), rule.fix(&ctx).unwrap())
913    }
914
915    #[test]
916    fn test_non_commonmark_whitespace_is_destination_content() {
917        // NBSP, EM SPACE and IDEOGRAPHIC SPACE are not CommonMark whitespace, so
918        // each is part of the destination and must neither warn nor be stripped.
919        for ws in ['\u{a0}', '\u{2003}', '\u{3000}'] {
920            for source in [
921                format!("[a]({ws}x)\n"),
922                format!("[a](x{ws})\n"),
923                format!("[a]({ws}x{ws})\n"),
924                format!("[a](x{ws}\"t\")\n"),
925                format!("![a]({ws}x)\n"),
926            ] {
927                let (warnings, fixed) = md062_fix(&source);
928                assert_eq!(warnings, 0, "{source:?} must not warn");
929                assert_eq!(fixed, source, "{source:?} must be unchanged");
930            }
931        }
932    }
933
934    #[test]
935    fn test_ascii_whitespace_around_non_commonmark_whitespace() {
936        // Only the ASCII space and tab are stripped, the NBSP inside the
937        // destination stays.
938        let (warnings, fixed) = md062_fix("[a]( \u{a0}x\t)\n");
939        assert_eq!(warnings, 1);
940        assert_eq!(fixed, "[a](\u{a0}x)\n");
941    }
942
943    #[test]
944    fn test_blockquote_multiline_destination_is_fixed_without_the_marker() {
945        // The `>` on a continuation line belongs to the blockquote. The fix joins the
946        // destination onto one line and the marker never becomes the destination.
947        for (source, expected) in [
948            ("> [k](\n> )\n", "> [k]()\n"),
949            ("> [k](\n> url)\n", "> [k](url)\n"),
950            ("> [k](url\n> )\n", "> [k](url)\n"),
951            ("> [k](\n> x\n> )\n", "> [k](x)\n"),
952            ("> [k](\n> x\n> \"t\")\n", "> [k](x \"t\")\n"),
953            (">> [k](\n>> )\n", ">> [k]()\n"),
954            ("> > [k](\n> > url\n> > )\n", "> > [k](url)\n"),
955            ("> - [k](\n>   )\n", "> - [k]()\n"),
956            ("- > [k](\n  > )\n", "- > [k]()\n"),
957            ("> ![k](\n> )\n", "> ![k]()\n"),
958            ("> [k](\n)\n", "> [k]()\n"),
959        ] {
960            let (warnings, fixed) = md062_fix(source);
961            assert_eq!(warnings, 1, "{source:?} must warn");
962            assert_eq!(fixed, expected, "{source:?}");
963            assert_eq!(md062_fix(expected).0, 0, "{expected:?} must be clean");
964        }
965    }
966
967    #[test]
968    fn test_blockquote_multiline_label_keeps_its_markers() {
969        // Only the destination is rewritten: the label's line break and marker stay.
970        for (source, expected) in [
971            ("> [multi\n> line]( url )\n", "> [multi\n> line](url)\n"),
972            ("> ![multi\n> line]( url )\n", "> ![multi\n> line](url)\n"),
973            (
974                "> > [multi\n> > line](\n> > url\n> > )\n",
975                "> > [multi\n> > line](url)\n",
976            ),
977            ("- > [multi\n  > line]( url )\n", "- > [multi\n  > line](url)\n"),
978        ] {
979            let (warnings, fixed) = md062_fix(source);
980            assert_eq!(warnings, 1, "{source:?} must warn");
981            assert_eq!(fixed, expected, "{source:?}");
982            assert_eq!(md062_fix(expected).0, 0, "{expected:?} must be clean");
983        }
984    }
985
986    #[test]
987    fn test_blockquote_title_spanning_lines_is_left_alone() {
988        // Joining the title would need its continuation line's marker re-inserted, so
989        // there is no warning the fix could resolve.
990        let source = "> [k](x \"a\n> b\" )\n";
991        let (warnings, fixed) = md062_fix(source);
992        assert_eq!(warnings, 0);
993        assert_eq!(fixed, source);
994    }
995
996    #[test]
997    fn test_blockquote_single_line_destination_is_still_fixed() {
998        let (warnings, fixed) = md062_fix("> [a]( x )\n>\n> [b](  y  \"t\" )\n");
999        assert_eq!(warnings, 2);
1000        assert_eq!(fixed, "> [a](x)\n>\n> [b](y \"t\")\n");
1001    }
1002
1003    #[test]
1004    fn test_list_item_multiline_destination_is_still_fixed() {
1005        // Indentation-only containers have no marker to leak into the destination.
1006        let (warnings, fixed) = md062_fix("- [k](\n  )\n");
1007        assert_eq!(warnings, 1);
1008        assert_eq!(fixed, "- [k]()\n");
1009        let (warnings, fixed) = md062_fix("- [k](\n  url\n  )\n");
1010        assert_eq!(warnings, 1);
1011        assert_eq!(fixed, "- [k](url)\n");
1012    }
1013
1014    #[test]
1015    fn test_quote_glued_to_the_destination_is_not_a_title() {
1016        // A title needs whitespace before it. `x"t"` and `x<NBSP>"t"` are URLs, so
1017        // splitting a title off would change the destination.
1018        for source in ["[a](x\"t\" )\n", "[a](x\u{a0}\"t\" )\n", "[a]( x'a' )\n"] {
1019            let (_, fixed) = md062_fix(source);
1020            assert!(
1021                !fixed.contains(" \"t\")") && !fixed.contains(" 'a')"),
1022                "{source:?} gained a title: {fixed:?}"
1023            );
1024        }
1025        assert_eq!(md062_fix("[a](x\"t\" )\n").1, "[a](x\"t\")\n");
1026        // Positive control: a separated title is still a title.
1027        assert_eq!(md062_fix("[a](x  \"t\" )\n").1, "[a](x \"t\")\n");
1028    }
1029
1030    #[test]
1031    fn test_indented_angle_bracket_is_destination_text_not_a_quote_marker() {
1032        // Four spaces of indentation keep `>` from opening a quote, so it is part of
1033        // the destination and must survive any fix.
1034        for source in ["[a](\n    >url)\n", "![a](\n    >url)\n", "[a](\n    >url\n    )\n"] {
1035            let (_, fixed) = md062_fix(source);
1036            assert!(
1037                fixed.contains(">url") || fixed == source,
1038                "{source:?} lost the > of its destination: {fixed:?}"
1039            );
1040        }
1041        // Positive control: a real marker is still dropped.
1042        assert_eq!(md062_fix("> [a](\n> url)\n").1, "> [a](url)\n");
1043    }
1044}