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