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