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::jinja_utils::is_in_jinja_template;
6use crate::utils::range_utils::{LineIndex, calculate_match_range};
7use crate::utils::regex_cache::get_cached_regex;
8use crate::utils::skip_context::{is_in_html_comment, is_in_math_context};
9
10// Reversed link detection pattern
11const REVERSED_LINK_REGEX_STR: &str = r"(^|[^\\])\(([^()]+)\)\[([^\]]+)\]";
12
13#[derive(Clone)]
14pub struct MD011NoReversedLinks;
15
16impl MD011NoReversedLinks {
17    fn find_reversed_links(content: &str) -> Vec<(usize, usize, String, String)> {
18        let mut results = Vec::new();
19        let mut line_num = 1;
20
21        for line in content.lines() {
22            let mut last_end = 0;
23
24            while let Some(cap) = get_cached_regex(REVERSED_LINK_REGEX_STR)
25                .ok()
26                .and_then(|re| re.captures(&line[last_end..]))
27            {
28                let match_obj = cap.get(0).unwrap();
29                let prechar = &cap[1];
30                let url = &cap[2];
31                let text = &cap[3];
32
33                // Check if the brackets at the end are escaped
34                if text.ends_with('\\') {
35                    last_end += match_obj.end();
36                    continue;
37                }
38
39                // Manual negative lookahead: skip if followed by (
40                // This prevents matching (text)[ref](url) patterns
41                let end_pos = last_end + match_obj.end();
42                if end_pos < line.len() && line[end_pos..].starts_with('(') {
43                    last_end += match_obj.end();
44                    continue;
45                }
46
47                // Calculate the actual column (accounting for any prefix character)
48                let column = last_end + match_obj.start() + prechar.len() + 1;
49
50                results.push((line_num, column, text.to_string(), url.to_string()));
51                last_end += match_obj.end();
52            }
53
54            line_num += 1;
55        }
56
57        results
58    }
59}
60
61impl Rule for MD011NoReversedLinks {
62    fn name(&self) -> &'static str {
63        "MD011"
64    }
65
66    fn description(&self) -> &'static str {
67        "Reversed link syntax"
68    }
69
70    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
71        let content = ctx.content;
72        let mut warnings = Vec::new();
73
74        // Create LineIndex for correct byte position calculations across all line ending types
75        let line_index = LineIndex::new(content.to_string());
76
77        for (line_num, line) in content.lines().enumerate() {
78            // Skip lines that are in front matter (use pre-computed info from LintContext)
79            if ctx.line_info(line_num).is_some_and(|info| info.in_front_matter) {
80                continue;
81            }
82
83            let byte_pos = line_index.get_line_start_byte(line_num + 1).unwrap_or(0);
84
85            let mut last_end = 0;
86
87            while let Some(cap) = get_cached_regex(REVERSED_LINK_REGEX_STR)
88                .ok()
89                .and_then(|re| re.captures(&line[last_end..]))
90            {
91                let match_obj = cap.get(0).unwrap();
92                let prechar = &cap[1];
93                let url = &cap[2];
94                let text = &cap[3];
95
96                // Check if the brackets at the end are escaped
97                if text.ends_with('\\') {
98                    last_end += match_obj.end();
99                    continue;
100                }
101
102                // Manual negative lookahead: skip if followed by (
103                // This prevents matching (text)[ref](url) patterns
104                let end_pos = last_end + match_obj.end();
105                if end_pos < line.len() && line[end_pos..].starts_with('(') {
106                    last_end += match_obj.end();
107                    continue;
108                }
109
110                // Calculate the actual position
111                let match_start = last_end + match_obj.start() + prechar.len();
112                let match_byte_pos = byte_pos + match_start;
113
114                // Skip if in code block, inline code, HTML comments, math contexts, or Jinja templates
115                if ctx.is_in_code_block_or_span(match_byte_pos)
116                    || is_in_html_comment(content, match_byte_pos)
117                    || is_in_math_context(ctx, match_byte_pos)
118                    || is_in_jinja_template(content, match_byte_pos)
119                {
120                    last_end += match_obj.end();
121                    continue;
122                }
123
124                // Calculate the range for the actual reversed link (excluding prechar)
125                let actual_length = match_obj.len() - prechar.len();
126                let (start_line, start_col, end_line, end_col) =
127                    calculate_match_range(line_num + 1, line, match_start, actual_length);
128
129                warnings.push(LintWarning {
130                    rule_name: Some(self.name().to_string()),
131                    message: format!("Reversed link syntax: use [{text}]({url}) instead"),
132                    line: start_line,
133                    column: start_col,
134                    end_line,
135                    end_column: end_col,
136                    severity: Severity::Warning,
137                    fix: Some(Fix {
138                        range: {
139                            let match_start_byte = byte_pos + match_start;
140                            let match_end_byte = match_start_byte + actual_length;
141                            match_start_byte..match_end_byte
142                        },
143                        replacement: format!("[{text}]({url})"),
144                    }),
145                });
146
147                last_end += match_obj.end();
148            }
149        }
150
151        Ok(warnings)
152    }
153
154    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
155        let content = ctx.content;
156        let mut result = content.to_string();
157        let mut offset: isize = 0;
158
159        // Create LineIndex for correct byte position calculations across all line ending types
160        let line_index = LineIndex::new(content.to_string());
161
162        for (line_num, column, text, url) in Self::find_reversed_links(content) {
163            // Skip if in front matter (line_num is 1-based from find_reversed_links)
164            if line_num > 0 && ctx.line_info(line_num - 1).is_some_and(|info| info.in_front_matter) {
165                continue;
166            }
167
168            // Calculate absolute position in original content using LineIndex
169            let line_start = line_index.get_line_start_byte(line_num).unwrap_or(0);
170            let pos = line_start + (column - 1);
171
172            // Skip if in any skip context
173            if !ctx.is_in_code_block_or_span(pos)
174                && !is_in_html_comment(content, pos)
175                && !is_in_math_context(ctx, pos)
176                && !is_in_jinja_template(content, pos)
177            {
178                let adjusted_pos = (pos as isize + offset) as usize;
179                let original = format!("({url})[{text}]");
180                let replacement = format!("[{text}]({url})");
181
182                // Make sure we have the right substring before replacing
183                let end_pos = adjusted_pos + original.len();
184                if end_pos <= result.len() && adjusted_pos < result.len() {
185                    result.replace_range(adjusted_pos..end_pos, &replacement);
186                    // Update offset based on the difference in lengths
187                    offset += replacement.len() as isize - original.len() as isize;
188                }
189            }
190        }
191
192        Ok(result)
193    }
194
195    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
196        ctx.content.is_empty() || !ctx.likely_has_links_or_images()
197    }
198
199    fn as_any(&self) -> &dyn std::any::Any {
200        self
201    }
202
203    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
204    where
205        Self: Sized,
206    {
207        Box::new(MD011NoReversedLinks)
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::lint_context::LintContext;
215
216    #[test]
217    fn test_md011_basic() {
218        let rule = MD011NoReversedLinks;
219
220        // Should detect reversed links
221        let content = "(http://example.com)[Example]\n";
222        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
223        let warnings = rule.check(&ctx).unwrap();
224        assert_eq!(warnings.len(), 1);
225        assert_eq!(warnings[0].line, 1);
226
227        // Should not detect correct links
228        let content = "[Example](http://example.com)\n";
229        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
230        let warnings = rule.check(&ctx).unwrap();
231        assert_eq!(warnings.len(), 0);
232    }
233
234    #[test]
235    fn test_md011_with_escaped_brackets() {
236        let rule = MD011NoReversedLinks;
237
238        // Should not detect if brackets are escaped
239        let content = "(url)[text\\]\n";
240        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
241        let warnings = rule.check(&ctx).unwrap();
242        assert_eq!(warnings.len(), 0);
243    }
244
245    #[test]
246    fn test_md011_no_false_positive_with_reference_link() {
247        let rule = MD011NoReversedLinks;
248
249        // Should not detect (text)[ref](url) as reversed
250        let content = "(text)[ref](url)\n";
251        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
252        let warnings = rule.check(&ctx).unwrap();
253        assert_eq!(warnings.len(), 0);
254    }
255
256    #[test]
257    fn test_md011_fix() {
258        let rule = MD011NoReversedLinks;
259
260        let content = "(http://example.com)[Example]\n(another/url)[text]\n";
261        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
262        let fixed = rule.fix(&ctx).unwrap();
263        assert_eq!(fixed, "[Example](http://example.com)\n[text](another/url)\n");
264    }
265
266    #[test]
267    fn test_md011_in_code_block() {
268        let rule = MD011NoReversedLinks;
269
270        let content = "```\n(url)[text]\n```\n(url)[text]\n";
271        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
272        let warnings = rule.check(&ctx).unwrap();
273        assert_eq!(warnings.len(), 1);
274        assert_eq!(warnings[0].line, 4);
275    }
276
277    #[test]
278    fn test_md011_inline_code() {
279        let rule = MD011NoReversedLinks;
280
281        let content = "`(url)[text]` and (url)[text]\n";
282        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
283        let warnings = rule.check(&ctx).unwrap();
284        assert_eq!(warnings.len(), 1);
285        assert_eq!(warnings[0].column, 19);
286    }
287}