1use std::sync::LazyLock;
5
6use regex::Regex;
7
8use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
9use crate::utils::range_utils::calculate_url_range;
10use crate::utils::regex_cache::{EMAIL_PATTERN, URL_IPV6_REGEX, URL_STANDARD_REGEX, URL_WWW_REGEX, XMPP_URI_REGEX};
11
12use crate::filtered_lines::FilteredLinesExt;
13use crate::lint_context::LintContext;
14
15fn collect_urls(line: &str, found: &mut Vec<(usize, usize)>) {
18 found.clear();
19 if line.contains("://[") {
20 for mat in URL_IPV6_REGEX.find_iter(line) {
22 found.push((mat.start(), mat.end()));
23 }
24 }
25 if line.contains("://") {
26 for mat in URL_STANDARD_REGEX.find_iter(line) {
28 let url_str = mat.as_str();
29
30 if url_str.contains("://[") {
32 continue;
33 }
34
35 if let Some(host_start) = url_str.find("://") {
38 let after_protocol = &url_str[host_start + 3..];
39 if after_protocol.contains("::") || after_protocol.chars().filter(|&c| c == ':').count() > 1 {
41 if line.as_bytes().get(mat.end()) == Some(&b']') {
43 continue;
45 }
46 }
47 }
48
49 found.push((mat.start(), mat.end()));
50 }
51 }
52 if line.contains("www.") {
53 for mat in URL_WWW_REGEX.find_iter(line) {
55 let start_pos = mat.start();
56 let end_pos = mat.end();
57
58 if start_pos > 0 {
60 let prev_char = line.as_bytes().get(start_pos - 1).copied();
61 if prev_char == Some(b'/') || prev_char == Some(b'@') {
62 continue;
63 }
64 }
65
66 if start_pos > 0 && end_pos < line.len() {
68 let prev_char = line.as_bytes().get(start_pos - 1).copied();
69 let next_char = line.as_bytes().get(end_pos).copied();
70 if prev_char == Some(b'<') && next_char == Some(b'>') {
71 continue;
72 }
73 }
74
75 found.push((start_pos, end_pos));
76 }
77 }
78 if line.contains("xmpp:") {
79 for mat in XMPP_URI_REGEX.find_iter(line) {
81 let start_pos = mat.start();
82 let end_pos = mat.end();
83
84 if start_pos > 0 && end_pos < line.len() {
86 let prev_char = line.as_bytes().get(start_pos - 1).copied();
87 let next_char = line.as_bytes().get(end_pos).copied();
88 if prev_char == Some(b'<') && next_char == Some(b'>') {
89 continue;
90 }
91 }
92
93 found.push((start_pos, end_pos));
94 }
95 }
96 found.sort_by_key(|&(start, end)| (start, std::cmp::Reverse(end)));
101 let mut previous_end = 0;
102 found.retain(|&(start, end)| {
103 if start < previous_end {
104 false
105 } else {
106 previous_end = end;
107 true
108 }
109 });
110}
111
112#[cfg(test)]
113fn collect_urls_legacy(line: &str, found: &mut Vec<(usize, usize)>) {
114 for mat in URL_IPV6_REGEX.find_iter(line) {
116 found.push((mat.start(), mat.end()));
117 }
118
119 for mat in URL_STANDARD_REGEX.find_iter(line) {
121 let url_str = mat.as_str();
122
123 if url_str.contains("://[") {
125 continue;
126 }
127
128 if let Some(host_start) = url_str.find("://") {
131 let after_protocol = &url_str[host_start + 3..];
132 if after_protocol.contains("::") || after_protocol.chars().filter(|&c| c == ':').count() > 1 {
134 if line.as_bytes().get(mat.end()) == Some(&b']') {
136 continue;
138 }
139 }
140 }
141
142 found.push((mat.start(), mat.end()));
143 }
144
145 for mat in URL_WWW_REGEX.find_iter(line) {
147 let start_pos = mat.start();
148 let end_pos = mat.end();
149
150 if start_pos > 0 {
152 let prev_char = line.as_bytes().get(start_pos - 1).copied();
153 if prev_char == Some(b'/') || prev_char == Some(b'@') {
154 continue;
155 }
156 }
157
158 if start_pos > 0 && end_pos < line.len() {
160 let prev_char = line.as_bytes().get(start_pos - 1).copied();
161 let next_char = line.as_bytes().get(end_pos).copied();
162 if prev_char == Some(b'<') && next_char == Some(b'>') {
163 continue;
164 }
165 }
166
167 found.push((start_pos, end_pos));
168 }
169
170 for mat in XMPP_URI_REGEX.find_iter(line) {
172 let start_pos = mat.start();
173 let end_pos = mat.end();
174
175 if start_pos > 0 && end_pos < line.len() {
177 let prev_char = line.as_bytes().get(start_pos - 1).copied();
178 let next_char = line.as_bytes().get(end_pos).copied();
179 if prev_char == Some(b'<') && next_char == Some(b'>') {
180 continue;
181 }
182 }
183
184 found.push((start_pos, end_pos));
185 }
186
187 found.sort_by_key(|&(start, end)| (start, std::cmp::Reverse(end)));
192 let mut previous_end = 0;
193 found.retain(|&(start, end)| {
194 if start < previous_end {
195 false
196 } else {
197 previous_end = end;
198 true
199 }
200 });
201}
202
203static MARKDOWN_LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
205 Regex::new(r#"\[(?:[^\[\]]|\[[^\]]*\])*\]\(([^)\s]+)(?:\s+(?:\"[^\"]*\"|\'[^\']*\'))?\)"#).unwrap()
206});
207static MARKDOWN_EMPTY_LINK_REGEX: LazyLock<Regex> =
208 LazyLock::new(|| Regex::new(r#"\[(?:[^\[\]]|\[[^\]]*\])*\]\(\)"#).unwrap());
209static MARKDOWN_EMPTY_REF_REGEX: LazyLock<Regex> =
210 LazyLock::new(|| Regex::new(r#"\[(?:[^\[\]]|\[[^\]]*\])*\]\[\]"#).unwrap());
211static ANGLE_LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
212 Regex::new(
213 r#"<((?:https?|ftps?)://(?:\[[0-9a-fA-F:]+(?:%[a-zA-Z0-9]+)?\]|[^>]+)|xmpp:[^>]+|[^@\s]+@[^@\s]+\.[^@\s>]+)>"#,
214 )
215 .unwrap()
216});
217static BADGE_LINK_LINE_REGEX: LazyLock<Regex> =
218 LazyLock::new(|| Regex::new(r#"^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*$"#).unwrap());
219static MARKDOWN_IMAGE_REGEX: LazyLock<Regex> =
220 LazyLock::new(|| Regex::new(r#"!\s*\[([^\]]*)\]\s*\(([^)\s]+)(?:\s+(?:\"[^\"]*\"|\'[^\']*\'))?\)"#).unwrap());
221static SHORTCUT_REF_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"\[([^\[\]]+)\]"#).unwrap());
222static SCHEMED_URI_REGEX: LazyLock<Regex> =
226 LazyLock::new(|| Regex::new(r#"[a-zA-Z][a-zA-Z0-9+.-]*://[^\s<>\\'\"`]+"#).unwrap());
227
228const MDX_LINK_TEXT_ESCAPES: [char; 12] = ['\\', '`', '*', '_', '{', '}', '[', ']', '<', '>', '~', '&'];
245
246fn escape_mdx_link_text(text: &str) -> String {
248 let mut escaped = String::with_capacity(text.len());
249 for ch in text.chars() {
250 if MDX_LINK_TEXT_ESCAPES.contains(&ch) {
251 escaped.push('\\');
252 }
253 escaped.push(ch);
254 }
255 escaped
256}
257
258fn has_balanced_parens(url: &str) -> bool {
263 let mut depth: i32 = 0;
264 for ch in url.chars() {
265 match ch {
266 '(' => depth += 1,
267 ')' => {
268 depth -= 1;
269 if depth < 0 {
270 return false;
271 }
272 }
273 _ => {}
274 }
275 }
276 depth == 0
277}
278
279fn jsx_safe_link(text: &str, destination: &str) -> String {
290 let escaped = escape_mdx_link_text(text);
291 if has_balanced_parens(destination) {
292 format!("[{escaped}]({destination})")
293 } else {
294 format!("[{escaped}](<{destination}>)")
295 }
296}
297
298enum LinkPrefix {
305 Free,
307 ActiveBang,
309 ActiveCloseBracket,
313}
314
315fn classify_link_prefix(line: &str, start: usize) -> LinkPrefix {
317 let before = &line[..start];
318 let Some(last) = before.chars().next_back() else {
319 return LinkPrefix::Free;
320 };
321 if last != '!' && last != ']' {
322 return LinkPrefix::Free;
323 }
324
325 let preceding = &before[..before.len() - last.len_utf8()];
328 if preceding.bytes().rev().take_while(|&b| b == b'\\').count() % 2 == 1 {
329 return LinkPrefix::Free;
330 }
331
332 if last == '!' {
333 LinkPrefix::ActiveBang
334 } else {
335 LinkPrefix::ActiveCloseBracket
336 }
337}
338
339fn jsx_fix(line: &str, start: usize, text: &str, destination: &str) -> Option<(usize, String)> {
345 let link = jsx_safe_link(text, destination);
346 match classify_link_prefix(line, start) {
347 LinkPrefix::Free => Some((start, link)),
348 LinkPrefix::ActiveBang => Some((start - 1, format!("\\!{link}"))),
351 LinkPrefix::ActiveCloseBracket => None,
354 }
355}
356
357fn follows_uri_scheme(line: &str, start: usize) -> bool {
368 let prefix = &line[..start];
369 let Some(before) = prefix.strip_suffix("://").or_else(|| prefix.strip_suffix(':')) else {
370 return false;
371 };
372 let scheme: &str = {
373 let tail = before.len() - before.bytes().rev().take_while(|b| is_scheme_byte(*b)).count();
374 &before[tail..]
375 };
376 scheme.bytes().next().is_some_and(|b| b.is_ascii_alphabetic())
377}
378
379fn is_scheme_byte(b: u8) -> bool {
381 b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.')
382}
383
384fn uri_content_end(uri: &str) -> usize {
387 let mut parens = 0usize;
388 let mut brackets = 0usize;
389 for (offset, byte) in uri.bytes().enumerate() {
390 match byte {
391 b'(' => parens += 1,
392 b'[' => brackets += 1,
393 b')' if parens == 0 => return offset,
394 b']' if brackets == 0 => return offset,
395 b')' => parens -= 1,
396 b']' => brackets -= 1,
397 _ => {}
398 }
399 }
400 uri.len()
401}
402
403#[derive(Default)]
405struct LineCheckBuffers {
406 markdown_link_ranges: Vec<(usize, usize)>,
407 image_ranges: Vec<(usize, usize)>,
408 urls_found: Vec<(usize, usize)>,
409 schemed_uri_ranges: Vec<(usize, usize)>,
410}
411
412#[derive(Default, Clone)]
413pub struct MD034NoBareUrls;
414
415impl MD034NoBareUrls {
416 #[inline]
417 pub fn should_skip_content(&self, content: &str) -> bool {
418 let bytes = content.as_bytes();
421 let has_colon = bytes.contains(&b':');
422 let has_at = bytes.contains(&b'@');
423 let has_www = content.contains("www.");
424 !has_colon && !has_at && !has_www
425 }
426
427 fn trim_trailing_punctuation<'a>(&self, url: &'a str) -> &'a str {
429 let mut trimmed = url;
430
431 let open_parens = url.chars().filter(|&c| c == '(').count();
433 let close_parens = url.chars().filter(|&c| c == ')').count();
434
435 if close_parens > open_parens {
436 let mut balance = 0;
438 let mut last_balanced_pos = url.len();
439
440 for (byte_idx, c) in url.char_indices() {
441 if c == '(' {
442 balance += 1;
443 } else if c == ')' {
444 balance -= 1;
445 if balance < 0 {
446 last_balanced_pos = byte_idx;
448 break;
449 }
450 }
451 }
452
453 trimmed = &trimmed[..last_balanced_pos];
454 }
455
456 trimmed.trim_end_matches(['.', ',', ';', ':', '!', '?', '*', '_', '~'])
464 }
465
466 fn check_line(
467 &self,
468 line: &str,
469 ctx: &LintContext,
470 line_number: usize,
471 buffers: &mut LineCheckBuffers,
472 ) -> Vec<LintWarning> {
473 let mut warnings = Vec::new();
474
475 if ctx
477 .line_info(line_number)
478 .is_some_and(|info| info.in_html_block || info.is_myst_comment)
479 {
480 return warnings;
481 }
482
483 let has_quick_check = line.contains("://") || line.contains("xmpp:");
485 let has_www = line.contains("www.");
486 let has_at = line.contains('@');
487
488 if !has_quick_check && !has_at && !has_www {
489 return warnings;
490 }
491
492 buffers.markdown_link_ranges.clear();
494 buffers.image_ranges.clear();
495 buffers.schemed_uri_ranges.clear();
496
497 if has_at && line.contains("://") {
498 for mat in SCHEMED_URI_REGEX.find_iter(line) {
499 if mat.start() > 0 && is_scheme_byte(line.as_bytes()[mat.start() - 1]) {
502 continue;
503 }
504 let end = mat.start() + uri_content_end(mat.as_str());
505 buffers.schemed_uri_ranges.push((mat.start(), end));
506 }
507 }
508
509 let has_bracket = line.contains('[');
510 let has_angle = line.contains('<');
511 let has_bang = line.contains('!');
512
513 if has_bracket {
514 for mat in MARKDOWN_LINK_REGEX.find_iter(line) {
515 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
516 }
517
518 for mat in MARKDOWN_EMPTY_LINK_REGEX.find_iter(line) {
520 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
521 }
522
523 for mat in MARKDOWN_EMPTY_REF_REGEX.find_iter(line) {
524 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
525 }
526
527 for mat in SHORTCUT_REF_REGEX.find_iter(line) {
529 let end = mat.end();
530 let next_non_ws = line[end..].bytes().find(|b| !b.is_ascii_whitespace());
531 if next_non_ws == Some(b'(') || next_non_ws == Some(b'[') {
532 continue;
533 }
534 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
535 }
536
537 if has_bang && BADGE_LINK_LINE_REGEX.is_match(line) {
539 return warnings;
540 }
541 }
542
543 if has_angle {
544 for mat in ANGLE_LINK_REGEX.find_iter(line) {
545 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
546 }
547 }
548
549 if has_bang && has_bracket {
551 for mat in MARKDOWN_IMAGE_REGEX.find_iter(line) {
552 buffers.image_ranges.push((mat.start(), mat.end()));
553 }
554 }
555
556 buffers.urls_found.clear();
558
559 collect_urls(line, &mut buffers.urls_found);
560
561 for &(start, end) in &buffers.urls_found {
563 let url_str = &line[start..end];
564 let is_inside_construct = buffers
570 .markdown_link_ranges
571 .iter()
572 .any(|&(s, e)| start >= s && start < e)
573 || buffers.image_ranges.iter().any(|&(s, e)| start >= s && start < e);
574
575 if is_inside_construct {
576 continue;
577 }
578
579 let line_start_byte = ctx.line_start_byte(line_number).unwrap_or(0);
581 let absolute_pos = line_start_byte + start;
582
583 if ctx
584 .image_containing(absolute_pos)
585 .is_some_and(|image| !(image.is_reference && image.url.is_empty()))
586 {
587 continue;
588 }
589
590 if ctx.is_in_jinja_string(absolute_pos) {
592 continue;
593 }
594
595 if ctx.overlaps_mdx_inline_code(absolute_pos, line_start_byte + end) {
596 continue;
597 }
598
599 if ctx.is_in_math_span(absolute_pos) {
601 continue;
602 }
603
604 if ctx.is_in_html_tag(absolute_pos) {
606 continue;
607 }
608
609 if ctx.is_in_jsx_component_tag(absolute_pos) {
613 continue;
614 }
615
616 if ctx.is_in_html_comment(absolute_pos) || ctx.is_in_mdx_comment(absolute_pos) {
618 continue;
619 }
620
621 if ctx.is_in_shortcode(absolute_pos) {
623 continue;
624 }
625
626 if ctx.flavor.is_pandoc_compatible()
630 && (ctx.is_in_line_block(absolute_pos) || ctx.is_in_pandoc_metadata(absolute_pos))
631 {
632 continue;
633 }
634
635 let trimmed_url = self.trim_trailing_punctuation(url_str);
637
638 if !trimmed_url.is_empty() && !is_scheme_without_host(trimmed_url) {
641 let trimmed_len = trimmed_url.len();
642 let (start_line, start_col, end_line, end_col) =
643 calculate_url_range(line_number, line, start, trimmed_len);
644
645 let destination = if trimmed_url.starts_with("www.") {
647 format!("https://{trimmed_url}")
648 } else {
649 trimmed_url.to_string()
650 };
651 let line_start_byte = ctx.line_start_byte(line_number).unwrap_or(0);
652 let span_end = line_start_byte + start + trimmed_len;
653 let fix = if ctx.flavor.supports_jsx() {
654 jsx_fix(line, start, trimmed_url, &destination)
655 .map(|(fix_start, replacement)| Fix::new((line_start_byte + fix_start)..span_end, replacement))
656 } else {
657 Some(Fix::new(
658 (line_start_byte + start)..span_end,
659 format!("<{destination}>"),
660 ))
661 };
662
663 warnings.push(LintWarning {
664 rule_name: Some("MD034".to_string()),
665 line: start_line,
666 column: start_col,
667 end_line,
668 end_column: end_col,
669 message: if ctx.flavor == crate::config::MarkdownFlavor::MDG {
670 format!(
671 "URL without link formatting: '{trimmed_url}' (angle brackets are Gherkin placeholder syntax; use explicit link formatting where appropriate, or disable MD034)"
672 )
673 } else {
674 format!("URL without angle brackets or link formatting: '{trimmed_url}'")
675 },
676 severity: Severity::Warning,
677 fix,
678 });
679 }
680 }
681
682 for cap in EMAIL_PATTERN.captures_iter(line) {
684 if let Some(mat) = cap.get(0) {
685 let email = mat.as_str();
686 let start = mat.start();
687 let end = mat.end();
688
689 let url_index = buffers.urls_found.partition_point(|&(url_start, _)| url_start <= start);
692 if url_index > 0 && end <= buffers.urls_found[url_index - 1].1 {
693 continue;
694 }
695
696 if follows_uri_scheme(line, start) {
699 continue;
700 }
701
702 let uri_index = buffers
703 .schemed_uri_ranges
704 .partition_point(|&(uri_start, _)| uri_start <= start);
705 if uri_index > 0 && end <= buffers.schemed_uri_ranges[uri_index - 1].1 {
706 continue;
707 }
708
709 let mut is_inside_construct = false;
711 for &(link_start, link_end) in &buffers.markdown_link_ranges {
712 if start >= link_start && end <= link_end {
713 is_inside_construct = true;
714 break;
715 }
716 }
717
718 if !is_inside_construct {
719 let line_start_byte = ctx.line_start_byte(line_number).unwrap_or(0);
721 let absolute_pos = line_start_byte + start;
722
723 if ctx
724 .image_containing(absolute_pos)
725 .is_some_and(|image| !(image.is_reference && image.url.is_empty()))
726 {
727 continue;
728 }
729
730 if ctx.is_in_jinja_string(absolute_pos) {
731 continue;
732 }
733
734 if ctx.overlaps_mdx_inline_code(absolute_pos, line_start_byte + end) {
735 continue;
736 }
737
738 if ctx.is_in_math_span(absolute_pos) {
739 continue;
740 }
741
742 if ctx.is_in_html_tag(absolute_pos) {
744 continue;
745 }
746
747 if ctx.is_in_jsx_component_tag(absolute_pos) {
750 continue;
751 }
752
753 if ctx.is_in_html_comment(absolute_pos)
755 || ctx.is_in_mdx_comment(absolute_pos)
756 || ctx.is_in_shortcode(absolute_pos)
757 {
758 continue;
759 }
760
761 if ctx.flavor.is_pandoc_compatible()
763 && (ctx.is_in_line_block(absolute_pos) || ctx.is_in_pandoc_metadata(absolute_pos))
764 {
765 continue;
766 }
767
768 let is_in_code_span = ctx.is_byte_offset_in_code_span(absolute_pos);
770
771 if !is_in_code_span {
772 let email_len = end - start;
773 let (start_line, start_col, end_line, end_col) =
774 calculate_url_range(line_number, line, start, email_len);
775
776 let fix = if ctx.flavor.supports_jsx() {
777 jsx_fix(line, start, email, &format!("mailto:{email}")).map(|(fix_start, replacement)| {
778 Fix::new((line_start_byte + fix_start)..(line_start_byte + end), replacement)
779 })
780 } else {
781 Some(Fix::new(
782 (line_start_byte + start)..(line_start_byte + end),
783 format!("<{email}>"),
784 ))
785 };
786
787 warnings.push(LintWarning {
788 rule_name: Some("MD034".to_string()),
789 line: start_line,
790 column: start_col,
791 end_line,
792 end_column: end_col,
793 message: if ctx.flavor == crate::config::MarkdownFlavor::MDG {
794 format!(
795 "Email address without link formatting: '{email}' (angle brackets are Gherkin placeholder syntax; use explicit link formatting where appropriate, or disable MD034)"
796 )
797 } else {
798 format!("Email address without angle brackets or link formatting: '{email}'")
799 },
800 severity: Severity::Warning,
801 fix,
802 });
803 }
804 }
805 }
806 }
807
808 warnings
809 }
810}
811
812impl Rule for MD034NoBareUrls {
813 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
814 Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(34))
815 }
816
817 #[inline]
818 fn name(&self) -> &'static str {
819 "MD034"
820 }
821
822 fn as_any(&self) -> &dyn std::any::Any {
823 self
824 }
825
826 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
827 where
828 Self: Sized,
829 {
830 Box::new(MD034NoBareUrls)
831 }
832
833 #[inline]
834 fn category(&self) -> RuleCategory {
835 RuleCategory::Link
836 }
837
838 fn skippable_by_category(&self) -> bool {
839 false
844 }
845
846 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
847 !ctx.likely_has_links_or_images() && self.should_skip_content(ctx.content)
848 }
849
850 #[inline]
851 fn description(&self) -> &'static str {
852 "No bare URLs - wrap URLs in angle brackets"
853 }
854
855 fn check(&self, ctx: &LintContext) -> LintResult {
856 let mut warnings = Vec::new();
857 let content = ctx.content;
858
859 if self.should_skip_content(content) {
861 return Ok(warnings);
862 }
863
864 let ref_def_lines: std::collections::HashSet<usize> =
868 ctx.reference_definitions().iter().map(|def| def.line).collect();
869
870 let mut buffers = LineCheckBuffers::default();
872
873 for line in ctx
877 .filtered_lines()
878 .skip_front_matter()
879 .skip_code_blocks()
880 .skip_esm_blocks()
881 .skip_obsidian_comments()
882 .candidates(crate::lint_context::LineCandidateKind::Url)
883 {
884 if ctx.flavor == crate::config::MarkdownFlavor::GhAw && crate::utils::gh_aw::is_control_line(line.content) {
888 continue;
889 }
890
891 if ctx.is_myst_colon_directive_opener_line(line.line_num) {
897 continue;
898 }
899
900 if ref_def_lines.contains(&line.line_num) {
902 continue;
903 }
904
905 let mut line_warnings = self.check_line(line.content, ctx, line.line_num, &mut buffers);
906
907 line_warnings.retain(|warning| {
909 if let Some(fix) = &warning.fix {
910 !ctx.is_byte_offset_in_code_span(fix.range.start)
911 } else {
912 !ctx.source_code_spans().iter().any(|span| {
913 span.line == warning.line
914 && span.end_line == warning.line
915 && warning.column > 0
916 && (warning.column - 1) >= span.start_col
917 && (warning.column - 1) < span.end_col
918 })
919 }
920 });
921
922 line_warnings.retain(|warning| {
923 if let Some(fix) = &warning.fix {
924 !ctx.is_range_in_rendered_link(&fix.range)
926 } else {
927 true
928 }
929 });
930
931 line_warnings.retain(|warning| !ctx.is_position_in_obsidian_comment(warning.line, warning.column));
934
935 for warning in &mut line_warnings {
940 if let Some(fix) = &warning.fix {
941 let source = &ctx.content[fix.range.clone()];
942 if !source.contains('{') {
943 continue;
944 }
945 let crosses_statement = source
946 .match_indices("{%")
947 .any(|(offset, _)| ctx.is_in_jinja_range(fix.range.start + offset));
948 let partial_expression = source.contains("{{") && ctx.is_in_jinja_range(fix.range.end);
949 if crosses_statement || partial_expression {
950 warning.fix = None;
951 }
952 }
953 }
954
955 warnings.extend(line_warnings);
956 }
957
958 if ctx.flavor == crate::config::MarkdownFlavor::MDG {
962 for warning in &mut warnings {
963 warning.fix = None;
964 }
965 }
966
967 Ok(warnings)
968 }
969
970 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
971 let mut content = ctx.content.to_string();
972 let warnings = self.check(ctx)?;
973 let mut warnings =
974 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
975
976 warnings.sort_by_key(|w| w.fix.as_ref().map_or(0, |f| f.range.start));
978
979 for warning in warnings.iter().rev() {
981 if let Some(fix) = &warning.fix {
982 let start = fix.range.start;
983 let end = fix.range.end;
984 content.replace_range(start..end, &fix.replacement);
985 }
986 }
987
988 Ok(content)
989 }
990}
991
992fn is_scheme_without_host(url: &str) -> bool {
999 url.split_once("://").is_some_and(|(scheme, authority)| {
1000 !scheme.is_empty()
1001 && scheme
1002 .chars()
1003 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
1004 && !authority.starts_with('[')
1005 && authority.chars().all(|c| c.is_ascii_punctuation())
1006 })
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011 use super::*;
1012
1013 #[test]
1014 fn test_shortcut_ref_at_end_of_line_no_trailing_chars() {
1015 let rule = MD034NoBareUrls;
1016 let content = "See [https://example.com]";
1017 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1018 let result = rule.check(&ctx).unwrap();
1019 assert!(
1020 result.is_empty(),
1021 "[URL] at end of line should be treated as shortcut ref: {result:?}"
1022 );
1023 }
1024
1025 #[test]
1026 fn test_shortcut_ref_multiple_spaces_before_paren() {
1027 let rule = MD034NoBareUrls;
1028 let content = "[text] (https://example.com)";
1029 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1030 let result = rule.check(&ctx).unwrap();
1031 let _ = result; }
1036
1037 #[test]
1038 fn test_shortcut_ref_tab_before_bracket() {
1039 let rule = MD034NoBareUrls;
1040 let content = "[https://example.com]\t[other]";
1041 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1042 let result = rule.check(&ctx).unwrap();
1043 assert_eq!(
1047 result.len(),
1048 1,
1049 "Bare URL inside shortcut ref should be detected: {result:?}"
1050 );
1051 }
1052
1053 #[test]
1054 fn test_shortcut_ref_followed_by_punctuation() {
1055 let rule = MD034NoBareUrls;
1056 let content = "[https://example.com], see also other things.";
1057 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1058 let result = rule.check(&ctx).unwrap();
1059 assert!(
1060 result.is_empty(),
1061 "[URL] followed by comma should be treated as shortcut ref: {result:?}"
1062 );
1063 }
1064
1065 #[test]
1066 fn test_url_in_backticks_inside_mdx_component_not_flagged() {
1067 let rule = MD034NoBareUrls;
1071 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";
1072 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1073 let result = rule.check(&ctx).unwrap();
1074 assert!(
1075 result.is_empty(),
1076 "URL in backticks inside MDX component must not be flagged: {result:?}"
1077 );
1078 }
1079
1080 #[test]
1081 fn test_bare_url_inside_mdx_component_still_flagged() {
1082 let rule = MD034NoBareUrls;
1085 let content =
1086 "# Test\n\n<ParamField path=\"--stuff\">\n Visit https://rumdl.example.com/ for details.\n</ParamField>\n";
1087 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1088 let result = rule.check(&ctx).unwrap();
1089 assert_eq!(
1090 result.len(),
1091 1,
1092 "Bare URL in MDX component body must still be flagged: {result:?}"
1093 );
1094 }
1095
1096 #[test]
1097 fn test_url_in_backticks_inside_nested_mdx_component_not_flagged() {
1098 let rule = MD034NoBareUrls;
1100 let content = "<Outer>\n <Inner>\n Check `https://example.com/` here.\n </Inner>\n</Outer>\n";
1101 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1102 let result = rule.check(&ctx).unwrap();
1103 assert!(
1104 result.is_empty(),
1105 "URL in backticks inside nested MDX component must not be flagged: {result:?}"
1106 );
1107 }
1108
1109 #[test]
1113 fn test_url_in_fenced_code_block_inside_jsx_not_flagged() {
1114 let rule = MD034NoBareUrls;
1115 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";
1116 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1117 let result = rule.check(&ctx).unwrap();
1118 assert!(
1119 result.is_empty(),
1120 "URL in a fenced code block nested in a JSX component must not be flagged: {result:?}"
1121 );
1122 }
1123
1124 #[test]
1127 fn test_fix_does_not_rewrite_url_in_fenced_code_block_inside_jsx() {
1128 let rule = MD034NoBareUrls;
1129 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";
1130 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1131 let fixed = rule.fix(&ctx).unwrap();
1132 assert_eq!(
1133 fixed, content,
1134 "fix must not rewrite a URL inside a JSX-nested fenced code block"
1135 );
1136 }
1137
1138 #[test]
1141 fn test_bare_url_in_jsx_body_outside_fence_still_flagged() {
1142 let rule = MD034NoBareUrls;
1143 let content = "# Title\n\n<Steps>\n <Step title=\"Send a request\">\n Visit https://example.com/api now.\n </Step>\n</Steps>\n";
1144 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1145 let result = rule.check(&ctx).unwrap();
1146 assert_eq!(
1147 result.len(),
1148 1,
1149 "A bare URL in the JSX body (not in a fence) must still be flagged: {result:?}"
1150 );
1151 }
1152
1153 #[test]
1157 fn test_bare_url_not_masked_by_comment_delimiter_in_code_block() {
1158 let rule = MD034NoBareUrls;
1159 let content =
1160 "# T\n\n```text\n<!-- literal opener, not a comment\n```\n\nhttps://example.com should be flagged\n\n-->\n";
1161 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1162 let result = rule.check(&ctx).unwrap();
1163 assert_eq!(result.len(), 1, "the bare URL must still be flagged: {result:?}");
1164 assert!(
1165 result[0].message.contains("example.com"),
1166 "the flagged URL must be the bare one: {result:?}"
1167 );
1168 }
1169
1170 #[test]
1175 fn test_bare_url_in_indented_comment_in_admonition_still_skipped() {
1176 let rule = MD034NoBareUrls;
1177 let content = "# T\n\n!!! note\n Some text.\n\n <!--\n https://example.com\n -->\n";
1178 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1179 let result = rule.check(&ctx).unwrap();
1180 assert!(
1181 result.is_empty(),
1182 "URL inside an indented HTML comment in an admonition must not be flagged: {result:?}"
1183 );
1184 }
1185
1186 #[test]
1190 fn test_url_in_jsx_component_attribute_not_flagged() {
1191 let rule = MD034NoBareUrls;
1192 let content = "<Card title=\"Docs\" href=\"https://example.com/docs\" />\n";
1193 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1194 let result = rule.check(&ctx).unwrap();
1195 assert!(
1196 result.is_empty(),
1197 "URL in a JSX component attribute must not be flagged: {result:?}"
1198 );
1199 }
1200
1201 #[test]
1203 fn test_url_in_multiline_jsx_component_attribute_not_flagged() {
1204 let rule = MD034NoBareUrls;
1205 let content = "<Card\n title=\"Docs\"\n href=\"https://example.com/docs\"\n/>\n";
1206 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1207 let result = rule.check(&ctx).unwrap();
1208 assert!(
1209 result.is_empty(),
1210 "URL in a multi-line JSX component attribute must not be flagged: {result:?}"
1211 );
1212 }
1213
1214 #[test]
1217 fn test_jsx_attribute_url_skipped_but_body_url_flagged() {
1218 let rule = MD034NoBareUrls;
1219 let content = "<Card href=\"https://attr.example.com\">\n Visit https://body.example.com now.\n</Card>\n";
1220 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1221 let result = rule.check(&ctx).unwrap();
1222 assert_eq!(
1223 result.len(),
1224 1,
1225 "Only the body URL must be flagged, not the attribute URL: {result:?}"
1226 );
1227 assert!(
1228 result[0].message.contains("body.example.com"),
1229 "The flagged URL must be the body one: {result:?}"
1230 );
1231 }
1232
1233 #[test]
1236 fn test_email_in_jsx_component_attribute_not_flagged() {
1237 let rule = MD034NoBareUrls;
1238 let content = "<Contact email=\"hello@example.com\" />\n";
1239 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1240 let result = rule.check(&ctx).unwrap();
1241 assert!(
1242 result.is_empty(),
1243 "Email in a JSX component attribute must not be flagged: {result:?}"
1244 );
1245 }
1246
1247 #[test]
1251 fn test_jsx_attribute_url_not_flagged_in_standard_flavor() {
1252 let rule = MD034NoBareUrls;
1253 let content = "<Card href=\"https://example.com/docs\" />\n";
1254 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1255 let result = rule.check(&ctx).unwrap();
1256 assert!(
1257 result.is_empty(),
1258 "URL in a tag attribute must not be flagged under Standard flavor either: {result:?}"
1259 );
1260 }
1261
1262 #[test]
1264 fn test_pandoc_skips_urls_in_line_blocks() {
1265 use crate::config::MarkdownFlavor;
1266 use crate::lint_context::LintContext;
1267 let rule = MD034NoBareUrls;
1268 let content = "| See https://example.com\n| For details\n";
1269 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1270 let result = rule.check(&ctx).unwrap();
1271 assert!(
1272 result.is_empty(),
1273 "MD034 should skip URLs in Pandoc line blocks: {result:?}"
1274 );
1275 }
1276
1277 #[test]
1279 fn test_pandoc_skips_urls_in_metadata() {
1280 use crate::config::MarkdownFlavor;
1281 use crate::lint_context::LintContext;
1282 let rule = MD034NoBareUrls;
1283 let content = "---\nhomepage: https://example.com\n---\n\nBody.\n";
1284 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1285 let result = rule.check(&ctx).unwrap();
1286 assert!(
1287 result.is_empty(),
1288 "MD034 should skip URLs in Pandoc YAML metadata: {result:?}"
1289 );
1290 }
1291
1292 #[test]
1295 fn test_standard_still_flags_urls_in_pipe_prefixed_lines() {
1296 use crate::config::MarkdownFlavor;
1297 use crate::lint_context::LintContext;
1298 let rule = MD034NoBareUrls;
1299 let content = "| See https://example.com\n";
1300 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1301 let result = rule.check(&ctx).unwrap();
1302 assert!(
1303 !result.is_empty(),
1304 "MD034 should still flag URLs in pipe-prefixed lines under Standard flavor"
1305 );
1306 }
1307
1308 #[test]
1309 fn test_url_in_backticks_after_fenced_code_block_inside_mdx_not_flagged() {
1310 let rule = MD034NoBareUrls;
1314 let content = "\
1315<Component>
1316Some intro text.
1317
1318```
1319example code here
1320```
1321
1322Check `https://example.com/` here.
1323</Component>
1324";
1325 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1326 let result = rule.check(&ctx).unwrap();
1327 assert!(
1328 result.is_empty(),
1329 "URL in backticks after a fenced code block inside MDX must not be flagged: {result:?}"
1330 );
1331 }
1332
1333 #[test]
1337 fn test_myst_colon_directive_argument_url_not_flagged() {
1338 use crate::config::MarkdownFlavor;
1339 use crate::lint_context::LintContext;
1340 let rule = MD034NoBareUrls;
1341 let content = "\
1342:::{anywidget} https://cdn.jsdelivr.net/npm/repo-review-webapp@1.1.3/dist/repo-review-anywidget.mjs
1343{
1344 \"deps\": [\"repo-review~=1.1.0\"]
1345}
1346:::
1347";
1348 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
1349 let result = rule.check(&ctx).unwrap();
1350 assert!(
1351 result.is_empty(),
1352 "URL argument on a MyST colon directive opener must not be flagged: {result:?}"
1353 );
1354 }
1355
1356 #[test]
1358 fn test_myst_nested_colon_directive_argument_url_not_flagged() {
1359 use crate::config::MarkdownFlavor;
1360 use crate::lint_context::LintContext;
1361 let rule = MD034NoBareUrls;
1362 let content = "\
1363::::{grid}
1364:::{card} https://example.com/card-target
1365Some caption.
1366:::
1367::::
1368";
1369 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
1370 let result = rule.check(&ctx).unwrap();
1371 assert!(
1372 result.is_empty(),
1373 "URL argument on a nested MyST colon directive opener must not be flagged: {result:?}"
1374 );
1375 }
1376
1377 #[test]
1380 fn test_myst_directive_body_url_still_flagged() {
1381 use crate::config::MarkdownFlavor;
1382 use crate::lint_context::LintContext;
1383 let rule = MD034NoBareUrls;
1384 let content = "\
1385:::{note}
1386See https://example.com/docs for more details.
1387:::
1388";
1389 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
1390 let result = rule.check(&ctx).unwrap();
1391 assert_eq!(
1392 result.len(),
1393 1,
1394 "Bare URL in a MyST directive body must still be flagged: {result:?}"
1395 );
1396 }
1397
1398 #[test]
1401 fn test_myst_unclosed_colon_directive_argument_url_not_flagged() {
1402 use crate::config::MarkdownFlavor;
1403 use crate::lint_context::LintContext;
1404 let rule = MD034NoBareUrls;
1405 let content = "\
1406:::{anywidget} https://example.com/widget.mjs
1407Some trailing content with no closing fence.
1408";
1409 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
1410 let result = rule.check(&ctx).unwrap();
1411 assert!(
1412 result.is_empty(),
1413 "URL argument on an unclosed MyST colon directive opener must not be flagged: {result:?}"
1414 );
1415 }
1416
1417 #[test]
1420 fn test_colon_directive_url_flagged_in_standard_flavor() {
1421 use crate::config::MarkdownFlavor;
1422 use crate::lint_context::LintContext;
1423 let rule = MD034NoBareUrls;
1424 let content = ":::{anywidget} https://example.com/widget.mjs\n";
1425 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1426 let result = rule.check(&ctx).unwrap();
1427 assert_eq!(
1428 result.len(),
1429 1,
1430 "Under Standard flavor a bare URL on a `:::` line must still be flagged: {result:?}"
1431 );
1432 }
1433
1434 #[test]
1435 fn test_md034_complex_link() {
1436 let rule = MD034NoBareUrls;
1437
1438 let content = "Check [link `code [with brackets]` text](http://example.com) and see http://bare.com.\n";
1441 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1442 let result = rule.check(&ctx).unwrap();
1443 assert_eq!(result.len(), 1, "Should flag exactly 1 URL (the bare one): {result:?}");
1444 assert!(result[0].message.contains("bare.com"));
1445
1446 let content2 = "Check [link `code [` text](http://example.com) and see http://bare.com.\n";
1449 let ctx2 = crate::lint_context::LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1450 let result2 = rule.check(&ctx2).unwrap();
1451 assert_eq!(
1452 result2.len(),
1453 1,
1454 "Should flag exactly 1 URL (the bare one): {result2:?}"
1455 );
1456 assert!(result2[0].message.contains("bare.com"));
1457 }
1458
1459 #[test]
1462 fn test_mdg_reports_bare_urls_without_fixing_them() {
1463 let rule = MD034NoBareUrls;
1464 let content = "\
1465# Feature: Visit https://feature.example.com
1466
1467Prose about https://prose.example.com for background.
1468
1469## Scenario Outline: Open https://outline.example.com
1470
1471* Given I go to https://step.example.com
1472 | site |
1473 | https://datatable.example.com |
1474
1475> * Given I go to https://blockquoted.example.com
1476
14771. Given I go to https://ordered.example.com
1478
1479| url |
1480| ------------------------------ |
1481| https://unindented.example.com |
1482
1483### Examples:
1484
1485 | url |
1486 | ---------------------------- |
1487 | https://examples.example.com |
1488";
1489
1490 let standard_ctx =
1491 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1492 let standard_lines: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
1493 assert_eq!(
1494 standard_lines,
1495 vec![1, 3, 5, 7, 9, 11, 13, 17, 23],
1496 "Standard flavor flags every bare URL"
1497 );
1498
1499 let mdg_ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1500 assert!(!rule.should_skip(&mdg_ctx), "MDG must still run the diagnostic");
1501 let mdg = rule.check(&mdg_ctx).unwrap();
1502 assert_eq!(mdg.iter().map(|w| w.line).collect::<Vec<_>>(), standard_lines);
1503 assert!(mdg.iter().all(|warning| warning.fix.is_none()));
1504 assert!(
1505 mdg.iter()
1506 .all(|warning| warning.message.contains("Gherkin placeholder"))
1507 );
1508 assert!(mdg.iter().all(|warning| warning.message.contains("disable MD034")));
1509 assert_eq!(rule.fix(&mdg_ctx).unwrap(), content, "MDG must rewrite nothing");
1510 }
1511
1512 #[test]
1513 fn test_mdg_reports_bare_email_without_fixing_it() {
1514 let rule = MD034NoBareUrls;
1515 let content = "# Feature: Contact\n\n* Given I email user@example.com\n";
1516 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1517
1518 let warnings = rule.check(&ctx).unwrap();
1519 assert_eq!(warnings.len(), 1);
1520 assert!(warnings[0].message.contains("Gherkin placeholder"));
1521 assert!(warnings[0].message.contains("disable MD034"));
1522 assert!(warnings[0].fix.is_none());
1523 assert_eq!(rule.fix(&ctx).unwrap(), content);
1524 }
1525
1526 #[test]
1529 fn test_mdg_exemption_does_not_affect_other_flavors() {
1530 let rule = MD034NoBareUrls;
1531 let content = "\
1532# Feature: Visit https://feature.example.com
1533
1534Prose about https://prose.example.com for background.
1535
1536## Scenario Outline: Open https://outline.example.com
1537
1538* Given I go to https://step.example.com
1539 | site |
1540 | https://datatable.example.com |
1541
1542### Examples:
1543
1544 | url |
1545 | ---------------------------- |
1546 | https://examples.example.com |
1547";
1548 let expected = "\
1549# Feature: Visit <https://feature.example.com>
1550
1551Prose about <https://prose.example.com> for background.
1552
1553## Scenario Outline: Open <https://outline.example.com>
1554
1555* Given I go to <https://step.example.com>
1556 | site |
1557 | <https://datatable.example.com> |
1558
1559### Examples:
1560
1561 | url |
1562 | ---------------------------- |
1563 | <https://examples.example.com> |
1564";
1565
1566 for flavor in [
1567 crate::config::MarkdownFlavor::Standard,
1568 crate::config::MarkdownFlavor::MkDocs,
1569 crate::config::MarkdownFlavor::MyST,
1570 ] {
1571 let ctx = crate::lint_context::LintContext::new(content, flavor, None);
1572 assert!(!rule.should_skip(&ctx), "{flavor:?} must still run the rule");
1573 assert_eq!(rule.check(&ctx).unwrap().len(), 6, "{flavor:?} must flag all six URLs");
1574
1575 let fixed = rule.fix(&ctx).unwrap();
1576 assert_eq!(fixed, expected, "{flavor:?} must wrap all six URLs");
1577
1578 let fixed_ctx = crate::lint_context::LintContext::new(&fixed, flavor, None);
1579 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1580 assert_eq!(
1581 rule.fix(&fixed_ctx).unwrap(),
1582 fixed,
1583 "{flavor:?} fix must be idempotent"
1584 );
1585 }
1586 }
1587
1588 #[test]
1596 fn test_mdx_fixes_bare_urls_to_links_instead_of_autolinks() {
1597 let rule = MD034NoBareUrls;
1598 let cases = [
1599 (
1600 "Bare link: http://localhost/\n",
1601 "Bare link: [http://localhost/](http://localhost/)\n",
1602 "Bare link: <http://localhost/>\n",
1603 ),
1604 (
1605 "Visit www.example.com today\n",
1606 "Visit [www.example.com](https://www.example.com) today\n",
1607 "Visit <https://www.example.com> today\n",
1608 ),
1609 (
1610 "Mail user@example.com now\n",
1611 "Mail [user@example.com](mailto:user@example.com) now\n",
1612 "Mail <user@example.com> now\n",
1613 ),
1614 (
1615 "Chat xmpp:foo@bar.baz please\n",
1616 "Chat [xmpp:foo@bar.baz](xmpp:foo@bar.baz) please\n",
1617 "Chat <xmpp:foo@bar.baz> please\n",
1618 ),
1619 ];
1620
1621 for (content, expected_mdx, expected_standard) in cases {
1622 let mdx_ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1623 assert_eq!(
1624 rule.check(&mdx_ctx).unwrap().len(),
1625 1,
1626 "MDX must still report the bare URL in {content:?}"
1627 );
1628 assert_eq!(rule.fix(&mdx_ctx).unwrap(), expected_mdx, "MDX fix for {content:?}");
1629
1630 let standard_ctx =
1631 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1632 assert_eq!(
1633 rule.fix(&standard_ctx).unwrap(),
1634 expected_standard,
1635 "Standard fix for {content:?} must be unchanged"
1636 );
1637 }
1638 }
1639
1640 #[test]
1644 fn test_an_address_behind_a_uri_scheme_is_not_a_bare_email() {
1645 let rule = MD034NoBareUrls;
1646 for content in [
1647 "Mail mailto:user@example.com now\n",
1648 "Chat xmpp:foo@bar.baz please\n",
1649 "Call sip:user@example.com now\n",
1650 "Key openpgp4fpr:user@example.com here\n",
1651 "Ping xmpp+tls:user@example.com now\n",
1652 ] {
1653 for flavor in [
1654 crate::config::MarkdownFlavor::Standard,
1655 crate::config::MarkdownFlavor::MDX,
1656 ] {
1657 let ctx = crate::lint_context::LintContext::new(content, flavor, None);
1658 let emails: Vec<_> = rule
1659 .check(&ctx)
1660 .unwrap()
1661 .into_iter()
1662 .filter(|w| w.message.starts_with("Email address"))
1663 .collect();
1664 assert!(
1665 emails.is_empty(),
1666 "{flavor:?} reported the tail of a schemed URI in {content:?} as a bare email: {emails:?}"
1667 );
1668 }
1669 }
1670 }
1671
1672 #[test]
1675 fn test_a_colon_before_an_address_is_still_a_bare_email() {
1676 let rule = MD034NoBareUrls;
1677 for content in [
1678 "Contact: user@example.com\n",
1679 "Note (see 3:1): user@example.com\n",
1680 "Mail 2user@example.com now\n",
1681 "Ratio 3:user@example.com now\n",
1684 "Mail :user@example.com now\n",
1685 ] {
1686 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1687 assert_eq!(
1688 rule.check(&ctx).unwrap().len(),
1689 1,
1690 "{content:?} must still report a bare email"
1691 );
1692 }
1693 }
1694
1695 #[test]
1696 fn test_follows_uri_scheme() {
1697 assert!(follows_uri_scheme("mailto:a@b.co", 7));
1699 assert!(follows_uri_scheme("Mail mailto:a@b.co", 12));
1700 assert!(follows_uri_scheme("xmpp+tls:a@b.co", 9));
1701 assert!(follows_uri_scheme("a:a@b.co", 2));
1702
1703 assert!(!follows_uri_scheme("a@b.co", 0));
1704 assert!(!follows_uri_scheme("Contact: a@b.co", 9), "a space separates the colon");
1705 assert!(!follows_uri_scheme("2mailto:a@b.co", 8));
1707 assert!(!follows_uri_scheme(":a@b.co", 1), "empty scheme");
1708 assert!(follows_uri_scheme("Schrijf mailto:a@b.co", 15));
1711 assert!(!follows_uri_scheme("Schrijf é:a@b.co", 11));
1712 }
1713
1714 #[test]
1720 fn test_mdx_escapes_an_active_bang_before_the_link() {
1721 let rule = MD034NoBareUrls;
1722 let cases = [
1723 (
1724 "Download now!https://example.com/f today\n",
1725 "Download now\\ today\n",
1726 ),
1727 (
1728 "Contact us!user@example.com now\n",
1729 "Contact us\\ now\n",
1730 ),
1731 (
1734 "Escaped already\\!https://example.com/e today\n",
1735 "Escaped already\\ today\n",
1736 ),
1737 (
1739 "Two slashes\\\\!https://example.com/t today\n",
1740 "Two slashes\\\\\\ today\n",
1741 ),
1742 (
1744 "Normal! https://example.com/s today\n",
1745 "Normal! [https://example.com/s](https://example.com/s) today\n",
1746 ),
1747 ];
1748
1749 for (content, expected) in cases {
1750 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1751 assert_eq!(rule.fix(&ctx).unwrap(), expected, "MDX fix for {content:?}");
1752 }
1753 }
1754
1755 #[test]
1758 fn test_a_preceding_bang_is_untouched_outside_jsx_flavors() {
1759 let rule = MD034NoBareUrls;
1760 let ctx = crate::lint_context::LintContext::new(
1761 "Download now!https://example.com/f today\n",
1762 crate::config::MarkdownFlavor::Standard,
1763 None,
1764 );
1765 assert_eq!(rule.fix(&ctx).unwrap(), "Download now!<https://example.com/f> today\n");
1766 }
1767
1768 #[test]
1773 fn test_mdx_reports_but_does_not_fix_a_url_after_an_active_close_bracket() {
1774 let rule = MD034NoBareUrls;
1775 let content =
1776 "[See more]https://example.com/x here\n\n[https://example.com/x]: https://elsewhere.example.com/\n";
1777 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1778
1779 let warnings = rule.check(&ctx).unwrap();
1780 assert_eq!(warnings.len(), 1, "the bare URL is still a finding");
1781 assert!(warnings[0].fix.is_none(), "no replacement is safe here");
1782 assert_eq!(rule.fix(&ctx).unwrap(), content, "fmt must leave the line alone");
1783 }
1784
1785 #[test]
1787 fn test_mdx_fixes_after_an_escaped_close_bracket() {
1788 let rule = MD034NoBareUrls;
1789 let ctx = crate::lint_context::LintContext::new(
1790 "Text \\]https://example.com/x here\n",
1791 crate::config::MarkdownFlavor::MDX,
1792 None,
1793 );
1794 assert_eq!(
1795 rule.fix(&ctx).unwrap(),
1796 "Text \\][https://example.com/x](https://example.com/x) here\n"
1797 );
1798 }
1799
1800 #[test]
1801 fn test_classify_link_prefix() {
1802 let free = |s: &str| matches!(classify_link_prefix(s, s.len()), LinkPrefix::Free);
1803 let bang = |s: &str| matches!(classify_link_prefix(s, s.len()), LinkPrefix::ActiveBang);
1804 let bracket = |s: &str| matches!(classify_link_prefix(s, s.len()), LinkPrefix::ActiveCloseBracket);
1805
1806 assert!(free(""), "start of line binds to nothing");
1807 assert!(free("plain "));
1808 assert!(free("plain"));
1809 assert!(bang("hi!"));
1810 assert!(free("hi\\!"), "one backslash escapes the bang");
1811 assert!(bang("hi\\\\!"), "two backslashes escape each other, not the bang");
1812 assert!(free("hi\\\\\\!"), "three escape the bang again");
1813 assert!(bracket("[a]"));
1814 assert!(free("[a\\]"), "an escaped bracket closes no span");
1815 assert!(free("café"));
1817 assert!(bang("café!"));
1818 }
1819
1820 #[test]
1828 fn test_mdx_link_text_escapes_characters_that_would_not_render_literally() {
1829 let rule = MD034NoBareUrls;
1830 let cases = [
1831 ("https://ex.com/a*b*c", "https://ex.com/a\\*b\\*c"),
1832 ("https://ex.com/a&b", "https://ex.com/a\\&b"),
1833 ("https://ex.com/a~b~c", "https://ex.com/a\\~b\\~c"),
1834 ("https://ex.com/a_b_c", "https://ex.com/a\\_b\\_c"),
1835 ];
1836
1837 for (url, escaped_text) in cases {
1838 let content = format!("See {url} here\n");
1839 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDX, None);
1840 assert_eq!(
1841 rule.fix(&ctx).unwrap(),
1842 format!("See [{escaped_text}]({url}) here\n"),
1843 "MDX must escape the link text for {url}"
1844 );
1845
1846 let standard_ctx =
1847 crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1848 assert_eq!(
1849 rule.fix(&standard_ctx).unwrap(),
1850 format!("See <{url}> here\n"),
1851 "Standard emits the autolink, which needs no escaping"
1852 );
1853 }
1854 }
1855
1856 #[test]
1862 fn test_mdx_braces_are_skipped_when_paired_and_escaped_when_not() {
1863 let rule = MD034NoBareUrls;
1864
1865 let paired = "See https://ex.com/a{b}c here\n";
1866 let paired_ctx = crate::lint_context::LintContext::new(paired, crate::config::MarkdownFlavor::MDX, None);
1867 assert!(
1868 rule.check(&paired_ctx).unwrap().is_empty(),
1869 "a balanced brace pair is a JSX expression, which MD034 leaves alone"
1870 );
1871
1872 let standard_ctx = crate::lint_context::LintContext::new(paired, crate::config::MarkdownFlavor::Standard, None);
1873 assert_eq!(
1874 rule.fix(&standard_ctx).unwrap(),
1875 "See <https://ex.com/a{b}c> here\n",
1876 "outside MDX the braces carry no meaning, so the URL is still reported"
1877 );
1878
1879 for (url, escaped_text) in [
1880 ("https://ex.com/a{b", "https://ex.com/a\\{b"),
1881 ("https://ex.com/a}b", "https://ex.com/a\\}b"),
1882 ] {
1883 let content = format!("See {url} here\n");
1884 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDX, None);
1885 assert_eq!(
1886 rule.fix(&ctx).unwrap(),
1887 format!("See [{escaped_text}]({url}) here\n"),
1888 "an unmatched brace reaches the fix and must be escaped"
1889 );
1890 }
1891 }
1892
1893 #[test]
1898 fn test_mdx_unbalanced_open_paren_uses_an_angle_bracket_destination() {
1899 let rule = MD034NoBareUrls;
1900
1901 let unbalanced = "Go to https://ex.com/a(b now\n";
1902 let ctx = crate::lint_context::LintContext::new(unbalanced, crate::config::MarkdownFlavor::MDX, None);
1903 assert_eq!(
1904 rule.fix(&ctx).unwrap(),
1905 "Go to [https://ex.com/a(b](<https://ex.com/a(b>) now\n"
1906 );
1907
1908 let balanced = "Go to https://en.wikipedia.org/wiki/Foo_(bar) now\n";
1909 let ctx = crate::lint_context::LintContext::new(balanced, crate::config::MarkdownFlavor::MDX, None);
1910 assert_eq!(
1911 rule.fix(&ctx).unwrap(),
1912 "Go to [https://en.wikipedia.org/wiki/Foo\\_(bar)](https://en.wikipedia.org/wiki/Foo_(bar)) now\n",
1913 "balanced parens need no angle brackets"
1914 );
1915 }
1916
1917 #[test]
1920 fn test_mdx_fix_is_idempotent_and_stops_reporting() {
1921 let rule = MD034NoBareUrls;
1922 let content = "\
1923Plain http://localhost/ and www.example.com.
1924
1925Mail user@example.com or see https://ex.com/a*b_c{d}e.
1926
1927Parens https://ex.com/a(b and https://en.wikipedia.org/wiki/Foo_(bar).
1928";
1929 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1930 let fixed = rule.fix(&ctx).unwrap();
1931 assert_ne!(fixed, content, "the fix must actually rewrite this document");
1932
1933 let fixed_ctx = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::MDX, None);
1934 assert!(
1935 rule.check(&fixed_ctx).unwrap().is_empty(),
1936 "MDX must not re-report its own output: {:?}",
1937 rule.check(&fixed_ctx).unwrap()
1938 );
1939 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDX fix must be idempotent");
1940 }
1941
1942 #[test]
1945 fn test_link_form_is_confined_to_jsx_flavors() {
1946 let rule = MD034NoBareUrls;
1947 let content = "Visit https://example.com today\n";
1948
1949 for flavor in [
1950 crate::config::MarkdownFlavor::Standard,
1951 crate::config::MarkdownFlavor::MkDocs,
1952 crate::config::MarkdownFlavor::MyST,
1953 crate::config::MarkdownFlavor::Quarto,
1954 crate::config::MarkdownFlavor::Obsidian,
1955 ] {
1956 assert!(!flavor.supports_jsx(), "{flavor:?} is not a JSX flavor");
1957 let ctx = crate::lint_context::LintContext::new(content, flavor, None);
1958 assert_eq!(
1959 rule.fix(&ctx).unwrap(),
1960 "Visit <https://example.com> today\n",
1961 "{flavor:?} must keep the autolink form"
1962 );
1963 }
1964
1965 let mdg_ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1966 assert_eq!(rule.check(&mdg_ctx).unwrap().len(), 1);
1967 assert!(rule.check(&mdg_ctx).unwrap()[0].fix.is_none());
1968 assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
1969 }
1970
1971 #[test]
1972 fn test_escape_mdx_link_text_covers_every_active_character() {
1973 assert_eq!(escape_mdx_link_text("plain"), "plain");
1974 for ch in MDX_LINK_TEXT_ESCAPES {
1975 assert_eq!(escape_mdx_link_text(&ch.to_string()), format!("\\{ch}"));
1976 }
1977 }
1978
1979 #[test]
1980 fn test_has_balanced_parens() {
1981 assert!(has_balanced_parens("https://ex.com/a"));
1982 assert!(has_balanced_parens("https://ex.com/(a)"));
1983 assert!(has_balanced_parens("https://ex.com/(a)(b)"));
1984 assert!(has_balanced_parens("https://ex.com/((a))"));
1985 assert!(!has_balanced_parens("https://ex.com/(a"));
1986 assert!(!has_balanced_parens("https://ex.com/a)"));
1987 assert!(
1988 !has_balanced_parens("https://ex.com/)a("),
1989 "equal counts are not balance"
1990 );
1991 }
1992}
1993
1994#[cfg(test)]
1995mod shared_url_scan_tests {
1996 use super::{collect_urls, collect_urls_legacy};
1997 use proptest::prelude::*;
1998
1999 #[test]
2000 fn crossing_domain_and_scheme_preserves_independent_cursors() {
2001 for line in [
2002 "WWW.example.com Www.example.com XMPP:user@example.com HTTPS://host HTTP://[::1]",
2003 "www.example.comhttps://example.comhttps://host/https://host/",
2004 "www.x.com/https://[::1]/https://foo",
2005 "<www.example.com/https://inner.com>",
2006 "xmpp:user@example.comhttps://host/https://host/",
2007 ] {
2008 let mut expected = Vec::new();
2009 collect_urls_legacy(line, &mut expected);
2010 let mut actual = Vec::new();
2011 collect_urls(line, &mut actual);
2012 assert_eq!(actual, expected, "{line}");
2013 }
2014 }
2015
2016 proptest! {
2017 #![proptest_config(ProptestConfig::with_cases(12000))]
2018 #[test]
2019 fn prefix_gated_scan_matches_independent_patterns(
2020 parts in prop::collection::vec(prop::sample::select(vec![
2021 "https://example.com", "http://localhost:80/a(b)?x=y#z",
2022 "ftps://host/a", "www.example.com", "www.a.com/path",
2023 "xmpp:user@example.com/path", "https://[::1]/path",
2024 "https://::1]", "https://host/a:b:c]", "https://host/",
2025 "WWW.", "Www.", "XMPP:", "HTTPS://", "HTTP://[",
2026 "https://", "www.", "xmpp:", "@", "://", "[", "]",
2027 "(", ")", "<", ">", "\"", "'", "`", "\\", "/", "?", "#",
2028 " ", "\n", "\t", "\r\n", "\u{a0}", "\u{2003}", "é", "中文", ":", ".", "_", "*",
2029 ]), 0..40)
2030 ) {
2031 let line = parts.concat();
2032 let mut expected = Vec::new();
2033 collect_urls_legacy(&line, &mut expected);
2034 let mut actual = vec![(999, 999)];
2035 collect_urls(&line, &mut actual);
2036 prop_assert_eq!(actual, expected, "input: {:?}", line);
2037 }
2038 }
2039}