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::{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
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
74 let line_index = LineIndex::new(content.to_string());
76
77 for (line_num, line) in content.lines().enumerate() {
78 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 if text.ends_with('\\') {
98 last_end += match_obj.end();
99 continue;
100 }
101
102 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 let match_start = last_end + match_obj.start() + prechar.len();
112 let match_byte_pos = byte_pos + match_start;
113
114 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 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 let line_index = LineIndex::new(content.to_string());
161
162 for (line_num, column, text, url) in Self::find_reversed_links(content) {
163 if line_num > 0 && ctx.line_info(line_num - 1).is_some_and(|info| info.in_front_matter) {
165 continue;
166 }
167
168 let line_start = line_index.get_line_start_byte(line_num).unwrap_or(0);
170 let pos = line_start + (column - 1);
171
172 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 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 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 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 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 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 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}