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/// Characters that are active inside a Markdown link text and must be escaped so
42/// the text renders as the literal URL.
43///
44/// `{` and `}` are the MDX-specific members and the ones that matter most: an
45/// unescaped `{b}` is evaluated as a JSX expression instead of shown, and a lone
46/// brace is a hard MDX compile error, so omitting them would let the fix turn a
47/// building document into one that no longer compiles. `*` and `&` are the other
48/// two verified against the `@mdx-js/mdx` compiler: a `*` pair renders as emphasis
49/// and `&amp;` decodes to `&`.
50///
51/// The remainder are escaped to keep the helper total rather than because a URL can
52/// reach them today. ``< > [ ] \ ` `` all terminate the match in `URL_STANDARD_STR`
53/// and `EMAIL_PATTERN`, so they cannot appear in captured text; `_` and `~` can, and
54/// are inert under CommonMark alone but active under `remark-gfm`. Escaping is
55/// always safe here (an escaped punctuation character renders as itself), so the
56/// superset costs nothing and survives those patterns widening.
57const MDX_LINK_TEXT_ESCAPES: [char; 12] = ['\\', '`', '*', '_', '{', '}', '[', ']', '<', '>', '~', '&'];
58
59/// Escape a URL or email so it renders literally as the text of a Markdown link.
60fn escape_mdx_link_text(text: &str) -> String {
61    let mut escaped = String::with_capacity(text.len());
62    for ch in text.chars() {
63        if MDX_LINK_TEXT_ESCAPES.contains(&ch) {
64            escaped.push('\\');
65        }
66        escaped.push(ch);
67    }
68    escaped
69}
70
71/// Whether every parenthesis in the URL is matched.
72///
73/// A bare link destination may only contain balanced parentheses; an unmatched
74/// `(` makes CommonMark reject the link entirely and emit no anchor at all.
75fn has_balanced_parens(url: &str) -> bool {
76    let mut depth: i32 = 0;
77    for ch in url.chars() {
78        match ch {
79            '(' => depth += 1,
80            ')' => {
81                depth -= 1;
82                if depth < 0 {
83                    return false;
84                }
85            }
86            _ => {}
87        }
88    }
89    depth == 0
90}
91
92/// Build the replacement for a bare URL or email under a JSX-carrying flavor.
93///
94/// `<` opens JSX in MDX, so the autolink form `<https://example.com>` is a parse
95/// error and the "fixed" document stops compiling. The link form is what MDX's own
96/// error message recommends and it renders identically to the autolink it replaces.
97///
98/// `trim_trailing_punctuation` already removes unmatched closing parens, so only
99/// unmatched openers reach the destination here; those go in angle brackets, which
100/// carry no balancing requirement. No URL this rule captures can contain `<`, `>`
101/// or whitespace, so that wrapping is always safe.
102fn jsx_safe_link(text: &str, destination: &str) -> String {
103    let escaped = escape_mdx_link_text(text);
104    if has_balanced_parens(destination) {
105        format!("[{escaped}]({destination})")
106    } else {
107        format!("[{escaped}](<{destination}>)")
108    }
109}
110
111/// What the source text immediately before a span would bind to if the span became a
112/// `[text](destination)` link.
113///
114/// A link is self-contained only in isolation: its opening `[` binds leftwards. The
115/// autolink form opens with `<` and binds to nothing, so both hazards below belong to
116/// the link form alone and have to be handled where it is emitted.
117enum LinkPrefix {
118    /// Nothing before the span can bind to a `[`.
119    Free,
120    /// An active `!`, which would read the emitted link as an image instead.
121    ActiveBang,
122    /// An active `]`, which would read the emitted link text as that span's reference
123    /// label, resolving the anchor against an unrelated definition and leaving the
124    /// real destination behind as literal text.
125    ActiveCloseBracket,
126}
127
128/// Classify the character immediately before `start` in `line`.
129fn classify_link_prefix(line: &str, start: usize) -> LinkPrefix {
130    let before = &line[..start];
131    let Some(last) = before.chars().next_back() else {
132        return LinkPrefix::Free;
133    };
134    if last != '!' && last != ']' {
135        return LinkPrefix::Free;
136    }
137
138    // An odd run of backslashes escapes the character, leaving it literal text that
139    // binds to nothing. An even run escapes only itself, so the character stays active.
140    let preceding = &before[..before.len() - last.len_utf8()];
141    if preceding.bytes().rev().take_while(|&b| b == b'\\').count() % 2 == 1 {
142        return LinkPrefix::Free;
143    }
144
145    if last == '!' {
146        LinkPrefix::ActiveBang
147    } else {
148        LinkPrefix::ActiveCloseBracket
149    }
150}
151
152/// Build the JSX-flavor fix for the span at `start`, guarded against what precedes it.
153///
154/// Returns the byte offset within `line` that the replacement starts at, which is not
155/// always `start`, together with the replacement. `None` means no replacement is safe
156/// and the finding is reported without one.
157fn jsx_fix(line: &str, start: usize, text: &str, destination: &str) -> Option<(usize, String)> {
158    let link = jsx_safe_link(text, destination);
159    match classify_link_prefix(line, start) {
160        LinkPrefix::Free => Some((start, link)),
161        // Absorb the `!` into the replacement and escape it, so it renders as itself
162        // rather than opening an image. It is one byte, so the span grows by one.
163        LinkPrefix::ActiveBang => Some((start - 1, format!("\\!{link}"))),
164        // Escaping the `]` would break the span it closes, and no other spelling of the
165        // link avoids the reference-label reading, so leave the text to the author.
166        LinkPrefix::ActiveCloseBracket => None,
167    }
168}
169
170/// Whether the address at `start` is already carrying a URI scheme, as in
171/// `mailto:user@example.com` or `xmpp:user@example.com`.
172///
173/// `EMAIL_PATTERN` matches only the address part, so a schemed URI presents its tail as a
174/// bare email. Wrapping that tail alone would produce `mailto:[user@example.com](...)`,
175/// splitting the URI. GFM's autolink extension also declines to link an address whose
176/// preceding character is anything but whitespace or one of `*_~(`, so an address behind a
177/// scheme is not a link waiting to happen either way.
178///
179/// The scheme grammar is RFC 3986's: `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )`.
180fn follows_uri_scheme(line: &str, start: usize) -> bool {
181    let Some(before) = line[..start].strip_suffix(':') else {
182        return false;
183    };
184    let scheme: &str = {
185        let tail = before.len() - before.bytes().rev().take_while(|b| is_scheme_byte(*b)).count();
186        &before[tail..]
187    };
188    scheme.bytes().next().is_some_and(|b| b.is_ascii_alphabetic())
189}
190
191/// Whether a byte may appear in a URI scheme.
192fn is_scheme_byte(b: u8) -> bool {
193    b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.')
194}
195
196/// Reusable buffers for check_line to reduce allocations
197#[derive(Default)]
198struct LineCheckBuffers {
199    markdown_link_ranges: Vec<(usize, usize)>,
200    image_ranges: Vec<(usize, usize)>,
201    urls_found: Vec<(usize, usize, String)>,
202}
203
204#[derive(Default, Clone)]
205pub struct MD034NoBareUrls;
206
207impl MD034NoBareUrls {
208    #[inline]
209    pub fn should_skip_content(&self, content: &str) -> bool {
210        // Skip if content has no URLs, XMPP URIs, or email addresses
211        // Fast byte scanning for common URL/email/xmpp indicators
212        let bytes = content.as_bytes();
213        let has_colon = bytes.contains(&b':');
214        let has_at = bytes.contains(&b'@');
215        let has_www = content.contains("www.");
216        !has_colon && !has_at && !has_www
217    }
218
219    /// Remove trailing punctuation that is likely sentence punctuation, not part of the URL
220    fn trim_trailing_punctuation<'a>(&self, url: &'a str) -> &'a str {
221        let mut trimmed = url;
222
223        // Check for balanced parentheses - if we have unmatched closing parens, they're likely punctuation
224        let open_parens = url.chars().filter(|&c| c == '(').count();
225        let close_parens = url.chars().filter(|&c| c == ')').count();
226
227        if close_parens > open_parens {
228            // Find the last balanced closing paren position
229            let mut balance = 0;
230            let mut last_balanced_pos = url.len();
231
232            for (byte_idx, c) in url.char_indices() {
233                if c == '(' {
234                    balance += 1;
235                } else if c == ')' {
236                    balance -= 1;
237                    if balance < 0 {
238                        // Found an unmatched closing paren
239                        last_balanced_pos = byte_idx;
240                        break;
241                    }
242                }
243            }
244
245            trimmed = &trimmed[..last_balanced_pos];
246        }
247
248        // Trim specific punctuation only if not followed by more URL-like chars
249        while let Some(last_char) = trimmed.chars().last() {
250            if matches!(last_char, '.' | ',' | ';' | ':' | '!' | '?') {
251                // Check if this looks like it could be part of the URL
252                // For ':' specifically, keep it if followed by digits (port number)
253                if last_char == ':' && trimmed.len() > 1 {
254                    // Don't trim
255                    break;
256                }
257                trimmed = &trimmed[..trimmed.len() - 1];
258            } else {
259                break;
260            }
261        }
262
263        trimmed
264    }
265
266    fn check_line(
267        &self,
268        line: &str,
269        ctx: &LintContext,
270        line_number: usize,
271        code_spans: &[crate::lint_context::CodeSpan],
272        buffers: &mut LineCheckBuffers,
273    ) -> Vec<LintWarning> {
274        let mut warnings = Vec::new();
275
276        // Skip lines inside HTML blocks - URLs in HTML attributes should not be linted
277        if ctx.line_info(line_number).is_some_and(|info| info.in_html_block) {
278            return warnings;
279        }
280
281        // Skip lines that are continuations of multiline markdown links
282        // Pattern: text](url) without a leading [
283        if MULTILINE_LINK_CONTINUATION_REGEX.is_match(line) {
284            return warnings;
285        }
286
287        // Quick check - does this line potentially have a URL or email?
288        let has_quick_check = URL_QUICK_CHECK_REGEX.is_match(line);
289        let has_www = line.contains("www.");
290        let has_at = line.contains('@');
291
292        if !has_quick_check && !has_at && !has_www {
293            return warnings;
294        }
295
296        // Clear and reuse buffers instead of allocating new ones
297        buffers.markdown_link_ranges.clear();
298        buffers.image_ranges.clear();
299
300        let has_bracket = line.contains('[');
301        let has_angle = line.contains('<');
302        let has_bang = line.contains('!');
303
304        if has_bracket {
305            for mat in MARKDOWN_LINK_REGEX.find_iter(line) {
306                buffers.markdown_link_ranges.push((mat.start(), mat.end()));
307            }
308
309            // Also include empty link patterns like [text]() and [text][]
310            for mat in MARKDOWN_EMPTY_LINK_REGEX.find_iter(line) {
311                buffers.markdown_link_ranges.push((mat.start(), mat.end()));
312            }
313
314            for mat in MARKDOWN_EMPTY_REF_REGEX.find_iter(line) {
315                buffers.markdown_link_ranges.push((mat.start(), mat.end()));
316            }
317
318            // Also exclude shortcut reference links like [URL]
319            for mat in SHORTCUT_REF_REGEX.find_iter(line) {
320                let end = mat.end();
321                let next_non_ws = line[end..].bytes().find(|b| !b.is_ascii_whitespace());
322                if next_non_ws == Some(b'(') || next_non_ws == Some(b'[') {
323                    continue;
324                }
325                buffers.markdown_link_ranges.push((mat.start(), mat.end()));
326            }
327
328            // Check if this line contains only a badge link (common pattern)
329            if has_bang && BADGE_LINK_LINE_REGEX.is_match(line) {
330                return warnings;
331            }
332        }
333
334        if has_angle {
335            for mat in ANGLE_LINK_REGEX.find_iter(line) {
336                buffers.markdown_link_ranges.push((mat.start(), mat.end()));
337            }
338        }
339
340        // Find all markdown images for exclusion
341        if has_bang && has_bracket {
342            for mat in MARKDOWN_IMAGE_REGEX.find_iter(line) {
343                buffers.image_ranges.push((mat.start(), mat.end()));
344            }
345        }
346
347        // Find bare URLs
348        buffers.urls_found.clear();
349
350        // First, find IPv6 URLs (they need special handling)
351        for mat in URL_IPV6_REGEX.find_iter(line) {
352            let url_str = mat.as_str();
353            buffers.urls_found.push((mat.start(), mat.end(), url_str.to_string()));
354        }
355
356        // Then find regular URLs
357        for mat in URL_STANDARD_REGEX.find_iter(line) {
358            let url_str = mat.as_str();
359
360            // Skip if it's an IPv6 URL (already handled)
361            if url_str.contains("://[") {
362                continue;
363            }
364
365            // Skip malformed IPv6-like URLs
366            // Check for IPv6-like patterns that are malformed
367            if let Some(host_start) = url_str.find("://") {
368                let after_protocol = &url_str[host_start + 3..];
369                // If it looks like IPv6 (has :: or multiple :) but no brackets, skip if followed by ]
370                if after_protocol.contains("::") || after_protocol.chars().filter(|&c| c == ':').count() > 1 {
371                    // Check if the next byte after our match is ] (ASCII, so byte check is safe)
372                    if line.as_bytes().get(mat.end()) == Some(&b']') {
373                        // This is likely a malformed IPv6 URL like "https://::1]:8080"
374                        continue;
375                    }
376                }
377            }
378
379            buffers.urls_found.push((mat.start(), mat.end(), url_str.to_string()));
380        }
381
382        // Find www URLs without protocol (e.g., www.example.com)
383        for mat in URL_WWW_REGEX.find_iter(line) {
384            let url_str = mat.as_str();
385            let start_pos = mat.start();
386            let end_pos = mat.end();
387
388            // Skip if preceded by / or @ (likely part of a full URL)
389            if start_pos > 0 {
390                let prev_char = line.as_bytes().get(start_pos - 1).copied();
391                if prev_char == Some(b'/') || prev_char == Some(b'@') {
392                    continue;
393                }
394            }
395
396            // Skip if inside angle brackets (autolink syntax like <www.example.com>)
397            if start_pos > 0 && end_pos < line.len() {
398                let prev_char = line.as_bytes().get(start_pos - 1).copied();
399                let next_char = line.as_bytes().get(end_pos).copied();
400                if prev_char == Some(b'<') && next_char == Some(b'>') {
401                    continue;
402                }
403            }
404
405            buffers.urls_found.push((start_pos, end_pos, url_str.to_string()));
406        }
407
408        // Find XMPP URIs (GFM extended autolinks: xmpp:user@domain/resource)
409        for mat in XMPP_URI_REGEX.find_iter(line) {
410            let uri_str = mat.as_str();
411            let start_pos = mat.start();
412            let end_pos = mat.end();
413
414            // Skip if inside angle brackets (already properly formatted: <xmpp:user@domain>)
415            if start_pos > 0 && end_pos < line.len() {
416                let prev_char = line.as_bytes().get(start_pos - 1).copied();
417                let next_char = line.as_bytes().get(end_pos).copied();
418                if prev_char == Some(b'<') && next_char == Some(b'>') {
419                    continue;
420                }
421            }
422
423            buffers.urls_found.push((start_pos, end_pos, uri_str.to_string()));
424        }
425
426        // Process found URLs
427        for &(start, _end, ref url_str) in &buffers.urls_found {
428            // Skip custom protocols
429            if CUSTOM_PROTOCOL_REGEX.is_match(url_str) {
430                continue;
431            }
432
433            // Check if this URL is inside a markdown link, angle bracket, or image
434            // We check if the URL starts within a construct, not if it's entirely contained.
435            // This handles cases where URL detection may include trailing characters
436            // that extend past the construct boundary (e.g., parentheses).
437            // Linear scan is correct here because ranges can overlap/nest (e.g., [[1]](url))
438            let is_inside_construct = buffers
439                .markdown_link_ranges
440                .iter()
441                .any(|&(s, e)| start >= s && start < e)
442                || buffers.image_ranges.iter().any(|&(s, e)| start >= s && start < e);
443
444            if is_inside_construct {
445                continue;
446            }
447
448            // Calculate absolute byte position for context-aware checks
449            let line_start_byte = ctx.line_start_byte(line_number).unwrap_or(0);
450            let absolute_pos = line_start_byte + start;
451
452            // Check if URL is inside an HTML tag (handles multiline tags correctly)
453            if ctx.is_in_html_tag(absolute_pos) {
454                continue;
455            }
456
457            // Check if URL is a JSX component attribute value (e.g. `<Card href="..."/>`).
458            // These are string props, not bare prose; wrapping them in angle brackets
459            // would produce invalid JSX. No-op for non-JSX flavors.
460            if ctx.is_in_jsx_component_tag(absolute_pos) {
461                continue;
462            }
463
464            // Check if we're inside an HTML comment
465            if ctx.is_in_html_comment(absolute_pos) || ctx.is_in_mdx_comment(absolute_pos) {
466                continue;
467            }
468
469            // Check if we're inside a Hugo/Quarto shortcode
470            if ctx.is_in_shortcode(absolute_pos) {
471                continue;
472            }
473
474            // Skip URLs inside Pandoc line blocks (`| text`) or YAML metadata blocks.
475            // Both constructs treat their content as literal/structured text where bare
476            // URLs are intentional and should not be reformatted.
477            if ctx.flavor.is_pandoc_compatible()
478                && (ctx.is_in_line_block(absolute_pos) || ctx.is_in_pandoc_metadata(absolute_pos))
479            {
480                continue;
481            }
482
483            // Clean up the URL by removing trailing punctuation
484            let trimmed_url = self.trim_trailing_punctuation(url_str);
485
486            // Only report if we have a valid URL after trimming
487            if !trimmed_url.is_empty() && trimmed_url != "//" {
488                let trimmed_len = trimmed_url.len();
489                let (start_line, start_col, end_line, end_col) =
490                    calculate_url_range(line_number, line, start, trimmed_len);
491
492                // For www URLs without protocol, add https:// prefix in the fix
493                let destination = if trimmed_url.starts_with("www.") {
494                    format!("https://{trimmed_url}")
495                } else {
496                    trimmed_url.to_string()
497                };
498                let line_start_byte = ctx.line_start_byte(line_number).unwrap_or(0);
499                let span_end = line_start_byte + start + trimmed_len;
500                let fix = if ctx.flavor.supports_jsx() {
501                    jsx_fix(line, start, trimmed_url, &destination)
502                        .map(|(fix_start, replacement)| Fix::new((line_start_byte + fix_start)..span_end, replacement))
503                } else {
504                    Some(Fix::new(
505                        (line_start_byte + start)..span_end,
506                        format!("<{destination}>"),
507                    ))
508                };
509
510                warnings.push(LintWarning {
511                    rule_name: Some("MD034".to_string()),
512                    line: start_line,
513                    column: start_col,
514                    end_line,
515                    end_column: end_col,
516                    message: if ctx.flavor == crate::config::MarkdownFlavor::MDG {
517                        format!(
518                            "URL without link formatting: '{trimmed_url}' (angle brackets are Gherkin placeholder syntax; use explicit link formatting where appropriate, or disable MD034)"
519                        )
520                    } else {
521                        format!("URL without angle brackets or link formatting: '{trimmed_url}'")
522                    },
523                    severity: Severity::Warning,
524                    fix,
525                });
526            }
527        }
528
529        // Check for bare email addresses
530        for cap in EMAIL_PATTERN.captures_iter(line) {
531            if let Some(mat) = cap.get(0) {
532                let email = mat.as_str();
533                let start = mat.start();
534                let end = mat.end();
535
536                // Skip an address that is the tail of a schemed URI (xmpp:, mailto:, ...);
537                // the scheme is outside the match, so wrapping the tail would split the URI.
538                if follows_uri_scheme(line, start) {
539                    continue;
540                }
541
542                // Check if email is inside angle brackets or markdown link
543                let mut is_inside_construct = false;
544                for &(link_start, link_end) in &buffers.markdown_link_ranges {
545                    if start >= link_start && end <= link_end {
546                        is_inside_construct = true;
547                        break;
548                    }
549                }
550
551                if !is_inside_construct {
552                    // Calculate absolute byte position for context-aware checks
553                    let line_start_byte = ctx.line_start_byte(line_number).unwrap_or(0);
554                    let absolute_pos = line_start_byte + start;
555
556                    // Check if email is inside an HTML tag (handles multiline tags)
557                    if ctx.is_in_html_tag(absolute_pos) {
558                        continue;
559                    }
560
561                    // Check if email is a JSX component attribute value (e.g.
562                    // `<Contact email="..."/>`). No-op for non-JSX flavors.
563                    if ctx.is_in_jsx_component_tag(absolute_pos) {
564                        continue;
565                    }
566
567                    // Skip emails inside Pandoc line blocks or YAML metadata blocks.
568                    if ctx.flavor.is_pandoc_compatible()
569                        && (ctx.is_in_line_block(absolute_pos) || ctx.is_in_pandoc_metadata(absolute_pos))
570                    {
571                        continue;
572                    }
573
574                    // Check if email is inside a code span (byte offsets handle multi-line spans)
575                    let is_in_code_span = code_spans
576                        .iter()
577                        .any(|span| absolute_pos >= span.byte_offset && absolute_pos < span.byte_end);
578
579                    if !is_in_code_span {
580                        let email_len = end - start;
581                        let (start_line, start_col, end_line, end_col) =
582                            calculate_url_range(line_number, line, start, email_len);
583
584                        let fix = if ctx.flavor.supports_jsx() {
585                            jsx_fix(line, start, email, &format!("mailto:{email}")).map(|(fix_start, replacement)| {
586                                Fix::new((line_start_byte + fix_start)..(line_start_byte + end), replacement)
587                            })
588                        } else {
589                            Some(Fix::new(
590                                (line_start_byte + start)..(line_start_byte + end),
591                                format!("<{email}>"),
592                            ))
593                        };
594
595                        warnings.push(LintWarning {
596                            rule_name: Some("MD034".to_string()),
597                            line: start_line,
598                            column: start_col,
599                            end_line,
600                            end_column: end_col,
601                            message: if ctx.flavor == crate::config::MarkdownFlavor::MDG {
602                                format!(
603                                    "Email address without link formatting: '{email}' (angle brackets are Gherkin placeholder syntax; use explicit link formatting where appropriate, or disable MD034)"
604                                )
605                            } else {
606                                format!("Email address without angle brackets or link formatting: '{email}'")
607                            },
608                            severity: Severity::Warning,
609                            fix,
610                        });
611                    }
612                }
613            }
614        }
615
616        warnings
617    }
618}
619
620impl Rule for MD034NoBareUrls {
621    #[inline]
622    fn name(&self) -> &'static str {
623        "MD034"
624    }
625
626    fn as_any(&self) -> &dyn std::any::Any {
627        self
628    }
629
630    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
631    where
632        Self: Sized,
633    {
634        Box::new(MD034NoBareUrls)
635    }
636
637    #[inline]
638    fn category(&self) -> RuleCategory {
639        RuleCategory::Link
640    }
641
642    fn skippable_by_category(&self) -> bool {
643        // Bare email addresses and `xmpp:` URIs are MD034 findings, but the
644        // document-wide Link prefilter deliberately recognizes only Markdown
645        // links and common URL forms. Let MD034's own cheap `should_skip`
646        // predicate decide so email/XMPP-only documents are still checked.
647        false
648    }
649
650    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
651        !ctx.likely_has_links_or_images() && self.should_skip_content(ctx.content)
652    }
653
654    #[inline]
655    fn description(&self) -> &'static str {
656        "No bare URLs - wrap URLs in angle brackets"
657    }
658
659    fn check(&self, ctx: &LintContext) -> LintResult {
660        let mut warnings = Vec::new();
661        let content = ctx.content;
662
663        // Quick skip for content without URLs
664        if self.should_skip_content(content) {
665            return Ok(warnings);
666        }
667
668        // Get code spans for exclusion
669        let code_spans = ctx.code_spans();
670
671        // Reference-definition lines are detected by rumdl's shared parser (which
672        // understands blockquote-prefixed definitions and the full CommonMark
673        // grammar), so their destination URLs are not flagged as bare URLs.
674        let ref_def_lines: std::collections::HashSet<usize> =
675            ctx.reference_definitions().iter().map(|def| def.line).collect();
676
677        // Allocate reusable buffers once instead of per-line to reduce allocations
678        let mut buffers = LineCheckBuffers::default();
679
680        // Iterate over content lines, automatically skipping front matter, code blocks,
681        // and Obsidian comments (when in Obsidian flavor)
682        // This uses the filtered iterator API which centralizes the skip logic
683        for line in ctx
684            .filtered_lines()
685            .skip_front_matter()
686            .skip_code_blocks()
687            .skip_jsx_expressions()
688            .skip_mdx_comments()
689            .skip_obsidian_comments()
690        {
691            // A gh-aw control directive is template syntax, not Markdown prose.
692            // In particular, wrapping a runtime-import URL would corrupt the
693            // directive by making the closing braces part of the autolink.
694            if ctx.flavor == crate::config::MarkdownFlavor::GhAw && crate::utils::gh_aw::is_control_line(line.content) {
695                continue;
696            }
697
698            // Skip MyST colon-fence directive openers (`:::{name} <arg>`). The text
699            // after the directive name is an opaque argument (a URL, path, or label),
700            // not markdown prose, so a bare URL there must not be wrapped in angle
701            // brackets. Directive body lines are not openers, so they fall through to
702            // `check_line` and are linted as usual.
703            if ctx.is_myst_colon_directive_opener_line(line.line_num) {
704                continue;
705            }
706
707            // Skip reference-definition lines (`[id]: url`, including inside blockquotes).
708            if ref_def_lines.contains(&line.line_num) {
709                continue;
710            }
711
712            let mut line_warnings = self.check_line(line.content, ctx, line.line_num, &code_spans, &mut buffers);
713
714            // Filter out warnings that are inside code spans (handles multi-line spans via byte offsets)
715            line_warnings.retain(|warning| {
716                !code_spans.iter().any(|span| {
717                    if let Some(fix) = &warning.fix {
718                        // Byte-offset check handles both single-line and multi-line code spans
719                        fix.range.start >= span.byte_offset && fix.range.start < span.byte_end
720                    } else {
721                        span.line == warning.line
722                            && span.end_line == warning.line
723                            && warning.column > 0
724                            && (warning.column - 1) >= span.start_col
725                            && (warning.column - 1) < span.end_col
726                    }
727                })
728            });
729
730            line_warnings.retain(|warning| {
731                if let Some(fix) = &warning.fix {
732                    // Check if the fix range falls inside any parsed link's byte range
733                    !ctx.links().iter().any(|link| {
734                        !(link.is_reference && link.url.is_empty())
735                            && fix.range.start >= link.byte_offset
736                            && fix.range.end <= link.byte_end
737                    })
738                } else {
739                    true
740                }
741            });
742
743            // Filter out warnings where the URL is inside an Obsidian comment (%%...%%)
744            // This handles inline comments like: text %%https://hidden.com%% text
745            line_warnings.retain(|warning| !ctx.is_position_in_obsidian_comment(warning.line, warning.column));
746
747            warnings.extend(line_warnings);
748        }
749
750        // The generated ranges are needed by the filters above. Strip fixes only
751        // after filtering: under MDG `<...>` is placeholder syntax, so the
752        // standard automatic correction is not semantics-preserving.
753        if ctx.flavor == crate::config::MarkdownFlavor::MDG {
754            for warning in &mut warnings {
755                warning.fix = None;
756            }
757        }
758
759        Ok(warnings)
760    }
761
762    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
763        let mut content = ctx.content.to_string();
764        let warnings = self.check(ctx)?;
765        let mut warnings =
766            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
767
768        // Sort warnings by position to ensure consistent fix application
769        warnings.sort_by_key(|w| w.fix.as_ref().map_or(0, |f| f.range.start));
770
771        // Apply fixes in reverse order to maintain positions
772        for warning in warnings.iter().rev() {
773            if let Some(fix) = &warning.fix {
774                let start = fix.range.start;
775                let end = fix.range.end;
776                content.replace_range(start..end, &fix.replacement);
777            }
778        }
779
780        Ok(content)
781    }
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787
788    #[test]
789    fn test_shortcut_ref_at_end_of_line_no_trailing_chars() {
790        let rule = MD034NoBareUrls;
791        let content = "See [https://example.com]";
792        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
793        let result = rule.check(&ctx).unwrap();
794        assert!(
795            result.is_empty(),
796            "[URL] at end of line should be treated as shortcut ref: {result:?}"
797        );
798    }
799
800    #[test]
801    fn test_shortcut_ref_multiple_spaces_before_paren() {
802        let rule = MD034NoBareUrls;
803        let content = "[text]  (https://example.com)";
804        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
805        let result = rule.check(&ctx).unwrap();
806        // [text]  (url) — the spaces between ] and ( mean this should be treated
807        // as shortcut ref then bare parens, NOT a markdown link. URL may still be bare.
808        // This test verifies consistent behavior with the FancyRegex that had (?!\s*[\[(])
809        let _ = result; // Just verify no panic; the exact warning count depends on other rules
810    }
811
812    #[test]
813    fn test_shortcut_ref_tab_before_bracket() {
814        let rule = MD034NoBareUrls;
815        let content = "[https://example.com]\t[other]";
816        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
817        let result = rule.check(&ctx).unwrap();
818        // Tab between ] and [ does not form a full reference link in Markdown.
819        // The first [URL] is a shortcut ref containing a bare URL, so MD034 warns.
820        // This test verifies consistent behavior and no panic with tab characters.
821        assert_eq!(
822            result.len(),
823            1,
824            "Bare URL inside shortcut ref should be detected: {result:?}"
825        );
826    }
827
828    #[test]
829    fn test_shortcut_ref_followed_by_punctuation() {
830        let rule = MD034NoBareUrls;
831        let content = "[https://example.com], see also other things.";
832        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
833        let result = rule.check(&ctx).unwrap();
834        assert!(
835            result.is_empty(),
836            "[URL] followed by comma should be treated as shortcut ref: {result:?}"
837        );
838    }
839
840    #[test]
841    fn test_url_in_backticks_inside_mdx_component_not_flagged() {
842        // Exact reproduction from issue #572: URL inside inline code within an MDX
843        // component body must not be flagged. The same URL in backticks outside the
844        // component is already handled correctly and serves as a control.
845        let rule = MD034NoBareUrls;
846        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";
847        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
848        let result = rule.check(&ctx).unwrap();
849        assert!(
850            result.is_empty(),
851            "URL in backticks inside MDX component must not be flagged: {result:?}"
852        );
853    }
854
855    #[test]
856    fn test_bare_url_inside_mdx_component_still_flagged() {
857        // A bare URL (not in backticks) inside an MDX component body must still be flagged.
858        // This ensures the fix for issue #572 only suppresses properly code-spanned URLs.
859        let rule = MD034NoBareUrls;
860        let content =
861            "# Test\n\n<ParamField path=\"--stuff\">\n  Visit https://rumdl.example.com/ for details.\n</ParamField>\n";
862        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
863        let result = rule.check(&ctx).unwrap();
864        assert_eq!(
865            result.len(),
866            1,
867            "Bare URL in MDX component body must still be flagged: {result:?}"
868        );
869    }
870
871    #[test]
872    fn test_url_in_backticks_inside_nested_mdx_component_not_flagged() {
873        // Nested MDX components must also respect code spans.
874        let rule = MD034NoBareUrls;
875        let content = "<Outer>\n  <Inner>\n    Check `https://example.com/` here.\n  </Inner>\n</Outer>\n";
876        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
877        let result = rule.check(&ctx).unwrap();
878        assert!(
879            result.is_empty(),
880            "URL in backticks inside nested MDX component must not be flagged: {result:?}"
881        );
882    }
883
884    /// Issue #678: a URL inside a fenced code block that is nested within a JSX/MDX
885    /// component (e.g. `<Steps><Step>`) is code, not bare prose. It must not be
886    /// flagged, and `fix` must not rewrite it (which would corrupt the command).
887    #[test]
888    fn test_url_in_fenced_code_block_inside_jsx_not_flagged() {
889        let rule = MD034NoBareUrls;
890        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";
891        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
892        let result = rule.check(&ctx).unwrap();
893        assert!(
894            result.is_empty(),
895            "URL in a fenced code block nested in a JSX component must not be flagged: {result:?}"
896        );
897    }
898
899    /// The same code block must be left byte-for-byte intact by `fix` (no
900    /// `<https://...>` rewrite that breaks a copy-pasteable command).
901    #[test]
902    fn test_fix_does_not_rewrite_url_in_fenced_code_block_inside_jsx() {
903        let rule = MD034NoBareUrls;
904        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";
905        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
906        let fixed = rule.fix(&ctx).unwrap();
907        assert_eq!(
908            fixed, content,
909            "fix must not rewrite a URL inside a JSX-nested fenced code block"
910        );
911    }
912
913    /// Control: a bare URL in the JSX *body* (outside any fence) is genuine prose
914    /// and must still be flagged, so the fence exemption is not over-broad.
915    #[test]
916    fn test_bare_url_in_jsx_body_outside_fence_still_flagged() {
917        let rule = MD034NoBareUrls;
918        let content = "# Title\n\n<Steps>\n  <Step title=\"Send a request\">\n  Visit https://example.com/api now.\n  </Step>\n</Steps>\n";
919        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
920        let result = rule.check(&ctx).unwrap();
921        assert_eq!(
922            result.len(),
923            1,
924            "A bare URL in the JSX body (not in a fence) must still be flagged: {result:?}"
925        );
926    }
927
928    /// A `<!--` inside a fenced code block is literal, not a comment opener, so it
929    /// must not pair with a later `-->` to form a comment range that masks a real
930    /// bare URL between them (the code-block counterpart to the code-span fix).
931    #[test]
932    fn test_bare_url_not_masked_by_comment_delimiter_in_code_block() {
933        let rule = MD034NoBareUrls;
934        let content =
935            "# T\n\n```text\n<!-- literal opener, not a comment\n```\n\nhttps://example.com should be flagged\n\n-->\n";
936        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
937        let result = rule.check(&ctx).unwrap();
938        assert_eq!(result.len(), 1, "the bare URL must still be flagged: {result:?}");
939        assert!(
940            result[0].message.contains("example.com"),
941            "the flagged URL must be the bare one: {result:?}"
942        );
943    }
944
945    /// Only *fenced* code blocks suppress `<!--`/`-->` as literal. A real HTML
946    /// comment indented inside a MkDocs admonition (which pulldown-cmark
947    /// misclassifies as an indented code block) must still be recognized as a
948    /// comment, so its bare URL stays skipped.
949    #[test]
950    fn test_bare_url_in_indented_comment_in_admonition_still_skipped() {
951        let rule = MD034NoBareUrls;
952        let content = "# T\n\n!!! note\n    Some text.\n\n    <!--\n    https://example.com\n    -->\n";
953        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
954        let result = rule.check(&ctx).unwrap();
955        assert!(
956            result.is_empty(),
957            "URL inside an indented HTML comment in an admonition must not be flagged: {result:?}"
958        );
959    }
960
961    /// Issue #649: a URL that is a JSX component attribute value (e.g. `href="..."`)
962    /// is a string prop, not bare prose. Wrapping it in angle brackets produces
963    /// invalid JSX, so MD034 must not flag it under the MDX flavor.
964    #[test]
965    fn test_url_in_jsx_component_attribute_not_flagged() {
966        let rule = MD034NoBareUrls;
967        let content = "<Card title=\"Docs\" href=\"https://example.com/docs\" />\n";
968        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
969        let result = rule.check(&ctx).unwrap();
970        assert!(
971            result.is_empty(),
972            "URL in a JSX component attribute must not be flagged: {result:?}"
973        );
974    }
975
976    /// The same exemption must apply when the JSX opening tag spans multiple lines.
977    #[test]
978    fn test_url_in_multiline_jsx_component_attribute_not_flagged() {
979        let rule = MD034NoBareUrls;
980        let content = "<Card\n  title=\"Docs\"\n  href=\"https://example.com/docs\"\n/>\n";
981        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
982        let result = rule.check(&ctx).unwrap();
983        assert!(
984            result.is_empty(),
985            "URL in a multi-line JSX component attribute must not be flagged: {result:?}"
986        );
987    }
988
989    /// The exemption is surgical: a URL in the component's *attributes* is skipped,
990    /// but a bare URL in the component's *body* is genuine prose and still flagged.
991    #[test]
992    fn test_jsx_attribute_url_skipped_but_body_url_flagged() {
993        let rule = MD034NoBareUrls;
994        let content = "<Card href=\"https://attr.example.com\">\n  Visit https://body.example.com now.\n</Card>\n";
995        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
996        let result = rule.check(&ctx).unwrap();
997        assert_eq!(
998            result.len(),
999            1,
1000            "Only the body URL must be flagged, not the attribute URL: {result:?}"
1001        );
1002        assert!(
1003            result[0].message.contains("body.example.com"),
1004            "The flagged URL must be the body one: {result:?}"
1005        );
1006    }
1007
1008    /// The email path has the same JSX-attribute blind spot; an email used as a
1009    /// JSX component attribute value must not be flagged either.
1010    #[test]
1011    fn test_email_in_jsx_component_attribute_not_flagged() {
1012        let rule = MD034NoBareUrls;
1013        let content = "<Contact email=\"hello@example.com\" />\n";
1014        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1015        let result = rule.check(&ctx).unwrap();
1016        assert!(
1017            result.is_empty(),
1018            "Email in a JSX component attribute must not be flagged: {result:?}"
1019        );
1020    }
1021
1022    /// Control: under the Standard flavor `<Card .../>` is parsed as an HTML tag,
1023    /// so the attribute URL is already covered by the existing HTML-tag guard.
1024    /// This locks in that the two flavors agree.
1025    #[test]
1026    fn test_jsx_attribute_url_not_flagged_in_standard_flavor() {
1027        let rule = MD034NoBareUrls;
1028        let content = "<Card href=\"https://example.com/docs\" />\n";
1029        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1030        let result = rule.check(&ctx).unwrap();
1031        assert!(
1032            result.is_empty(),
1033            "URL in a tag attribute must not be flagged under Standard flavor either: {result:?}"
1034        );
1035    }
1036
1037    /// URLs inside Pandoc line blocks (`| text`) must not be flagged as bare URLs.
1038    #[test]
1039    fn test_pandoc_skips_urls_in_line_blocks() {
1040        use crate::config::MarkdownFlavor;
1041        use crate::lint_context::LintContext;
1042        let rule = MD034NoBareUrls;
1043        let content = "| See https://example.com\n| For details\n";
1044        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1045        let result = rule.check(&ctx).unwrap();
1046        assert!(
1047            result.is_empty(),
1048            "MD034 should skip URLs in Pandoc line blocks: {result:?}"
1049        );
1050    }
1051
1052    /// URLs inside Pandoc YAML metadata blocks must not be flagged.
1053    #[test]
1054    fn test_pandoc_skips_urls_in_metadata() {
1055        use crate::config::MarkdownFlavor;
1056        use crate::lint_context::LintContext;
1057        let rule = MD034NoBareUrls;
1058        let content = "---\nhomepage: https://example.com\n---\n\nBody.\n";
1059        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1060        let result = rule.check(&ctx).unwrap();
1061        assert!(
1062            result.is_empty(),
1063            "MD034 should skip URLs in Pandoc YAML metadata: {result:?}"
1064        );
1065    }
1066
1067    /// Standard flavor must still flag bare URLs in lines starting with `|`
1068    /// (which are not interpreted as line blocks).
1069    #[test]
1070    fn test_standard_still_flags_urls_in_pipe_prefixed_lines() {
1071        use crate::config::MarkdownFlavor;
1072        use crate::lint_context::LintContext;
1073        let rule = MD034NoBareUrls;
1074        let content = "| See https://example.com\n";
1075        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1076        let result = rule.check(&ctx).unwrap();
1077        assert!(
1078            !result.is_empty(),
1079            "MD034 should still flag URLs in pipe-prefixed lines under Standard flavor"
1080        );
1081    }
1082
1083    #[test]
1084    fn test_url_in_backticks_after_fenced_code_block_inside_mdx_not_flagged() {
1085        // A fenced code block inside a JSX component must not misalign the code-span
1086        // offset map. The URL in backticks that appears *after* the code block must
1087        // still be recognised as being inside a code span.
1088        let rule = MD034NoBareUrls;
1089        let content = "\
1090<Component>
1091Some intro text.
1092
1093```
1094example code here
1095```
1096
1097Check `https://example.com/` here.
1098</Component>
1099";
1100        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1101        let result = rule.check(&ctx).unwrap();
1102        assert!(
1103            result.is_empty(),
1104            "URL in backticks after a fenced code block inside MDX must not be flagged: {result:?}"
1105        );
1106    }
1107
1108    /// Issue #642: a URL given as the argument of a MyST colon-fence directive
1109    /// (`:::{name} <url>`) is the directive's opaque argument, not markdown prose,
1110    /// and must not be wrapped in angle brackets.
1111    #[test]
1112    fn test_myst_colon_directive_argument_url_not_flagged() {
1113        use crate::config::MarkdownFlavor;
1114        use crate::lint_context::LintContext;
1115        let rule = MD034NoBareUrls;
1116        let content = "\
1117:::{anywidget} https://cdn.jsdelivr.net/npm/repo-review-webapp@1.1.3/dist/repo-review-anywidget.mjs
1118{
1119  \"deps\": [\"repo-review~=1.1.0\"]
1120}
1121:::
1122";
1123        let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
1124        let result = rule.check(&ctx).unwrap();
1125        assert!(
1126            result.is_empty(),
1127            "URL argument on a MyST colon directive opener must not be flagged: {result:?}"
1128        );
1129    }
1130
1131    /// A nested MyST colon directive opener also carries an opaque argument.
1132    #[test]
1133    fn test_myst_nested_colon_directive_argument_url_not_flagged() {
1134        use crate::config::MarkdownFlavor;
1135        use crate::lint_context::LintContext;
1136        let rule = MD034NoBareUrls;
1137        let content = "\
1138::::{grid}
1139:::{card} https://example.com/card-target
1140Some caption.
1141:::
1142::::
1143";
1144        let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
1145        let result = rule.check(&ctx).unwrap();
1146        assert!(
1147            result.is_empty(),
1148            "URL argument on a nested MyST colon directive opener must not be flagged: {result:?}"
1149        );
1150    }
1151
1152    /// A bare URL in the *body* of a content directive (e.g. `{note}`) is genuine
1153    /// prose and must still be flagged. The opener exemption must not leak to the body.
1154    #[test]
1155    fn test_myst_directive_body_url_still_flagged() {
1156        use crate::config::MarkdownFlavor;
1157        use crate::lint_context::LintContext;
1158        let rule = MD034NoBareUrls;
1159        let content = "\
1160:::{note}
1161See https://example.com/docs for more details.
1162:::
1163";
1164        let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
1165        let result = rule.check(&ctx).unwrap();
1166        assert_eq!(
1167            result.len(),
1168            1,
1169            "Bare URL in a MyST directive body must still be flagged: {result:?}"
1170        );
1171    }
1172
1173    /// An unclosed colon directive (no terminating `:::`) still has its opener
1174    /// argument treated as opaque: the URL must not be flagged.
1175    #[test]
1176    fn test_myst_unclosed_colon_directive_argument_url_not_flagged() {
1177        use crate::config::MarkdownFlavor;
1178        use crate::lint_context::LintContext;
1179        let rule = MD034NoBareUrls;
1180        let content = "\
1181:::{anywidget} https://example.com/widget.mjs
1182Some trailing content with no closing fence.
1183";
1184        let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
1185        let result = rule.check(&ctx).unwrap();
1186        assert!(
1187            result.is_empty(),
1188            "URL argument on an unclosed MyST colon directive opener must not be flagged: {result:?}"
1189        );
1190    }
1191
1192    /// The colon-directive exemption is MyST-specific: under the Standard flavor a
1193    /// `:::{...}` line is ordinary text and a bare URL on it must still be flagged.
1194    #[test]
1195    fn test_colon_directive_url_flagged_in_standard_flavor() {
1196        use crate::config::MarkdownFlavor;
1197        use crate::lint_context::LintContext;
1198        let rule = MD034NoBareUrls;
1199        let content = ":::{anywidget} https://example.com/widget.mjs\n";
1200        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1201        let result = rule.check(&ctx).unwrap();
1202        assert_eq!(
1203            result.len(),
1204            1,
1205            "Under Standard flavor a bare URL on a `:::` line must still be flagged: {result:?}"
1206        );
1207    }
1208
1209    #[test]
1210    fn test_md034_complex_link() {
1211        let rule = MD034NoBareUrls;
1212
1213        // Case 1: Balanced brackets in code span.
1214        // We should flag the bare URL at the end, but NOT the one inside the link.
1215        let content = "Check [link `code [with brackets]` text](http://example.com) and see http://bare.com.\n";
1216        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1217        let result = rule.check(&ctx).unwrap();
1218        assert_eq!(result.len(), 1, "Should flag exactly 1 URL (the bare one): {result:?}");
1219        assert!(result[0].message.contains("bare.com"));
1220
1221        // Case 2: Unbalanced brackets in code span.
1222        // We should flag the bare URL at the end, but NOT the one inside the link.
1223        let content2 = "Check [link `code [` text](http://example.com) and see http://bare.com.\n";
1224        let ctx2 = crate::lint_context::LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1225        let result2 = rule.check(&ctx2).unwrap();
1226        assert_eq!(
1227            result2.len(),
1228            1,
1229            "Should flag exactly 1 URL (the bare one): {result2:?}"
1230        );
1231        assert!(result2[0].message.contains("bare.com"));
1232    }
1233
1234    /// `<...>` is Gherkin placeholder syntax, substituted from `Examples` data. MD034
1235    /// still reports bare URLs under MDG, but withholds that unsafe automatic fix.
1236    #[test]
1237    fn test_mdg_reports_bare_urls_without_fixing_them() {
1238        let rule = MD034NoBareUrls;
1239        let content = "\
1240# Feature: Visit https://feature.example.com
1241
1242Prose about https://prose.example.com for background.
1243
1244## Scenario Outline: Open https://outline.example.com
1245
1246* Given I go to https://step.example.com
1247  | site                          |
1248  | https://datatable.example.com |
1249
1250> * Given I go to https://blockquoted.example.com
1251
12521. Given I go to https://ordered.example.com
1253
1254| url                            |
1255| ------------------------------ |
1256| https://unindented.example.com |
1257
1258### Examples:
1259
1260  | url                          |
1261  | ---------------------------- |
1262  | https://examples.example.com |
1263";
1264
1265        let standard_ctx =
1266            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1267        let standard_lines: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
1268        assert_eq!(
1269            standard_lines,
1270            vec![1, 3, 5, 7, 9, 11, 13, 17, 23],
1271            "Standard flavor flags every bare URL"
1272        );
1273
1274        let mdg_ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1275        assert!(!rule.should_skip(&mdg_ctx), "MDG must still run the diagnostic");
1276        let mdg = rule.check(&mdg_ctx).unwrap();
1277        assert_eq!(mdg.iter().map(|w| w.line).collect::<Vec<_>>(), standard_lines);
1278        assert!(mdg.iter().all(|warning| warning.fix.is_none()));
1279        assert!(
1280            mdg.iter()
1281                .all(|warning| warning.message.contains("Gherkin placeholder"))
1282        );
1283        assert!(mdg.iter().all(|warning| warning.message.contains("disable MD034")));
1284        assert_eq!(rule.fix(&mdg_ctx).unwrap(), content, "MDG must rewrite nothing");
1285    }
1286
1287    #[test]
1288    fn test_mdg_reports_bare_email_without_fixing_it() {
1289        let rule = MD034NoBareUrls;
1290        let content = "# Feature: Contact\n\n* Given I email user@example.com\n";
1291        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1292
1293        let warnings = rule.check(&ctx).unwrap();
1294        assert_eq!(warnings.len(), 1);
1295        assert!(warnings[0].message.contains("Gherkin placeholder"));
1296        assert!(warnings[0].message.contains("disable MD034"));
1297        assert!(warnings[0].fix.is_none());
1298        assert_eq!(rule.fix(&ctx).unwrap(), content);
1299    }
1300
1301    /// The exemption is confined to MDG: every other flavor still rewrites the same
1302    /// document, at every position.
1303    #[test]
1304    fn test_mdg_exemption_does_not_affect_other_flavors() {
1305        let rule = MD034NoBareUrls;
1306        let content = "\
1307# Feature: Visit https://feature.example.com
1308
1309Prose about https://prose.example.com for background.
1310
1311## Scenario Outline: Open https://outline.example.com
1312
1313* Given I go to https://step.example.com
1314  | site                          |
1315  | https://datatable.example.com |
1316
1317### Examples:
1318
1319  | url                          |
1320  | ---------------------------- |
1321  | https://examples.example.com |
1322";
1323        let expected = "\
1324# Feature: Visit <https://feature.example.com>
1325
1326Prose about <https://prose.example.com> for background.
1327
1328## Scenario Outline: Open <https://outline.example.com>
1329
1330* Given I go to <https://step.example.com>
1331  | site                          |
1332  | <https://datatable.example.com> |
1333
1334### Examples:
1335
1336  | url                          |
1337  | ---------------------------- |
1338  | <https://examples.example.com> |
1339";
1340
1341        for flavor in [
1342            crate::config::MarkdownFlavor::Standard,
1343            crate::config::MarkdownFlavor::MkDocs,
1344            crate::config::MarkdownFlavor::MyST,
1345        ] {
1346            let ctx = crate::lint_context::LintContext::new(content, flavor, None);
1347            assert!(!rule.should_skip(&ctx), "{flavor:?} must still run the rule");
1348            assert_eq!(rule.check(&ctx).unwrap().len(), 6, "{flavor:?} must flag all six URLs");
1349
1350            let fixed = rule.fix(&ctx).unwrap();
1351            assert_eq!(fixed, expected, "{flavor:?} must wrap all six URLs");
1352
1353            let fixed_ctx = crate::lint_context::LintContext::new(&fixed, flavor, None);
1354            assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1355            assert_eq!(
1356                rule.fix(&fixed_ctx).unwrap(),
1357                fixed,
1358                "{flavor:?} fix must be idempotent"
1359            );
1360        }
1361    }
1362
1363    /// `<` opens JSX, so the autolink form the other flavors use is a parse error in
1364    /// MDX and the "fixed" document stops compiling. Each MDX expectation below was
1365    /// verified to compile under `@mdx-js/mdx` 3.x and to render an anchor whose
1366    /// href is the URL and whose text is the URL, literally.
1367    ///
1368    /// The Standard rows are the control: a change that merely stopped emitting the
1369    /// angle-bracket form everywhere would pass a one-sided test.
1370    #[test]
1371    fn test_mdx_fixes_bare_urls_to_links_instead_of_autolinks() {
1372        let rule = MD034NoBareUrls;
1373        let cases = [
1374            (
1375                "Bare link: http://localhost/\n",
1376                "Bare link: [http://localhost/](http://localhost/)\n",
1377                "Bare link: <http://localhost/>\n",
1378            ),
1379            (
1380                "Visit www.example.com today\n",
1381                "Visit [www.example.com](https://www.example.com) today\n",
1382                "Visit <https://www.example.com> today\n",
1383            ),
1384            (
1385                "Mail user@example.com now\n",
1386                "Mail [user@example.com](mailto:user@example.com) now\n",
1387                "Mail <user@example.com> now\n",
1388            ),
1389            (
1390                "Chat xmpp:foo@bar.baz please\n",
1391                "Chat [xmpp:foo@bar.baz](xmpp:foo@bar.baz) please\n",
1392                "Chat <xmpp:foo@bar.baz> please\n",
1393            ),
1394        ];
1395
1396        for (content, expected_mdx, expected_standard) in cases {
1397            let mdx_ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1398            assert_eq!(
1399                rule.check(&mdx_ctx).unwrap().len(),
1400                1,
1401                "MDX must still report the bare URL in {content:?}"
1402            );
1403            assert_eq!(rule.fix(&mdx_ctx).unwrap(), expected_mdx, "MDX fix for {content:?}");
1404
1405            let standard_ctx =
1406                crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1407            assert_eq!(
1408                rule.fix(&standard_ctx).unwrap(),
1409                expected_standard,
1410                "Standard fix for {content:?} must be unchanged"
1411            );
1412        }
1413    }
1414
1415    /// `EMAIL_PATTERN` matches the address alone, so every schemed URI ending in one
1416    /// presents its tail as a bare email. `xmpp:` was recognized by name; the others are
1417    /// the same construct and were reported, which would have split the URI at the colon.
1418    #[test]
1419    fn test_an_address_behind_a_uri_scheme_is_not_a_bare_email() {
1420        let rule = MD034NoBareUrls;
1421        for content in [
1422            "Mail mailto:user@example.com now\n",
1423            "Chat xmpp:foo@bar.baz please\n",
1424            "Call sip:user@example.com now\n",
1425            "Key openpgp4fpr:user@example.com here\n",
1426            "Ping xmpp+tls:user@example.com now\n",
1427        ] {
1428            for flavor in [
1429                crate::config::MarkdownFlavor::Standard,
1430                crate::config::MarkdownFlavor::MDX,
1431            ] {
1432                let ctx = crate::lint_context::LintContext::new(content, flavor, None);
1433                let emails: Vec<_> = rule
1434                    .check(&ctx)
1435                    .unwrap()
1436                    .into_iter()
1437                    .filter(|w| w.message.starts_with("Email address"))
1438                    .collect();
1439                assert!(
1440                    emails.is_empty(),
1441                    "{flavor:?} reported the tail of a schemed URI in {content:?} as a bare email: {emails:?}"
1442                );
1443            }
1444        }
1445    }
1446
1447    /// The control for the guard above: it must not swallow an address that merely has a
1448    /// colon somewhere before it, which is ordinary prose and a real finding.
1449    #[test]
1450    fn test_a_colon_before_an_address_is_still_a_bare_email() {
1451        let rule = MD034NoBareUrls;
1452        for content in [
1453            "Contact: user@example.com\n",
1454            "Note (see 3:1): user@example.com\n",
1455            "Mail 2user@example.com now\n",
1456            // The colon is adjacent, so only the scheme grammar separates these from a
1457            // schemed URI: a scheme cannot start with a digit, and cannot be empty.
1458            "Ratio 3:user@example.com now\n",
1459            "Mail :user@example.com now\n",
1460        ] {
1461            let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1462            assert_eq!(
1463                rule.check(&ctx).unwrap().len(),
1464                1,
1465                "{content:?} must still report a bare email"
1466            );
1467        }
1468    }
1469
1470    #[test]
1471    fn test_follows_uri_scheme() {
1472        // The address starts right after the colon in each of these.
1473        assert!(follows_uri_scheme("mailto:a@b.co", 7));
1474        assert!(follows_uri_scheme("Mail mailto:a@b.co", 12));
1475        assert!(follows_uri_scheme("xmpp+tls:a@b.co", 9));
1476        assert!(follows_uri_scheme("a:a@b.co", 2));
1477
1478        assert!(!follows_uri_scheme("a@b.co", 0));
1479        assert!(!follows_uri_scheme("Contact: a@b.co", 9), "a space separates the colon");
1480        // A scheme must begin with a letter, so neither of these is one.
1481        assert!(!follows_uri_scheme("2mailto:a@b.co", 8));
1482        assert!(!follows_uri_scheme(":a@b.co", 1), "empty scheme");
1483        // Multi-byte text before the colon must not panic or be misread. `é` is not a
1484        // scheme character, so the run ends on it and the slice must land on its boundary.
1485        assert!(follows_uri_scheme("Schrijf mailto:a@b.co", 15));
1486        assert!(!follows_uri_scheme("Schrijf é:a@b.co", 11));
1487    }
1488
1489    /// A `[` binds to the character before it, which the `<url>` form this replaces
1490    /// never had to care about. Every expectation below was rendered through a
1491    /// spec-exact CommonMark+GFM implementation: unguarded, the first two produce an
1492    /// `<img>` instead of an `<a>`, silently and permanently, since the result is
1493    /// valid Markdown that MD034 does not report again.
1494    #[test]
1495    fn test_mdx_escapes_an_active_bang_before_the_link() {
1496        let rule = MD034NoBareUrls;
1497        let cases = [
1498            (
1499                "Download now!https://example.com/f today\n",
1500                "Download now\\![https://example.com/f](https://example.com/f) today\n",
1501            ),
1502            (
1503                "Contact us!user@example.com now\n",
1504                "Contact us\\![user@example.com](mailto:user@example.com) now\n",
1505            ),
1506            // Already escaped: the `!` is literal text and binds to nothing, so a second
1507            // backslash would escape the backslash instead and reinstate the image.
1508            (
1509                "Escaped already\\!https://example.com/e today\n",
1510                "Escaped already\\![https://example.com/e](https://example.com/e) today\n",
1511            ),
1512            // An even run leaves the `!` active again.
1513            (
1514                "Two slashes\\\\!https://example.com/t today\n",
1515                "Two slashes\\\\\\![https://example.com/t](https://example.com/t) today\n",
1516            ),
1517            // Separated by a space, the `!` cannot bind to the link at all.
1518            (
1519                "Normal! https://example.com/s today\n",
1520                "Normal! [https://example.com/s](https://example.com/s) today\n",
1521            ),
1522        ];
1523
1524        for (content, expected) in cases {
1525            let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1526            assert_eq!(rule.fix(&ctx).unwrap(), expected, "MDX fix for {content:?}");
1527        }
1528    }
1529
1530    /// The bang guard belongs to the link form, so the autolink flavors must not grow
1531    /// an escape they never needed: `!<url>` is not image syntax.
1532    #[test]
1533    fn test_a_preceding_bang_is_untouched_outside_jsx_flavors() {
1534        let rule = MD034NoBareUrls;
1535        let ctx = crate::lint_context::LintContext::new(
1536            "Download now!https://example.com/f today\n",
1537            crate::config::MarkdownFlavor::Standard,
1538            None,
1539        );
1540        assert_eq!(rule.fix(&ctx).unwrap(), "Download now!<https://example.com/f> today\n");
1541    }
1542
1543    /// After a `]`, the emitted link text is read as that span's reference label, so the
1544    /// anchor resolves against an unrelated definition and the real destination is left
1545    /// behind as literal text. Escaping the `]` would break the span it closes, so the
1546    /// finding is reported with no fix rather than with a corrupting one.
1547    #[test]
1548    fn test_mdx_reports_but_does_not_fix_a_url_after_an_active_close_bracket() {
1549        let rule = MD034NoBareUrls;
1550        let content =
1551            "[See more]https://example.com/x here\n\n[https://example.com/x]: https://elsewhere.example.com/\n";
1552        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1553
1554        let warnings = rule.check(&ctx).unwrap();
1555        assert_eq!(warnings.len(), 1, "the bare URL is still a finding");
1556        assert!(warnings[0].fix.is_none(), "no replacement is safe here");
1557        assert_eq!(rule.fix(&ctx).unwrap(), content, "fmt must leave the line alone");
1558    }
1559
1560    /// An escaped `]` closes no span, so the link is safe and keeps its fix.
1561    #[test]
1562    fn test_mdx_fixes_after_an_escaped_close_bracket() {
1563        let rule = MD034NoBareUrls;
1564        let ctx = crate::lint_context::LintContext::new(
1565            "Text \\]https://example.com/x here\n",
1566            crate::config::MarkdownFlavor::MDX,
1567            None,
1568        );
1569        assert_eq!(
1570            rule.fix(&ctx).unwrap(),
1571            "Text \\][https://example.com/x](https://example.com/x) here\n"
1572        );
1573    }
1574
1575    #[test]
1576    fn test_classify_link_prefix() {
1577        let free = |s: &str| matches!(classify_link_prefix(s, s.len()), LinkPrefix::Free);
1578        let bang = |s: &str| matches!(classify_link_prefix(s, s.len()), LinkPrefix::ActiveBang);
1579        let bracket = |s: &str| matches!(classify_link_prefix(s, s.len()), LinkPrefix::ActiveCloseBracket);
1580
1581        assert!(free(""), "start of line binds to nothing");
1582        assert!(free("plain "));
1583        assert!(free("plain"));
1584        assert!(bang("hi!"));
1585        assert!(free("hi\\!"), "one backslash escapes the bang");
1586        assert!(bang("hi\\\\!"), "two backslashes escape each other, not the bang");
1587        assert!(free("hi\\\\\\!"), "three escape the bang again");
1588        assert!(bracket("[a]"));
1589        assert!(free("[a\\]"), "an escaped bracket closes no span");
1590        // A multi-byte character before the span must not be mistaken for either.
1591        assert!(free("café"));
1592        assert!(bang("café!"));
1593    }
1594
1595    /// The link text must display the URL literally, so every character that is
1596    /// active there is escaped. Each case below was checked against the `@mdx-js/mdx`
1597    /// 3.x compiler: unescaped, a `*` pair becomes emphasis and `&amp;` decodes to a
1598    /// bare `&`, both dropping characters from the URL the reader sees. `_` and `~`
1599    /// happen to render literally today (intraword `_` is not emphasis, and MDX
1600    /// enables no strikethrough by default), but a `remark-gfm` pipeline is the norm
1601    /// in MDX projects, so they are escaped rather than left to the plugin set.
1602    #[test]
1603    fn test_mdx_link_text_escapes_characters_that_would_not_render_literally() {
1604        let rule = MD034NoBareUrls;
1605        let cases = [
1606            ("https://ex.com/a*b*c", "https://ex.com/a\\*b\\*c"),
1607            ("https://ex.com/a&amp;b", "https://ex.com/a\\&amp;b"),
1608            ("https://ex.com/a~b~c", "https://ex.com/a\\~b\\~c"),
1609            ("https://ex.com/a_b_c", "https://ex.com/a\\_b\\_c"),
1610        ];
1611
1612        for (url, escaped_text) in cases {
1613            let content = format!("See {url} here\n");
1614            let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDX, None);
1615            assert_eq!(
1616                rule.fix(&ctx).unwrap(),
1617                format!("See [{escaped_text}]({url}) here\n"),
1618                "MDX must escape the link text for {url}"
1619            );
1620
1621            let standard_ctx =
1622                crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1623            assert_eq!(
1624                rule.fix(&standard_ctx).unwrap(),
1625                format!("See <{url}> here\n"),
1626                "Standard emits the autolink, which needs no escaping"
1627            );
1628        }
1629    }
1630
1631    /// A `{...}` pair inside a URL makes MDX treat the span as a JSX expression, so
1632    /// the rule never reports it and no fix is offered. An UNMATCHED brace is not a
1633    /// complete expression, so it reaches the fix and must be escaped: left alone it
1634    /// is a hard MDX compile error ("expected a corresponding closing brace"), which
1635    /// would make the fix produce a document that no longer builds.
1636    #[test]
1637    fn test_mdx_braces_are_skipped_when_paired_and_escaped_when_not() {
1638        let rule = MD034NoBareUrls;
1639
1640        let paired = "See https://ex.com/a{b}c here\n";
1641        let paired_ctx = crate::lint_context::LintContext::new(paired, crate::config::MarkdownFlavor::MDX, None);
1642        assert!(
1643            rule.check(&paired_ctx).unwrap().is_empty(),
1644            "a balanced brace pair is a JSX expression, which MD034 leaves alone"
1645        );
1646
1647        let standard_ctx = crate::lint_context::LintContext::new(paired, crate::config::MarkdownFlavor::Standard, None);
1648        assert_eq!(
1649            rule.fix(&standard_ctx).unwrap(),
1650            "See <https://ex.com/a{b}c> here\n",
1651            "outside MDX the braces carry no meaning, so the URL is still reported"
1652        );
1653
1654        for (url, escaped_text) in [
1655            ("https://ex.com/a{b", "https://ex.com/a\\{b"),
1656            ("https://ex.com/a}b", "https://ex.com/a\\}b"),
1657        ] {
1658            let content = format!("See {url} here\n");
1659            let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDX, None);
1660            assert_eq!(
1661                rule.fix(&ctx).unwrap(),
1662                format!("See [{escaped_text}]({url}) here\n"),
1663                "an unmatched brace reaches the fix and must be escaped"
1664            );
1665        }
1666    }
1667
1668    /// A bare link destination may only hold balanced parentheses: with an unmatched
1669    /// `(`, CommonMark rejects the link and renders no anchor at all. Unmatched
1670    /// closers never reach here (`trim_trailing_punctuation` strips those), so the
1671    /// angle-bracket destination is what covers the remaining case.
1672    #[test]
1673    fn test_mdx_unbalanced_open_paren_uses_an_angle_bracket_destination() {
1674        let rule = MD034NoBareUrls;
1675
1676        let unbalanced = "Go to https://ex.com/a(b now\n";
1677        let ctx = crate::lint_context::LintContext::new(unbalanced, crate::config::MarkdownFlavor::MDX, None);
1678        assert_eq!(
1679            rule.fix(&ctx).unwrap(),
1680            "Go to [https://ex.com/a(b](<https://ex.com/a(b>) now\n"
1681        );
1682
1683        let balanced = "Go to https://en.wikipedia.org/wiki/Foo_(bar) now\n";
1684        let ctx = crate::lint_context::LintContext::new(balanced, crate::config::MarkdownFlavor::MDX, None);
1685        assert_eq!(
1686            rule.fix(&ctx).unwrap(),
1687            "Go to [https://en.wikipedia.org/wiki/Foo\\_(bar)](https://en.wikipedia.org/wiki/Foo_(bar)) now\n",
1688            "balanced parens need no angle brackets"
1689        );
1690    }
1691
1692    /// Everything the MDX branch emits must survive a second pass untouched,
1693    /// including the shapes whose escaping or angle brackets are unusual.
1694    #[test]
1695    fn test_mdx_fix_is_idempotent_and_stops_reporting() {
1696        let rule = MD034NoBareUrls;
1697        let content = "\
1698Plain http://localhost/ and www.example.com.
1699
1700Mail user@example.com or see https://ex.com/a*b_c{d}e.
1701
1702Parens https://ex.com/a(b and https://en.wikipedia.org/wiki/Foo_(bar).
1703";
1704        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1705        let fixed = rule.fix(&ctx).unwrap();
1706        assert_ne!(fixed, content, "the fix must actually rewrite this document");
1707
1708        let fixed_ctx = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::MDX, None);
1709        assert!(
1710            rule.check(&fixed_ctx).unwrap().is_empty(),
1711            "MDX must not re-report its own output: {:?}",
1712            rule.check(&fixed_ctx).unwrap()
1713        );
1714        assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDX fix must be idempotent");
1715    }
1716
1717    /// The carve-out is confined to flavors that carry JSX. MDG in particular reaches
1718    /// its own fix-stripping branch unchanged.
1719    #[test]
1720    fn test_link_form_is_confined_to_jsx_flavors() {
1721        let rule = MD034NoBareUrls;
1722        let content = "Visit https://example.com today\n";
1723
1724        for flavor in [
1725            crate::config::MarkdownFlavor::Standard,
1726            crate::config::MarkdownFlavor::MkDocs,
1727            crate::config::MarkdownFlavor::MyST,
1728            crate::config::MarkdownFlavor::Quarto,
1729            crate::config::MarkdownFlavor::Obsidian,
1730        ] {
1731            assert!(!flavor.supports_jsx(), "{flavor:?} is not a JSX flavor");
1732            let ctx = crate::lint_context::LintContext::new(content, flavor, None);
1733            assert_eq!(
1734                rule.fix(&ctx).unwrap(),
1735                "Visit <https://example.com> today\n",
1736                "{flavor:?} must keep the autolink form"
1737            );
1738        }
1739
1740        let mdg_ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1741        assert_eq!(rule.check(&mdg_ctx).unwrap().len(), 1);
1742        assert!(rule.check(&mdg_ctx).unwrap()[0].fix.is_none());
1743        assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
1744    }
1745
1746    #[test]
1747    fn test_escape_mdx_link_text_covers_every_active_character() {
1748        assert_eq!(escape_mdx_link_text("plain"), "plain");
1749        for ch in MDX_LINK_TEXT_ESCAPES {
1750            assert_eq!(escape_mdx_link_text(&ch.to_string()), format!("\\{ch}"));
1751        }
1752    }
1753
1754    #[test]
1755    fn test_has_balanced_parens() {
1756        assert!(has_balanced_parens("https://ex.com/a"));
1757        assert!(has_balanced_parens("https://ex.com/(a)"));
1758        assert!(has_balanced_parens("https://ex.com/(a)(b)"));
1759        assert!(has_balanced_parens("https://ex.com/((a))"));
1760        assert!(!has_balanced_parens("https://ex.com/(a"));
1761        assert!(!has_balanced_parens("https://ex.com/a)"));
1762        assert!(
1763            !has_balanced_parens("https://ex.com/)a("),
1764            "equal counts are not balance"
1765        );
1766    }
1767}