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 as_any(&self) -> &dyn std::any::Any {
198 self
199 }
200
201 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
202 where
203 Self: Sized,
204 {
205 Box::new(MD011NoReversedLinks)
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use crate::lint_context::LintContext;
213
214 #[test]
215 fn test_md011_basic() {
216 let rule = MD011NoReversedLinks;
217
218 let content = "(http://example.com)[Example]\n";
220 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
221 let warnings = rule.check(&ctx).unwrap();
222 assert_eq!(warnings.len(), 1);
223 assert_eq!(warnings[0].line, 1);
224
225 let content = "[Example](http://example.com)\n";
227 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
228 let warnings = rule.check(&ctx).unwrap();
229 assert_eq!(warnings.len(), 0);
230 }
231
232 #[test]
233 fn test_md011_with_escaped_brackets() {
234 let rule = MD011NoReversedLinks;
235
236 let content = "(url)[text\\]\n";
238 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
239 let warnings = rule.check(&ctx).unwrap();
240 assert_eq!(warnings.len(), 0);
241 }
242
243 #[test]
244 fn test_md011_no_false_positive_with_reference_link() {
245 let rule = MD011NoReversedLinks;
246
247 let content = "(text)[ref](url)\n";
249 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
250 let warnings = rule.check(&ctx).unwrap();
251 assert_eq!(warnings.len(), 0);
252 }
253
254 #[test]
255 fn test_md011_fix() {
256 let rule = MD011NoReversedLinks;
257
258 let content = "(http://example.com)[Example]\n(another/url)[text]\n";
259 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
260 let fixed = rule.fix(&ctx).unwrap();
261 assert_eq!(fixed, "[Example](http://example.com)\n[text](another/url)\n");
262 }
263
264 #[test]
265 fn test_md011_in_code_block() {
266 let rule = MD011NoReversedLinks;
267
268 let content = "```\n(url)[text]\n```\n(url)[text]\n";
269 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
270 let warnings = rule.check(&ctx).unwrap();
271 assert_eq!(warnings.len(), 1);
272 assert_eq!(warnings[0].line, 4);
273 }
274
275 #[test]
276 fn test_md011_inline_code() {
277 let rule = MD011NoReversedLinks;
278
279 let content = "`(url)[text]` and (url)[text]\n";
280 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
281 let warnings = rule.check(&ctx).unwrap();
282 assert_eq!(warnings.len(), 1);
283 assert_eq!(warnings[0].column, 19);
284 }
285}