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