1use 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
17static 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#[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 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 fn trim_trailing_punctuation<'a>(&self, url: &'a str) -> &'a str {
66 let mut trimmed = url;
67
68 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 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 last_balanced_pos = byte_idx;
85 break;
86 }
87 }
88 }
89
90 trimmed = &trimmed[..last_balanced_pos];
91 }
92
93 while let Some(last_char) = trimmed.chars().last() {
95 if matches!(last_char, '.' | ',' | ';' | ':' | '!' | '?') {
96 if last_char == ':' && trimmed.len() > 1 {
99 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 if ctx.line_info(line_number).is_some_and(|info| info.in_html_block) {
124 return warnings;
125 }
126
127 if MULTILINE_LINK_CONTINUATION_REGEX.is_match(line) {
130 return warnings;
131 }
132
133 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 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 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 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 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 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 buffers.urls_found.clear();
195
196 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 for mat in URL_STANDARD_REGEX.find_iter(line) {
204 let url_str = mat.as_str();
205
206 if url_str.contains("://[") {
208 continue;
209 }
210
211 if let Some(host_start) = url_str.find("://") {
214 let after_protocol = &url_str[host_start + 3..];
215 if after_protocol.contains("::") || after_protocol.chars().filter(|&c| c == ':').count() > 1 {
217 if line.as_bytes().get(mat.end()) == Some(&b']') {
219 continue;
221 }
222 }
223 }
224
225 buffers.urls_found.push((mat.start(), mat.end(), url_str.to_string()));
226 }
227
228 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 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 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 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 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 for &(start, _end, ref url_str) in &buffers.urls_found {
274 if CUSTOM_PROTOCOL_REGEX.is_match(url_str) {
276 continue;
277 }
278
279 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 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 if ctx.is_in_html_tag(absolute_pos) {
300 continue;
301 }
302
303 if ctx.is_in_jsx_component_tag(absolute_pos) {
307 continue;
308 }
309
310 if ctx.is_in_html_comment(absolute_pos) || ctx.is_in_mdx_comment(absolute_pos) {
312 continue;
313 }
314
315 if ctx.is_in_shortcode(absolute_pos) {
317 continue;
318 }
319
320 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 let trimmed_url = self.trim_trailing_punctuation(url_str);
331
332 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 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 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 if start >= 5 && line.is_char_boundary(start - 5) && &line[start - 5..start] == "xmpp:" {
374 continue;
375 }
376
377 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 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 if ctx.is_in_html_tag(absolute_pos) {
393 continue;
394 }
395
396 if ctx.is_in_jsx_component_tag(absolute_pos) {
399 continue;
400 }
401
402 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 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 if self.should_skip_content(content) {
478 return Ok(warnings);
479 }
480
481 let line_index = &ctx.line_index;
483
484 let code_spans = ctx.code_spans();
486
487 let ref_def_lines: std::collections::HashSet<usize> = ctx.reference_defs.iter().map(|def| def.line).collect();
491
492 let mut buffers = LineCheckBuffers::default();
494
495 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 if ctx.is_myst_colon_directive_opener_line(line.line_num) {
512 continue;
513 }
514
515 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 line_warnings.retain(|warning| {
525 !code_spans.iter().any(|span| {
526 if let Some(fix) = &warning.fix {
527 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| {
543 if let Some(fix) = &warning.fix {
544 !ctx.links
546 .iter()
547 .any(|link| fix.range.start >= link.byte_offset && fix.range.end <= link.byte_end)
548 } else {
549 true
550 }
551 });
552
553 line_warnings.retain(|warning| !ctx.is_position_in_obsidian_comment(warning.line, warning.column));
556
557 warnings.extend(line_warnings);
558 }
559
560 Ok(warnings)
561 }
562
563 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
564 let mut content = ctx.content.to_string();
565 let warnings = self.check(ctx)?;
566 let mut warnings =
567 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
568
569 warnings.sort_by_key(|w| w.fix.as_ref().map_or(0, |f| f.range.start));
571
572 for warning in warnings.iter().rev() {
574 if let Some(fix) = &warning.fix {
575 let start = fix.range.start;
576 let end = fix.range.end;
577 content.replace_range(start..end, &fix.replacement);
578 }
579 }
580
581 Ok(content)
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 #[test]
590 fn test_shortcut_ref_at_end_of_line_no_trailing_chars() {
591 let rule = MD034NoBareUrls;
592 let content = "See [https://example.com]";
593 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
594 let result = rule.check(&ctx).unwrap();
595 assert!(
596 result.is_empty(),
597 "[URL] at end of line should be treated as shortcut ref: {result:?}"
598 );
599 }
600
601 #[test]
602 fn test_shortcut_ref_multiple_spaces_before_paren() {
603 let rule = MD034NoBareUrls;
604 let content = "[text] (https://example.com)";
605 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
606 let result = rule.check(&ctx).unwrap();
607 let _ = result; }
612
613 #[test]
614 fn test_shortcut_ref_tab_before_bracket() {
615 let rule = MD034NoBareUrls;
616 let content = "[https://example.com]\t[other]";
617 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
618 let result = rule.check(&ctx).unwrap();
619 assert_eq!(
623 result.len(),
624 1,
625 "Bare URL inside shortcut ref should be detected: {result:?}"
626 );
627 }
628
629 #[test]
630 fn test_shortcut_ref_followed_by_punctuation() {
631 let rule = MD034NoBareUrls;
632 let content = "[https://example.com], see also other things.";
633 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
634 let result = rule.check(&ctx).unwrap();
635 assert!(
636 result.is_empty(),
637 "[URL] followed by comma should be treated as shortcut ref: {result:?}"
638 );
639 }
640
641 #[test]
642 fn test_url_in_backticks_inside_mdx_component_not_flagged() {
643 let rule = MD034NoBareUrls;
647 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";
648 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
649 let result = rule.check(&ctx).unwrap();
650 assert!(
651 result.is_empty(),
652 "URL in backticks inside MDX component must not be flagged: {result:?}"
653 );
654 }
655
656 #[test]
657 fn test_bare_url_inside_mdx_component_still_flagged() {
658 let rule = MD034NoBareUrls;
661 let content =
662 "# Test\n\n<ParamField path=\"--stuff\">\n Visit https://rumdl.example.com/ for details.\n</ParamField>\n";
663 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
664 let result = rule.check(&ctx).unwrap();
665 assert_eq!(
666 result.len(),
667 1,
668 "Bare URL in MDX component body must still be flagged: {result:?}"
669 );
670 }
671
672 #[test]
673 fn test_url_in_backticks_inside_nested_mdx_component_not_flagged() {
674 let rule = MD034NoBareUrls;
676 let content = "<Outer>\n <Inner>\n Check `https://example.com/` here.\n </Inner>\n</Outer>\n";
677 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
678 let result = rule.check(&ctx).unwrap();
679 assert!(
680 result.is_empty(),
681 "URL in backticks inside nested MDX component must not be flagged: {result:?}"
682 );
683 }
684
685 #[test]
689 fn test_url_in_fenced_code_block_inside_jsx_not_flagged() {
690 let rule = MD034NoBareUrls;
691 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";
692 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
693 let result = rule.check(&ctx).unwrap();
694 assert!(
695 result.is_empty(),
696 "URL in a fenced code block nested in a JSX component must not be flagged: {result:?}"
697 );
698 }
699
700 #[test]
703 fn test_fix_does_not_rewrite_url_in_fenced_code_block_inside_jsx() {
704 let rule = MD034NoBareUrls;
705 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";
706 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
707 let fixed = rule.fix(&ctx).unwrap();
708 assert_eq!(
709 fixed, content,
710 "fix must not rewrite a URL inside a JSX-nested fenced code block"
711 );
712 }
713
714 #[test]
717 fn test_bare_url_in_jsx_body_outside_fence_still_flagged() {
718 let rule = MD034NoBareUrls;
719 let content = "# Title\n\n<Steps>\n <Step title=\"Send a request\">\n Visit https://example.com/api now.\n </Step>\n</Steps>\n";
720 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
721 let result = rule.check(&ctx).unwrap();
722 assert_eq!(
723 result.len(),
724 1,
725 "A bare URL in the JSX body (not in a fence) must still be flagged: {result:?}"
726 );
727 }
728
729 #[test]
733 fn test_bare_url_not_masked_by_comment_delimiter_in_code_block() {
734 let rule = MD034NoBareUrls;
735 let content =
736 "# T\n\n```text\n<!-- literal opener, not a comment\n```\n\nhttps://example.com should be flagged\n\n-->\n";
737 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
738 let result = rule.check(&ctx).unwrap();
739 assert_eq!(result.len(), 1, "the bare URL must still be flagged: {result:?}");
740 assert!(
741 result[0].message.contains("example.com"),
742 "the flagged URL must be the bare one: {result:?}"
743 );
744 }
745
746 #[test]
751 fn test_bare_url_in_indented_comment_in_admonition_still_skipped() {
752 let rule = MD034NoBareUrls;
753 let content = "# T\n\n!!! note\n Some text.\n\n <!--\n https://example.com\n -->\n";
754 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
755 let result = rule.check(&ctx).unwrap();
756 assert!(
757 result.is_empty(),
758 "URL inside an indented HTML comment in an admonition must not be flagged: {result:?}"
759 );
760 }
761
762 #[test]
766 fn test_url_in_jsx_component_attribute_not_flagged() {
767 let rule = MD034NoBareUrls;
768 let content = "<Card title=\"Docs\" href=\"https://example.com/docs\" />\n";
769 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
770 let result = rule.check(&ctx).unwrap();
771 assert!(
772 result.is_empty(),
773 "URL in a JSX component attribute must not be flagged: {result:?}"
774 );
775 }
776
777 #[test]
779 fn test_url_in_multiline_jsx_component_attribute_not_flagged() {
780 let rule = MD034NoBareUrls;
781 let content = "<Card\n title=\"Docs\"\n href=\"https://example.com/docs\"\n/>\n";
782 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
783 let result = rule.check(&ctx).unwrap();
784 assert!(
785 result.is_empty(),
786 "URL in a multi-line JSX component attribute must not be flagged: {result:?}"
787 );
788 }
789
790 #[test]
793 fn test_jsx_attribute_url_skipped_but_body_url_flagged() {
794 let rule = MD034NoBareUrls;
795 let content = "<Card href=\"https://attr.example.com\">\n Visit https://body.example.com now.\n</Card>\n";
796 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
797 let result = rule.check(&ctx).unwrap();
798 assert_eq!(
799 result.len(),
800 1,
801 "Only the body URL must be flagged, not the attribute URL: {result:?}"
802 );
803 assert!(
804 result[0].message.contains("body.example.com"),
805 "The flagged URL must be the body one: {result:?}"
806 );
807 }
808
809 #[test]
812 fn test_email_in_jsx_component_attribute_not_flagged() {
813 let rule = MD034NoBareUrls;
814 let content = "<Contact email=\"hello@example.com\" />\n";
815 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
816 let result = rule.check(&ctx).unwrap();
817 assert!(
818 result.is_empty(),
819 "Email in a JSX component attribute must not be flagged: {result:?}"
820 );
821 }
822
823 #[test]
827 fn test_jsx_attribute_url_not_flagged_in_standard_flavor() {
828 let rule = MD034NoBareUrls;
829 let content = "<Card href=\"https://example.com/docs\" />\n";
830 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
831 let result = rule.check(&ctx).unwrap();
832 assert!(
833 result.is_empty(),
834 "URL in a tag attribute must not be flagged under Standard flavor either: {result:?}"
835 );
836 }
837
838 #[test]
840 fn test_pandoc_skips_urls_in_line_blocks() {
841 use crate::config::MarkdownFlavor;
842 use crate::lint_context::LintContext;
843 let rule = MD034NoBareUrls;
844 let content = "| See https://example.com\n| For details\n";
845 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
846 let result = rule.check(&ctx).unwrap();
847 assert!(
848 result.is_empty(),
849 "MD034 should skip URLs in Pandoc line blocks: {result:?}"
850 );
851 }
852
853 #[test]
855 fn test_pandoc_skips_urls_in_metadata() {
856 use crate::config::MarkdownFlavor;
857 use crate::lint_context::LintContext;
858 let rule = MD034NoBareUrls;
859 let content = "---\nhomepage: https://example.com\n---\n\nBody.\n";
860 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
861 let result = rule.check(&ctx).unwrap();
862 assert!(
863 result.is_empty(),
864 "MD034 should skip URLs in Pandoc YAML metadata: {result:?}"
865 );
866 }
867
868 #[test]
871 fn test_standard_still_flags_urls_in_pipe_prefixed_lines() {
872 use crate::config::MarkdownFlavor;
873 use crate::lint_context::LintContext;
874 let rule = MD034NoBareUrls;
875 let content = "| See https://example.com\n";
876 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
877 let result = rule.check(&ctx).unwrap();
878 assert!(
879 !result.is_empty(),
880 "MD034 should still flag URLs in pipe-prefixed lines under Standard flavor"
881 );
882 }
883
884 #[test]
885 fn test_url_in_backticks_after_fenced_code_block_inside_mdx_not_flagged() {
886 let rule = MD034NoBareUrls;
890 let content = "\
891<Component>
892Some intro text.
893
894```
895example code here
896```
897
898Check `https://example.com/` here.
899</Component>
900";
901 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
902 let result = rule.check(&ctx).unwrap();
903 assert!(
904 result.is_empty(),
905 "URL in backticks after a fenced code block inside MDX must not be flagged: {result:?}"
906 );
907 }
908
909 #[test]
913 fn test_myst_colon_directive_argument_url_not_flagged() {
914 use crate::config::MarkdownFlavor;
915 use crate::lint_context::LintContext;
916 let rule = MD034NoBareUrls;
917 let content = "\
918:::{anywidget} https://cdn.jsdelivr.net/npm/repo-review-webapp@1.1.3/dist/repo-review-anywidget.mjs
919{
920 \"deps\": [\"repo-review~=1.1.0\"]
921}
922:::
923";
924 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
925 let result = rule.check(&ctx).unwrap();
926 assert!(
927 result.is_empty(),
928 "URL argument on a MyST colon directive opener must not be flagged: {result:?}"
929 );
930 }
931
932 #[test]
934 fn test_myst_nested_colon_directive_argument_url_not_flagged() {
935 use crate::config::MarkdownFlavor;
936 use crate::lint_context::LintContext;
937 let rule = MD034NoBareUrls;
938 let content = "\
939::::{grid}
940:::{card} https://example.com/card-target
941Some caption.
942:::
943::::
944";
945 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
946 let result = rule.check(&ctx).unwrap();
947 assert!(
948 result.is_empty(),
949 "URL argument on a nested MyST colon directive opener must not be flagged: {result:?}"
950 );
951 }
952
953 #[test]
956 fn test_myst_directive_body_url_still_flagged() {
957 use crate::config::MarkdownFlavor;
958 use crate::lint_context::LintContext;
959 let rule = MD034NoBareUrls;
960 let content = "\
961:::{note}
962See https://example.com/docs for more details.
963:::
964";
965 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
966 let result = rule.check(&ctx).unwrap();
967 assert_eq!(
968 result.len(),
969 1,
970 "Bare URL in a MyST directive body must still be flagged: {result:?}"
971 );
972 }
973
974 #[test]
977 fn test_myst_unclosed_colon_directive_argument_url_not_flagged() {
978 use crate::config::MarkdownFlavor;
979 use crate::lint_context::LintContext;
980 let rule = MD034NoBareUrls;
981 let content = "\
982:::{anywidget} https://example.com/widget.mjs
983Some trailing content with no closing fence.
984";
985 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
986 let result = rule.check(&ctx).unwrap();
987 assert!(
988 result.is_empty(),
989 "URL argument on an unclosed MyST colon directive opener must not be flagged: {result:?}"
990 );
991 }
992
993 #[test]
996 fn test_colon_directive_url_flagged_in_standard_flavor() {
997 use crate::config::MarkdownFlavor;
998 use crate::lint_context::LintContext;
999 let rule = MD034NoBareUrls;
1000 let content = ":::{anywidget} https://example.com/widget.mjs\n";
1001 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1002 let result = rule.check(&ctx).unwrap();
1003 assert_eq!(
1004 result.len(),
1005 1,
1006 "Under Standard flavor a bare URL on a `:::` line must still be flagged: {result:?}"
1007 );
1008 }
1009}