Skip to main content

rumdl_lib/rules/
md011_no_reversed_links.rs

1/// Rule MD011: No reversed link syntax
2///
3/// See [docs/md011.md](../../docs/md011.md) for full documentation, configuration, and examples.
4use 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
11// Reversed link detection pattern
12const REVERSED_LINK_REGEX_STR: &str = r"\(([^()]+)\)\[((?:\\.|[^\]\\])+)\]";
13static REVERSED_LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(REVERSED_LINK_REGEX_STR).unwrap());
14
15/// Classification of a link component
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17enum LinkComponent {
18    /// Clear URL: has protocol, www., mailto:, or path prefix
19    ClearUrl,
20    /// A complete native relative destination with a title
21    TitledRelativeUrl,
22    /// Multiple words or sentence-like (likely link text, not URL)
23    MultiWord,
24    /// Single word - could be either URL or text
25    Ambiguous,
26}
27
28/// Information about a detected reversed link pattern
29#[derive(Debug, Clone)]
30struct ReversedLinkInfo {
31    /// Content found in parentheses
32    paren_content: String,
33    /// Content found in square brackets
34    bracket_content: String,
35    /// Classification of parentheses content
36    paren_type: LinkComponent,
37    /// Classification of bracket content
38    bracket_type: LinkComponent,
39}
40
41impl ReversedLinkInfo {
42    /// Determine the correct order: returns (text, url)
43    fn correct_order(&self) -> (&str, &str) {
44        use LinkComponent::{Ambiguous, ClearUrl, MultiWord, TitledRelativeUrl};
45
46        match (self.paren_type, self.bracket_type) {
47            // One side is clearly a URL - that's the URL
48            (ClearUrl, _) => (&self.bracket_content, &self.paren_content),
49            (_, ClearUrl) => (&self.paren_content, &self.bracket_content),
50
51            // A native title belongs to its relative destination, not the text.
52            (TitledRelativeUrl, _) => (&self.bracket_content, &self.paren_content),
53            (_, TitledRelativeUrl) => (&self.paren_content, &self.bracket_content),
54
55            // One side is multi-word - that's the text, other is URL
56            (MultiWord, _) => (&self.paren_content, &self.bracket_content),
57            (_, MultiWord) => (&self.bracket_content, &self.paren_content),
58
59            // Both ambiguous: assume standard reversed pattern (url)[text]
60            (Ambiguous, Ambiguous) => (&self.bracket_content, &self.paren_content),
61        }
62    }
63}
64
65#[derive(Clone)]
66pub struct MD011NoReversedLinks;
67
68impl MD011NoReversedLinks {
69    /// A complete inline link, rather than a valid prefix followed by text.
70    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    /// Complete a label containing nested brackets. Escapes and brackets in
85    /// literal Markdown/template contexts do not change the nesting depth.
86    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    /// Space-containing destinations need angle brackets. Keep valid native
131    /// titles intact and withhold a fix when a title cannot be distinguished
132    /// from the destination, or when the result would still not be a link.
133    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        // The source is already Markdown-escaped: retain its backslashes and
154        // escape only angle delimiters that are not already escaped.
155        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    /// Classify a link component as URL, multi-word text, or ambiguous
180    fn classify_component(s: &str) -> LinkComponent {
181        let trimmed = s.trim();
182        // Angle-wrapped destinations can contain spaces and an optional title.
183        // Decode only complete native link syntax; ordinary angle-bracket prose
184        // and malformed destinations retain their existing classification.
185        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        // Only promote a relative path when the whole component is a native
211        // destination with a title. Slashes or quoted prose alone are ambiguous.
212        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        // Multi-word text is likely a description, not a URL
230        if trimmed.contains(' ') {
231            return LinkComponent::MultiWord;
232        }
233
234        // Single word - could be either
235        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        // Use filtered_lines() to automatically skip front-matter and Obsidian comments
263        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            // Raw HTML block contents are literal. MDX JSX children and
273            // containers opted into Markdown remain lintable.
274            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                // An opening parenthesis is escaped only by an odd run of
294                // backslashes. Even runs leave a literal backslash before it.
295                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                // Skip wiki-link patterns whose bracket content starts with [.
316                // This handles cases like (url)[[wiki-link]] being misdetected
317                if bracket_content.starts_with('[') {
318                    last_end = end_pos;
319                    continue;
320                }
321
322                // Skip footnote references: [^footnote]
323                // This prevents false positives like [link](url)[^footnote]
324                if bracket_content.starts_with('^') {
325                    last_end = end_pos;
326                    continue;
327                }
328
329                // Skip Dataview inline fields in Obsidian flavor
330                // Pattern: (field:: value)[text] is valid Obsidian syntax, not a reversed link
331                if ctx.flavor == crate::config::MarkdownFlavor::Obsidian && paren_content.contains("::") {
332                    last_end = end_pos;
333                    continue;
334                }
335
336                // Manual negative lookahead: skip if followed by (
337                // This prevents matching (text)[ref](url) patterns
338                if end_pos < line.len() && line[end_pos..].starts_with('(') {
339                    last_end = end_pos;
340                    continue;
341                }
342
343                // Calculate the actual position
344                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                // A closing bracket inside a literal context cannot complete
349                // reversed link syntax that started outside that context.
350                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                // Link labels, destinations, and reference definitions already
361                // belong to another construct. Rewriting their contents can
362                // create a nested link and destroy the original outer link.
363                if in_literal_context
364                    || ctx.is_in_link(match_byte_pos)
365                    // Parenthesized prose can precede a valid reference link;
366                    // its destination must not be replaced by the parentheses.
367                    || ctx.has_parsed_link_start_in(bracket_byte_pos, bracket_byte_pos + 1)
368                    // Protect MDX code by range so prose beside it remains lintable.
369                    || ctx.overlaps_mdx_inline_code(match_byte_pos, closing_byte_pos + 1)
370                {
371                    last_end = end_pos;
372                    continue;
373                }
374
375                // Classify both components and determine correct order
376                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                // A standalone candidate parse cannot resolve the document's
389                // references. Preserve actual inner links, including empty
390                // reference destinations, instead of creating nested anchors.
391                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        // Apply fixes in reverse order to preserve byte offsets
435        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        // Should detect reversed links
1045        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        // Should not detect correct links
1052        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        // Should not detect if brackets are escaped
1063        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        // Should not detect (text)[ref](url) as reversed
1074        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        // Should not detect [link](url)[^footnote] as reversed - this is valid markdown
1117        // The [^footnote] is a footnote reference, not part of a reversed link
1118        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        // Also test with multiple footnotes
1124        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        // But should still detect actual reversed links
1130        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        // Dataview inline field pattern: (field:: value)[text]
1143        // In Obsidian flavor, this should NOT be flagged as a reversed link
1144        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        // Multiple inline fields
1154        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        // Mixed content: Dataview field and actual reversed link
1160        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        // In Standard flavor, (field:: value)[text] is treated as a reversed link
1172        // because Dataview is Obsidian-specific
1173        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        // A reversed link inside a fenced code block nested in a JSX component is
1186        // code, not prose. pulldown-cmark classifies the component as one HTML
1187        // block and emits no code-block range for the fence, so MD011's
1188        // byte-range code check would flag it (and `fmt` would corrupt the code)
1189        // unless the JSX fence range is added to ctx.code_blocks.
1190        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        // Dataview also supports [field:: value] syntax inside brackets
1206        // The pattern (field:: value)[text] should be skipped in Obsidian
1207        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        // Field with no value (just key::)
1213        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        // Reversed link inside Obsidian comment block should not be modified by fix()
1224        let content = "%%\n(http://example.com)[hidden link]\n%%\n";
1225        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1226
1227        // check() should produce no warnings (Obsidian comment is skipped)
1228        let warnings = rule.check(&ctx).unwrap();
1229        assert_eq!(warnings.len(), 0, "check() should skip Obsidian comment content");
1230
1231        // fix() should not modify content inside Obsidian comments
1232        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        // Mix of Obsidian comment and real reversed link
1244        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        // check() should only flag the visible one
1248        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        // fix() should only fix the visible one, leaving comment content untouched
1253        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        // Dataview inline field should not be modified by fix()
1265        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 "&amp;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}