Skip to main content

rumdl_lib/rules/
md034_no_bare_urls.rs

1/// Rule MD034: No unformatted URLs
2///
3/// See [docs/md034.md](../../docs/md034.md) for full documentation, configuration, and examples.
4use std::sync::LazyLock;
5
6use regex::Regex;
7
8use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
9use crate::utils::range_utils::calculate_url_range;
10use crate::utils::regex_cache::{
11    EMAIL_PATTERN, URL_IPV6_REGEX, URL_QUICK_CHECK_REGEX, URL_STANDARD_REGEX, URL_WWW_REGEX, XMPP_URI_REGEX,
12};
13
14use crate::filtered_lines::FilteredLinesExt;
15use crate::lint_context::LintContext;
16
17// MD034-specific pre-compiled regex patterns for markdown constructs
18static CUSTOM_PROTOCOL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
19    Regex::new(r#"(?:grpc|ws|wss|ssh|git|svn|file|data|javascript|vscode|chrome|about|slack|discord|matrix|irc|redis|mongodb|postgresql|mysql|kafka|nats|amqp|mqtt|custom|app|api|service)://"#).unwrap()
20});
21static MARKDOWN_LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
22    Regex::new(r#"\[(?:[^\[\]]|\[[^\]]*\])*\]\(([^)\s]+)(?:\s+(?:\"[^\"]*\"|\'[^\']*\'))?\)"#).unwrap()
23});
24static MARKDOWN_EMPTY_LINK_REGEX: LazyLock<Regex> =
25    LazyLock::new(|| Regex::new(r#"\[(?:[^\[\]]|\[[^\]]*\])*\]\(\)"#).unwrap());
26static MARKDOWN_EMPTY_REF_REGEX: LazyLock<Regex> =
27    LazyLock::new(|| Regex::new(r#"\[(?:[^\[\]]|\[[^\]]*\])*\]\[\]"#).unwrap());
28static ANGLE_LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
29    Regex::new(
30        r#"<((?:https?|ftps?)://(?:\[[0-9a-fA-F:]+(?:%[a-zA-Z0-9]+)?\]|[^>]+)|xmpp:[^>]+|[^@\s]+@[^@\s]+\.[^@\s>]+)>"#,
31    )
32    .unwrap()
33});
34static BADGE_LINK_LINE_REGEX: LazyLock<Regex> =
35    LazyLock::new(|| Regex::new(r#"^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*$"#).unwrap());
36static MARKDOWN_IMAGE_REGEX: LazyLock<Regex> =
37    LazyLock::new(|| Regex::new(r#"!\s*\[([^\]]*)\]\s*\(([^)\s]+)(?:\s+(?:\"[^\"]*\"|\'[^\']*\'))?\)"#).unwrap());
38static MULTILINE_LINK_CONTINUATION_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"^[^\[]*\]\(.*\)"#).unwrap());
39static SHORTCUT_REF_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"\[([^\[\]]+)\]"#).unwrap());
40
41/// Reusable buffers for check_line to reduce allocations
42#[derive(Default)]
43struct LineCheckBuffers {
44    markdown_link_ranges: Vec<(usize, usize)>,
45    image_ranges: Vec<(usize, usize)>,
46    urls_found: Vec<(usize, usize, String)>,
47}
48
49#[derive(Default, Clone)]
50pub struct MD034NoBareUrls;
51
52impl MD034NoBareUrls {
53    #[inline]
54    pub fn should_skip_content(&self, content: &str) -> bool {
55        // Skip if content has no URLs, XMPP URIs, or email addresses
56        // Fast byte scanning for common URL/email/xmpp indicators
57        let bytes = content.as_bytes();
58        let has_colon = bytes.contains(&b':');
59        let has_at = bytes.contains(&b'@');
60        let has_www = content.contains("www.");
61        !has_colon && !has_at && !has_www
62    }
63
64    /// Remove trailing punctuation that is likely sentence punctuation, not part of the URL
65    fn trim_trailing_punctuation<'a>(&self, url: &'a str) -> &'a str {
66        let mut trimmed = url;
67
68        // Check for balanced parentheses - if we have unmatched closing parens, they're likely punctuation
69        let open_parens = url.chars().filter(|&c| c == '(').count();
70        let close_parens = url.chars().filter(|&c| c == ')').count();
71
72        if close_parens > open_parens {
73            // Find the last balanced closing paren position
74            let mut balance = 0;
75            let mut last_balanced_pos = url.len();
76
77            for (byte_idx, c) in url.char_indices() {
78                if c == '(' {
79                    balance += 1;
80                } else if c == ')' {
81                    balance -= 1;
82                    if balance < 0 {
83                        // Found an unmatched closing paren
84                        last_balanced_pos = byte_idx;
85                        break;
86                    }
87                }
88            }
89
90            trimmed = &trimmed[..last_balanced_pos];
91        }
92
93        // Trim specific punctuation only if not followed by more URL-like chars
94        while let Some(last_char) = trimmed.chars().last() {
95            if matches!(last_char, '.' | ',' | ';' | ':' | '!' | '?') {
96                // Check if this looks like it could be part of the URL
97                // For ':' specifically, keep it if followed by digits (port number)
98                if last_char == ':' && trimmed.len() > 1 {
99                    // Don't trim
100                    break;
101                }
102                trimmed = &trimmed[..trimmed.len() - 1];
103            } else {
104                break;
105            }
106        }
107
108        trimmed
109    }
110
111    fn check_line(
112        &self,
113        line: &str,
114        ctx: &LintContext,
115        line_number: usize,
116        code_spans: &[crate::lint_context::CodeSpan],
117        buffers: &mut LineCheckBuffers,
118    ) -> Vec<LintWarning> {
119        let mut warnings = Vec::new();
120
121        // Skip lines inside HTML blocks - URLs in HTML attributes should not be linted
122        if ctx.line_info(line_number).is_some_and(|info| info.in_html_block) {
123            return warnings;
124        }
125
126        // Skip lines that are continuations of multiline markdown links
127        // Pattern: text](url) without a leading [
128        if MULTILINE_LINK_CONTINUATION_REGEX.is_match(line) {
129            return warnings;
130        }
131
132        // Quick check - does this line potentially have a URL or email?
133        let has_quick_check = URL_QUICK_CHECK_REGEX.is_match(line);
134        let has_www = line.contains("www.");
135        let has_at = line.contains('@');
136
137        if !has_quick_check && !has_at && !has_www {
138            return warnings;
139        }
140
141        // Clear and reuse buffers instead of allocating new ones
142        buffers.markdown_link_ranges.clear();
143        buffers.image_ranges.clear();
144
145        let has_bracket = line.contains('[');
146        let has_angle = line.contains('<');
147        let has_bang = line.contains('!');
148
149        if has_bracket {
150            for mat in MARKDOWN_LINK_REGEX.find_iter(line) {
151                buffers.markdown_link_ranges.push((mat.start(), mat.end()));
152            }
153
154            // Also include empty link patterns like [text]() and [text][]
155            for mat in MARKDOWN_EMPTY_LINK_REGEX.find_iter(line) {
156                buffers.markdown_link_ranges.push((mat.start(), mat.end()));
157            }
158
159            for mat in MARKDOWN_EMPTY_REF_REGEX.find_iter(line) {
160                buffers.markdown_link_ranges.push((mat.start(), mat.end()));
161            }
162
163            // Also exclude shortcut reference links like [URL]
164            for mat in SHORTCUT_REF_REGEX.find_iter(line) {
165                let end = mat.end();
166                let next_non_ws = line[end..].bytes().find(|b| !b.is_ascii_whitespace());
167                if next_non_ws == Some(b'(') || next_non_ws == Some(b'[') {
168                    continue;
169                }
170                buffers.markdown_link_ranges.push((mat.start(), mat.end()));
171            }
172
173            // Check if this line contains only a badge link (common pattern)
174            if has_bang && BADGE_LINK_LINE_REGEX.is_match(line) {
175                return warnings;
176            }
177        }
178
179        if has_angle {
180            for mat in ANGLE_LINK_REGEX.find_iter(line) {
181                buffers.markdown_link_ranges.push((mat.start(), mat.end()));
182            }
183        }
184
185        // Find all markdown images for exclusion
186        if has_bang && has_bracket {
187            for mat in MARKDOWN_IMAGE_REGEX.find_iter(line) {
188                buffers.image_ranges.push((mat.start(), mat.end()));
189            }
190        }
191
192        // Find bare URLs
193        buffers.urls_found.clear();
194
195        // First, find IPv6 URLs (they need special handling)
196        for mat in URL_IPV6_REGEX.find_iter(line) {
197            let url_str = mat.as_str();
198            buffers.urls_found.push((mat.start(), mat.end(), url_str.to_string()));
199        }
200
201        // Then find regular URLs
202        for mat in URL_STANDARD_REGEX.find_iter(line) {
203            let url_str = mat.as_str();
204
205            // Skip if it's an IPv6 URL (already handled)
206            if url_str.contains("://[") {
207                continue;
208            }
209
210            // Skip malformed IPv6-like URLs
211            // Check for IPv6-like patterns that are malformed
212            if let Some(host_start) = url_str.find("://") {
213                let after_protocol = &url_str[host_start + 3..];
214                // If it looks like IPv6 (has :: or multiple :) but no brackets, skip if followed by ]
215                if after_protocol.contains("::") || after_protocol.chars().filter(|&c| c == ':').count() > 1 {
216                    // Check if the next byte after our match is ] (ASCII, so byte check is safe)
217                    if line.as_bytes().get(mat.end()) == Some(&b']') {
218                        // This is likely a malformed IPv6 URL like "https://::1]:8080"
219                        continue;
220                    }
221                }
222            }
223
224            buffers.urls_found.push((mat.start(), mat.end(), url_str.to_string()));
225        }
226
227        // Find www URLs without protocol (e.g., www.example.com)
228        for mat in URL_WWW_REGEX.find_iter(line) {
229            let url_str = mat.as_str();
230            let start_pos = mat.start();
231            let end_pos = mat.end();
232
233            // Skip if preceded by / or @ (likely part of a full URL)
234            if start_pos > 0 {
235                let prev_char = line.as_bytes().get(start_pos - 1).copied();
236                if prev_char == Some(b'/') || prev_char == Some(b'@') {
237                    continue;
238                }
239            }
240
241            // Skip if inside angle brackets (autolink syntax like <www.example.com>)
242            if start_pos > 0 && end_pos < line.len() {
243                let prev_char = line.as_bytes().get(start_pos - 1).copied();
244                let next_char = line.as_bytes().get(end_pos).copied();
245                if prev_char == Some(b'<') && next_char == Some(b'>') {
246                    continue;
247                }
248            }
249
250            buffers.urls_found.push((start_pos, end_pos, url_str.to_string()));
251        }
252
253        // Find XMPP URIs (GFM extended autolinks: xmpp:user@domain/resource)
254        for mat in XMPP_URI_REGEX.find_iter(line) {
255            let uri_str = mat.as_str();
256            let start_pos = mat.start();
257            let end_pos = mat.end();
258
259            // Skip if inside angle brackets (already properly formatted: <xmpp:user@domain>)
260            if start_pos > 0 && end_pos < line.len() {
261                let prev_char = line.as_bytes().get(start_pos - 1).copied();
262                let next_char = line.as_bytes().get(end_pos).copied();
263                if prev_char == Some(b'<') && next_char == Some(b'>') {
264                    continue;
265                }
266            }
267
268            buffers.urls_found.push((start_pos, end_pos, uri_str.to_string()));
269        }
270
271        // Process found URLs
272        for &(start, _end, ref url_str) in &buffers.urls_found {
273            // Skip custom protocols
274            if CUSTOM_PROTOCOL_REGEX.is_match(url_str) {
275                continue;
276            }
277
278            // Check if this URL is inside a markdown link, angle bracket, or image
279            // We check if the URL starts within a construct, not if it's entirely contained.
280            // This handles cases where URL detection may include trailing characters
281            // that extend past the construct boundary (e.g., parentheses).
282            // Linear scan is correct here because ranges can overlap/nest (e.g., [[1]](url))
283            let is_inside_construct = buffers
284                .markdown_link_ranges
285                .iter()
286                .any(|&(s, e)| start >= s && start < e)
287                || buffers.image_ranges.iter().any(|&(s, e)| start >= s && start < e);
288
289            if is_inside_construct {
290                continue;
291            }
292
293            // Calculate absolute byte position for context-aware checks
294            let line_start_byte = ctx.line_start_byte(line_number).unwrap_or(0);
295            let absolute_pos = line_start_byte + start;
296
297            // Check if URL is inside an HTML tag (handles multiline tags correctly)
298            if ctx.is_in_html_tag(absolute_pos) {
299                continue;
300            }
301
302            // Check if URL is a JSX component attribute value (e.g. `<Card href="..."/>`).
303            // These are string props, not bare prose; wrapping them in angle brackets
304            // would produce invalid JSX. No-op for non-JSX flavors.
305            if ctx.is_in_jsx_component_tag(absolute_pos) {
306                continue;
307            }
308
309            // Check if we're inside an HTML comment
310            if ctx.is_in_html_comment(absolute_pos) || ctx.is_in_mdx_comment(absolute_pos) {
311                continue;
312            }
313
314            // Check if we're inside a Hugo/Quarto shortcode
315            if ctx.is_in_shortcode(absolute_pos) {
316                continue;
317            }
318
319            // Skip URLs inside Pandoc line blocks (`| text`) or YAML metadata blocks.
320            // Both constructs treat their content as literal/structured text where bare
321            // URLs are intentional and should not be reformatted.
322            if ctx.flavor.is_pandoc_compatible()
323                && (ctx.is_in_line_block(absolute_pos) || ctx.is_in_pandoc_metadata(absolute_pos))
324            {
325                continue;
326            }
327
328            // Clean up the URL by removing trailing punctuation
329            let trimmed_url = self.trim_trailing_punctuation(url_str);
330
331            // Only report if we have a valid URL after trimming
332            if !trimmed_url.is_empty() && trimmed_url != "//" {
333                let trimmed_len = trimmed_url.len();
334                let (start_line, start_col, end_line, end_col) =
335                    calculate_url_range(line_number, line, start, trimmed_len);
336
337                // For www URLs without protocol, add https:// prefix in the fix
338                let replacement = if trimmed_url.starts_with("www.") {
339                    format!("<https://{trimmed_url}>")
340                } else {
341                    format!("<{trimmed_url}>")
342                };
343
344                warnings.push(LintWarning {
345                    rule_name: Some("MD034".to_string()),
346                    line: start_line,
347                    column: start_col,
348                    end_line,
349                    end_column: end_col,
350                    message: if ctx.flavor == crate::config::MarkdownFlavor::MDG {
351                        format!(
352                            "URL without link formatting: '{trimmed_url}' (angle brackets are Gherkin placeholder syntax; use explicit link formatting where appropriate, or disable MD034)"
353                        )
354                    } else {
355                        format!("URL without angle brackets or link formatting: '{trimmed_url}'")
356                    },
357                    severity: Severity::Warning,
358                    fix: Some(Fix::new(
359                        {
360                            let line_start_byte = ctx.line_start_byte(line_number).unwrap_or(0);
361                            (line_start_byte + start)..(line_start_byte + start + trimmed_len)
362                        },
363                        replacement,
364                    )),
365                });
366            }
367        }
368
369        // Check for bare email addresses
370        for cap in EMAIL_PATTERN.captures_iter(line) {
371            if let Some(mat) = cap.get(0) {
372                let email = mat.as_str();
373                let start = mat.start();
374                let end = mat.end();
375
376                // Skip if email is part of an XMPP URI (xmpp:user@domain)
377                // Check character boundary to avoid panics with multi-byte UTF-8
378                if start >= 5 && line.is_char_boundary(start - 5) && &line[start - 5..start] == "xmpp:" {
379                    continue;
380                }
381
382                // Check if email is inside angle brackets or markdown link
383                let mut is_inside_construct = false;
384                for &(link_start, link_end) in &buffers.markdown_link_ranges {
385                    if start >= link_start && end <= link_end {
386                        is_inside_construct = true;
387                        break;
388                    }
389                }
390
391                if !is_inside_construct {
392                    // Calculate absolute byte position for context-aware checks
393                    let line_start_byte = ctx.line_start_byte(line_number).unwrap_or(0);
394                    let absolute_pos = line_start_byte + start;
395
396                    // Check if email is inside an HTML tag (handles multiline tags)
397                    if ctx.is_in_html_tag(absolute_pos) {
398                        continue;
399                    }
400
401                    // Check if email is a JSX component attribute value (e.g.
402                    // `<Contact email="..."/>`). No-op for non-JSX flavors.
403                    if ctx.is_in_jsx_component_tag(absolute_pos) {
404                        continue;
405                    }
406
407                    // Skip emails inside Pandoc line blocks or YAML metadata blocks.
408                    if ctx.flavor.is_pandoc_compatible()
409                        && (ctx.is_in_line_block(absolute_pos) || ctx.is_in_pandoc_metadata(absolute_pos))
410                    {
411                        continue;
412                    }
413
414                    // Check if email is inside a code span (byte offsets handle multi-line spans)
415                    let is_in_code_span = code_spans
416                        .iter()
417                        .any(|span| absolute_pos >= span.byte_offset && absolute_pos < span.byte_end);
418
419                    if !is_in_code_span {
420                        let email_len = end - start;
421                        let (start_line, start_col, end_line, end_col) =
422                            calculate_url_range(line_number, line, start, email_len);
423
424                        warnings.push(LintWarning {
425                            rule_name: Some("MD034".to_string()),
426                            line: start_line,
427                            column: start_col,
428                            end_line,
429                            end_column: end_col,
430                            message: if ctx.flavor == crate::config::MarkdownFlavor::MDG {
431                                format!(
432                                    "Email address without link formatting: '{email}' (angle brackets are Gherkin placeholder syntax; use explicit link formatting where appropriate, or disable MD034)"
433                                )
434                            } else {
435                                format!("Email address without angle brackets or link formatting: '{email}'")
436                            },
437                            severity: Severity::Warning,
438                            fix: Some(Fix::new(
439                                (line_start_byte + start)..(line_start_byte + end),
440                                format!("<{email}>"),
441                            )),
442                        });
443                    }
444                }
445            }
446        }
447
448        warnings
449    }
450}
451
452impl Rule for MD034NoBareUrls {
453    #[inline]
454    fn name(&self) -> &'static str {
455        "MD034"
456    }
457
458    fn as_any(&self) -> &dyn std::any::Any {
459        self
460    }
461
462    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
463    where
464        Self: Sized,
465    {
466        Box::new(MD034NoBareUrls)
467    }
468
469    #[inline]
470    fn category(&self) -> RuleCategory {
471        RuleCategory::Link
472    }
473
474    fn skippable_by_category(&self) -> bool {
475        // Bare email addresses and `xmpp:` URIs are MD034 findings, but the
476        // document-wide Link prefilter deliberately recognizes only Markdown
477        // links and common URL forms. Let MD034's own cheap `should_skip`
478        // predicate decide so email/XMPP-only documents are still checked.
479        false
480    }
481
482    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
483        !ctx.likely_has_links_or_images() && self.should_skip_content(ctx.content)
484    }
485
486    #[inline]
487    fn description(&self) -> &'static str {
488        "No bare URLs - wrap URLs in angle brackets"
489    }
490
491    fn check(&self, ctx: &LintContext) -> LintResult {
492        let mut warnings = Vec::new();
493        let content = ctx.content;
494
495        // Quick skip for content without URLs
496        if self.should_skip_content(content) {
497            return Ok(warnings);
498        }
499
500        // Get code spans for exclusion
501        let code_spans = ctx.code_spans();
502
503        // Reference-definition lines are detected by rumdl's shared parser (which
504        // understands blockquote-prefixed definitions and the full CommonMark
505        // grammar), so their destination URLs are not flagged as bare URLs.
506        let ref_def_lines: std::collections::HashSet<usize> =
507            ctx.reference_definitions().iter().map(|def| def.line).collect();
508
509        // Allocate reusable buffers once instead of per-line to reduce allocations
510        let mut buffers = LineCheckBuffers::default();
511
512        // Iterate over content lines, automatically skipping front matter, code blocks,
513        // and Obsidian comments (when in Obsidian flavor)
514        // This uses the filtered iterator API which centralizes the skip logic
515        for line in ctx
516            .filtered_lines()
517            .skip_front_matter()
518            .skip_code_blocks()
519            .skip_jsx_expressions()
520            .skip_mdx_comments()
521            .skip_obsidian_comments()
522        {
523            // A gh-aw control directive is template syntax, not Markdown prose.
524            // In particular, wrapping a runtime-import URL would corrupt the
525            // directive by making the closing braces part of the autolink.
526            if ctx.flavor == crate::config::MarkdownFlavor::GhAw && crate::utils::gh_aw::is_control_line(line.content) {
527                continue;
528            }
529
530            // Skip MyST colon-fence directive openers (`:::{name} <arg>`). The text
531            // after the directive name is an opaque argument (a URL, path, or label),
532            // not markdown prose, so a bare URL there must not be wrapped in angle
533            // brackets. Directive body lines are not openers, so they fall through to
534            // `check_line` and are linted as usual.
535            if ctx.is_myst_colon_directive_opener_line(line.line_num) {
536                continue;
537            }
538
539            // Skip reference-definition lines (`[id]: url`, including inside blockquotes).
540            if ref_def_lines.contains(&line.line_num) {
541                continue;
542            }
543
544            let mut line_warnings = self.check_line(line.content, ctx, line.line_num, &code_spans, &mut buffers);
545
546            // Filter out warnings that are inside code spans (handles multi-line spans via byte offsets)
547            line_warnings.retain(|warning| {
548                !code_spans.iter().any(|span| {
549                    if let Some(fix) = &warning.fix {
550                        // Byte-offset check handles both single-line and multi-line code spans
551                        fix.range.start >= span.byte_offset && fix.range.start < span.byte_end
552                    } else {
553                        span.line == warning.line
554                            && span.end_line == warning.line
555                            && warning.column > 0
556                            && (warning.column - 1) >= span.start_col
557                            && (warning.column - 1) < span.end_col
558                    }
559                })
560            });
561
562            line_warnings.retain(|warning| {
563                if let Some(fix) = &warning.fix {
564                    // Check if the fix range falls inside any parsed link's byte range
565                    !ctx.links().iter().any(|link| {
566                        !(link.is_reference && link.url.is_empty())
567                            && fix.range.start >= link.byte_offset
568                            && fix.range.end <= link.byte_end
569                    })
570                } else {
571                    true
572                }
573            });
574
575            // Filter out warnings where the URL is inside an Obsidian comment (%%...%%)
576            // This handles inline comments like: text %%https://hidden.com%% text
577            line_warnings.retain(|warning| !ctx.is_position_in_obsidian_comment(warning.line, warning.column));
578
579            warnings.extend(line_warnings);
580        }
581
582        // The generated ranges are needed by the filters above. Strip fixes only
583        // after filtering: under MDG `<...>` is placeholder syntax, so the
584        // standard automatic correction is not semantics-preserving.
585        if ctx.flavor == crate::config::MarkdownFlavor::MDG {
586            for warning in &mut warnings {
587                warning.fix = None;
588            }
589        }
590
591        Ok(warnings)
592    }
593
594    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
595        let mut content = ctx.content.to_string();
596        let warnings = self.check(ctx)?;
597        let mut warnings =
598            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
599
600        // Sort warnings by position to ensure consistent fix application
601        warnings.sort_by_key(|w| w.fix.as_ref().map_or(0, |f| f.range.start));
602
603        // Apply fixes in reverse order to maintain positions
604        for warning in warnings.iter().rev() {
605            if let Some(fix) = &warning.fix {
606                let start = fix.range.start;
607                let end = fix.range.end;
608                content.replace_range(start..end, &fix.replacement);
609            }
610        }
611
612        Ok(content)
613    }
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    #[test]
621    fn test_shortcut_ref_at_end_of_line_no_trailing_chars() {
622        let rule = MD034NoBareUrls;
623        let content = "See [https://example.com]";
624        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
625        let result = rule.check(&ctx).unwrap();
626        assert!(
627            result.is_empty(),
628            "[URL] at end of line should be treated as shortcut ref: {result:?}"
629        );
630    }
631
632    #[test]
633    fn test_shortcut_ref_multiple_spaces_before_paren() {
634        let rule = MD034NoBareUrls;
635        let content = "[text]  (https://example.com)";
636        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
637        let result = rule.check(&ctx).unwrap();
638        // [text]  (url) — the spaces between ] and ( mean this should be treated
639        // as shortcut ref then bare parens, NOT a markdown link. URL may still be bare.
640        // This test verifies consistent behavior with the FancyRegex that had (?!\s*[\[(])
641        let _ = result; // Just verify no panic; the exact warning count depends on other rules
642    }
643
644    #[test]
645    fn test_shortcut_ref_tab_before_bracket() {
646        let rule = MD034NoBareUrls;
647        let content = "[https://example.com]\t[other]";
648        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
649        let result = rule.check(&ctx).unwrap();
650        // Tab between ] and [ does not form a full reference link in Markdown.
651        // The first [URL] is a shortcut ref containing a bare URL, so MD034 warns.
652        // This test verifies consistent behavior and no panic with tab characters.
653        assert_eq!(
654            result.len(),
655            1,
656            "Bare URL inside shortcut ref should be detected: {result:?}"
657        );
658    }
659
660    #[test]
661    fn test_shortcut_ref_followed_by_punctuation() {
662        let rule = MD034NoBareUrls;
663        let content = "[https://example.com], see also other things.";
664        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
665        let result = rule.check(&ctx).unwrap();
666        assert!(
667            result.is_empty(),
668            "[URL] followed by comma should be treated as shortcut ref: {result:?}"
669        );
670    }
671
672    #[test]
673    fn test_url_in_backticks_inside_mdx_component_not_flagged() {
674        // Exact reproduction from issue #572: URL inside inline code within an MDX
675        // component body must not be flagged. The same URL in backticks outside the
676        // component is already handled correctly and serves as a control.
677        let rule = MD034NoBareUrls;
678        let content = "# Test\n\nControl: `https://rumdl.example.com/` is fine here.\n\n<ParamField path=\"--stuff\">\n  This URL `https://rumdl.example.com/` must not be flagged.\n</ParamField>\n";
679        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
680        let result = rule.check(&ctx).unwrap();
681        assert!(
682            result.is_empty(),
683            "URL in backticks inside MDX component must not be flagged: {result:?}"
684        );
685    }
686
687    #[test]
688    fn test_bare_url_inside_mdx_component_still_flagged() {
689        // A bare URL (not in backticks) inside an MDX component body must still be flagged.
690        // This ensures the fix for issue #572 only suppresses properly code-spanned URLs.
691        let rule = MD034NoBareUrls;
692        let content =
693            "# Test\n\n<ParamField path=\"--stuff\">\n  Visit https://rumdl.example.com/ for details.\n</ParamField>\n";
694        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
695        let result = rule.check(&ctx).unwrap();
696        assert_eq!(
697            result.len(),
698            1,
699            "Bare URL in MDX component body must still be flagged: {result:?}"
700        );
701    }
702
703    #[test]
704    fn test_url_in_backticks_inside_nested_mdx_component_not_flagged() {
705        // Nested MDX components must also respect code spans.
706        let rule = MD034NoBareUrls;
707        let content = "<Outer>\n  <Inner>\n    Check `https://example.com/` here.\n  </Inner>\n</Outer>\n";
708        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
709        let result = rule.check(&ctx).unwrap();
710        assert!(
711            result.is_empty(),
712            "URL in backticks inside nested MDX component must not be flagged: {result:?}"
713        );
714    }
715
716    /// Issue #678: a URL inside a fenced code block that is nested within a JSX/MDX
717    /// component (e.g. `<Steps><Step>`) is code, not bare prose. It must not be
718    /// flagged, and `fix` must not rewrite it (which would corrupt the command).
719    #[test]
720    fn test_url_in_fenced_code_block_inside_jsx_not_flagged() {
721        let rule = MD034NoBareUrls;
722        let content = "# Title\n\n<Steps>\n  <Step title=\"Send a request\">\n```bash\ncurl https://example.com/api\n```\n  </Step>\n</Steps>\n";
723        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
724        let result = rule.check(&ctx).unwrap();
725        assert!(
726            result.is_empty(),
727            "URL in a fenced code block nested in a JSX component must not be flagged: {result:?}"
728        );
729    }
730
731    /// The same code block must be left byte-for-byte intact by `fix` (no
732    /// `<https://...>` rewrite that breaks a copy-pasteable command).
733    #[test]
734    fn test_fix_does_not_rewrite_url_in_fenced_code_block_inside_jsx() {
735        let rule = MD034NoBareUrls;
736        let content = "# Title\n\n<Steps>\n  <Step title=\"Send a request\">\n```bash\ncurl https://example.com/api\n```\n  </Step>\n</Steps>\n";
737        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
738        let fixed = rule.fix(&ctx).unwrap();
739        assert_eq!(
740            fixed, content,
741            "fix must not rewrite a URL inside a JSX-nested fenced code block"
742        );
743    }
744
745    /// Control: a bare URL in the JSX *body* (outside any fence) is genuine prose
746    /// and must still be flagged, so the fence exemption is not over-broad.
747    #[test]
748    fn test_bare_url_in_jsx_body_outside_fence_still_flagged() {
749        let rule = MD034NoBareUrls;
750        let content = "# Title\n\n<Steps>\n  <Step title=\"Send a request\">\n  Visit https://example.com/api now.\n  </Step>\n</Steps>\n";
751        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
752        let result = rule.check(&ctx).unwrap();
753        assert_eq!(
754            result.len(),
755            1,
756            "A bare URL in the JSX body (not in a fence) must still be flagged: {result:?}"
757        );
758    }
759
760    /// A `<!--` inside a fenced code block is literal, not a comment opener, so it
761    /// must not pair with a later `-->` to form a comment range that masks a real
762    /// bare URL between them (the code-block counterpart to the code-span fix).
763    #[test]
764    fn test_bare_url_not_masked_by_comment_delimiter_in_code_block() {
765        let rule = MD034NoBareUrls;
766        let content =
767            "# T\n\n```text\n<!-- literal opener, not a comment\n```\n\nhttps://example.com should be flagged\n\n-->\n";
768        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
769        let result = rule.check(&ctx).unwrap();
770        assert_eq!(result.len(), 1, "the bare URL must still be flagged: {result:?}");
771        assert!(
772            result[0].message.contains("example.com"),
773            "the flagged URL must be the bare one: {result:?}"
774        );
775    }
776
777    /// Only *fenced* code blocks suppress `<!--`/`-->` as literal. A real HTML
778    /// comment indented inside a MkDocs admonition (which pulldown-cmark
779    /// misclassifies as an indented code block) must still be recognized as a
780    /// comment, so its bare URL stays skipped.
781    #[test]
782    fn test_bare_url_in_indented_comment_in_admonition_still_skipped() {
783        let rule = MD034NoBareUrls;
784        let content = "# T\n\n!!! note\n    Some text.\n\n    <!--\n    https://example.com\n    -->\n";
785        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
786        let result = rule.check(&ctx).unwrap();
787        assert!(
788            result.is_empty(),
789            "URL inside an indented HTML comment in an admonition must not be flagged: {result:?}"
790        );
791    }
792
793    /// Issue #649: a URL that is a JSX component attribute value (e.g. `href="..."`)
794    /// is a string prop, not bare prose. Wrapping it in angle brackets produces
795    /// invalid JSX, so MD034 must not flag it under the MDX flavor.
796    #[test]
797    fn test_url_in_jsx_component_attribute_not_flagged() {
798        let rule = MD034NoBareUrls;
799        let content = "<Card title=\"Docs\" href=\"https://example.com/docs\" />\n";
800        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
801        let result = rule.check(&ctx).unwrap();
802        assert!(
803            result.is_empty(),
804            "URL in a JSX component attribute must not be flagged: {result:?}"
805        );
806    }
807
808    /// The same exemption must apply when the JSX opening tag spans multiple lines.
809    #[test]
810    fn test_url_in_multiline_jsx_component_attribute_not_flagged() {
811        let rule = MD034NoBareUrls;
812        let content = "<Card\n  title=\"Docs\"\n  href=\"https://example.com/docs\"\n/>\n";
813        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
814        let result = rule.check(&ctx).unwrap();
815        assert!(
816            result.is_empty(),
817            "URL in a multi-line JSX component attribute must not be flagged: {result:?}"
818        );
819    }
820
821    /// The exemption is surgical: a URL in the component's *attributes* is skipped,
822    /// but a bare URL in the component's *body* is genuine prose and still flagged.
823    #[test]
824    fn test_jsx_attribute_url_skipped_but_body_url_flagged() {
825        let rule = MD034NoBareUrls;
826        let content = "<Card href=\"https://attr.example.com\">\n  Visit https://body.example.com now.\n</Card>\n";
827        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
828        let result = rule.check(&ctx).unwrap();
829        assert_eq!(
830            result.len(),
831            1,
832            "Only the body URL must be flagged, not the attribute URL: {result:?}"
833        );
834        assert!(
835            result[0].message.contains("body.example.com"),
836            "The flagged URL must be the body one: {result:?}"
837        );
838    }
839
840    /// The email path has the same JSX-attribute blind spot; an email used as a
841    /// JSX component attribute value must not be flagged either.
842    #[test]
843    fn test_email_in_jsx_component_attribute_not_flagged() {
844        let rule = MD034NoBareUrls;
845        let content = "<Contact email=\"hello@example.com\" />\n";
846        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
847        let result = rule.check(&ctx).unwrap();
848        assert!(
849            result.is_empty(),
850            "Email in a JSX component attribute must not be flagged: {result:?}"
851        );
852    }
853
854    /// Control: under the Standard flavor `<Card .../>` is parsed as an HTML tag,
855    /// so the attribute URL is already covered by the existing HTML-tag guard.
856    /// This locks in that the two flavors agree.
857    #[test]
858    fn test_jsx_attribute_url_not_flagged_in_standard_flavor() {
859        let rule = MD034NoBareUrls;
860        let content = "<Card href=\"https://example.com/docs\" />\n";
861        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
862        let result = rule.check(&ctx).unwrap();
863        assert!(
864            result.is_empty(),
865            "URL in a tag attribute must not be flagged under Standard flavor either: {result:?}"
866        );
867    }
868
869    /// URLs inside Pandoc line blocks (`| text`) must not be flagged as bare URLs.
870    #[test]
871    fn test_pandoc_skips_urls_in_line_blocks() {
872        use crate::config::MarkdownFlavor;
873        use crate::lint_context::LintContext;
874        let rule = MD034NoBareUrls;
875        let content = "| See https://example.com\n| For details\n";
876        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
877        let result = rule.check(&ctx).unwrap();
878        assert!(
879            result.is_empty(),
880            "MD034 should skip URLs in Pandoc line blocks: {result:?}"
881        );
882    }
883
884    /// URLs inside Pandoc YAML metadata blocks must not be flagged.
885    #[test]
886    fn test_pandoc_skips_urls_in_metadata() {
887        use crate::config::MarkdownFlavor;
888        use crate::lint_context::LintContext;
889        let rule = MD034NoBareUrls;
890        let content = "---\nhomepage: https://example.com\n---\n\nBody.\n";
891        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
892        let result = rule.check(&ctx).unwrap();
893        assert!(
894            result.is_empty(),
895            "MD034 should skip URLs in Pandoc YAML metadata: {result:?}"
896        );
897    }
898
899    /// Standard flavor must still flag bare URLs in lines starting with `|`
900    /// (which are not interpreted as line blocks).
901    #[test]
902    fn test_standard_still_flags_urls_in_pipe_prefixed_lines() {
903        use crate::config::MarkdownFlavor;
904        use crate::lint_context::LintContext;
905        let rule = MD034NoBareUrls;
906        let content = "| See https://example.com\n";
907        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
908        let result = rule.check(&ctx).unwrap();
909        assert!(
910            !result.is_empty(),
911            "MD034 should still flag URLs in pipe-prefixed lines under Standard flavor"
912        );
913    }
914
915    #[test]
916    fn test_url_in_backticks_after_fenced_code_block_inside_mdx_not_flagged() {
917        // A fenced code block inside a JSX component must not misalign the code-span
918        // offset map. The URL in backticks that appears *after* the code block must
919        // still be recognised as being inside a code span.
920        let rule = MD034NoBareUrls;
921        let content = "\
922<Component>
923Some intro text.
924
925```
926example code here
927```
928
929Check `https://example.com/` here.
930</Component>
931";
932        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
933        let result = rule.check(&ctx).unwrap();
934        assert!(
935            result.is_empty(),
936            "URL in backticks after a fenced code block inside MDX must not be flagged: {result:?}"
937        );
938    }
939
940    /// Issue #642: a URL given as the argument of a MyST colon-fence directive
941    /// (`:::{name} <url>`) is the directive's opaque argument, not markdown prose,
942    /// and must not be wrapped in angle brackets.
943    #[test]
944    fn test_myst_colon_directive_argument_url_not_flagged() {
945        use crate::config::MarkdownFlavor;
946        use crate::lint_context::LintContext;
947        let rule = MD034NoBareUrls;
948        let content = "\
949:::{anywidget} https://cdn.jsdelivr.net/npm/repo-review-webapp@1.1.3/dist/repo-review-anywidget.mjs
950{
951  \"deps\": [\"repo-review~=1.1.0\"]
952}
953:::
954";
955        let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
956        let result = rule.check(&ctx).unwrap();
957        assert!(
958            result.is_empty(),
959            "URL argument on a MyST colon directive opener must not be flagged: {result:?}"
960        );
961    }
962
963    /// A nested MyST colon directive opener also carries an opaque argument.
964    #[test]
965    fn test_myst_nested_colon_directive_argument_url_not_flagged() {
966        use crate::config::MarkdownFlavor;
967        use crate::lint_context::LintContext;
968        let rule = MD034NoBareUrls;
969        let content = "\
970::::{grid}
971:::{card} https://example.com/card-target
972Some caption.
973:::
974::::
975";
976        let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
977        let result = rule.check(&ctx).unwrap();
978        assert!(
979            result.is_empty(),
980            "URL argument on a nested MyST colon directive opener must not be flagged: {result:?}"
981        );
982    }
983
984    /// A bare URL in the *body* of a content directive (e.g. `{note}`) is genuine
985    /// prose and must still be flagged. The opener exemption must not leak to the body.
986    #[test]
987    fn test_myst_directive_body_url_still_flagged() {
988        use crate::config::MarkdownFlavor;
989        use crate::lint_context::LintContext;
990        let rule = MD034NoBareUrls;
991        let content = "\
992:::{note}
993See https://example.com/docs for more details.
994:::
995";
996        let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
997        let result = rule.check(&ctx).unwrap();
998        assert_eq!(
999            result.len(),
1000            1,
1001            "Bare URL in a MyST directive body must still be flagged: {result:?}"
1002        );
1003    }
1004
1005    /// An unclosed colon directive (no terminating `:::`) still has its opener
1006    /// argument treated as opaque: the URL must not be flagged.
1007    #[test]
1008    fn test_myst_unclosed_colon_directive_argument_url_not_flagged() {
1009        use crate::config::MarkdownFlavor;
1010        use crate::lint_context::LintContext;
1011        let rule = MD034NoBareUrls;
1012        let content = "\
1013:::{anywidget} https://example.com/widget.mjs
1014Some trailing content with no closing fence.
1015";
1016        let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
1017        let result = rule.check(&ctx).unwrap();
1018        assert!(
1019            result.is_empty(),
1020            "URL argument on an unclosed MyST colon directive opener must not be flagged: {result:?}"
1021        );
1022    }
1023
1024    /// The colon-directive exemption is MyST-specific: under the Standard flavor a
1025    /// `:::{...}` line is ordinary text and a bare URL on it must still be flagged.
1026    #[test]
1027    fn test_colon_directive_url_flagged_in_standard_flavor() {
1028        use crate::config::MarkdownFlavor;
1029        use crate::lint_context::LintContext;
1030        let rule = MD034NoBareUrls;
1031        let content = ":::{anywidget} https://example.com/widget.mjs\n";
1032        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1033        let result = rule.check(&ctx).unwrap();
1034        assert_eq!(
1035            result.len(),
1036            1,
1037            "Under Standard flavor a bare URL on a `:::` line must still be flagged: {result:?}"
1038        );
1039    }
1040
1041    #[test]
1042    fn test_md034_complex_link() {
1043        let rule = MD034NoBareUrls;
1044
1045        // Case 1: Balanced brackets in code span.
1046        // We should flag the bare URL at the end, but NOT the one inside the link.
1047        let content = "Check [link `code [with brackets]` text](http://example.com) and see http://bare.com.\n";
1048        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1049        let result = rule.check(&ctx).unwrap();
1050        assert_eq!(result.len(), 1, "Should flag exactly 1 URL (the bare one): {result:?}");
1051        assert!(result[0].message.contains("bare.com"));
1052
1053        // Case 2: Unbalanced brackets in code span.
1054        // We should flag the bare URL at the end, but NOT the one inside the link.
1055        let content2 = "Check [link `code [` text](http://example.com) and see http://bare.com.\n";
1056        let ctx2 = crate::lint_context::LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1057        let result2 = rule.check(&ctx2).unwrap();
1058        assert_eq!(
1059            result2.len(),
1060            1,
1061            "Should flag exactly 1 URL (the bare one): {result2:?}"
1062        );
1063        assert!(result2[0].message.contains("bare.com"));
1064    }
1065
1066    /// `<...>` is Gherkin placeholder syntax, substituted from `Examples` data. MD034
1067    /// still reports bare URLs under MDG, but withholds that unsafe automatic fix.
1068    #[test]
1069    fn test_mdg_reports_bare_urls_without_fixing_them() {
1070        let rule = MD034NoBareUrls;
1071        let content = "\
1072# Feature: Visit https://feature.example.com
1073
1074Prose about https://prose.example.com for background.
1075
1076## Scenario Outline: Open https://outline.example.com
1077
1078* Given I go to https://step.example.com
1079  | site                          |
1080  | https://datatable.example.com |
1081
1082> * Given I go to https://blockquoted.example.com
1083
10841. Given I go to https://ordered.example.com
1085
1086| url                            |
1087| ------------------------------ |
1088| https://unindented.example.com |
1089
1090### Examples:
1091
1092  | url                          |
1093  | ---------------------------- |
1094  | https://examples.example.com |
1095";
1096
1097        let standard_ctx =
1098            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1099        let standard_lines: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
1100        assert_eq!(
1101            standard_lines,
1102            vec![1, 3, 5, 7, 9, 11, 13, 17, 23],
1103            "Standard flavor flags every bare URL"
1104        );
1105
1106        let mdg_ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1107        assert!(!rule.should_skip(&mdg_ctx), "MDG must still run the diagnostic");
1108        let mdg = rule.check(&mdg_ctx).unwrap();
1109        assert_eq!(mdg.iter().map(|w| w.line).collect::<Vec<_>>(), standard_lines);
1110        assert!(mdg.iter().all(|warning| warning.fix.is_none()));
1111        assert!(
1112            mdg.iter()
1113                .all(|warning| warning.message.contains("Gherkin placeholder"))
1114        );
1115        assert!(mdg.iter().all(|warning| warning.message.contains("disable MD034")));
1116        assert_eq!(rule.fix(&mdg_ctx).unwrap(), content, "MDG must rewrite nothing");
1117    }
1118
1119    #[test]
1120    fn test_mdg_reports_bare_email_without_fixing_it() {
1121        let rule = MD034NoBareUrls;
1122        let content = "# Feature: Contact\n\n* Given I email user@example.com\n";
1123        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1124
1125        let warnings = rule.check(&ctx).unwrap();
1126        assert_eq!(warnings.len(), 1);
1127        assert!(warnings[0].message.contains("Gherkin placeholder"));
1128        assert!(warnings[0].message.contains("disable MD034"));
1129        assert!(warnings[0].fix.is_none());
1130        assert_eq!(rule.fix(&ctx).unwrap(), content);
1131    }
1132
1133    /// The exemption is confined to MDG: every other flavor still rewrites the same
1134    /// document, at every position.
1135    #[test]
1136    fn test_mdg_exemption_does_not_affect_other_flavors() {
1137        let rule = MD034NoBareUrls;
1138        let content = "\
1139# Feature: Visit https://feature.example.com
1140
1141Prose about https://prose.example.com for background.
1142
1143## Scenario Outline: Open https://outline.example.com
1144
1145* Given I go to https://step.example.com
1146  | site                          |
1147  | https://datatable.example.com |
1148
1149### Examples:
1150
1151  | url                          |
1152  | ---------------------------- |
1153  | https://examples.example.com |
1154";
1155        let expected = "\
1156# Feature: Visit <https://feature.example.com>
1157
1158Prose about <https://prose.example.com> for background.
1159
1160## Scenario Outline: Open <https://outline.example.com>
1161
1162* Given I go to <https://step.example.com>
1163  | site                          |
1164  | <https://datatable.example.com> |
1165
1166### Examples:
1167
1168  | url                          |
1169  | ---------------------------- |
1170  | <https://examples.example.com> |
1171";
1172
1173        for flavor in [
1174            crate::config::MarkdownFlavor::Standard,
1175            crate::config::MarkdownFlavor::MkDocs,
1176            crate::config::MarkdownFlavor::MyST,
1177        ] {
1178            let ctx = crate::lint_context::LintContext::new(content, flavor, None);
1179            assert!(!rule.should_skip(&ctx), "{flavor:?} must still run the rule");
1180            assert_eq!(rule.check(&ctx).unwrap().len(), 6, "{flavor:?} must flag all six URLs");
1181
1182            let fixed = rule.fix(&ctx).unwrap();
1183            assert_eq!(fixed, expected, "{flavor:?} must wrap all six URLs");
1184
1185            let fixed_ctx = crate::lint_context::LintContext::new(&fixed, flavor, None);
1186            assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1187            assert_eq!(
1188                rule.fix(&fixed_ctx).unwrap(),
1189                fixed,
1190                "{flavor:?} fix must be idempotent"
1191            );
1192        }
1193    }
1194}