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::{LineIndex, calculate_url_range};
10use crate::utils::regex_cache::{
11    EMAIL_PATTERN, URL_IPV6_REGEX, URL_QUICK_CHECK_REGEX, URL_STANDARD_REGEX, URL_WWW_REGEX, XMPP_URI_REGEX,
12};
13
14use crate::filtered_lines::FilteredLinesExt;
15use crate::lint_context::LintContext;
16
17// MD034-specific pre-compiled regex patterns for markdown constructs
18static CUSTOM_PROTOCOL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
19    Regex::new(r#"(?:grpc|ws|wss|ssh|git|svn|file|data|javascript|vscode|chrome|about|slack|discord|matrix|irc|redis|mongodb|postgresql|mysql|kafka|nats|amqp|mqtt|custom|app|api|service)://"#).unwrap()
20});
21static MARKDOWN_LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
22    Regex::new(r#"\[(?:[^\[\]]|\[[^\]]*\])*\]\(([^)\s]+)(?:\s+(?:\"[^\"]*\"|\'[^\']*\'))?\)"#).unwrap()
23});
24static MARKDOWN_EMPTY_LINK_REGEX: LazyLock<Regex> =
25    LazyLock::new(|| Regex::new(r#"\[(?:[^\[\]]|\[[^\]]*\])*\]\(\)"#).unwrap());
26static MARKDOWN_EMPTY_REF_REGEX: LazyLock<Regex> =
27    LazyLock::new(|| Regex::new(r#"\[(?:[^\[\]]|\[[^\]]*\])*\]\[\]"#).unwrap());
28static ANGLE_LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
29    Regex::new(
30        r#"<((?:https?|ftps?)://(?:\[[0-9a-fA-F:]+(?:%[a-zA-Z0-9]+)?\]|[^>]+)|xmpp:[^>]+|[^@\s]+@[^@\s]+\.[^@\s>]+)>"#,
31    )
32    .unwrap()
33});
34static BADGE_LINK_LINE_REGEX: LazyLock<Regex> =
35    LazyLock::new(|| Regex::new(r#"^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*$"#).unwrap());
36static MARKDOWN_IMAGE_REGEX: LazyLock<Regex> =
37    LazyLock::new(|| Regex::new(r#"!\s*\[([^\]]*)\]\s*\(([^)\s]+)(?:\s+(?:\"[^\"]*\"|\'[^\']*\'))?\)"#).unwrap());
38static REFERENCE_DEF_REGEX: LazyLock<Regex> =
39    LazyLock::new(|| Regex::new(r"^\s*\[[^\]]+\]:\s*(?:<|(?:https?|ftps?)://)").unwrap());
40static MULTILINE_LINK_CONTINUATION_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"^[^\[]*\]\(.*\)"#).unwrap());
41static SHORTCUT_REF_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"\[([^\[\]]+)\]"#).unwrap());
42
43/// Reusable buffers for check_line to reduce allocations
44#[derive(Default)]
45struct LineCheckBuffers {
46    markdown_link_ranges: Vec<(usize, usize)>,
47    image_ranges: Vec<(usize, usize)>,
48    urls_found: Vec<(usize, usize, String)>,
49}
50
51#[derive(Default, Clone)]
52pub struct MD034NoBareUrls;
53
54impl MD034NoBareUrls {
55    #[inline]
56    pub fn should_skip_content(&self, content: &str) -> bool {
57        // Skip if content has no URLs, XMPP URIs, or email addresses
58        // Fast byte scanning for common URL/email/xmpp indicators
59        let bytes = content.as_bytes();
60        let has_colon = bytes.contains(&b':');
61        let has_at = bytes.contains(&b'@');
62        let has_www = content.contains("www.");
63        !has_colon && !has_at && !has_www
64    }
65
66    /// Remove trailing punctuation that is likely sentence punctuation, not part of the URL
67    fn trim_trailing_punctuation<'a>(&self, url: &'a str) -> &'a str {
68        let mut trimmed = url;
69
70        // Check for balanced parentheses - if we have unmatched closing parens, they're likely punctuation
71        let open_parens = url.chars().filter(|&c| c == '(').count();
72        let close_parens = url.chars().filter(|&c| c == ')').count();
73
74        if close_parens > open_parens {
75            // Find the last balanced closing paren position
76            let mut balance = 0;
77            let mut last_balanced_pos = url.len();
78
79            for (byte_idx, c) in url.char_indices() {
80                if c == '(' {
81                    balance += 1;
82                } else if c == ')' {
83                    balance -= 1;
84                    if balance < 0 {
85                        // Found an unmatched closing paren
86                        last_balanced_pos = byte_idx;
87                        break;
88                    }
89                }
90            }
91
92            trimmed = &trimmed[..last_balanced_pos];
93        }
94
95        // Trim specific punctuation only if not followed by more URL-like chars
96        while let Some(last_char) = trimmed.chars().last() {
97            if matches!(last_char, '.' | ',' | ';' | ':' | '!' | '?') {
98                // Check if this looks like it could be part of the URL
99                // For ':' specifically, keep it if followed by digits (port number)
100                if last_char == ':' && trimmed.len() > 1 {
101                    // Don't trim
102                    break;
103                }
104                trimmed = &trimmed[..trimmed.len() - 1];
105            } else {
106                break;
107            }
108        }
109
110        trimmed
111    }
112
113    /// Check if line is inside a reference definition
114    fn is_reference_definition(&self, line: &str) -> bool {
115        REFERENCE_DEF_REGEX.is_match(line)
116    }
117
118    fn check_line(
119        &self,
120        line: &str,
121        ctx: &LintContext,
122        line_number: usize,
123        code_spans: &[crate::lint_context::CodeSpan],
124        buffers: &mut LineCheckBuffers,
125        line_index: &LineIndex,
126    ) -> Vec<LintWarning> {
127        let mut warnings = Vec::new();
128
129        // Skip reference definitions
130        if self.is_reference_definition(line) {
131            return warnings;
132        }
133
134        // Skip lines inside HTML blocks - URLs in HTML attributes should not be linted
135        if ctx.line_info(line_number).is_some_and(|info| info.in_html_block) {
136            return warnings;
137        }
138
139        // Skip lines that are continuations of multiline markdown links
140        // Pattern: text](url) without a leading [
141        if MULTILINE_LINK_CONTINUATION_REGEX.is_match(line) {
142            return warnings;
143        }
144
145        // Quick check - does this line potentially have a URL or email?
146        let has_quick_check = URL_QUICK_CHECK_REGEX.is_match(line);
147        let has_www = line.contains("www.");
148        let has_at = line.contains('@');
149
150        if !has_quick_check && !has_at && !has_www {
151            return warnings;
152        }
153
154        // Clear and reuse buffers instead of allocating new ones
155        buffers.markdown_link_ranges.clear();
156        buffers.image_ranges.clear();
157
158        let has_bracket = line.contains('[');
159        let has_angle = line.contains('<');
160        let has_bang = line.contains('!');
161
162        if has_bracket {
163            for mat in MARKDOWN_LINK_REGEX.find_iter(line) {
164                buffers.markdown_link_ranges.push((mat.start(), mat.end()));
165            }
166
167            // Also include empty link patterns like [text]() and [text][]
168            for mat in MARKDOWN_EMPTY_LINK_REGEX.find_iter(line) {
169                buffers.markdown_link_ranges.push((mat.start(), mat.end()));
170            }
171
172            for mat in MARKDOWN_EMPTY_REF_REGEX.find_iter(line) {
173                buffers.markdown_link_ranges.push((mat.start(), mat.end()));
174            }
175
176            // Also exclude shortcut reference links like [URL]
177            for mat in SHORTCUT_REF_REGEX.find_iter(line) {
178                let end = mat.end();
179                let next_non_ws = line[end..].bytes().find(|b| !b.is_ascii_whitespace());
180                if next_non_ws == Some(b'(') || next_non_ws == Some(b'[') {
181                    continue;
182                }
183                buffers.markdown_link_ranges.push((mat.start(), mat.end()));
184            }
185
186            // Check if this line contains only a badge link (common pattern)
187            if has_bang && BADGE_LINK_LINE_REGEX.is_match(line) {
188                return warnings;
189            }
190        }
191
192        if has_angle {
193            for mat in ANGLE_LINK_REGEX.find_iter(line) {
194                buffers.markdown_link_ranges.push((mat.start(), mat.end()));
195            }
196        }
197
198        // Find all markdown images for exclusion
199        if has_bang && has_bracket {
200            for mat in MARKDOWN_IMAGE_REGEX.find_iter(line) {
201                buffers.image_ranges.push((mat.start(), mat.end()));
202            }
203        }
204
205        // Find bare URLs
206        buffers.urls_found.clear();
207
208        // First, find IPv6 URLs (they need special handling)
209        for mat in URL_IPV6_REGEX.find_iter(line) {
210            let url_str = mat.as_str();
211            buffers.urls_found.push((mat.start(), mat.end(), url_str.to_string()));
212        }
213
214        // Then find regular URLs
215        for mat in URL_STANDARD_REGEX.find_iter(line) {
216            let url_str = mat.as_str();
217
218            // Skip if it's an IPv6 URL (already handled)
219            if url_str.contains("://[") {
220                continue;
221            }
222
223            // Skip malformed IPv6-like URLs
224            // Check for IPv6-like patterns that are malformed
225            if let Some(host_start) = url_str.find("://") {
226                let after_protocol = &url_str[host_start + 3..];
227                // If it looks like IPv6 (has :: or multiple :) but no brackets, skip if followed by ]
228                if after_protocol.contains("::") || after_protocol.chars().filter(|&c| c == ':').count() > 1 {
229                    // Check if the next byte after our match is ] (ASCII, so byte check is safe)
230                    if line.as_bytes().get(mat.end()) == Some(&b']') {
231                        // This is likely a malformed IPv6 URL like "https://::1]:8080"
232                        continue;
233                    }
234                }
235            }
236
237            buffers.urls_found.push((mat.start(), mat.end(), url_str.to_string()));
238        }
239
240        // Find www URLs without protocol (e.g., www.example.com)
241        for mat in URL_WWW_REGEX.find_iter(line) {
242            let url_str = mat.as_str();
243            let start_pos = mat.start();
244            let end_pos = mat.end();
245
246            // Skip if preceded by / or @ (likely part of a full URL)
247            if start_pos > 0 {
248                let prev_char = line.as_bytes().get(start_pos - 1).copied();
249                if prev_char == Some(b'/') || prev_char == Some(b'@') {
250                    continue;
251                }
252            }
253
254            // Skip if inside angle brackets (autolink syntax like <www.example.com>)
255            if start_pos > 0 && end_pos < line.len() {
256                let prev_char = line.as_bytes().get(start_pos - 1).copied();
257                let next_char = line.as_bytes().get(end_pos).copied();
258                if prev_char == Some(b'<') && next_char == Some(b'>') {
259                    continue;
260                }
261            }
262
263            buffers.urls_found.push((start_pos, end_pos, url_str.to_string()));
264        }
265
266        // Find XMPP URIs (GFM extended autolinks: xmpp:user@domain/resource)
267        for mat in XMPP_URI_REGEX.find_iter(line) {
268            let uri_str = mat.as_str();
269            let start_pos = mat.start();
270            let end_pos = mat.end();
271
272            // Skip if inside angle brackets (already properly formatted: <xmpp:user@domain>)
273            if start_pos > 0 && end_pos < line.len() {
274                let prev_char = line.as_bytes().get(start_pos - 1).copied();
275                let next_char = line.as_bytes().get(end_pos).copied();
276                if prev_char == Some(b'<') && next_char == Some(b'>') {
277                    continue;
278                }
279            }
280
281            buffers.urls_found.push((start_pos, end_pos, uri_str.to_string()));
282        }
283
284        // Process found URLs
285        for &(start, _end, ref url_str) in &buffers.urls_found {
286            // Skip custom protocols
287            if CUSTOM_PROTOCOL_REGEX.is_match(url_str) {
288                continue;
289            }
290
291            // Check if this URL is inside a markdown link, angle bracket, or image
292            // We check if the URL starts within a construct, not if it's entirely contained.
293            // This handles cases where URL detection may include trailing characters
294            // that extend past the construct boundary (e.g., parentheses).
295            // Linear scan is correct here because ranges can overlap/nest (e.g., [[1]](url))
296            let is_inside_construct = buffers
297                .markdown_link_ranges
298                .iter()
299                .any(|&(s, e)| start >= s && start < e)
300                || buffers.image_ranges.iter().any(|&(s, e)| start >= s && start < e);
301
302            if is_inside_construct {
303                continue;
304            }
305
306            // Calculate absolute byte position for context-aware checks
307            let line_start_byte = line_index.get_line_start_byte(line_number).unwrap_or(0);
308            let absolute_pos = line_start_byte + start;
309
310            // Check if URL is inside an HTML tag (handles multiline tags correctly)
311            if ctx.is_in_html_tag(absolute_pos) {
312                continue;
313            }
314
315            // Check if URL is a JSX component attribute value (e.g. `<Card href="..."/>`).
316            // These are string props, not bare prose; wrapping them in angle brackets
317            // would produce invalid JSX. No-op for non-JSX flavors.
318            if ctx.is_in_jsx_component_tag(absolute_pos) {
319                continue;
320            }
321
322            // Check if we're inside an HTML comment
323            if ctx.is_in_html_comment(absolute_pos) || ctx.is_in_mdx_comment(absolute_pos) {
324                continue;
325            }
326
327            // Check if we're inside a Hugo/Quarto shortcode
328            if ctx.is_in_shortcode(absolute_pos) {
329                continue;
330            }
331
332            // Skip URLs inside Pandoc line blocks (`| text`) or YAML metadata blocks.
333            // Both constructs treat their content as literal/structured text where bare
334            // URLs are intentional and should not be reformatted.
335            if ctx.flavor.is_pandoc_compatible()
336                && (ctx.is_in_line_block(absolute_pos) || ctx.is_in_pandoc_metadata(absolute_pos))
337            {
338                continue;
339            }
340
341            // Clean up the URL by removing trailing punctuation
342            let trimmed_url = self.trim_trailing_punctuation(url_str);
343
344            // Only report if we have a valid URL after trimming
345            if !trimmed_url.is_empty() && trimmed_url != "//" {
346                let trimmed_len = trimmed_url.len();
347                let (start_line, start_col, end_line, end_col) =
348                    calculate_url_range(line_number, line, start, trimmed_len);
349
350                // For www URLs without protocol, add https:// prefix in the fix
351                let replacement = if trimmed_url.starts_with("www.") {
352                    format!("<https://{trimmed_url}>")
353                } else {
354                    format!("<{trimmed_url}>")
355                };
356
357                warnings.push(LintWarning {
358                    rule_name: Some("MD034".to_string()),
359                    line: start_line,
360                    column: start_col,
361                    end_line,
362                    end_column: end_col,
363                    message: format!("URL without angle brackets or link formatting: '{trimmed_url}'"),
364                    severity: Severity::Warning,
365                    fix: Some(Fix::new(
366                        {
367                            let line_start_byte = line_index.get_line_start_byte(line_number).unwrap_or(0);
368                            (line_start_byte + start)..(line_start_byte + start + trimmed_len)
369                        },
370                        replacement,
371                    )),
372                });
373            }
374        }
375
376        // Check for bare email addresses
377        for cap in EMAIL_PATTERN.captures_iter(line) {
378            if let Some(mat) = cap.get(0) {
379                let email = mat.as_str();
380                let start = mat.start();
381                let end = mat.end();
382
383                // Skip if email is part of an XMPP URI (xmpp:user@domain)
384                // Check character boundary to avoid panics with multi-byte UTF-8
385                if start >= 5 && line.is_char_boundary(start - 5) && &line[start - 5..start] == "xmpp:" {
386                    continue;
387                }
388
389                // Check if email is inside angle brackets or markdown link
390                let mut is_inside_construct = false;
391                for &(link_start, link_end) in &buffers.markdown_link_ranges {
392                    if start >= link_start && end <= link_end {
393                        is_inside_construct = true;
394                        break;
395                    }
396                }
397
398                if !is_inside_construct {
399                    // Calculate absolute byte position for context-aware checks
400                    let line_start_byte = line_index.get_line_start_byte(line_number).unwrap_or(0);
401                    let absolute_pos = line_start_byte + start;
402
403                    // Check if email is inside an HTML tag (handles multiline tags)
404                    if ctx.is_in_html_tag(absolute_pos) {
405                        continue;
406                    }
407
408                    // Check if email is a JSX component attribute value (e.g.
409                    // `<Contact email="..."/>`). No-op for non-JSX flavors.
410                    if ctx.is_in_jsx_component_tag(absolute_pos) {
411                        continue;
412                    }
413
414                    // Skip emails inside Pandoc line blocks or YAML metadata blocks.
415                    if ctx.flavor.is_pandoc_compatible()
416                        && (ctx.is_in_line_block(absolute_pos) || ctx.is_in_pandoc_metadata(absolute_pos))
417                    {
418                        continue;
419                    }
420
421                    // Check if email is inside a code span (byte offsets handle multi-line spans)
422                    let is_in_code_span = code_spans
423                        .iter()
424                        .any(|span| absolute_pos >= span.byte_offset && absolute_pos < span.byte_end);
425
426                    if !is_in_code_span {
427                        let email_len = end - start;
428                        let (start_line, start_col, end_line, end_col) =
429                            calculate_url_range(line_number, line, start, email_len);
430
431                        warnings.push(LintWarning {
432                            rule_name: Some("MD034".to_string()),
433                            line: start_line,
434                            column: start_col,
435                            end_line,
436                            end_column: end_col,
437                            message: format!("Email address without angle brackets or link formatting: '{email}'"),
438                            severity: Severity::Warning,
439                            fix: Some(Fix::new(
440                                (line_start_byte + start)..(line_start_byte + end),
441                                format!("<{email}>"),
442                            )),
443                        });
444                    }
445                }
446            }
447        }
448
449        warnings
450    }
451}
452
453impl Rule for MD034NoBareUrls {
454    #[inline]
455    fn name(&self) -> &'static str {
456        "MD034"
457    }
458
459    fn as_any(&self) -> &dyn std::any::Any {
460        self
461    }
462
463    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
464    where
465        Self: Sized,
466    {
467        Box::new(MD034NoBareUrls)
468    }
469
470    #[inline]
471    fn category(&self) -> RuleCategory {
472        RuleCategory::Link
473    }
474
475    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
476        !ctx.likely_has_links_or_images() && self.should_skip_content(ctx.content)
477    }
478
479    #[inline]
480    fn description(&self) -> &'static str {
481        "No bare URLs - wrap URLs in angle brackets"
482    }
483
484    fn check(&self, ctx: &LintContext) -> LintResult {
485        let mut warnings = Vec::new();
486        let content = ctx.content;
487
488        // Quick skip for content without URLs
489        if self.should_skip_content(content) {
490            return Ok(warnings);
491        }
492
493        // Create LineIndex for correct byte position calculations across all line ending types
494        let line_index = &ctx.line_index;
495
496        // Get code spans for exclusion
497        let code_spans = ctx.code_spans();
498
499        // Allocate reusable buffers once instead of per-line to reduce allocations
500        let mut buffers = LineCheckBuffers::default();
501
502        // Iterate over content lines, automatically skipping front matter, code blocks,
503        // and Obsidian comments (when in Obsidian flavor)
504        // This uses the filtered iterator API which centralizes the skip logic
505        for line in ctx
506            .filtered_lines()
507            .skip_front_matter()
508            .skip_code_blocks()
509            .skip_jsx_expressions()
510            .skip_mdx_comments()
511            .skip_obsidian_comments()
512        {
513            // Skip MyST colon-fence directive openers (`:::{name} <arg>`). The text
514            // after the directive name is an opaque argument (a URL, path, or label),
515            // not markdown prose, so a bare URL there must not be wrapped in angle
516            // brackets. Directive body lines are not openers, so they fall through to
517            // `check_line` and are linted as usual.
518            if ctx.is_myst_colon_directive_opener_line(line.line_num) {
519                continue;
520            }
521
522            let mut line_warnings =
523                self.check_line(line.content, ctx, line.line_num, &code_spans, &mut buffers, line_index);
524
525            // Filter out warnings that are inside code spans (handles multi-line spans via byte offsets)
526            line_warnings.retain(|warning| {
527                !code_spans.iter().any(|span| {
528                    if let Some(fix) = &warning.fix {
529                        // Byte-offset check handles both single-line and multi-line code spans
530                        fix.range.start >= span.byte_offset && fix.range.start < span.byte_end
531                    } else {
532                        span.line == warning.line
533                            && span.end_line == warning.line
534                            && warning.column > 0
535                            && (warning.column - 1) >= span.start_col
536                            && (warning.column - 1) < span.end_col
537                    }
538                })
539            });
540
541            // Filter out warnings where the URL is inside a parsed link
542            // This handles cases like [text]( https://url ) where the URL has leading whitespace
543            // pulldown-cmark correctly parses these as valid links even though our regex misses them
544            line_warnings.retain(|warning| {
545                if let Some(fix) = &warning.fix {
546                    // Check if the fix range falls inside any parsed link's byte range
547                    !ctx.links
548                        .iter()
549                        .any(|link| fix.range.start >= link.byte_offset && fix.range.end <= link.byte_end)
550                } else {
551                    true
552                }
553            });
554
555            // Filter out warnings where the URL is inside an Obsidian comment (%%...%%)
556            // This handles inline comments like: text %%https://hidden.com%% text
557            line_warnings.retain(|warning| !ctx.is_position_in_obsidian_comment(warning.line, warning.column));
558
559            warnings.extend(line_warnings);
560        }
561
562        Ok(warnings)
563    }
564
565    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
566        let mut content = ctx.content.to_string();
567        let warnings = self.check(ctx)?;
568        let mut warnings =
569            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
570
571        // Sort warnings by position to ensure consistent fix application
572        warnings.sort_by_key(|w| w.fix.as_ref().map_or(0, |f| f.range.start));
573
574        // Apply fixes in reverse order to maintain positions
575        for warning in warnings.iter().rev() {
576            if let Some(fix) = &warning.fix {
577                let start = fix.range.start;
578                let end = fix.range.end;
579                content.replace_range(start..end, &fix.replacement);
580            }
581        }
582
583        Ok(content)
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    #[test]
592    fn test_shortcut_ref_at_end_of_line_no_trailing_chars() {
593        let rule = MD034NoBareUrls;
594        let content = "See [https://example.com]";
595        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
596        let result = rule.check(&ctx).unwrap();
597        assert!(
598            result.is_empty(),
599            "[URL] at end of line should be treated as shortcut ref: {result:?}"
600        );
601    }
602
603    #[test]
604    fn test_shortcut_ref_multiple_spaces_before_paren() {
605        let rule = MD034NoBareUrls;
606        let content = "[text]  (https://example.com)";
607        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608        let result = rule.check(&ctx).unwrap();
609        // [text]  (url) — the spaces between ] and ( mean this should be treated
610        // as shortcut ref then bare parens, NOT a markdown link. URL may still be bare.
611        // This test verifies consistent behavior with the FancyRegex that had (?!\s*[\[(])
612        let _ = result; // Just verify no panic; the exact warning count depends on other rules
613    }
614
615    #[test]
616    fn test_shortcut_ref_tab_before_bracket() {
617        let rule = MD034NoBareUrls;
618        let content = "[https://example.com]\t[other]";
619        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
620        let result = rule.check(&ctx).unwrap();
621        // Tab between ] and [ does not form a full reference link in Markdown.
622        // The first [URL] is a shortcut ref containing a bare URL, so MD034 warns.
623        // This test verifies consistent behavior and no panic with tab characters.
624        assert_eq!(
625            result.len(),
626            1,
627            "Bare URL inside shortcut ref should be detected: {result:?}"
628        );
629    }
630
631    #[test]
632    fn test_shortcut_ref_followed_by_punctuation() {
633        let rule = MD034NoBareUrls;
634        let content = "[https://example.com], see also other things.";
635        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
636        let result = rule.check(&ctx).unwrap();
637        assert!(
638            result.is_empty(),
639            "[URL] followed by comma should be treated as shortcut ref: {result:?}"
640        );
641    }
642
643    #[test]
644    fn test_url_in_backticks_inside_mdx_component_not_flagged() {
645        // Exact reproduction from issue #572: URL inside inline code within an MDX
646        // component body must not be flagged. The same URL in backticks outside the
647        // component is already handled correctly and serves as a control.
648        let rule = MD034NoBareUrls;
649        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";
650        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
651        let result = rule.check(&ctx).unwrap();
652        assert!(
653            result.is_empty(),
654            "URL in backticks inside MDX component must not be flagged: {result:?}"
655        );
656    }
657
658    #[test]
659    fn test_bare_url_inside_mdx_component_still_flagged() {
660        // A bare URL (not in backticks) inside an MDX component body must still be flagged.
661        // This ensures the fix for issue #572 only suppresses properly code-spanned URLs.
662        let rule = MD034NoBareUrls;
663        let content =
664            "# Test\n\n<ParamField path=\"--stuff\">\n  Visit https://rumdl.example.com/ for details.\n</ParamField>\n";
665        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
666        let result = rule.check(&ctx).unwrap();
667        assert_eq!(
668            result.len(),
669            1,
670            "Bare URL in MDX component body must still be flagged: {result:?}"
671        );
672    }
673
674    #[test]
675    fn test_url_in_backticks_inside_nested_mdx_component_not_flagged() {
676        // Nested MDX components must also respect code spans.
677        let rule = MD034NoBareUrls;
678        let content = "<Outer>\n  <Inner>\n    Check `https://example.com/` here.\n  </Inner>\n</Outer>\n";
679        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
680        let result = rule.check(&ctx).unwrap();
681        assert!(
682            result.is_empty(),
683            "URL in backticks inside nested MDX component must not be flagged: {result:?}"
684        );
685    }
686
687    /// Issue #649: a URL that is a JSX component attribute value (e.g. `href="..."`)
688    /// is a string prop, not bare prose. Wrapping it in angle brackets produces
689    /// invalid JSX, so MD034 must not flag it under the MDX flavor.
690    #[test]
691    fn test_url_in_jsx_component_attribute_not_flagged() {
692        let rule = MD034NoBareUrls;
693        let content = "<Card title=\"Docs\" href=\"https://example.com/docs\" />\n";
694        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
695        let result = rule.check(&ctx).unwrap();
696        assert!(
697            result.is_empty(),
698            "URL in a JSX component attribute must not be flagged: {result:?}"
699        );
700    }
701
702    /// The same exemption must apply when the JSX opening tag spans multiple lines.
703    #[test]
704    fn test_url_in_multiline_jsx_component_attribute_not_flagged() {
705        let rule = MD034NoBareUrls;
706        let content = "<Card\n  title=\"Docs\"\n  href=\"https://example.com/docs\"\n/>\n";
707        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
708        let result = rule.check(&ctx).unwrap();
709        assert!(
710            result.is_empty(),
711            "URL in a multi-line JSX component attribute must not be flagged: {result:?}"
712        );
713    }
714
715    /// The exemption is surgical: a URL in the component's *attributes* is skipped,
716    /// but a bare URL in the component's *body* is genuine prose and still flagged.
717    #[test]
718    fn test_jsx_attribute_url_skipped_but_body_url_flagged() {
719        let rule = MD034NoBareUrls;
720        let content = "<Card href=\"https://attr.example.com\">\n  Visit https://body.example.com now.\n</Card>\n";
721        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
722        let result = rule.check(&ctx).unwrap();
723        assert_eq!(
724            result.len(),
725            1,
726            "Only the body URL must be flagged, not the attribute URL: {result:?}"
727        );
728        assert!(
729            result[0].message.contains("body.example.com"),
730            "The flagged URL must be the body one: {result:?}"
731        );
732    }
733
734    /// The email path has the same JSX-attribute blind spot; an email used as a
735    /// JSX component attribute value must not be flagged either.
736    #[test]
737    fn test_email_in_jsx_component_attribute_not_flagged() {
738        let rule = MD034NoBareUrls;
739        let content = "<Contact email=\"hello@example.com\" />\n";
740        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
741        let result = rule.check(&ctx).unwrap();
742        assert!(
743            result.is_empty(),
744            "Email in a JSX component attribute must not be flagged: {result:?}"
745        );
746    }
747
748    /// Control: under the Standard flavor `<Card .../>` is parsed as an HTML tag,
749    /// so the attribute URL is already covered by the existing HTML-tag guard.
750    /// This locks in that the two flavors agree.
751    #[test]
752    fn test_jsx_attribute_url_not_flagged_in_standard_flavor() {
753        let rule = MD034NoBareUrls;
754        let content = "<Card href=\"https://example.com/docs\" />\n";
755        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
756        let result = rule.check(&ctx).unwrap();
757        assert!(
758            result.is_empty(),
759            "URL in a tag attribute must not be flagged under Standard flavor either: {result:?}"
760        );
761    }
762
763    /// URLs inside Pandoc line blocks (`| text`) must not be flagged as bare URLs.
764    #[test]
765    fn test_pandoc_skips_urls_in_line_blocks() {
766        use crate::config::MarkdownFlavor;
767        use crate::lint_context::LintContext;
768        let rule = MD034NoBareUrls;
769        let content = "| See https://example.com\n| For details\n";
770        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
771        let result = rule.check(&ctx).unwrap();
772        assert!(
773            result.is_empty(),
774            "MD034 should skip URLs in Pandoc line blocks: {result:?}"
775        );
776    }
777
778    /// URLs inside Pandoc YAML metadata blocks must not be flagged.
779    #[test]
780    fn test_pandoc_skips_urls_in_metadata() {
781        use crate::config::MarkdownFlavor;
782        use crate::lint_context::LintContext;
783        let rule = MD034NoBareUrls;
784        let content = "---\nhomepage: https://example.com\n---\n\nBody.\n";
785        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
786        let result = rule.check(&ctx).unwrap();
787        assert!(
788            result.is_empty(),
789            "MD034 should skip URLs in Pandoc YAML metadata: {result:?}"
790        );
791    }
792
793    /// Standard flavor must still flag bare URLs in lines starting with `|`
794    /// (which are not interpreted as line blocks).
795    #[test]
796    fn test_standard_still_flags_urls_in_pipe_prefixed_lines() {
797        use crate::config::MarkdownFlavor;
798        use crate::lint_context::LintContext;
799        let rule = MD034NoBareUrls;
800        let content = "| See https://example.com\n";
801        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
802        let result = rule.check(&ctx).unwrap();
803        assert!(
804            !result.is_empty(),
805            "MD034 should still flag URLs in pipe-prefixed lines under Standard flavor"
806        );
807    }
808
809    #[test]
810    fn test_url_in_backticks_after_fenced_code_block_inside_mdx_not_flagged() {
811        // A fenced code block inside a JSX component must not misalign the code-span
812        // offset map. The URL in backticks that appears *after* the code block must
813        // still be recognised as being inside a code span.
814        let rule = MD034NoBareUrls;
815        let content = "\
816<Component>
817Some intro text.
818
819```
820example code here
821```
822
823Check `https://example.com/` here.
824</Component>
825";
826        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
827        let result = rule.check(&ctx).unwrap();
828        assert!(
829            result.is_empty(),
830            "URL in backticks after a fenced code block inside MDX must not be flagged: {result:?}"
831        );
832    }
833
834    /// Issue #642: a URL given as the argument of a MyST colon-fence directive
835    /// (`:::{name} <url>`) is the directive's opaque argument, not markdown prose,
836    /// and must not be wrapped in angle brackets.
837    #[test]
838    fn test_myst_colon_directive_argument_url_not_flagged() {
839        use crate::config::MarkdownFlavor;
840        use crate::lint_context::LintContext;
841        let rule = MD034NoBareUrls;
842        let content = "\
843:::{anywidget} https://cdn.jsdelivr.net/npm/repo-review-webapp@1.1.3/dist/repo-review-anywidget.mjs
844{
845  \"deps\": [\"repo-review~=1.1.0\"]
846}
847:::
848";
849        let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
850        let result = rule.check(&ctx).unwrap();
851        assert!(
852            result.is_empty(),
853            "URL argument on a MyST colon directive opener must not be flagged: {result:?}"
854        );
855    }
856
857    /// A nested MyST colon directive opener also carries an opaque argument.
858    #[test]
859    fn test_myst_nested_colon_directive_argument_url_not_flagged() {
860        use crate::config::MarkdownFlavor;
861        use crate::lint_context::LintContext;
862        let rule = MD034NoBareUrls;
863        let content = "\
864::::{grid}
865:::{card} https://example.com/card-target
866Some caption.
867:::
868::::
869";
870        let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
871        let result = rule.check(&ctx).unwrap();
872        assert!(
873            result.is_empty(),
874            "URL argument on a nested MyST colon directive opener must not be flagged: {result:?}"
875        );
876    }
877
878    /// A bare URL in the *body* of a content directive (e.g. `{note}`) is genuine
879    /// prose and must still be flagged. The opener exemption must not leak to the body.
880    #[test]
881    fn test_myst_directive_body_url_still_flagged() {
882        use crate::config::MarkdownFlavor;
883        use crate::lint_context::LintContext;
884        let rule = MD034NoBareUrls;
885        let content = "\
886:::{note}
887See https://example.com/docs for more details.
888:::
889";
890        let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
891        let result = rule.check(&ctx).unwrap();
892        assert_eq!(
893            result.len(),
894            1,
895            "Bare URL in a MyST directive body must still be flagged: {result:?}"
896        );
897    }
898
899    /// An unclosed colon directive (no terminating `:::`) still has its opener
900    /// argument treated as opaque: the URL must not be flagged.
901    #[test]
902    fn test_myst_unclosed_colon_directive_argument_url_not_flagged() {
903        use crate::config::MarkdownFlavor;
904        use crate::lint_context::LintContext;
905        let rule = MD034NoBareUrls;
906        let content = "\
907:::{anywidget} https://example.com/widget.mjs
908Some trailing content with no closing fence.
909";
910        let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
911        let result = rule.check(&ctx).unwrap();
912        assert!(
913            result.is_empty(),
914            "URL argument on an unclosed MyST colon directive opener must not be flagged: {result:?}"
915        );
916    }
917
918    /// The colon-directive exemption is MyST-specific: under the Standard flavor a
919    /// `:::{...}` line is ordinary text and a bare URL on it must still be flagged.
920    #[test]
921    fn test_colon_directive_url_flagged_in_standard_flavor() {
922        use crate::config::MarkdownFlavor;
923        use crate::lint_context::LintContext;
924        let rule = MD034NoBareUrls;
925        let content = ":::{anywidget} https://example.com/widget.mjs\n";
926        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
927        let result = rule.check(&ctx).unwrap();
928        assert_eq!(
929            result.len(),
930            1,
931            "Under Standard flavor a bare URL on a `:::` line must still be flagged: {result:?}"
932        );
933    }
934}