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