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