Skip to main content

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