rumdl_lib/rules/
md011_no_reversed_links.rs1use 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
10const 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 if text.ends_with('\\') {
35 last_end += match_obj.end();
36 continue;
37 }
38
39 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 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 if ctx.line_info(line_num).is_some_and(|info| info.in_front_matter) {
78 byte_pos += line.len() + 1; 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 if text.ends_with('\\') {
95 last_end += match_obj.end();
96 continue;
97 }
98
99 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 let match_start = last_end + match_obj.start() + prechar.len();
109 let match_byte_pos = byte_pos + match_start;
110
111 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 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; }
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 if line_num > 0 && ctx.line_info(line_num - 1).is_some_and(|info| info.in_front_matter) {
161 continue;
162 }
163
164 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 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 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 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 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 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 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 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}