Skip to main content

rumdl_lib/rules/
md011_no_reversed_links.rs

1/// Rule MD011: No reversed link syntax
2///
3/// See [docs/md011.md](../../docs/md011.md) for full documentation, configuration, and examples.
4use 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
11// Reversed link detection pattern
12const REVERSED_LINK_REGEX_STR: &str = r"(^|[^\\])\(([^()]+)\)\[([^\]]+)\]";
13static REVERSED_LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(REVERSED_LINK_REGEX_STR).unwrap());
14
15/// Classification of a link component
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17enum LinkComponent {
18    /// Clear URL: has protocol, www., mailto:, or path prefix
19    ClearUrl,
20    /// Multiple words or sentence-like (likely link text, not URL)
21    MultiWord,
22    /// Single word - could be either URL or text
23    Ambiguous,
24}
25
26/// Information about a detected reversed link pattern
27#[derive(Debug, Clone)]
28struct ReversedLinkInfo {
29    /// Content found in parentheses
30    paren_content: String,
31    /// Content found in square brackets
32    bracket_content: String,
33    /// Classification of parentheses content
34    paren_type: LinkComponent,
35    /// Classification of bracket content
36    bracket_type: LinkComponent,
37}
38
39impl ReversedLinkInfo {
40    /// Determine the correct order: returns (text, url)
41    fn correct_order(&self) -> (&str, &str) {
42        use LinkComponent::{Ambiguous, ClearUrl, MultiWord};
43
44        match (self.paren_type, self.bracket_type) {
45            // One side is clearly a URL - that's the URL
46            (ClearUrl, _) => (&self.bracket_content, &self.paren_content),
47            (_, ClearUrl) => (&self.paren_content, &self.bracket_content),
48
49            // One side is multi-word - that's the text, other is URL
50            (MultiWord, _) => (&self.paren_content, &self.bracket_content),
51            (_, MultiWord) => (&self.bracket_content, &self.paren_content),
52
53            // Both ambiguous: assume standard reversed pattern (url)[text]
54            (Ambiguous, Ambiguous) => (&self.bracket_content, &self.paren_content),
55        }
56    }
57}
58
59#[derive(Clone)]
60pub struct MD011NoReversedLinks;
61
62impl MD011NoReversedLinks {
63    /// Classify a link component as URL, multi-word text, or ambiguous
64    fn classify_component(s: &str) -> LinkComponent {
65        let trimmed = s.trim();
66
67        // Check for clear URL indicators
68        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        // Multi-word text is likely a description, not a URL
81        if trimmed.contains(' ') {
82            return LinkComponent::MultiWord;
83        }
84
85        // Single word - could be either
86        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        // Use filtered_lines() to automatically skip front-matter and Obsidian comments
109        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                // Skip wiki-link patterns: if bracket content starts with [ or ends with ]
130                // This handles cases like (url)[[wiki-link]] being misdetected
131                if bracket_content.starts_with('[') || bracket_content.ends_with(']') {
132                    last_end += match_obj.end();
133                    continue;
134                }
135
136                // Skip footnote references: [^footnote]
137                // This prevents false positives like [link](url)[^footnote]
138                if bracket_content.starts_with('^') {
139                    last_end += match_obj.end();
140                    continue;
141                }
142
143                // Skip Dataview inline fields in Obsidian flavor
144                // Pattern: (field:: value)[text] is valid Obsidian syntax, not a reversed link
145                if ctx.flavor == crate::config::MarkdownFlavor::Obsidian && paren_content.contains("::") {
146                    last_end += match_obj.end();
147                    continue;
148                }
149
150                // Check if the brackets at the end are escaped
151                if bracket_content.ends_with('\\') {
152                    last_end += match_obj.end();
153                    continue;
154                }
155
156                // Manual negative lookahead: skip if followed by (
157                // This prevents matching (text)[ref](url) patterns
158                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                // Calculate the actual position
165                let match_start = last_end + match_obj.start() + prechar.len();
166                let match_byte_pos = byte_pos + match_start;
167
168                // Skip if in code block, inline code, HTML comments, math contexts, or Jinja templates
169                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                // Classify both components and determine correct order
180                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                // Calculate the range for the actual reversed link (excluding prechar)
193                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        // Apply fixes in reverse order to preserve byte offsets
232        let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
233        fixes.sort_by(|a, b| b.range.start.cmp(&a.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        // Should detect reversed links
269        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        // Should not detect correct links
276        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        // Should not detect if brackets are escaped
287        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        // Should not detect (text)[ref](url) as reversed
298        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        // Should not detect [link](url)[^footnote] as reversed - this is valid markdown
341        // The [^footnote] is a footnote reference, not part of a reversed link
342        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        // Also test with multiple footnotes
348        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        // But should still detect actual reversed links
354        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        // Dataview inline field pattern: (field:: value)[text]
367        // In Obsidian flavor, this should NOT be flagged as a reversed link
368        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        // Multiple inline fields
378        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        // Mixed content: Dataview field and actual reversed link
384        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        // In Standard flavor, (field:: value)[text] is treated as a reversed link
396        // because Dataview is Obsidian-specific
397        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_dataview_bracket_syntax_obsidian() {
409        let rule = MD011NoReversedLinks;
410
411        // Dataview also supports [field:: value] syntax inside brackets
412        // The pattern (field:: value)[text] should be skipped in Obsidian
413        let content = "Task has (priority:: high)[see details]\n";
414        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
415        let warnings = rule.check(&ctx).unwrap();
416        assert_eq!(warnings.len(), 0, "Should skip Dataview field with spaces");
417
418        // Field with no value (just key::)
419        let content = "(completed::)[marker]\n";
420        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
421        let warnings = rule.check(&ctx).unwrap();
422        assert_eq!(warnings.len(), 0, "Should skip Dataview field with empty value");
423    }
424
425    #[test]
426    fn test_md011_fix_skips_obsidian_comments() {
427        let rule = MD011NoReversedLinks;
428
429        // Reversed link inside Obsidian comment block should not be modified by fix()
430        let content = "%%\n(http://example.com)[hidden link]\n%%\n";
431        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
432
433        // check() should produce no warnings (Obsidian comment is skipped)
434        let warnings = rule.check(&ctx).unwrap();
435        assert_eq!(warnings.len(), 0, "check() should skip Obsidian comment content");
436
437        // fix() should not modify content inside Obsidian comments
438        let fixed = rule.fix(&ctx).unwrap();
439        assert_eq!(
440            fixed, content,
441            "fix() should not modify reversed links inside Obsidian comments"
442        );
443    }
444
445    #[test]
446    fn test_md011_fix_skips_obsidian_comments_with_surrounding_content() {
447        let rule = MD011NoReversedLinks;
448
449        // Mix of Obsidian comment and real reversed link
450        let content = "%%\n(http://example.com)[hidden]\n%%\n\n(http://real.com)[visible]\n";
451        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
452
453        // check() should only flag the visible one
454        let warnings = rule.check(&ctx).unwrap();
455        assert_eq!(warnings.len(), 1, "check() should only flag visible reversed link");
456        assert_eq!(warnings[0].line, 5);
457
458        // fix() should only fix the visible one, leaving comment content untouched
459        let fixed = rule.fix(&ctx).unwrap();
460        assert_eq!(
461            fixed, "%%\n(http://example.com)[hidden]\n%%\n\n[visible](http://real.com)\n",
462            "fix() should only modify visible reversed links"
463        );
464    }
465
466    #[test]
467    fn test_md011_fix_skips_dataview_fields_obsidian() {
468        let rule = MD011NoReversedLinks;
469
470        // Dataview inline field should not be modified by fix()
471        let content = "(status:: active)[link text]\n(http://example.com)[real link]\n";
472        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
473
474        let warnings = rule.check(&ctx).unwrap();
475        assert_eq!(warnings.len(), 1, "check() should only flag the real reversed link");
476
477        let fixed = rule.fix(&ctx).unwrap();
478        assert_eq!(
479            fixed, "(status:: active)[link text]\n[real link](http://example.com)\n",
480            "fix() should not modify Dataview inline fields"
481        );
482    }
483}