rumdl_lib/rules/
md011_no_reversed_links.rs1use 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 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 if text.ends_with('\\') {
37 last_end += match_obj.end();
38 continue;
39 }
40
41 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 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 if ctx.line_info(line_num).is_some_and(|info| info.in_front_matter) {
80 byte_pos += line.len() + 1; 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 if text.ends_with('\\') {
94 last_end += match_obj.end();
95 continue;
96 }
97
98 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 let match_start = last_end + match_obj.start() + prechar.len();
108 let match_byte_pos = byte_pos + match_start;
109
110 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 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; }
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 if line_num > 0 && ctx.line_info(line_num - 1).is_some_and(|info| info.in_front_matter) {
159 continue;
160 }
161
162 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 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 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 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 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 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 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 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}