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 {
306                        range: link.byte_offset..link.byte_end,
307                        replacement: r,
308                    }),
309                });
310            }
311        }
312
313        Ok(warnings)
314    }
315
316    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
317        let content = ctx.content;
318
319        // Get all warnings first - only fix links that are actually flagged
320        let warnings = self.check(ctx)?;
321        let warnings =
322            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
323        if warnings.is_empty() {
324            return Ok(content.to_string());
325        }
326
327        // Collect all fixes with their ranges
328        let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
329            .iter()
330            .filter_map(|w| w.fix.as_ref().map(|f| (f.range.clone(), f.replacement.clone())))
331            .collect();
332
333        // Sort fixes by position (descending) to apply from end to start
334        fixes.sort_by(|a, b| b.0.start.cmp(&a.0.start));
335
336        let mut result = content.to_string();
337
338        // Apply fixes from end to start to maintain correct positions
339        for (range, replacement) in fixes {
340            result.replace_range(range, &replacement);
341        }
342
343        Ok(result)
344    }
345
346    /// Get the category of this rule for selective processing
347    fn category(&self) -> RuleCategory {
348        RuleCategory::Link
349    }
350
351    /// Check if this rule should be skipped
352    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
353        ctx.content.is_empty() || !ctx.likely_has_links_or_images()
354    }
355
356    fn as_any(&self) -> &dyn std::any::Any {
357        self
358    }
359
360    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
361    where
362        Self: Sized,
363    {
364        // Flavor is now accessed from LintContext during check
365        Box::new(MD042NoEmptyLinks::new())
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::lint_context::LintContext;
373
374    #[test]
375    fn test_links_with_text_should_pass() {
376        let ctx = LintContext::new(
377            "[valid link](https://example.com)",
378            crate::config::MarkdownFlavor::Standard,
379            None,
380        );
381        let rule = MD042NoEmptyLinks::new();
382        let result = rule.check(&ctx).unwrap();
383        assert!(result.is_empty(), "Links with text should pass");
384
385        let ctx = LintContext::new(
386            "[another valid link](path/to/page.html)",
387            crate::config::MarkdownFlavor::Standard,
388            None,
389        );
390        let result = rule.check(&ctx).unwrap();
391        assert!(result.is_empty(), "Links with text and relative URLs should pass");
392    }
393
394    #[test]
395    fn test_links_with_empty_text_but_valid_url_pass() {
396        // MD042 only flags empty URLs, not empty text
397        // "Empty links do not lead anywhere" - these links DO lead somewhere
398        let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
399        let rule = MD042NoEmptyLinks::new();
400        let result = rule.check(&ctx).unwrap();
401        assert!(
402            result.is_empty(),
403            "Empty text with valid URL should NOT be flagged by MD042. Got: {result:?}"
404        );
405    }
406
407    #[test]
408    fn test_links_with_only_whitespace_but_valid_url_pass() {
409        // MD042 only flags empty URLs, not empty/whitespace text
410        let ctx = LintContext::new(
411            "[   ](https://example.com)",
412            crate::config::MarkdownFlavor::Standard,
413            None,
414        );
415        let rule = MD042NoEmptyLinks::new();
416        let result = rule.check(&ctx).unwrap();
417        assert!(
418            result.is_empty(),
419            "Whitespace text with valid URL should NOT be flagged. Got: {result:?}"
420        );
421
422        let ctx = LintContext::new(
423            "[\t\n](https://example.com)",
424            crate::config::MarkdownFlavor::Standard,
425            None,
426        );
427        let result = rule.check(&ctx).unwrap();
428        assert!(
429            result.is_empty(),
430            "Whitespace text with valid URL should NOT be flagged. Got: {result:?}"
431        );
432    }
433
434    #[test]
435    fn test_reference_links_with_empty_text_but_valid_ref() {
436        // Empty text with valid reference (has URL) should NOT be flagged
437        // MD042 only flags empty URLs, not empty text
438        let ctx = LintContext::new(
439            "[][ref]\n\n[ref]: https://example.com",
440            crate::config::MarkdownFlavor::Standard,
441            None,
442        );
443        let rule = MD042NoEmptyLinks::new();
444        let result = rule.check(&ctx).unwrap();
445        assert!(
446            result.is_empty(),
447            "Empty text with valid reference should NOT be flagged. Got: {result:?}"
448        );
449
450        // Note: `[]:` (empty reference label) is NOT valid CommonMark
451        // So we don't test that case - empty labels are not supported
452    }
453
454    #[test]
455    fn test_images_should_be_ignored() {
456        // Images can have empty alt text, so they should not trigger the rule
457        let ctx = LintContext::new("![](image.png)", crate::config::MarkdownFlavor::Standard, None);
458        let rule = MD042NoEmptyLinks::new();
459        let result = rule.check(&ctx).unwrap();
460        assert!(result.is_empty(), "Images with empty alt text should be ignored");
461
462        let ctx = LintContext::new("![   ](image.png)", crate::config::MarkdownFlavor::Standard, None);
463        let result = rule.check(&ctx).unwrap();
464        assert!(result.is_empty(), "Images with whitespace alt text should be ignored");
465    }
466
467    #[test]
468    fn test_links_with_nested_formatting() {
469        // MD042 only flags empty URLs - all of these have valid URLs so they pass
470        let rule = MD042NoEmptyLinks::new();
471
472        // [**] contains "**" as text, has URL → pass
473        let ctx = LintContext::new(
474            "[**](https://example.com)",
475            crate::config::MarkdownFlavor::Standard,
476            None,
477        );
478        let result = rule.check(&ctx).unwrap();
479        assert!(result.is_empty(), "[**](url) has URL so should pass");
480
481        // [__] contains "__" as text, has URL → pass
482        let ctx = LintContext::new(
483            "[__](https://example.com)",
484            crate::config::MarkdownFlavor::Standard,
485            None,
486        );
487        let result = rule.check(&ctx).unwrap();
488        assert!(result.is_empty(), "[__](url) has URL so should pass");
489
490        // [](url) - empty text but has URL → pass (per markdownlint behavior)
491        let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
492        let result = rule.check(&ctx).unwrap();
493        assert!(result.is_empty(), "[](url) has URL so should pass");
494
495        // [**bold text**](url) - has text and URL → pass
496        let ctx = LintContext::new(
497            "[**bold text**](https://example.com)",
498            crate::config::MarkdownFlavor::Standard,
499            None,
500        );
501        let result = rule.check(&ctx).unwrap();
502        assert!(result.is_empty(), "Links with nested formatting and text should pass");
503
504        // [*italic* and **bold**](url) - has text and URL → pass
505        let ctx = LintContext::new(
506            "[*italic* and **bold**](https://example.com)",
507            crate::config::MarkdownFlavor::Standard,
508            None,
509        );
510        let result = rule.check(&ctx).unwrap();
511        assert!(result.is_empty(), "Links with multiple nested formatting should pass");
512    }
513
514    #[test]
515    fn test_multiple_empty_links_on_same_line() {
516        // MD042 only flags empty URLs - all these have URLs so they pass
517        let ctx = LintContext::new(
518            "[](url1) and [](url2) and [valid](url3)",
519            crate::config::MarkdownFlavor::Standard,
520            None,
521        );
522        let rule = MD042NoEmptyLinks::new();
523        let result = rule.check(&ctx).unwrap();
524        assert!(
525            result.is_empty(),
526            "Empty text with valid URL should NOT be flagged. Got: {result:?}"
527        );
528
529        // Test multiple truly empty links (empty URL)
530        let ctx = LintContext::new(
531            "[text1]() and [text2]() and [text3](url)",
532            crate::config::MarkdownFlavor::Standard,
533            None,
534        );
535        let result = rule.check(&ctx).unwrap();
536        assert_eq!(result.len(), 2, "Should detect both empty URL links");
537        assert_eq!(result[0].column, 1); // [text1]()
538        assert_eq!(result[1].column, 15); // [text2]()
539    }
540
541    #[test]
542    fn test_escaped_brackets() {
543        // Escaped brackets should not be treated as links
544        let ctx = LintContext::new(
545            "\\[\\](https://example.com)",
546            crate::config::MarkdownFlavor::Standard,
547            None,
548        );
549        let rule = MD042NoEmptyLinks::new();
550        let result = rule.check(&ctx).unwrap();
551        assert!(result.is_empty(), "Escaped brackets should not be treated as links");
552
553        // But this should still be a link
554        let ctx = LintContext::new(
555            "[\\[\\]](https://example.com)",
556            crate::config::MarkdownFlavor::Standard,
557            None,
558        );
559        let result = rule.check(&ctx).unwrap();
560        assert!(result.is_empty(), "Link with escaped brackets in text should pass");
561    }
562
563    #[test]
564    fn test_links_in_lists_and_blockquotes() {
565        // MD042 only flags empty URLs - [](url) has URL so it passes
566        let rule = MD042NoEmptyLinks::new();
567
568        // Empty text with URL in lists - passes (has URL)
569        let ctx = LintContext::new(
570            "- [](https://example.com)\n- [valid](https://example.com)",
571            crate::config::MarkdownFlavor::Standard,
572            None,
573        );
574        let result = rule.check(&ctx).unwrap();
575        assert!(result.is_empty(), "[](url) in lists should pass");
576
577        // Empty text with URL in blockquotes - passes (has URL)
578        let ctx = LintContext::new(
579            "> [](https://example.com)\n> [valid](https://example.com)",
580            crate::config::MarkdownFlavor::Standard,
581            None,
582        );
583        let result = rule.check(&ctx).unwrap();
584        assert!(result.is_empty(), "[](url) in blockquotes should pass");
585
586        // Empty URL in lists - FAILS (no URL)
587        let ctx = LintContext::new(
588            "- [text]()\n- [valid](url)",
589            crate::config::MarkdownFlavor::Standard,
590            None,
591        );
592        let result = rule.check(&ctx).unwrap();
593        assert_eq!(result.len(), 1, "Empty URL should be flagged");
594        assert_eq!(result[0].line, 1);
595    }
596
597    #[test]
598    fn test_unicode_whitespace_characters() {
599        // MD042 only flags empty URLs - all these have URLs so they pass
600        // regardless of the text content (whitespace or not)
601        let rule = MD042NoEmptyLinks::new();
602
603        // Non-breaking space (U+00A0) - has URL, passes
604        let ctx = LintContext::new(
605            "[\u{00A0}](https://example.com)",
606            crate::config::MarkdownFlavor::Standard,
607            None,
608        );
609        let result = rule.check(&ctx).unwrap();
610        assert!(result.is_empty(), "Has URL, should pass regardless of text");
611
612        // Em space (U+2003) - has URL, passes
613        let ctx = LintContext::new(
614            "[\u{2003}](https://example.com)",
615            crate::config::MarkdownFlavor::Standard,
616            None,
617        );
618        let result = rule.check(&ctx).unwrap();
619        assert!(result.is_empty(), "Has URL, should pass regardless of text");
620
621        // Zero-width space (U+200B) - has URL, passes
622        let ctx = LintContext::new(
623            "[\u{200B}](https://example.com)",
624            crate::config::MarkdownFlavor::Standard,
625            None,
626        );
627        let result = rule.check(&ctx).unwrap();
628        assert!(result.is_empty(), "Has URL, should pass regardless of text");
629
630        // Test with zero-width space between spaces - has URL, passes
631        let ctx = LintContext::new(
632            "[ \u{200B} ](https://example.com)",
633            crate::config::MarkdownFlavor::Standard,
634            None,
635        );
636        let result = rule.check(&ctx).unwrap();
637        assert!(result.is_empty(), "Has URL, should pass regardless of text");
638    }
639
640    #[test]
641    fn test_empty_url_with_text() {
642        let ctx = LintContext::new("[some text]()", crate::config::MarkdownFlavor::Standard, None);
643        let rule = MD042NoEmptyLinks::new();
644        let result = rule.check(&ctx).unwrap();
645        assert_eq!(result.len(), 1);
646        assert_eq!(result[0].message, "Empty link found: [some text]()");
647    }
648
649    #[test]
650    fn test_both_empty_text_and_url() {
651        let ctx = LintContext::new("[]()", crate::config::MarkdownFlavor::Standard, None);
652        let rule = MD042NoEmptyLinks::new();
653        let result = rule.check(&ctx).unwrap();
654        assert_eq!(result.len(), 1);
655        assert_eq!(result[0].message, "Empty link found: []()");
656    }
657
658    #[test]
659    fn test_bare_hash_treated_as_empty_url() {
660        let rule = MD042NoEmptyLinks::new();
661
662        // [](#) - bare fragment marker with no name is an empty/meaningless URL
663        let ctx = LintContext::new("# Title\n\n[](#)\n", crate::config::MarkdownFlavor::Standard, None);
664        let result = rule.check(&ctx).unwrap();
665        assert_eq!(
666            result.len(),
667            1,
668            "[](#) should be flagged as empty link. Got: {result:?}"
669        );
670        assert!(result[0].message.contains("[](#)"));
671
672        // [text](#) - text with bare # URL
673        let ctx = LintContext::new("# Title\n\n[text](#)\n", crate::config::MarkdownFlavor::Standard, None);
674        let result = rule.check(&ctx).unwrap();
675        assert_eq!(
676            result.len(),
677            1,
678            "[text](#) should be flagged as empty link. Got: {result:?}"
679        );
680        assert!(result[0].message.contains("[text](#)"));
681
682        // [text]( # ) - bare # with surrounding whitespace
683        let ctx = LintContext::new(
684            "# Title\n\n[text]( # )\n",
685            crate::config::MarkdownFlavor::Standard,
686            None,
687        );
688        let result = rule.check(&ctx).unwrap();
689        assert_eq!(
690            result.len(),
691            1,
692            "[text]( # ) should be flagged as empty link. Got: {result:?}"
693        );
694
695        // [text](#foo) - actual fragment should NOT be flagged
696        let ctx = LintContext::new(
697            "# Title\n\n[text](#foo)\n",
698            crate::config::MarkdownFlavor::Standard,
699            None,
700        );
701        let result = rule.check(&ctx).unwrap();
702        assert!(
703            result.is_empty(),
704            "[text](#foo) has a real fragment, should NOT be flagged. Got: {result:?}"
705        );
706
707        // [](#section) - empty text but valid fragment URL should NOT be flagged
708        let ctx = LintContext::new(
709            "# Title\n\n[](#section)\n",
710            crate::config::MarkdownFlavor::Standard,
711            None,
712        );
713        let result = rule.check(&ctx).unwrap();
714        assert!(
715            result.is_empty(),
716            "[](#section) has a real URL, should NOT be flagged. Got: {result:?}"
717        );
718    }
719
720    #[test]
721    fn test_reference_link_with_undefined_reference() {
722        // Undefined references are handled by MD052, not MD042
723        // MD042 should NOT flag [text][undefined] - it's not an "empty link"
724        let ctx = LintContext::new("[text][undefined]", crate::config::MarkdownFlavor::Standard, None);
725        let rule = MD042NoEmptyLinks::new();
726        let result = rule.check(&ctx).unwrap();
727        assert!(
728            result.is_empty(),
729            "MD042 should NOT flag [text][undefined] - undefined refs are MD052's job. Got: {result:?}"
730        );
731
732        // But empty text with undefined reference SHOULD be flagged
733        let ctx = LintContext::new("[][undefined]", crate::config::MarkdownFlavor::Standard, None);
734        let result = rule.check(&ctx).unwrap();
735        assert_eq!(result.len(), 1, "Empty text in reference link should still be flagged");
736    }
737
738    #[test]
739    fn test_shortcut_reference_links() {
740        // Valid shortcut reference link (implicit reference)
741        // Note: [example] by itself is not parsed as a link by the LINK_PATTERN regex
742        // It needs to be followed by [] or () to be recognized as a link
743        let ctx = LintContext::new(
744            "[example][]\n\n[example]: https://example.com",
745            crate::config::MarkdownFlavor::Standard,
746            None,
747        );
748        let rule = MD042NoEmptyLinks::new();
749        let result = rule.check(&ctx).unwrap();
750        assert!(result.is_empty(), "Valid implicit reference link should pass");
751
752        // Note: `[]:` (empty reference label) is NOT valid CommonMark
753        // Empty labels are not supported, so we don't test `[][]\n\n[]: url`
754
755        // Test actual shortcut-style links are not detected (since they don't match the pattern)
756        let ctx = LintContext::new(
757            "[example]\n\n[example]: https://example.com",
758            crate::config::MarkdownFlavor::Standard,
759            None,
760        );
761        let result = rule.check(&ctx).unwrap();
762        assert!(
763            result.is_empty(),
764            "Shortcut links without [] or () are not parsed as links"
765        );
766    }
767
768    #[test]
769    fn test_fix_suggestions() {
770        // MD042 only flags empty URLs now
771        let rule = MD042NoEmptyLinks::new();
772
773        // Case 1: Empty text, has URL - NOT flagged (has URL)
774        let ctx = LintContext::new("[](https://example.com)", crate::config::MarkdownFlavor::Standard, None);
775        let result = rule.check(&ctx).unwrap();
776        assert!(result.is_empty(), "Empty text with URL should NOT be flagged");
777
778        // Case 2: Non-URL text, empty URL - flagged, NOT fixable (can't guess the URL)
779        let ctx = LintContext::new("[text]()", crate::config::MarkdownFlavor::Standard, None);
780        let result = rule.check(&ctx).unwrap();
781        assert_eq!(result.len(), 1, "Empty URL should be flagged");
782        assert!(
783            result[0].fix.is_none(),
784            "Non-URL text with empty URL should NOT be fixable"
785        );
786
787        // Case 3: URL text, empty URL - flagged, fixable (use text as URL)
788        let ctx = LintContext::new("[https://example.com]()", crate::config::MarkdownFlavor::Standard, None);
789        let result = rule.check(&ctx).unwrap();
790        assert_eq!(result.len(), 1, "Empty URL should be flagged");
791        assert!(result[0].fix.is_some(), "URL text with empty URL should be fixable");
792        let fix = result[0].fix.as_ref().unwrap();
793        assert_eq!(fix.replacement, "[https://example.com](https://example.com)");
794
795        // Case 4: Both empty - flagged, NOT fixable (can't guess either)
796        let ctx = LintContext::new("[]()", crate::config::MarkdownFlavor::Standard, None);
797        let result = rule.check(&ctx).unwrap();
798        assert_eq!(result.len(), 1, "Empty URL should be flagged");
799        assert!(result[0].fix.is_none(), "Both empty should NOT be fixable");
800    }
801
802    #[test]
803    fn test_complex_markdown_document() {
804        // MD042 only flags empty URLs - not empty text
805        let content = r#"# Document with various links
806
807[Valid link](https://example.com) followed by [](empty.com).
808
809## Lists with links
810- [Good link](url1)
811- [](url2)
812- Item with [inline empty]() link
813
814> Quote with [](quoted-empty.com)
815> And [valid quoted](quoted-valid.com)
816
817Code block should be ignored:
818```
819[](this-is-code)
820```
821
822[Reference style][ref1] and [][ref2]
823
824[ref1]: https://ref1.com
825[ref2]: https://ref2.com
826"#;
827
828        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
829        let rule = MD042NoEmptyLinks::new();
830        let result = rule.check(&ctx).unwrap();
831
832        // Only [inline empty]() on line 9 has empty URL - should be the only one flagged
833        // All [](url) patterns have URLs so they're NOT flagged
834        // [][ref2] has a valid reference so it's NOT flagged
835        assert_eq!(result.len(), 1, "Should only flag empty URL links. Got: {result:?}");
836        assert_eq!(result[0].line, 8, "Only [inline empty]() should be flagged");
837        assert!(result[0].message.contains("[inline empty]()"));
838    }
839
840    #[test]
841    fn test_issue_29_code_block_with_tildes() {
842        // Test for issue #29 - code blocks with tilde markers should not break reference links
843        let content = r#"In addition to the [local scope][] and the [global scope][], Python also has a **built-in scope**.
844
845```pycon
846>>> @count_calls
847... def greet(name):
848...     print("Hi", name)
849...
850>>> greet("Trey")
851Traceback (most recent call last):
852  File "<python-input-2>", line 1, in <module>
853    greet("Trey")
854    ~~~~~^^^^^^^^
855  File "<python-input-0>", line 4, in wrapper
856    calls += 1
857    ^^^^^
858UnboundLocalError: cannot access local variable 'calls' where it is not associated with a value
859```
860
861
862[local scope]: https://www.pythonmorsels.com/local-and-global-variables/
863[global scope]: https://www.pythonmorsels.com/assigning-global-variables/"#;
864
865        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866        let rule = MD042NoEmptyLinks::new();
867        let result = rule.check(&ctx).unwrap();
868
869        // These reference links should NOT be flagged as empty
870        assert!(
871            result.is_empty(),
872            "Should not flag reference links as empty when code blocks contain tildes (issue #29). Got: {result:?}"
873        );
874    }
875
876    #[test]
877    fn test_link_with_inline_code_in_text() {
878        // Links with inline code in the text should NOT be flagged as empty
879        let ctx = LintContext::new(
880            "[`#[derive(Serialize, Deserialize)`](https://serde.rs/derive.html)",
881            crate::config::MarkdownFlavor::Standard,
882            None,
883        );
884        let rule = MD042NoEmptyLinks::new();
885        let result = rule.check(&ctx).unwrap();
886        assert!(
887            result.is_empty(),
888            "Links with inline code should not be flagged as empty. Got: {result:?}"
889        );
890    }
891
892    #[test]
893    fn test_frontmatter_not_flagged() {
894        let rule = MD042NoEmptyLinks::new();
895
896        // [Symbol.dispose]() in YAML frontmatter should NOT be flagged
897        let content = "---\ntitle: \"[Symbol.dispose]()\"\n---\n\n# Hello\n";
898        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
899        let result = rule.check(&ctx).unwrap();
900        assert!(
901            result.is_empty(),
902            "Should not flag [Symbol.dispose]() inside YAML frontmatter. Got: {result:?}"
903        );
904
905        // Same pattern outside frontmatter SHOULD be flagged
906        let content = "# Hello\n\n[Symbol.dispose]()\n";
907        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
908        let result = rule.check(&ctx).unwrap();
909        assert_eq!(result.len(), 1, "Should flag [Symbol.dispose]() in regular content");
910
911        // Multiple link-like patterns in frontmatter
912        let content = "---\ntags: [\"[foo]()\", \"[bar]()\"]\n---\n\n# Hello\n";
913        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
914        let result = rule.check(&ctx).unwrap();
915        assert!(
916            result.is_empty(),
917            "Should not flag link-like patterns inside frontmatter. Got: {result:?}"
918        );
919    }
920
921    #[test]
922    fn test_mkdocs_backtick_wrapped_references() {
923        // Test for issue #97 - backtick-wrapped references should be recognized as MkDocs auto-references
924        let rule = MD042NoEmptyLinks::new();
925
926        // Module.Class pattern with backticks
927        let ctx = LintContext::new("[`module.Class`][]", crate::config::MarkdownFlavor::MkDocs, None);
928        let result = rule.check(&ctx).unwrap();
929        assert!(
930            result.is_empty(),
931            "Should not flag [`module.Class`][] as empty in MkDocs mode (issue #97). Got: {result:?}"
932        );
933
934        // Reference with explicit ID
935        let ctx = LintContext::new("[`module.Class`][ref]", crate::config::MarkdownFlavor::MkDocs, None);
936        let result = rule.check(&ctx).unwrap();
937        assert!(
938            result.is_empty(),
939            "Should not flag [`module.Class`][ref] as empty in MkDocs mode (issue #97). Got: {result:?}"
940        );
941
942        // Path-like reference with backticks
943        let ctx = LintContext::new("[`api/endpoint`][]", crate::config::MarkdownFlavor::MkDocs, None);
944        let result = rule.check(&ctx).unwrap();
945        assert!(
946            result.is_empty(),
947            "Should not flag [`api/endpoint`][] as empty in MkDocs mode (issue #97). Got: {result:?}"
948        );
949
950        // In standard mode, undefined collapsed references are handled by MD052, not MD042
951        // MD042 only flags truly empty links, not undefined references
952        let ctx = LintContext::new("[`module.Class`][]", crate::config::MarkdownFlavor::Standard, None);
953        let result = rule.check(&ctx).unwrap();
954        assert!(
955            result.is_empty(),
956            "MD042 should NOT flag [`module.Class`][] - undefined refs are MD052's job. Got: {result:?}"
957        );
958
959        // Should still flag truly empty links even in MkDocs mode
960        let ctx = LintContext::new("[][]", crate::config::MarkdownFlavor::MkDocs, None);
961        let result = rule.check(&ctx).unwrap();
962        assert_eq!(
963            result.len(),
964            1,
965            "Should still flag [][] as empty in MkDocs mode. Got: {result:?}"
966        );
967    }
968}