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