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