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, 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
10// Reversed link detection pattern
11const REVERSED_LINK_REGEX_STR: &str = r"(^|[^\\])\(([^()]+)\)\[([^\]]+)\]";
12
13/// Classification of a link component
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15enum LinkComponent {
16    /// Clear URL: has protocol, www., mailto:, or path prefix
17    ClearUrl,
18    /// Multiple words or sentence-like (likely link text, not URL)
19    MultiWord,
20    /// Single word - could be either URL or text
21    Ambiguous,
22}
23
24/// Information about a detected reversed link pattern
25#[derive(Debug, Clone)]
26struct ReversedLinkInfo {
27    /// Content found in parentheses
28    paren_content: String,
29    /// Content found in square brackets
30    bracket_content: String,
31    /// Classification of parentheses content
32    paren_type: LinkComponent,
33    /// Classification of bracket content
34    bracket_type: LinkComponent,
35}
36
37impl ReversedLinkInfo {
38    /// Determine the correct order: returns (text, url)
39    fn correct_order(&self) -> (&str, &str) {
40        use LinkComponent::*;
41
42        match (self.paren_type, self.bracket_type) {
43            // One side is clearly a URL - that's the URL
44            (ClearUrl, _) => (&self.bracket_content, &self.paren_content),
45            (_, ClearUrl) => (&self.paren_content, &self.bracket_content),
46
47            // One side is multi-word - that's the text, other is URL
48            (MultiWord, _) => (&self.paren_content, &self.bracket_content),
49            (_, MultiWord) => (&self.bracket_content, &self.paren_content),
50
51            // Both ambiguous: assume standard reversed pattern (url)[text]
52            (Ambiguous, Ambiguous) => (&self.bracket_content, &self.paren_content),
53        }
54    }
55}
56
57#[derive(Clone)]
58pub struct MD011NoReversedLinks;
59
60impl MD011NoReversedLinks {
61    /// Classify a link component as URL, multi-word text, or ambiguous
62    fn classify_component(s: &str) -> LinkComponent {
63        let trimmed = s.trim();
64
65        // Check for clear URL indicators
66        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        // Multi-word text is likely a description, not a URL
79        if trimmed.contains(' ') {
80            return LinkComponent::MultiWord;
81        }
82
83        // Single word - could be either
84        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        // Use filtered_lines() to automatically skip front-matter and Obsidian comments
103        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                // Skip wiki-link patterns: if bracket content starts with [ or ends with ]
121                // This handles cases like (url)[[wiki-link]] being misdetected
122                if bracket_content.starts_with('[') || bracket_content.ends_with(']') {
123                    last_end += match_obj.end();
124                    continue;
125                }
126
127                // Skip footnote references: [^footnote]
128                // This prevents false positives like [link](url)[^footnote]
129                if bracket_content.starts_with('^') {
130                    last_end += match_obj.end();
131                    continue;
132                }
133
134                // Skip Dataview inline fields in Obsidian flavor
135                // Pattern: (field:: value)[text] is valid Obsidian syntax, not a reversed link
136                if ctx.flavor == crate::config::MarkdownFlavor::Obsidian && paren_content.contains("::") {
137                    last_end += match_obj.end();
138                    continue;
139                }
140
141                // Check if the brackets at the end are escaped
142                if bracket_content.ends_with('\\') {
143                    last_end += match_obj.end();
144                    continue;
145                }
146
147                // Manual negative lookahead: skip if followed by (
148                // This prevents matching (text)[ref](url) patterns
149                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                // Calculate the actual position
156                let match_start = last_end + match_obj.start() + prechar.len();
157                let match_byte_pos = byte_pos + match_start;
158
159                // Skip if in code block, inline code, HTML comments, math contexts, or Jinja templates
160                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                // Classify both components and determine correct order
170                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                // Calculate the range for the actual reversed link (excluding prechar)
183                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        // Apply fixes in reverse order to preserve byte offsets
220        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        // Should detect reversed links
257        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        // Should not detect correct links
264        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        // Should not detect if brackets are escaped
275        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        // Should not detect (text)[ref](url) as reversed
286        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        // Should not detect [link](url)[^footnote] as reversed - this is valid markdown
329        // The [^footnote] is a footnote reference, not part of a reversed link
330        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        // Also test with multiple footnotes
336        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        // But should still detect actual reversed links
342        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        // Dataview inline field pattern: (field:: value)[text]
355        // In Obsidian flavor, this should NOT be flagged as a reversed link
356        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        // Multiple inline fields
366        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        // Mixed content: Dataview field and actual reversed link
372        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        // In Standard flavor, (field:: value)[text] is treated as a reversed link
384        // because Dataview is Obsidian-specific
385        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        // Dataview also supports [field:: value] syntax inside brackets
400        // The pattern (field:: value)[text] should be skipped in Obsidian
401        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        // Field with no value (just key::)
407        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        // Reversed link inside Obsidian comment block should not be modified by fix()
418        let content = "%%\n(http://example.com)[hidden link]\n%%\n";
419        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
420
421        // check() should produce no warnings (Obsidian comment is skipped)
422        let warnings = rule.check(&ctx).unwrap();
423        assert_eq!(warnings.len(), 0, "check() should skip Obsidian comment content");
424
425        // fix() should not modify content inside Obsidian comments
426        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        // Mix of Obsidian comment and real reversed link
438        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        // check() should only flag the visible one
442        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        // fix() should only fix the visible one, leaving comment content untouched
447        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        // Dataview inline field should not be modified by fix()
459        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}