1use crate::filtered_lines::FilteredLinesExt;
5use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
6use crate::utils::range_utils::calculate_match_range;
7use crate::utils::regex_cache::get_cached_regex;
8use crate::utils::skip_context::is_in_math_context;
9
10const REVERSED_LINK_REGEX_STR: &str = r"(^|[^\\])\(([^()]+)\)\[([^\]]+)\]";
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15enum LinkComponent {
16 ClearUrl,
18 MultiWord,
20 Ambiguous,
22}
23
24#[derive(Debug, Clone)]
26struct ReversedLinkInfo {
27 paren_content: String,
29 bracket_content: String,
31 paren_type: LinkComponent,
33 bracket_type: LinkComponent,
35}
36
37impl ReversedLinkInfo {
38 fn correct_order(&self) -> (&str, &str) {
40 use LinkComponent::*;
41
42 match (self.paren_type, self.bracket_type) {
43 (ClearUrl, _) => (&self.bracket_content, &self.paren_content),
45 (_, ClearUrl) => (&self.paren_content, &self.bracket_content),
46
47 (MultiWord, _) => (&self.paren_content, &self.bracket_content),
49 (_, MultiWord) => (&self.bracket_content, &self.paren_content),
50
51 (Ambiguous, Ambiguous) => (&self.bracket_content, &self.paren_content),
53 }
54 }
55}
56
57#[derive(Clone)]
58pub struct MD011NoReversedLinks;
59
60impl MD011NoReversedLinks {
61 fn classify_component(s: &str) -> LinkComponent {
63 let trimmed = s.trim();
64
65 if trimmed.starts_with("http://")
67 || trimmed.starts_with("https://")
68 || trimmed.starts_with("ftp://")
69 || trimmed.starts_with("www.")
70 || (trimmed.starts_with("mailto:") && trimmed.contains('@'))
71 || (trimmed.starts_with('/') && trimmed.len() > 1)
72 || (trimmed.starts_with("./") || trimmed.starts_with("../"))
73 || (trimmed.starts_with('#') && trimmed.len() > 1 && !trimmed[1..].contains(' '))
74 {
75 return LinkComponent::ClearUrl;
76 }
77
78 if trimmed.contains(' ') {
80 return LinkComponent::MultiWord;
81 }
82
83 LinkComponent::Ambiguous
85 }
86}
87
88impl Rule for MD011NoReversedLinks {
89 fn name(&self) -> &'static str {
90 "MD011"
91 }
92
93 fn description(&self) -> &'static str {
94 "Reversed link syntax"
95 }
96
97 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
98 let mut warnings = Vec::new();
99
100 let line_index = &ctx.line_index;
101
102 for filtered_line in ctx.filtered_lines().skip_front_matter().skip_obsidian_comments() {
104 let line_num = filtered_line.line_num;
105 let line = filtered_line.content;
106
107 let byte_pos = line_index.get_line_start_byte(line_num).unwrap_or(0);
108
109 let mut last_end = 0;
110
111 while let Some(cap) = get_cached_regex(REVERSED_LINK_REGEX_STR)
112 .ok()
113 .and_then(|re| re.captures(&line[last_end..]))
114 {
115 let match_obj = cap.get(0).unwrap();
116 let prechar = &cap[1];
117 let paren_content = cap[2].to_string();
118 let bracket_content = cap[3].to_string();
119
120 if bracket_content.starts_with('[') || bracket_content.ends_with(']') {
123 last_end += match_obj.end();
124 continue;
125 }
126
127 if bracket_content.starts_with('^') {
130 last_end += match_obj.end();
131 continue;
132 }
133
134 if ctx.flavor == crate::config::MarkdownFlavor::Obsidian && paren_content.contains("::") {
137 last_end += match_obj.end();
138 continue;
139 }
140
141 if bracket_content.ends_with('\\') {
143 last_end += match_obj.end();
144 continue;
145 }
146
147 let end_pos = last_end + match_obj.end();
150 if end_pos < line.len() && line[end_pos..].starts_with('(') {
151 last_end += match_obj.end();
152 continue;
153 }
154
155 let match_start = last_end + match_obj.start() + prechar.len();
157 let match_byte_pos = byte_pos + match_start;
158
159 if ctx.is_in_code_block_or_span(match_byte_pos)
161 || ctx.is_in_html_comment(match_byte_pos)
162 || is_in_math_context(ctx, match_byte_pos)
163 || ctx.is_in_jinja_range(match_byte_pos)
164 {
165 last_end += match_obj.end();
166 continue;
167 }
168
169 let paren_type = Self::classify_component(&paren_content);
171 let bracket_type = Self::classify_component(&bracket_content);
172
173 let info = ReversedLinkInfo {
174 paren_content,
175 bracket_content,
176 paren_type,
177 bracket_type,
178 };
179
180 let (text, url) = info.correct_order();
181
182 let actual_length = match_obj.len() - prechar.len();
184 let (start_line, start_col, end_line, end_col) =
185 calculate_match_range(line_num, line, match_start, actual_length);
186
187 warnings.push(LintWarning {
188 rule_name: Some(self.name().to_string()),
189 message: format!("Reversed link syntax: use [{text}]({url}) instead"),
190 line: start_line,
191 column: start_col,
192 end_line,
193 end_column: end_col,
194 severity: Severity::Error,
195 fix: Some(Fix {
196 range: {
197 let match_start_byte = byte_pos + match_start;
198 let match_end_byte = match_start_byte + actual_length;
199 match_start_byte..match_end_byte
200 },
201 replacement: format!("[{text}]({url})"),
202 }),
203 });
204
205 last_end += match_obj.end();
206 }
207 }
208
209 Ok(warnings)
210 }
211
212 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
213 let warnings = self.check(ctx)?;
214 if warnings.is_empty() {
215 return Ok(ctx.content.to_string());
216 }
217
218 let mut content = ctx.content.to_string();
219 let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
221 fixes.sort_by(|a, b| b.range.start.cmp(&a.range.start));
222
223 for fix in fixes {
224 if fix.range.start < content.len() && fix.range.end <= content.len() {
225 content.replace_range(fix.range.clone(), &fix.replacement);
226 }
227 }
228 Ok(content)
229 }
230
231 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
232 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
233 }
234
235 fn as_any(&self) -> &dyn std::any::Any {
236 self
237 }
238
239 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
240 where
241 Self: Sized,
242 {
243 Box::new(MD011NoReversedLinks)
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use crate::lint_context::LintContext;
251
252 #[test]
253 fn test_md011_basic() {
254 let rule = MD011NoReversedLinks;
255
256 let content = "(http://example.com)[Example]\n";
258 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
259 let warnings = rule.check(&ctx).unwrap();
260 assert_eq!(warnings.len(), 1);
261 assert_eq!(warnings[0].line, 1);
262
263 let content = "[Example](http://example.com)\n";
265 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
266 let warnings = rule.check(&ctx).unwrap();
267 assert_eq!(warnings.len(), 0);
268 }
269
270 #[test]
271 fn test_md011_with_escaped_brackets() {
272 let rule = MD011NoReversedLinks;
273
274 let content = "(url)[text\\]\n";
276 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
277 let warnings = rule.check(&ctx).unwrap();
278 assert_eq!(warnings.len(), 0);
279 }
280
281 #[test]
282 fn test_md011_no_false_positive_with_reference_link() {
283 let rule = MD011NoReversedLinks;
284
285 let content = "(text)[ref](url)\n";
287 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
288 let warnings = rule.check(&ctx).unwrap();
289 assert_eq!(warnings.len(), 0);
290 }
291
292 #[test]
293 fn test_md011_fix() {
294 let rule = MD011NoReversedLinks;
295
296 let content = "(http://example.com)[Example]\n(another/url)[text]\n";
297 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
298 let fixed = rule.fix(&ctx).unwrap();
299 assert_eq!(fixed, "[Example](http://example.com)\n[text](another/url)\n");
300 }
301
302 #[test]
303 fn test_md011_in_code_block() {
304 let rule = MD011NoReversedLinks;
305
306 let content = "```\n(url)[text]\n```\n(url)[text]\n";
307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
308 let warnings = rule.check(&ctx).unwrap();
309 assert_eq!(warnings.len(), 1);
310 assert_eq!(warnings[0].line, 4);
311 }
312
313 #[test]
314 fn test_md011_inline_code() {
315 let rule = MD011NoReversedLinks;
316
317 let content = "`(url)[text]` and (url)[text]\n";
318 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
319 let warnings = rule.check(&ctx).unwrap();
320 assert_eq!(warnings.len(), 1);
321 assert_eq!(warnings[0].column, 19);
322 }
323
324 #[test]
325 fn test_md011_no_false_positive_with_footnote() {
326 let rule = MD011NoReversedLinks;
327
328 let content = "Some text with [a link](https://example.com/)[^ft].\n\n[^ft]: Note.\n";
331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
332 let warnings = rule.check(&ctx).unwrap();
333 assert_eq!(warnings.len(), 0);
334
335 let content = "[link1](url1)[^1] and [link2](url2)[^2]\n\n[^1]: First\n[^2]: Second\n";
337 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
338 let warnings = rule.check(&ctx).unwrap();
339 assert_eq!(warnings.len(), 0);
340
341 let content = "(url)[text] and [link](url)[^footnote]\n";
343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
344 let warnings = rule.check(&ctx).unwrap();
345 assert_eq!(warnings.len(), 1);
346 assert_eq!(warnings[0].line, 1);
347 assert_eq!(warnings[0].column, 1);
348 }
349
350 #[test]
351 fn test_md011_skip_dataview_inline_fields_obsidian() {
352 let rule = MD011NoReversedLinks;
353
354 let content = "(status:: active)[link text]\n";
357 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
358 let warnings = rule.check(&ctx).unwrap();
359 assert_eq!(
360 warnings.len(),
361 0,
362 "Should not flag Dataview inline field in Obsidian flavor"
363 );
364
365 let content = "(author:: John)[read more] and (date:: 2024-01-01)[link]\n";
367 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
368 let warnings = rule.check(&ctx).unwrap();
369 assert_eq!(warnings.len(), 0, "Should not flag multiple Dataview inline fields");
370
371 let content = "(status:: done)[info] (url)[text]\n";
373 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
374 let warnings = rule.check(&ctx).unwrap();
375 assert_eq!(warnings.len(), 1, "Should flag reversed link but not Dataview field");
376 assert_eq!(warnings[0].column, 23);
377 }
378
379 #[test]
380 fn test_md011_flag_dataview_in_standard_flavor() {
381 let rule = MD011NoReversedLinks;
382
383 let content = "(status:: active)[link text]\n";
386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
387 let warnings = rule.check(&ctx).unwrap();
388 assert_eq!(
389 warnings.len(),
390 1,
391 "Should flag Dataview-like pattern in Standard flavor"
392 );
393 }
394
395 #[test]
396 fn test_md011_dataview_bracket_syntax_obsidian() {
397 let rule = MD011NoReversedLinks;
398
399 let content = "Task has (priority:: high)[see details]\n";
402 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
403 let warnings = rule.check(&ctx).unwrap();
404 assert_eq!(warnings.len(), 0, "Should skip Dataview field with spaces");
405
406 let content = "(completed::)[marker]\n";
408 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
409 let warnings = rule.check(&ctx).unwrap();
410 assert_eq!(warnings.len(), 0, "Should skip Dataview field with empty value");
411 }
412
413 #[test]
414 fn test_md011_fix_skips_obsidian_comments() {
415 let rule = MD011NoReversedLinks;
416
417 let content = "%%\n(http://example.com)[hidden link]\n%%\n";
419 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
420
421 let warnings = rule.check(&ctx).unwrap();
423 assert_eq!(warnings.len(), 0, "check() should skip Obsidian comment content");
424
425 let fixed = rule.fix(&ctx).unwrap();
427 assert_eq!(
428 fixed, content,
429 "fix() should not modify reversed links inside Obsidian comments"
430 );
431 }
432
433 #[test]
434 fn test_md011_fix_skips_obsidian_comments_with_surrounding_content() {
435 let rule = MD011NoReversedLinks;
436
437 let content = "%%\n(http://example.com)[hidden]\n%%\n\n(http://real.com)[visible]\n";
439 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
440
441 let warnings = rule.check(&ctx).unwrap();
443 assert_eq!(warnings.len(), 1, "check() should only flag visible reversed link");
444 assert_eq!(warnings[0].line, 5);
445
446 let fixed = rule.fix(&ctx).unwrap();
448 assert_eq!(
449 fixed, "%%\n(http://example.com)[hidden]\n%%\n\n[visible](http://real.com)\n",
450 "fix() should only modify visible reversed links"
451 );
452 }
453
454 #[test]
455 fn test_md011_fix_skips_dataview_fields_obsidian() {
456 let rule = MD011NoReversedLinks;
457
458 let content = "(status:: active)[link text]\n(http://example.com)[real link]\n";
460 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
461
462 let warnings = rule.check(&ctx).unwrap();
463 assert_eq!(warnings.len(), 1, "check() should only flag the real reversed link");
464
465 let fixed = rule.fix(&ctx).unwrap();
466 assert_eq!(
467 fixed, "(status:: active)[link text]\n[real link](http://example.com)\n",
468 "fix() should not modify Dataview inline fields"
469 );
470 }
471}