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