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 REFERENCE_DEF_REGEX: LazyLock<Regex> =
39 LazyLock::new(|| Regex::new(r"^\s*\[[^\]]+\]:\s*(?:<|(?:https?|ftps?)://)").unwrap());
40static MULTILINE_LINK_CONTINUATION_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"^[^\[]*\]\(.*\)"#).unwrap());
41static SHORTCUT_REF_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"\[([^\[\]]+)\]"#).unwrap());
42
43#[derive(Default)]
45struct LineCheckBuffers {
46 markdown_link_ranges: Vec<(usize, usize)>,
47 image_ranges: Vec<(usize, usize)>,
48 urls_found: Vec<(usize, usize, String)>,
49}
50
51#[derive(Default, Clone)]
52pub struct MD034NoBareUrls;
53
54impl MD034NoBareUrls {
55 #[inline]
56 pub fn should_skip_content(&self, content: &str) -> bool {
57 let bytes = content.as_bytes();
60 let has_colon = bytes.contains(&b':');
61 let has_at = bytes.contains(&b'@');
62 let has_www = content.contains("www.");
63 !has_colon && !has_at && !has_www
64 }
65
66 fn trim_trailing_punctuation<'a>(&self, url: &'a str) -> &'a str {
68 let mut trimmed = url;
69
70 let open_parens = url.chars().filter(|&c| c == '(').count();
72 let close_parens = url.chars().filter(|&c| c == ')').count();
73
74 if close_parens > open_parens {
75 let mut balance = 0;
77 let mut last_balanced_pos = url.len();
78
79 for (byte_idx, c) in url.char_indices() {
80 if c == '(' {
81 balance += 1;
82 } else if c == ')' {
83 balance -= 1;
84 if balance < 0 {
85 last_balanced_pos = byte_idx;
87 break;
88 }
89 }
90 }
91
92 trimmed = &trimmed[..last_balanced_pos];
93 }
94
95 while let Some(last_char) = trimmed.chars().last() {
97 if matches!(last_char, '.' | ',' | ';' | ':' | '!' | '?') {
98 if last_char == ':' && trimmed.len() > 1 {
101 break;
103 }
104 trimmed = &trimmed[..trimmed.len() - 1];
105 } else {
106 break;
107 }
108 }
109
110 trimmed
111 }
112
113 fn is_reference_definition(&self, line: &str) -> bool {
115 REFERENCE_DEF_REGEX.is_match(line)
116 }
117
118 fn check_line(
119 &self,
120 line: &str,
121 ctx: &LintContext,
122 line_number: usize,
123 code_spans: &[crate::lint_context::CodeSpan],
124 buffers: &mut LineCheckBuffers,
125 line_index: &LineIndex,
126 ) -> Vec<LintWarning> {
127 let mut warnings = Vec::new();
128
129 if self.is_reference_definition(line) {
131 return warnings;
132 }
133
134 if ctx.line_info(line_number).is_some_and(|info| info.in_html_block) {
136 return warnings;
137 }
138
139 if MULTILINE_LINK_CONTINUATION_REGEX.is_match(line) {
142 return warnings;
143 }
144
145 let has_quick_check = URL_QUICK_CHECK_REGEX.is_match(line);
147 let has_www = line.contains("www.");
148 let has_at = line.contains('@');
149
150 if !has_quick_check && !has_at && !has_www {
151 return warnings;
152 }
153
154 buffers.markdown_link_ranges.clear();
156 buffers.image_ranges.clear();
157
158 let has_bracket = line.contains('[');
159 let has_angle = line.contains('<');
160 let has_bang = line.contains('!');
161
162 if has_bracket {
163 for mat in MARKDOWN_LINK_REGEX.find_iter(line) {
164 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
165 }
166
167 for mat in MARKDOWN_EMPTY_LINK_REGEX.find_iter(line) {
169 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
170 }
171
172 for mat in MARKDOWN_EMPTY_REF_REGEX.find_iter(line) {
173 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
174 }
175
176 for mat in SHORTCUT_REF_REGEX.find_iter(line) {
178 let end = mat.end();
179 let next_non_ws = line[end..].bytes().find(|b| !b.is_ascii_whitespace());
180 if next_non_ws == Some(b'(') || next_non_ws == Some(b'[') {
181 continue;
182 }
183 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
184 }
185
186 if has_bang && BADGE_LINK_LINE_REGEX.is_match(line) {
188 return warnings;
189 }
190 }
191
192 if has_angle {
193 for mat in ANGLE_LINK_REGEX.find_iter(line) {
194 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
195 }
196 }
197
198 if has_bang && has_bracket {
200 for mat in MARKDOWN_IMAGE_REGEX.find_iter(line) {
201 buffers.image_ranges.push((mat.start(), mat.end()));
202 }
203 }
204
205 buffers.urls_found.clear();
207
208 for mat in URL_IPV6_REGEX.find_iter(line) {
210 let url_str = mat.as_str();
211 buffers.urls_found.push((mat.start(), mat.end(), url_str.to_string()));
212 }
213
214 for mat in URL_STANDARD_REGEX.find_iter(line) {
216 let url_str = mat.as_str();
217
218 if url_str.contains("://[") {
220 continue;
221 }
222
223 if let Some(host_start) = url_str.find("://") {
226 let after_protocol = &url_str[host_start + 3..];
227 if after_protocol.contains("::") || after_protocol.chars().filter(|&c| c == ':').count() > 1 {
229 if line.as_bytes().get(mat.end()) == Some(&b']') {
231 continue;
233 }
234 }
235 }
236
237 buffers.urls_found.push((mat.start(), mat.end(), url_str.to_string()));
238 }
239
240 for mat in URL_WWW_REGEX.find_iter(line) {
242 let url_str = mat.as_str();
243 let start_pos = mat.start();
244 let end_pos = mat.end();
245
246 if start_pos > 0 {
248 let prev_char = line.as_bytes().get(start_pos - 1).copied();
249 if prev_char == Some(b'/') || prev_char == Some(b'@') {
250 continue;
251 }
252 }
253
254 if start_pos > 0 && end_pos < line.len() {
256 let prev_char = line.as_bytes().get(start_pos - 1).copied();
257 let next_char = line.as_bytes().get(end_pos).copied();
258 if prev_char == Some(b'<') && next_char == Some(b'>') {
259 continue;
260 }
261 }
262
263 buffers.urls_found.push((start_pos, end_pos, url_str.to_string()));
264 }
265
266 for mat in XMPP_URI_REGEX.find_iter(line) {
268 let uri_str = mat.as_str();
269 let start_pos = mat.start();
270 let end_pos = mat.end();
271
272 if start_pos > 0 && end_pos < line.len() {
274 let prev_char = line.as_bytes().get(start_pos - 1).copied();
275 let next_char = line.as_bytes().get(end_pos).copied();
276 if prev_char == Some(b'<') && next_char == Some(b'>') {
277 continue;
278 }
279 }
280
281 buffers.urls_found.push((start_pos, end_pos, uri_str.to_string()));
282 }
283
284 for &(start, _end, ref url_str) in &buffers.urls_found {
286 if CUSTOM_PROTOCOL_REGEX.is_match(url_str) {
288 continue;
289 }
290
291 let is_inside_construct = buffers
297 .markdown_link_ranges
298 .iter()
299 .any(|&(s, e)| start >= s && start < e)
300 || buffers.image_ranges.iter().any(|&(s, e)| start >= s && start < e);
301
302 if is_inside_construct {
303 continue;
304 }
305
306 let line_start_byte = line_index.get_line_start_byte(line_number).unwrap_or(0);
308 let absolute_pos = line_start_byte + start;
309
310 if ctx.is_in_html_tag(absolute_pos) {
312 continue;
313 }
314
315 if ctx.is_in_jsx_component_tag(absolute_pos) {
319 continue;
320 }
321
322 if ctx.is_in_html_comment(absolute_pos) || ctx.is_in_mdx_comment(absolute_pos) {
324 continue;
325 }
326
327 if ctx.is_in_shortcode(absolute_pos) {
329 continue;
330 }
331
332 if ctx.flavor.is_pandoc_compatible()
336 && (ctx.is_in_line_block(absolute_pos) || ctx.is_in_pandoc_metadata(absolute_pos))
337 {
338 continue;
339 }
340
341 let trimmed_url = self.trim_trailing_punctuation(url_str);
343
344 if !trimmed_url.is_empty() && trimmed_url != "//" {
346 let trimmed_len = trimmed_url.len();
347 let (start_line, start_col, end_line, end_col) =
348 calculate_url_range(line_number, line, start, trimmed_len);
349
350 let replacement = if trimmed_url.starts_with("www.") {
352 format!("<https://{trimmed_url}>")
353 } else {
354 format!("<{trimmed_url}>")
355 };
356
357 warnings.push(LintWarning {
358 rule_name: Some("MD034".to_string()),
359 line: start_line,
360 column: start_col,
361 end_line,
362 end_column: end_col,
363 message: format!("URL without angle brackets or link formatting: '{trimmed_url}'"),
364 severity: Severity::Warning,
365 fix: Some(Fix::new(
366 {
367 let line_start_byte = line_index.get_line_start_byte(line_number).unwrap_or(0);
368 (line_start_byte + start)..(line_start_byte + start + trimmed_len)
369 },
370 replacement,
371 )),
372 });
373 }
374 }
375
376 for cap in EMAIL_PATTERN.captures_iter(line) {
378 if let Some(mat) = cap.get(0) {
379 let email = mat.as_str();
380 let start = mat.start();
381 let end = mat.end();
382
383 if start >= 5 && line.is_char_boundary(start - 5) && &line[start - 5..start] == "xmpp:" {
386 continue;
387 }
388
389 let mut is_inside_construct = false;
391 for &(link_start, link_end) in &buffers.markdown_link_ranges {
392 if start >= link_start && end <= link_end {
393 is_inside_construct = true;
394 break;
395 }
396 }
397
398 if !is_inside_construct {
399 let line_start_byte = line_index.get_line_start_byte(line_number).unwrap_or(0);
401 let absolute_pos = line_start_byte + start;
402
403 if ctx.is_in_html_tag(absolute_pos) {
405 continue;
406 }
407
408 if ctx.is_in_jsx_component_tag(absolute_pos) {
411 continue;
412 }
413
414 if ctx.flavor.is_pandoc_compatible()
416 && (ctx.is_in_line_block(absolute_pos) || ctx.is_in_pandoc_metadata(absolute_pos))
417 {
418 continue;
419 }
420
421 let is_in_code_span = code_spans
423 .iter()
424 .any(|span| absolute_pos >= span.byte_offset && absolute_pos < span.byte_end);
425
426 if !is_in_code_span {
427 let email_len = end - start;
428 let (start_line, start_col, end_line, end_col) =
429 calculate_url_range(line_number, line, start, email_len);
430
431 warnings.push(LintWarning {
432 rule_name: Some("MD034".to_string()),
433 line: start_line,
434 column: start_col,
435 end_line,
436 end_column: end_col,
437 message: format!("Email address without angle brackets or link formatting: '{email}'"),
438 severity: Severity::Warning,
439 fix: Some(Fix::new(
440 (line_start_byte + start)..(line_start_byte + end),
441 format!("<{email}>"),
442 )),
443 });
444 }
445 }
446 }
447 }
448
449 warnings
450 }
451}
452
453impl Rule for MD034NoBareUrls {
454 #[inline]
455 fn name(&self) -> &'static str {
456 "MD034"
457 }
458
459 fn as_any(&self) -> &dyn std::any::Any {
460 self
461 }
462
463 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
464 where
465 Self: Sized,
466 {
467 Box::new(MD034NoBareUrls)
468 }
469
470 #[inline]
471 fn category(&self) -> RuleCategory {
472 RuleCategory::Link
473 }
474
475 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
476 !ctx.likely_has_links_or_images() && self.should_skip_content(ctx.content)
477 }
478
479 #[inline]
480 fn description(&self) -> &'static str {
481 "No bare URLs - wrap URLs in angle brackets"
482 }
483
484 fn check(&self, ctx: &LintContext) -> LintResult {
485 let mut warnings = Vec::new();
486 let content = ctx.content;
487
488 if self.should_skip_content(content) {
490 return Ok(warnings);
491 }
492
493 let line_index = &ctx.line_index;
495
496 let code_spans = ctx.code_spans();
498
499 let mut buffers = LineCheckBuffers::default();
501
502 for line in ctx
506 .filtered_lines()
507 .skip_front_matter()
508 .skip_code_blocks()
509 .skip_jsx_expressions()
510 .skip_mdx_comments()
511 .skip_obsidian_comments()
512 {
513 if ctx.is_myst_colon_directive_opener_line(line.line_num) {
519 continue;
520 }
521
522 let mut line_warnings =
523 self.check_line(line.content, ctx, line.line_num, &code_spans, &mut buffers, line_index);
524
525 line_warnings.retain(|warning| {
527 !code_spans.iter().any(|span| {
528 if let Some(fix) = &warning.fix {
529 fix.range.start >= span.byte_offset && fix.range.start < span.byte_end
531 } else {
532 span.line == warning.line
533 && span.end_line == warning.line
534 && warning.column > 0
535 && (warning.column - 1) >= span.start_col
536 && (warning.column - 1) < span.end_col
537 }
538 })
539 });
540
541 line_warnings.retain(|warning| {
545 if let Some(fix) = &warning.fix {
546 !ctx.links
548 .iter()
549 .any(|link| fix.range.start >= link.byte_offset && fix.range.end <= link.byte_end)
550 } else {
551 true
552 }
553 });
554
555 line_warnings.retain(|warning| !ctx.is_position_in_obsidian_comment(warning.line, warning.column));
558
559 warnings.extend(line_warnings);
560 }
561
562 Ok(warnings)
563 }
564
565 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
566 let mut content = ctx.content.to_string();
567 let warnings = self.check(ctx)?;
568 let mut warnings =
569 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
570
571 warnings.sort_by_key(|w| w.fix.as_ref().map_or(0, |f| f.range.start));
573
574 for warning in warnings.iter().rev() {
576 if let Some(fix) = &warning.fix {
577 let start = fix.range.start;
578 let end = fix.range.end;
579 content.replace_range(start..end, &fix.replacement);
580 }
581 }
582
583 Ok(content)
584 }
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590
591 #[test]
592 fn test_shortcut_ref_at_end_of_line_no_trailing_chars() {
593 let rule = MD034NoBareUrls;
594 let content = "See [https://example.com]";
595 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
596 let result = rule.check(&ctx).unwrap();
597 assert!(
598 result.is_empty(),
599 "[URL] at end of line should be treated as shortcut ref: {result:?}"
600 );
601 }
602
603 #[test]
604 fn test_shortcut_ref_multiple_spaces_before_paren() {
605 let rule = MD034NoBareUrls;
606 let content = "[text] (https://example.com)";
607 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608 let result = rule.check(&ctx).unwrap();
609 let _ = result; }
614
615 #[test]
616 fn test_shortcut_ref_tab_before_bracket() {
617 let rule = MD034NoBareUrls;
618 let content = "[https://example.com]\t[other]";
619 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
620 let result = rule.check(&ctx).unwrap();
621 assert_eq!(
625 result.len(),
626 1,
627 "Bare URL inside shortcut ref should be detected: {result:?}"
628 );
629 }
630
631 #[test]
632 fn test_shortcut_ref_followed_by_punctuation() {
633 let rule = MD034NoBareUrls;
634 let content = "[https://example.com], see also other things.";
635 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
636 let result = rule.check(&ctx).unwrap();
637 assert!(
638 result.is_empty(),
639 "[URL] followed by comma should be treated as shortcut ref: {result:?}"
640 );
641 }
642
643 #[test]
644 fn test_url_in_backticks_inside_mdx_component_not_flagged() {
645 let rule = MD034NoBareUrls;
649 let content = "# Test\n\nControl: `https://rumdl.example.com/` is fine here.\n\n<ParamField path=\"--stuff\">\n This URL `https://rumdl.example.com/` must not be flagged.\n</ParamField>\n";
650 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
651 let result = rule.check(&ctx).unwrap();
652 assert!(
653 result.is_empty(),
654 "URL in backticks inside MDX component must not be flagged: {result:?}"
655 );
656 }
657
658 #[test]
659 fn test_bare_url_inside_mdx_component_still_flagged() {
660 let rule = MD034NoBareUrls;
663 let content =
664 "# Test\n\n<ParamField path=\"--stuff\">\n Visit https://rumdl.example.com/ for details.\n</ParamField>\n";
665 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
666 let result = rule.check(&ctx).unwrap();
667 assert_eq!(
668 result.len(),
669 1,
670 "Bare URL in MDX component body must still be flagged: {result:?}"
671 );
672 }
673
674 #[test]
675 fn test_url_in_backticks_inside_nested_mdx_component_not_flagged() {
676 let rule = MD034NoBareUrls;
678 let content = "<Outer>\n <Inner>\n Check `https://example.com/` here.\n </Inner>\n</Outer>\n";
679 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
680 let result = rule.check(&ctx).unwrap();
681 assert!(
682 result.is_empty(),
683 "URL in backticks inside nested MDX component must not be flagged: {result:?}"
684 );
685 }
686
687 #[test]
691 fn test_url_in_jsx_component_attribute_not_flagged() {
692 let rule = MD034NoBareUrls;
693 let content = "<Card title=\"Docs\" href=\"https://example.com/docs\" />\n";
694 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
695 let result = rule.check(&ctx).unwrap();
696 assert!(
697 result.is_empty(),
698 "URL in a JSX component attribute must not be flagged: {result:?}"
699 );
700 }
701
702 #[test]
704 fn test_url_in_multiline_jsx_component_attribute_not_flagged() {
705 let rule = MD034NoBareUrls;
706 let content = "<Card\n title=\"Docs\"\n href=\"https://example.com/docs\"\n/>\n";
707 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
708 let result = rule.check(&ctx).unwrap();
709 assert!(
710 result.is_empty(),
711 "URL in a multi-line JSX component attribute must not be flagged: {result:?}"
712 );
713 }
714
715 #[test]
718 fn test_jsx_attribute_url_skipped_but_body_url_flagged() {
719 let rule = MD034NoBareUrls;
720 let content = "<Card href=\"https://attr.example.com\">\n Visit https://body.example.com now.\n</Card>\n";
721 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
722 let result = rule.check(&ctx).unwrap();
723 assert_eq!(
724 result.len(),
725 1,
726 "Only the body URL must be flagged, not the attribute URL: {result:?}"
727 );
728 assert!(
729 result[0].message.contains("body.example.com"),
730 "The flagged URL must be the body one: {result:?}"
731 );
732 }
733
734 #[test]
737 fn test_email_in_jsx_component_attribute_not_flagged() {
738 let rule = MD034NoBareUrls;
739 let content = "<Contact email=\"hello@example.com\" />\n";
740 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
741 let result = rule.check(&ctx).unwrap();
742 assert!(
743 result.is_empty(),
744 "Email in a JSX component attribute must not be flagged: {result:?}"
745 );
746 }
747
748 #[test]
752 fn test_jsx_attribute_url_not_flagged_in_standard_flavor() {
753 let rule = MD034NoBareUrls;
754 let content = "<Card href=\"https://example.com/docs\" />\n";
755 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
756 let result = rule.check(&ctx).unwrap();
757 assert!(
758 result.is_empty(),
759 "URL in a tag attribute must not be flagged under Standard flavor either: {result:?}"
760 );
761 }
762
763 #[test]
765 fn test_pandoc_skips_urls_in_line_blocks() {
766 use crate::config::MarkdownFlavor;
767 use crate::lint_context::LintContext;
768 let rule = MD034NoBareUrls;
769 let content = "| See https://example.com\n| For details\n";
770 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
771 let result = rule.check(&ctx).unwrap();
772 assert!(
773 result.is_empty(),
774 "MD034 should skip URLs in Pandoc line blocks: {result:?}"
775 );
776 }
777
778 #[test]
780 fn test_pandoc_skips_urls_in_metadata() {
781 use crate::config::MarkdownFlavor;
782 use crate::lint_context::LintContext;
783 let rule = MD034NoBareUrls;
784 let content = "---\nhomepage: https://example.com\n---\n\nBody.\n";
785 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
786 let result = rule.check(&ctx).unwrap();
787 assert!(
788 result.is_empty(),
789 "MD034 should skip URLs in Pandoc YAML metadata: {result:?}"
790 );
791 }
792
793 #[test]
796 fn test_standard_still_flags_urls_in_pipe_prefixed_lines() {
797 use crate::config::MarkdownFlavor;
798 use crate::lint_context::LintContext;
799 let rule = MD034NoBareUrls;
800 let content = "| See https://example.com\n";
801 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
802 let result = rule.check(&ctx).unwrap();
803 assert!(
804 !result.is_empty(),
805 "MD034 should still flag URLs in pipe-prefixed lines under Standard flavor"
806 );
807 }
808
809 #[test]
810 fn test_url_in_backticks_after_fenced_code_block_inside_mdx_not_flagged() {
811 let rule = MD034NoBareUrls;
815 let content = "\
816<Component>
817Some intro text.
818
819```
820example code here
821```
822
823Check `https://example.com/` here.
824</Component>
825";
826 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
827 let result = rule.check(&ctx).unwrap();
828 assert!(
829 result.is_empty(),
830 "URL in backticks after a fenced code block inside MDX must not be flagged: {result:?}"
831 );
832 }
833
834 #[test]
838 fn test_myst_colon_directive_argument_url_not_flagged() {
839 use crate::config::MarkdownFlavor;
840 use crate::lint_context::LintContext;
841 let rule = MD034NoBareUrls;
842 let content = "\
843:::{anywidget} https://cdn.jsdelivr.net/npm/repo-review-webapp@1.1.3/dist/repo-review-anywidget.mjs
844{
845 \"deps\": [\"repo-review~=1.1.0\"]
846}
847:::
848";
849 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
850 let result = rule.check(&ctx).unwrap();
851 assert!(
852 result.is_empty(),
853 "URL argument on a MyST colon directive opener must not be flagged: {result:?}"
854 );
855 }
856
857 #[test]
859 fn test_myst_nested_colon_directive_argument_url_not_flagged() {
860 use crate::config::MarkdownFlavor;
861 use crate::lint_context::LintContext;
862 let rule = MD034NoBareUrls;
863 let content = "\
864::::{grid}
865:::{card} https://example.com/card-target
866Some caption.
867:::
868::::
869";
870 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
871 let result = rule.check(&ctx).unwrap();
872 assert!(
873 result.is_empty(),
874 "URL argument on a nested MyST colon directive opener must not be flagged: {result:?}"
875 );
876 }
877
878 #[test]
881 fn test_myst_directive_body_url_still_flagged() {
882 use crate::config::MarkdownFlavor;
883 use crate::lint_context::LintContext;
884 let rule = MD034NoBareUrls;
885 let content = "\
886:::{note}
887See https://example.com/docs for more details.
888:::
889";
890 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
891 let result = rule.check(&ctx).unwrap();
892 assert_eq!(
893 result.len(),
894 1,
895 "Bare URL in a MyST directive body must still be flagged: {result:?}"
896 );
897 }
898
899 #[test]
902 fn test_myst_unclosed_colon_directive_argument_url_not_flagged() {
903 use crate::config::MarkdownFlavor;
904 use crate::lint_context::LintContext;
905 let rule = MD034NoBareUrls;
906 let content = "\
907:::{anywidget} https://example.com/widget.mjs
908Some trailing content with no closing fence.
909";
910 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
911 let result = rule.check(&ctx).unwrap();
912 assert!(
913 result.is_empty(),
914 "URL argument on an unclosed MyST colon directive opener must not be flagged: {result:?}"
915 );
916 }
917
918 #[test]
921 fn test_colon_directive_url_flagged_in_standard_flavor() {
922 use crate::config::MarkdownFlavor;
923 use crate::lint_context::LintContext;
924 let rule = MD034NoBareUrls;
925 let content = ":::{anywidget} https://example.com/widget.mjs\n";
926 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
927 let result = rule.check(&ctx).unwrap();
928 assert_eq!(
929 result.len(),
930 1,
931 "Under Standard flavor a bare URL on a `:::` line must still be flagged: {result:?}"
932 );
933 }
934}