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