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::{
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
41const MDX_LINK_TEXT_ESCAPES: [char; 12] = ['\\', '`', '*', '_', '{', '}', '[', ']', '<', '>', '~', '&'];
58
59fn escape_mdx_link_text(text: &str) -> String {
61 let mut escaped = String::with_capacity(text.len());
62 for ch in text.chars() {
63 if MDX_LINK_TEXT_ESCAPES.contains(&ch) {
64 escaped.push('\\');
65 }
66 escaped.push(ch);
67 }
68 escaped
69}
70
71fn has_balanced_parens(url: &str) -> bool {
76 let mut depth: i32 = 0;
77 for ch in url.chars() {
78 match ch {
79 '(' => depth += 1,
80 ')' => {
81 depth -= 1;
82 if depth < 0 {
83 return false;
84 }
85 }
86 _ => {}
87 }
88 }
89 depth == 0
90}
91
92fn jsx_safe_link(text: &str, destination: &str) -> String {
103 let escaped = escape_mdx_link_text(text);
104 if has_balanced_parens(destination) {
105 format!("[{escaped}]({destination})")
106 } else {
107 format!("[{escaped}](<{destination}>)")
108 }
109}
110
111enum LinkPrefix {
118 Free,
120 ActiveBang,
122 ActiveCloseBracket,
126}
127
128fn classify_link_prefix(line: &str, start: usize) -> LinkPrefix {
130 let before = &line[..start];
131 let Some(last) = before.chars().next_back() else {
132 return LinkPrefix::Free;
133 };
134 if last != '!' && last != ']' {
135 return LinkPrefix::Free;
136 }
137
138 let preceding = &before[..before.len() - last.len_utf8()];
141 if preceding.bytes().rev().take_while(|&b| b == b'\\').count() % 2 == 1 {
142 return LinkPrefix::Free;
143 }
144
145 if last == '!' {
146 LinkPrefix::ActiveBang
147 } else {
148 LinkPrefix::ActiveCloseBracket
149 }
150}
151
152fn jsx_fix(line: &str, start: usize, text: &str, destination: &str) -> Option<(usize, String)> {
158 let link = jsx_safe_link(text, destination);
159 match classify_link_prefix(line, start) {
160 LinkPrefix::Free => Some((start, link)),
161 LinkPrefix::ActiveBang => Some((start - 1, format!("\\!{link}"))),
164 LinkPrefix::ActiveCloseBracket => None,
167 }
168}
169
170fn follows_uri_scheme(line: &str, start: usize) -> bool {
181 let Some(before) = line[..start].strip_suffix(':') else {
182 return false;
183 };
184 let scheme: &str = {
185 let tail = before.len() - before.bytes().rev().take_while(|b| is_scheme_byte(*b)).count();
186 &before[tail..]
187 };
188 scheme.bytes().next().is_some_and(|b| b.is_ascii_alphabetic())
189}
190
191fn is_scheme_byte(b: u8) -> bool {
193 b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.')
194}
195
196#[derive(Default)]
198struct LineCheckBuffers {
199 markdown_link_ranges: Vec<(usize, usize)>,
200 image_ranges: Vec<(usize, usize)>,
201 urls_found: Vec<(usize, usize, String)>,
202}
203
204#[derive(Default, Clone)]
205pub struct MD034NoBareUrls;
206
207impl MD034NoBareUrls {
208 #[inline]
209 pub fn should_skip_content(&self, content: &str) -> bool {
210 let bytes = content.as_bytes();
213 let has_colon = bytes.contains(&b':');
214 let has_at = bytes.contains(&b'@');
215 let has_www = content.contains("www.");
216 !has_colon && !has_at && !has_www
217 }
218
219 fn trim_trailing_punctuation<'a>(&self, url: &'a str) -> &'a str {
221 let mut trimmed = url;
222
223 let open_parens = url.chars().filter(|&c| c == '(').count();
225 let close_parens = url.chars().filter(|&c| c == ')').count();
226
227 if close_parens > open_parens {
228 let mut balance = 0;
230 let mut last_balanced_pos = url.len();
231
232 for (byte_idx, c) in url.char_indices() {
233 if c == '(' {
234 balance += 1;
235 } else if c == ')' {
236 balance -= 1;
237 if balance < 0 {
238 last_balanced_pos = byte_idx;
240 break;
241 }
242 }
243 }
244
245 trimmed = &trimmed[..last_balanced_pos];
246 }
247
248 while let Some(last_char) = trimmed.chars().last() {
250 if matches!(last_char, '.' | ',' | ';' | ':' | '!' | '?') {
251 if last_char == ':' && trimmed.len() > 1 {
254 break;
256 }
257 trimmed = &trimmed[..trimmed.len() - 1];
258 } else {
259 break;
260 }
261 }
262
263 trimmed
264 }
265
266 fn check_line(
267 &self,
268 line: &str,
269 ctx: &LintContext,
270 line_number: usize,
271 code_spans: &[crate::lint_context::CodeSpan],
272 buffers: &mut LineCheckBuffers,
273 ) -> Vec<LintWarning> {
274 let mut warnings = Vec::new();
275
276 if ctx.line_info(line_number).is_some_and(|info| info.in_html_block) {
278 return warnings;
279 }
280
281 if MULTILINE_LINK_CONTINUATION_REGEX.is_match(line) {
284 return warnings;
285 }
286
287 let has_quick_check = URL_QUICK_CHECK_REGEX.is_match(line);
289 let has_www = line.contains("www.");
290 let has_at = line.contains('@');
291
292 if !has_quick_check && !has_at && !has_www {
293 return warnings;
294 }
295
296 buffers.markdown_link_ranges.clear();
298 buffers.image_ranges.clear();
299
300 let has_bracket = line.contains('[');
301 let has_angle = line.contains('<');
302 let has_bang = line.contains('!');
303
304 if has_bracket {
305 for mat in MARKDOWN_LINK_REGEX.find_iter(line) {
306 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
307 }
308
309 for mat in MARKDOWN_EMPTY_LINK_REGEX.find_iter(line) {
311 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
312 }
313
314 for mat in MARKDOWN_EMPTY_REF_REGEX.find_iter(line) {
315 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
316 }
317
318 for mat in SHORTCUT_REF_REGEX.find_iter(line) {
320 let end = mat.end();
321 let next_non_ws = line[end..].bytes().find(|b| !b.is_ascii_whitespace());
322 if next_non_ws == Some(b'(') || next_non_ws == Some(b'[') {
323 continue;
324 }
325 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
326 }
327
328 if has_bang && BADGE_LINK_LINE_REGEX.is_match(line) {
330 return warnings;
331 }
332 }
333
334 if has_angle {
335 for mat in ANGLE_LINK_REGEX.find_iter(line) {
336 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
337 }
338 }
339
340 if has_bang && has_bracket {
342 for mat in MARKDOWN_IMAGE_REGEX.find_iter(line) {
343 buffers.image_ranges.push((mat.start(), mat.end()));
344 }
345 }
346
347 buffers.urls_found.clear();
349
350 for mat in URL_IPV6_REGEX.find_iter(line) {
352 let url_str = mat.as_str();
353 buffers.urls_found.push((mat.start(), mat.end(), url_str.to_string()));
354 }
355
356 for mat in URL_STANDARD_REGEX.find_iter(line) {
358 let url_str = mat.as_str();
359
360 if url_str.contains("://[") {
362 continue;
363 }
364
365 if let Some(host_start) = url_str.find("://") {
368 let after_protocol = &url_str[host_start + 3..];
369 if after_protocol.contains("::") || after_protocol.chars().filter(|&c| c == ':').count() > 1 {
371 if line.as_bytes().get(mat.end()) == Some(&b']') {
373 continue;
375 }
376 }
377 }
378
379 buffers.urls_found.push((mat.start(), mat.end(), url_str.to_string()));
380 }
381
382 for mat in URL_WWW_REGEX.find_iter(line) {
384 let url_str = mat.as_str();
385 let start_pos = mat.start();
386 let end_pos = mat.end();
387
388 if start_pos > 0 {
390 let prev_char = line.as_bytes().get(start_pos - 1).copied();
391 if prev_char == Some(b'/') || prev_char == Some(b'@') {
392 continue;
393 }
394 }
395
396 if start_pos > 0 && end_pos < line.len() {
398 let prev_char = line.as_bytes().get(start_pos - 1).copied();
399 let next_char = line.as_bytes().get(end_pos).copied();
400 if prev_char == Some(b'<') && next_char == Some(b'>') {
401 continue;
402 }
403 }
404
405 buffers.urls_found.push((start_pos, end_pos, url_str.to_string()));
406 }
407
408 for mat in XMPP_URI_REGEX.find_iter(line) {
410 let uri_str = mat.as_str();
411 let start_pos = mat.start();
412 let end_pos = mat.end();
413
414 if start_pos > 0 && end_pos < line.len() {
416 let prev_char = line.as_bytes().get(start_pos - 1).copied();
417 let next_char = line.as_bytes().get(end_pos).copied();
418 if prev_char == Some(b'<') && next_char == Some(b'>') {
419 continue;
420 }
421 }
422
423 buffers.urls_found.push((start_pos, end_pos, uri_str.to_string()));
424 }
425
426 for &(start, _end, ref url_str) in &buffers.urls_found {
428 if CUSTOM_PROTOCOL_REGEX.is_match(url_str) {
430 continue;
431 }
432
433 let is_inside_construct = buffers
439 .markdown_link_ranges
440 .iter()
441 .any(|&(s, e)| start >= s && start < e)
442 || buffers.image_ranges.iter().any(|&(s, e)| start >= s && start < e);
443
444 if is_inside_construct {
445 continue;
446 }
447
448 let line_start_byte = ctx.line_start_byte(line_number).unwrap_or(0);
450 let absolute_pos = line_start_byte + start;
451
452 if ctx.is_in_html_tag(absolute_pos) {
454 continue;
455 }
456
457 if ctx.is_in_jsx_component_tag(absolute_pos) {
461 continue;
462 }
463
464 if ctx.is_in_html_comment(absolute_pos) || ctx.is_in_mdx_comment(absolute_pos) {
466 continue;
467 }
468
469 if ctx.is_in_shortcode(absolute_pos) {
471 continue;
472 }
473
474 if ctx.flavor.is_pandoc_compatible()
478 && (ctx.is_in_line_block(absolute_pos) || ctx.is_in_pandoc_metadata(absolute_pos))
479 {
480 continue;
481 }
482
483 let trimmed_url = self.trim_trailing_punctuation(url_str);
485
486 if !trimmed_url.is_empty() && trimmed_url != "//" {
488 let trimmed_len = trimmed_url.len();
489 let (start_line, start_col, end_line, end_col) =
490 calculate_url_range(line_number, line, start, trimmed_len);
491
492 let destination = if trimmed_url.starts_with("www.") {
494 format!("https://{trimmed_url}")
495 } else {
496 trimmed_url.to_string()
497 };
498 let line_start_byte = ctx.line_start_byte(line_number).unwrap_or(0);
499 let span_end = line_start_byte + start + trimmed_len;
500 let fix = if ctx.flavor.supports_jsx() {
501 jsx_fix(line, start, trimmed_url, &destination)
502 .map(|(fix_start, replacement)| Fix::new((line_start_byte + fix_start)..span_end, replacement))
503 } else {
504 Some(Fix::new(
505 (line_start_byte + start)..span_end,
506 format!("<{destination}>"),
507 ))
508 };
509
510 warnings.push(LintWarning {
511 rule_name: Some("MD034".to_string()),
512 line: start_line,
513 column: start_col,
514 end_line,
515 end_column: end_col,
516 message: if ctx.flavor == crate::config::MarkdownFlavor::MDG {
517 format!(
518 "URL without link formatting: '{trimmed_url}' (angle brackets are Gherkin placeholder syntax; use explicit link formatting where appropriate, or disable MD034)"
519 )
520 } else {
521 format!("URL without angle brackets or link formatting: '{trimmed_url}'")
522 },
523 severity: Severity::Warning,
524 fix,
525 });
526 }
527 }
528
529 for cap in EMAIL_PATTERN.captures_iter(line) {
531 if let Some(mat) = cap.get(0) {
532 let email = mat.as_str();
533 let start = mat.start();
534 let end = mat.end();
535
536 if follows_uri_scheme(line, start) {
539 continue;
540 }
541
542 let mut is_inside_construct = false;
544 for &(link_start, link_end) in &buffers.markdown_link_ranges {
545 if start >= link_start && end <= link_end {
546 is_inside_construct = true;
547 break;
548 }
549 }
550
551 if !is_inside_construct {
552 let line_start_byte = ctx.line_start_byte(line_number).unwrap_or(0);
554 let absolute_pos = line_start_byte + start;
555
556 if ctx.is_in_html_tag(absolute_pos) {
558 continue;
559 }
560
561 if ctx.is_in_jsx_component_tag(absolute_pos) {
564 continue;
565 }
566
567 if ctx.flavor.is_pandoc_compatible()
569 && (ctx.is_in_line_block(absolute_pos) || ctx.is_in_pandoc_metadata(absolute_pos))
570 {
571 continue;
572 }
573
574 let is_in_code_span = code_spans
576 .iter()
577 .any(|span| absolute_pos >= span.byte_offset && absolute_pos < span.byte_end);
578
579 if !is_in_code_span {
580 let email_len = end - start;
581 let (start_line, start_col, end_line, end_col) =
582 calculate_url_range(line_number, line, start, email_len);
583
584 let fix = if ctx.flavor.supports_jsx() {
585 jsx_fix(line, start, email, &format!("mailto:{email}")).map(|(fix_start, replacement)| {
586 Fix::new((line_start_byte + fix_start)..(line_start_byte + end), replacement)
587 })
588 } else {
589 Some(Fix::new(
590 (line_start_byte + start)..(line_start_byte + end),
591 format!("<{email}>"),
592 ))
593 };
594
595 warnings.push(LintWarning {
596 rule_name: Some("MD034".to_string()),
597 line: start_line,
598 column: start_col,
599 end_line,
600 end_column: end_col,
601 message: if ctx.flavor == crate::config::MarkdownFlavor::MDG {
602 format!(
603 "Email address without link formatting: '{email}' (angle brackets are Gherkin placeholder syntax; use explicit link formatting where appropriate, or disable MD034)"
604 )
605 } else {
606 format!("Email address without angle brackets or link formatting: '{email}'")
607 },
608 severity: Severity::Warning,
609 fix,
610 });
611 }
612 }
613 }
614 }
615
616 warnings
617 }
618}
619
620impl Rule for MD034NoBareUrls {
621 #[inline]
622 fn name(&self) -> &'static str {
623 "MD034"
624 }
625
626 fn as_any(&self) -> &dyn std::any::Any {
627 self
628 }
629
630 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
631 where
632 Self: Sized,
633 {
634 Box::new(MD034NoBareUrls)
635 }
636
637 #[inline]
638 fn category(&self) -> RuleCategory {
639 RuleCategory::Link
640 }
641
642 fn skippable_by_category(&self) -> bool {
643 false
648 }
649
650 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
651 !ctx.likely_has_links_or_images() && self.should_skip_content(ctx.content)
652 }
653
654 #[inline]
655 fn description(&self) -> &'static str {
656 "No bare URLs - wrap URLs in angle brackets"
657 }
658
659 fn check(&self, ctx: &LintContext) -> LintResult {
660 let mut warnings = Vec::new();
661 let content = ctx.content;
662
663 if self.should_skip_content(content) {
665 return Ok(warnings);
666 }
667
668 let code_spans = ctx.code_spans();
670
671 let ref_def_lines: std::collections::HashSet<usize> =
675 ctx.reference_definitions().iter().map(|def| def.line).collect();
676
677 let mut buffers = LineCheckBuffers::default();
679
680 for line in ctx
684 .filtered_lines()
685 .skip_front_matter()
686 .skip_code_blocks()
687 .skip_jsx_expressions()
688 .skip_mdx_comments()
689 .skip_obsidian_comments()
690 {
691 if ctx.flavor == crate::config::MarkdownFlavor::GhAw && crate::utils::gh_aw::is_control_line(line.content) {
695 continue;
696 }
697
698 if ctx.is_myst_colon_directive_opener_line(line.line_num) {
704 continue;
705 }
706
707 if ref_def_lines.contains(&line.line_num) {
709 continue;
710 }
711
712 let mut line_warnings = self.check_line(line.content, ctx, line.line_num, &code_spans, &mut buffers);
713
714 line_warnings.retain(|warning| {
716 !code_spans.iter().any(|span| {
717 if let Some(fix) = &warning.fix {
718 fix.range.start >= span.byte_offset && fix.range.start < span.byte_end
720 } else {
721 span.line == warning.line
722 && span.end_line == warning.line
723 && warning.column > 0
724 && (warning.column - 1) >= span.start_col
725 && (warning.column - 1) < span.end_col
726 }
727 })
728 });
729
730 line_warnings.retain(|warning| {
731 if let Some(fix) = &warning.fix {
732 !ctx.links().iter().any(|link| {
734 !(link.is_reference && link.url.is_empty())
735 && fix.range.start >= link.byte_offset
736 && fix.range.end <= link.byte_end
737 })
738 } else {
739 true
740 }
741 });
742
743 line_warnings.retain(|warning| !ctx.is_position_in_obsidian_comment(warning.line, warning.column));
746
747 warnings.extend(line_warnings);
748 }
749
750 if ctx.flavor == crate::config::MarkdownFlavor::MDG {
754 for warning in &mut warnings {
755 warning.fix = None;
756 }
757 }
758
759 Ok(warnings)
760 }
761
762 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
763 let mut content = ctx.content.to_string();
764 let warnings = self.check(ctx)?;
765 let mut warnings =
766 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
767
768 warnings.sort_by_key(|w| w.fix.as_ref().map_or(0, |f| f.range.start));
770
771 for warning in warnings.iter().rev() {
773 if let Some(fix) = &warning.fix {
774 let start = fix.range.start;
775 let end = fix.range.end;
776 content.replace_range(start..end, &fix.replacement);
777 }
778 }
779
780 Ok(content)
781 }
782}
783
784#[cfg(test)]
785mod tests {
786 use super::*;
787
788 #[test]
789 fn test_shortcut_ref_at_end_of_line_no_trailing_chars() {
790 let rule = MD034NoBareUrls;
791 let content = "See [https://example.com]";
792 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
793 let result = rule.check(&ctx).unwrap();
794 assert!(
795 result.is_empty(),
796 "[URL] at end of line should be treated as shortcut ref: {result:?}"
797 );
798 }
799
800 #[test]
801 fn test_shortcut_ref_multiple_spaces_before_paren() {
802 let rule = MD034NoBareUrls;
803 let content = "[text] (https://example.com)";
804 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
805 let result = rule.check(&ctx).unwrap();
806 let _ = result; }
811
812 #[test]
813 fn test_shortcut_ref_tab_before_bracket() {
814 let rule = MD034NoBareUrls;
815 let content = "[https://example.com]\t[other]";
816 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
817 let result = rule.check(&ctx).unwrap();
818 assert_eq!(
822 result.len(),
823 1,
824 "Bare URL inside shortcut ref should be detected: {result:?}"
825 );
826 }
827
828 #[test]
829 fn test_shortcut_ref_followed_by_punctuation() {
830 let rule = MD034NoBareUrls;
831 let content = "[https://example.com], see also other things.";
832 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
833 let result = rule.check(&ctx).unwrap();
834 assert!(
835 result.is_empty(),
836 "[URL] followed by comma should be treated as shortcut ref: {result:?}"
837 );
838 }
839
840 #[test]
841 fn test_url_in_backticks_inside_mdx_component_not_flagged() {
842 let rule = MD034NoBareUrls;
846 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";
847 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
848 let result = rule.check(&ctx).unwrap();
849 assert!(
850 result.is_empty(),
851 "URL in backticks inside MDX component must not be flagged: {result:?}"
852 );
853 }
854
855 #[test]
856 fn test_bare_url_inside_mdx_component_still_flagged() {
857 let rule = MD034NoBareUrls;
860 let content =
861 "# Test\n\n<ParamField path=\"--stuff\">\n Visit https://rumdl.example.com/ for details.\n</ParamField>\n";
862 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
863 let result = rule.check(&ctx).unwrap();
864 assert_eq!(
865 result.len(),
866 1,
867 "Bare URL in MDX component body must still be flagged: {result:?}"
868 );
869 }
870
871 #[test]
872 fn test_url_in_backticks_inside_nested_mdx_component_not_flagged() {
873 let rule = MD034NoBareUrls;
875 let content = "<Outer>\n <Inner>\n Check `https://example.com/` here.\n </Inner>\n</Outer>\n";
876 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
877 let result = rule.check(&ctx).unwrap();
878 assert!(
879 result.is_empty(),
880 "URL in backticks inside nested MDX component must not be flagged: {result:?}"
881 );
882 }
883
884 #[test]
888 fn test_url_in_fenced_code_block_inside_jsx_not_flagged() {
889 let rule = MD034NoBareUrls;
890 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";
891 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
892 let result = rule.check(&ctx).unwrap();
893 assert!(
894 result.is_empty(),
895 "URL in a fenced code block nested in a JSX component must not be flagged: {result:?}"
896 );
897 }
898
899 #[test]
902 fn test_fix_does_not_rewrite_url_in_fenced_code_block_inside_jsx() {
903 let rule = MD034NoBareUrls;
904 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";
905 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
906 let fixed = rule.fix(&ctx).unwrap();
907 assert_eq!(
908 fixed, content,
909 "fix must not rewrite a URL inside a JSX-nested fenced code block"
910 );
911 }
912
913 #[test]
916 fn test_bare_url_in_jsx_body_outside_fence_still_flagged() {
917 let rule = MD034NoBareUrls;
918 let content = "# Title\n\n<Steps>\n <Step title=\"Send a request\">\n Visit https://example.com/api now.\n </Step>\n</Steps>\n";
919 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
920 let result = rule.check(&ctx).unwrap();
921 assert_eq!(
922 result.len(),
923 1,
924 "A bare URL in the JSX body (not in a fence) must still be flagged: {result:?}"
925 );
926 }
927
928 #[test]
932 fn test_bare_url_not_masked_by_comment_delimiter_in_code_block() {
933 let rule = MD034NoBareUrls;
934 let content =
935 "# T\n\n```text\n<!-- literal opener, not a comment\n```\n\nhttps://example.com should be flagged\n\n-->\n";
936 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
937 let result = rule.check(&ctx).unwrap();
938 assert_eq!(result.len(), 1, "the bare URL must still be flagged: {result:?}");
939 assert!(
940 result[0].message.contains("example.com"),
941 "the flagged URL must be the bare one: {result:?}"
942 );
943 }
944
945 #[test]
950 fn test_bare_url_in_indented_comment_in_admonition_still_skipped() {
951 let rule = MD034NoBareUrls;
952 let content = "# T\n\n!!! note\n Some text.\n\n <!--\n https://example.com\n -->\n";
953 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
954 let result = rule.check(&ctx).unwrap();
955 assert!(
956 result.is_empty(),
957 "URL inside an indented HTML comment in an admonition must not be flagged: {result:?}"
958 );
959 }
960
961 #[test]
965 fn test_url_in_jsx_component_attribute_not_flagged() {
966 let rule = MD034NoBareUrls;
967 let content = "<Card title=\"Docs\" href=\"https://example.com/docs\" />\n";
968 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
969 let result = rule.check(&ctx).unwrap();
970 assert!(
971 result.is_empty(),
972 "URL in a JSX component attribute must not be flagged: {result:?}"
973 );
974 }
975
976 #[test]
978 fn test_url_in_multiline_jsx_component_attribute_not_flagged() {
979 let rule = MD034NoBareUrls;
980 let content = "<Card\n title=\"Docs\"\n href=\"https://example.com/docs\"\n/>\n";
981 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
982 let result = rule.check(&ctx).unwrap();
983 assert!(
984 result.is_empty(),
985 "URL in a multi-line JSX component attribute must not be flagged: {result:?}"
986 );
987 }
988
989 #[test]
992 fn test_jsx_attribute_url_skipped_but_body_url_flagged() {
993 let rule = MD034NoBareUrls;
994 let content = "<Card href=\"https://attr.example.com\">\n Visit https://body.example.com now.\n</Card>\n";
995 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
996 let result = rule.check(&ctx).unwrap();
997 assert_eq!(
998 result.len(),
999 1,
1000 "Only the body URL must be flagged, not the attribute URL: {result:?}"
1001 );
1002 assert!(
1003 result[0].message.contains("body.example.com"),
1004 "The flagged URL must be the body one: {result:?}"
1005 );
1006 }
1007
1008 #[test]
1011 fn test_email_in_jsx_component_attribute_not_flagged() {
1012 let rule = MD034NoBareUrls;
1013 let content = "<Contact email=\"hello@example.com\" />\n";
1014 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1015 let result = rule.check(&ctx).unwrap();
1016 assert!(
1017 result.is_empty(),
1018 "Email in a JSX component attribute must not be flagged: {result:?}"
1019 );
1020 }
1021
1022 #[test]
1026 fn test_jsx_attribute_url_not_flagged_in_standard_flavor() {
1027 let rule = MD034NoBareUrls;
1028 let content = "<Card href=\"https://example.com/docs\" />\n";
1029 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1030 let result = rule.check(&ctx).unwrap();
1031 assert!(
1032 result.is_empty(),
1033 "URL in a tag attribute must not be flagged under Standard flavor either: {result:?}"
1034 );
1035 }
1036
1037 #[test]
1039 fn test_pandoc_skips_urls_in_line_blocks() {
1040 use crate::config::MarkdownFlavor;
1041 use crate::lint_context::LintContext;
1042 let rule = MD034NoBareUrls;
1043 let content = "| See https://example.com\n| For details\n";
1044 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1045 let result = rule.check(&ctx).unwrap();
1046 assert!(
1047 result.is_empty(),
1048 "MD034 should skip URLs in Pandoc line blocks: {result:?}"
1049 );
1050 }
1051
1052 #[test]
1054 fn test_pandoc_skips_urls_in_metadata() {
1055 use crate::config::MarkdownFlavor;
1056 use crate::lint_context::LintContext;
1057 let rule = MD034NoBareUrls;
1058 let content = "---\nhomepage: https://example.com\n---\n\nBody.\n";
1059 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1060 let result = rule.check(&ctx).unwrap();
1061 assert!(
1062 result.is_empty(),
1063 "MD034 should skip URLs in Pandoc YAML metadata: {result:?}"
1064 );
1065 }
1066
1067 #[test]
1070 fn test_standard_still_flags_urls_in_pipe_prefixed_lines() {
1071 use crate::config::MarkdownFlavor;
1072 use crate::lint_context::LintContext;
1073 let rule = MD034NoBareUrls;
1074 let content = "| See https://example.com\n";
1075 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1076 let result = rule.check(&ctx).unwrap();
1077 assert!(
1078 !result.is_empty(),
1079 "MD034 should still flag URLs in pipe-prefixed lines under Standard flavor"
1080 );
1081 }
1082
1083 #[test]
1084 fn test_url_in_backticks_after_fenced_code_block_inside_mdx_not_flagged() {
1085 let rule = MD034NoBareUrls;
1089 let content = "\
1090<Component>
1091Some intro text.
1092
1093```
1094example code here
1095```
1096
1097Check `https://example.com/` here.
1098</Component>
1099";
1100 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1101 let result = rule.check(&ctx).unwrap();
1102 assert!(
1103 result.is_empty(),
1104 "URL in backticks after a fenced code block inside MDX must not be flagged: {result:?}"
1105 );
1106 }
1107
1108 #[test]
1112 fn test_myst_colon_directive_argument_url_not_flagged() {
1113 use crate::config::MarkdownFlavor;
1114 use crate::lint_context::LintContext;
1115 let rule = MD034NoBareUrls;
1116 let content = "\
1117:::{anywidget} https://cdn.jsdelivr.net/npm/repo-review-webapp@1.1.3/dist/repo-review-anywidget.mjs
1118{
1119 \"deps\": [\"repo-review~=1.1.0\"]
1120}
1121:::
1122";
1123 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
1124 let result = rule.check(&ctx).unwrap();
1125 assert!(
1126 result.is_empty(),
1127 "URL argument on a MyST colon directive opener must not be flagged: {result:?}"
1128 );
1129 }
1130
1131 #[test]
1133 fn test_myst_nested_colon_directive_argument_url_not_flagged() {
1134 use crate::config::MarkdownFlavor;
1135 use crate::lint_context::LintContext;
1136 let rule = MD034NoBareUrls;
1137 let content = "\
1138::::{grid}
1139:::{card} https://example.com/card-target
1140Some caption.
1141:::
1142::::
1143";
1144 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
1145 let result = rule.check(&ctx).unwrap();
1146 assert!(
1147 result.is_empty(),
1148 "URL argument on a nested MyST colon directive opener must not be flagged: {result:?}"
1149 );
1150 }
1151
1152 #[test]
1155 fn test_myst_directive_body_url_still_flagged() {
1156 use crate::config::MarkdownFlavor;
1157 use crate::lint_context::LintContext;
1158 let rule = MD034NoBareUrls;
1159 let content = "\
1160:::{note}
1161See https://example.com/docs for more details.
1162:::
1163";
1164 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
1165 let result = rule.check(&ctx).unwrap();
1166 assert_eq!(
1167 result.len(),
1168 1,
1169 "Bare URL in a MyST directive body must still be flagged: {result:?}"
1170 );
1171 }
1172
1173 #[test]
1176 fn test_myst_unclosed_colon_directive_argument_url_not_flagged() {
1177 use crate::config::MarkdownFlavor;
1178 use crate::lint_context::LintContext;
1179 let rule = MD034NoBareUrls;
1180 let content = "\
1181:::{anywidget} https://example.com/widget.mjs
1182Some trailing content with no closing fence.
1183";
1184 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
1185 let result = rule.check(&ctx).unwrap();
1186 assert!(
1187 result.is_empty(),
1188 "URL argument on an unclosed MyST colon directive opener must not be flagged: {result:?}"
1189 );
1190 }
1191
1192 #[test]
1195 fn test_colon_directive_url_flagged_in_standard_flavor() {
1196 use crate::config::MarkdownFlavor;
1197 use crate::lint_context::LintContext;
1198 let rule = MD034NoBareUrls;
1199 let content = ":::{anywidget} https://example.com/widget.mjs\n";
1200 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1201 let result = rule.check(&ctx).unwrap();
1202 assert_eq!(
1203 result.len(),
1204 1,
1205 "Under Standard flavor a bare URL on a `:::` line must still be flagged: {result:?}"
1206 );
1207 }
1208
1209 #[test]
1210 fn test_md034_complex_link() {
1211 let rule = MD034NoBareUrls;
1212
1213 let content = "Check [link `code [with brackets]` text](http://example.com) and see http://bare.com.\n";
1216 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1217 let result = rule.check(&ctx).unwrap();
1218 assert_eq!(result.len(), 1, "Should flag exactly 1 URL (the bare one): {result:?}");
1219 assert!(result[0].message.contains("bare.com"));
1220
1221 let content2 = "Check [link `code [` text](http://example.com) and see http://bare.com.\n";
1224 let ctx2 = crate::lint_context::LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1225 let result2 = rule.check(&ctx2).unwrap();
1226 assert_eq!(
1227 result2.len(),
1228 1,
1229 "Should flag exactly 1 URL (the bare one): {result2:?}"
1230 );
1231 assert!(result2[0].message.contains("bare.com"));
1232 }
1233
1234 #[test]
1237 fn test_mdg_reports_bare_urls_without_fixing_them() {
1238 let rule = MD034NoBareUrls;
1239 let content = "\
1240# Feature: Visit https://feature.example.com
1241
1242Prose about https://prose.example.com for background.
1243
1244## Scenario Outline: Open https://outline.example.com
1245
1246* Given I go to https://step.example.com
1247 | site |
1248 | https://datatable.example.com |
1249
1250> * Given I go to https://blockquoted.example.com
1251
12521. Given I go to https://ordered.example.com
1253
1254| url |
1255| ------------------------------ |
1256| https://unindented.example.com |
1257
1258### Examples:
1259
1260 | url |
1261 | ---------------------------- |
1262 | https://examples.example.com |
1263";
1264
1265 let standard_ctx =
1266 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1267 let standard_lines: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
1268 assert_eq!(
1269 standard_lines,
1270 vec![1, 3, 5, 7, 9, 11, 13, 17, 23],
1271 "Standard flavor flags every bare URL"
1272 );
1273
1274 let mdg_ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1275 assert!(!rule.should_skip(&mdg_ctx), "MDG must still run the diagnostic");
1276 let mdg = rule.check(&mdg_ctx).unwrap();
1277 assert_eq!(mdg.iter().map(|w| w.line).collect::<Vec<_>>(), standard_lines);
1278 assert!(mdg.iter().all(|warning| warning.fix.is_none()));
1279 assert!(
1280 mdg.iter()
1281 .all(|warning| warning.message.contains("Gherkin placeholder"))
1282 );
1283 assert!(mdg.iter().all(|warning| warning.message.contains("disable MD034")));
1284 assert_eq!(rule.fix(&mdg_ctx).unwrap(), content, "MDG must rewrite nothing");
1285 }
1286
1287 #[test]
1288 fn test_mdg_reports_bare_email_without_fixing_it() {
1289 let rule = MD034NoBareUrls;
1290 let content = "# Feature: Contact\n\n* Given I email user@example.com\n";
1291 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1292
1293 let warnings = rule.check(&ctx).unwrap();
1294 assert_eq!(warnings.len(), 1);
1295 assert!(warnings[0].message.contains("Gherkin placeholder"));
1296 assert!(warnings[0].message.contains("disable MD034"));
1297 assert!(warnings[0].fix.is_none());
1298 assert_eq!(rule.fix(&ctx).unwrap(), content);
1299 }
1300
1301 #[test]
1304 fn test_mdg_exemption_does_not_affect_other_flavors() {
1305 let rule = MD034NoBareUrls;
1306 let content = "\
1307# Feature: Visit https://feature.example.com
1308
1309Prose about https://prose.example.com for background.
1310
1311## Scenario Outline: Open https://outline.example.com
1312
1313* Given I go to https://step.example.com
1314 | site |
1315 | https://datatable.example.com |
1316
1317### Examples:
1318
1319 | url |
1320 | ---------------------------- |
1321 | https://examples.example.com |
1322";
1323 let expected = "\
1324# Feature: Visit <https://feature.example.com>
1325
1326Prose about <https://prose.example.com> for background.
1327
1328## Scenario Outline: Open <https://outline.example.com>
1329
1330* Given I go to <https://step.example.com>
1331 | site |
1332 | <https://datatable.example.com> |
1333
1334### Examples:
1335
1336 | url |
1337 | ---------------------------- |
1338 | <https://examples.example.com> |
1339";
1340
1341 for flavor in [
1342 crate::config::MarkdownFlavor::Standard,
1343 crate::config::MarkdownFlavor::MkDocs,
1344 crate::config::MarkdownFlavor::MyST,
1345 ] {
1346 let ctx = crate::lint_context::LintContext::new(content, flavor, None);
1347 assert!(!rule.should_skip(&ctx), "{flavor:?} must still run the rule");
1348 assert_eq!(rule.check(&ctx).unwrap().len(), 6, "{flavor:?} must flag all six URLs");
1349
1350 let fixed = rule.fix(&ctx).unwrap();
1351 assert_eq!(fixed, expected, "{flavor:?} must wrap all six URLs");
1352
1353 let fixed_ctx = crate::lint_context::LintContext::new(&fixed, flavor, None);
1354 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1355 assert_eq!(
1356 rule.fix(&fixed_ctx).unwrap(),
1357 fixed,
1358 "{flavor:?} fix must be idempotent"
1359 );
1360 }
1361 }
1362
1363 #[test]
1371 fn test_mdx_fixes_bare_urls_to_links_instead_of_autolinks() {
1372 let rule = MD034NoBareUrls;
1373 let cases = [
1374 (
1375 "Bare link: http://localhost/\n",
1376 "Bare link: [http://localhost/](http://localhost/)\n",
1377 "Bare link: <http://localhost/>\n",
1378 ),
1379 (
1380 "Visit www.example.com today\n",
1381 "Visit [www.example.com](https://www.example.com) today\n",
1382 "Visit <https://www.example.com> today\n",
1383 ),
1384 (
1385 "Mail user@example.com now\n",
1386 "Mail [user@example.com](mailto:user@example.com) now\n",
1387 "Mail <user@example.com> now\n",
1388 ),
1389 (
1390 "Chat xmpp:foo@bar.baz please\n",
1391 "Chat [xmpp:foo@bar.baz](xmpp:foo@bar.baz) please\n",
1392 "Chat <xmpp:foo@bar.baz> please\n",
1393 ),
1394 ];
1395
1396 for (content, expected_mdx, expected_standard) in cases {
1397 let mdx_ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1398 assert_eq!(
1399 rule.check(&mdx_ctx).unwrap().len(),
1400 1,
1401 "MDX must still report the bare URL in {content:?}"
1402 );
1403 assert_eq!(rule.fix(&mdx_ctx).unwrap(), expected_mdx, "MDX fix for {content:?}");
1404
1405 let standard_ctx =
1406 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1407 assert_eq!(
1408 rule.fix(&standard_ctx).unwrap(),
1409 expected_standard,
1410 "Standard fix for {content:?} must be unchanged"
1411 );
1412 }
1413 }
1414
1415 #[test]
1419 fn test_an_address_behind_a_uri_scheme_is_not_a_bare_email() {
1420 let rule = MD034NoBareUrls;
1421 for content in [
1422 "Mail mailto:user@example.com now\n",
1423 "Chat xmpp:foo@bar.baz please\n",
1424 "Call sip:user@example.com now\n",
1425 "Key openpgp4fpr:user@example.com here\n",
1426 "Ping xmpp+tls:user@example.com now\n",
1427 ] {
1428 for flavor in [
1429 crate::config::MarkdownFlavor::Standard,
1430 crate::config::MarkdownFlavor::MDX,
1431 ] {
1432 let ctx = crate::lint_context::LintContext::new(content, flavor, None);
1433 let emails: Vec<_> = rule
1434 .check(&ctx)
1435 .unwrap()
1436 .into_iter()
1437 .filter(|w| w.message.starts_with("Email address"))
1438 .collect();
1439 assert!(
1440 emails.is_empty(),
1441 "{flavor:?} reported the tail of a schemed URI in {content:?} as a bare email: {emails:?}"
1442 );
1443 }
1444 }
1445 }
1446
1447 #[test]
1450 fn test_a_colon_before_an_address_is_still_a_bare_email() {
1451 let rule = MD034NoBareUrls;
1452 for content in [
1453 "Contact: user@example.com\n",
1454 "Note (see 3:1): user@example.com\n",
1455 "Mail 2user@example.com now\n",
1456 "Ratio 3:user@example.com now\n",
1459 "Mail :user@example.com now\n",
1460 ] {
1461 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1462 assert_eq!(
1463 rule.check(&ctx).unwrap().len(),
1464 1,
1465 "{content:?} must still report a bare email"
1466 );
1467 }
1468 }
1469
1470 #[test]
1471 fn test_follows_uri_scheme() {
1472 assert!(follows_uri_scheme("mailto:a@b.co", 7));
1474 assert!(follows_uri_scheme("Mail mailto:a@b.co", 12));
1475 assert!(follows_uri_scheme("xmpp+tls:a@b.co", 9));
1476 assert!(follows_uri_scheme("a:a@b.co", 2));
1477
1478 assert!(!follows_uri_scheme("a@b.co", 0));
1479 assert!(!follows_uri_scheme("Contact: a@b.co", 9), "a space separates the colon");
1480 assert!(!follows_uri_scheme("2mailto:a@b.co", 8));
1482 assert!(!follows_uri_scheme(":a@b.co", 1), "empty scheme");
1483 assert!(follows_uri_scheme("Schrijf mailto:a@b.co", 15));
1486 assert!(!follows_uri_scheme("Schrijf é:a@b.co", 11));
1487 }
1488
1489 #[test]
1495 fn test_mdx_escapes_an_active_bang_before_the_link() {
1496 let rule = MD034NoBareUrls;
1497 let cases = [
1498 (
1499 "Download now!https://example.com/f today\n",
1500 "Download now\\ today\n",
1501 ),
1502 (
1503 "Contact us!user@example.com now\n",
1504 "Contact us\\ now\n",
1505 ),
1506 (
1509 "Escaped already\\!https://example.com/e today\n",
1510 "Escaped already\\ today\n",
1511 ),
1512 (
1514 "Two slashes\\\\!https://example.com/t today\n",
1515 "Two slashes\\\\\\ today\n",
1516 ),
1517 (
1519 "Normal! https://example.com/s today\n",
1520 "Normal! [https://example.com/s](https://example.com/s) today\n",
1521 ),
1522 ];
1523
1524 for (content, expected) in cases {
1525 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1526 assert_eq!(rule.fix(&ctx).unwrap(), expected, "MDX fix for {content:?}");
1527 }
1528 }
1529
1530 #[test]
1533 fn test_a_preceding_bang_is_untouched_outside_jsx_flavors() {
1534 let rule = MD034NoBareUrls;
1535 let ctx = crate::lint_context::LintContext::new(
1536 "Download now!https://example.com/f today\n",
1537 crate::config::MarkdownFlavor::Standard,
1538 None,
1539 );
1540 assert_eq!(rule.fix(&ctx).unwrap(), "Download now!<https://example.com/f> today\n");
1541 }
1542
1543 #[test]
1548 fn test_mdx_reports_but_does_not_fix_a_url_after_an_active_close_bracket() {
1549 let rule = MD034NoBareUrls;
1550 let content =
1551 "[See more]https://example.com/x here\n\n[https://example.com/x]: https://elsewhere.example.com/\n";
1552 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1553
1554 let warnings = rule.check(&ctx).unwrap();
1555 assert_eq!(warnings.len(), 1, "the bare URL is still a finding");
1556 assert!(warnings[0].fix.is_none(), "no replacement is safe here");
1557 assert_eq!(rule.fix(&ctx).unwrap(), content, "fmt must leave the line alone");
1558 }
1559
1560 #[test]
1562 fn test_mdx_fixes_after_an_escaped_close_bracket() {
1563 let rule = MD034NoBareUrls;
1564 let ctx = crate::lint_context::LintContext::new(
1565 "Text \\]https://example.com/x here\n",
1566 crate::config::MarkdownFlavor::MDX,
1567 None,
1568 );
1569 assert_eq!(
1570 rule.fix(&ctx).unwrap(),
1571 "Text \\][https://example.com/x](https://example.com/x) here\n"
1572 );
1573 }
1574
1575 #[test]
1576 fn test_classify_link_prefix() {
1577 let free = |s: &str| matches!(classify_link_prefix(s, s.len()), LinkPrefix::Free);
1578 let bang = |s: &str| matches!(classify_link_prefix(s, s.len()), LinkPrefix::ActiveBang);
1579 let bracket = |s: &str| matches!(classify_link_prefix(s, s.len()), LinkPrefix::ActiveCloseBracket);
1580
1581 assert!(free(""), "start of line binds to nothing");
1582 assert!(free("plain "));
1583 assert!(free("plain"));
1584 assert!(bang("hi!"));
1585 assert!(free("hi\\!"), "one backslash escapes the bang");
1586 assert!(bang("hi\\\\!"), "two backslashes escape each other, not the bang");
1587 assert!(free("hi\\\\\\!"), "three escape the bang again");
1588 assert!(bracket("[a]"));
1589 assert!(free("[a\\]"), "an escaped bracket closes no span");
1590 assert!(free("café"));
1592 assert!(bang("café!"));
1593 }
1594
1595 #[test]
1603 fn test_mdx_link_text_escapes_characters_that_would_not_render_literally() {
1604 let rule = MD034NoBareUrls;
1605 let cases = [
1606 ("https://ex.com/a*b*c", "https://ex.com/a\\*b\\*c"),
1607 ("https://ex.com/a&b", "https://ex.com/a\\&b"),
1608 ("https://ex.com/a~b~c", "https://ex.com/a\\~b\\~c"),
1609 ("https://ex.com/a_b_c", "https://ex.com/a\\_b\\_c"),
1610 ];
1611
1612 for (url, escaped_text) in cases {
1613 let content = format!("See {url} here\n");
1614 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDX, None);
1615 assert_eq!(
1616 rule.fix(&ctx).unwrap(),
1617 format!("See [{escaped_text}]({url}) here\n"),
1618 "MDX must escape the link text for {url}"
1619 );
1620
1621 let standard_ctx =
1622 crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1623 assert_eq!(
1624 rule.fix(&standard_ctx).unwrap(),
1625 format!("See <{url}> here\n"),
1626 "Standard emits the autolink, which needs no escaping"
1627 );
1628 }
1629 }
1630
1631 #[test]
1637 fn test_mdx_braces_are_skipped_when_paired_and_escaped_when_not() {
1638 let rule = MD034NoBareUrls;
1639
1640 let paired = "See https://ex.com/a{b}c here\n";
1641 let paired_ctx = crate::lint_context::LintContext::new(paired, crate::config::MarkdownFlavor::MDX, None);
1642 assert!(
1643 rule.check(&paired_ctx).unwrap().is_empty(),
1644 "a balanced brace pair is a JSX expression, which MD034 leaves alone"
1645 );
1646
1647 let standard_ctx = crate::lint_context::LintContext::new(paired, crate::config::MarkdownFlavor::Standard, None);
1648 assert_eq!(
1649 rule.fix(&standard_ctx).unwrap(),
1650 "See <https://ex.com/a{b}c> here\n",
1651 "outside MDX the braces carry no meaning, so the URL is still reported"
1652 );
1653
1654 for (url, escaped_text) in [
1655 ("https://ex.com/a{b", "https://ex.com/a\\{b"),
1656 ("https://ex.com/a}b", "https://ex.com/a\\}b"),
1657 ] {
1658 let content = format!("See {url} here\n");
1659 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDX, None);
1660 assert_eq!(
1661 rule.fix(&ctx).unwrap(),
1662 format!("See [{escaped_text}]({url}) here\n"),
1663 "an unmatched brace reaches the fix and must be escaped"
1664 );
1665 }
1666 }
1667
1668 #[test]
1673 fn test_mdx_unbalanced_open_paren_uses_an_angle_bracket_destination() {
1674 let rule = MD034NoBareUrls;
1675
1676 let unbalanced = "Go to https://ex.com/a(b now\n";
1677 let ctx = crate::lint_context::LintContext::new(unbalanced, crate::config::MarkdownFlavor::MDX, None);
1678 assert_eq!(
1679 rule.fix(&ctx).unwrap(),
1680 "Go to [https://ex.com/a(b](<https://ex.com/a(b>) now\n"
1681 );
1682
1683 let balanced = "Go to https://en.wikipedia.org/wiki/Foo_(bar) now\n";
1684 let ctx = crate::lint_context::LintContext::new(balanced, crate::config::MarkdownFlavor::MDX, None);
1685 assert_eq!(
1686 rule.fix(&ctx).unwrap(),
1687 "Go to [https://en.wikipedia.org/wiki/Foo\\_(bar)](https://en.wikipedia.org/wiki/Foo_(bar)) now\n",
1688 "balanced parens need no angle brackets"
1689 );
1690 }
1691
1692 #[test]
1695 fn test_mdx_fix_is_idempotent_and_stops_reporting() {
1696 let rule = MD034NoBareUrls;
1697 let content = "\
1698Plain http://localhost/ and www.example.com.
1699
1700Mail user@example.com or see https://ex.com/a*b_c{d}e.
1701
1702Parens https://ex.com/a(b and https://en.wikipedia.org/wiki/Foo_(bar).
1703";
1704 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1705 let fixed = rule.fix(&ctx).unwrap();
1706 assert_ne!(fixed, content, "the fix must actually rewrite this document");
1707
1708 let fixed_ctx = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::MDX, None);
1709 assert!(
1710 rule.check(&fixed_ctx).unwrap().is_empty(),
1711 "MDX must not re-report its own output: {:?}",
1712 rule.check(&fixed_ctx).unwrap()
1713 );
1714 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDX fix must be idempotent");
1715 }
1716
1717 #[test]
1720 fn test_link_form_is_confined_to_jsx_flavors() {
1721 let rule = MD034NoBareUrls;
1722 let content = "Visit https://example.com today\n";
1723
1724 for flavor in [
1725 crate::config::MarkdownFlavor::Standard,
1726 crate::config::MarkdownFlavor::MkDocs,
1727 crate::config::MarkdownFlavor::MyST,
1728 crate::config::MarkdownFlavor::Quarto,
1729 crate::config::MarkdownFlavor::Obsidian,
1730 ] {
1731 assert!(!flavor.supports_jsx(), "{flavor:?} is not a JSX flavor");
1732 let ctx = crate::lint_context::LintContext::new(content, flavor, None);
1733 assert_eq!(
1734 rule.fix(&ctx).unwrap(),
1735 "Visit <https://example.com> today\n",
1736 "{flavor:?} must keep the autolink form"
1737 );
1738 }
1739
1740 let mdg_ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1741 assert_eq!(rule.check(&mdg_ctx).unwrap().len(), 1);
1742 assert!(rule.check(&mdg_ctx).unwrap()[0].fix.is_none());
1743 assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
1744 }
1745
1746 #[test]
1747 fn test_escape_mdx_link_text_covers_every_active_character() {
1748 assert_eq!(escape_mdx_link_text("plain"), "plain");
1749 for ch in MDX_LINK_TEXT_ESCAPES {
1750 assert_eq!(escape_mdx_link_text(&ch.to_string()), format!("\\{ch}"));
1751 }
1752 }
1753
1754 #[test]
1755 fn test_has_balanced_parens() {
1756 assert!(has_balanced_parens("https://ex.com/a"));
1757 assert!(has_balanced_parens("https://ex.com/(a)"));
1758 assert!(has_balanced_parens("https://ex.com/(a)(b)"));
1759 assert!(has_balanced_parens("https://ex.com/((a))"));
1760 assert!(!has_balanced_parens("https://ex.com/(a"));
1761 assert!(!has_balanced_parens("https://ex.com/a)"));
1762 assert!(
1763 !has_balanced_parens("https://ex.com/)a("),
1764 "equal counts are not balance"
1765 );
1766 }
1767}