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