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