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