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 let line_index = &ctx.line_index;
107
108 for filtered_line in ctx
110 .filtered_lines()
111 .skip_front_matter()
112 .skip_jsx_expressions()
113 .skip_mdx_comments()
114 .skip_obsidian_comments()
115 {
116 let line_num = filtered_line.line_num;
117 let line = filtered_line.content;
118
119 let byte_pos = line_index.get_line_start_byte(line_num).unwrap_or(0);
120
121 let mut last_end = 0;
122
123 while let Some(cap) = REVERSED_LINK_REGEX.captures(&line[last_end..]) {
124 let match_obj = cap.get(0).unwrap();
125 let prechar = &cap[1];
126 let paren_content = cap[2].to_string();
127 let bracket_content = cap[3].to_string();
128
129 if bracket_content.starts_with('[') || bracket_content.ends_with(']') {
132 last_end += match_obj.end();
133 continue;
134 }
135
136 if bracket_content.starts_with('^') {
139 last_end += match_obj.end();
140 continue;
141 }
142
143 if ctx.flavor == crate::config::MarkdownFlavor::Obsidian && paren_content.contains("::") {
146 last_end += match_obj.end();
147 continue;
148 }
149
150 if bracket_content.ends_with('\\') {
152 last_end += match_obj.end();
153 continue;
154 }
155
156 let end_pos = last_end + match_obj.end();
159 if end_pos < line.len() && line[end_pos..].starts_with('(') {
160 last_end += match_obj.end();
161 continue;
162 }
163
164 let match_start = last_end + match_obj.start() + prechar.len();
166 let match_byte_pos = byte_pos + match_start;
167
168 if ctx.is_in_code_block_or_span(match_byte_pos)
170 || ctx.is_in_html_comment(match_byte_pos)
171 || ctx.is_in_mdx_comment(match_byte_pos)
172 || is_in_math_context(ctx, match_byte_pos)
173 || ctx.is_in_jinja_range(match_byte_pos)
174 {
175 last_end += match_obj.end();
176 continue;
177 }
178
179 let paren_type = Self::classify_component(&paren_content);
181 let bracket_type = Self::classify_component(&bracket_content);
182
183 let info = ReversedLinkInfo {
184 paren_content,
185 bracket_content,
186 paren_type,
187 bracket_type,
188 };
189
190 let (text, url) = info.correct_order();
191
192 let actual_length = match_obj.len() - prechar.len();
194 let (start_line, start_col, end_line, end_col) =
195 calculate_match_range(line_num, line, match_start, actual_length);
196
197 warnings.push(LintWarning {
198 rule_name: Some(self.name().to_string()),
199 message: format!("Reversed link syntax: use [{text}]({url}) instead"),
200 line: start_line,
201 column: start_col,
202 end_line,
203 end_column: end_col,
204 severity: Severity::Error,
205 fix: Some(Fix::new(
206 {
207 let match_start_byte = byte_pos + match_start;
208 let match_end_byte = match_start_byte + actual_length;
209 match_start_byte..match_end_byte
210 },
211 format!("[{text}]({url})"),
212 )),
213 });
214
215 last_end += match_obj.end();
216 }
217 }
218
219 Ok(warnings)
220 }
221
222 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
223 let warnings = self.check(ctx)?;
224 let warnings =
225 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
226 if warnings.is_empty() {
227 return Ok(ctx.content.to_string());
228 }
229
230 let mut content = ctx.content.to_string();
231 let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
233 fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
234
235 for fix in fixes {
236 if fix.range.start < content.len() && fix.range.end <= content.len() {
237 content.replace_range(fix.range.clone(), &fix.replacement);
238 }
239 }
240 Ok(content)
241 }
242
243 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
244 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
245 }
246
247 fn as_any(&self) -> &dyn std::any::Any {
248 self
249 }
250
251 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
252 where
253 Self: Sized,
254 {
255 Box::new(MD011NoReversedLinks)
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 use crate::lint_context::LintContext;
263
264 #[test]
265 fn test_md011_basic() {
266 let rule = MD011NoReversedLinks;
267
268 let content = "(http://example.com)[Example]\n";
270 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
271 let warnings = rule.check(&ctx).unwrap();
272 assert_eq!(warnings.len(), 1);
273 assert_eq!(warnings[0].line, 1);
274
275 let content = "[Example](http://example.com)\n";
277 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
278 let warnings = rule.check(&ctx).unwrap();
279 assert_eq!(warnings.len(), 0);
280 }
281
282 #[test]
283 fn test_md011_with_escaped_brackets() {
284 let rule = MD011NoReversedLinks;
285
286 let content = "(url)[text\\]\n";
288 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
289 let warnings = rule.check(&ctx).unwrap();
290 assert_eq!(warnings.len(), 0);
291 }
292
293 #[test]
294 fn test_md011_no_false_positive_with_reference_link() {
295 let rule = MD011NoReversedLinks;
296
297 let content = "(text)[ref](url)\n";
299 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
300 let warnings = rule.check(&ctx).unwrap();
301 assert_eq!(warnings.len(), 0);
302 }
303
304 #[test]
305 fn test_md011_fix() {
306 let rule = MD011NoReversedLinks;
307
308 let content = "(http://example.com)[Example]\n(another/url)[text]\n";
309 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
310 let fixed = rule.fix(&ctx).unwrap();
311 assert_eq!(fixed, "[Example](http://example.com)\n[text](another/url)\n");
312 }
313
314 #[test]
315 fn test_md011_in_code_block() {
316 let rule = MD011NoReversedLinks;
317
318 let content = "```\n(url)[text]\n```\n(url)[text]\n";
319 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
320 let warnings = rule.check(&ctx).unwrap();
321 assert_eq!(warnings.len(), 1);
322 assert_eq!(warnings[0].line, 4);
323 }
324
325 #[test]
326 fn test_md011_inline_code() {
327 let rule = MD011NoReversedLinks;
328
329 let content = "`(url)[text]` and (url)[text]\n";
330 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
331 let warnings = rule.check(&ctx).unwrap();
332 assert_eq!(warnings.len(), 1);
333 assert_eq!(warnings[0].column, 19);
334 }
335
336 #[test]
337 fn test_md011_no_false_positive_with_footnote() {
338 let rule = MD011NoReversedLinks;
339
340 let content = "Some text with [a link](https://example.com/)[^ft].\n\n[^ft]: Note.\n";
343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
344 let warnings = rule.check(&ctx).unwrap();
345 assert_eq!(warnings.len(), 0);
346
347 let content = "[link1](url1)[^1] and [link2](url2)[^2]\n\n[^1]: First\n[^2]: Second\n";
349 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
350 let warnings = rule.check(&ctx).unwrap();
351 assert_eq!(warnings.len(), 0);
352
353 let content = "(url)[text] and [link](url)[^footnote]\n";
355 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
356 let warnings = rule.check(&ctx).unwrap();
357 assert_eq!(warnings.len(), 1);
358 assert_eq!(warnings[0].line, 1);
359 assert_eq!(warnings[0].column, 1);
360 }
361
362 #[test]
363 fn test_md011_skip_dataview_inline_fields_obsidian() {
364 let rule = MD011NoReversedLinks;
365
366 let content = "(status:: active)[link text]\n";
369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
370 let warnings = rule.check(&ctx).unwrap();
371 assert_eq!(
372 warnings.len(),
373 0,
374 "Should not flag Dataview inline field in Obsidian flavor"
375 );
376
377 let content = "(author:: John)[read more] and (date:: 2024-01-01)[link]\n";
379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
380 let warnings = rule.check(&ctx).unwrap();
381 assert_eq!(warnings.len(), 0, "Should not flag multiple Dataview inline fields");
382
383 let content = "(status:: done)[info] (url)[text]\n";
385 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
386 let warnings = rule.check(&ctx).unwrap();
387 assert_eq!(warnings.len(), 1, "Should flag reversed link but not Dataview field");
388 assert_eq!(warnings[0].column, 23);
389 }
390
391 #[test]
392 fn test_md011_flag_dataview_in_standard_flavor() {
393 let rule = MD011NoReversedLinks;
394
395 let content = "(status:: active)[link text]\n";
398 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
399 let warnings = rule.check(&ctx).unwrap();
400 assert_eq!(
401 warnings.len(),
402 1,
403 "Should flag Dataview-like pattern in Standard flavor"
404 );
405 }
406
407 #[test]
408 fn test_md011_reversed_link_in_jsx_nested_fence_not_flagged() {
409 let rule = MD011NoReversedLinks;
415 let content =
416 "<Steps>\n <Step>\n```text\nsee (this)[https://example.com] reversed\n```\n </Step>\n</Steps>\n";
417 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
418 let warnings = rule.check(&ctx).unwrap();
419 assert!(
420 warnings.is_empty(),
421 "reversed link inside a JSX-nested fence must not be flagged: {warnings:?}"
422 );
423 }
424
425 #[test]
426 fn test_md011_dataview_bracket_syntax_obsidian() {
427 let rule = MD011NoReversedLinks;
428
429 let content = "Task has (priority:: high)[see details]\n";
432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
433 let warnings = rule.check(&ctx).unwrap();
434 assert_eq!(warnings.len(), 0, "Should skip Dataview field with spaces");
435
436 let content = "(completed::)[marker]\n";
438 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
439 let warnings = rule.check(&ctx).unwrap();
440 assert_eq!(warnings.len(), 0, "Should skip Dataview field with empty value");
441 }
442
443 #[test]
444 fn test_md011_fix_skips_obsidian_comments() {
445 let rule = MD011NoReversedLinks;
446
447 let content = "%%\n(http://example.com)[hidden link]\n%%\n";
449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
450
451 let warnings = rule.check(&ctx).unwrap();
453 assert_eq!(warnings.len(), 0, "check() should skip Obsidian comment content");
454
455 let fixed = rule.fix(&ctx).unwrap();
457 assert_eq!(
458 fixed, content,
459 "fix() should not modify reversed links inside Obsidian comments"
460 );
461 }
462
463 #[test]
464 fn test_md011_fix_skips_obsidian_comments_with_surrounding_content() {
465 let rule = MD011NoReversedLinks;
466
467 let content = "%%\n(http://example.com)[hidden]\n%%\n\n(http://real.com)[visible]\n";
469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
470
471 let warnings = rule.check(&ctx).unwrap();
473 assert_eq!(warnings.len(), 1, "check() should only flag visible reversed link");
474 assert_eq!(warnings[0].line, 5);
475
476 let fixed = rule.fix(&ctx).unwrap();
478 assert_eq!(
479 fixed, "%%\n(http://example.com)[hidden]\n%%\n\n[visible](http://real.com)\n",
480 "fix() should only modify visible reversed links"
481 );
482 }
483
484 #[test]
485 fn test_md011_fix_skips_dataview_fields_obsidian() {
486 let rule = MD011NoReversedLinks;
487
488 let content = "(status:: active)[link text]\n(http://example.com)[real link]\n";
490 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
491
492 let warnings = rule.check(&ctx).unwrap();
493 assert_eq!(warnings.len(), 1, "check() should only flag the real reversed link");
494
495 let fixed = rule.fix(&ctx).unwrap();
496 assert_eq!(
497 fixed, "(status:: active)[link text]\n[real link](http://example.com)\n",
498 "fix() should not modify Dataview inline fields"
499 );
500 }
501}