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
41#[derive(Default)]
43struct LineCheckBuffers {
44 markdown_link_ranges: Vec<(usize, usize)>,
45 image_ranges: Vec<(usize, usize)>,
46 urls_found: Vec<(usize, usize, String)>,
47}
48
49#[derive(Default, Clone)]
50pub struct MD034NoBareUrls;
51
52impl MD034NoBareUrls {
53 #[inline]
54 pub fn should_skip_content(&self, content: &str) -> bool {
55 let bytes = content.as_bytes();
58 let has_colon = bytes.contains(&b':');
59 let has_at = bytes.contains(&b'@');
60 let has_www = content.contains("www.");
61 !has_colon && !has_at && !has_www
62 }
63
64 fn trim_trailing_punctuation<'a>(&self, url: &'a str) -> &'a str {
66 let mut trimmed = url;
67
68 let open_parens = url.chars().filter(|&c| c == '(').count();
70 let close_parens = url.chars().filter(|&c| c == ')').count();
71
72 if close_parens > open_parens {
73 let mut balance = 0;
75 let mut last_balanced_pos = url.len();
76
77 for (byte_idx, c) in url.char_indices() {
78 if c == '(' {
79 balance += 1;
80 } else if c == ')' {
81 balance -= 1;
82 if balance < 0 {
83 last_balanced_pos = byte_idx;
85 break;
86 }
87 }
88 }
89
90 trimmed = &trimmed[..last_balanced_pos];
91 }
92
93 while let Some(last_char) = trimmed.chars().last() {
95 if matches!(last_char, '.' | ',' | ';' | ':' | '!' | '?') {
96 if last_char == ':' && trimmed.len() > 1 {
99 break;
101 }
102 trimmed = &trimmed[..trimmed.len() - 1];
103 } else {
104 break;
105 }
106 }
107
108 trimmed
109 }
110
111 fn check_line(
112 &self,
113 line: &str,
114 ctx: &LintContext,
115 line_number: usize,
116 code_spans: &[crate::lint_context::CodeSpan],
117 buffers: &mut LineCheckBuffers,
118 ) -> Vec<LintWarning> {
119 let mut warnings = Vec::new();
120
121 if ctx.line_info(line_number).is_some_and(|info| info.in_html_block) {
123 return warnings;
124 }
125
126 if MULTILINE_LINK_CONTINUATION_REGEX.is_match(line) {
129 return warnings;
130 }
131
132 let has_quick_check = URL_QUICK_CHECK_REGEX.is_match(line);
134 let has_www = line.contains("www.");
135 let has_at = line.contains('@');
136
137 if !has_quick_check && !has_at && !has_www {
138 return warnings;
139 }
140
141 buffers.markdown_link_ranges.clear();
143 buffers.image_ranges.clear();
144
145 let has_bracket = line.contains('[');
146 let has_angle = line.contains('<');
147 let has_bang = line.contains('!');
148
149 if has_bracket {
150 for mat in MARKDOWN_LINK_REGEX.find_iter(line) {
151 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
152 }
153
154 for mat in MARKDOWN_EMPTY_LINK_REGEX.find_iter(line) {
156 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
157 }
158
159 for mat in MARKDOWN_EMPTY_REF_REGEX.find_iter(line) {
160 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
161 }
162
163 for mat in SHORTCUT_REF_REGEX.find_iter(line) {
165 let end = mat.end();
166 let next_non_ws = line[end..].bytes().find(|b| !b.is_ascii_whitespace());
167 if next_non_ws == Some(b'(') || next_non_ws == Some(b'[') {
168 continue;
169 }
170 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
171 }
172
173 if has_bang && BADGE_LINK_LINE_REGEX.is_match(line) {
175 return warnings;
176 }
177 }
178
179 if has_angle {
180 for mat in ANGLE_LINK_REGEX.find_iter(line) {
181 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
182 }
183 }
184
185 if has_bang && has_bracket {
187 for mat in MARKDOWN_IMAGE_REGEX.find_iter(line) {
188 buffers.image_ranges.push((mat.start(), mat.end()));
189 }
190 }
191
192 buffers.urls_found.clear();
194
195 for mat in URL_IPV6_REGEX.find_iter(line) {
197 let url_str = mat.as_str();
198 buffers.urls_found.push((mat.start(), mat.end(), url_str.to_string()));
199 }
200
201 for mat in URL_STANDARD_REGEX.find_iter(line) {
203 let url_str = mat.as_str();
204
205 if url_str.contains("://[") {
207 continue;
208 }
209
210 if let Some(host_start) = url_str.find("://") {
213 let after_protocol = &url_str[host_start + 3..];
214 if after_protocol.contains("::") || after_protocol.chars().filter(|&c| c == ':').count() > 1 {
216 if line.as_bytes().get(mat.end()) == Some(&b']') {
218 continue;
220 }
221 }
222 }
223
224 buffers.urls_found.push((mat.start(), mat.end(), url_str.to_string()));
225 }
226
227 for mat in URL_WWW_REGEX.find_iter(line) {
229 let url_str = mat.as_str();
230 let start_pos = mat.start();
231 let end_pos = mat.end();
232
233 if start_pos > 0 {
235 let prev_char = line.as_bytes().get(start_pos - 1).copied();
236 if prev_char == Some(b'/') || prev_char == Some(b'@') {
237 continue;
238 }
239 }
240
241 if start_pos > 0 && end_pos < line.len() {
243 let prev_char = line.as_bytes().get(start_pos - 1).copied();
244 let next_char = line.as_bytes().get(end_pos).copied();
245 if prev_char == Some(b'<') && next_char == Some(b'>') {
246 continue;
247 }
248 }
249
250 buffers.urls_found.push((start_pos, end_pos, url_str.to_string()));
251 }
252
253 for mat in XMPP_URI_REGEX.find_iter(line) {
255 let uri_str = mat.as_str();
256 let start_pos = mat.start();
257 let end_pos = mat.end();
258
259 if start_pos > 0 && end_pos < line.len() {
261 let prev_char = line.as_bytes().get(start_pos - 1).copied();
262 let next_char = line.as_bytes().get(end_pos).copied();
263 if prev_char == Some(b'<') && next_char == Some(b'>') {
264 continue;
265 }
266 }
267
268 buffers.urls_found.push((start_pos, end_pos, uri_str.to_string()));
269 }
270
271 for &(start, _end, ref url_str) in &buffers.urls_found {
273 if CUSTOM_PROTOCOL_REGEX.is_match(url_str) {
275 continue;
276 }
277
278 let is_inside_construct = buffers
284 .markdown_link_ranges
285 .iter()
286 .any(|&(s, e)| start >= s && start < e)
287 || buffers.image_ranges.iter().any(|&(s, e)| start >= s && start < e);
288
289 if is_inside_construct {
290 continue;
291 }
292
293 let line_start_byte = ctx.line_start_byte(line_number).unwrap_or(0);
295 let absolute_pos = line_start_byte + start;
296
297 if ctx.is_in_html_tag(absolute_pos) {
299 continue;
300 }
301
302 if ctx.is_in_jsx_component_tag(absolute_pos) {
306 continue;
307 }
308
309 if ctx.is_in_html_comment(absolute_pos) || ctx.is_in_mdx_comment(absolute_pos) {
311 continue;
312 }
313
314 if ctx.is_in_shortcode(absolute_pos) {
316 continue;
317 }
318
319 if ctx.flavor.is_pandoc_compatible()
323 && (ctx.is_in_line_block(absolute_pos) || ctx.is_in_pandoc_metadata(absolute_pos))
324 {
325 continue;
326 }
327
328 let trimmed_url = self.trim_trailing_punctuation(url_str);
330
331 if !trimmed_url.is_empty() && trimmed_url != "//" {
333 let trimmed_len = trimmed_url.len();
334 let (start_line, start_col, end_line, end_col) =
335 calculate_url_range(line_number, line, start, trimmed_len);
336
337 let replacement = if trimmed_url.starts_with("www.") {
339 format!("<https://{trimmed_url}>")
340 } else {
341 format!("<{trimmed_url}>")
342 };
343
344 warnings.push(LintWarning {
345 rule_name: Some("MD034".to_string()),
346 line: start_line,
347 column: start_col,
348 end_line,
349 end_column: end_col,
350 message: if ctx.flavor == crate::config::MarkdownFlavor::MDG {
351 format!(
352 "URL without link formatting: '{trimmed_url}' (angle brackets are Gherkin placeholder syntax; use explicit link formatting where appropriate, or disable MD034)"
353 )
354 } else {
355 format!("URL without angle brackets or link formatting: '{trimmed_url}'")
356 },
357 severity: Severity::Warning,
358 fix: Some(Fix::new(
359 {
360 let line_start_byte = ctx.line_start_byte(line_number).unwrap_or(0);
361 (line_start_byte + start)..(line_start_byte + start + trimmed_len)
362 },
363 replacement,
364 )),
365 });
366 }
367 }
368
369 for cap in EMAIL_PATTERN.captures_iter(line) {
371 if let Some(mat) = cap.get(0) {
372 let email = mat.as_str();
373 let start = mat.start();
374 let end = mat.end();
375
376 if start >= 5 && line.is_char_boundary(start - 5) && &line[start - 5..start] == "xmpp:" {
379 continue;
380 }
381
382 let mut is_inside_construct = false;
384 for &(link_start, link_end) in &buffers.markdown_link_ranges {
385 if start >= link_start && end <= link_end {
386 is_inside_construct = true;
387 break;
388 }
389 }
390
391 if !is_inside_construct {
392 let line_start_byte = ctx.line_start_byte(line_number).unwrap_or(0);
394 let absolute_pos = line_start_byte + start;
395
396 if ctx.is_in_html_tag(absolute_pos) {
398 continue;
399 }
400
401 if ctx.is_in_jsx_component_tag(absolute_pos) {
404 continue;
405 }
406
407 if ctx.flavor.is_pandoc_compatible()
409 && (ctx.is_in_line_block(absolute_pos) || ctx.is_in_pandoc_metadata(absolute_pos))
410 {
411 continue;
412 }
413
414 let is_in_code_span = code_spans
416 .iter()
417 .any(|span| absolute_pos >= span.byte_offset && absolute_pos < span.byte_end);
418
419 if !is_in_code_span {
420 let email_len = end - start;
421 let (start_line, start_col, end_line, end_col) =
422 calculate_url_range(line_number, line, start, email_len);
423
424 warnings.push(LintWarning {
425 rule_name: Some("MD034".to_string()),
426 line: start_line,
427 column: start_col,
428 end_line,
429 end_column: end_col,
430 message: if ctx.flavor == crate::config::MarkdownFlavor::MDG {
431 format!(
432 "Email address without link formatting: '{email}' (angle brackets are Gherkin placeholder syntax; use explicit link formatting where appropriate, or disable MD034)"
433 )
434 } else {
435 format!("Email address without angle brackets or link formatting: '{email}'")
436 },
437 severity: Severity::Warning,
438 fix: Some(Fix::new(
439 (line_start_byte + start)..(line_start_byte + end),
440 format!("<{email}>"),
441 )),
442 });
443 }
444 }
445 }
446 }
447
448 warnings
449 }
450}
451
452impl Rule for MD034NoBareUrls {
453 #[inline]
454 fn name(&self) -> &'static str {
455 "MD034"
456 }
457
458 fn as_any(&self) -> &dyn std::any::Any {
459 self
460 }
461
462 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
463 where
464 Self: Sized,
465 {
466 Box::new(MD034NoBareUrls)
467 }
468
469 #[inline]
470 fn category(&self) -> RuleCategory {
471 RuleCategory::Link
472 }
473
474 fn skippable_by_category(&self) -> bool {
475 false
480 }
481
482 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
483 !ctx.likely_has_links_or_images() && self.should_skip_content(ctx.content)
484 }
485
486 #[inline]
487 fn description(&self) -> &'static str {
488 "No bare URLs - wrap URLs in angle brackets"
489 }
490
491 fn check(&self, ctx: &LintContext) -> LintResult {
492 let mut warnings = Vec::new();
493 let content = ctx.content;
494
495 if self.should_skip_content(content) {
497 return Ok(warnings);
498 }
499
500 let code_spans = ctx.code_spans();
502
503 let ref_def_lines: std::collections::HashSet<usize> =
507 ctx.reference_definitions().iter().map(|def| def.line).collect();
508
509 let mut buffers = LineCheckBuffers::default();
511
512 for line in ctx
516 .filtered_lines()
517 .skip_front_matter()
518 .skip_code_blocks()
519 .skip_jsx_expressions()
520 .skip_mdx_comments()
521 .skip_obsidian_comments()
522 {
523 if ctx.flavor == crate::config::MarkdownFlavor::GhAw && crate::utils::gh_aw::is_control_line(line.content) {
527 continue;
528 }
529
530 if ctx.is_myst_colon_directive_opener_line(line.line_num) {
536 continue;
537 }
538
539 if ref_def_lines.contains(&line.line_num) {
541 continue;
542 }
543
544 let mut line_warnings = self.check_line(line.content, ctx, line.line_num, &code_spans, &mut buffers);
545
546 line_warnings.retain(|warning| {
548 !code_spans.iter().any(|span| {
549 if let Some(fix) = &warning.fix {
550 fix.range.start >= span.byte_offset && fix.range.start < span.byte_end
552 } else {
553 span.line == warning.line
554 && span.end_line == warning.line
555 && warning.column > 0
556 && (warning.column - 1) >= span.start_col
557 && (warning.column - 1) < span.end_col
558 }
559 })
560 });
561
562 line_warnings.retain(|warning| {
563 if let Some(fix) = &warning.fix {
564 !ctx.links().iter().any(|link| {
566 !(link.is_reference && link.url.is_empty())
567 && fix.range.start >= link.byte_offset
568 && fix.range.end <= link.byte_end
569 })
570 } else {
571 true
572 }
573 });
574
575 line_warnings.retain(|warning| !ctx.is_position_in_obsidian_comment(warning.line, warning.column));
578
579 warnings.extend(line_warnings);
580 }
581
582 if ctx.flavor == crate::config::MarkdownFlavor::MDG {
586 for warning in &mut warnings {
587 warning.fix = None;
588 }
589 }
590
591 Ok(warnings)
592 }
593
594 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
595 let mut content = ctx.content.to_string();
596 let warnings = self.check(ctx)?;
597 let mut warnings =
598 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
599
600 warnings.sort_by_key(|w| w.fix.as_ref().map_or(0, |f| f.range.start));
602
603 for warning in warnings.iter().rev() {
605 if let Some(fix) = &warning.fix {
606 let start = fix.range.start;
607 let end = fix.range.end;
608 content.replace_range(start..end, &fix.replacement);
609 }
610 }
611
612 Ok(content)
613 }
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619
620 #[test]
621 fn test_shortcut_ref_at_end_of_line_no_trailing_chars() {
622 let rule = MD034NoBareUrls;
623 let content = "See [https://example.com]";
624 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
625 let result = rule.check(&ctx).unwrap();
626 assert!(
627 result.is_empty(),
628 "[URL] at end of line should be treated as shortcut ref: {result:?}"
629 );
630 }
631
632 #[test]
633 fn test_shortcut_ref_multiple_spaces_before_paren() {
634 let rule = MD034NoBareUrls;
635 let content = "[text] (https://example.com)";
636 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
637 let result = rule.check(&ctx).unwrap();
638 let _ = result; }
643
644 #[test]
645 fn test_shortcut_ref_tab_before_bracket() {
646 let rule = MD034NoBareUrls;
647 let content = "[https://example.com]\t[other]";
648 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
649 let result = rule.check(&ctx).unwrap();
650 assert_eq!(
654 result.len(),
655 1,
656 "Bare URL inside shortcut ref should be detected: {result:?}"
657 );
658 }
659
660 #[test]
661 fn test_shortcut_ref_followed_by_punctuation() {
662 let rule = MD034NoBareUrls;
663 let content = "[https://example.com], see also other things.";
664 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
665 let result = rule.check(&ctx).unwrap();
666 assert!(
667 result.is_empty(),
668 "[URL] followed by comma should be treated as shortcut ref: {result:?}"
669 );
670 }
671
672 #[test]
673 fn test_url_in_backticks_inside_mdx_component_not_flagged() {
674 let rule = MD034NoBareUrls;
678 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";
679 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
680 let result = rule.check(&ctx).unwrap();
681 assert!(
682 result.is_empty(),
683 "URL in backticks inside MDX component must not be flagged: {result:?}"
684 );
685 }
686
687 #[test]
688 fn test_bare_url_inside_mdx_component_still_flagged() {
689 let rule = MD034NoBareUrls;
692 let content =
693 "# Test\n\n<ParamField path=\"--stuff\">\n Visit https://rumdl.example.com/ for details.\n</ParamField>\n";
694 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
695 let result = rule.check(&ctx).unwrap();
696 assert_eq!(
697 result.len(),
698 1,
699 "Bare URL in MDX component body must still be flagged: {result:?}"
700 );
701 }
702
703 #[test]
704 fn test_url_in_backticks_inside_nested_mdx_component_not_flagged() {
705 let rule = MD034NoBareUrls;
707 let content = "<Outer>\n <Inner>\n Check `https://example.com/` here.\n </Inner>\n</Outer>\n";
708 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
709 let result = rule.check(&ctx).unwrap();
710 assert!(
711 result.is_empty(),
712 "URL in backticks inside nested MDX component must not be flagged: {result:?}"
713 );
714 }
715
716 #[test]
720 fn test_url_in_fenced_code_block_inside_jsx_not_flagged() {
721 let rule = MD034NoBareUrls;
722 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";
723 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
724 let result = rule.check(&ctx).unwrap();
725 assert!(
726 result.is_empty(),
727 "URL in a fenced code block nested in a JSX component must not be flagged: {result:?}"
728 );
729 }
730
731 #[test]
734 fn test_fix_does_not_rewrite_url_in_fenced_code_block_inside_jsx() {
735 let rule = MD034NoBareUrls;
736 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";
737 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
738 let fixed = rule.fix(&ctx).unwrap();
739 assert_eq!(
740 fixed, content,
741 "fix must not rewrite a URL inside a JSX-nested fenced code block"
742 );
743 }
744
745 #[test]
748 fn test_bare_url_in_jsx_body_outside_fence_still_flagged() {
749 let rule = MD034NoBareUrls;
750 let content = "# Title\n\n<Steps>\n <Step title=\"Send a request\">\n Visit https://example.com/api now.\n </Step>\n</Steps>\n";
751 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
752 let result = rule.check(&ctx).unwrap();
753 assert_eq!(
754 result.len(),
755 1,
756 "A bare URL in the JSX body (not in a fence) must still be flagged: {result:?}"
757 );
758 }
759
760 #[test]
764 fn test_bare_url_not_masked_by_comment_delimiter_in_code_block() {
765 let rule = MD034NoBareUrls;
766 let content =
767 "# T\n\n```text\n<!-- literal opener, not a comment\n```\n\nhttps://example.com should be flagged\n\n-->\n";
768 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
769 let result = rule.check(&ctx).unwrap();
770 assert_eq!(result.len(), 1, "the bare URL must still be flagged: {result:?}");
771 assert!(
772 result[0].message.contains("example.com"),
773 "the flagged URL must be the bare one: {result:?}"
774 );
775 }
776
777 #[test]
782 fn test_bare_url_in_indented_comment_in_admonition_still_skipped() {
783 let rule = MD034NoBareUrls;
784 let content = "# T\n\n!!! note\n Some text.\n\n <!--\n https://example.com\n -->\n";
785 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
786 let result = rule.check(&ctx).unwrap();
787 assert!(
788 result.is_empty(),
789 "URL inside an indented HTML comment in an admonition must not be flagged: {result:?}"
790 );
791 }
792
793 #[test]
797 fn test_url_in_jsx_component_attribute_not_flagged() {
798 let rule = MD034NoBareUrls;
799 let content = "<Card title=\"Docs\" href=\"https://example.com/docs\" />\n";
800 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
801 let result = rule.check(&ctx).unwrap();
802 assert!(
803 result.is_empty(),
804 "URL in a JSX component attribute must not be flagged: {result:?}"
805 );
806 }
807
808 #[test]
810 fn test_url_in_multiline_jsx_component_attribute_not_flagged() {
811 let rule = MD034NoBareUrls;
812 let content = "<Card\n title=\"Docs\"\n href=\"https://example.com/docs\"\n/>\n";
813 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
814 let result = rule.check(&ctx).unwrap();
815 assert!(
816 result.is_empty(),
817 "URL in a multi-line JSX component attribute must not be flagged: {result:?}"
818 );
819 }
820
821 #[test]
824 fn test_jsx_attribute_url_skipped_but_body_url_flagged() {
825 let rule = MD034NoBareUrls;
826 let content = "<Card href=\"https://attr.example.com\">\n Visit https://body.example.com now.\n</Card>\n";
827 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
828 let result = rule.check(&ctx).unwrap();
829 assert_eq!(
830 result.len(),
831 1,
832 "Only the body URL must be flagged, not the attribute URL: {result:?}"
833 );
834 assert!(
835 result[0].message.contains("body.example.com"),
836 "The flagged URL must be the body one: {result:?}"
837 );
838 }
839
840 #[test]
843 fn test_email_in_jsx_component_attribute_not_flagged() {
844 let rule = MD034NoBareUrls;
845 let content = "<Contact email=\"hello@example.com\" />\n";
846 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
847 let result = rule.check(&ctx).unwrap();
848 assert!(
849 result.is_empty(),
850 "Email in a JSX component attribute must not be flagged: {result:?}"
851 );
852 }
853
854 #[test]
858 fn test_jsx_attribute_url_not_flagged_in_standard_flavor() {
859 let rule = MD034NoBareUrls;
860 let content = "<Card href=\"https://example.com/docs\" />\n";
861 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
862 let result = rule.check(&ctx).unwrap();
863 assert!(
864 result.is_empty(),
865 "URL in a tag attribute must not be flagged under Standard flavor either: {result:?}"
866 );
867 }
868
869 #[test]
871 fn test_pandoc_skips_urls_in_line_blocks() {
872 use crate::config::MarkdownFlavor;
873 use crate::lint_context::LintContext;
874 let rule = MD034NoBareUrls;
875 let content = "| See https://example.com\n| For details\n";
876 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
877 let result = rule.check(&ctx).unwrap();
878 assert!(
879 result.is_empty(),
880 "MD034 should skip URLs in Pandoc line blocks: {result:?}"
881 );
882 }
883
884 #[test]
886 fn test_pandoc_skips_urls_in_metadata() {
887 use crate::config::MarkdownFlavor;
888 use crate::lint_context::LintContext;
889 let rule = MD034NoBareUrls;
890 let content = "---\nhomepage: https://example.com\n---\n\nBody.\n";
891 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
892 let result = rule.check(&ctx).unwrap();
893 assert!(
894 result.is_empty(),
895 "MD034 should skip URLs in Pandoc YAML metadata: {result:?}"
896 );
897 }
898
899 #[test]
902 fn test_standard_still_flags_urls_in_pipe_prefixed_lines() {
903 use crate::config::MarkdownFlavor;
904 use crate::lint_context::LintContext;
905 let rule = MD034NoBareUrls;
906 let content = "| See https://example.com\n";
907 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
908 let result = rule.check(&ctx).unwrap();
909 assert!(
910 !result.is_empty(),
911 "MD034 should still flag URLs in pipe-prefixed lines under Standard flavor"
912 );
913 }
914
915 #[test]
916 fn test_url_in_backticks_after_fenced_code_block_inside_mdx_not_flagged() {
917 let rule = MD034NoBareUrls;
921 let content = "\
922<Component>
923Some intro text.
924
925```
926example code here
927```
928
929Check `https://example.com/` here.
930</Component>
931";
932 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
933 let result = rule.check(&ctx).unwrap();
934 assert!(
935 result.is_empty(),
936 "URL in backticks after a fenced code block inside MDX must not be flagged: {result:?}"
937 );
938 }
939
940 #[test]
944 fn test_myst_colon_directive_argument_url_not_flagged() {
945 use crate::config::MarkdownFlavor;
946 use crate::lint_context::LintContext;
947 let rule = MD034NoBareUrls;
948 let content = "\
949:::{anywidget} https://cdn.jsdelivr.net/npm/repo-review-webapp@1.1.3/dist/repo-review-anywidget.mjs
950{
951 \"deps\": [\"repo-review~=1.1.0\"]
952}
953:::
954";
955 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
956 let result = rule.check(&ctx).unwrap();
957 assert!(
958 result.is_empty(),
959 "URL argument on a MyST colon directive opener must not be flagged: {result:?}"
960 );
961 }
962
963 #[test]
965 fn test_myst_nested_colon_directive_argument_url_not_flagged() {
966 use crate::config::MarkdownFlavor;
967 use crate::lint_context::LintContext;
968 let rule = MD034NoBareUrls;
969 let content = "\
970::::{grid}
971:::{card} https://example.com/card-target
972Some caption.
973:::
974::::
975";
976 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
977 let result = rule.check(&ctx).unwrap();
978 assert!(
979 result.is_empty(),
980 "URL argument on a nested MyST colon directive opener must not be flagged: {result:?}"
981 );
982 }
983
984 #[test]
987 fn test_myst_directive_body_url_still_flagged() {
988 use crate::config::MarkdownFlavor;
989 use crate::lint_context::LintContext;
990 let rule = MD034NoBareUrls;
991 let content = "\
992:::{note}
993See https://example.com/docs for more details.
994:::
995";
996 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
997 let result = rule.check(&ctx).unwrap();
998 assert_eq!(
999 result.len(),
1000 1,
1001 "Bare URL in a MyST directive body must still be flagged: {result:?}"
1002 );
1003 }
1004
1005 #[test]
1008 fn test_myst_unclosed_colon_directive_argument_url_not_flagged() {
1009 use crate::config::MarkdownFlavor;
1010 use crate::lint_context::LintContext;
1011 let rule = MD034NoBareUrls;
1012 let content = "\
1013:::{anywidget} https://example.com/widget.mjs
1014Some trailing content with no closing fence.
1015";
1016 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
1017 let result = rule.check(&ctx).unwrap();
1018 assert!(
1019 result.is_empty(),
1020 "URL argument on an unclosed MyST colon directive opener must not be flagged: {result:?}"
1021 );
1022 }
1023
1024 #[test]
1027 fn test_colon_directive_url_flagged_in_standard_flavor() {
1028 use crate::config::MarkdownFlavor;
1029 use crate::lint_context::LintContext;
1030 let rule = MD034NoBareUrls;
1031 let content = ":::{anywidget} https://example.com/widget.mjs\n";
1032 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1033 let result = rule.check(&ctx).unwrap();
1034 assert_eq!(
1035 result.len(),
1036 1,
1037 "Under Standard flavor a bare URL on a `:::` line must still be flagged: {result:?}"
1038 );
1039 }
1040
1041 #[test]
1042 fn test_md034_complex_link() {
1043 let rule = MD034NoBareUrls;
1044
1045 let content = "Check [link `code [with brackets]` text](http://example.com) and see http://bare.com.\n";
1048 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1049 let result = rule.check(&ctx).unwrap();
1050 assert_eq!(result.len(), 1, "Should flag exactly 1 URL (the bare one): {result:?}");
1051 assert!(result[0].message.contains("bare.com"));
1052
1053 let content2 = "Check [link `code [` text](http://example.com) and see http://bare.com.\n";
1056 let ctx2 = crate::lint_context::LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1057 let result2 = rule.check(&ctx2).unwrap();
1058 assert_eq!(
1059 result2.len(),
1060 1,
1061 "Should flag exactly 1 URL (the bare one): {result2:?}"
1062 );
1063 assert!(result2[0].message.contains("bare.com"));
1064 }
1065
1066 #[test]
1069 fn test_mdg_reports_bare_urls_without_fixing_them() {
1070 let rule = MD034NoBareUrls;
1071 let content = "\
1072# Feature: Visit https://feature.example.com
1073
1074Prose about https://prose.example.com for background.
1075
1076## Scenario Outline: Open https://outline.example.com
1077
1078* Given I go to https://step.example.com
1079 | site |
1080 | https://datatable.example.com |
1081
1082> * Given I go to https://blockquoted.example.com
1083
10841. Given I go to https://ordered.example.com
1085
1086| url |
1087| ------------------------------ |
1088| https://unindented.example.com |
1089
1090### Examples:
1091
1092 | url |
1093 | ---------------------------- |
1094 | https://examples.example.com |
1095";
1096
1097 let standard_ctx =
1098 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1099 let standard_lines: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
1100 assert_eq!(
1101 standard_lines,
1102 vec![1, 3, 5, 7, 9, 11, 13, 17, 23],
1103 "Standard flavor flags every bare URL"
1104 );
1105
1106 let mdg_ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1107 assert!(!rule.should_skip(&mdg_ctx), "MDG must still run the diagnostic");
1108 let mdg = rule.check(&mdg_ctx).unwrap();
1109 assert_eq!(mdg.iter().map(|w| w.line).collect::<Vec<_>>(), standard_lines);
1110 assert!(mdg.iter().all(|warning| warning.fix.is_none()));
1111 assert!(
1112 mdg.iter()
1113 .all(|warning| warning.message.contains("Gherkin placeholder"))
1114 );
1115 assert!(mdg.iter().all(|warning| warning.message.contains("disable MD034")));
1116 assert_eq!(rule.fix(&mdg_ctx).unwrap(), content, "MDG must rewrite nothing");
1117 }
1118
1119 #[test]
1120 fn test_mdg_reports_bare_email_without_fixing_it() {
1121 let rule = MD034NoBareUrls;
1122 let content = "# Feature: Contact\n\n* Given I email user@example.com\n";
1123 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1124
1125 let warnings = rule.check(&ctx).unwrap();
1126 assert_eq!(warnings.len(), 1);
1127 assert!(warnings[0].message.contains("Gherkin placeholder"));
1128 assert!(warnings[0].message.contains("disable MD034"));
1129 assert!(warnings[0].fix.is_none());
1130 assert_eq!(rule.fix(&ctx).unwrap(), content);
1131 }
1132
1133 #[test]
1136 fn test_mdg_exemption_does_not_affect_other_flavors() {
1137 let rule = MD034NoBareUrls;
1138 let content = "\
1139# Feature: Visit https://feature.example.com
1140
1141Prose about https://prose.example.com for background.
1142
1143## Scenario Outline: Open https://outline.example.com
1144
1145* Given I go to https://step.example.com
1146 | site |
1147 | https://datatable.example.com |
1148
1149### Examples:
1150
1151 | url |
1152 | ---------------------------- |
1153 | https://examples.example.com |
1154";
1155 let expected = "\
1156# Feature: Visit <https://feature.example.com>
1157
1158Prose about <https://prose.example.com> for background.
1159
1160## Scenario Outline: Open <https://outline.example.com>
1161
1162* Given I go to <https://step.example.com>
1163 | site |
1164 | <https://datatable.example.com> |
1165
1166### Examples:
1167
1168 | url |
1169 | ---------------------------- |
1170 | <https://examples.example.com> |
1171";
1172
1173 for flavor in [
1174 crate::config::MarkdownFlavor::Standard,
1175 crate::config::MarkdownFlavor::MkDocs,
1176 crate::config::MarkdownFlavor::MyST,
1177 ] {
1178 let ctx = crate::lint_context::LintContext::new(content, flavor, None);
1179 assert!(!rule.should_skip(&ctx), "{flavor:?} must still run the rule");
1180 assert_eq!(rule.check(&ctx).unwrap().len(), 6, "{flavor:?} must flag all six URLs");
1181
1182 let fixed = rule.fix(&ctx).unwrap();
1183 assert_eq!(fixed, expected, "{flavor:?} must wrap all six URLs");
1184
1185 let fixed_ctx = crate::lint_context::LintContext::new(&fixed, flavor, None);
1186 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1187 assert_eq!(
1188 rule.fix(&fixed_ctx).unwrap(),
1189 fixed,
1190 "{flavor:?} fix must be idempotent"
1191 );
1192 }
1193 }
1194}