1use std::sync::LazyLock;
5
6use regex::Regex;
7
8use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
9use crate::utils::range_utils::{LineIndex, calculate_url_range};
10use crate::utils::regex_cache::{
11 EMAIL_PATTERN, URL_IPV6_REGEX, URL_QUICK_CHECK_REGEX, URL_STANDARD_REGEX, URL_WWW_REGEX, XMPP_URI_REGEX,
12};
13
14use crate::filtered_lines::FilteredLinesExt;
15use crate::lint_context::LintContext;
16
17static CUSTOM_PROTOCOL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
19 Regex::new(r#"(?:grpc|ws|wss|ssh|git|svn|file|data|javascript|vscode|chrome|about|slack|discord|matrix|irc|redis|mongodb|postgresql|mysql|kafka|nats|amqp|mqtt|custom|app|api|service)://"#).unwrap()
20});
21static MARKDOWN_LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
22 Regex::new(r#"\[(?:[^\[\]]|\[[^\]]*\])*\]\(([^)\s]+)(?:\s+(?:\"[^\"]*\"|\'[^\']*\'))?\)"#).unwrap()
23});
24static MARKDOWN_EMPTY_LINK_REGEX: LazyLock<Regex> =
25 LazyLock::new(|| Regex::new(r#"\[(?:[^\[\]]|\[[^\]]*\])*\]\(\)"#).unwrap());
26static MARKDOWN_EMPTY_REF_REGEX: LazyLock<Regex> =
27 LazyLock::new(|| Regex::new(r#"\[(?:[^\[\]]|\[[^\]]*\])*\]\[\]"#).unwrap());
28static ANGLE_LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
29 Regex::new(
30 r#"<((?:https?|ftps?)://(?:\[[0-9a-fA-F:]+(?:%[a-zA-Z0-9]+)?\]|[^>]+)|xmpp:[^>]+|[^@\s]+@[^@\s]+\.[^@\s>]+)>"#,
31 )
32 .unwrap()
33});
34static BADGE_LINK_LINE_REGEX: LazyLock<Regex> =
35 LazyLock::new(|| Regex::new(r#"^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*$"#).unwrap());
36static MARKDOWN_IMAGE_REGEX: LazyLock<Regex> =
37 LazyLock::new(|| Regex::new(r#"!\s*\[([^\]]*)\]\s*\(([^)\s]+)(?:\s+(?:\"[^\"]*\"|\'[^\']*\'))?\)"#).unwrap());
38static REFERENCE_DEF_REGEX: LazyLock<Regex> =
39 LazyLock::new(|| Regex::new(r"^\s*\[[^\]]+\]:\s*(?:<|(?:https?|ftps?)://)").unwrap());
40static MULTILINE_LINK_CONTINUATION_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"^[^\[]*\]\(.*\)"#).unwrap());
41static SHORTCUT_REF_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"\[([^\[\]]+)\]"#).unwrap());
42
43#[derive(Default)]
45struct LineCheckBuffers {
46 markdown_link_ranges: Vec<(usize, usize)>,
47 image_ranges: Vec<(usize, usize)>,
48 urls_found: Vec<(usize, usize, String)>,
49}
50
51#[derive(Default, Clone)]
52pub struct MD034NoBareUrls;
53
54impl MD034NoBareUrls {
55 #[inline]
56 pub fn should_skip_content(&self, content: &str) -> bool {
57 let bytes = content.as_bytes();
60 let has_colon = bytes.contains(&b':');
61 let has_at = bytes.contains(&b'@');
62 let has_www = content.contains("www.");
63 !has_colon && !has_at && !has_www
64 }
65
66 fn trim_trailing_punctuation<'a>(&self, url: &'a str) -> &'a str {
68 let mut trimmed = url;
69
70 let open_parens = url.chars().filter(|&c| c == '(').count();
72 let close_parens = url.chars().filter(|&c| c == ')').count();
73
74 if close_parens > open_parens {
75 let mut balance = 0;
77 let mut last_balanced_pos = url.len();
78
79 for (byte_idx, c) in url.char_indices() {
80 if c == '(' {
81 balance += 1;
82 } else if c == ')' {
83 balance -= 1;
84 if balance < 0 {
85 last_balanced_pos = byte_idx;
87 break;
88 }
89 }
90 }
91
92 trimmed = &trimmed[..last_balanced_pos];
93 }
94
95 while let Some(last_char) = trimmed.chars().last() {
97 if matches!(last_char, '.' | ',' | ';' | ':' | '!' | '?') {
98 if last_char == ':' && trimmed.len() > 1 {
101 break;
103 }
104 trimmed = &trimmed[..trimmed.len() - 1];
105 } else {
106 break;
107 }
108 }
109
110 trimmed
111 }
112
113 fn is_reference_definition(&self, line: &str) -> bool {
115 REFERENCE_DEF_REGEX.is_match(line)
116 }
117
118 fn check_line(
119 &self,
120 line: &str,
121 ctx: &LintContext,
122 line_number: usize,
123 code_spans: &[crate::lint_context::CodeSpan],
124 buffers: &mut LineCheckBuffers,
125 line_index: &LineIndex,
126 ) -> Vec<LintWarning> {
127 let mut warnings = Vec::new();
128
129 if self.is_reference_definition(line) {
131 return warnings;
132 }
133
134 if ctx.line_info(line_number).is_some_and(|info| info.in_html_block) {
136 return warnings;
137 }
138
139 if MULTILINE_LINK_CONTINUATION_REGEX.is_match(line) {
142 return warnings;
143 }
144
145 let has_quick_check = URL_QUICK_CHECK_REGEX.is_match(line);
147 let has_www = line.contains("www.");
148 let has_at = line.contains('@');
149
150 if !has_quick_check && !has_at && !has_www {
151 return warnings;
152 }
153
154 buffers.markdown_link_ranges.clear();
156 buffers.image_ranges.clear();
157
158 let has_bracket = line.contains('[');
159 let has_angle = line.contains('<');
160 let has_bang = line.contains('!');
161
162 if has_bracket {
163 for mat in MARKDOWN_LINK_REGEX.find_iter(line) {
164 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
165 }
166
167 for mat in MARKDOWN_EMPTY_LINK_REGEX.find_iter(line) {
169 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
170 }
171
172 for mat in MARKDOWN_EMPTY_REF_REGEX.find_iter(line) {
173 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
174 }
175
176 for mat in SHORTCUT_REF_REGEX.find_iter(line) {
178 let end = mat.end();
179 let next_non_ws = line[end..].bytes().find(|b| !b.is_ascii_whitespace());
180 if next_non_ws == Some(b'(') || next_non_ws == Some(b'[') {
181 continue;
182 }
183 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
184 }
185
186 if has_bang && BADGE_LINK_LINE_REGEX.is_match(line) {
188 return warnings;
189 }
190 }
191
192 if has_angle {
193 for mat in ANGLE_LINK_REGEX.find_iter(line) {
194 buffers.markdown_link_ranges.push((mat.start(), mat.end()));
195 }
196 }
197
198 if has_bang && has_bracket {
200 for mat in MARKDOWN_IMAGE_REGEX.find_iter(line) {
201 buffers.image_ranges.push((mat.start(), mat.end()));
202 }
203 }
204
205 buffers.urls_found.clear();
207
208 for mat in URL_IPV6_REGEX.find_iter(line) {
210 let url_str = mat.as_str();
211 buffers.urls_found.push((mat.start(), mat.end(), url_str.to_string()));
212 }
213
214 for mat in URL_STANDARD_REGEX.find_iter(line) {
216 let url_str = mat.as_str();
217
218 if url_str.contains("://[") {
220 continue;
221 }
222
223 if let Some(host_start) = url_str.find("://") {
226 let after_protocol = &url_str[host_start + 3..];
227 if after_protocol.contains("::") || after_protocol.chars().filter(|&c| c == ':').count() > 1 {
229 if line.as_bytes().get(mat.end()) == Some(&b']') {
231 continue;
233 }
234 }
235 }
236
237 buffers.urls_found.push((mat.start(), mat.end(), url_str.to_string()));
238 }
239
240 for mat in URL_WWW_REGEX.find_iter(line) {
242 let url_str = mat.as_str();
243 let start_pos = mat.start();
244 let end_pos = mat.end();
245
246 if start_pos > 0 {
248 let prev_char = line.as_bytes().get(start_pos - 1).copied();
249 if prev_char == Some(b'/') || prev_char == Some(b'@') {
250 continue;
251 }
252 }
253
254 if start_pos > 0 && end_pos < line.len() {
256 let prev_char = line.as_bytes().get(start_pos - 1).copied();
257 let next_char = line.as_bytes().get(end_pos).copied();
258 if prev_char == Some(b'<') && next_char == Some(b'>') {
259 continue;
260 }
261 }
262
263 buffers.urls_found.push((start_pos, end_pos, url_str.to_string()));
264 }
265
266 for mat in XMPP_URI_REGEX.find_iter(line) {
268 let uri_str = mat.as_str();
269 let start_pos = mat.start();
270 let end_pos = mat.end();
271
272 if start_pos > 0 && end_pos < line.len() {
274 let prev_char = line.as_bytes().get(start_pos - 1).copied();
275 let next_char = line.as_bytes().get(end_pos).copied();
276 if prev_char == Some(b'<') && next_char == Some(b'>') {
277 continue;
278 }
279 }
280
281 buffers.urls_found.push((start_pos, end_pos, uri_str.to_string()));
282 }
283
284 for &(start, _end, ref url_str) in &buffers.urls_found {
286 if CUSTOM_PROTOCOL_REGEX.is_match(url_str) {
288 continue;
289 }
290
291 let is_inside_construct = buffers
297 .markdown_link_ranges
298 .iter()
299 .any(|&(s, e)| start >= s && start < e)
300 || buffers.image_ranges.iter().any(|&(s, e)| start >= s && start < e);
301
302 if is_inside_construct {
303 continue;
304 }
305
306 let line_start_byte = line_index.get_line_start_byte(line_number).unwrap_or(0);
308 let absolute_pos = line_start_byte + start;
309
310 if ctx.is_in_html_tag(absolute_pos) {
312 continue;
313 }
314
315 if ctx.is_in_html_comment(absolute_pos) || ctx.is_in_mdx_comment(absolute_pos) {
317 continue;
318 }
319
320 if ctx.is_in_shortcode(absolute_pos) {
322 continue;
323 }
324
325 if ctx.flavor.is_pandoc_compatible()
329 && (ctx.is_in_line_block(absolute_pos) || ctx.is_in_pandoc_metadata(absolute_pos))
330 {
331 continue;
332 }
333
334 let trimmed_url = self.trim_trailing_punctuation(url_str);
336
337 if !trimmed_url.is_empty() && trimmed_url != "//" {
339 let trimmed_len = trimmed_url.len();
340 let (start_line, start_col, end_line, end_col) =
341 calculate_url_range(line_number, line, start, trimmed_len);
342
343 let replacement = if trimmed_url.starts_with("www.") {
345 format!("<https://{trimmed_url}>")
346 } else {
347 format!("<{trimmed_url}>")
348 };
349
350 warnings.push(LintWarning {
351 rule_name: Some("MD034".to_string()),
352 line: start_line,
353 column: start_col,
354 end_line,
355 end_column: end_col,
356 message: format!("URL without angle brackets or link formatting: '{trimmed_url}'"),
357 severity: Severity::Warning,
358 fix: Some(Fix::new(
359 {
360 let line_start_byte = line_index.get_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 = line_index.get_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.flavor.is_pandoc_compatible()
403 && (ctx.is_in_line_block(absolute_pos) || ctx.is_in_pandoc_metadata(absolute_pos))
404 {
405 continue;
406 }
407
408 let is_in_code_span = code_spans
410 .iter()
411 .any(|span| absolute_pos >= span.byte_offset && absolute_pos < span.byte_end);
412
413 if !is_in_code_span {
414 let email_len = end - start;
415 let (start_line, start_col, end_line, end_col) =
416 calculate_url_range(line_number, line, start, email_len);
417
418 warnings.push(LintWarning {
419 rule_name: Some("MD034".to_string()),
420 line: start_line,
421 column: start_col,
422 end_line,
423 end_column: end_col,
424 message: format!("Email address without angle brackets or link formatting: '{email}'"),
425 severity: Severity::Warning,
426 fix: Some(Fix::new(
427 (line_start_byte + start)..(line_start_byte + end),
428 format!("<{email}>"),
429 )),
430 });
431 }
432 }
433 }
434 }
435
436 warnings
437 }
438}
439
440impl Rule for MD034NoBareUrls {
441 #[inline]
442 fn name(&self) -> &'static str {
443 "MD034"
444 }
445
446 fn as_any(&self) -> &dyn std::any::Any {
447 self
448 }
449
450 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
451 where
452 Self: Sized,
453 {
454 Box::new(MD034NoBareUrls)
455 }
456
457 #[inline]
458 fn category(&self) -> RuleCategory {
459 RuleCategory::Link
460 }
461
462 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
463 !ctx.likely_has_links_or_images() && self.should_skip_content(ctx.content)
464 }
465
466 #[inline]
467 fn description(&self) -> &'static str {
468 "No bare URLs - wrap URLs in angle brackets"
469 }
470
471 fn check(&self, ctx: &LintContext) -> LintResult {
472 let mut warnings = Vec::new();
473 let content = ctx.content;
474
475 if self.should_skip_content(content) {
477 return Ok(warnings);
478 }
479
480 let line_index = &ctx.line_index;
482
483 let code_spans = ctx.code_spans();
485
486 let mut buffers = LineCheckBuffers::default();
488
489 for line in ctx
493 .filtered_lines()
494 .skip_front_matter()
495 .skip_code_blocks()
496 .skip_jsx_expressions()
497 .skip_mdx_comments()
498 .skip_obsidian_comments()
499 {
500 if ctx.is_myst_colon_directive_opener_line(line.line_num) {
506 continue;
507 }
508
509 let mut line_warnings =
510 self.check_line(line.content, ctx, line.line_num, &code_spans, &mut buffers, line_index);
511
512 line_warnings.retain(|warning| {
514 !code_spans.iter().any(|span| {
515 if let Some(fix) = &warning.fix {
516 fix.range.start >= span.byte_offset && fix.range.start < span.byte_end
518 } else {
519 span.line == warning.line
520 && span.end_line == warning.line
521 && warning.column > 0
522 && (warning.column - 1) >= span.start_col
523 && (warning.column - 1) < span.end_col
524 }
525 })
526 });
527
528 line_warnings.retain(|warning| {
532 if let Some(fix) = &warning.fix {
533 !ctx.links
535 .iter()
536 .any(|link| fix.range.start >= link.byte_offset && fix.range.end <= link.byte_end)
537 } else {
538 true
539 }
540 });
541
542 line_warnings.retain(|warning| !ctx.is_position_in_obsidian_comment(warning.line, warning.column));
545
546 warnings.extend(line_warnings);
547 }
548
549 Ok(warnings)
550 }
551
552 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
553 let mut content = ctx.content.to_string();
554 let warnings = self.check(ctx)?;
555 let mut warnings =
556 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
557
558 warnings.sort_by_key(|w| w.fix.as_ref().map_or(0, |f| f.range.start));
560
561 for warning in warnings.iter().rev() {
563 if let Some(fix) = &warning.fix {
564 let start = fix.range.start;
565 let end = fix.range.end;
566 content.replace_range(start..end, &fix.replacement);
567 }
568 }
569
570 Ok(content)
571 }
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577
578 #[test]
579 fn test_shortcut_ref_at_end_of_line_no_trailing_chars() {
580 let rule = MD034NoBareUrls;
581 let content = "See [https://example.com]";
582 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
583 let result = rule.check(&ctx).unwrap();
584 assert!(
585 result.is_empty(),
586 "[URL] at end of line should be treated as shortcut ref: {result:?}"
587 );
588 }
589
590 #[test]
591 fn test_shortcut_ref_multiple_spaces_before_paren() {
592 let rule = MD034NoBareUrls;
593 let content = "[text] (https://example.com)";
594 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
595 let result = rule.check(&ctx).unwrap();
596 let _ = result; }
601
602 #[test]
603 fn test_shortcut_ref_tab_before_bracket() {
604 let rule = MD034NoBareUrls;
605 let content = "[https://example.com]\t[other]";
606 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
607 let result = rule.check(&ctx).unwrap();
608 assert_eq!(
612 result.len(),
613 1,
614 "Bare URL inside shortcut ref should be detected: {result:?}"
615 );
616 }
617
618 #[test]
619 fn test_shortcut_ref_followed_by_punctuation() {
620 let rule = MD034NoBareUrls;
621 let content = "[https://example.com], see also other things.";
622 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
623 let result = rule.check(&ctx).unwrap();
624 assert!(
625 result.is_empty(),
626 "[URL] followed by comma should be treated as shortcut ref: {result:?}"
627 );
628 }
629
630 #[test]
631 fn test_url_in_backticks_inside_mdx_component_not_flagged() {
632 let rule = MD034NoBareUrls;
636 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";
637 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
638 let result = rule.check(&ctx).unwrap();
639 assert!(
640 result.is_empty(),
641 "URL in backticks inside MDX component must not be flagged: {result:?}"
642 );
643 }
644
645 #[test]
646 fn test_bare_url_inside_mdx_component_still_flagged() {
647 let rule = MD034NoBareUrls;
650 let content =
651 "# Test\n\n<ParamField path=\"--stuff\">\n Visit https://rumdl.example.com/ for details.\n</ParamField>\n";
652 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
653 let result = rule.check(&ctx).unwrap();
654 assert_eq!(
655 result.len(),
656 1,
657 "Bare URL in MDX component body must still be flagged: {result:?}"
658 );
659 }
660
661 #[test]
662 fn test_url_in_backticks_inside_nested_mdx_component_not_flagged() {
663 let rule = MD034NoBareUrls;
665 let content = "<Outer>\n <Inner>\n Check `https://example.com/` here.\n </Inner>\n</Outer>\n";
666 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
667 let result = rule.check(&ctx).unwrap();
668 assert!(
669 result.is_empty(),
670 "URL in backticks inside nested MDX component must not be flagged: {result:?}"
671 );
672 }
673
674 #[test]
676 fn test_pandoc_skips_urls_in_line_blocks() {
677 use crate::config::MarkdownFlavor;
678 use crate::lint_context::LintContext;
679 let rule = MD034NoBareUrls;
680 let content = "| See https://example.com\n| For details\n";
681 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
682 let result = rule.check(&ctx).unwrap();
683 assert!(
684 result.is_empty(),
685 "MD034 should skip URLs in Pandoc line blocks: {result:?}"
686 );
687 }
688
689 #[test]
691 fn test_pandoc_skips_urls_in_metadata() {
692 use crate::config::MarkdownFlavor;
693 use crate::lint_context::LintContext;
694 let rule = MD034NoBareUrls;
695 let content = "---\nhomepage: https://example.com\n---\n\nBody.\n";
696 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
697 let result = rule.check(&ctx).unwrap();
698 assert!(
699 result.is_empty(),
700 "MD034 should skip URLs in Pandoc YAML metadata: {result:?}"
701 );
702 }
703
704 #[test]
707 fn test_standard_still_flags_urls_in_pipe_prefixed_lines() {
708 use crate::config::MarkdownFlavor;
709 use crate::lint_context::LintContext;
710 let rule = MD034NoBareUrls;
711 let content = "| See https://example.com\n";
712 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
713 let result = rule.check(&ctx).unwrap();
714 assert!(
715 !result.is_empty(),
716 "MD034 should still flag URLs in pipe-prefixed lines under Standard flavor"
717 );
718 }
719
720 #[test]
721 fn test_url_in_backticks_after_fenced_code_block_inside_mdx_not_flagged() {
722 let rule = MD034NoBareUrls;
726 let content = "\
727<Component>
728Some intro text.
729
730```
731example code here
732```
733
734Check `https://example.com/` here.
735</Component>
736";
737 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
738 let result = rule.check(&ctx).unwrap();
739 assert!(
740 result.is_empty(),
741 "URL in backticks after a fenced code block inside MDX must not be flagged: {result:?}"
742 );
743 }
744
745 #[test]
749 fn test_myst_colon_directive_argument_url_not_flagged() {
750 use crate::config::MarkdownFlavor;
751 use crate::lint_context::LintContext;
752 let rule = MD034NoBareUrls;
753 let content = "\
754:::{anywidget} https://cdn.jsdelivr.net/npm/repo-review-webapp@1.1.3/dist/repo-review-anywidget.mjs
755{
756 \"deps\": [\"repo-review~=1.1.0\"]
757}
758:::
759";
760 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
761 let result = rule.check(&ctx).unwrap();
762 assert!(
763 result.is_empty(),
764 "URL argument on a MyST colon directive opener must not be flagged: {result:?}"
765 );
766 }
767
768 #[test]
770 fn test_myst_nested_colon_directive_argument_url_not_flagged() {
771 use crate::config::MarkdownFlavor;
772 use crate::lint_context::LintContext;
773 let rule = MD034NoBareUrls;
774 let content = "\
775::::{grid}
776:::{card} https://example.com/card-target
777Some caption.
778:::
779::::
780";
781 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
782 let result = rule.check(&ctx).unwrap();
783 assert!(
784 result.is_empty(),
785 "URL argument on a nested MyST colon directive opener must not be flagged: {result:?}"
786 );
787 }
788
789 #[test]
792 fn test_myst_directive_body_url_still_flagged() {
793 use crate::config::MarkdownFlavor;
794 use crate::lint_context::LintContext;
795 let rule = MD034NoBareUrls;
796 let content = "\
797:::{note}
798See https://example.com/docs for more details.
799:::
800";
801 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
802 let result = rule.check(&ctx).unwrap();
803 assert_eq!(
804 result.len(),
805 1,
806 "Bare URL in a MyST directive body must still be flagged: {result:?}"
807 );
808 }
809
810 #[test]
813 fn test_myst_unclosed_colon_directive_argument_url_not_flagged() {
814 use crate::config::MarkdownFlavor;
815 use crate::lint_context::LintContext;
816 let rule = MD034NoBareUrls;
817 let content = "\
818:::{anywidget} https://example.com/widget.mjs
819Some trailing content with no closing fence.
820";
821 let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
822 let result = rule.check(&ctx).unwrap();
823 assert!(
824 result.is_empty(),
825 "URL argument on an unclosed MyST colon directive opener must not be flagged: {result:?}"
826 );
827 }
828
829 #[test]
832 fn test_colon_directive_url_flagged_in_standard_flavor() {
833 use crate::config::MarkdownFlavor;
834 use crate::lint_context::LintContext;
835 let rule = MD034NoBareUrls;
836 let content = ":::{anywidget} https://example.com/widget.mjs\n";
837 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
838 let result = rule.check(&ctx).unwrap();
839 assert_eq!(
840 result.len(),
841 1,
842 "Under Standard flavor a bare URL on a `:::` line must still be flagged: {result:?}"
843 );
844 }
845}