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 crate::utils::range_utils::calculate_match_range;
8use regex::Regex;
9use std::collections::BTreeSet;
10use std::sync::LazyLock;
11
12mod md054_config;
13use md054_config::MD054Config;
14
15// Updated regex patterns that work with Unicode characters
16static AUTOLINK_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<([^<>]+)>").unwrap());
17static INLINE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap());
18static SHORTCUT_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]").unwrap());
19static COLLAPSED_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\[\]").unwrap());
20static FULL_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\[([^\]]+)\]").unwrap());
21static REFERENCE_DEF_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\[([^\]]+)\]:\s+(.+)$").unwrap());
22
23/// Rule MD054: Link and image style should be consistent
24///
25/// This rule is triggered when different link or image styles are used in the same document.
26/// Markdown supports various styles for links and images, and this rule enforces consistency.
27///
28/// ## Supported Link Styles
29///
30/// - **Autolink**: `<https://example.com>`
31/// - **Inline**: `[link text](https://example.com)`
32/// - **URL Inline**: Special case of inline links where the URL itself is also the link text: `[https://example.com](https://example.com)`
33/// - **Shortcut**: `[link text]` (requires a reference definition elsewhere in the document)
34/// - **Collapsed**: `[link text][]` (requires a reference definition with the same name)
35/// - **Full**: `[link text][reference]` (requires a reference definition for the reference)
36///
37/// ## Configuration Options
38///
39/// You can configure which link styles are allowed. By default, all styles are allowed:
40///
41/// ```yaml
42/// MD054:
43///   autolink: true    # Allow autolink style
44///   inline: true      # Allow inline style
45///   url_inline: true  # Allow URL inline style
46///   shortcut: true    # Allow shortcut style
47///   collapsed: true   # Allow collapsed style
48///   full: true        # Allow full style
49/// ```
50///
51/// To enforce a specific style, set only that style to `true` and all others to `false`.
52///
53/// ## Unicode Support
54///
55/// This rule fully supports Unicode characters in link text and URLs, including:
56/// - Combining characters (e.g., café)
57/// - Zero-width joiners (e.g., family emojis: 👨‍👩‍👧‍👦)
58/// - Right-to-left text (e.g., Arabic, Hebrew)
59/// - Emojis and other special characters
60///
61/// ## Rationale
62///
63/// Consistent link styles improve document readability and maintainability. Different link
64/// styles have different advantages (e.g., inline links are self-contained, reference links
65/// keep the content cleaner), but mixing styles can create confusion.
66///
67#[derive(Debug, Default, Clone)]
68pub struct MD054LinkImageStyle {
69    config: MD054Config,
70}
71
72impl MD054LinkImageStyle {
73    pub fn new(autolink: bool, collapsed: bool, full: bool, inline: bool, shortcut: bool, url_inline: bool) -> Self {
74        Self {
75            config: MD054Config {
76                autolink,
77                collapsed,
78                full,
79                inline,
80                shortcut,
81                url_inline,
82            },
83        }
84    }
85
86    pub fn from_config_struct(config: MD054Config) -> Self {
87        Self { config }
88    }
89
90    /// Check if a style is allowed based on configuration
91    fn is_style_allowed(&self, style: &str) -> bool {
92        match style {
93            "autolink" => self.config.autolink,
94            "collapsed" => self.config.collapsed,
95            "full" => self.config.full,
96            "inline" => self.config.inline,
97            "shortcut" => self.config.shortcut,
98            "url-inline" => self.config.url_inline,
99            _ => false,
100        }
101    }
102}
103
104#[derive(Debug)]
105struct LinkMatch {
106    style: &'static str,
107    start: usize,
108    end: usize,
109}
110
111impl Rule for MD054LinkImageStyle {
112    fn name(&self) -> &'static str {
113        "MD054"
114    }
115
116    fn description(&self) -> &'static str {
117        "Link and image style should be consistent"
118    }
119
120    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
121        let content = ctx.content;
122
123        // Early returns for performance
124        if content.is_empty() {
125            return Ok(Vec::new());
126        }
127
128        // Quick check for any link patterns before expensive processing
129        if !content.contains('[') && !content.contains('<') {
130            return Ok(Vec::new());
131        }
132
133        let mut warnings = Vec::new();
134        let lines = ctx.raw_lines();
135
136        for (line_num, line) in lines.iter().enumerate() {
137            // Skip code blocks and reference definitions early
138            if ctx.line_info(line_num + 1).is_some_and(|info| info.in_code_block) {
139                continue;
140            }
141            if REFERENCE_DEF_RE.is_match(line) {
142                continue;
143            }
144            if line.trim_start().starts_with("<!--") {
145                continue;
146            }
147
148            // Quick check for any link patterns in this line
149            if !line.contains('[') && !line.contains('<') {
150                continue;
151            }
152
153            // Use BTreeSet to efficiently track occupied byte ranges
154            let mut occupied_ranges = BTreeSet::new();
155            let mut filtered_matches = Vec::new();
156
157            // Collect all non-shortcut matches first and track their byte ranges
158            let mut all_matches = Vec::new();
159
160            // Find all autolinks
161            for cap in AUTOLINK_RE.captures_iter(line) {
162                let m = cap.get(0).unwrap();
163                let content = cap.get(1).unwrap().as_str();
164
165                // Filter out HTML tags: only match if content starts with a URL scheme
166                // HTML tags like <br> should not be flagged as autolinks
167                let is_url = content.starts_with("http://")
168                    || content.starts_with("https://")
169                    || content.starts_with("ftp://")
170                    || content.starts_with("ftps://")
171                    || content.starts_with("mailto:");
172
173                if is_url {
174                    all_matches.push(LinkMatch {
175                        style: "autolink",
176                        start: m.start(),
177                        end: m.end(),
178                    });
179                }
180            }
181
182            // Find all full references
183            for cap in FULL_RE.captures_iter(line) {
184                let m = cap.get(0).unwrap();
185                all_matches.push(LinkMatch {
186                    style: "full",
187                    start: m.start(),
188                    end: m.end(),
189                });
190            }
191
192            // Find all collapsed references
193            for cap in COLLAPSED_RE.captures_iter(line) {
194                let m = cap.get(0).unwrap();
195                all_matches.push(LinkMatch {
196                    style: "collapsed",
197                    start: m.start(),
198                    end: m.end(),
199                });
200            }
201
202            // Find all inline links
203            for cap in INLINE_RE.captures_iter(line) {
204                let m = cap.get(0).unwrap();
205                let text = cap.get(1).unwrap().as_str();
206                let url = cap.get(2).unwrap().as_str();
207                all_matches.push(LinkMatch {
208                    style: if text == url { "url-inline" } else { "inline" },
209                    start: m.start(),
210                    end: m.end(),
211                });
212            }
213
214            // Sort matches by start position to ensure we don't double-count
215            all_matches.sort_by_key(|m| m.start);
216
217            // Remove overlapping matches (keep the first one) and build occupied ranges set
218            let mut last_end = 0;
219            for m in all_matches {
220                if m.start >= last_end {
221                    last_end = m.end;
222                    // Add each byte in the range to the set
223                    for byte_pos in m.start..m.end {
224                        occupied_ranges.insert(byte_pos);
225                    }
226                    filtered_matches.push(m);
227                }
228            }
229
230            // Now find shortcut references that don't overlap with other matches
231            // Using BTreeSet for O(log n) lookups instead of O(n) iteration
232            for cap in SHORTCUT_RE.captures_iter(line) {
233                let m = cap.get(0).unwrap();
234                let start = m.start();
235                let end = m.end();
236                let link_text = cap.get(1).unwrap().as_str();
237
238                // Skip alert/callout syntax: [!TYPE]
239                // Used by GFM, GitLab, Hugo, Obsidian, and other markdown flavors
240                if link_text.starts_with('!') {
241                    continue;
242                }
243
244                // Filter out task list checkboxes: [ ], [x], or [X]
245                // Task list checkboxes should not be flagged as shortcut links
246                // Task list pattern: list marker (*, -, +) followed by whitespace, then [ ], [x], or [X]
247                if link_text.trim() == "" || link_text == "x" || link_text == "X" {
248                    // Check if this is preceded by a list marker with whitespace
249                    if start > 0 {
250                        let before = &line[..start];
251                        // Trim leading whitespace to handle indentation
252                        let trimmed_before = before.trim_start();
253                        // Check if starts with list marker (*, -, +) followed by whitespace
254                        if let Some(marker_char) = trimmed_before.chars().next()
255                            && (marker_char == '*' || marker_char == '-' || marker_char == '+')
256                            && trimmed_before.len() > 1
257                        {
258                            let after_marker = &trimmed_before[1..];
259                            if after_marker.chars().next().is_some_and(|c| c.is_whitespace()) {
260                                // This is a task list checkbox: marker + whitespace + [ ]
261                                continue;
262                            }
263                        }
264                    }
265                }
266
267                // Check if any byte in this range is occupied (O(log n) per byte)
268                let overlaps = (start..end).any(|byte_pos| occupied_ranges.contains(&byte_pos));
269
270                if !overlaps {
271                    // Check if followed by '(', '[', '[]', or ']['
272                    let after = &line[end..];
273                    if !after.starts_with('(') && !after.starts_with('[') {
274                        // Add this range to occupied set
275                        for byte_pos in start..end {
276                            occupied_ranges.insert(byte_pos);
277                        }
278                        filtered_matches.push(LinkMatch {
279                            style: "shortcut",
280                            start,
281                            end,
282                        });
283                    }
284                }
285            }
286
287            // Sort again after adding shortcuts
288            filtered_matches.sort_by_key(|m| m.start);
289
290            // Check each match
291            for m in filtered_matches {
292                let match_start_char = line[..m.start].chars().count();
293
294                // is_in_code_span expects 1-indexed column
295                if !ctx.is_in_code_span(line_num + 1, match_start_char + 1) && !self.is_style_allowed(m.style) {
296                    // calculate_match_range expects byte positions, not character counts
297                    let match_byte_len = m.end - m.start;
298                    let (start_line, start_col, end_line, end_col) =
299                        calculate_match_range(line_num + 1, line, m.start, match_byte_len);
300
301                    warnings.push(LintWarning {
302                        rule_name: Some(self.name().to_string()),
303                        line: start_line,
304                        column: start_col,
305                        end_line,
306                        end_column: end_col,
307                        message: format!("Link/image style '{}' is not allowed", m.style),
308                        severity: Severity::Warning,
309                        fix: None,
310                    });
311                }
312            }
313        }
314        Ok(warnings)
315    }
316
317    fn fix(&self, _ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
318        // Automatic fixing for link styles is not supported and could break content
319        Err(LintError::FixFailed(
320            "MD054 does not support automatic fixing of link/image style consistency.".to_string(),
321        ))
322    }
323
324    fn fix_capability(&self) -> crate::rule::FixCapability {
325        crate::rule::FixCapability::Unfixable
326    }
327
328    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
329        ctx.content.is_empty() || !ctx.likely_has_links_or_images()
330    }
331
332    fn as_any(&self) -> &dyn std::any::Any {
333        self
334    }
335
336    fn default_config_section(&self) -> Option<(String, toml::Value)> {
337        let json_value = serde_json::to_value(&self.config).ok()?;
338        Some((
339            self.name().to_string(),
340            crate::rule_config_serde::json_to_toml_value(&json_value)?,
341        ))
342    }
343
344    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
345    where
346        Self: Sized,
347    {
348        let rule_config = crate::rule_config_serde::load_rule_config::<MD054Config>(config);
349        Box::new(Self::from_config_struct(rule_config))
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use crate::lint_context::LintContext;
357
358    #[test]
359    fn test_all_styles_allowed_by_default() {
360        let rule = MD054LinkImageStyle::new(true, true, true, true, true, true);
361        let content = "[inline](url) [ref][] [ref] <autolink> [full][ref] [url](url)\n\n[ref]: url";
362        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
363        let result = rule.check(&ctx).unwrap();
364
365        assert_eq!(result.len(), 0);
366    }
367
368    #[test]
369    fn test_only_inline_allowed() {
370        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
371        let content = "[allowed](url) [not][ref] <https://bad.com> [bad][] [shortcut]\n\n[ref]: url\n[shortcut]: url";
372        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
373        let result = rule.check(&ctx).unwrap();
374
375        assert_eq!(result.len(), 4);
376        assert!(result[0].message.contains("'full'"));
377        assert!(result[1].message.contains("'autolink'"));
378        assert!(result[2].message.contains("'collapsed'"));
379        assert!(result[3].message.contains("'shortcut'"));
380    }
381
382    #[test]
383    fn test_only_autolink_allowed() {
384        let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
385        let content = "<https://good.com> [bad](url) [bad][ref]\n\n[ref]: url";
386        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
387        let result = rule.check(&ctx).unwrap();
388
389        assert_eq!(result.len(), 2);
390        assert!(result[0].message.contains("'inline'"));
391        assert!(result[1].message.contains("'full'"));
392    }
393
394    #[test]
395    fn test_url_inline_detection() {
396        let rule = MD054LinkImageStyle::new(false, false, false, true, false, true);
397        let content = "[https://example.com](https://example.com) [text](https://example.com)";
398        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
399        let result = rule.check(&ctx).unwrap();
400
401        // First is url_inline (allowed), second is inline (allowed)
402        assert_eq!(result.len(), 0);
403    }
404
405    #[test]
406    fn test_url_inline_not_allowed() {
407        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
408        let content = "[https://example.com](https://example.com)";
409        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
410        let result = rule.check(&ctx).unwrap();
411
412        assert_eq!(result.len(), 1);
413        assert!(result[0].message.contains("'url-inline'"));
414    }
415
416    #[test]
417    fn test_shortcut_vs_full_detection() {
418        let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
419        let content = "[shortcut] [full][ref]\n\n[shortcut]: url\n[ref]: url2";
420        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
421        let result = rule.check(&ctx).unwrap();
422
423        // Only shortcut should be flagged
424        assert_eq!(result.len(), 1);
425        assert!(result[0].message.contains("'shortcut'"));
426    }
427
428    #[test]
429    fn test_collapsed_reference() {
430        let rule = MD054LinkImageStyle::new(false, true, false, false, false, false);
431        let content = "[collapsed][] [bad][ref]\n\n[collapsed]: url\n[ref]: url2";
432        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
433        let result = rule.check(&ctx).unwrap();
434
435        assert_eq!(result.len(), 1);
436        assert!(result[0].message.contains("'full'"));
437    }
438
439    #[test]
440    fn test_code_blocks_ignored() {
441        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
442        let content = "```\n[ignored](url) <https://ignored.com>\n```\n\n[checked](url)";
443        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
444        let result = rule.check(&ctx).unwrap();
445
446        // Only the link outside code block should be checked
447        assert_eq!(result.len(), 0);
448    }
449
450    #[test]
451    fn test_code_spans_ignored() {
452        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
453        let content = "`[ignored](url)` and `<https://ignored.com>` but [checked](url)";
454        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
455        let result = rule.check(&ctx).unwrap();
456
457        // Only the link outside code spans should be checked
458        assert_eq!(result.len(), 0);
459    }
460
461    #[test]
462    fn test_reference_definitions_ignored() {
463        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
464        let content = "[ref]: https://example.com\n[ref2]: <https://example2.com>";
465        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
466        let result = rule.check(&ctx).unwrap();
467
468        // Reference definitions should be ignored
469        assert_eq!(result.len(), 0);
470    }
471
472    #[test]
473    fn test_html_comments_ignored() {
474        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
475        let content = "<!-- [ignored](url) -->\n  <!-- <https://ignored.com> -->";
476        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
477        let result = rule.check(&ctx).unwrap();
478
479        assert_eq!(result.len(), 0);
480    }
481
482    #[test]
483    fn test_unicode_support() {
484        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
485        let content = "[café ☕](https://café.com) [emoji 😀](url) [한글](url) [עברית](url)";
486        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
487        let result = rule.check(&ctx).unwrap();
488
489        // All should be detected as inline (allowed)
490        assert_eq!(result.len(), 0);
491    }
492
493    #[test]
494    fn test_line_positions() {
495        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
496        let content = "Line 1\n\nLine 3 with <https://bad.com> here";
497        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
498        let result = rule.check(&ctx).unwrap();
499
500        assert_eq!(result.len(), 1);
501        assert_eq!(result[0].line, 3);
502        assert_eq!(result[0].column, 13); // Position of '<'
503    }
504
505    #[test]
506    fn test_multiple_links_same_line() {
507        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
508        let content = "[ok](url) but <https://good.com> and [also][bad]\n\n[bad]: url";
509        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
510        let result = rule.check(&ctx).unwrap();
511
512        assert_eq!(result.len(), 2);
513        assert!(result[0].message.contains("'autolink'"));
514        assert!(result[1].message.contains("'full'"));
515    }
516
517    #[test]
518    fn test_empty_content() {
519        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
520        let content = "";
521        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
522        let result = rule.check(&ctx).unwrap();
523
524        assert_eq!(result.len(), 0);
525    }
526
527    #[test]
528    fn test_no_links() {
529        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
530        let content = "Just plain text without any links";
531        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532        let result = rule.check(&ctx).unwrap();
533
534        assert_eq!(result.len(), 0);
535    }
536
537    #[test]
538    fn test_fix_returns_error() {
539        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
540        let content = "[link](url)";
541        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
542        let result = rule.fix(&ctx);
543
544        assert!(result.is_err());
545        if let Err(LintError::FixFailed(msg)) = result {
546            assert!(msg.contains("does not support automatic fixing"));
547        }
548    }
549
550    #[test]
551    fn test_priority_order() {
552        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
553        // Test that [text][ref] is detected as full, not shortcut
554        let content = "[text][ref] not detected as [shortcut]\n\n[ref]: url\n[shortcut]: url2";
555        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
556        let result = rule.check(&ctx).unwrap();
557
558        assert_eq!(result.len(), 2);
559        assert!(result[0].message.contains("'full'"));
560        assert!(result[1].message.contains("'shortcut'"));
561    }
562
563    #[test]
564    fn test_not_shortcut_when_followed_by_bracket() {
565        let rule = MD054LinkImageStyle::new(false, false, false, true, true, false);
566        // [text][ should not be detected as shortcut
567        let content = "[text][ more text\n[text](url) is inline";
568        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
569        let result = rule.check(&ctx).unwrap();
570
571        // Only second line should have inline link
572        assert_eq!(result.len(), 0);
573    }
574
575    #[test]
576    fn test_cjk_correct_column_positions() {
577        // Verify that column positions use byte offsets, not character counts,
578        // so CJK text produces correct warning positions.
579        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
580        // "日本語テスト " = 7 chars, 19 bytes (6 CJK chars * 3 bytes + 1 space)
581        let content = "日本語テスト <https://example.com>";
582        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
583        let result = rule.check(&ctx).unwrap();
584
585        assert_eq!(result.len(), 1);
586        assert!(result[0].message.contains("'autolink'"));
587        // The '<' starts at byte position 19 (after 6 CJK chars * 3 bytes + 1 space)
588        // which is character position 8 (1-indexed)
589        assert_eq!(
590            result[0].column, 8,
591            "Column should be 1-indexed character position of '<'"
592        );
593    }
594
595    #[test]
596    fn test_code_span_detection_with_cjk_prefix() {
597        // Verify that is_in_code_span correctly detects code spans after CJK text
598        // This tests the 1-indexed column fix
599        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
600        // Link inside code span after CJK characters
601        let content = "日本語 `[link](url)` text";
602        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
603        let result = rule.check(&ctx).unwrap();
604
605        // The link is inside a code span, so it should not be flagged
606        assert_eq!(result.len(), 0, "Link inside code span should not be flagged");
607    }
608
609    #[test]
610    fn test_complex_unicode_with_zwj() {
611        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
612        // Test with zero-width joiners and complex Unicode
613        let content = "[👨‍👩‍👧‍👦 family](url) [café☕](https://café.com)";
614        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
615        let result = rule.check(&ctx).unwrap();
616
617        // Both should be detected as inline (allowed)
618        assert_eq!(result.len(), 0);
619    }
620
621    #[test]
622    fn test_gfm_alert_not_flagged_as_shortcut() {
623        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
624        let content = "> [!NOTE]\n> This is a note.\n";
625        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
626        let result = rule.check(&ctx).unwrap();
627        assert!(
628            result.is_empty(),
629            "GFM alert should not be flagged as shortcut link, got: {result:?}"
630        );
631    }
632
633    #[test]
634    fn test_various_alert_types_not_flagged() {
635        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
636        for alert_type in ["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION", "note", "info"] {
637            let content = format!("> [!{alert_type}]\n> Content.\n");
638            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
639            let result = rule.check(&ctx).unwrap();
640            assert!(
641                result.is_empty(),
642                "Alert type {alert_type} should not be flagged, got: {result:?}"
643            );
644        }
645    }
646
647    #[test]
648    fn test_shortcut_link_still_flagged_when_disallowed() {
649        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
650        let content = "See [reference] for details.\n\n[reference]: https://example.com\n";
651        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
652        let result = rule.check(&ctx).unwrap();
653        assert!(!result.is_empty(), "Regular shortcut links should still be flagged");
654    }
655
656    #[test]
657    fn test_alert_with_frontmatter_not_flagged() {
658        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
659        let content = "---\ntitle: heading\n---\n\n> [!note]\n> Content for the note.\n";
660        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
661        let result = rule.check(&ctx).unwrap();
662        assert!(
663            result.is_empty(),
664            "Alert in blockquote with frontmatter should not be flagged, got: {result:?}"
665        );
666    }
667
668    #[test]
669    fn test_alert_without_blockquote_prefix_not_flagged() {
670        // Even without the `> ` prefix, [!TYPE] is alert syntax and should not be
671        // treated as a shortcut reference
672        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
673        let content = "[!NOTE]\nSome content\n";
674        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
675        let result = rule.check(&ctx).unwrap();
676        assert!(
677            result.is_empty(),
678            "[!NOTE] without blockquote prefix should not be flagged, got: {result:?}"
679        );
680    }
681
682    #[test]
683    fn test_alert_custom_types_not_flagged() {
684        // Obsidian and other flavors support custom callout types
685        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
686        for alert_type in ["bug", "example", "quote", "abstract", "todo", "faq"] {
687            let content = format!("> [!{alert_type}]\n> Content.\n");
688            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
689            let result = rule.check(&ctx).unwrap();
690            assert!(
691                result.is_empty(),
692                "Custom alert type {alert_type} should not be flagged, got: {result:?}"
693            );
694        }
695    }
696}