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