Skip to main content

rumdl_lib/rules/
md054_link_image_style.rs

1//!
2//! Rule MD054: Link and image style should be consistent
3//!
4//! See [docs/md054.md](../../docs/md054.md) for full documentation, configuration, and examples.
5
6use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
7use pulldown_cmark::LinkType;
8
9mod md054_config;
10use md054_config::MD054Config;
11
12/// Rule MD054: Link and image style should be consistent
13///
14/// This rule is triggered when different link or image styles are used in the same document.
15/// Markdown supports various styles for links and images, and this rule enforces consistency.
16///
17/// ## Supported Link Styles
18///
19/// - **Autolink**: `<https://example.com>`
20/// - **Inline**: `[link text](https://example.com)`
21/// - **URL Inline**: Special case of inline links where the URL itself is also the link text: `[https://example.com](https://example.com)`
22/// - **Shortcut**: `[link text]` (requires a reference definition elsewhere in the document)
23/// - **Collapsed**: `[link text][]` (requires a reference definition with the same name)
24/// - **Full**: `[link text][reference]` (requires a reference definition for the reference)
25///
26/// ## Configuration Options
27///
28/// You can configure which link styles are allowed. By default, all styles are allowed:
29///
30/// ```yaml
31/// MD054:
32///   autolink: true    # Allow autolink style
33///   inline: true      # Allow inline style
34///   url_inline: true  # Allow URL inline style
35///   shortcut: true    # Allow shortcut style
36///   collapsed: true   # Allow collapsed style
37///   full: true        # Allow full style
38/// ```
39///
40/// To enforce a specific style, set only that style to `true` and all others to `false`.
41///
42/// ## Unicode Support
43///
44/// This rule fully supports Unicode characters in link text and URLs, including:
45/// - Combining characters (e.g., cafe)
46/// - Zero-width joiners (e.g., family emojis)
47/// - Right-to-left text (e.g., Arabic, Hebrew)
48/// - Emojis and other special characters
49///
50/// ## Rationale
51///
52/// Consistent link styles improve document readability and maintainability. Different link
53/// styles have different advantages (e.g., inline links are self-contained, reference links
54/// keep the content cleaner), but mixing styles can create confusion.
55///
56#[derive(Debug, Default, Clone)]
57pub struct MD054LinkImageStyle {
58    config: MD054Config,
59}
60
61impl MD054LinkImageStyle {
62    pub fn new(autolink: bool, collapsed: bool, full: bool, inline: bool, shortcut: bool, url_inline: bool) -> Self {
63        Self {
64            config: MD054Config {
65                autolink,
66                collapsed,
67                full,
68                inline,
69                shortcut,
70                url_inline,
71            },
72        }
73    }
74
75    pub fn from_config_struct(config: MD054Config) -> Self {
76        Self { config }
77    }
78
79    /// Convert a byte offset to a 1-indexed character column within its line.
80    /// Only called for disallowed links (cold path), so O(line_length) is fine.
81    fn byte_to_char_col(content: &str, byte_offset: usize) -> usize {
82        let before = &content[..byte_offset];
83        let last_newline = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
84        before[last_newline..].chars().count() + 1
85    }
86
87    /// Check if a style is allowed based on configuration
88    fn is_style_allowed(&self, style: &str) -> bool {
89        match style {
90            "autolink" => self.config.autolink,
91            "collapsed" => self.config.collapsed,
92            "full" => self.config.full,
93            "inline" => self.config.inline,
94            "shortcut" => self.config.shortcut,
95            "url-inline" => self.config.url_inline,
96            _ => false,
97        }
98    }
99}
100
101impl Rule for MD054LinkImageStyle {
102    fn name(&self) -> &'static str {
103        "MD054"
104    }
105
106    fn description(&self) -> &'static str {
107        "Link and image style should be consistent"
108    }
109
110    fn category(&self) -> RuleCategory {
111        RuleCategory::Link
112    }
113
114    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
115        let content = ctx.content;
116        let mut warnings = Vec::new();
117
118        // Process links from pre-parsed data
119        for link in &ctx.links {
120            // Skip broken references (empty URL means unresolved reference)
121            if matches!(
122                link.link_type,
123                LinkType::Reference | LinkType::Collapsed | LinkType::Shortcut
124            ) && link.url.is_empty()
125            {
126                continue;
127            }
128
129            let style = match link.link_type {
130                LinkType::Autolink | LinkType::Email => "autolink",
131                LinkType::Inline => {
132                    if link.text == link.url {
133                        "url-inline"
134                    } else {
135                        "inline"
136                    }
137                }
138                LinkType::Reference => "full",
139                LinkType::Collapsed => "collapsed",
140                LinkType::Shortcut => "shortcut",
141                _ => continue,
142            };
143
144            // Filter out links in frontmatter or code blocks
145            if ctx
146                .line_info(link.line)
147                .is_some_and(|info| info.in_front_matter || info.in_code_block)
148            {
149                continue;
150            }
151
152            if !self.is_style_allowed(style) {
153                let start_col = Self::byte_to_char_col(content, link.byte_offset);
154                let (end_line, _) = ctx.offset_to_line_col(link.byte_end);
155                let end_col = Self::byte_to_char_col(content, link.byte_end);
156
157                warnings.push(LintWarning {
158                    rule_name: Some(self.name().to_string()),
159                    line: link.line,
160                    column: start_col,
161                    end_line,
162                    end_column: end_col,
163                    message: format!("Link/image style '{style}' is not allowed"),
164                    severity: Severity::Warning,
165                    fix: None,
166                });
167            }
168        }
169
170        // Process images from pre-parsed data
171        for image in &ctx.images {
172            // Skip broken references (empty URL means unresolved reference)
173            if matches!(
174                image.link_type,
175                LinkType::Reference | LinkType::Collapsed | LinkType::Shortcut
176            ) && image.url.is_empty()
177            {
178                continue;
179            }
180
181            let style = match image.link_type {
182                LinkType::Autolink | LinkType::Email => "autolink",
183                LinkType::Inline => {
184                    if image.alt_text == image.url {
185                        "url-inline"
186                    } else {
187                        "inline"
188                    }
189                }
190                LinkType::Reference => "full",
191                LinkType::Collapsed => "collapsed",
192                LinkType::Shortcut => "shortcut",
193                _ => continue,
194            };
195
196            // Filter out images in frontmatter or code blocks
197            if ctx
198                .line_info(image.line)
199                .is_some_and(|info| info.in_front_matter || info.in_code_block)
200            {
201                continue;
202            }
203
204            if !self.is_style_allowed(style) {
205                let start_col = Self::byte_to_char_col(content, image.byte_offset);
206                let (end_line, _) = ctx.offset_to_line_col(image.byte_end);
207                let end_col = Self::byte_to_char_col(content, image.byte_end);
208
209                warnings.push(LintWarning {
210                    rule_name: Some(self.name().to_string()),
211                    line: image.line,
212                    column: start_col,
213                    end_line,
214                    end_column: end_col,
215                    message: format!("Link/image style '{style}' is not allowed"),
216                    severity: Severity::Warning,
217                    fix: None,
218                });
219            }
220        }
221
222        Ok(warnings)
223    }
224
225    fn fix(&self, _ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
226        // Automatic fixing for link styles is not supported and could break content
227        Err(LintError::FixFailed(
228            "MD054 does not support automatic fixing of link/image style consistency.".to_string(),
229        ))
230    }
231
232    fn fix_capability(&self) -> crate::rule::FixCapability {
233        crate::rule::FixCapability::Unfixable
234    }
235
236    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
237        ctx.content.is_empty() || (!ctx.likely_has_links_or_images() && !ctx.likely_has_html())
238    }
239
240    fn as_any(&self) -> &dyn std::any::Any {
241        self
242    }
243
244    fn default_config_section(&self) -> Option<(String, toml::Value)> {
245        let json_value = serde_json::to_value(&self.config).ok()?;
246        Some((
247            self.name().to_string(),
248            crate::rule_config_serde::json_to_toml_value(&json_value)?,
249        ))
250    }
251
252    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
253    where
254        Self: Sized,
255    {
256        let rule_config = crate::rule_config_serde::load_rule_config::<MD054Config>(config);
257        Box::new(Self::from_config_struct(rule_config))
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::lint_context::LintContext;
265
266    #[test]
267    fn test_all_styles_allowed_by_default() {
268        let rule = MD054LinkImageStyle::new(true, true, true, true, true, true);
269        let content = "[inline](url) [ref][] [ref] <https://autolink.com> [full][ref] [url](url)\n\n[ref]: url";
270        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
271        let result = rule.check(&ctx).unwrap();
272
273        assert_eq!(result.len(), 0);
274    }
275
276    #[test]
277    fn test_only_inline_allowed() {
278        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
279        // [bad][] has no definition for "bad", so pulldown-cmark doesn't emit it as a link
280        let content = "[allowed](url) [not][ref] <https://bad.com> [collapsed][] [shortcut]\n\n[ref]: url\n[shortcut]: url\n[collapsed]: url";
281        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
282        let result = rule.check(&ctx).unwrap();
283
284        assert_eq!(result.len(), 4, "Expected 4 warnings, got: {result:?}");
285        assert!(result[0].message.contains("'full'"));
286        assert!(result[1].message.contains("'autolink'"));
287        assert!(result[2].message.contains("'collapsed'"));
288        assert!(result[3].message.contains("'shortcut'"));
289    }
290
291    #[test]
292    fn test_only_autolink_allowed() {
293        let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
294        let content = "<https://good.com> [bad](url) [bad][ref]\n\n[ref]: url";
295        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
296        let result = rule.check(&ctx).unwrap();
297
298        assert_eq!(result.len(), 2, "Expected 2 warnings, got: {result:?}");
299        assert!(result[0].message.contains("'inline'"));
300        assert!(result[1].message.contains("'full'"));
301    }
302
303    #[test]
304    fn test_url_inline_detection() {
305        let rule = MD054LinkImageStyle::new(false, false, false, true, false, true);
306        let content = "[https://example.com](https://example.com) [text](https://example.com)";
307        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
308        let result = rule.check(&ctx).unwrap();
309
310        // First is url_inline (allowed), second is inline (allowed)
311        assert_eq!(result.len(), 0);
312    }
313
314    #[test]
315    fn test_url_inline_not_allowed() {
316        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
317        let content = "[https://example.com](https://example.com)";
318        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
319        let result = rule.check(&ctx).unwrap();
320
321        assert_eq!(result.len(), 1);
322        assert!(result[0].message.contains("'url-inline'"));
323    }
324
325    #[test]
326    fn test_shortcut_vs_full_detection() {
327        let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
328        let content = "[shortcut] [full][ref]\n\n[shortcut]: url\n[ref]: url2";
329        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
330        let result = rule.check(&ctx).unwrap();
331
332        // Only shortcut should be flagged
333        assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
334        assert!(result[0].message.contains("'shortcut'"));
335    }
336
337    #[test]
338    fn test_collapsed_reference() {
339        let rule = MD054LinkImageStyle::new(false, true, false, false, false, false);
340        let content = "[collapsed][] [bad][ref]\n\n[collapsed]: url\n[ref]: url2";
341        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
342        let result = rule.check(&ctx).unwrap();
343
344        assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
345        assert!(result[0].message.contains("'full'"));
346    }
347
348    #[test]
349    fn test_code_blocks_ignored() {
350        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
351        let content = "```\n[ignored](url) <https://ignored.com>\n```\n\n[checked](url)";
352        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
353        let result = rule.check(&ctx).unwrap();
354
355        // Only the link outside code block should be checked
356        assert_eq!(result.len(), 0);
357    }
358
359    #[test]
360    fn test_code_spans_ignored() {
361        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
362        let content = "`[ignored](url)` and `<https://ignored.com>` but [checked](url)";
363        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
364        let result = rule.check(&ctx).unwrap();
365
366        // Only the link outside code spans should be checked
367        assert_eq!(result.len(), 0);
368    }
369
370    #[test]
371    fn test_reference_definitions_ignored() {
372        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
373        let content = "[ref]: https://example.com\n[ref2]: <https://example2.com>";
374        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
375        let result = rule.check(&ctx).unwrap();
376
377        // Reference definitions should be ignored
378        assert_eq!(result.len(), 0);
379    }
380
381    #[test]
382    fn test_html_comments_ignored() {
383        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
384        let content = "<!-- [ignored](url) -->\n  <!-- <https://ignored.com> -->";
385        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
386        let result = rule.check(&ctx).unwrap();
387
388        assert_eq!(result.len(), 0);
389    }
390
391    #[test]
392    fn test_unicode_support() {
393        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
394        let content = "[cafe](https://cafe.com) [emoji](url) [korean](url) [hebrew](url)";
395        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
396        let result = rule.check(&ctx).unwrap();
397
398        // All should be detected as inline (allowed)
399        assert_eq!(result.len(), 0);
400    }
401
402    #[test]
403    fn test_line_positions() {
404        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
405        let content = "Line 1\n\nLine 3 with <https://bad.com> here";
406        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
407        let result = rule.check(&ctx).unwrap();
408
409        assert_eq!(result.len(), 1);
410        assert_eq!(result[0].line, 3);
411        assert_eq!(result[0].column, 13); // Position of '<'
412    }
413
414    #[test]
415    fn test_multiple_links_same_line() {
416        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
417        let content = "[ok](url) but <https://good.com> and [also][bad]\n\n[bad]: url";
418        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
419        let result = rule.check(&ctx).unwrap();
420
421        assert_eq!(result.len(), 2, "Expected 2 warnings, got: {result:?}");
422        assert!(result[0].message.contains("'autolink'"));
423        assert!(result[1].message.contains("'full'"));
424    }
425
426    #[test]
427    fn test_empty_content() {
428        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
429        let content = "";
430        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
431        let result = rule.check(&ctx).unwrap();
432
433        assert_eq!(result.len(), 0);
434    }
435
436    #[test]
437    fn test_no_links() {
438        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
439        let content = "Just plain text without any links";
440        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
441        let result = rule.check(&ctx).unwrap();
442
443        assert_eq!(result.len(), 0);
444    }
445
446    #[test]
447    fn test_fix_returns_error() {
448        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
449        let content = "[link](url)";
450        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
451        let result = rule.fix(&ctx);
452
453        assert!(result.is_err());
454        if let Err(LintError::FixFailed(msg)) = result {
455            assert!(msg.contains("does not support automatic fixing"));
456        }
457    }
458
459    #[test]
460    fn test_priority_order() {
461        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
462        // Test that [text][ref] is detected as full, not shortcut
463        let content = "[text][ref] not detected as [shortcut]\n\n[ref]: url\n[shortcut]: url2";
464        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
465        let result = rule.check(&ctx).unwrap();
466
467        assert_eq!(result.len(), 2, "Expected 2 warnings, got: {result:?}");
468        assert!(result[0].message.contains("'full'"));
469        assert!(result[1].message.contains("'shortcut'"));
470    }
471
472    #[test]
473    fn test_not_shortcut_when_followed_by_bracket() {
474        let rule = MD054LinkImageStyle::new(false, false, false, true, true, false);
475        // [text][ should not be detected as shortcut
476        let content = "[text][ more text\n[text](url) is inline";
477        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
478        let result = rule.check(&ctx).unwrap();
479
480        // Only second line should have inline link
481        assert_eq!(result.len(), 0);
482    }
483
484    #[test]
485    fn test_cjk_correct_column_positions() {
486        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
487        let content = "日本語テスト <https://example.com>";
488        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
489        let result = rule.check(&ctx).unwrap();
490
491        assert_eq!(result.len(), 1);
492        assert!(result[0].message.contains("'autolink'"));
493        // The '<' starts at byte position 19 (after 6 CJK chars * 3 bytes + 1 space)
494        // which is character position 8 (1-indexed)
495        assert_eq!(
496            result[0].column, 8,
497            "Column should be 1-indexed character position of '<'"
498        );
499    }
500
501    #[test]
502    fn test_code_span_detection_with_cjk_prefix() {
503        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
504        // Link inside code span after CJK characters
505        let content = "日本語 `[link](url)` text";
506        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
507        let result = rule.check(&ctx).unwrap();
508
509        // The link is inside a code span, so it should not be flagged
510        assert_eq!(result.len(), 0, "Link inside code span should not be flagged");
511    }
512
513    #[test]
514    fn test_complex_unicode_with_zwj() {
515        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
516        let content = "[family](url) [cafe](https://cafe.com)";
517        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
518        let result = rule.check(&ctx).unwrap();
519
520        // Both should be detected as inline (allowed)
521        assert_eq!(result.len(), 0);
522    }
523
524    #[test]
525    fn test_gfm_alert_not_flagged_as_shortcut() {
526        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
527        let content = "> [!NOTE]\n> This is a note.\n";
528        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
529        let result = rule.check(&ctx).unwrap();
530        assert!(
531            result.is_empty(),
532            "GFM alert should not be flagged as shortcut link, got: {result:?}"
533        );
534    }
535
536    #[test]
537    fn test_various_alert_types_not_flagged() {
538        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
539        for alert_type in ["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION", "note", "info"] {
540            let content = format!("> [!{alert_type}]\n> Content.\n");
541            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
542            let result = rule.check(&ctx).unwrap();
543            assert!(
544                result.is_empty(),
545                "Alert type {alert_type} should not be flagged, got: {result:?}"
546            );
547        }
548    }
549
550    #[test]
551    fn test_shortcut_link_still_flagged_when_disallowed() {
552        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
553        let content = "See [reference] for details.\n\n[reference]: https://example.com\n";
554        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
555        let result = rule.check(&ctx).unwrap();
556        assert!(!result.is_empty(), "Regular shortcut links should still be flagged");
557    }
558
559    #[test]
560    fn test_alert_with_frontmatter_not_flagged() {
561        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
562        let content = "---\ntitle: heading\n---\n\n> [!note]\n> Content for the note.\n";
563        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
564        let result = rule.check(&ctx).unwrap();
565        assert!(
566            result.is_empty(),
567            "Alert in blockquote with frontmatter should not be flagged, got: {result:?}"
568        );
569    }
570
571    #[test]
572    fn test_alert_without_blockquote_prefix_not_flagged() {
573        // Even without the `> ` prefix, [!TYPE] is alert syntax and should not be
574        // treated as a shortcut reference
575        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
576        let content = "[!NOTE]\nSome content\n";
577        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
578        let result = rule.check(&ctx).unwrap();
579        assert!(
580            result.is_empty(),
581            "[!NOTE] without blockquote prefix should not be flagged, got: {result:?}"
582        );
583    }
584
585    #[test]
586    fn test_alert_custom_types_not_flagged() {
587        // Obsidian and other flavors support custom callout types
588        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
589        for alert_type in ["bug", "example", "quote", "abstract", "todo", "faq"] {
590            let content = format!("> [!{alert_type}]\n> Content.\n");
591            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
592            let result = rule.check(&ctx).unwrap();
593            assert!(
594                result.is_empty(),
595                "Custom alert type {alert_type} should not be flagged, got: {result:?}"
596            );
597        }
598    }
599
600    // Tests for issue #488: code spans with brackets in inline link text
601
602    #[test]
603    fn test_code_span_with_brackets_in_inline_link() {
604        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
605        let content = "Link to [`[myArray]`](#info).";
606        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
607        let result = rule.check(&ctx).unwrap();
608        // The inline link should be detected correctly, [myArray] should NOT be flagged as shortcut
609        assert!(
610            result.is_empty(),
611            "Code span with brackets in inline link should not be flagged, got: {result:?}"
612        );
613    }
614
615    #[test]
616    fn test_code_span_with_array_index_in_inline_link() {
617        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
618        let content = "See [`item[0]`](#info) for details.";
619        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
620        let result = rule.check(&ctx).unwrap();
621        assert!(
622            result.is_empty(),
623            "Array index in code span should not be flagged, got: {result:?}"
624        );
625    }
626
627    #[test]
628    fn test_code_span_with_hash_brackets_in_inline_link() {
629        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
630        let content = r#"See [`hash["key"]`](#info) for details."#;
631        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
632        let result = rule.check(&ctx).unwrap();
633        assert!(
634            result.is_empty(),
635            "Hash access in code span should not be flagged, got: {result:?}"
636        );
637    }
638
639    #[test]
640    fn test_issue_488_full_reproduction() {
641        // Exact reproduction case from issue #488
642        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
643        let content = "---\ntitle: heading\n---\n\nLink to information about [`[myArray]`](#information-on-myarray).\n\n## Information on `[myArray]`\n\nSome section content.\n";
644        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
645        let result = rule.check(&ctx).unwrap();
646        assert!(
647            result.is_empty(),
648            "Issue #488 reproduction case should produce no warnings, got: {result:?}"
649        );
650    }
651
652    #[test]
653    fn test_bracket_text_without_definition_not_flagged() {
654        // [text] without a matching [text]: url definition is NOT a link.
655        // It should never be flagged regardless of config.
656        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
657        let content = "Some [noref] text without a definition.";
658        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
659        let result = rule.check(&ctx).unwrap();
660        assert!(
661            result.is_empty(),
662            "Bracket text without definition should not be flagged as a link, got: {result:?}"
663        );
664    }
665
666    #[test]
667    fn test_array_index_notation_not_flagged() {
668        // Common bracket patterns that are not links should never be flagged
669        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
670        let content = "Access `arr[0]` and use [1] or [optional] in your code.";
671        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
672        let result = rule.check(&ctx).unwrap();
673        assert!(
674            result.is_empty(),
675            "Array indices and bracket text should not be flagged, got: {result:?}"
676        );
677    }
678
679    #[test]
680    fn test_real_shortcut_reference_still_flagged() {
681        // [text] WITH a matching definition IS a shortcut link and should be flagged
682        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
683        let content = "See [example] for details.\n\n[example]: https://example.com\n";
684        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
685        let result = rule.check(&ctx).unwrap();
686        assert_eq!(
687            result.len(),
688            1,
689            "Real shortcut reference with definition should be flagged, got: {result:?}"
690        );
691        assert!(result[0].message.contains("'shortcut'"));
692    }
693
694    #[test]
695    fn test_footnote_syntax_not_flagged_as_shortcut() {
696        // [^ref] should not be flagged as a shortcut reference
697        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
698        let content = "See [^1] for details.";
699        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700        let result = rule.check(&ctx).unwrap();
701        assert!(
702            result.is_empty(),
703            "Footnote syntax should not be flagged as shortcut, got: {result:?}"
704        );
705    }
706
707    #[test]
708    fn test_inline_link_with_code_span_detected_as_inline() {
709        // When inline is disallowed, code-span-with-brackets inline link should be flagged as inline
710        let rule = MD054LinkImageStyle::new(true, true, true, false, true, true);
711        let content = "See [`[myArray]`](#info) for details.";
712        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
713        let result = rule.check(&ctx).unwrap();
714        assert_eq!(
715            result.len(),
716            1,
717            "Inline link with code span should be flagged when inline is disallowed"
718        );
719        assert!(
720            result[0].message.contains("'inline'"),
721            "Should be flagged as 'inline' style, got: {}",
722            result[0].message
723        );
724    }
725
726    #[test]
727    fn test_autolink_only_document_not_skipped() {
728        // Document with only autolinks (no brackets) must still be checked
729        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
730        let content = "Visit <https://example.com> for more info.";
731        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
732        assert!(
733            !rule.should_skip(&ctx),
734            "should_skip must return false for autolink-only documents"
735        );
736        let result = rule.check(&ctx).unwrap();
737        assert_eq!(result.len(), 1, "Autolink should be flagged when disallowed");
738        assert!(result[0].message.contains("'autolink'"));
739    }
740
741    #[test]
742    fn test_nested_image_in_link() {
743        // [![alt](img.png)](https://example.com) — image nested inside a link
744        let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
745        let content = "[![alt text](img.png)](https://example.com)";
746        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
747        let result = rule.check(&ctx).unwrap();
748        // Both the inner image (inline) and outer link (inline) should be detected
749        assert!(
750            result.len() >= 2,
751            "Nested image-in-link should detect both elements, got: {result:?}"
752        );
753    }
754
755    #[test]
756    fn test_multi_line_link() {
757        let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
758        let content = "[long link\ntext](url)";
759        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
760        let result = rule.check(&ctx).unwrap();
761        assert_eq!(result.len(), 1, "Multi-line inline link should be detected");
762        assert!(result[0].message.contains("'inline'"));
763    }
764
765    #[test]
766    fn test_link_with_title() {
767        let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
768        let content = r#"[text](url "title")"#;
769        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770        let result = rule.check(&ctx).unwrap();
771        assert_eq!(result.len(), 1, "Link with title should be detected as inline");
772        assert!(result[0].message.contains("'inline'"));
773    }
774
775    #[test]
776    fn test_empty_link_text() {
777        let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
778        let content = "[](url)";
779        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
780        let result = rule.check(&ctx).unwrap();
781        assert_eq!(result.len(), 1, "Empty link text should be detected");
782        assert!(result[0].message.contains("'inline'"));
783    }
784
785    #[test]
786    fn test_escaped_brackets_not_detected() {
787        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
788        let content = r"\[not a link\] and also \[not this either\]";
789        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790        let result = rule.check(&ctx).unwrap();
791        assert!(
792            result.is_empty(),
793            "Escaped brackets should not be flagged, got: {result:?}"
794        );
795    }
796
797    #[test]
798    fn test_links_in_blockquotes() {
799        let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
800        let content = "> [link](url) in a blockquote";
801        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
802        let result = rule.check(&ctx).unwrap();
803        assert_eq!(result.len(), 1, "Links in blockquotes should be detected");
804        assert!(result[0].message.contains("'inline'"));
805    }
806
807    #[test]
808    fn test_image_detection() {
809        let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
810        let content = "![alt](img.png)";
811        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
812        let result = rule.check(&ctx).unwrap();
813        assert_eq!(result.len(), 1, "Inline image should be detected");
814        assert!(result[0].message.contains("'inline'"));
815    }
816}