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 trimmed.trim_end_matches(['.', ',', ';', ':', '!', '?', '*', '_', '~'])
256 }
257
258 fn check_line(
259 &self,
260 line: &str,
261 ctx: &LintContext,
262 line_number: usize,
263 code_spans: &[crate::lint_context::CodeSpan],
264 buffers: &mut LineCheckBuffers,
265 ) -> Vec<LintWarning> {
266 let mut warnings = Vec::new();
267
268 if ctx.line_info(line_number).is_some_and(|info| info.in_html_block) {
270 return warnings;
271 }
272
273 if MULTILINE_LINK_CONTINUATION_REGEX.is_match(line) {
276 return warnings;
277 }
278
279 let has_quick_check = URL_QUICK_CHECK_REGEX.is_match(line);
281 let has_www = line.contains("www.");
282 let has_at = line.contains('@');
283
284 if !has_quick_check && !has_at && !has_www {
285 return warnings;
286 }
287
288 buffers.markdown_link_ranges.clear();
290 buffers.image_ranges.clear();
291
292 let has_bracket = line.contains('[');
293 let has_angle = line.contains('<');
294 let has_bang = line.contains('!');
295
296 if has_bracket {
297 for mat in MARKDOWN_LINK_REGEX.find_iter(line) {
298 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
299 }
300
301 for mat in MARKDOWN_EMPTY_LINK_REGEX.find_iter(line) {
303 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
304 }
305
306 for mat in MARKDOWN_EMPTY_REF_REGEX.find_iter(line) {
307 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
308 }
309
310 for mat in SHORTCUT_REF_REGEX.find_iter(line) {
312 let end = mat.end();
313 let next_non_ws = line[end..].bytes().find(|b| !b.is_ascii_whitespace());
314 if next_non_ws == Some(b'(') || next_non_ws == Some(b'[') {
315 continue;
316 }
317 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
318 }
319
320 if has_bang && BADGE_LINK_LINE_REGEX.is_match(line) {
322 return warnings;
323 }
324 }
325
326 if has_angle {
327 for mat in ANGLE_LINK_REGEX.find_iter(line) {
328 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
329 }
330 }
331
332 if has_bang && has_bracket {
334 for mat in MARKDOWN_IMAGE_REGEX.find_iter(line) {
335 buffers.image_ranges.push((mat.start(), mat.end()));
336 }
337 }
338
339 buffers.urls_found.clear();
341
342 for mat in URL_IPV6_REGEX.find_iter(line) {
344 let url_str = mat.as_str();
345 buffers.urls_found.push((mat.start(), mat.end(), url_str.to_string()));
346 }
347
348 for mat in URL_STANDARD_REGEX.find_iter(line) {
350 let url_str = mat.as_str();
351
352 if url_str.contains("://[") {
354 continue;
355 }
356
357 if let Some(host_start) = url_str.find("://") {
360 let after_protocol = &url_str[host_start + 3..];
361 if after_protocol.contains("::") || after_protocol.chars().filter(|&c| c == ':').count() > 1 {
363 if line.as_bytes().get(mat.end()) == Some(&b']') {
365 continue;
367 }
368 }
369 }
370
371 buffers.urls_found.push((mat.start(), mat.end(), url_str.to_string()));
372 }
373
374 for mat in URL_WWW_REGEX.find_iter(line) {
376 let url_str = mat.as_str();
377 let start_pos = mat.start();
378 let end_pos = mat.end();
379
380 if start_pos > 0 {
382 let prev_char = line.as_bytes().get(start_pos - 1).copied();
383 if prev_char == Some(b'/') || prev_char == Some(b'@') {
384 continue;
385 }
386 }
387
388 if start_pos > 0 && end_pos < line.len() {
390 let prev_char = line.as_bytes().get(start_pos - 1).copied();
391 let next_char = line.as_bytes().get(end_pos).copied();
392 if prev_char == Some(b'<') && next_char == Some(b'>') {
393 continue;
394 }
395 }
396
397 buffers.urls_found.push((start_pos, end_pos, url_str.to_string()));
398 }
399
400 for mat in XMPP_URI_REGEX.find_iter(line) {
402 let uri_str = mat.as_str();
403 let start_pos = mat.start();
404 let end_pos = mat.end();
405
406 if start_pos > 0 && end_pos < line.len() {
408 let prev_char = line.as_bytes().get(start_pos - 1).copied();
409 let next_char = line.as_bytes().get(end_pos).copied();
410 if prev_char == Some(b'<') && next_char == Some(b'>') {
411 continue;
412 }
413 }
414
415 buffers.urls_found.push((start_pos, end_pos, uri_str.to_string()));
416 }
417
418 for &(start, _end, ref url_str) in &buffers.urls_found {
420 if CUSTOM_PROTOCOL_REGEX.is_match(url_str) {
422 continue;
423 }
424
425 let is_inside_construct = buffers
431 .markdown_link_ranges
432 .iter()
433 .any(|&(s, e)| start >= s && start < e)
434 || buffers.image_ranges.iter().any(|&(s, e)| start >= s && start < e);
435
436 if is_inside_construct {
437 continue;
438 }
439
440 let line_start_byte = ctx.line_start_byte(line_number).unwrap_or(0);
442 let absolute_pos = line_start_byte + start;
443
444 if ctx.is_in_html_tag(absolute_pos) {
446 continue;
447 }
448
449 if ctx.is_in_jsx_component_tag(absolute_pos) {
453 continue;
454 }
455
456 if ctx.is_in_html_comment(absolute_pos) || ctx.is_in_mdx_comment(absolute_pos) {
458 continue;
459 }
460
461 if ctx.is_in_shortcode(absolute_pos) {
463 continue;
464 }
465
466 if ctx.flavor.is_pandoc_compatible()
470 && (ctx.is_in_line_block(absolute_pos) || ctx.is_in_pandoc_metadata(absolute_pos))
471 {
472 continue;
473 }
474
475 let trimmed_url = self.trim_trailing_punctuation(url_str);
477
478 if !trimmed_url.is_empty() && trimmed_url != "//" {
480 let trimmed_len = trimmed_url.len();
481 let (start_line, start_col, end_line, end_col) =
482 calculate_url_range(line_number, line, start, trimmed_len);
483
484 let destination = if trimmed_url.starts_with("www.") {
486 format!("https://{trimmed_url}")
487 } else {
488 trimmed_url.to_string()
489 };
490 let line_start_byte = ctx.line_start_byte(line_number).unwrap_or(0);
491 let span_end = line_start_byte + start + trimmed_len;
492 let fix = if ctx.flavor.supports_jsx() {
493 jsx_fix(line, start, trimmed_url, &destination)
494 .map(|(fix_start, replacement)| Fix::new((line_start_byte + fix_start)..span_end, replacement))
495 } else {
496 Some(Fix::new(
497 (line_start_byte + start)..span_end,
498 format!("<{destination}>"),
499 ))
500 };
501
502 warnings.push(LintWarning {
503 rule_name: Some("MD034".to_string()),
504 line: start_line,
505 column: start_col,
506 end_line,
507 end_column: end_col,
508 message: if ctx.flavor == crate::config::MarkdownFlavor::MDG {
509 format!(
510 "URL without link formatting: '{trimmed_url}' (angle brackets are Gherkin placeholder syntax; use explicit link formatting where appropriate, or disable MD034)"
511 )
512 } else {
513 format!("URL without angle brackets or link formatting: '{trimmed_url}'")
514 },
515 severity: Severity::Warning,
516 fix,
517 });
518 }
519 }
520
521 for cap in EMAIL_PATTERN.captures_iter(line) {
523 if let Some(mat) = cap.get(0) {
524 let email = mat.as_str();
525 let start = mat.start();
526 let end = mat.end();
527
528 if follows_uri_scheme(line, start) {
531 continue;
532 }
533
534 let mut is_inside_construct = false;
536 for &(link_start, link_end) in &buffers.markdown_link_ranges {
537 if start >= link_start && end <= link_end {
538 is_inside_construct = true;
539 break;
540 }
541 }
542
543 if !is_inside_construct {
544 let line_start_byte = ctx.line_start_byte(line_number).unwrap_or(0);
546 let absolute_pos = line_start_byte + start;
547
548 if ctx.is_in_html_tag(absolute_pos) {
550 continue;
551 }
552
553 if ctx.is_in_jsx_component_tag(absolute_pos) {
556 continue;
557 }
558
559 if ctx.flavor.is_pandoc_compatible()
561 && (ctx.is_in_line_block(absolute_pos) || ctx.is_in_pandoc_metadata(absolute_pos))
562 {
563 continue;
564 }
565
566 let is_in_code_span = code_spans
568 .iter()
569 .any(|span| absolute_pos >= span.byte_offset && absolute_pos < span.byte_end);
570
571 if !is_in_code_span {
572 let email_len = end - start;
573 let (start_line, start_col, end_line, end_col) =
574 calculate_url_range(line_number, line, start, email_len);
575
576 let fix = if ctx.flavor.supports_jsx() {
577 jsx_fix(line, start, email, &format!("mailto:{email}")).map(|(fix_start, replacement)| {
578 Fix::new((line_start_byte + fix_start)..(line_start_byte + end), replacement)
579 })
580 } else {
581 Some(Fix::new(
582 (line_start_byte + start)..(line_start_byte + end),
583 format!("<{email}>"),
584 ))
585 };
586
587 warnings.push(LintWarning {
588 rule_name: Some("MD034".to_string()),
589 line: start_line,
590 column: start_col,
591 end_line,
592 end_column: end_col,
593 message: if ctx.flavor == crate::config::MarkdownFlavor::MDG {
594 format!(
595 "Email address without link formatting: '{email}' (angle brackets are Gherkin placeholder syntax; use explicit link formatting where appropriate, or disable MD034)"
596 )
597 } else {
598 format!("Email address without angle brackets or link formatting: '{email}'")
599 },
600 severity: Severity::Warning,
601 fix,
602 });
603 }
604 }
605 }
606 }
607
608 warnings
609 }
610}
611
612impl Rule for MD034NoBareUrls {
613 #[inline]
614 fn name(&self) -> &'static str {
615 "MD034"
616 }
617
618 fn as_any(&self) -> &dyn std::any::Any {
619 self
620 }
621
622 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
623 where
624 Self: Sized,
625 {
626 Box::new(MD034NoBareUrls)
627 }
628
629 #[inline]
630 fn category(&self) -> RuleCategory {
631 RuleCategory::Link
632 }
633
634 fn skippable_by_category(&self) -> bool {
635 false
640 }
641
642 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
643 !ctx.likely_has_links_or_images() && self.should_skip_content(ctx.content)
644 }
645
646 #[inline]
647 fn description(&self) -> &'static str {
648 "No bare URLs - wrap URLs in angle brackets"
649 }
650
651 fn check(&self, ctx: &LintContext) -> LintResult {
652 let mut warnings = Vec::new();
653 let content = ctx.content;
654
655 if self.should_skip_content(content) {
657 return Ok(warnings);
658 }
659
660 let code_spans = ctx.code_spans();
662
663 let ref_def_lines: std::collections::HashSet<usize> =
667 ctx.reference_definitions().iter().map(|def| def.line).collect();
668
669 let mut buffers = LineCheckBuffers::default();
671
672 for line in ctx
676 .filtered_lines()
677 .skip_front_matter()
678 .skip_code_blocks()
679 .skip_jsx_expressions()
680 .skip_mdx_comments()
681 .skip_obsidian_comments()
682 {
683 if ctx.flavor == crate::config::MarkdownFlavor::GhAw && crate::utils::gh_aw::is_control_line(line.content) {
687 continue;
688 }
689
690 if ctx.is_myst_colon_directive_opener_line(line.line_num) {
696 continue;
697 }
698
699 if ref_def_lines.contains(&line.line_num) {
701 continue;
702 }
703
704 let mut line_warnings = self.check_line(line.content, ctx, line.line_num, &code_spans, &mut buffers);
705
706 line_warnings.retain(|warning| {
708 !code_spans.iter().any(|span| {
709 if let Some(fix) = &warning.fix {
710 fix.range.start >= span.byte_offset && fix.range.start < span.byte_end
712 } else {
713 span.line == warning.line
714 && span.end_line == warning.line
715 && warning.column > 0
716 && (warning.column - 1) >= span.start_col
717 && (warning.column - 1) < span.end_col
718 }
719 })
720 });
721
722 line_warnings.retain(|warning| {
723 if let Some(fix) = &warning.fix {
724 !ctx.links().iter().any(|link| {
726 !(link.is_reference && link.url.is_empty())
727 && fix.range.start >= link.byte_offset
728 && fix.range.end <= link.byte_end
729 })
730 } else {
731 true
732 }
733 });
734
735 line_warnings.retain(|warning| !ctx.is_position_in_obsidian_comment(warning.line, warning.column));
738
739 warnings.extend(line_warnings);
740 }
741
742 if ctx.flavor == crate::config::MarkdownFlavor::MDG {
746 for warning in &mut warnings {
747 warning.fix = None;
748 }
749 }
750
751 Ok(warnings)
752 }
753
754 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
755 let mut content = ctx.content.to_string();
756 let warnings = self.check(ctx)?;
757 let mut warnings =
758 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
759
760 warnings.sort_by_key(|w| w.fix.as_ref().map_or(0, |f| f.range.start));
762
763 for warning in warnings.iter().rev() {
765 if let Some(fix) = &warning.fix {
766 let start = fix.range.start;
767 let end = fix.range.end;
768 content.replace_range(start..end, &fix.replacement);
769 }
770 }
771
772 Ok(content)
773 }
774}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779
780 #[test]
781 fn test_shortcut_ref_at_end_of_line_no_trailing_chars() {
782 let rule = MD034NoBareUrls;
783 let content = "See [https://example.com]";
784 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
785 let result = rule.check(&ctx).unwrap();
786 assert!(
787 result.is_empty(),
788 "[URL] at end of line should be treated as shortcut ref: {result:?}"
789 );
790 }
791
792 #[test]
793 fn test_shortcut_ref_multiple_spaces_before_paren() {
794 let rule = MD034NoBareUrls;
795 let content = "[text] (https://example.com)";
796 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
797 let result = rule.check(&ctx).unwrap();
798 let _ = result; }
803
804 #[test]
805 fn test_shortcut_ref_tab_before_bracket() {
806 let rule = MD034NoBareUrls;
807 let content = "[https://example.com]\t[other]";
808 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
809 let result = rule.check(&ctx).unwrap();
810 assert_eq!(
814 result.len(),
815 1,
816 "Bare URL inside shortcut ref should be detected: {result:?}"
817 );
818 }
819
820 #[test]
821 fn test_shortcut_ref_followed_by_punctuation() {
822 let rule = MD034NoBareUrls;
823 let content = "[https://example.com], see also other things.";
824 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
825 let result = rule.check(&ctx).unwrap();
826 assert!(
827 result.is_empty(),
828 "[URL] followed by comma should be treated as shortcut ref: {result:?}"
829 );
830 }
831
832 #[test]
833 fn test_url_in_backticks_inside_mdx_component_not_flagged() {
834 let rule = MD034NoBareUrls;
838 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";
839 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
840 let result = rule.check(&ctx).unwrap();
841 assert!(
842 result.is_empty(),
843 "URL in backticks inside MDX component must not be flagged: {result:?}"
844 );
845 }
846
847 #[test]
848 fn test_bare_url_inside_mdx_component_still_flagged() {
849 let rule = MD034NoBareUrls;
852 let content =
853 "# Test\n\n<ParamField path=\"--stuff\">\n Visit https://rumdl.example.com/ for details.\n</ParamField>\n";
854 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
855 let result = rule.check(&ctx).unwrap();
856 assert_eq!(
857 result.len(),
858 1,
859 "Bare URL in MDX component body must still be flagged: {result:?}"
860 );
861 }
862
863 #[test]
864 fn test_url_in_backticks_inside_nested_mdx_component_not_flagged() {
865 let rule = MD034NoBareUrls;
867 let content = "<Outer>\n <Inner>\n Check `https://example.com/` here.\n </Inner>\n</Outer>\n";
868 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
869 let result = rule.check(&ctx).unwrap();
870 assert!(
871 result.is_empty(),
872 "URL in backticks inside nested MDX component must not be flagged: {result:?}"
873 );
874 }
875
876 #[test]
880 fn test_url_in_fenced_code_block_inside_jsx_not_flagged() {
881 let rule = MD034NoBareUrls;
882 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";
883 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
884 let result = rule.check(&ctx).unwrap();
885 assert!(
886 result.is_empty(),
887 "URL in a fenced code block nested in a JSX component must not be flagged: {result:?}"
888 );
889 }
890
891 #[test]
894 fn test_fix_does_not_rewrite_url_in_fenced_code_block_inside_jsx() {
895 let rule = MD034NoBareUrls;
896 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";
897 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
898 let fixed = rule.fix(&ctx).unwrap();
899 assert_eq!(
900 fixed, content,
901 "fix must not rewrite a URL inside a JSX-nested fenced code block"
902 );
903 }
904
905 #[test]
908 fn test_bare_url_in_jsx_body_outside_fence_still_flagged() {
909 let rule = MD034NoBareUrls;
910 let content = "# Title\n\n<Steps>\n <Step title=\"Send a request\">\n Visit https://example.com/api now.\n </Step>\n</Steps>\n";
911 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
912 let result = rule.check(&ctx).unwrap();
913 assert_eq!(
914 result.len(),
915 1,
916 "A bare URL in the JSX body (not in a fence) must still be flagged: {result:?}"
917 );
918 }
919
920 #[test]
924 fn test_bare_url_not_masked_by_comment_delimiter_in_code_block() {
925 let rule = MD034NoBareUrls;
926 let content =
927 "# T\n\n```text\n<!-- literal opener, not a comment\n```\n\nhttps://example.com should be flagged\n\n-->\n";
928 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
929 let result = rule.check(&ctx).unwrap();
930 assert_eq!(result.len(), 1, "the bare URL must still be flagged: {result:?}");
931 assert!(
932 result[0].message.contains("example.com"),
933 "the flagged URL must be the bare one: {result:?}"
934 );
935 }
936
937 #[test]
942 fn test_bare_url_in_indented_comment_in_admonition_still_skipped() {
943 let rule = MD034NoBareUrls;
944 let content = "# T\n\n!!! note\n Some text.\n\n <!--\n https://example.com\n -->\n";
945 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
946 let result = rule.check(&ctx).unwrap();
947 assert!(
948 result.is_empty(),
949 "URL inside an indented HTML comment in an admonition must not be flagged: {result:?}"
950 );
951 }
952
953 #[test]
957 fn test_url_in_jsx_component_attribute_not_flagged() {
958 let rule = MD034NoBareUrls;
959 let content = "<Card title=\"Docs\" href=\"https://example.com/docs\" />\n";
960 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
961 let result = rule.check(&ctx).unwrap();
962 assert!(
963 result.is_empty(),
964 "URL in a JSX component attribute must not be flagged: {result:?}"
965 );
966 }
967
968 #[test]
970 fn test_url_in_multiline_jsx_component_attribute_not_flagged() {
971 let rule = MD034NoBareUrls;
972 let content = "<Card\n title=\"Docs\"\n href=\"https://example.com/docs\"\n/>\n";
973 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
974 let result = rule.check(&ctx).unwrap();
975 assert!(
976 result.is_empty(),
977 "URL in a multi-line JSX component attribute must not be flagged: {result:?}"
978 );
979 }
980
981 #[test]
984 fn test_jsx_attribute_url_skipped_but_body_url_flagged() {
985 let rule = MD034NoBareUrls;
986 let content = "<Card href=\"https://attr.example.com\">\n Visit https://body.example.com now.\n</Card>\n";
987 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
988 let result = rule.check(&ctx).unwrap();
989 assert_eq!(
990 result.len(),
991 1,
992 "Only the body URL must be flagged, not the attribute URL: {result:?}"
993 );
994 assert!(
995 result[0].message.contains("body.example.com"),
996 "The flagged URL must be the body one: {result:?}"
997 );
998 }
999
1000 #[test]
1003 fn test_email_in_jsx_component_attribute_not_flagged() {
1004 let rule = MD034NoBareUrls;
1005 let content = "<Contact email=\"hello@example.com\" />\n";
1006 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1007 let result = rule.check(&ctx).unwrap();
1008 assert!(
1009 result.is_empty(),
1010 "Email in a JSX component attribute must not be flagged: {result:?}"
1011 );
1012 }
1013
1014 #[test]
1018 fn test_jsx_attribute_url_not_flagged_in_standard_flavor() {
1019 let rule = MD034NoBareUrls;
1020 let content = "<Card href=\"https://example.com/docs\" />\n";
1021 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1022 let result = rule.check(&ctx).unwrap();
1023 assert!(
1024 result.is_empty(),
1025 "URL in a tag attribute must not be flagged under Standard flavor either: {result:?}"
1026 );
1027 }
1028
1029 #[test]
1031 fn test_pandoc_skips_urls_in_line_blocks() {
1032 use crate::config::MarkdownFlavor;
1033 use crate::lint_context::LintContext;
1034 let rule = MD034NoBareUrls;
1035 let content = "| See https://example.com\n| For details\n";
1036 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1037 let result = rule.check(&ctx).unwrap();
1038 assert!(
1039 result.is_empty(),
1040 "MD034 should skip URLs in Pandoc line blocks: {result:?}"
1041 );
1042 }
1043
1044 #[test]
1046 fn test_pandoc_skips_urls_in_metadata() {
1047 use crate::config::MarkdownFlavor;
1048 use crate::lint_context::LintContext;
1049 let rule = MD034NoBareUrls;
1050 let content = "---\nhomepage: https://example.com\n---\n\nBody.\n";
1051 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1052 let result = rule.check(&ctx).unwrap();
1053 assert!(
1054 result.is_empty(),
1055 "MD034 should skip URLs in Pandoc YAML metadata: {result:?}"
1056 );
1057 }
1058
1059 #[test]
1062 fn test_standard_still_flags_urls_in_pipe_prefixed_lines() {
1063 use crate::config::MarkdownFlavor;
1064 use crate::lint_context::LintContext;
1065 let rule = MD034NoBareUrls;
1066 let content = "| See https://example.com\n";
1067 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1068 let result = rule.check(&ctx).unwrap();
1069 assert!(
1070 !result.is_empty(),
1071 "MD034 should still flag URLs in pipe-prefixed lines under Standard flavor"
1072 );
1073 }
1074
1075 #[test]
1076 fn test_url_in_backticks_after_fenced_code_block_inside_mdx_not_flagged() {
1077 let rule = MD034NoBareUrls;
1081 let content = "\
1082<Component>
1083Some intro text.
1084
1085```
1086example code here
1087```
1088
1089Check `https://example.com/` here.
1090</Component>
1091";
1092 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1093 let result = rule.check(&ctx).unwrap();
1094 assert!(
1095 result.is_empty(),
1096 "URL in backticks after a fenced code block inside MDX must not be flagged: {result:?}"
1097 );
1098 }
1099
1100 #[test]
1104 fn test_myst_colon_directive_argument_url_not_flagged() {
1105 use crate::config::MarkdownFlavor;
1106 use crate::lint_context::LintContext;
1107 let rule = MD034NoBareUrls;
1108 let content = "\
1109:::{anywidget} https://cdn.jsdelivr.net/npm/repo-review-webapp@1.1.3/dist/repo-review-anywidget.mjs
1110{
1111 \"deps\": [\"repo-review~=1.1.0\"]
1112}
1113:::
1114";
1115 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
1116 let result = rule.check(&ctx).unwrap();
1117 assert!(
1118 result.is_empty(),
1119 "URL argument on a MyST colon directive opener must not be flagged: {result:?}"
1120 );
1121 }
1122
1123 #[test]
1125 fn test_myst_nested_colon_directive_argument_url_not_flagged() {
1126 use crate::config::MarkdownFlavor;
1127 use crate::lint_context::LintContext;
1128 let rule = MD034NoBareUrls;
1129 let content = "\
1130::::{grid}
1131:::{card} https://example.com/card-target
1132Some caption.
1133:::
1134::::
1135";
1136 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
1137 let result = rule.check(&ctx).unwrap();
1138 assert!(
1139 result.is_empty(),
1140 "URL argument on a nested MyST colon directive opener must not be flagged: {result:?}"
1141 );
1142 }
1143
1144 #[test]
1147 fn test_myst_directive_body_url_still_flagged() {
1148 use crate::config::MarkdownFlavor;
1149 use crate::lint_context::LintContext;
1150 let rule = MD034NoBareUrls;
1151 let content = "\
1152:::{note}
1153See https://example.com/docs for more details.
1154:::
1155";
1156 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
1157 let result = rule.check(&ctx).unwrap();
1158 assert_eq!(
1159 result.len(),
1160 1,
1161 "Bare URL in a MyST directive body must still be flagged: {result:?}"
1162 );
1163 }
1164
1165 #[test]
1168 fn test_myst_unclosed_colon_directive_argument_url_not_flagged() {
1169 use crate::config::MarkdownFlavor;
1170 use crate::lint_context::LintContext;
1171 let rule = MD034NoBareUrls;
1172 let content = "\
1173:::{anywidget} https://example.com/widget.mjs
1174Some trailing content with no closing fence.
1175";
1176 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
1177 let result = rule.check(&ctx).unwrap();
1178 assert!(
1179 result.is_empty(),
1180 "URL argument on an unclosed MyST colon directive opener must not be flagged: {result:?}"
1181 );
1182 }
1183
1184 #[test]
1187 fn test_colon_directive_url_flagged_in_standard_flavor() {
1188 use crate::config::MarkdownFlavor;
1189 use crate::lint_context::LintContext;
1190 let rule = MD034NoBareUrls;
1191 let content = ":::{anywidget} https://example.com/widget.mjs\n";
1192 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1193 let result = rule.check(&ctx).unwrap();
1194 assert_eq!(
1195 result.len(),
1196 1,
1197 "Under Standard flavor a bare URL on a `:::` line must still be flagged: {result:?}"
1198 );
1199 }
1200
1201 #[test]
1202 fn test_md034_complex_link() {
1203 let rule = MD034NoBareUrls;
1204
1205 let content = "Check [link `code [with brackets]` text](http://example.com) and see http://bare.com.\n";
1208 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1209 let result = rule.check(&ctx).unwrap();
1210 assert_eq!(result.len(), 1, "Should flag exactly 1 URL (the bare one): {result:?}");
1211 assert!(result[0].message.contains("bare.com"));
1212
1213 let content2 = "Check [link `code [` text](http://example.com) and see http://bare.com.\n";
1216 let ctx2 = crate::lint_context::LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1217 let result2 = rule.check(&ctx2).unwrap();
1218 assert_eq!(
1219 result2.len(),
1220 1,
1221 "Should flag exactly 1 URL (the bare one): {result2:?}"
1222 );
1223 assert!(result2[0].message.contains("bare.com"));
1224 }
1225
1226 #[test]
1229 fn test_mdg_reports_bare_urls_without_fixing_them() {
1230 let rule = MD034NoBareUrls;
1231 let content = "\
1232# Feature: Visit https://feature.example.com
1233
1234Prose about https://prose.example.com for background.
1235
1236## Scenario Outline: Open https://outline.example.com
1237
1238* Given I go to https://step.example.com
1239 | site |
1240 | https://datatable.example.com |
1241
1242> * Given I go to https://blockquoted.example.com
1243
12441. Given I go to https://ordered.example.com
1245
1246| url |
1247| ------------------------------ |
1248| https://unindented.example.com |
1249
1250### Examples:
1251
1252 | url |
1253 | ---------------------------- |
1254 | https://examples.example.com |
1255";
1256
1257 let standard_ctx =
1258 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1259 let standard_lines: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
1260 assert_eq!(
1261 standard_lines,
1262 vec![1, 3, 5, 7, 9, 11, 13, 17, 23],
1263 "Standard flavor flags every bare URL"
1264 );
1265
1266 let mdg_ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1267 assert!(!rule.should_skip(&mdg_ctx), "MDG must still run the diagnostic");
1268 let mdg = rule.check(&mdg_ctx).unwrap();
1269 assert_eq!(mdg.iter().map(|w| w.line).collect::<Vec<_>>(), standard_lines);
1270 assert!(mdg.iter().all(|warning| warning.fix.is_none()));
1271 assert!(
1272 mdg.iter()
1273 .all(|warning| warning.message.contains("Gherkin placeholder"))
1274 );
1275 assert!(mdg.iter().all(|warning| warning.message.contains("disable MD034")));
1276 assert_eq!(rule.fix(&mdg_ctx).unwrap(), content, "MDG must rewrite nothing");
1277 }
1278
1279 #[test]
1280 fn test_mdg_reports_bare_email_without_fixing_it() {
1281 let rule = MD034NoBareUrls;
1282 let content = "# Feature: Contact\n\n* Given I email user@example.com\n";
1283 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1284
1285 let warnings = rule.check(&ctx).unwrap();
1286 assert_eq!(warnings.len(), 1);
1287 assert!(warnings[0].message.contains("Gherkin placeholder"));
1288 assert!(warnings[0].message.contains("disable MD034"));
1289 assert!(warnings[0].fix.is_none());
1290 assert_eq!(rule.fix(&ctx).unwrap(), content);
1291 }
1292
1293 #[test]
1296 fn test_mdg_exemption_does_not_affect_other_flavors() {
1297 let rule = MD034NoBareUrls;
1298 let content = "\
1299# Feature: Visit https://feature.example.com
1300
1301Prose about https://prose.example.com for background.
1302
1303## Scenario Outline: Open https://outline.example.com
1304
1305* Given I go to https://step.example.com
1306 | site |
1307 | https://datatable.example.com |
1308
1309### Examples:
1310
1311 | url |
1312 | ---------------------------- |
1313 | https://examples.example.com |
1314";
1315 let expected = "\
1316# Feature: Visit <https://feature.example.com>
1317
1318Prose about <https://prose.example.com> for background.
1319
1320## Scenario Outline: Open <https://outline.example.com>
1321
1322* Given I go to <https://step.example.com>
1323 | site |
1324 | <https://datatable.example.com> |
1325
1326### Examples:
1327
1328 | url |
1329 | ---------------------------- |
1330 | <https://examples.example.com> |
1331";
1332
1333 for flavor in [
1334 crate::config::MarkdownFlavor::Standard,
1335 crate::config::MarkdownFlavor::MkDocs,
1336 crate::config::MarkdownFlavor::MyST,
1337 ] {
1338 let ctx = crate::lint_context::LintContext::new(content, flavor, None);
1339 assert!(!rule.should_skip(&ctx), "{flavor:?} must still run the rule");
1340 assert_eq!(rule.check(&ctx).unwrap().len(), 6, "{flavor:?} must flag all six URLs");
1341
1342 let fixed = rule.fix(&ctx).unwrap();
1343 assert_eq!(fixed, expected, "{flavor:?} must wrap all six URLs");
1344
1345 let fixed_ctx = crate::lint_context::LintContext::new(&fixed, flavor, None);
1346 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1347 assert_eq!(
1348 rule.fix(&fixed_ctx).unwrap(),
1349 fixed,
1350 "{flavor:?} fix must be idempotent"
1351 );
1352 }
1353 }
1354
1355 #[test]
1363 fn test_mdx_fixes_bare_urls_to_links_instead_of_autolinks() {
1364 let rule = MD034NoBareUrls;
1365 let cases = [
1366 (
1367 "Bare link: http://localhost/\n",
1368 "Bare link: [http://localhost/](http://localhost/)\n",
1369 "Bare link: <http://localhost/>\n",
1370 ),
1371 (
1372 "Visit www.example.com today\n",
1373 "Visit [www.example.com](https://www.example.com) today\n",
1374 "Visit <https://www.example.com> today\n",
1375 ),
1376 (
1377 "Mail user@example.com now\n",
1378 "Mail [user@example.com](mailto:user@example.com) now\n",
1379 "Mail <user@example.com> now\n",
1380 ),
1381 (
1382 "Chat xmpp:foo@bar.baz please\n",
1383 "Chat [xmpp:foo@bar.baz](xmpp:foo@bar.baz) please\n",
1384 "Chat <xmpp:foo@bar.baz> please\n",
1385 ),
1386 ];
1387
1388 for (content, expected_mdx, expected_standard) in cases {
1389 let mdx_ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1390 assert_eq!(
1391 rule.check(&mdx_ctx).unwrap().len(),
1392 1,
1393 "MDX must still report the bare URL in {content:?}"
1394 );
1395 assert_eq!(rule.fix(&mdx_ctx).unwrap(), expected_mdx, "MDX fix for {content:?}");
1396
1397 let standard_ctx =
1398 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1399 assert_eq!(
1400 rule.fix(&standard_ctx).unwrap(),
1401 expected_standard,
1402 "Standard fix for {content:?} must be unchanged"
1403 );
1404 }
1405 }
1406
1407 #[test]
1411 fn test_an_address_behind_a_uri_scheme_is_not_a_bare_email() {
1412 let rule = MD034NoBareUrls;
1413 for content in [
1414 "Mail mailto:user@example.com now\n",
1415 "Chat xmpp:foo@bar.baz please\n",
1416 "Call sip:user@example.com now\n",
1417 "Key openpgp4fpr:user@example.com here\n",
1418 "Ping xmpp+tls:user@example.com now\n",
1419 ] {
1420 for flavor in [
1421 crate::config::MarkdownFlavor::Standard,
1422 crate::config::MarkdownFlavor::MDX,
1423 ] {
1424 let ctx = crate::lint_context::LintContext::new(content, flavor, None);
1425 let emails: Vec<_> = rule
1426 .check(&ctx)
1427 .unwrap()
1428 .into_iter()
1429 .filter(|w| w.message.starts_with("Email address"))
1430 .collect();
1431 assert!(
1432 emails.is_empty(),
1433 "{flavor:?} reported the tail of a schemed URI in {content:?} as a bare email: {emails:?}"
1434 );
1435 }
1436 }
1437 }
1438
1439 #[test]
1442 fn test_a_colon_before_an_address_is_still_a_bare_email() {
1443 let rule = MD034NoBareUrls;
1444 for content in [
1445 "Contact: user@example.com\n",
1446 "Note (see 3:1): user@example.com\n",
1447 "Mail 2user@example.com now\n",
1448 "Ratio 3:user@example.com now\n",
1451 "Mail :user@example.com now\n",
1452 ] {
1453 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1454 assert_eq!(
1455 rule.check(&ctx).unwrap().len(),
1456 1,
1457 "{content:?} must still report a bare email"
1458 );
1459 }
1460 }
1461
1462 #[test]
1463 fn test_follows_uri_scheme() {
1464 assert!(follows_uri_scheme("mailto:a@b.co", 7));
1466 assert!(follows_uri_scheme("Mail mailto:a@b.co", 12));
1467 assert!(follows_uri_scheme("xmpp+tls:a@b.co", 9));
1468 assert!(follows_uri_scheme("a:a@b.co", 2));
1469
1470 assert!(!follows_uri_scheme("a@b.co", 0));
1471 assert!(!follows_uri_scheme("Contact: a@b.co", 9), "a space separates the colon");
1472 assert!(!follows_uri_scheme("2mailto:a@b.co", 8));
1474 assert!(!follows_uri_scheme(":a@b.co", 1), "empty scheme");
1475 assert!(follows_uri_scheme("Schrijf mailto:a@b.co", 15));
1478 assert!(!follows_uri_scheme("Schrijf é:a@b.co", 11));
1479 }
1480
1481 #[test]
1487 fn test_mdx_escapes_an_active_bang_before_the_link() {
1488 let rule = MD034NoBareUrls;
1489 let cases = [
1490 (
1491 "Download now!https://example.com/f today\n",
1492 "Download now\\ today\n",
1493 ),
1494 (
1495 "Contact us!user@example.com now\n",
1496 "Contact us\\ now\n",
1497 ),
1498 (
1501 "Escaped already\\!https://example.com/e today\n",
1502 "Escaped already\\ today\n",
1503 ),
1504 (
1506 "Two slashes\\\\!https://example.com/t today\n",
1507 "Two slashes\\\\\\ today\n",
1508 ),
1509 (
1511 "Normal! https://example.com/s today\n",
1512 "Normal! [https://example.com/s](https://example.com/s) today\n",
1513 ),
1514 ];
1515
1516 for (content, expected) in cases {
1517 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1518 assert_eq!(rule.fix(&ctx).unwrap(), expected, "MDX fix for {content:?}");
1519 }
1520 }
1521
1522 #[test]
1525 fn test_a_preceding_bang_is_untouched_outside_jsx_flavors() {
1526 let rule = MD034NoBareUrls;
1527 let ctx = crate::lint_context::LintContext::new(
1528 "Download now!https://example.com/f today\n",
1529 crate::config::MarkdownFlavor::Standard,
1530 None,
1531 );
1532 assert_eq!(rule.fix(&ctx).unwrap(), "Download now!<https://example.com/f> today\n");
1533 }
1534
1535 #[test]
1540 fn test_mdx_reports_but_does_not_fix_a_url_after_an_active_close_bracket() {
1541 let rule = MD034NoBareUrls;
1542 let content =
1543 "[See more]https://example.com/x here\n\n[https://example.com/x]: https://elsewhere.example.com/\n";
1544 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1545
1546 let warnings = rule.check(&ctx).unwrap();
1547 assert_eq!(warnings.len(), 1, "the bare URL is still a finding");
1548 assert!(warnings[0].fix.is_none(), "no replacement is safe here");
1549 assert_eq!(rule.fix(&ctx).unwrap(), content, "fmt must leave the line alone");
1550 }
1551
1552 #[test]
1554 fn test_mdx_fixes_after_an_escaped_close_bracket() {
1555 let rule = MD034NoBareUrls;
1556 let ctx = crate::lint_context::LintContext::new(
1557 "Text \\]https://example.com/x here\n",
1558 crate::config::MarkdownFlavor::MDX,
1559 None,
1560 );
1561 assert_eq!(
1562 rule.fix(&ctx).unwrap(),
1563 "Text \\][https://example.com/x](https://example.com/x) here\n"
1564 );
1565 }
1566
1567 #[test]
1568 fn test_classify_link_prefix() {
1569 let free = |s: &str| matches!(classify_link_prefix(s, s.len()), LinkPrefix::Free);
1570 let bang = |s: &str| matches!(classify_link_prefix(s, s.len()), LinkPrefix::ActiveBang);
1571 let bracket = |s: &str| matches!(classify_link_prefix(s, s.len()), LinkPrefix::ActiveCloseBracket);
1572
1573 assert!(free(""), "start of line binds to nothing");
1574 assert!(free("plain "));
1575 assert!(free("plain"));
1576 assert!(bang("hi!"));
1577 assert!(free("hi\\!"), "one backslash escapes the bang");
1578 assert!(bang("hi\\\\!"), "two backslashes escape each other, not the bang");
1579 assert!(free("hi\\\\\\!"), "three escape the bang again");
1580 assert!(bracket("[a]"));
1581 assert!(free("[a\\]"), "an escaped bracket closes no span");
1582 assert!(free("café"));
1584 assert!(bang("café!"));
1585 }
1586
1587 #[test]
1595 fn test_mdx_link_text_escapes_characters_that_would_not_render_literally() {
1596 let rule = MD034NoBareUrls;
1597 let cases = [
1598 ("https://ex.com/a*b*c", "https://ex.com/a\\*b\\*c"),
1599 ("https://ex.com/a&b", "https://ex.com/a\\&b"),
1600 ("https://ex.com/a~b~c", "https://ex.com/a\\~b\\~c"),
1601 ("https://ex.com/a_b_c", "https://ex.com/a\\_b\\_c"),
1602 ];
1603
1604 for (url, escaped_text) in cases {
1605 let content = format!("See {url} here\n");
1606 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDX, None);
1607 assert_eq!(
1608 rule.fix(&ctx).unwrap(),
1609 format!("See [{escaped_text}]({url}) here\n"),
1610 "MDX must escape the link text for {url}"
1611 );
1612
1613 let standard_ctx =
1614 crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1615 assert_eq!(
1616 rule.fix(&standard_ctx).unwrap(),
1617 format!("See <{url}> here\n"),
1618 "Standard emits the autolink, which needs no escaping"
1619 );
1620 }
1621 }
1622
1623 #[test]
1629 fn test_mdx_braces_are_skipped_when_paired_and_escaped_when_not() {
1630 let rule = MD034NoBareUrls;
1631
1632 let paired = "See https://ex.com/a{b}c here\n";
1633 let paired_ctx = crate::lint_context::LintContext::new(paired, crate::config::MarkdownFlavor::MDX, None);
1634 assert!(
1635 rule.check(&paired_ctx).unwrap().is_empty(),
1636 "a balanced brace pair is a JSX expression, which MD034 leaves alone"
1637 );
1638
1639 let standard_ctx = crate::lint_context::LintContext::new(paired, crate::config::MarkdownFlavor::Standard, None);
1640 assert_eq!(
1641 rule.fix(&standard_ctx).unwrap(),
1642 "See <https://ex.com/a{b}c> here\n",
1643 "outside MDX the braces carry no meaning, so the URL is still reported"
1644 );
1645
1646 for (url, escaped_text) in [
1647 ("https://ex.com/a{b", "https://ex.com/a\\{b"),
1648 ("https://ex.com/a}b", "https://ex.com/a\\}b"),
1649 ] {
1650 let content = format!("See {url} here\n");
1651 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDX, None);
1652 assert_eq!(
1653 rule.fix(&ctx).unwrap(),
1654 format!("See [{escaped_text}]({url}) here\n"),
1655 "an unmatched brace reaches the fix and must be escaped"
1656 );
1657 }
1658 }
1659
1660 #[test]
1665 fn test_mdx_unbalanced_open_paren_uses_an_angle_bracket_destination() {
1666 let rule = MD034NoBareUrls;
1667
1668 let unbalanced = "Go to https://ex.com/a(b now\n";
1669 let ctx = crate::lint_context::LintContext::new(unbalanced, crate::config::MarkdownFlavor::MDX, None);
1670 assert_eq!(
1671 rule.fix(&ctx).unwrap(),
1672 "Go to [https://ex.com/a(b](<https://ex.com/a(b>) now\n"
1673 );
1674
1675 let balanced = "Go to https://en.wikipedia.org/wiki/Foo_(bar) now\n";
1676 let ctx = crate::lint_context::LintContext::new(balanced, crate::config::MarkdownFlavor::MDX, None);
1677 assert_eq!(
1678 rule.fix(&ctx).unwrap(),
1679 "Go to [https://en.wikipedia.org/wiki/Foo\\_(bar)](https://en.wikipedia.org/wiki/Foo_(bar)) now\n",
1680 "balanced parens need no angle brackets"
1681 );
1682 }
1683
1684 #[test]
1687 fn test_mdx_fix_is_idempotent_and_stops_reporting() {
1688 let rule = MD034NoBareUrls;
1689 let content = "\
1690Plain http://localhost/ and www.example.com.
1691
1692Mail user@example.com or see https://ex.com/a*b_c{d}e.
1693
1694Parens https://ex.com/a(b and https://en.wikipedia.org/wiki/Foo_(bar).
1695";
1696 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1697 let fixed = rule.fix(&ctx).unwrap();
1698 assert_ne!(fixed, content, "the fix must actually rewrite this document");
1699
1700 let fixed_ctx = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::MDX, None);
1701 assert!(
1702 rule.check(&fixed_ctx).unwrap().is_empty(),
1703 "MDX must not re-report its own output: {:?}",
1704 rule.check(&fixed_ctx).unwrap()
1705 );
1706 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDX fix must be idempotent");
1707 }
1708
1709 #[test]
1712 fn test_link_form_is_confined_to_jsx_flavors() {
1713 let rule = MD034NoBareUrls;
1714 let content = "Visit https://example.com today\n";
1715
1716 for flavor in [
1717 crate::config::MarkdownFlavor::Standard,
1718 crate::config::MarkdownFlavor::MkDocs,
1719 crate::config::MarkdownFlavor::MyST,
1720 crate::config::MarkdownFlavor::Quarto,
1721 crate::config::MarkdownFlavor::Obsidian,
1722 ] {
1723 assert!(!flavor.supports_jsx(), "{flavor:?} is not a JSX flavor");
1724 let ctx = crate::lint_context::LintContext::new(content, flavor, None);
1725 assert_eq!(
1726 rule.fix(&ctx).unwrap(),
1727 "Visit <https://example.com> today\n",
1728 "{flavor:?} must keep the autolink form"
1729 );
1730 }
1731
1732 let mdg_ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1733 assert_eq!(rule.check(&mdg_ctx).unwrap().len(), 1);
1734 assert!(rule.check(&mdg_ctx).unwrap()[0].fix.is_none());
1735 assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
1736 }
1737
1738 #[test]
1739 fn test_escape_mdx_link_text_covers_every_active_character() {
1740 assert_eq!(escape_mdx_link_text("plain"), "plain");
1741 for ch in MDX_LINK_TEXT_ESCAPES {
1742 assert_eq!(escape_mdx_link_text(&ch.to_string()), format!("\\{ch}"));
1743 }
1744 }
1745
1746 #[test]
1747 fn test_has_balanced_parens() {
1748 assert!(has_balanced_parens("https://ex.com/a"));
1749 assert!(has_balanced_parens("https://ex.com/(a)"));
1750 assert!(has_balanced_parens("https://ex.com/(a)(b)"));
1751 assert!(has_balanced_parens("https://ex.com/((a))"));
1752 assert!(!has_balanced_parens("https://ex.com/(a"));
1753 assert!(!has_balanced_parens("https://ex.com/a)"));
1754 assert!(
1755 !has_balanced_parens("https://ex.com/)a("),
1756 "equal counts are not balance"
1757 );
1758 }
1759}