rumdl_lib/rules/
md042_no_empty_links.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::mkdocs_patterns::is_mkdocs_auto_reference;
3
4/// Rule MD042: No empty links
5///
6/// See [docs/md042.md](../../docs/md042.md) for full documentation, configuration, and examples.
7///
8/// This rule is triggered when a link has no content (text) or destination (URL).
9///
10/// # MkDocs Support
11///
12/// When `flavor = "mkdocs"` is configured, this rule recognizes two types of valid MkDocs patterns:
13///
14/// ## 1. Auto-References (via mkdocs-autorefs / mkdocstrings)
15///
16/// Backtick-wrapped Python identifiers used for cross-referencing:
17/// ```markdown
18/// [`module.Class`][]     // Python class reference
19/// [`str`][]              // Built-in type reference
20/// [`api.function`][]     // Function reference
21/// ```
22///
23/// **References:**
24/// - [mkdocs-autorefs](https://mkdocstrings.github.io/autorefs/)
25/// - [mkdocstrings](https://mkdocstrings.github.io/)
26///
27/// ## 2. Paragraph Anchors (via Python-Markdown attr_list extension)
28///
29/// Empty links combined with attributes to create anchor points:
30/// ```markdown
31/// [](){ #my-anchor }              // Basic anchor
32/// [](){ #anchor .class }          // Anchor with CSS class
33/// [](){: #anchor }                // With colon (canonical attr_list syntax)
34/// [](){ .class1 .class2 }         // Classes only
35/// ```
36///
37/// This syntax combines:
38/// - Empty link `[]()` → creates `<a href=""></a>`
39/// - attr_list syntax `{ #id }` → adds attributes to preceding element
40/// - Result: `<a href="" id="my-anchor"></a>`
41///
42/// **References:**
43/// - [Python-Markdown attr_list](https://python-markdown.github.io/extensions/attr_list/)
44/// - [MkDocs discussion](https://github.com/mkdocs/mkdocs/discussions/3754)
45///
46/// **Implementation:** See [`is_mkdocs_attribute_anchor`](Self::is_mkdocs_attribute_anchor)
47#[derive(Clone, Default)]
48pub struct MD042NoEmptyLinks {}
49
50impl MD042NoEmptyLinks {
51    pub fn new() -> Self {
52        Self {}
53    }
54
55    /// Strip surrounding backticks from a string
56    /// Used for MkDocs auto-reference detection where `module.Class` should be treated as module.Class
57    fn strip_backticks(s: &str) -> &str {
58        s.trim_start_matches('`').trim_end_matches('`')
59    }
60
61    /// Check if a string is a valid Python identifier
62    /// Python identifiers can contain alphanumeric characters and underscores, but cannot start with a digit
63    fn is_valid_python_identifier(s: &str) -> bool {
64        if s.is_empty() {
65            return false;
66        }
67
68        let first_char = s.chars().next().unwrap();
69        if !first_char.is_ascii_alphabetic() && first_char != '_' {
70            return false;
71        }
72
73        s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
74    }
75
76    /// Check if an empty link is followed by MkDocs attribute syntax
77    /// Pattern: []() followed by { #anchor } or { #anchor .class }
78    ///
79    /// This validates the Python-Markdown attr_list extension syntax when applied to empty links.
80    /// Empty links `[]()` combined with attributes like `{ #anchor }` create anchor points in
81    /// documentation, as documented by mkdocs-autorefs and the attr_list extension.
82    fn is_mkdocs_attribute_anchor(content: &str, link_end: usize) -> bool {
83        // UTF-8 safety: Validate byte position is at character boundary
84        if !content.is_char_boundary(link_end) {
85            return false;
86        }
87
88        // Get the content after the link
89        if let Some(rest) = content.get(link_end..) {
90            // Trim whitespace and check if it starts with {
91            // Note: trim_start() removes all whitespace including newlines
92            // This is intentionally permissive to match real-world MkDocs usage
93            let trimmed = rest.trim_start();
94
95            // Check for opening brace (with optional colon per attr_list spec)
96            let stripped = if let Some(s) = trimmed.strip_prefix("{:") {
97                s
98            } else if let Some(s) = trimmed.strip_prefix('{') {
99                s
100            } else {
101                return false;
102            };
103
104            // Look for closing brace
105            if let Some(end_brace) = stripped.find('}') {
106                // DoS prevention: Limit attribute section length
107                if end_brace > 500 {
108                    return false;
109                }
110
111                let attrs = stripped[..end_brace].trim();
112
113                // Empty attributes should not be considered valid
114                if attrs.is_empty() {
115                    return false;
116                }
117
118                // Check if it contains an anchor (starts with #) or class (starts with .)
119                // Valid patterns: { #anchor }, { #anchor .class }, { .class #anchor }
120                // At least one attribute starting with # or . is required
121                return attrs
122                    .split_whitespace()
123                    .any(|part| part.starts_with('#') || part.starts_with('.'));
124            }
125        }
126        false
127    }
128}
129
130impl Rule for MD042NoEmptyLinks {
131    fn name(&self) -> &'static str {
132        "MD042"
133    }
134
135    fn description(&self) -> &'static str {
136        "No empty links"
137    }
138
139    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
140        let mut warnings = Vec::new();
141
142        // Check if we're in MkDocs mode from the context
143        let mkdocs_mode = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
144
145        // Use centralized link parsing from LintContext
146        for link in &ctx.links {
147            // Skip links inside Jinja templates
148            if ctx.is_in_jinja_range(link.byte_offset) {
149                continue;
150            }
151
152            // Skip links inside HTML tags (e.g., <a href="...?p[images][0]=...">)
153            // Check if the link's byte position falls within any HTML tag range
154            let in_html_tag = ctx
155                .html_tags()
156                .iter()
157                .any(|html_tag| html_tag.byte_offset <= link.byte_offset && link.byte_offset < html_tag.byte_end);
158            if in_html_tag {
159                continue;
160            }
161
162            // For reference links, resolve the URL
163            let effective_url: &str = if link.is_reference {
164                if let Some(ref_id) = &link.reference_id {
165                    ctx.get_reference_url(ref_id.as_ref()).unwrap_or("")
166                } else {
167                    ""
168                }
169            } else {
170                &link.url
171            };
172
173            // For MkDocs mode, check if this looks like an auto-reference
174            // Note: We check both the reference_id AND the text since shorthand references
175            // like [class.Name][] use the text as the implicit reference
176            // Also strip backticks since MkDocs resolves `module.Class` as module.Class
177            if mkdocs_mode && link.is_reference {
178                // Check the reference_id if present (strip backticks first)
179                if let Some(ref_id) = &link.reference_id {
180                    let stripped_ref = Self::strip_backticks(ref_id);
181                    // Accept if it matches MkDocs patterns OR if it's a backtick-wrapped valid identifier
182                    // Backticks indicate code/type reference (like `str`, `int`, `MyClass`)
183                    if is_mkdocs_auto_reference(stripped_ref)
184                        || (ref_id != stripped_ref && Self::is_valid_python_identifier(stripped_ref))
185                    {
186                        continue;
187                    }
188                }
189                // Also check the link text itself for shorthand references (strip backticks)
190                let stripped_text = Self::strip_backticks(&link.text);
191                // Accept if it matches MkDocs patterns OR if it's a backtick-wrapped valid identifier
192                if is_mkdocs_auto_reference(stripped_text)
193                    || (link.text.as_ref() != stripped_text && Self::is_valid_python_identifier(stripped_text))
194                {
195                    continue;
196                }
197            }
198
199            // Skip autolinks (like <https://example.com>)
200            // Autolinks are valid CommonMark syntax: <URL> where text field is empty but URL is the display
201            // Detect by checking if source markdown is wrapped in < and >
202            let link_markdown = &ctx.content[link.byte_offset..link.byte_end];
203            if link_markdown.starts_with('<') && link_markdown.ends_with('>') {
204                continue;
205            }
206
207            // Skip wiki-style links (Obsidian/Notion syntax: [[Page Name]] or [[Page|Display]])
208            // Wiki links are valid syntax and should never be flagged as "empty links".
209            // This covers all wiki link patterns including:
210            // - Basic: [[Page Name]]
211            // - With path: [[Folder/Page]]
212            // - With alias: [[Page|Display Text]]
213            // - With heading: [[Page#heading]]
214            // - Block references: [[Page#^block-id]] or [[#^block-id]]
215            //
216            // Detection: pulldown-cmark captures [[Example] as bytes 0..10, with trailing ] at byte 10
217            // We check: starts with "[[" AND the char after byte_end is "]"
218            if link_markdown.starts_with("[[")
219                && link_markdown.ends_with(']')
220                && ctx.content.as_bytes().get(link.byte_end) == Some(&b']')
221            {
222                continue;
223            }
224
225            // Check for empty links
226            if link.text.trim().is_empty() || effective_url.trim().is_empty() {
227                // In MkDocs mode, check if this is an attribute anchor: []() followed by { #anchor }
228                if mkdocs_mode
229                    && link.text.trim().is_empty()
230                    && effective_url.trim().is_empty()
231                    && Self::is_mkdocs_attribute_anchor(ctx.content, link.byte_end)
232                {
233                    // This is a valid MkDocs attribute anchor, skip it
234                    continue;
235                }
236
237                // Determine if we can provide a meaningful fix
238                let replacement = if link.text.trim().is_empty() {
239                    // Empty text - can we fix it?
240                    if !effective_url.trim().is_empty() {
241                        // Has URL but no text - add placeholder text
242                        if link.is_reference {
243                            Some(format!(
244                                "[Link text]{}",
245                                &ctx.content[link.byte_offset + 1..link.byte_end]
246                            ))
247                        } else {
248                            Some(format!("[Link text]({effective_url})"))
249                        }
250                    } else {
251                        // Both empty - can't meaningfully auto-fix
252                        None
253                    }
254                } else if link.is_reference {
255                    // Reference links with text but no/empty reference - keep the format
256                    let ref_part = &ctx.content[link.byte_offset + link.text.len() + 2..link.byte_end];
257                    Some(format!("[{}]{}", link.text, ref_part))
258                } else {
259                    // URL is empty, but text is not
260                    // Check if the link text looks like a URL - if so, use it as the destination
261                    let text_is_url = link.text.starts_with("http://")
262                        || link.text.starts_with("https://")
263                        || link.text.starts_with("ftp://")
264                        || link.text.starts_with("ftps://");
265
266                    if text_is_url {
267                        Some(format!("[{}]({})", link.text, link.text))
268                    } else {
269                        // Text is not a URL - can't meaningfully auto-fix
270                        None
271                    }
272                };
273
274                // Extract the exact link text from the source
275                let link_display = &ctx.content[link.byte_offset..link.byte_end];
276
277                warnings.push(LintWarning {
278                    rule_name: Some(self.name().to_string()),
279                    message: format!("Empty link found: {link_display}"),
280                    line: link.line,
281                    column: link.start_col + 1, // Convert to 1-indexed
282                    end_line: link.line,
283                    end_column: link.end_col + 1, // Convert to 1-indexed
284                    severity: Severity::Warning,
285                    fix: replacement.map(|r| Fix {
286                        range: link.byte_offset..link.byte_end,
287                        replacement: r,
288                    }),
289                });
290            }
291        }
292
293        Ok(warnings)
294    }
295
296    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
297        let content = ctx.content;
298
299        // Get all warnings first - only fix links that are actually flagged
300        let warnings = self.check(ctx)?;
301        if warnings.is_empty() {
302            return Ok(content.to_string());
303        }
304
305        // Collect all fixes with their ranges
306        let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
307            .iter()
308            .filter_map(|w| w.fix.as_ref().map(|f| (f.range.clone(), f.replacement.clone())))
309            .collect();
310
311        // Sort fixes by position (descending) to apply from end to start
312        fixes.sort_by(|a, b| b.0.start.cmp(&a.0.start));
313
314        let mut result = content.to_string();
315
316        // Apply fixes from end to start to maintain correct positions
317        for (range, replacement) in fixes {
318            result.replace_range(range, &replacement);
319        }
320
321        Ok(result)
322    }
323
324    /// Get the category of this rule for selective processing
325    fn category(&self) -> RuleCategory {
326        RuleCategory::Link
327    }
328
329    /// Check if this rule should be skipped
330    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
331        ctx.content.is_empty() || !ctx.likely_has_links_or_images()
332    }
333
334    fn as_any(&self) -> &dyn std::any::Any {
335        self
336    }
337
338    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
339    where
340        Self: Sized,
341    {
342        // Flavor is now accessed from LintContext during check
343        Box::new(MD042NoEmptyLinks::new())
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crate::lint_context::LintContext;
351
352    #[test]
353    fn test_links_with_text_should_pass() {
354        let ctx = LintContext::new(
355            "[valid link](https://example.com)",
356            crate::config::MarkdownFlavor::Standard,
357            None,
358        );
359        let rule = MD042NoEmptyLinks::new();
360        let result = rule.check(&ctx).unwrap();
361        assert!(result.is_empty(), "Links with text should pass");
362
363        let ctx = LintContext::new(
364            "[another valid link](path/to/page.html)",
365            crate::config::MarkdownFlavor::Standard,
366            None,
367        );
368        let result = rule.check(&ctx).unwrap();
369        assert!(result.is_empty(), "Links with text and relative URLs should pass");
370    }
371
372    #[test]
373    fn test_links_with_empty_text_should_fail() {
374        let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
375        let rule = MD042NoEmptyLinks::new();
376        let result = rule.check(&ctx).unwrap();
377        assert_eq!(result.len(), 1);
378        assert_eq!(result[0].message, "Empty link found: [](https://example.com)");
379        assert_eq!(result[0].line, 1);
380        assert_eq!(result[0].column, 1);
381    }
382
383    #[test]
384    fn test_links_with_only_whitespace_should_fail() {
385        let ctx = LintContext::new(
386            "[   ](https://example.com)",
387            crate::config::MarkdownFlavor::Standard,
388            None,
389        );
390        let rule = MD042NoEmptyLinks::new();
391        let result = rule.check(&ctx).unwrap();
392        assert_eq!(result.len(), 1);
393        assert_eq!(result[0].message, "Empty link found: [   ](https://example.com)");
394
395        let ctx = LintContext::new(
396            "[\t\n](https://example.com)",
397            crate::config::MarkdownFlavor::Standard,
398            None,
399        );
400        let result = rule.check(&ctx).unwrap();
401        assert_eq!(result.len(), 1);
402        assert_eq!(result[0].message, "Empty link found: [\t\n](https://example.com)");
403    }
404
405    #[test]
406    fn test_reference_links_with_empty_text() {
407        let ctx = LintContext::new(
408            "[][ref]\n\n[ref]: https://example.com",
409            crate::config::MarkdownFlavor::Standard,
410            None,
411        );
412        let rule = MD042NoEmptyLinks::new();
413        let result = rule.check(&ctx).unwrap();
414        assert_eq!(result.len(), 1);
415        assert_eq!(result[0].message, "Empty link found: [][ref]");
416        assert_eq!(result[0].line, 1);
417
418        // Empty text with empty reference
419        let ctx = LintContext::new(
420            "[][]\n\n[]: https://example.com",
421            crate::config::MarkdownFlavor::Standard,
422            None,
423        );
424        let result = rule.check(&ctx).unwrap();
425        assert_eq!(result.len(), 1);
426    }
427
428    #[test]
429    fn test_images_should_be_ignored() {
430        // Images can have empty alt text, so they should not trigger the rule
431        let ctx = LintContext::new("![](image.png)", crate::config::MarkdownFlavor::Standard, None);
432        let rule = MD042NoEmptyLinks::new();
433        let result = rule.check(&ctx).unwrap();
434        assert!(result.is_empty(), "Images with empty alt text should be ignored");
435
436        let ctx = LintContext::new("![   ](image.png)", crate::config::MarkdownFlavor::Standard, None);
437        let result = rule.check(&ctx).unwrap();
438        assert!(result.is_empty(), "Images with whitespace alt text should be ignored");
439    }
440
441    #[test]
442    fn test_links_with_nested_formatting() {
443        // Links with nested formatting but empty effective text
444        // Note: [**] contains "**" as text, which is not empty after trimming
445        let ctx = LintContext::new(
446            "[**](https://example.com)",
447            crate::config::MarkdownFlavor::Standard,
448            None,
449        );
450        let rule = MD042NoEmptyLinks::new();
451        let result = rule.check(&ctx).unwrap();
452        assert!(result.is_empty(), "[**] is not considered empty since ** is text");
453
454        let ctx = LintContext::new(
455            "[__](https://example.com)",
456            crate::config::MarkdownFlavor::Standard,
457            None,
458        );
459        let result = rule.check(&ctx).unwrap();
460        assert!(result.is_empty(), "[__] is not considered empty since __ is text");
461
462        // Links with truly empty formatting should fail
463        let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
464        let result = rule.check(&ctx).unwrap();
465        assert_eq!(result.len(), 1);
466
467        // Links with nested formatting and actual text should pass
468        let ctx = LintContext::new(
469            "[**bold text**](https://example.com)",
470            crate::config::MarkdownFlavor::Standard,
471            None,
472        );
473        let result = rule.check(&ctx).unwrap();
474        assert!(result.is_empty(), "Links with nested formatting and text should pass");
475
476        let ctx = LintContext::new(
477            "[*italic* and **bold**](https://example.com)",
478            crate::config::MarkdownFlavor::Standard,
479            None,
480        );
481        let result = rule.check(&ctx).unwrap();
482        assert!(result.is_empty(), "Links with multiple nested formatting should pass");
483    }
484
485    #[test]
486    fn test_multiple_empty_links_on_same_line() {
487        let ctx = LintContext::new(
488            "[](url1) and [](url2) and [valid](url3)",
489            crate::config::MarkdownFlavor::Standard,
490            None,
491        );
492        let rule = MD042NoEmptyLinks::new();
493        let result = rule.check(&ctx).unwrap();
494        assert_eq!(result.len(), 2, "Should detect both empty links");
495        assert_eq!(result[0].column, 1);
496        assert_eq!(result[1].column, 14);
497    }
498
499    #[test]
500    fn test_escaped_brackets() {
501        // Escaped brackets should not be treated as links
502        let ctx = LintContext::new(
503            "\\[\\](https://example.com)",
504            crate::config::MarkdownFlavor::Standard,
505            None,
506        );
507        let rule = MD042NoEmptyLinks::new();
508        let result = rule.check(&ctx).unwrap();
509        assert!(result.is_empty(), "Escaped brackets should not be treated as links");
510
511        // But this should still be a link
512        let ctx = LintContext::new(
513            "[\\[\\]](https://example.com)",
514            crate::config::MarkdownFlavor::Standard,
515            None,
516        );
517        let result = rule.check(&ctx).unwrap();
518        assert!(result.is_empty(), "Link with escaped brackets in text should pass");
519    }
520
521    #[test]
522    fn test_links_in_lists_and_blockquotes() {
523        // Empty links in lists
524        let ctx = LintContext::new(
525            "- [](https://example.com)\n- [valid](https://example.com)",
526            crate::config::MarkdownFlavor::Standard,
527            None,
528        );
529        let rule = MD042NoEmptyLinks::new();
530        let result = rule.check(&ctx).unwrap();
531        assert_eq!(result.len(), 1);
532        assert_eq!(result[0].line, 1);
533
534        // Empty links in blockquotes
535        let ctx = LintContext::new(
536            "> [](https://example.com)\n> [valid](https://example.com)",
537            crate::config::MarkdownFlavor::Standard,
538            None,
539        );
540        let result = rule.check(&ctx).unwrap();
541        assert_eq!(result.len(), 1);
542        assert_eq!(result[0].line, 1);
543
544        // Nested structures
545        let ctx = LintContext::new(
546            "> - [](url1)\n> - [text](url2)",
547            crate::config::MarkdownFlavor::Standard,
548            None,
549        );
550        let result = rule.check(&ctx).unwrap();
551        assert_eq!(result.len(), 1);
552    }
553
554    #[test]
555    fn test_unicode_whitespace_characters() {
556        // Non-breaking space (U+00A0) - IS considered whitespace by Rust's trim()
557        let ctx = LintContext::new(
558            "[\u{00A0}](https://example.com)",
559            crate::config::MarkdownFlavor::Standard,
560            None,
561        );
562        let rule = MD042NoEmptyLinks::new();
563        let result = rule.check(&ctx).unwrap();
564        assert_eq!(result.len(), 1, "Non-breaking space should be treated as whitespace");
565
566        // Em space (U+2003) - IS considered whitespace by Rust's trim()
567        let ctx = LintContext::new(
568            "[\u{2003}](https://example.com)",
569            crate::config::MarkdownFlavor::Standard,
570            None,
571        );
572        let result = rule.check(&ctx).unwrap();
573        assert_eq!(result.len(), 1, "Em space should be treated as whitespace");
574
575        // Zero-width space (U+200B) - NOT considered whitespace by Rust's trim()
576        // This is a formatting character, not a whitespace character
577        let ctx = LintContext::new(
578            "[\u{200B}](https://example.com)",
579            crate::config::MarkdownFlavor::Standard,
580            None,
581        );
582        let result = rule.check(&ctx).unwrap();
583        assert!(
584            result.is_empty(),
585            "Zero-width space is not considered whitespace by trim()"
586        );
587
588        // Test with zero-width space between spaces
589        // Since trim() doesn't consider zero-width space as whitespace,
590        // " \u{200B} " becomes "\u{200B}" after trimming, which is NOT empty
591        let ctx = LintContext::new(
592            "[ \u{200B} ](https://example.com)",
593            crate::config::MarkdownFlavor::Standard,
594            None,
595        );
596        let result = rule.check(&ctx).unwrap();
597        assert!(
598            result.is_empty(),
599            "Zero-width space remains after trim(), so link is not empty"
600        );
601    }
602
603    #[test]
604    fn test_empty_url_with_text() {
605        let ctx = LintContext::new("[some text]()", crate::config::MarkdownFlavor::Standard, None);
606        let rule = MD042NoEmptyLinks::new();
607        let result = rule.check(&ctx).unwrap();
608        assert_eq!(result.len(), 1);
609        assert_eq!(result[0].message, "Empty link found: [some text]()");
610    }
611
612    #[test]
613    fn test_both_empty_text_and_url() {
614        let ctx = LintContext::new("[]()", crate::config::MarkdownFlavor::Standard, None);
615        let rule = MD042NoEmptyLinks::new();
616        let result = rule.check(&ctx).unwrap();
617        assert_eq!(result.len(), 1);
618        assert_eq!(result[0].message, "Empty link found: []()");
619    }
620
621    #[test]
622    fn test_reference_link_with_undefined_reference() {
623        let ctx = LintContext::new("[text][undefined]", crate::config::MarkdownFlavor::Standard, None);
624        let rule = MD042NoEmptyLinks::new();
625        let result = rule.check(&ctx).unwrap();
626        assert_eq!(result.len(), 1, "Undefined reference should be treated as empty URL");
627    }
628
629    #[test]
630    fn test_shortcut_reference_links() {
631        // Valid shortcut reference link (implicit reference)
632        // Note: [example] by itself is not parsed as a link by the LINK_PATTERN regex
633        // It needs to be followed by [] or () to be recognized as a link
634        let ctx = LintContext::new(
635            "[example][]\n\n[example]: https://example.com",
636            crate::config::MarkdownFlavor::Standard,
637            None,
638        );
639        let rule = MD042NoEmptyLinks::new();
640        let result = rule.check(&ctx).unwrap();
641        assert!(result.is_empty(), "Valid implicit reference link should pass");
642
643        // Empty implicit reference link
644        let ctx = LintContext::new(
645            "[][]\n\n[]: https://example.com",
646            crate::config::MarkdownFlavor::Standard,
647            None,
648        );
649        let result = rule.check(&ctx).unwrap();
650        assert_eq!(result.len(), 1, "Empty implicit reference link should fail");
651
652        // Test actual shortcut-style links are not detected (since they don't match the pattern)
653        let ctx = LintContext::new(
654            "[example]\n\n[example]: https://example.com",
655            crate::config::MarkdownFlavor::Standard,
656            None,
657        );
658        let result = rule.check(&ctx).unwrap();
659        assert!(
660            result.is_empty(),
661            "Shortcut links without [] or () are not parsed as links"
662        );
663    }
664
665    #[test]
666    fn test_fix_suggestions() {
667        let rule = MD042NoEmptyLinks::new();
668
669        // Case 1: Empty text, has URL - fixable (add placeholder text)
670        let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
671        let result = rule.check(&ctx).unwrap();
672        assert!(result[0].fix.is_some(), "Empty text with URL should be fixable");
673        let fix = result[0].fix.as_ref().unwrap();
674        assert_eq!(fix.replacement, "[Link text](https://example.com)");
675
676        // Case 2: Non-URL text, empty URL - NOT fixable (can't guess the URL)
677        let ctx = LintContext::new("[text]()", crate::config::MarkdownFlavor::Standard, None);
678        let result = rule.check(&ctx).unwrap();
679        assert!(
680            result[0].fix.is_none(),
681            "Non-URL text with empty URL should NOT be fixable"
682        );
683
684        // Case 3: URL text, empty URL - fixable (use text as URL)
685        let ctx = LintContext::new("[https://example.com]()", crate::config::MarkdownFlavor::Standard, None);
686        let result = rule.check(&ctx).unwrap();
687        assert!(result[0].fix.is_some(), "URL text with empty URL should be fixable");
688        let fix = result[0].fix.as_ref().unwrap();
689        assert_eq!(fix.replacement, "[https://example.com](https://example.com)");
690
691        // Case 4: Both empty - NOT fixable (can't guess either)
692        let ctx = LintContext::new("[]()", crate::config::MarkdownFlavor::Standard, None);
693        let result = rule.check(&ctx).unwrap();
694        assert!(result[0].fix.is_none(), "Both empty should NOT be fixable");
695    }
696
697    #[test]
698    fn test_complex_markdown_document() {
699        let content = r#"# Document with various links
700
701[Valid link](https://example.com) followed by [](empty.com).
702
703## Lists with links
704- [Good link](url1)
705- [](url2)
706- Item with [inline empty]() link
707
708> Quote with [](quoted-empty.com)
709> And [valid quoted](quoted-valid.com)
710
711Code block should be ignored:
712```
713[](this-is-code)
714```
715
716[Reference style][ref1] and [][ref2]
717
718[ref1]: https://ref1.com
719[ref2]: https://ref2.com
720"#;
721
722        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
723        let rule = MD042NoEmptyLinks::new();
724        let result = rule.check(&ctx).unwrap();
725
726        // Count the empty links
727        let empty_link_lines = [3, 7, 8, 10, 18];
728        assert_eq!(result.len(), empty_link_lines.len(), "Should find all empty links");
729
730        // Verify line numbers
731        for (i, &expected_line) in empty_link_lines.iter().enumerate() {
732            assert_eq!(
733                result[i].line, expected_line,
734                "Empty link {i} should be on line {expected_line}"
735            );
736        }
737    }
738
739    #[test]
740    fn test_issue_29_code_block_with_tildes() {
741        // Test for issue #29 - code blocks with tilde markers should not break reference links
742        let content = r#"In addition to the [local scope][] and the [global scope][], Python also has a **built-in scope**.
743
744```pycon
745>>> @count_calls
746... def greet(name):
747...     print("Hi", name)
748...
749>>> greet("Trey")
750Traceback (most recent call last):
751  File "<python-input-2>", line 1, in <module>
752    greet("Trey")
753    ~~~~~^^^^^^^^
754  File "<python-input-0>", line 4, in wrapper
755    calls += 1
756    ^^^^^
757UnboundLocalError: cannot access local variable 'calls' where it is not associated with a value
758```
759
760
761[local scope]: https://www.pythonmorsels.com/local-and-global-variables/
762[global scope]: https://www.pythonmorsels.com/assigning-global-variables/"#;
763
764        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
765        let rule = MD042NoEmptyLinks::new();
766        let result = rule.check(&ctx).unwrap();
767
768        // These reference links should NOT be flagged as empty
769        assert!(
770            result.is_empty(),
771            "Should not flag reference links as empty when code blocks contain tildes (issue #29). Got: {result:?}"
772        );
773    }
774
775    #[test]
776    fn test_link_with_inline_code_in_text() {
777        // Links with inline code in the text should NOT be flagged as empty
778        let ctx = LintContext::new(
779            "[`#[derive(Serialize, Deserialize)`](https://serde.rs/derive.html)",
780            crate::config::MarkdownFlavor::Standard,
781            None,
782        );
783        let rule = MD042NoEmptyLinks::new();
784        let result = rule.check(&ctx).unwrap();
785        assert!(
786            result.is_empty(),
787            "Links with inline code should not be flagged as empty. Got: {result:?}"
788        );
789    }
790
791    #[test]
792    fn test_mkdocs_backtick_wrapped_references() {
793        // Test for issue #97 - backtick-wrapped references should be recognized as MkDocs auto-references
794        let rule = MD042NoEmptyLinks::new();
795
796        // Module.Class pattern with backticks
797        let ctx = LintContext::new("[`module.Class`][]", crate::config::MarkdownFlavor::MkDocs, None);
798        let result = rule.check(&ctx).unwrap();
799        assert!(
800            result.is_empty(),
801            "Should not flag [`module.Class`][] as empty in MkDocs mode (issue #97). Got: {result:?}"
802        );
803
804        // Reference with explicit ID
805        let ctx = LintContext::new("[`module.Class`][ref]", crate::config::MarkdownFlavor::MkDocs, None);
806        let result = rule.check(&ctx).unwrap();
807        assert!(
808            result.is_empty(),
809            "Should not flag [`module.Class`][ref] as empty in MkDocs mode (issue #97). Got: {result:?}"
810        );
811
812        // Path-like reference with backticks
813        let ctx = LintContext::new("[`api/endpoint`][]", crate::config::MarkdownFlavor::MkDocs, None);
814        let result = rule.check(&ctx).unwrap();
815        assert!(
816            result.is_empty(),
817            "Should not flag [`api/endpoint`][] as empty in MkDocs mode (issue #97). Got: {result:?}"
818        );
819
820        // Should still flag in standard mode
821        let ctx = LintContext::new("[`module.Class`][]", crate::config::MarkdownFlavor::Standard, None);
822        let result = rule.check(&ctx).unwrap();
823        assert_eq!(
824            result.len(),
825            1,
826            "Should flag [`module.Class`][] as empty in Standard mode (no auto-refs). Got: {result:?}"
827        );
828
829        // Should still flag truly empty links even in MkDocs mode
830        let ctx = LintContext::new("[][]", crate::config::MarkdownFlavor::MkDocs, None);
831        let result = rule.check(&ctx).unwrap();
832        assert_eq!(
833            result.len(),
834            1,
835            "Should still flag [][] as empty in MkDocs mode. Got: {result:?}"
836        );
837    }
838}