1use crate::filtered_lines::FilteredLinesExt;
5use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6use crate::utils::range_utils::calculate_match_range;
7use crate::utils::skip_context::is_in_math_context;
8use regex::Regex;
9use std::sync::LazyLock;
10
11const REVERSED_LINK_REGEX_STR: &str = r"\(([^()]+)\)\[((?:\\.|[^\]\\])+)\]";
13static REVERSED_LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(REVERSED_LINK_REGEX_STR).unwrap());
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17enum LinkComponent {
18 ClearUrl,
20 TitledRelativeUrl,
22 MultiWord,
24 Ambiguous,
26}
27
28#[derive(Debug, Clone)]
30struct ReversedLinkInfo {
31 paren_content: String,
33 bracket_content: String,
35 paren_type: LinkComponent,
37 bracket_type: LinkComponent,
39}
40
41impl ReversedLinkInfo {
42 fn correct_order(&self) -> (&str, &str) {
44 use LinkComponent::{Ambiguous, ClearUrl, MultiWord, TitledRelativeUrl};
45
46 match (self.paren_type, self.bracket_type) {
47 (ClearUrl, _) => (&self.bracket_content, &self.paren_content),
49 (_, ClearUrl) => (&self.paren_content, &self.bracket_content),
50
51 (TitledRelativeUrl, _) => (&self.bracket_content, &self.paren_content),
53 (_, TitledRelativeUrl) => (&self.paren_content, &self.bracket_content),
54
55 (MultiWord, _) => (&self.paren_content, &self.bracket_content),
57 (_, MultiWord) => (&self.bracket_content, &self.paren_content),
58
59 (Ambiguous, Ambiguous) => (&self.bracket_content, &self.paren_content),
61 }
62 }
63}
64
65#[derive(Clone)]
66pub struct MD011NoReversedLinks;
67
68impl MD011NoReversedLinks {
69 fn is_complete_inline_link(candidate: &str) -> bool {
71 pulldown_cmark::Parser::new(candidate)
72 .into_offset_iter()
73 .any(|(event, range)| {
74 matches!(
75 event,
76 pulldown_cmark::Event::Start(pulldown_cmark::Tag::Link {
77 link_type: pulldown_cmark::LinkType::Inline,
78 ..
79 })
80 ) && range == (0..candidate.len())
81 })
82 }
83
84 fn nested_label_end(
87 ctx: &crate::lint_context::LintContext,
88 line: &str,
89 line_byte_pos: usize,
90 open: usize,
91 ) -> Option<usize> {
92 let mut depth = 1;
93 let mut escaped = false;
94 for (relative, ch) in line[open + 1..].char_indices() {
95 if escaped {
96 escaped = false;
97 continue;
98 }
99 if ch == '\\' {
100 escaped = true;
101 continue;
102 }
103 if !matches!(ch, '[' | ']') {
104 continue;
105 }
106 let pos = line_byte_pos + open + 1 + relative;
107 if ctx.is_in_code_block_or_span(pos)
108 || ctx.is_in_html_tag(pos)
109 || ctx.is_in_jsx_component_tag(pos)
110 || ctx.is_in_html_comment(pos)
111 || ctx.is_in_jinja_range(pos)
112 || ctx.is_in_jsx_expression(pos)
113 || ctx.is_in_mdx_comment(pos)
114 || is_in_math_context(ctx, pos)
115 {
116 continue;
117 }
118 if ch == '[' {
119 depth += 1;
120 } else {
121 depth -= 1;
122 if depth == 0 {
123 return Some(open + relative + 2);
124 }
125 }
126 }
127 None
128 }
129
130 fn replacement(text: &str, url: &str) -> Option<String> {
134 let candidate = format!("[{text}]({url})");
135 if !url.contains([' ', '\t']) {
136 return (!text.contains(['[', ']']) || Self::is_complete_inline_link(&candidate)).then_some(candidate);
137 }
138 if Self::is_complete_inline_link(&candidate) {
139 return Some(candidate);
140 }
141 let destination = url.trim_matches([' ', '\t']);
142 if destination.bytes().any(|byte| byte.is_ascii_control()) {
143 return None;
144 }
145 if let Some(quote @ (b'\'' | b'"')) = destination.as_bytes().last().copied()
146 && destination
147 .as_bytes()
148 .windows(2)
149 .any(|pair| pair[0] == b' ' && pair[1] == quote)
150 {
151 return None;
152 }
153 let mut escaped = String::with_capacity(destination.len());
156 let mut backslashes = 0;
157 for ch in destination.chars() {
158 if matches!(ch, '<' | '>') && backslashes % 2 == 0 {
159 escaped.push('\\');
160 }
161 escaped.push(ch);
162 backslashes = if ch == '\\' { backslashes + 1 } else { 0 };
163 }
164 let candidate = format!("[{text}](<{escaped}>)");
165 Self::is_complete_inline_link(&candidate).then_some(candidate)
166 }
167
168 fn has_clear_url_prefix(trimmed: &str) -> bool {
169 trimmed.starts_with("http://")
170 || trimmed.starts_with("https://")
171 || trimmed.starts_with("ftp://")
172 || trimmed.starts_with("www.")
173 || (trimmed.starts_with("mailto:") && trimmed.contains('@'))
174 || (trimmed.starts_with('/') && trimmed.len() > 1)
175 || (trimmed.starts_with("./") || trimmed.starts_with("../"))
176 || (trimmed.starts_with('#') && trimmed.len() > 1 && !trimmed[1..].contains(' '))
177 }
178
179 fn classify_component(s: &str) -> LinkComponent {
181 let trimmed = s.trim();
182 let angle_destination = trimmed
186 .starts_with('<')
187 .then(|| {
188 let candidate = format!("[label]({trimmed})");
189 pulldown_cmark::Parser::new(&candidate)
190 .into_offset_iter()
191 .find_map(|(event, range)| match event {
192 pulldown_cmark::Event::Start(pulldown_cmark::Tag::Link {
193 link_type: pulldown_cmark::LinkType::Inline,
194 dest_url,
195 ..
196 }) if range == (0..candidate.len()) => Some(dest_url.into_string()),
197 _ => None,
198 })
199 })
200 .flatten();
201 let trimmed = angle_destination
202 .as_deref()
203 .filter(|url| Self::has_clear_url_prefix(url))
204 .unwrap_or(trimmed);
205
206 if Self::has_clear_url_prefix(trimmed) {
207 return LinkComponent::ClearUrl;
208 }
209
210 if trimmed.contains('/') && trimmed.contains(['\'', '"']) {
213 let candidate = format!("[label]({trimmed})");
214 if pulldown_cmark::Parser::new(&candidate)
215 .into_offset_iter()
216 .any(|(event, range)| {
217 matches!(event, pulldown_cmark::Event::Start(pulldown_cmark::Tag::Link {
218 link_type: pulldown_cmark::LinkType::Inline,
219 dest_url,
220 title,
221 ..
222 }) if range == (0..candidate.len()) && dest_url.contains('/') && !title.is_empty())
223 })
224 {
225 return LinkComponent::TitledRelativeUrl;
226 }
227 }
228
229 if trimmed.contains(' ') {
231 return LinkComponent::MultiWord;
232 }
233
234 LinkComponent::Ambiguous
236 }
237}
238
239impl Rule for MD011NoReversedLinks {
240 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
241 Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(11))
242 }
243
244 fn name(&self) -> &'static str {
245 "MD011"
246 }
247
248 fn description(&self) -> &'static str {
249 "Reversed link syntax"
250 }
251
252 fn category(&self) -> RuleCategory {
253 RuleCategory::Link
254 }
255
256 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
257 let mut warnings = Vec::new();
258 if !ctx.content.contains(")[") {
259 return Ok(warnings);
260 }
261
262 for filtered_line in ctx
264 .filtered_lines()
265 .skip_front_matter()
266 .skip_esm_blocks()
267 .skip_obsidian_comments()
268 {
269 if filtered_line.line_info.is_myst_comment {
270 continue;
271 }
272 if filtered_line.line_info.in_html_block
275 && !ctx.flavor.supports_jsx()
276 && !filtered_line.line_info.in_mkdocs_html_markdown
277 {
278 continue;
279 }
280 let line_num = filtered_line.line_num;
281 let line = filtered_line.content;
282 if !line.contains(")[") {
283 continue;
284 }
285
286 let byte_pos = ctx.line_start_byte(line_num).unwrap_or(0);
287
288 let mut last_end = 0;
289
290 while let Some(cap) = REVERSED_LINK_REGEX.captures(&line[last_end..]) {
291 let match_obj = cap.get(0).unwrap();
292 let match_start = last_end + match_obj.start();
293 if line.as_bytes()[..match_start]
296 .iter()
297 .rev()
298 .take_while(|&&byte| byte == b'\\')
299 .count()
300 % 2
301 == 1
302 {
303 last_end += match_obj.end();
304 continue;
305 }
306 let paren_content = cap[1].to_string();
307 let bracket_start = last_end + cap.get(2).unwrap().start() - 1;
308 let end_pos = if cap[2].contains('[') {
309 Self::nested_label_end(ctx, line, byte_pos, bracket_start).unwrap_or(last_end + match_obj.end())
310 } else {
311 last_end + match_obj.end()
312 };
313 let bracket_content = line[bracket_start + 1..end_pos - 1].to_string();
314
315 if bracket_content.starts_with('[') {
318 last_end = end_pos;
319 continue;
320 }
321
322 if bracket_content.starts_with('^') {
325 last_end = end_pos;
326 continue;
327 }
328
329 if ctx.flavor == crate::config::MarkdownFlavor::Obsidian && paren_content.contains("::") {
332 last_end = end_pos;
333 continue;
334 }
335
336 if end_pos < line.len() && line[end_pos..].starts_with('(') {
339 last_end = end_pos;
340 continue;
341 }
342
343 let match_byte_pos = byte_pos + match_start;
345 let bracket_byte_pos = byte_pos + bracket_start;
346 let closing_byte_pos = byte_pos + end_pos - 1;
347
348 let in_literal_context = [match_byte_pos, closing_byte_pos].into_iter().any(|pos| {
351 ctx.is_in_code_block_or_span(pos)
352 || ctx.is_in_html_tag(pos)
353 || ctx.is_in_jsx_component_tag(pos)
354 || ctx.is_in_html_comment(pos)
355 || ctx.is_in_mdx_comment(pos)
356 || is_in_math_context(ctx, pos)
357 || ctx.is_in_jinja_range(pos)
358 });
359
360 if in_literal_context
364 || ctx.is_in_link(match_byte_pos)
365 || ctx.has_parsed_link_start_in(bracket_byte_pos, bracket_byte_pos + 1)
368 || ctx.overlaps_mdx_inline_code(match_byte_pos, closing_byte_pos + 1)
370 {
371 last_end = end_pos;
372 continue;
373 }
374
375 let paren_type = Self::classify_component(&paren_content);
377 let bracket_type = Self::classify_component(&bracket_content);
378
379 let info = ReversedLinkInfo {
380 paren_content,
381 bracket_content,
382 paren_type,
383 bracket_type,
384 };
385
386 let (text, url) = info.correct_order();
387
388 let replacement = if ctx.has_parsed_link_start_in(bracket_byte_pos + 1, closing_byte_pos + 1) {
392 None
393 } else {
394 Self::replacement(text, url)
395 };
396 let actual_length = end_pos - match_start;
397 let (start_line, start_col, end_line, end_col) =
398 calculate_match_range(line_num, line, match_start, actual_length);
399
400 warnings.push(LintWarning {
401 rule_name: Some(self.name().to_string()),
402 message: replacement.as_ref().map_or_else(
403 || "Reversed link syntax: reorder the link text and destination manually".to_string(),
404 |replacement| format!("Reversed link syntax: use {replacement} instead"),
405 ),
406 line: start_line,
407 column: start_col,
408 end_line,
409 end_column: end_col,
410 severity: Severity::Error,
411 fix: replacement.map(|replacement| {
412 let match_start_byte = byte_pos + match_start;
413 let match_end_byte = match_start_byte + actual_length;
414 Fix::new(match_start_byte..match_end_byte, replacement)
415 }),
416 });
417
418 last_end = end_pos;
419 }
420 }
421
422 Ok(warnings)
423 }
424
425 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
426 let warnings = self.check(ctx)?;
427 let warnings =
428 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
429 if warnings.is_empty() {
430 return Ok(ctx.content.to_string());
431 }
432
433 let mut content = ctx.content.to_string();
434 let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
436 fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
437
438 for fix in fixes {
439 if fix.range.start < content.len() && fix.range.end <= content.len() {
440 content.replace_range(fix.range.clone(), &fix.replacement);
441 }
442 }
443 Ok(content)
444 }
445
446 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
447 !ctx.likely_has_links_or_images() || !ctx.content.contains(")[")
448 }
449
450 fn as_any(&self) -> &dyn std::any::Any {
451 self
452 }
453
454 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
455 where
456 Self: Sized,
457 {
458 Box::new(MD011NoReversedLinks)
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use crate::lint_context::LintContext;
466
467 #[test]
468 fn test_nested_reversed_labels_preserve_resolved_reference_links() {
469 let rule = MD011NoReversedLinks;
470 for destination in ["/existing", "<>"] {
471 let source = format!("(https://example.org)[outer [inner] tail]\n\n[inner]: {destination}\n");
472 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
473 let warnings = rule.check(&ctx).unwrap();
474 assert_eq!(warnings.len(), 1);
475 assert!(warnings[0].fix.is_none(), "existing nested reference: {source}");
476 assert_eq!(rule.fix(&ctx).unwrap(), source);
477 }
478 }
479
480 #[test]
481 fn test_reversed_patterns_preserve_empty_reference_destinations() {
482 let rule = MD011NoReversedLinks;
483 for source in [
484 "(prose)[known]\n\n[known]: <>\n",
485 "(prose)[display][known]\n\n[known]: <>\n",
486 "(prose)[known][]\n\n[known]: <>\n",
487 "(prose)[STRASSE]\n\n[Straße]: <>\n",
488 r"(prose)[label\!]
489
490[label\!]: <>
491",
492 ] {
493 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
494 assert!(
495 rule.check(&ctx).unwrap().is_empty(),
496 "resolved empty reference: {source}"
497 );
498 assert_eq!(rule.fix(&ctx).unwrap(), source);
499 }
500 }
501
502 #[test]
503 fn test_nested_reversed_labels_preserve_the_complete_link_text() {
504 let rule = MD011NoReversedLinks;
505 for label in [
506 "outer [inner]",
507 "outer [inner] tail",
508 "outer [inner [deep]] tail",
509 r"outer [inner\]] tail",
510 "内容 [内部] 終わり",
511 ] {
512 let source = format!("Before (https://example.org)[{label}] after\n");
513 let expected = format!("Before [{label}](https://example.org) after\n");
514 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
515 let warnings = rule.check(&ctx).unwrap();
516 assert_eq!(warnings.len(), 1, "complete nested label: {source}");
517 assert_eq!(rule.fix(&ctx).unwrap(), expected);
518 assert_eq!(
519 warnings[0].fix.as_ref().unwrap().range.end,
520 source.find(" after").unwrap()
521 );
522 let fixed_ctx =
523 crate::lint_context::LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
524 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
525 }
526 }
527
528 #[test]
529 fn test_nested_reversed_labels_ignore_literal_brackets() {
530 let rule = MD011NoReversedLinks;
531 for label in [
532 "outer [inner] `code ] [` tail",
533 r#"outer [inner] <span title="] [">text</span> tail"#,
534 "outer [inner] <!-- ] [ --> tail",
535 ] {
536 let source = format!("Before (https://example.org)[{label}] after\n");
537 let expected = format!("Before [{label}](https://example.org) after\n");
538 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
539 assert_eq!(rule.fix(&ctx).unwrap(), expected, "literal brackets: {source}");
540 }
541 }
542
543 #[test]
544 fn test_nested_reversed_label_does_not_consume_following_matches() {
545 let rule = MD011NoReversedLinks;
546 let source = "(https://example.org)[outer [inner] tail] and (https://other.test)[second [nested]]\n";
547 let expected = "[outer [inner] tail](https://example.org) and [second [nested]](https://other.test)\n";
548 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
549 assert_eq!(rule.check(&ctx).unwrap().len(), 2);
550 assert_eq!(rule.fix(&ctx).unwrap(), expected);
551 }
552
553 #[test]
554 fn test_nested_reversed_labels_with_invalid_link_structure_have_no_fix() {
555 let rule = MD011NoReversedLinks;
556 for source in [
557 "(https://example.org)[outer [inner]\n",
558 "(https://example.org)[outer [inner](https://other.test)]\n",
559 ] {
560 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
561 let warnings = rule.check(&ctx).unwrap();
562 assert_eq!(warnings.len(), 1);
563 assert!(warnings[0].fix.is_none(), "invalid nested link structure: {source}");
564 assert_eq!(rule.fix(&ctx).unwrap(), source);
565 }
566 }
567
568 #[test]
569 fn test_angle_wrapped_reversed_destinations_are_classified_as_urls() {
570 let rule = MD011NoReversedLinks;
571 for url in [
572 "<https://example.org/a b>",
573 "<https://example.org/a b> \"Title\"",
574 "<./hello world>",
575 "</hello world>",
576 "<../hello world>",
577 "<mailto:user@example.org>",
578 ] {
579 for label in ["label", "my label"] {
580 let source = format!("({url})[{label}]\n");
581 let expected = format!("[{label}]({url})\n");
582 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
583 assert_eq!(rule.fix(&ctx).unwrap(), expected, "angle destination: {source}");
584 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
585 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
586 assert!(MD011NoReversedLinks::is_complete_inline_link(expected.trim_end()));
587 }
588 }
589 }
590
591 #[test]
592 fn test_angle_destination_in_brackets_keeps_its_title() {
593 let rule = MD011NoReversedLinks;
594 let source = "(my label)[<https://example.org/a b> \"Title\"]\n";
595 let expected = "[my label](<https://example.org/a b> \"Title\")\n";
596 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
597 assert_eq!(rule.fix(&ctx).unwrap(), expected);
598 assert!(pulldown_cmark::Parser::new(expected).any(|event| matches!(event,
599 pulldown_cmark::Event::Start(pulldown_cmark::Tag::Link { dest_url, title, .. })
600 if dest_url.as_ref() == "https://example.org/a b" && title.as_ref() == "Title")));
601 }
602
603 #[test]
604 fn test_angle_wrapping_only_promotes_valid_clear_url_destinations() {
605 for (component, expected) in [
606 ("<hello world>", LinkComponent::MultiWord),
607 ("<hello>", LinkComponent::Ambiguous),
608 ("<hello> \"Title\"", LinkComponent::MultiWord),
609 ("<https://example.org/a b> stray text", LinkComponent::MultiWord),
610 ("<https://example.org/a b", LinkComponent::MultiWord),
611 ] {
612 assert_eq!(MD011NoReversedLinks::classify_component(component), expected);
613 }
614 }
615
616 #[test]
617 fn test_space_containing_reversed_destinations_form_real_links() {
618 let rule = MD011NoReversedLinks;
619 for url in [
620 "https://example.org/hello world",
621 "https://example.org/a b?x=c d",
622 "./hello world",
623 "/hello world",
624 "https://example.org/Bob's file",
625 ] {
626 let source = format!("({url})[label]\n");
627 let expected = format!("[label](<{url}>)\n");
628 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
629 assert_eq!(rule.fix(&ctx).unwrap(), expected);
630 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
631 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
632 let links: Vec<_> = pulldown_cmark::Parser::new(&expected)
633 .filter_map(|event| match event {
634 pulldown_cmark::Event::Start(pulldown_cmark::Tag::Link { dest_url, title, .. }) => {
635 Some((dest_url.to_string(), title.to_string()))
636 }
637 _ => None,
638 })
639 .collect();
640 assert_eq!(links, vec![(url.to_string(), String::new())]);
641 }
642 }
643
644 #[test]
645 fn test_reversed_space_destinations_preserve_escapes_and_angle_characters() {
646 let rule = MD011NoReversedLinks;
647 for (source, expected, destination) in [
648 (
649 r"(https://example.org/a<b c>d)[label]",
650 r"[label](<https://example.org/a\<b c\>d>)",
651 "https://example.org/a<b c>d",
652 ),
653 (
654 r"(https://example.org/a\<b c\>d)[a\]b]",
655 r"[a\]b](<https://example.org/a\<b c\>d>)",
656 "https://example.org/a<b c>d",
657 ),
658 (
659 r"(https://example.org/a\\ b)[label]",
660 r"[label](<https://example.org/a\\ b>)",
661 r"https://example.org/a\ b",
662 ),
663 (
664 "(my label)[https://example.org/a) b]",
665 "[my label](<https://example.org/a) b>)",
666 "https://example.org/a) b",
667 ),
668 ] {
669 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
670 assert_eq!(rule.fix(&ctx).unwrap(), expected);
671 assert!(pulldown_cmark::Parser::new(expected).any(|event| matches!(event,
672 pulldown_cmark::Event::Start(pulldown_cmark::Tag::Link { dest_url, .. }) if dest_url.as_ref() == destination)));
673 }
674 }
675
676 #[test]
677 fn test_reversed_native_titles_and_padding_keep_their_meaning() {
678 let rule = MD011NoReversedLinks;
679 for url in [
680 r#"https://example.org "Title""#,
681 "https://example.org 'Title'",
682 " https://example.org ",
683 "https://example.org/a<b>c",
684 ] {
685 let source = format!("({url})[label]\n");
686 let expected = format!("[label]({url})\n");
687 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
688 assert_eq!(rule.fix(&ctx).unwrap(), expected);
689 }
690 }
691
692 #[test]
693 fn test_ambiguous_or_control_character_space_destinations_need_manual_fix() {
694 let rule = MD011NoReversedLinks;
695 for url in [
696 r#"https://example.org/a b "Title""#,
697 "https://example.org/a b 'Title'",
698 "https://example.org/a\tb",
699 "https://example.org/a b\\",
700 ] {
701 let source = format!("({url})[label]\n");
702 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
703 let warnings = rule.check(&ctx).unwrap();
704 assert_eq!(warnings.len(), 1);
705 assert!(warnings[0].fix.is_none(), "unsafe destination: {url}");
706 assert_eq!(rule.fix(&ctx).unwrap(), source);
707 }
708 }
709
710 #[test]
711 fn test_myst_comment_contents_are_not_linted() {
712 let rule = MD011NoReversedLinks;
713 for prefix in ["% ", "%", " %\t", " % "] {
714 let source = format!("{prefix}(url)[literal]\n\nBefore (url)[visible]\n");
715 let expected = format!("{prefix}(url)[literal]\n\nBefore [visible](url)\n");
716 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::MyST, None);
717 let warnings = rule.check(&ctx).unwrap();
718 assert_eq!(warnings.len(), 1, "comments are not visible prose: {source}");
719 assert_eq!(warnings[0].line, 3);
720 assert_eq!(rule.fix(&ctx).unwrap(), expected);
721 }
722 }
723
724 #[test]
725 fn test_even_backslashes_before_reversed_links_are_not_escapes() {
726 let rule = MD011NoReversedLinks;
727 for prefix in [r"\\", r"\\\\", r"日本語 \\"] {
728 let source = format!("{prefix}(url)[label]\n");
729 let expected = format!("{prefix}[label](url)\n");
730 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
731 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "even backslashes: {source}");
732 assert_eq!(rule.fix(&ctx).unwrap(), expected);
733 }
734 }
735
736 #[test]
737 fn test_reversed_labels_with_escaped_brackets_or_backslashes_are_fixed() {
738 let rule = MD011NoReversedLinks;
739 for (source, expected) in [
740 (r"(https://example.org)[a\]b]", r"[a\]b](https://example.org)"),
741 (r"(url)[label\\]", r"[label\\](url)"),
742 (r"(url)[label\]]", r"[label\]](url)"),
743 (r"(url)[a\\\]b]", r"[a\\\]b](url)"),
744 ] {
745 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
746 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "escaped label: {source}");
747 assert_eq!(rule.fix(&ctx).unwrap(), expected);
748 let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
749 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
750 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
751 assert!(
752 pulldown_cmark::Parser::new(expected)
753 .any(|event| matches!(event, pulldown_cmark::Event::Start(pulldown_cmark::Tag::Link { .. })))
754 );
755 }
756 }
757
758 #[test]
759 fn test_escaped_openers_and_unterminated_escaped_labels_remain_ignored() {
760 let rule = MD011NoReversedLinks;
761 for source in [
762 r"\(url)[label]",
763 r"\\\(url)[label]",
764 r"(url)[label\]",
765 r"(url)[label\\\]",
766 ] {
767 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
768 assert!(rule.check(&ctx).unwrap().is_empty(), "escaped delimiter: {source}");
769 assert_eq!(rule.fix(&ctx).unwrap(), source);
770 }
771 }
772
773 #[test]
774 fn test_parenthetical_before_resolved_reference_is_not_reversed() {
775 let rule = MD011NoReversedLinks;
776 for source in [
777 "(see this)[ref]\n\n[ref]: /target\n",
778 "(https://other.test)[ref]\n\n[ref]: /target\n",
779 "(url)[ref][]\n\n[ref]: /target\n",
780 "(url)[text][ref]\n\n[ref]: /target\n",
781 "(前置)[STRASSE]\n\n[straße]: /target\n",
782 "(note)[two words]\n\n[two words]: /target\n",
783 "(note)[a\\!b]\n\n[a\\!b]: /target\n",
784 ] {
785 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
786 assert!(
787 rule.check(&ctx).unwrap().is_empty(),
788 "reference already resolves: {source}"
789 );
790 assert_eq!(rule.fix(&ctx).unwrap(), source);
791 }
792 }
793
794 #[test]
795 fn test_real_reversed_link_beside_parenthetical_reference_is_fixed() {
796 let rule = MD011NoReversedLinks;
797 let source = "(see this)[ref] and (url)[real]\n\n[ref]: /target\n";
798 let expected = "(see this)[ref] and [real](url)\n\n[ref]: /target\n";
799 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
800 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
801 assert_eq!(rule.fix(&ctx).unwrap(), expected);
802 let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
803 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
804 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
805 let mut html = String::new();
806 pulldown_cmark::html::push_html(&mut html, pulldown_cmark::Parser::new(expected));
807 assert_eq!(
808 html,
809 "<p>(see this)<a href=\"/target\">ref</a> and <a href=\"url\">real</a></p>\n"
810 );
811 }
812
813 #[test]
814 fn test_reversed_links_beside_mdx_expressions_are_checked() {
815 let rule = MD011NoReversedLinks;
816 for source in [
817 "Before {\"(url)[literal]\"} and (url)[real]\n",
818 "Before {/* (url)[comment] */} and (url)[real]\n",
819 "Before {\n\"(url)[literal]\"\n} and (url)[real]\n",
820 "Before {/*\n(url)[comment]\n*/} and (url)[real]\n",
821 "Before (url)[text {\"value]\"} and (url)[real]\n",
822 "日本語 {\"(url)[内容]\"} and (url)[real]\n",
823 ] {
824 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
825 let warnings = rule.check(&ctx).unwrap();
826 assert_eq!(warnings.len(), 1, "only the prose link: {source}");
827 assert_eq!(
828 warnings[0].fix.as_ref().unwrap().range.start,
829 source.rfind("(url)[real]").unwrap()
830 );
831 let expected = source.replace(" and (url)[real]", " and [real](url)");
832 assert_eq!(rule.fix(&ctx).unwrap(), expected);
833 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::MDX, None);
834 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
835 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
836 }
837 }
838
839 #[test]
840 fn test_reversed_patterns_touching_mdx_expressions_remain_ignored() {
841 let rule = MD011NoReversedLinks;
842 for source in [
843 "Before {\"(url)[literal]\"}\n",
844 "Before {/* (url)[comment] */}\n",
845 "Before (url)[text {\"value]\"}\n",
846 "Before (url)[text {\"value\"}]\n",
847 ] {
848 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
849 assert!(
850 rule.check(&ctx).unwrap().is_empty(),
851 "MDX code must be preserved: {source}"
852 );
853 assert_eq!(rule.fix(&ctx).unwrap(), source);
854 }
855 }
856
857 #[test]
858 fn test_reversed_patterns_closing_inside_literal_contexts_are_preserved() {
859 let rule = MD011NoReversedLinks;
860 for source in [
861 "Before (url)[label `code]\nend`\n",
862 "Before (url `code)[label]\nend`\n",
863 "Before (url)[text <!-- label] -->\n",
864 "Before (url)[label <span title=\"close]\">visible</span>\n",
865 "Before (url)[text {{ \"value]\" }}\n",
866 "Before (url)[text $x]$\n",
867 "日本語 (url)[内容 `コード]\n終了`\n",
868 ] {
869 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
870 assert!(
871 rule.check(&ctx).unwrap().is_empty(),
872 "closing bracket is literal: {source}"
873 );
874 assert_eq!(rule.fix(&ctx).unwrap(), source);
875 }
876 }
877
878 #[test]
879 fn test_reversed_link_beside_partial_literal_pattern_still_gets_fixed() {
880 let rule = MD011NoReversedLinks;
881 let source = "Before (url)[label `code]\nend` and (https://example.org)[real]\n";
882 let expected = "Before (url)[label `code]\nend` and [real](https://example.org)\n";
883 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
884 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
885 assert_eq!(rule.fix(&ctx).unwrap(), expected);
886 let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
887 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
888 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
889 let code_values = |content: &str| {
890 pulldown_cmark::Parser::new(content)
891 .filter_map(|event| match event {
892 pulldown_cmark::Event::Code(value) => Some(value.into_string()),
893 _ => None,
894 })
895 .collect::<Vec<_>>()
896 };
897 assert_eq!(code_values(source), vec!["code] end"]);
898 assert_eq!(code_values(source), code_values(expected));
899 }
900
901 #[test]
902 fn test_reversed_patterns_in_html_attributes_are_preserved() {
903 let rule = MD011NoReversedLinks;
904 for source in [
905 "Before <span title=\"(url)[tooltip]\">visible</span>\n",
906 "Before <span\n title=\"(url)[tooltip]\">visible</span>\n",
907 "日本語 <span title=\"(url)[内容]\">表示</span>\n",
908 ] {
909 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
910 assert!(rule.check(&ctx).unwrap().is_empty(), "literal attribute: {source}");
911 assert_eq!(rule.fix(&ctx).unwrap(), source);
912 }
913 }
914
915 #[test]
916 fn test_reversed_patterns_in_raw_html_blocks_are_preserved() {
917 let rule = MD011NoReversedLinks;
918 for source in [
919 "<pre>\n(url)[literal]\n</pre>\n",
920 "<script>\nconst text = \"(url)[literal]\";\n</script>\n",
921 "<div>\n(url)[literal]\n</div>\n",
922 ] {
923 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
924 assert!(rule.check(&ctx).unwrap().is_empty(), "raw HTML: {source}");
925 assert_eq!(rule.fix(&ctx).unwrap(), source);
926 }
927 }
928
929 #[test]
930 fn test_reversed_patterns_in_mdx_modules_are_preserved() {
931 let rule = MD011NoReversedLinks;
932 for source in [
933 "export const text = \"(url)[literal]\"\n\nVisible prose\n",
934 "export const text =\n \"(url)[literal]\"\n\nVisible prose\n",
935 "import thing from \"(url)[module]\"\n\nVisible prose\n",
936 "export const info = {text: \"(url)[literal]\"}\n\nVisible prose\n",
937 ] {
938 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
939 assert!(rule.check(&ctx).unwrap().is_empty(), "module string: {source}");
940 assert_eq!(rule.fix(&ctx).unwrap(), source);
941 }
942 }
943
944 #[test]
945 fn test_reversed_link_beside_html_literals_still_gets_fixed() {
946 let rule = MD011NoReversedLinks;
947 let source = "Before <span title=\"(url)[tooltip]\">visible</span> and (url)[real]\n\n<pre>\n(url)[literal]\n</pre>\n\n(url)[after]\n";
948 let expected = "Before <span title=\"(url)[tooltip]\">visible</span> and [real](url)\n\n<pre>\n(url)[literal]\n</pre>\n\n[after](url)\n";
949 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
950 assert_eq!(rule.check(&ctx).unwrap().len(), 2);
951 assert_eq!(rule.fix(&ctx).unwrap(), expected);
952 }
953
954 #[test]
955 fn test_reversed_link_after_mdx_module_still_gets_fixed() {
956 let rule = MD011NoReversedLinks;
957 let source = "export const text = \"(url)[literal]\"\n\n(url)[real]\n";
958 let expected = "export const text = \"(url)[literal]\"\n\n[real](url)\n";
959 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
960 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
961 assert_eq!(rule.fix(&ctx).unwrap(), expected);
962 }
963
964 #[test]
965 fn test_reversed_links_in_markdown_html_remain_fixable() {
966 let rule = MD011NoReversedLinks;
967 for (flavor, source) in [
968 (crate::config::MarkdownFlavor::MDX, "<div>\n(url)[visible]\n</div>\n"),
969 (
970 crate::config::MarkdownFlavor::MkDocs,
971 "<div markdown=\"1\">\n(url)[visible]\n</div>\n",
972 ),
973 (
974 crate::config::MarkdownFlavor::Kramdown,
975 "<div markdown=\"1\">\n(url)[visible]\n</div>\n",
976 ),
977 ] {
978 let ctx = LintContext::new(source, flavor, None);
979 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
980 assert_eq!(
981 rule.fix(&ctx).unwrap(),
982 source.replace("(url)[visible]", "[visible](url)")
983 );
984 }
985 }
986
987 #[test]
988 fn test_reversed_patterns_inside_existing_links_are_preserved() {
989 let rule = MD011NoReversedLinks;
990 for source in [
991 "[Example (https://example.org)[label]](https://outer.test)\n",
992 "![Example (https://example.org)[label]](https://outer.test/image.png)\n",
993 "[Example (url)[label]][ref]\n\n[ref]: /target\n",
994 "[Example (url)[label]][ref]\n\n[ref]: <>\n",
995 "[go](https://example.org/(segment)[query])\n",
996 "[reference]: https://example.org \"(url)[title]\"\n\n[reference]\n",
997 "[reference]: https://example.org\n \"(url)[title]\"\n\n[reference]\n",
998 "日本語 [例 (https://example.org)[内容]](/target)\n",
999 ] {
1000 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1001 assert!(rule.check(&ctx).unwrap().is_empty(), "existing link content: {source}");
1002 assert_eq!(rule.fix(&ctx).unwrap(), source);
1003 }
1004 }
1005
1006 #[test]
1007 fn test_real_reversed_link_beside_existing_link_is_fixed() {
1008 let rule = MD011NoReversedLinks;
1009 let source = "[Example (url)[label]](/outer) and (https://example.org)[real]\n";
1010 let expected = "[Example (url)[label]](/outer) and [real](https://example.org)\n";
1011 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1012 let warnings = rule.check(&ctx).unwrap();
1013 assert_eq!(warnings.len(), 1);
1014 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1015 let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
1016 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1017 let mut html = String::new();
1018 pulldown_cmark::html::push_html(&mut html, pulldown_cmark::Parser::new(expected));
1019 assert_eq!(
1020 html,
1021 "<p><a href=\"/outer\">Example (url)[label]</a> and <a href=\"https://example.org\">real</a></p>\n"
1022 );
1023 }
1024
1025 #[test]
1026 fn test_fixed_link_is_not_reinterpreted_as_another_reversed_link() {
1027 let rule = MD011NoReversedLinks;
1028 let source = "(url)[label][missing]\n";
1029 let expected = "[label](url)[missing]\n";
1030 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1031 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1032 let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
1033 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1034 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1035 let mut html = String::new();
1036 pulldown_cmark::html::push_html(&mut html, pulldown_cmark::Parser::new(expected));
1037 assert_eq!(html, "<p><a href=\"url\">label</a>[missing]</p>\n");
1038 }
1039
1040 #[test]
1041 fn test_md011_basic() {
1042 let rule = MD011NoReversedLinks;
1043
1044 let content = "(http://example.com)[Example]\n";
1046 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1047 let warnings = rule.check(&ctx).unwrap();
1048 assert_eq!(warnings.len(), 1);
1049 assert_eq!(warnings[0].line, 1);
1050
1051 let content = "[Example](http://example.com)\n";
1053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1054 let warnings = rule.check(&ctx).unwrap();
1055 assert_eq!(warnings.len(), 0);
1056 }
1057
1058 #[test]
1059 fn test_md011_with_escaped_brackets() {
1060 let rule = MD011NoReversedLinks;
1061
1062 let content = "(url)[text\\]\n";
1064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1065 let warnings = rule.check(&ctx).unwrap();
1066 assert_eq!(warnings.len(), 0);
1067 }
1068
1069 #[test]
1070 fn test_md011_no_false_positive_with_reference_link() {
1071 let rule = MD011NoReversedLinks;
1072
1073 let content = "(text)[ref](url)\n";
1075 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1076 let warnings = rule.check(&ctx).unwrap();
1077 assert_eq!(warnings.len(), 0);
1078 }
1079
1080 #[test]
1081 fn test_md011_fix() {
1082 let rule = MD011NoReversedLinks;
1083
1084 let content = "(http://example.com)[Example]\n(another/url)[text]\n";
1085 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1086 let fixed = rule.fix(&ctx).unwrap();
1087 assert_eq!(fixed, "[Example](http://example.com)\n[text](another/url)\n");
1088 }
1089
1090 #[test]
1091 fn test_md011_in_code_block() {
1092 let rule = MD011NoReversedLinks;
1093
1094 let content = "```\n(url)[text]\n```\n(url)[text]\n";
1095 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1096 let warnings = rule.check(&ctx).unwrap();
1097 assert_eq!(warnings.len(), 1);
1098 assert_eq!(warnings[0].line, 4);
1099 }
1100
1101 #[test]
1102 fn test_md011_inline_code() {
1103 let rule = MD011NoReversedLinks;
1104
1105 let content = "`(url)[text]` and (url)[text]\n";
1106 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1107 let warnings = rule.check(&ctx).unwrap();
1108 assert_eq!(warnings.len(), 1);
1109 assert_eq!(warnings[0].column, 19);
1110 }
1111
1112 #[test]
1113 fn test_md011_no_false_positive_with_footnote() {
1114 let rule = MD011NoReversedLinks;
1115
1116 let content = "Some text with [a link](https://example.com/)[^ft].\n\n[^ft]: Note.\n";
1119 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1120 let warnings = rule.check(&ctx).unwrap();
1121 assert_eq!(warnings.len(), 0);
1122
1123 let content = "[link1](url1)[^1] and [link2](url2)[^2]\n\n[^1]: First\n[^2]: Second\n";
1125 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1126 let warnings = rule.check(&ctx).unwrap();
1127 assert_eq!(warnings.len(), 0);
1128
1129 let content = "(url)[text] and [link](url)[^footnote]\n";
1131 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1132 let warnings = rule.check(&ctx).unwrap();
1133 assert_eq!(warnings.len(), 1);
1134 assert_eq!(warnings[0].line, 1);
1135 assert_eq!(warnings[0].column, 1);
1136 }
1137
1138 #[test]
1139 fn test_md011_skip_dataview_inline_fields_obsidian() {
1140 let rule = MD011NoReversedLinks;
1141
1142 let content = "(status:: active)[link text]\n";
1145 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1146 let warnings = rule.check(&ctx).unwrap();
1147 assert_eq!(
1148 warnings.len(),
1149 0,
1150 "Should not flag Dataview inline field in Obsidian flavor"
1151 );
1152
1153 let content = "(author:: John)[read more] and (date:: 2024-01-01)[link]\n";
1155 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1156 let warnings = rule.check(&ctx).unwrap();
1157 assert_eq!(warnings.len(), 0, "Should not flag multiple Dataview inline fields");
1158
1159 let content = "(status:: done)[info] (url)[text]\n";
1161 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1162 let warnings = rule.check(&ctx).unwrap();
1163 assert_eq!(warnings.len(), 1, "Should flag reversed link but not Dataview field");
1164 assert_eq!(warnings[0].column, 23);
1165 }
1166
1167 #[test]
1168 fn test_md011_flag_dataview_in_standard_flavor() {
1169 let rule = MD011NoReversedLinks;
1170
1171 let content = "(status:: active)[link text]\n";
1174 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1175 let warnings = rule.check(&ctx).unwrap();
1176 assert_eq!(
1177 warnings.len(),
1178 1,
1179 "Should flag Dataview-like pattern in Standard flavor"
1180 );
1181 }
1182
1183 #[test]
1184 fn test_md011_reversed_link_in_jsx_nested_fence_not_flagged() {
1185 let rule = MD011NoReversedLinks;
1191 let content =
1192 "<Steps>\n <Step>\n```text\nsee (this)[https://example.com] reversed\n```\n </Step>\n</Steps>\n";
1193 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
1194 let warnings = rule.check(&ctx).unwrap();
1195 assert!(
1196 warnings.is_empty(),
1197 "reversed link inside a JSX-nested fence must not be flagged: {warnings:?}"
1198 );
1199 }
1200
1201 #[test]
1202 fn test_md011_dataview_bracket_syntax_obsidian() {
1203 let rule = MD011NoReversedLinks;
1204
1205 let content = "Task has (priority:: high)[see details]\n";
1208 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1209 let warnings = rule.check(&ctx).unwrap();
1210 assert_eq!(warnings.len(), 0, "Should skip Dataview field with spaces");
1211
1212 let content = "(completed::)[marker]\n";
1214 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1215 let warnings = rule.check(&ctx).unwrap();
1216 assert_eq!(warnings.len(), 0, "Should skip Dataview field with empty value");
1217 }
1218
1219 #[test]
1220 fn test_md011_fix_skips_obsidian_comments() {
1221 let rule = MD011NoReversedLinks;
1222
1223 let content = "%%\n(http://example.com)[hidden link]\n%%\n";
1225 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1226
1227 let warnings = rule.check(&ctx).unwrap();
1229 assert_eq!(warnings.len(), 0, "check() should skip Obsidian comment content");
1230
1231 let fixed = rule.fix(&ctx).unwrap();
1233 assert_eq!(
1234 fixed, content,
1235 "fix() should not modify reversed links inside Obsidian comments"
1236 );
1237 }
1238
1239 #[test]
1240 fn test_md011_fix_skips_obsidian_comments_with_surrounding_content() {
1241 let rule = MD011NoReversedLinks;
1242
1243 let content = "%%\n(http://example.com)[hidden]\n%%\n\n(http://real.com)[visible]\n";
1245 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1246
1247 let warnings = rule.check(&ctx).unwrap();
1249 assert_eq!(warnings.len(), 1, "check() should only flag visible reversed link");
1250 assert_eq!(warnings[0].line, 5);
1251
1252 let fixed = rule.fix(&ctx).unwrap();
1254 assert_eq!(
1255 fixed, "%%\n(http://example.com)[hidden]\n%%\n\n[visible](http://real.com)\n",
1256 "fix() should only modify visible reversed links"
1257 );
1258 }
1259
1260 #[test]
1261 fn test_md011_fix_skips_dataview_fields_obsidian() {
1262 let rule = MD011NoReversedLinks;
1263
1264 let content = "(status:: active)[link text]\n(http://example.com)[real link]\n";
1266 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1267
1268 let warnings = rule.check(&ctx).unwrap();
1269 assert_eq!(warnings.len(), 1, "check() should only flag the real reversed link");
1270
1271 let fixed = rule.fix(&ctx).unwrap();
1272 assert_eq!(
1273 fixed, "(status:: active)[link text]\n[real link](http://example.com)\n",
1274 "fix() should not modify Dataview inline fields"
1275 );
1276 }
1277}
1278
1279#[cfg(test)]
1280mod titled_relative_destination_tests {
1281 use super::*;
1282 use crate::lint_context::LintContext;
1283
1284 #[test]
1285 fn md011_titled_relative_paths_keep_destination_and_title() {
1286 for url in [
1287 r#"docs/guide.md "Guide""#,
1288 "relative/path.html 'Title'",
1289 r#"<docs/my guide.md> "Long guide""#,
1290 r#"docs/guide.md "Title with \"quotes\"""#,
1291 r#"docs/guide.md "&copy;""#,
1292 ] {
1293 for label in ["label", "read the guide", "内容 guide"] {
1294 for source in [format!("({url})[{label}]"), format!("({label})[{url}]")] {
1295 for ending in ["\n", "\r\n"] {
1296 let source = format!("{source}{ending}");
1297 let expected = format!("[{label}]({url}){ending}");
1298 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
1299 assert_eq!(MD011NoReversedLinks.fix(&ctx).unwrap(), expected, "{source}");
1300 let ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
1301 assert!(MD011NoReversedLinks.check(&ctx).unwrap().is_empty());
1302 assert_eq!(MD011NoReversedLinks.fix(&ctx).unwrap(), expected);
1303 }
1304 }
1305 }
1306 }
1307 }
1308
1309 #[test]
1310 fn md011_clear_urls_keep_priority_over_titled_relative_paths() {
1311 for (source, expected) in [
1312 (
1313 r#"(docs/guide.md "Guide")[https://example.org]"#,
1314 r#"[docs/guide.md "Guide"](https://example.org)"#,
1315 ),
1316 (
1317 r#"(https://example.org)[docs/guide.md "Guide"]"#,
1318 r#"[docs/guide.md "Guide"](https://example.org)"#,
1319 ),
1320 (
1321 r#"(say "hello")[https://example.org]"#,
1322 r#"[say "hello"](https://example.org)"#,
1323 ),
1324 (
1325 r#"(https://example.org "Title")[label]"#,
1326 r#"[label](https://example.org "Title")"#,
1327 ),
1328 ] {
1329 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1330 assert_eq!(MD011NoReversedLinks.fix(&ctx).unwrap(), expected);
1331 }
1332 }
1333
1334 #[test]
1335 fn md011_slashes_and_quotes_alone_do_not_promote_prose() {
1336 for component in [
1337 "read/write documentation",
1338 r#"say "hello""#,
1339 r#"docs/guide.md "unterminated"#,
1340 r#"docs/guide.md "Title" trailing"#,
1341 r#"docs/guide.md """#,
1342 ] {
1343 assert_eq!(
1344 MD011NoReversedLinks::classify_component(component),
1345 LinkComponent::MultiWord,
1346 "{component}"
1347 );
1348 }
1349 }
1350}