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| {
540 if let Some(fix) = &warning.fix {
541 !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 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 warnings.sort_by_key(|w| w.fix.as_ref().map_or(0, |f| f.range.start));
570
571 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 let _ = result; }
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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 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}