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::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
5use crate::utils::range_utils::calculate_match_range;
6use crate::utils::skip_context::{is_in_front_matter, is_in_html_comment, is_in_math_context};
7use lazy_static::lazy_static;
8use regex::Regex;
9
10lazy_static! {
11    static ref REVERSED_LINK_REGEX: Regex =
12        Regex::new(r"\[([^\]]+)\]\(([^)]+)\)|(\([^)]+\))\[([^\]]+)\]").unwrap();
13    // Pattern to match reversed links: (URL)[text]
14    // The URL pattern allows for nested parentheses using a simple approach
15    static ref REVERSED_LINK_CHECK_REGEX: Regex = Regex::new(
16        r"\(([^)]*(?:\([^)]*\)[^)]*)*)\)\[([^\]]+)\]"
17    ).unwrap();
18
19    // Pattern to detect escaped brackets and parentheses
20    static ref ESCAPED_CHARS: Regex = Regex::new(r"\\[\[\]()]").unwrap();
21
22    // Pattern to detect mathematical context indicators
23    static ref MATH_CONTEXT: Regex = Regex::new(
24        r"(?:f|g|h|sin|cos|tan|log|ln|exp|matrix|vector|det|lim|sum|prod|int)\s*\([^)]+\)|[∈∉⊆⊂⊃⊇∩∪∧∨¬∀∃∅∞∫∑∏√±×÷≠≤≥≈≡]|\b(?:where|such that|for all|exists|if and only if)\b"
25    ).unwrap();
26
27    // Pattern to detect function/array notation that shouldn't be flagged
28    static ref FUNCTION_ARRAY_NOTATION: Regex = Regex::new(
29        r"(?:[a-zA-Z_]\w*\([^)]*\)\[[^\]]+\])|(?:\([^)]+\)\[(?:element|index|derivative|integral|component|row|column|entry|coefficient|term)\])"
30    ).unwrap();
31
32    // New patterns for detecting malformed link attempts where user intent is clear
33    static ref MALFORMED_LINK_PATTERNS: Vec<(Regex, &'static str)> = vec![
34        // Missing closing bracket: (URL)[text  or  [text](URL
35        (Regex::new(r"\(([^)]+)\)\[([^\]]*$)").unwrap(), "missing closing bracket"),
36        (Regex::new(r"\[([^\]]+)\]\(([^)]*$)").unwrap(), "missing closing parenthesis"),
37
38        // Wrong bracket types: {URL}[text] or [text]{URL}
39        (Regex::new(r"\{([^}]+)\}\[([^\]]+)\]").unwrap(), "wrong bracket type (curly instead of parentheses)"),
40        (Regex::new(r"\[([^\]]+)\]\{([^}]+)\}").unwrap(), "wrong bracket type (curly instead of parentheses)"),
41
42        // URL and text swapped in correct syntax: [URL](text) where URL is clearly a URL
43        (Regex::new(r"\[(https?://[^\]]+)\]\(([^)]+)\)").unwrap(), "URL and text appear to be swapped"),
44        (Regex::new(r"\[(www\.[^\]]+)\]\(([^)]+)\)").unwrap(), "URL and text appear to be swapped"),
45        (Regex::new(r"\[([^\]]*\.[a-z]{2,4}[^\]]*)\]\(([^)]+)\)").unwrap(), "URL and text appear to be swapped"),
46    ];
47}
48
49#[derive(Clone)]
50pub struct MD011NoReversedLinks;
51
52impl MD011NoReversedLinks {
53    /// Check if a character at position is escaped (preceded by odd number of backslashes)
54    fn is_escaped(content: &str, pos: usize) -> bool {
55        if pos == 0 {
56            return false;
57        }
58
59        let mut backslash_count = 0;
60        let mut check_pos = pos - 1;
61
62        loop {
63            if content.chars().nth(check_pos) == Some('\\') {
64                backslash_count += 1;
65                if check_pos == 0 {
66                    break;
67                }
68                check_pos -= 1;
69            } else {
70                break;
71            }
72        }
73
74        backslash_count % 2 == 1
75    }
76
77    fn find_reversed_links(content: &str) -> Vec<(usize, usize, String, String)> {
78        let mut results = Vec::new();
79        let mut line_start = 0;
80        let mut current_line = 1;
81
82        for line in content.lines() {
83            // Skip processing if we can't possibly have a reversed link
84            if !line.contains('(') || !line.contains('[') || !line.contains(']') || !line.contains(')') {
85                line_start += line.len() + 1;
86                current_line += 1;
87                continue;
88            }
89
90            // Skip if line contains mathematical context
91            if MATH_CONTEXT.is_match(line) {
92                line_start += line.len() + 1;
93                current_line += 1;
94                continue;
95            }
96
97            // Skip if line contains function/array notation
98            if FUNCTION_ARRAY_NOTATION.is_match(line) {
99                line_start += line.len() + 1;
100                current_line += 1;
101                continue;
102            }
103
104            for cap in REVERSED_LINK_CHECK_REGEX.captures_iter(line) {
105                // Extract URL and text
106                let url = &cap[1];
107                let text = &cap[2];
108
109                // Additional check: Skip if this looks like mathematical notation
110                // e.g., (0,1)[inclusive], (a,b)[intersection]
111                if text.len() < 20
112                    && (text.contains("inclusive")
113                        || text.contains("exclusive")
114                        || text.contains("intersection")
115                        || text.contains("union")
116                        || text.contains("element")
117                        || text.contains("derivative")
118                        || text.contains("component")
119                        || text.contains("index"))
120                {
121                    continue;
122                }
123
124                // Skip if URL part looks like coordinates or intervals
125                if url.contains(',') && !url.contains("://") && !url.contains('.') {
126                    continue;
127                }
128
129                let start = line_start + cap.get(0).unwrap().start();
130                results.push((current_line, start - line_start + 1, text.to_string(), url.to_string()));
131            }
132            line_start += line.len() + 1; // +1 for newline
133            current_line += 1;
134        }
135
136        results
137    }
138
139    /// Detect malformed link attempts where user intent is clear
140    fn detect_malformed_link_attempts(&self, line: &str) -> Vec<(usize, usize, String, String)> {
141        let mut results = Vec::new();
142        let mut processed_ranges = Vec::new(); // Track processed character ranges to avoid duplicates
143
144        for (pattern, issue_type) in MALFORMED_LINK_PATTERNS.iter() {
145            for cap in pattern.captures_iter(line) {
146                let match_obj = cap.get(0).unwrap();
147                let start = match_obj.start();
148                let len = match_obj.len();
149                let end = start + len;
150
151                // Skip if this range overlaps with already processed ranges
152                if processed_ranges
153                    .iter()
154                    .any(|(proc_start, proc_end)| (start < *proc_end && end > *proc_start))
155                {
156                    continue;
157                }
158
159                // Extract potential URL and text based on the pattern
160                if let Some((url, text)) = self.extract_url_and_text_from_match(&cap, issue_type) {
161                    // Only proceed if this looks like a genuine link attempt
162                    if self.looks_like_link_attempt(&url, &text) {
163                        results.push((start, len, url, text));
164                        processed_ranges.push((start, end));
165                    }
166                }
167            }
168        }
169
170        results
171    }
172
173    /// Extract URL and text from regex match based on the issue type
174    fn extract_url_and_text_from_match(&self, cap: &regex::Captures, issue_type: &str) -> Option<(String, String)> {
175        match issue_type {
176            "missing closing bracket" => {
177                // (URL)[text -> cap[1] = URL, cap[2] = incomplete text
178                Some((cap[1].to_string(), format!("{}]", &cap[2])))
179            }
180            "missing closing parenthesis" => {
181                // [text](URL -> cap[1] = text, cap[2] = incomplete URL
182                Some((format!("{})", &cap[2]), cap[1].to_string()))
183            }
184            "wrong bracket type (curly instead of parentheses)" => {
185                // {URL}[text] or [text]{URL} -> cap[1] and cap[2]
186                if cap.get(0).unwrap().as_str().starts_with('{') {
187                    // {URL}[text] -> swap and fix brackets
188                    Some((cap[1].to_string(), cap[2].to_string()))
189                } else {
190                    // [text]{URL} -> already in correct order, fix brackets
191                    Some((cap[2].to_string(), cap[1].to_string()))
192                }
193            }
194            "URL and text appear to be swapped" => {
195                // [URL](text) -> cap[1] = URL, cap[2] = text, need to swap
196                Some((cap[1].to_string(), cap[2].to_string()))
197            }
198            _ => None,
199        }
200    }
201
202    /// Check if the extracted URL and text look like a genuine link attempt
203    fn looks_like_link_attempt(&self, url: &str, text: &str) -> bool {
204        // URL should look like a URL
205        let url_indicators = [
206            "http://", "https://", "www.", "ftp://", ".com", ".org", ".net", ".edu", ".gov", ".io", ".co",
207        ];
208
209        let has_url_indicator = url_indicators
210            .iter()
211            .any(|indicator| url.to_lowercase().contains(indicator));
212
213        // Text should be reasonable length and not look like a URL
214        let text_looks_reasonable = text.len() >= 3
215            && text.len() <= 50
216            && !url_indicators
217                .iter()
218                .any(|indicator| text.to_lowercase().contains(indicator))
219            && !text.to_lowercase().starts_with("http")
220            && text.chars().any(|c| c.is_alphabetic()); // Must contain at least one letter
221
222        // URL should not be too short or contain only non-URL characters
223        let url_looks_reasonable =
224            url.len() >= 4 && (has_url_indicator || url.contains('.')) && !url.chars().all(|c| c.is_alphabetic()); // Shouldn't be just letters
225
226        // Both URL and text should look reasonable for this to be a link attempt
227        has_url_indicator && text_looks_reasonable && url_looks_reasonable
228    }
229}
230
231impl Default for MD011NoReversedLinks {
232    fn default() -> Self {
233        Self
234    }
235}
236
237impl Rule for MD011NoReversedLinks {
238    fn name(&self) -> &'static str {
239        "MD011"
240    }
241
242    fn description(&self) -> &'static str {
243        "Link syntax should not be reversed"
244    }
245
246    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
247        let content = ctx.content;
248        let mut warnings = Vec::new();
249        let mut byte_pos = 0;
250
251        for (line_num, line) in content.lines().enumerate() {
252            // Part 1: Check for existing perfectly formed reversed links
253            for cap in REVERSED_LINK_CHECK_REGEX.captures_iter(line) {
254                let match_obj = cap.get(0).unwrap();
255                let match_start = match_obj.start();
256                let match_end = match_obj.end();
257
258                // Check if this specific match is within a code block or inline code span
259                let match_byte_pos = byte_pos + match_start;
260                if ctx.is_in_code_block_or_span(match_byte_pos) {
261                    continue;
262                }
263
264                // Skip if in HTML comment
265                if is_in_html_comment(content, match_byte_pos) {
266                    continue;
267                }
268
269                // Skip if in math context
270                if is_in_math_context(ctx, match_byte_pos) {
271                    continue;
272                }
273
274                // Skip if in front matter (line_num is 0-based)
275                if is_in_front_matter(content, line_num) {
276                    continue;
277                }
278
279                // Check if the match contains escaped brackets or parentheses
280                let match_text = match_obj.as_str();
281
282                // Skip if the opening parenthesis is escaped
283                if match_start > 0 && Self::is_escaped(line, byte_pos + match_start) {
284                    continue;
285                }
286
287                // Check if any brackets/parentheses within the match are escaped
288                let mut skip_match = false;
289                for esc_match in ESCAPED_CHARS.find_iter(match_text) {
290                    let esc_pos = match_start + esc_match.start();
291                    if esc_pos > 0 && line.chars().nth(esc_pos.saturating_sub(1)) == Some('\\') {
292                        skip_match = true;
293                        break;
294                    }
295                }
296
297                if skip_match {
298                    continue;
299                }
300
301                // Manual check for negative lookahead: skip if followed by (url)
302                // This prevents false positives like "(text)[ref](url)"
303                let remaining = &line[match_end..];
304                if remaining.trim_start().starts_with('(') {
305                    continue;
306                }
307
308                // Extract URL and text
309                let url = &cap[1];
310                let text = &cap[2];
311
312                // Calculate precise character range for the reversed syntax
313                let (start_line, start_col, end_line, end_col) =
314                    calculate_match_range(line_num + 1, line, match_obj.start(), match_obj.len());
315
316                warnings.push(LintWarning {
317                    rule_name: Some(self.name()),
318                    message: format!("Reversed link syntax: use [{text}]({url}) instead"),
319                    line: start_line,
320                    column: start_col,
321                    end_line,
322                    end_column: end_col,
323                    severity: Severity::Warning,
324                    fix: Some(Fix {
325                        range: {
326                            // Calculate proper byte range using line offsets and match position
327                            let line_start_byte = ctx.line_offsets.get(line_num).copied().unwrap_or(0);
328                            let match_start_byte = line_start_byte + match_obj.start();
329                            let match_end_byte = match_start_byte + match_obj.len();
330                            match_start_byte..match_end_byte
331                        },
332                        replacement: format!("[{text}]({url})"),
333                    }),
334                });
335            }
336
337            // Part 2: Check for malformed link attempts where user intent is clear
338            let malformed_attempts = self.detect_malformed_link_attempts(line);
339            for (start, len, url, text) in malformed_attempts {
340                // Check if this specific match is within a code block or inline code span
341                let match_byte_pos = byte_pos + start;
342                if ctx.is_in_code_block_or_span(match_byte_pos) {
343                    continue;
344                }
345
346                // Skip if in HTML comment
347                if is_in_html_comment(content, match_byte_pos) {
348                    continue;
349                }
350
351                // Skip if in math context
352                if is_in_math_context(ctx, match_byte_pos) {
353                    continue;
354                }
355
356                // Skip if in front matter (line_num is 0-based)
357                if is_in_front_matter(content, line_num) {
358                    continue;
359                }
360
361                // Calculate precise character range for the malformed syntax
362                let (start_line, start_col, end_line, end_col) = calculate_match_range(line_num + 1, line, start, len);
363
364                warnings.push(LintWarning {
365                    rule_name: Some(self.name()),
366                    message: "Malformed link syntax".to_string(),
367                    line: start_line,
368                    column: start_col,
369                    end_line,
370                    end_column: end_col,
371                    severity: Severity::Warning,
372                    fix: Some(Fix {
373                        range: {
374                            // Calculate proper byte range using line offsets and match position
375                            let line_start_byte = ctx.line_offsets.get(line_num).copied().unwrap_or(0);
376                            let match_start_byte = line_start_byte + start;
377                            let match_end_byte = match_start_byte + len;
378                            match_start_byte..match_end_byte
379                        },
380                        replacement: format!("[{text}]({url})"),
381                    }),
382                });
383            }
384
385            byte_pos += line.len() + 1; // Update byte position for next line
386        }
387
388        Ok(warnings)
389    }
390
391    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
392        let content = ctx.content;
393        let mut result = content.to_string();
394        let mut offset: usize = 0;
395
396        for (line_num, column, text, url) in Self::find_reversed_links(content) {
397            // Calculate absolute position in original content
398            let mut pos = 0;
399            for (i, line) in content.lines().enumerate() {
400                if i + 1 == line_num {
401                    pos += column - 1;
402                    break;
403                }
404                pos += line.len() + 1;
405            }
406
407            if !ctx.is_in_code_block_or_span(pos) {
408                let adjusted_pos = pos + offset;
409                let original_len = format!("({text})[{url}]").len();
410                let replacement = format!("[{text}]({url})");
411                result.replace_range(adjusted_pos..adjusted_pos + original_len, &replacement);
412                // Update offset based on the difference in lengths
413                if replacement.len() > original_len {
414                    offset += replacement.len() - original_len;
415                } else {
416                    offset = offset.saturating_sub(original_len - replacement.len());
417                }
418            }
419        }
420
421        Ok(result)
422    }
423
424    fn as_any(&self) -> &dyn std::any::Any {
425        self
426    }
427
428    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
429        // Skip if content is empty or doesn't have the necessary characters for links
430        ctx.content.is_empty() || !ctx.content.contains('(') || !ctx.content.contains('[')
431    }
432
433    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
434    where
435        Self: Sized,
436    {
437        Box::new(MD011NoReversedLinks)
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use crate::lint_context::LintContext;
445    use crate::utils::skip_context::is_in_front_matter;
446
447    #[test]
448    fn test_capture_group_order_fix() {
449        // This test confirms that the capture group order bug is fixed
450        // The regex pattern \(([^)]+)\)\[([^\]]+)\] captures:
451        // cap[1] = URL (inside parentheses)
452        // cap[2] = text (inside brackets)
453        // So (URL)[text] should become [text](URL)
454
455        let rule = MD011NoReversedLinks;
456
457        // Test with reversed link syntax
458        let content = "Check out (https://example.com)[this link] for more info.";
459        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
460
461        // This should detect the reversed syntax
462        let result = rule.check(&ctx).unwrap();
463        assert_eq!(result.len(), 1);
464        assert!(result[0].message.contains("Reversed link syntax"));
465
466        // Verify the fix produces correct output
467        let fix = result[0].fix.as_ref().unwrap();
468        assert_eq!(fix.replacement, "[this link](https://example.com)");
469    }
470
471    #[test]
472    fn test_multiple_reversed_links() {
473        // Test multiple reversed links in the same content
474        let rule = MD011NoReversedLinks;
475
476        let content = "Visit (https://example.com)[Example] and (https://test.com)[Test Site].";
477        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
478
479        let result = rule.check(&ctx).unwrap();
480        assert_eq!(result.len(), 2);
481
482        // Verify both fixes are correct
483        assert_eq!(
484            result[0].fix.as_ref().unwrap().replacement,
485            "[Example](https://example.com)"
486        );
487        assert_eq!(
488            result[1].fix.as_ref().unwrap().replacement,
489            "[Test Site](https://test.com)"
490        );
491    }
492
493    #[test]
494    fn test_normal_links_not_flagged() {
495        // Test that normal link syntax is not flagged
496        let rule = MD011NoReversedLinks;
497
498        let content = "This is a normal [link](https://example.com) and another [link](https://test.com).";
499        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
500
501        let result = rule.check(&ctx).unwrap();
502        assert_eq!(result.len(), 0);
503    }
504
505    #[test]
506    fn debug_capture_groups() {
507        // Debug test to understand capture group behavior
508        let pattern = r"\(([^)]+)\)\[([^\]]+)\]";
509        let regex = Regex::new(pattern).unwrap();
510
511        let test_text = "(https://example.com)[Click here]";
512
513        if let Some(cap) = regex.captures(test_text) {
514            println!("Full match: {}", &cap[0]);
515            println!("cap[1] (first group): {}", &cap[1]);
516            println!("cap[2] (second group): {}", &cap[2]);
517
518            // Current fix format
519            let current_fix = format!("[{}]({})", &cap[2], &cap[1]);
520            println!("Current fix produces: {current_fix}");
521
522            // Test what the actual rule produces
523            let rule = MD011NoReversedLinks;
524            let ctx = LintContext::new(test_text, crate::config::MarkdownFlavor::Standard);
525            let result = rule.check(&ctx).unwrap();
526            if !result.is_empty() {
527                println!("Rule fix produces: {}", result[0].fix.as_ref().unwrap().replacement);
528            }
529        }
530    }
531
532    #[test]
533    fn test_front_matter_detection() {
534        let content = r#"---
535title: "My Post"
536tags: ["test", "example"]
537description: "Pattern (like)[this] in frontmatter"
538---
539
540# Content
541
542Regular (https://example.com)[reversed link] that should be flagged.
543
544+++
545title = "TOML frontmatter"
546tags = ["more", "tags"]
547pattern = "(toml)[pattern]"
548+++
549
550# More Content
551
552Another (https://test.com)[reversed] link should be flagged."#;
553
554        // Test line by line
555        for (idx, line) in content.lines().enumerate() {
556            let line_num = idx; // 0-based
557            let in_fm = is_in_front_matter(content, line_num);
558
559            println!("Line {:2} (0-idx: {:2}): in_fm={:5} | {:?}", idx + 1, idx, in_fm, line);
560
561            // Lines 0-4 should be in YAML front matter
562            if idx <= 4 {
563                assert!(
564                    in_fm,
565                    "Line {} (0-idx: {}) should be in YAML front matter but got false. Content: {:?}",
566                    idx + 1,
567                    idx,
568                    line
569                );
570            }
571            // Lines 10-14 are NOT front matter (TOML block not at beginning)
572            else if (10..=14).contains(&idx) {
573                assert!(
574                    !in_fm,
575                    "Line {} (0-idx: {}) should NOT be in front matter (TOML block not at beginning). Content: {:?}",
576                    idx + 1,
577                    idx,
578                    line
579                );
580            }
581            // Everything else should NOT be in front matter
582            else {
583                assert!(
584                    !in_fm,
585                    "Line {} (0-idx: {}) should NOT be in front matter but got true. Content: {:?}",
586                    idx + 1,
587                    idx,
588                    line
589                );
590            }
591        }
592    }
593
594    #[test]
595    fn test_malformed_link_detection() {
596        let rule = MD011NoReversedLinks;
597
598        // Test wrong bracket types
599        let content = "Check out {https://example.com}[this website].";
600        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
601        let result = rule.check(&ctx).unwrap();
602        assert_eq!(result.len(), 1);
603        assert!(result[0].message.contains("Malformed link syntax"));
604
605        // Test URL and text swapped
606        let content = "Visit [https://example.com](Click Here).";
607        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
608        let result = rule.check(&ctx).unwrap();
609        assert_eq!(result.len(), 1);
610        assert!(result[0].message.contains("Malformed link syntax"));
611
612        // Test that valid links are not flagged
613        let content = "This is a [normal link](https://example.com).";
614        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
615        let result = rule.check(&ctx).unwrap();
616        assert_eq!(result.len(), 0);
617
618        // Test that non-links are not flagged
619        let content = "Regular text with [brackets] and (parentheses).";
620        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
621        let result = rule.check(&ctx).unwrap();
622        assert_eq!(result.len(), 0);
623
624        // Test that risky patterns are NOT flagged (conservative approach)
625        let content = "(example.com)is a test domain.";
626        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
627        let result = rule.check(&ctx).unwrap();
628        assert_eq!(result.len(), 0);
629
630        let content = "(optional)parameter should not be flagged.";
631        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
632        let result = rule.check(&ctx).unwrap();
633        assert_eq!(result.len(), 0);
634    }
635
636    #[test]
637    fn test_malformed_link_fixes() {
638        let rule = MD011NoReversedLinks;
639
640        // Test wrong bracket types fix
641        let content = "Check out {https://example.com}[this website].";
642        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
643        let result = rule.check(&ctx).unwrap();
644        assert_eq!(result.len(), 1);
645        let fix = result[0].fix.as_ref().unwrap();
646        assert_eq!(fix.replacement, "[this website](https://example.com)");
647
648        // Test URL and text swapped fix
649        let content = "Visit [https://example.com](Click Here).";
650        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
651        let result = rule.check(&ctx).unwrap();
652        assert_eq!(result.len(), 1);
653        let fix = result[0].fix.as_ref().unwrap();
654        assert_eq!(fix.replacement, "[Click Here](https://example.com)");
655    }
656
657    #[test]
658    fn test_conservative_detection() {
659        let rule = MD011NoReversedLinks;
660
661        // Test that edge cases are not flagged
662        let content = "This (not-a-url)text should be ignored.";
663        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
664        let result = rule.check(&ctx).unwrap();
665        assert_eq!(result.len(), 0);
666
667        let content = "Also [regular text](not a url) should be ignored.";
668        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
669        let result = rule.check(&ctx).unwrap();
670        assert_eq!(result.len(), 0);
671
672        let content = "And {not-url}[not-text] should be ignored.";
673        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
674        let result = rule.check(&ctx).unwrap();
675        assert_eq!(result.len(), 0);
676    }
677
678    #[test]
679    fn test_skip_code_blocks() {
680        let rule = MD011NoReversedLinks;
681
682        // Test that patterns inside code blocks are not flagged
683        let content = r#"Here's an example:
684
685```rust
686// This regex pattern [.!?]+\s*$ should not be flagged
687static ref TRAILING_PUNCTUATION: Regex = Regex::new(r"(?m)[.!?]+\s*$").unwrap();
688```
689
690But this (https://example.com)[reversed link] should be flagged."#;
691
692        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
693        let result = rule.check(&ctx).unwrap();
694
695        // Should only flag the reversed link outside the code block
696        assert_eq!(result.len(), 1);
697        assert!(result[0].message.contains("Reversed link syntax"));
698        assert_eq!(result[0].line, 8); // The line with the actual reversed link
699    }
700
701    #[test]
702    fn test_negative_lookahead() {
703        let rule = MD011NoReversedLinks;
704
705        // Test that (text)[ref](url) pattern is not flagged
706        let content = "This is a reference-style link: (see here)[ref](https://example.com)";
707        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
708        let result = rule.check(&ctx).unwrap();
709        assert_eq!(result.len(), 0, "Should not flag (text)[ref](url) pattern");
710
711        // Test that genuine reversed links are still caught
712        let content = "This is reversed: (https://example.com)[click here]";
713        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
714        let result = rule.check(&ctx).unwrap();
715        assert_eq!(result.len(), 1, "Should flag genuine reversed links");
716
717        // Test with spacing before the second parentheses
718        let content = "Reference with space: (text)[ref] (url)";
719        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
720        let result = rule.check(&ctx).unwrap();
721        assert_eq!(result.len(), 0, "Should not flag when space before (url)");
722    }
723
724    #[test]
725    fn test_escaped_characters() {
726        let rule = MD011NoReversedLinks;
727
728        // Test escaped brackets and parentheses
729        let content = r"Escaped: \(not a link\)\[also not\]";
730        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
731        let result = rule.check(&ctx).unwrap();
732        assert_eq!(result.len(), 0, "Should not flag escaped brackets");
733
734        // Test with URL containing parentheses
735        let content = "(https://example.com/path(with)parens)[text]";
736        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
737        let result = rule.check(&ctx).unwrap();
738        assert_eq!(result.len(), 1, "Should still flag URLs with nested parentheses");
739    }
740
741    #[test]
742    fn test_inline_code_patterns() {
743        // Test for issue #19 - MD011 should not flag patterns inside inline code
744        let rule = MD011NoReversedLinks;
745
746        // Test the exact case from issue #19
747        let content = "I find `inspect.stack()[1].frame` a lot easier to understand (or at least guess about) at a glance than `inspect.stack()[1][0]`.";
748        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
749        let result = rule.check(&ctx).unwrap();
750        assert_eq!(result.len(), 0, "Should not flag ()[1] patterns inside inline code");
751
752        // Test other patterns that might look like reversed links in code
753        let content = "Use `array()[0]` or `func()[1]` to access elements.";
754        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
755        let result = rule.check(&ctx).unwrap();
756        assert_eq!(result.len(), 0, "Should not flag array access patterns in inline code");
757
758        // Test that actual reversed links outside code are still caught
759        let content = "Check out (https://example.com)[this link] and use `array()[1]`.";
760        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
761        let result = rule.check(&ctx).unwrap();
762        assert_eq!(result.len(), 1, "Should flag actual reversed link but not code pattern");
763        assert!(result[0].message.contains("Reversed link syntax"));
764
765        // Test mixed scenario with code blocks
766        let content = r#"
767Here's some code: `func()[1]` and `other()[2]`.
768
769But this is wrong: (https://example.com)[Click here]
770
771```python
772# This should not be flagged
773result = inspect.stack()[1]
774```
775"#;
776        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
777        let result = rule.check(&ctx).unwrap();
778        assert_eq!(result.len(), 1, "Should only flag the actual reversed link");
779        assert_eq!(result[0].line, 4, "Should flag the reversed link on line 4");
780    }
781
782    #[test]
783    fn test_issue_26_specific_case() {
784        // Test for issue #26 - specific case reported
785        let rule = MD011NoReversedLinks;
786
787        let content = r#"The first thing I need to find is the name of the redacted key name, `doc.<key_name_omitted>`. I'll use `SUBSTRING(ATTRIBUTES(doc)[0], 0, 1) == '<c>'` as that test, where `<c>` is different characters. This gets the first attribute from `doc` and uses `SUBSTRING` to get the first character."#;
788        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
789        let result = rule.check(&ctx).unwrap();
790        assert_eq!(
791            result.len(),
792            0,
793            "Should not flag ATTRIBUTES(doc)[0] inside inline code (issue #26)"
794        );
795    }
796}