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::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
7use pulldown_cmark::LinkType;
8use std::collections::HashMap;
9
10mod label;
11mod md054_config;
12mod transform;
13
14use md054_config::{MD054Config, PreferredStyles};
15
16/// URL-inline labels must display the literal URL. Parse labels containing
17/// possible inline syntax to preserve authored formatting during conversion.
18fn link_text_matches_url(text: &str, url: &str) -> bool {
19    let equal = text == url;
20    if equal && !text.contains(['\\', '`', '*', '_', '~', '[', ']', '&', '<', '>']) {
21        return true;
22    }
23    if !equal && !text.contains('\\') {
24        return false;
25    }
26    let mut remaining = url;
27    for event in pulldown_cmark::Parser::new_ext(text, pulldown_cmark::Options::ENABLE_STRIKETHROUGH) {
28        match event {
29            pulldown_cmark::Event::Start(pulldown_cmark::Tag::Paragraph)
30            | pulldown_cmark::Event::End(pulldown_cmark::TagEnd::Paragraph) => {}
31            pulldown_cmark::Event::Text(value) => {
32                let Some(rest) = remaining.strip_prefix(value.as_ref()) else {
33                    return false;
34                };
35                remaining = rest;
36            }
37            _ => return false,
38        }
39    }
40    remaining.is_empty()
41}
42
43/// Rule MD054: Link and image style should be consistent
44///
45/// This rule is triggered when different link or image styles are used in the same document.
46/// Markdown supports various styles for links and images, and this rule enforces consistency.
47///
48/// ## Supported Link Styles
49///
50/// - **Autolink**: `<https://example.com>`
51/// - **Inline**: `[link text](https://example.com)`
52/// - **URL Inline**: Special case of inline links where the URL itself is also the link text: `[https://example.com](https://example.com)`
53/// - **Shortcut**: `[link text]` (requires a reference definition elsewhere in the document)
54/// - **Collapsed**: `[link text][]` (requires a reference definition with the same name)
55/// - **Full**: `[link text][reference]` (requires a reference definition for the reference)
56///
57/// ## Configuration Options
58///
59/// You can configure which link styles are allowed. By default, all styles are allowed:
60///
61/// ```yaml
62/// MD054:
63///   autolink: true    # Allow autolink style
64///   inline: true      # Allow inline style
65///   url_inline: true  # Allow URL inline style
66///   shortcut: true    # Allow shortcut style
67///   collapsed: true   # Allow collapsed style
68///   full: true        # Allow full style
69/// ```
70///
71/// To enforce a specific style, set only that style to `true` and all others to `false`.
72///
73/// ## Unicode Support
74///
75/// This rule fully supports Unicode characters in link text and URLs, including:
76/// - Combining characters (e.g., cafe)
77/// - Zero-width joiners (e.g., family emojis)
78/// - Right-to-left text (e.g., Arabic, Hebrew)
79/// - Emojis and other special characters
80///
81/// ## Rationale
82///
83/// Consistent link styles improve document readability and maintainability. Different link
84/// styles have different advantages (e.g., inline links are self-contained, reference links
85/// keep the content cleaner), but mixing styles can create confusion.
86///
87#[derive(Debug, Default, Clone)]
88pub struct MD054LinkImageStyle {
89    config: MD054Config,
90}
91
92impl MD054LinkImageStyle {
93    pub fn new(autolink: bool, collapsed: bool, full: bool, inline: bool, shortcut: bool, url_inline: bool) -> Self {
94        Self {
95            config: MD054Config {
96                autolink,
97                collapsed,
98                full,
99                inline,
100                shortcut,
101                url_inline,
102                preferred_style: PreferredStyles::default(),
103            },
104        }
105    }
106
107    pub fn from_config_struct(config: MD054Config) -> Self {
108        Self { config }
109    }
110
111    /// Convert a byte offset to a 1-indexed character column within its line.
112    /// Only called for disallowed links (cold path), so O(line_length) is fine.
113    fn byte_to_char_col(content: &str, byte_offset: usize) -> usize {
114        let before = &content[..byte_offset];
115        let last_newline = before.rfind('\n').map_or(0, |i| i + 1);
116        before[last_newline..].chars().count() + 1
117    }
118
119    /// Check if a style is allowed based on configuration
120    fn is_style_allowed(&self, style: &str) -> bool {
121        match style {
122            "autolink" => self.config.autolink,
123            "collapsed" => self.config.collapsed,
124            "full" => self.config.full,
125            "inline" => self.config.inline,
126            "shortcut" => self.config.shortcut,
127            "url-inline" => self.config.url_inline,
128            _ => false,
129        }
130    }
131}
132
133impl Rule for MD054LinkImageStyle {
134    fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
135        (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(54))
136    }
137
138    fn name(&self) -> &'static str {
139        "MD054"
140    }
141
142    fn description(&self) -> &'static str {
143        "Link and image style should be consistent"
144    }
145
146    fn category(&self) -> RuleCategory {
147        RuleCategory::Link
148    }
149
150    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
151        let content = ctx.content;
152        let mut warnings = Vec::new();
153
154        // Compute the fix plan once and index its planned entries by source
155        // byte offset. Each warning whose link/image has an entry gets a Fix
156        // attached so the fix coordinator (which gates on `fix.is_some()`) and
157        // LSP code actions both see this rule as fixable.
158        //
159        // Conversions that require a new reference definition (inline → full,
160        // etc.) carry the EOF-appended ref-def as an `additional_edit` on the
161        // per-warning Fix, so quick-fix paths that apply a single warning
162        // produce a complete result (link rewrite + ref def) atomically.
163        let plan = if self.should_skip(ctx) {
164            transform::FixPlan::default()
165        } else {
166            transform::plan(ctx, &self.config)
167        };
168        let entries_by_offset: HashMap<usize, &transform::PlannedEdit> =
169            plan.entries.iter().map(|e| (e.edit.range.start, e)).collect();
170        let build_fix = |offset: usize| -> Option<Fix> {
171            let entry = entries_by_offset.get(&offset)?;
172            let primary_range = entry.edit.range.clone();
173            let primary_replacement = entry.edit.replacement.clone();
174            match &entry.new_ref {
175                None => Some(Fix::new(primary_range, primary_replacement)),
176                Some(def) => {
177                    let appended = transform::render_ref_def_append(content, def)?;
178                    let eof_range = content.len()..content.len();
179                    Some(Fix::with_additional_edits(
180                        primary_range,
181                        primary_replacement,
182                        vec![Fix::new(eof_range, appended)],
183                    ))
184                }
185            }
186        };
187
188        // Process links from pre-parsed data
189        for link in ctx.links() {
190            // Skip broken references (empty URL means unresolved reference)
191            if matches!(
192                link.link_type,
193                LinkType::Reference | LinkType::Collapsed | LinkType::Shortcut
194            ) && link.url.is_empty()
195            {
196                continue;
197            }
198
199            let style = match link.link_type {
200                LinkType::Autolink | LinkType::Email => "autolink",
201                LinkType::Inline => {
202                    if link_text_matches_url(&link.text, &link.url) {
203                        "url-inline"
204                    } else {
205                        "inline"
206                    }
207                }
208                LinkType::Reference => "full",
209                LinkType::Collapsed => "collapsed",
210                LinkType::Shortcut => "shortcut",
211                _ => continue,
212            };
213
214            if transform::skip_non_prose(ctx, link.line, link.byte_offset, link.byte_end) {
215                continue;
216            }
217
218            if !self.is_style_allowed(style) {
219                let start_col = Self::byte_to_char_col(content, link.byte_offset);
220                let (end_line, _) = ctx.offset_to_line_col(link.byte_end);
221                let end_col = Self::byte_to_char_col(content, link.byte_end);
222
223                warnings.push(LintWarning {
224                    rule_name: Some(self.name().to_string()),
225                    line: link.line,
226                    column: start_col,
227                    end_line,
228                    end_column: end_col,
229                    message: format!("Link/image style '{style}' is not allowed"),
230                    severity: Severity::Warning,
231                    fix: build_fix(link.byte_offset),
232                });
233            }
234        }
235
236        // Process images from pre-parsed data
237        for image in ctx.images() {
238            // Skip broken references (empty URL means unresolved reference)
239            if matches!(
240                image.link_type,
241                LinkType::Reference | LinkType::Collapsed | LinkType::Shortcut
242            ) && image.url.is_empty()
243            {
244                continue;
245            }
246
247            let style = match image.link_type {
248                LinkType::Autolink | LinkType::Email => "autolink",
249                LinkType::Inline => {
250                    if image.alt_text == image.url {
251                        "url-inline"
252                    } else {
253                        "inline"
254                    }
255                }
256                LinkType::Reference => "full",
257                LinkType::Collapsed => "collapsed",
258                LinkType::Shortcut => "shortcut",
259                _ => continue,
260            };
261
262            if transform::skip_non_prose(ctx, image.line, image.byte_offset, image.byte_end) {
263                continue;
264            }
265
266            if !self.is_style_allowed(style) {
267                let start_col = Self::byte_to_char_col(content, image.byte_offset);
268                let (end_line, _) = ctx.offset_to_line_col(image.byte_end);
269                let end_col = Self::byte_to_char_col(content, image.byte_end);
270
271                warnings.push(LintWarning {
272                    rule_name: Some(self.name().to_string()),
273                    line: image.line,
274                    column: start_col,
275                    end_line,
276                    end_column: end_col,
277                    message: format!("Link/image style '{style}' is not allowed"),
278                    severity: Severity::Warning,
279                    fix: build_fix(image.byte_offset),
280                });
281            }
282        }
283
284        Ok(warnings)
285    }
286
287    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
288        if self.should_skip(ctx) {
289            return Ok(ctx.content.to_string());
290        }
291        let plan = transform::plan(ctx, &self.config);
292        Ok(transform::apply(ctx.content, plan))
293    }
294
295    fn fix_capability(&self) -> crate::rule::FixCapability {
296        // Some (source, target) pairs are intentionally not auto-fixed —
297        // see `transform::reachable`.
298        crate::rule::FixCapability::ConditionallyFixable
299    }
300
301    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
302        ctx.content.is_empty() || (!ctx.likely_has_links_or_images() && !ctx.likely_has_html())
303    }
304
305    fn as_any(&self) -> &dyn std::any::Any {
306        self
307    }
308
309    crate::impl_rule_config_methods!(MD054Config);
310
311    fn polymorphic_config_keys(&self) -> &'static [&'static str] {
312        // `preferred-style` accepts either a scalar string or a list of strings.
313        // The serialized default can only encode one variant, so the registry
314        // replaces this entry with a polymorphic sentinel for validation while
315        // the user-facing default config (`rumdl config --defaults`) keeps the
316        // serialized scalar form.
317        &["preferred-style"]
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::lint_context::LintContext;
325
326    #[test]
327    fn test_all_styles_allowed_by_default() {
328        let rule = MD054LinkImageStyle::new(true, true, true, true, true, true);
329        let content = "[inline](url) [ref][] [ref] <https://autolink.com> [full][ref] [url](url)\n\n[ref]: url";
330        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
331        let result = rule.check(&ctx).unwrap();
332
333        assert_eq!(result.len(), 0);
334    }
335
336    #[test]
337    fn test_only_inline_allowed() {
338        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
339        // [bad][] has no definition for "bad", so pulldown-cmark doesn't emit it as a link
340        let content = "[allowed](url) [not][ref] <https://bad.com> [collapsed][] [shortcut]\n\n[ref]: url\n[shortcut]: url\n[collapsed]: url";
341        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
342        let result = rule.check(&ctx).unwrap();
343
344        assert_eq!(result.len(), 4, "Expected 4 warnings, got: {result:?}");
345        assert!(result[0].message.contains("'full'"));
346        assert!(result[1].message.contains("'autolink'"));
347        assert!(result[2].message.contains("'collapsed'"));
348        assert!(result[3].message.contains("'shortcut'"));
349    }
350
351    #[test]
352    fn test_only_autolink_allowed() {
353        let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
354        let content = "<https://good.com> [bad](url) [bad][ref]\n\n[ref]: url";
355        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
356        let result = rule.check(&ctx).unwrap();
357
358        assert_eq!(result.len(), 2, "Expected 2 warnings, got: {result:?}");
359        assert!(result[0].message.contains("'inline'"));
360        assert!(result[1].message.contains("'full'"));
361    }
362
363    #[test]
364    fn test_url_inline_detection() {
365        let rule = MD054LinkImageStyle::new(false, false, false, true, false, true);
366        let content = "[https://example.com](https://example.com) [text](https://example.com)";
367        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
368        let result = rule.check(&ctx).unwrap();
369
370        // First is url_inline (allowed), second is inline (allowed)
371        assert_eq!(result.len(), 0);
372    }
373
374    #[test]
375    fn test_url_inline_not_allowed() {
376        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
377        let content = "[https://example.com](https://example.com)";
378        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
379        let result = rule.check(&ctx).unwrap();
380
381        assert_eq!(result.len(), 1);
382        assert!(result[0].message.contains("'url-inline'"));
383    }
384
385    #[test]
386    fn test_shortcut_vs_full_detection() {
387        let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
388        let content = "[shortcut] [full][ref]\n\n[shortcut]: url\n[ref]: url2";
389        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
390        let result = rule.check(&ctx).unwrap();
391
392        // Only shortcut should be flagged
393        assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
394        assert!(result[0].message.contains("'shortcut'"));
395    }
396
397    #[test]
398    fn test_collapsed_reference() {
399        let rule = MD054LinkImageStyle::new(false, true, false, false, false, false);
400        let content = "[collapsed][] [bad][ref]\n\n[collapsed]: url\n[ref]: url2";
401        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
402        let result = rule.check(&ctx).unwrap();
403
404        assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
405        assert!(result[0].message.contains("'full'"));
406    }
407
408    #[test]
409    fn test_code_blocks_ignored() {
410        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
411        let content = "```\n[ignored](url) <https://ignored.com>\n```\n\n[checked](url)";
412        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
413        let result = rule.check(&ctx).unwrap();
414
415        // Only the link outside code block should be checked
416        assert_eq!(result.len(), 0);
417    }
418
419    #[test]
420    fn test_code_spans_ignored() {
421        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
422        let content = "`[ignored](url)` and `<https://ignored.com>` but [checked](url)";
423        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
424        let result = rule.check(&ctx).unwrap();
425
426        // Only the link outside code spans should be checked
427        assert_eq!(result.len(), 0);
428    }
429
430    #[test]
431    fn test_reference_definitions_ignored() {
432        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
433        let content = "[ref]: https://example.com\n[ref2]: <https://example2.com>";
434        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
435        let result = rule.check(&ctx).unwrap();
436
437        // Reference definitions should be ignored
438        assert_eq!(result.len(), 0);
439    }
440
441    #[test]
442    fn test_html_comments_ignored() {
443        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
444        let content = "<!-- [ignored](url) -->\n  <!-- <https://ignored.com> -->";
445        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
446        let result = rule.check(&ctx).unwrap();
447
448        assert_eq!(result.len(), 0);
449    }
450
451    #[test]
452    fn test_unicode_support() {
453        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
454        let content = "[cafe](https://cafe.com) [emoji](url) [korean](url) [hebrew](url)";
455        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
456        let result = rule.check(&ctx).unwrap();
457
458        // All should be detected as inline (allowed)
459        assert_eq!(result.len(), 0);
460    }
461
462    #[test]
463    fn test_line_positions() {
464        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
465        let content = "Line 1\n\nLine 3 with <https://bad.com> here";
466        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
467        let result = rule.check(&ctx).unwrap();
468
469        assert_eq!(result.len(), 1);
470        assert_eq!(result[0].line, 3);
471        assert_eq!(result[0].column, 13); // Position of '<'
472    }
473
474    #[test]
475    fn test_multiple_links_same_line() {
476        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
477        let content = "[ok](url) but <https://good.com> and [also][bad]\n\n[bad]: url";
478        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
479        let result = rule.check(&ctx).unwrap();
480
481        assert_eq!(result.len(), 2, "Expected 2 warnings, got: {result:?}");
482        assert!(result[0].message.contains("'autolink'"));
483        assert!(result[1].message.contains("'full'"));
484    }
485
486    #[test]
487    fn test_empty_content() {
488        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
489        let content = "";
490        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
491        let result = rule.check(&ctx).unwrap();
492
493        assert_eq!(result.len(), 0);
494    }
495
496    #[test]
497    fn test_no_links() {
498        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
499        let content = "Just plain text without any links";
500        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
501        let result = rule.check(&ctx).unwrap();
502
503        assert_eq!(result.len(), 0);
504    }
505
506    #[test]
507    fn test_fix_unreachable_target_is_noop() {
508        // inline disallowed but no reachable reference style allowed (only autolink),
509        // and the link's text doesn't match its url so autolink is unreachable too.
510        // The fix should leave the content unchanged rather than error.
511        let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
512        let content = "[link](url)";
513        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
514        let fixed = rule.fix(&ctx).unwrap();
515        assert_eq!(fixed, content);
516    }
517
518    #[test]
519    fn test_priority_order() {
520        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
521        // Test that [text][ref] is detected as full, not shortcut
522        let content = "[text][ref] not detected as [shortcut]\n\n[ref]: url\n[shortcut]: url2";
523        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
524        let result = rule.check(&ctx).unwrap();
525
526        assert_eq!(result.len(), 2, "Expected 2 warnings, got: {result:?}");
527        assert!(result[0].message.contains("'full'"));
528        assert!(result[1].message.contains("'shortcut'"));
529    }
530
531    #[test]
532    fn test_not_shortcut_when_followed_by_bracket() {
533        let rule = MD054LinkImageStyle::new(false, false, false, true, true, false);
534        // [text][ should not be detected as shortcut
535        let content = "[text][ more text\n[text](url) is inline";
536        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
537        let result = rule.check(&ctx).unwrap();
538
539        // Only second line should have inline link
540        assert_eq!(result.len(), 0);
541    }
542
543    #[test]
544    fn test_cjk_correct_column_positions() {
545        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
546        let content = "日本語テスト <https://example.com>";
547        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
548        let result = rule.check(&ctx).unwrap();
549
550        assert_eq!(result.len(), 1);
551        assert!(result[0].message.contains("'autolink'"));
552        // The '<' starts at byte position 19 (after 6 CJK chars * 3 bytes + 1 space)
553        // which is character position 8 (1-indexed)
554        assert_eq!(
555            result[0].column, 8,
556            "Column should be 1-indexed character position of '<'"
557        );
558    }
559
560    #[test]
561    fn test_code_span_detection_with_cjk_prefix() {
562        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
563        // Link inside code span after CJK characters
564        let content = "日本語 `[link](url)` text";
565        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
566        let result = rule.check(&ctx).unwrap();
567
568        // The link is inside a code span, so it should not be flagged
569        assert_eq!(result.len(), 0, "Link inside code span should not be flagged");
570    }
571
572    #[test]
573    fn test_complex_unicode_with_zwj() {
574        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
575        let content = "[family](url) [cafe](https://cafe.com)";
576        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
577        let result = rule.check(&ctx).unwrap();
578
579        // Both should be detected as inline (allowed)
580        assert_eq!(result.len(), 0);
581    }
582
583    #[test]
584    fn test_gfm_alert_not_flagged_as_shortcut() {
585        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
586        let content = "> [!NOTE]\n> This is a note.\n";
587        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
588        let result = rule.check(&ctx).unwrap();
589        assert!(
590            result.is_empty(),
591            "GFM alert should not be flagged as shortcut link, got: {result:?}"
592        );
593    }
594
595    #[test]
596    fn test_various_alert_types_not_flagged() {
597        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
598        for alert_type in ["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION", "note", "info"] {
599            let content = format!("> [!{alert_type}]\n> Content.\n");
600            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
601            let result = rule.check(&ctx).unwrap();
602            assert!(
603                result.is_empty(),
604                "Alert type {alert_type} should not be flagged, got: {result:?}"
605            );
606        }
607    }
608
609    #[test]
610    fn test_shortcut_link_still_flagged_when_disallowed() {
611        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
612        let content = "See [reference] for details.\n\n[reference]: https://example.com\n";
613        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
614        let result = rule.check(&ctx).unwrap();
615        assert!(!result.is_empty(), "Regular shortcut links should still be flagged");
616    }
617
618    #[test]
619    fn test_alert_with_frontmatter_not_flagged() {
620        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
621        let content = "---\ntitle: heading\n---\n\n> [!note]\n> Content for the note.\n";
622        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
623        let result = rule.check(&ctx).unwrap();
624        assert!(
625            result.is_empty(),
626            "Alert in blockquote with frontmatter should not be flagged, got: {result:?}"
627        );
628    }
629
630    #[test]
631    fn test_alert_without_blockquote_prefix_not_flagged() {
632        // Even without the `> ` prefix, [!TYPE] is alert syntax and should not be
633        // treated as a shortcut reference
634        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
635        let content = "[!NOTE]\nSome content\n";
636        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
637        let result = rule.check(&ctx).unwrap();
638        assert!(
639            result.is_empty(),
640            "[!NOTE] without blockquote prefix should not be flagged, got: {result:?}"
641        );
642    }
643
644    #[test]
645    fn test_alert_custom_types_not_flagged() {
646        // Obsidian and other flavors support custom callout types
647        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
648        for alert_type in ["bug", "example", "quote", "abstract", "todo", "faq"] {
649            let content = format!("> [!{alert_type}]\n> Content.\n");
650            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
651            let result = rule.check(&ctx).unwrap();
652            assert!(
653                result.is_empty(),
654                "Custom alert type {alert_type} should not be flagged, got: {result:?}"
655            );
656        }
657    }
658
659    // Tests for issue #488: code spans with brackets in inline link text
660
661    #[test]
662    fn test_code_span_with_brackets_in_inline_link() {
663        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
664        let content = "Link to [`[myArray]`](#info).";
665        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
666        let result = rule.check(&ctx).unwrap();
667        // The inline link should be detected correctly, [myArray] should NOT be flagged as shortcut
668        assert!(
669            result.is_empty(),
670            "Code span with brackets in inline link should not be flagged, got: {result:?}"
671        );
672    }
673
674    #[test]
675    fn test_code_span_with_array_index_in_inline_link() {
676        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
677        let content = "See [`item[0]`](#info) for details.";
678        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
679        let result = rule.check(&ctx).unwrap();
680        assert!(
681            result.is_empty(),
682            "Array index in code span should not be flagged, got: {result:?}"
683        );
684    }
685
686    #[test]
687    fn test_code_span_with_hash_brackets_in_inline_link() {
688        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
689        let content = r#"See [`hash["key"]`](#info) for details."#;
690        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
691        let result = rule.check(&ctx).unwrap();
692        assert!(
693            result.is_empty(),
694            "Hash access in code span should not be flagged, got: {result:?}"
695        );
696    }
697
698    #[test]
699    fn test_issue_488_full_reproduction() {
700        // Exact reproduction case from issue #488
701        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
702        let content = "---\ntitle: heading\n---\n\nLink to information about [`[myArray]`](#information-on-myarray).\n\n## Information on `[myArray]`\n\nSome section content.\n";
703        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
704        let result = rule.check(&ctx).unwrap();
705        assert!(
706            result.is_empty(),
707            "Issue #488 reproduction case should produce no warnings, got: {result:?}"
708        );
709    }
710
711    #[test]
712    fn test_bracket_text_without_definition_not_flagged() {
713        // [text] without a matching [text]: url definition is NOT a link.
714        // It should never be flagged regardless of config.
715        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
716        let content = "Some [noref] text without a definition.";
717        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
718        let result = rule.check(&ctx).unwrap();
719        assert!(
720            result.is_empty(),
721            "Bracket text without definition should not be flagged as a link, got: {result:?}"
722        );
723    }
724
725    #[test]
726    fn test_array_index_notation_not_flagged() {
727        // Common bracket patterns that are not links should never be flagged
728        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
729        let content = "Access `arr[0]` and use [1] or [optional] in your code.";
730        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
731        let result = rule.check(&ctx).unwrap();
732        assert!(
733            result.is_empty(),
734            "Array indices and bracket text should not be flagged, got: {result:?}"
735        );
736    }
737
738    #[test]
739    fn test_real_shortcut_reference_still_flagged() {
740        // [text] WITH a matching definition IS a shortcut link and should be flagged
741        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
742        let content = "See [example] for details.\n\n[example]: https://example.com\n";
743        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
744        let result = rule.check(&ctx).unwrap();
745        assert_eq!(
746            result.len(),
747            1,
748            "Real shortcut reference with definition should be flagged, got: {result:?}"
749        );
750        assert!(result[0].message.contains("'shortcut'"));
751    }
752
753    #[test]
754    fn test_footnote_syntax_not_flagged_as_shortcut() {
755        // [^ref] should not be flagged as a shortcut reference
756        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
757        let content = "See [^1] for details.";
758        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
759        let result = rule.check(&ctx).unwrap();
760        assert!(
761            result.is_empty(),
762            "Footnote syntax should not be flagged as shortcut, got: {result:?}"
763        );
764    }
765
766    #[test]
767    fn test_inline_link_with_code_span_detected_as_inline() {
768        // When inline is disallowed, code-span-with-brackets inline link should be flagged as inline
769        let rule = MD054LinkImageStyle::new(true, true, true, false, true, true);
770        let content = "See [`[myArray]`](#info) for details.";
771        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
772        let result = rule.check(&ctx).unwrap();
773        assert_eq!(
774            result.len(),
775            1,
776            "Inline link with code span should be flagged when inline is disallowed"
777        );
778        assert!(
779            result[0].message.contains("'inline'"),
780            "Should be flagged as 'inline' style, got: {}",
781            result[0].message
782        );
783    }
784
785    #[test]
786    fn test_autolink_only_document_not_skipped() {
787        // Document with only autolinks (no brackets) must still be checked
788        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
789        let content = "Visit <https://example.com> for more info.";
790        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
791        assert!(
792            !rule.should_skip(&ctx),
793            "should_skip must return false for autolink-only documents"
794        );
795        let result = rule.check(&ctx).unwrap();
796        assert_eq!(result.len(), 1, "Autolink should be flagged when disallowed");
797        assert!(result[0].message.contains("'autolink'"));
798    }
799
800    #[test]
801    fn test_nested_image_in_link() {
802        // [![alt](img.png)](https://example.com) — image nested inside a link
803        let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
804        let content = "[![alt text](img.png)](https://example.com)";
805        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
806        let result = rule.check(&ctx).unwrap();
807        // Both the inner image (inline) and outer link (inline) should be detected
808        assert!(
809            result.len() >= 2,
810            "Nested image-in-link should detect both elements, got: {result:?}"
811        );
812    }
813
814    #[test]
815    fn test_multi_line_link() {
816        let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
817        let content = "[long link\ntext](url)";
818        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
819        let result = rule.check(&ctx).unwrap();
820        assert_eq!(result.len(), 1, "Multi-line inline link should be detected");
821        assert!(result[0].message.contains("'inline'"));
822    }
823
824    #[test]
825    fn test_link_with_title() {
826        let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
827        let content = r#"[text](url "title")"#;
828        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
829        let result = rule.check(&ctx).unwrap();
830        assert_eq!(result.len(), 1, "Link with title should be detected as inline");
831        assert!(result[0].message.contains("'inline'"));
832    }
833
834    #[test]
835    fn test_empty_link_text() {
836        let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
837        let content = "[](url)";
838        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
839        let result = rule.check(&ctx).unwrap();
840        assert_eq!(result.len(), 1, "Empty link text should be detected");
841        assert!(result[0].message.contains("'inline'"));
842    }
843
844    #[test]
845    fn test_escaped_brackets_not_detected() {
846        let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
847        let content = r"\[not a link\] and also \[not this either\]";
848        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
849        let result = rule.check(&ctx).unwrap();
850        assert!(
851            result.is_empty(),
852            "Escaped brackets should not be flagged, got: {result:?}"
853        );
854    }
855
856    #[test]
857    fn test_links_in_blockquotes() {
858        let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
859        let content = "> [link](url) in a blockquote";
860        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
861        let result = rule.check(&ctx).unwrap();
862        assert_eq!(result.len(), 1, "Links in blockquotes should be detected");
863        assert!(result[0].message.contains("'inline'"));
864    }
865
866    #[test]
867    fn test_image_detection() {
868        let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
869        let content = "![alt](img.png)";
870        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
871        let result = rule.check(&ctx).unwrap();
872        assert_eq!(result.len(), 1, "Inline image should be detected");
873        assert!(result[0].message.contains("'inline'"));
874    }
875}
876
877#[cfg(test)]
878mod fix_tests {
879    use super::*;
880    use crate::config::MarkdownFlavor;
881    use crate::lint_context::LintContext;
882    use md054_config::PreferredStyle;
883    use pulldown_cmark::LinkType;
884
885    /// Resolve a link's destination to its canonical form. Pulldown-cmark stores
886    /// email autolinks (`<me@x>`) with the bare email in `url`, but per
887    /// CommonMark §6.5 the resolved destination is `mailto:<email>`. Round-trip
888    /// equivalence checks compare *destinations*, not raw `url` strings — so we
889    /// canonicalize before comparing.
890    fn canonical_link_url(link_type: LinkType, url: &str) -> String {
891        match link_type {
892            LinkType::Email => format!("mailto:{url}"),
893            _ => url.to_string(),
894        }
895    }
896
897    /// Helper: build a rule that disallows `inline` and leaves the remaining
898    /// reference styles allowed (mirrors the reporter's MD054 config in #587).
899    fn rule_inline_disallowed() -> MD054LinkImageStyle {
900        // autolink, collapsed, full, inline, shortcut, url_inline
901        MD054LinkImageStyle::new(true, true, true, false, true, true)
902    }
903
904    /// Helper: build a rule that disallows reference styles, leaving inline allowed.
905    fn rule_only_inline() -> MD054LinkImageStyle {
906        MD054LinkImageStyle::new(false, false, false, true, false, false)
907    }
908
909    /// Helper: assert four invariants together —
910    ///
911    /// 1. After `fix()`, MD054 emits zero warnings on the result (round-trip clean).
912    /// 2. `fix()` is idempotent (running it twice yields the same content).
913    /// 3. The multiset of resolved link URLs is preserved (no silent retargeting).
914    /// 4. The multiset of resolved image URLs is preserved.
915    fn assert_round_trip_clean(rule: &MD054LinkImageStyle, content: &str) -> String {
916        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
917        let before_link_urls: Vec<String> = ctx
918            .links()
919            .iter()
920            .map(|l| canonical_link_url(l.link_type, &l.url))
921            .filter(|u| !u.is_empty())
922            .collect();
923        let before_image_urls: Vec<String> = ctx
924            .images()
925            .iter()
926            .map(|i| canonical_link_url(i.link_type, &i.url))
927            .filter(|u| !u.is_empty())
928            .collect();
929
930        let fixed = rule.fix(&ctx).unwrap();
931
932        let ctx2 = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
933        let warnings = rule.check(&ctx2).unwrap();
934        assert!(
935            warnings.is_empty(),
936            "fix() left disallowed-style warnings: {warnings:?} in:\n{fixed}"
937        );
938
939        let mut after_link_urls: Vec<String> = ctx2
940            .links()
941            .iter()
942            .map(|l| canonical_link_url(l.link_type, &l.url))
943            .filter(|u| !u.is_empty())
944            .collect();
945        let mut after_image_urls: Vec<String> = ctx2
946            .images()
947            .iter()
948            .map(|i| canonical_link_url(i.link_type, &i.url))
949            .filter(|u| !u.is_empty())
950            .collect();
951        let mut before_link_urls_sorted = before_link_urls;
952        let mut before_image_urls_sorted = before_image_urls;
953        before_link_urls_sorted.sort();
954        before_image_urls_sorted.sort();
955        after_link_urls.sort();
956        after_image_urls.sort();
957        assert_eq!(
958            before_link_urls_sorted, after_link_urls,
959            "fix() changed the set of link URLs.\nbefore: {before_link_urls_sorted:?}\nafter: {after_link_urls:?}\nfixed:\n{fixed}"
960        );
961        assert_eq!(
962            before_image_urls_sorted, after_image_urls,
963            "fix() changed the set of image URLs.\nbefore: {before_image_urls_sorted:?}\nafter: {after_image_urls:?}\nfixed:\n{fixed}"
964        );
965
966        let fixed2 = rule.fix(&ctx2).unwrap();
967        assert_eq!(fixed, fixed2, "fix() is not idempotent");
968        fixed
969    }
970
971    // -------------------------------------------------------------------
972    // inline → full
973    // -------------------------------------------------------------------
974
975    #[test]
976    fn fix_inline_to_full_single_link() {
977        let rule = rule_inline_disallowed();
978        let content = "See the [documentation](https://example.com/docs) for details.\n";
979        let fixed = assert_round_trip_clean(&rule, content);
980        assert_eq!(
981            fixed,
982            "See the [documentation][documentation] for details.\n\n\
983             [documentation]: https://example.com/docs\n"
984        );
985    }
986
987    #[test]
988    fn fix_inline_to_full_multiple_links_dedup_by_url() {
989        let rule = rule_inline_disallowed();
990        let content = "First [docs](https://example.com/x).\nAgain [docs](https://example.com/x).\n";
991        let fixed = assert_round_trip_clean(&rule, content);
992        // Both inline links collapse onto the same generated label.
993        assert_eq!(
994            fixed,
995            "First [docs][docs].\nAgain [docs][docs].\n\n\
996             [docs]: https://example.com/x\n"
997        );
998    }
999
1000    #[test]
1001    fn fix_inline_to_full_same_url_different_titles_keeps_both_titles() {
1002        // Two inline links share a URL but carry distinct titles. A single
1003        // shared reference definition could only encode one title — silently
1004        // dropping the other. The fix must produce two distinct ref defs.
1005        let rule = rule_inline_disallowed();
1006        let content = "First [a](https://example.com \"Title A\").\nLater [b](https://example.com \"Title B\").\n";
1007        let fixed = assert_round_trip_clean(&rule, content);
1008        // Both titles must survive the round-trip.
1009        assert!(fixed.contains(r#""Title A""#), "Title A lost in conversion: {fixed}");
1010        assert!(fixed.contains(r#""Title B""#), "Title B lost in conversion: {fixed}");
1011        // Two distinct definitions, not one.
1012        let def_count = fixed.matches("]: https://example.com").count();
1013        assert_eq!(def_count, 2, "expected two ref defs (one per title), got:\n{fixed}");
1014    }
1015
1016    #[test]
1017    fn fix_inline_to_full_collision_disambiguates_with_suffix() {
1018        let rule = rule_inline_disallowed();
1019        let content = "[docs](https://a.com) and [docs](https://b.com).\n";
1020        let fixed = assert_round_trip_clean(&rule, content);
1021        // Same slug, different URL → second link gets `-2` suffix.
1022        assert!(fixed.contains("[docs][docs]"));
1023        assert!(fixed.contains("[docs][docs-2]"));
1024        assert!(fixed.contains("[docs]: https://a.com"));
1025        assert!(fixed.contains("[docs-2]: https://b.com"));
1026    }
1027
1028    #[test]
1029    fn fix_inline_to_full_preserves_title() {
1030        let rule = rule_inline_disallowed();
1031        let content = "See [link](https://example.com \"My Title\").\n";
1032        let fixed = assert_round_trip_clean(&rule, content);
1033        assert!(fixed.contains("[link][link]"));
1034        assert!(fixed.contains(r#"[link]: https://example.com "My Title""#));
1035    }
1036
1037    #[test]
1038    fn fix_inline_to_full_title_with_double_quotes_uses_single_quotes() {
1039        let rule = rule_inline_disallowed();
1040        let content = "See [link](https://example.com 'has \"double\" quotes').\n";
1041        let fixed = assert_round_trip_clean(&rule, content);
1042        // Output must use a delimiter that doesn't conflict; single quotes here.
1043        assert!(
1044            fixed.contains(r#"[link]: https://example.com 'has "double" quotes'"#),
1045            "got:\n{fixed}"
1046        );
1047    }
1048
1049    #[test]
1050    fn fix_inline_to_full_title_with_escaped_quote_unescapes_through_parser() {
1051        // CommonMark allows backslash-escaping the title delimiter so the same
1052        // delimiter can appear inside the title: `"has \"escaped\" quotes"`.
1053        // pulldown-cmark unescapes those characters before handing us the title;
1054        // when MD054 emits the new ref def, it must pick a delimiter that doesn't
1055        // conflict — here, single quotes — and not blindly reuse the literal
1056        // backslashes from the source span.
1057        let rule = rule_inline_disallowed();
1058        let content = "See [link](https://example.com \"has \\\"escaped\\\" quotes\").\n";
1059        let fixed = assert_round_trip_clean(&rule, content);
1060        // The unescaped title contains real " characters, so output must wrap in 'single' quotes.
1061        assert!(
1062            fixed.contains(r#"[link]: https://example.com 'has "escaped" quotes'"#),
1063            "expected unescaped title with single-quote delimiter, got:\n{fixed}"
1064        );
1065        // And no stray backslash-quote in the emitted definition.
1066        assert!(
1067            !fixed.contains(r#"\""#),
1068            "title should be unescaped, not pass through literal `\\\"`:\n{fixed}"
1069        );
1070    }
1071
1072    #[test]
1073    fn fix_inline_to_full_image() {
1074        let rule = rule_inline_disallowed();
1075        let content = "Logo: ![Company logo](https://example.com/logo.png).\n";
1076        let fixed = assert_round_trip_clean(&rule, content);
1077        assert!(fixed.contains("![Company logo][company-logo]"));
1078        assert!(fixed.contains("[company-logo]: https://example.com/logo.png"));
1079    }
1080
1081    #[test]
1082    fn fix_inline_to_full_unicode_text() {
1083        let rule = rule_inline_disallowed();
1084        let content = "Voir [café résumé](https://cafe.example.com).\n";
1085        let fixed = assert_round_trip_clean(&rule, content);
1086        // Slug preserves the Unicode letters (they're alphanumeric) and lowercases them.
1087        assert!(fixed.contains("[café résumé][café-résumé]"));
1088        assert!(fixed.contains("[café-résumé]: https://cafe.example.com"));
1089    }
1090
1091    #[test]
1092    fn fix_inline_to_full_reuses_existing_ref_def_for_same_url() {
1093        let rule = rule_inline_disallowed();
1094        let content = "Old: [other][site]\n\
1095                       New: [docs](https://example.com)\n\
1096                       \n\
1097                       [site]: https://example.com\n";
1098        let fixed = assert_round_trip_clean(&rule, content);
1099        // The new conversion should reuse the existing `site` label.
1100        assert!(
1101            fixed.contains("[docs][site]"),
1102            "expected reuse of existing label, got:\n{fixed}"
1103        );
1104        // No duplicate definition added.
1105        assert_eq!(fixed.matches("https://example.com").count(), 1);
1106    }
1107
1108    #[test]
1109    fn fix_inline_to_full_avoids_existing_label_collision() {
1110        let rule = rule_inline_disallowed();
1111        let content = "Old: [a][docs]\n\
1112                       New: [docs](https://other.com)\n\
1113                       \n\
1114                       [docs]: https://existing.com\n";
1115        let fixed = assert_round_trip_clean(&rule, content);
1116        // New link must NOT reuse [docs] (different URL); should suffix.
1117        assert!(fixed.contains("[docs][docs-2]"));
1118        assert!(fixed.contains("[docs-2]: https://other.com"));
1119        // Original definition unchanged.
1120        assert!(fixed.contains("[docs]: https://existing.com"));
1121    }
1122
1123    #[test]
1124    fn fix_inline_to_full_no_trailing_newline() {
1125        let rule = rule_inline_disallowed();
1126        let content = "[docs](https://example.com)";
1127        let fixed = assert_round_trip_clean(&rule, content);
1128        assert_eq!(fixed, "[docs][docs]\n\n[docs]: https://example.com\n");
1129    }
1130
1131    #[test]
1132    fn fix_inline_to_full_skips_code_blocks() {
1133        let rule = rule_inline_disallowed();
1134        let content = "Outside [a](https://x.com).\n\n```\n[fenced](https://y.com)\n```\n";
1135        let fixed = assert_round_trip_clean(&rule, content);
1136        // The fenced block content stays untouched.
1137        assert!(fixed.contains("[fenced](https://y.com)"));
1138        // Outside link converted.
1139        assert!(fixed.contains("[a][a]"));
1140    }
1141
1142    #[test]
1143    fn fix_inline_to_full_skips_frontmatter() {
1144        let rule = rule_inline_disallowed();
1145        let content = "---\nlink: [foo](https://x.com)\n---\n\n[doc](https://y.com)\n";
1146        let fixed = assert_round_trip_clean(&rule, content);
1147        // Frontmatter content untouched.
1148        assert!(fixed.contains("link: [foo](https://x.com)"));
1149        // Link in body converted.
1150        assert!(fixed.contains("[doc][doc]"));
1151    }
1152
1153    // -------------------------------------------------------------------
1154    // reference → inline
1155    // -------------------------------------------------------------------
1156
1157    #[test]
1158    fn fix_full_to_inline() {
1159        let rule = rule_only_inline();
1160        let content = "See [docs][site].\n\n[site]: https://example.com\n";
1161        let fixed = assert_round_trip_clean(&rule, content);
1162        // Inline splice; ref def remains (MD053 cleans up unused defs separately).
1163        assert!(fixed.contains("[docs](https://example.com)"));
1164    }
1165
1166    #[test]
1167    fn fix_collapsed_to_inline() {
1168        let rule = rule_only_inline();
1169        let content = "See [docs][].\n\n[docs]: https://example.com\n";
1170        let fixed = assert_round_trip_clean(&rule, content);
1171        assert!(fixed.contains("[docs](https://example.com)"));
1172    }
1173
1174    #[test]
1175    fn fix_shortcut_to_inline() {
1176        let rule = rule_only_inline();
1177        let content = "See [docs].\n\n[docs]: https://example.com\n";
1178        let fixed = assert_round_trip_clean(&rule, content);
1179        assert!(fixed.contains("[docs](https://example.com)"));
1180    }
1181
1182    #[test]
1183    fn fix_full_to_inline_preserves_title() {
1184        let rule = rule_only_inline();
1185        let content = "See [docs][site].\n\n[site]: https://example.com \"Site Title\"\n";
1186        let fixed = assert_round_trip_clean(&rule, content);
1187        assert!(
1188            fixed.contains(r#"[docs](https://example.com "Site Title")"#),
1189            "title not preserved, got:\n{fixed}"
1190        );
1191    }
1192
1193    #[test]
1194    fn fix_inline_to_full_text_with_code_span_containing_brackets() {
1195        // Text containing a code span with brackets used to confuse a hand-rolled
1196        // bracket-counter. We rely on pulldown-cmark for the parse, so the
1197        // conversion must round-trip cleanly.
1198        let rule = rule_inline_disallowed();
1199        let content = "See [`a[0]` index](https://example.com).\n";
1200        let fixed = assert_round_trip_clean(&rule, content);
1201        assert!(
1202            fixed.contains("[`a[0]` index]["),
1203            "code-span text not preserved, got:\n{fixed}"
1204        );
1205        assert!(
1206            fixed.contains("]: https://example.com"),
1207            "missing emitted ref def, got:\n{fixed}"
1208        );
1209    }
1210
1211    #[test]
1212    fn fix_full_to_inline_image() {
1213        let rule = rule_only_inline();
1214        let content = "Logo: ![alt][logo].\n\n[logo]: https://x.com/img.png\n";
1215        let fixed = assert_round_trip_clean(&rule, content);
1216        assert!(fixed.contains("![alt](https://x.com/img.png)"));
1217    }
1218
1219    // -------------------------------------------------------------------
1220    // Trivial reference inter-conversions
1221    // -------------------------------------------------------------------
1222
1223    #[test]
1224    fn fix_collapsed_to_full() {
1225        // Allow only full of the reference styles.
1226        let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
1227        let content = "[docs][].\n\n[docs]: https://example.com\n";
1228        let fixed = assert_round_trip_clean(&rule, content);
1229        assert_eq!(fixed, "[docs][docs].\n\n[docs]: https://example.com\n");
1230    }
1231
1232    #[test]
1233    fn fix_collapsed_to_full_with_trailing_content() {
1234        // Regression: pulldown-cmark's offset_iter range for a `Collapsed` link
1235        // covers only the `[text]` portion, not the trailing `[]`. If the span
1236        // end isn't extended, the auto-fix replaces just `[text]` and leaves
1237        // the `[]` behind, producing malformed `[docs][docs][]` output.
1238        let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
1239        let content = "See [docs][] for details.\n\n[docs]: https://example.com\n";
1240        let fixed = assert_round_trip_clean(&rule, content);
1241        assert_eq!(fixed, "See [docs][docs] for details.\n\n[docs]: https://example.com\n");
1242    }
1243
1244    #[test]
1245    fn fix_shortcut_to_full() {
1246        let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
1247        let content = "See [docs].\n\n[docs]: https://example.com\n";
1248        let fixed = assert_round_trip_clean(&rule, content);
1249        assert!(fixed.contains("See [docs][docs]"));
1250    }
1251
1252    #[test]
1253    fn fix_shortcut_to_collapsed() {
1254        let rule = MD054LinkImageStyle::new(false, true, false, false, false, false);
1255        let content = "See [docs].\n\n[docs]: https://example.com\n";
1256        let fixed = assert_round_trip_clean(&rule, content);
1257        assert!(fixed.contains("See [docs][]"));
1258    }
1259
1260    // -------------------------------------------------------------------
1261    // autolink ↔ inline
1262    // -------------------------------------------------------------------
1263
1264    #[test]
1265    fn fix_autolink_to_inline_form() {
1266        // Disallow autolink only; allow inline + url_inline (the default for the
1267        // remaining styles). An autolink's visible text is the URL, so the only
1268        // inline-shaped conversion is `[url](url)` — which classifies as
1269        // `url-inline`. Both must be allowed for a clean round-trip.
1270        let rule = MD054LinkImageStyle::new(false, true, true, true, true, true);
1271        let content = "Visit <https://example.com> today.\n";
1272        let fixed = assert_round_trip_clean(&rule, content);
1273        assert!(
1274            fixed.contains("[https://example.com](https://example.com)"),
1275            "got: {fixed:?}"
1276        );
1277    }
1278
1279    #[test]
1280    fn fix_autolink_to_full_when_inline_styles_disallowed() {
1281        // Disallow autolink + inline + url-inline; the only reachable target is
1282        // a reference style. The rule should fall through to `full` and emit a
1283        // generated ref def.
1284        let rule = MD054LinkImageStyle::new(false, true, true, false, true, false);
1285        let content = "Visit <https://example.com> today.\n";
1286        let fixed = assert_round_trip_clean(&rule, content);
1287        assert!(
1288            fixed.contains("[https://example.com][https-example-com]"),
1289            "got: {fixed:?}"
1290        );
1291        assert!(fixed.contains("[https-example-com]: https://example.com"));
1292    }
1293
1294    #[test]
1295    fn fix_url_inline_to_autolink() {
1296        // Disallow url-inline; autolink is the natural target when text==url.
1297        let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
1298        let content = "Visit [https://example.com](https://example.com).\n";
1299        let fixed = assert_round_trip_clean(&rule, content);
1300        assert!(fixed.contains("<https://example.com>"));
1301    }
1302
1303    // -------------------------------------------------------------------
1304    // No-op / unreachable-target cases
1305    // -------------------------------------------------------------------
1306
1307    #[test]
1308    fn fix_no_op_when_target_unreachable() {
1309        // Disallow inline, allow ONLY autolink. The inline link's text doesn't
1310        // match its URL, so autolink is unreachable. The fix is a no-op.
1311        let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
1312        let content = "See [docs](https://example.com).\n";
1313        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1314        let fixed = rule.fix(&ctx).unwrap();
1315        assert_eq!(fixed, content);
1316        // The warning is still produced.
1317        let warnings = rule.check(&ctx).unwrap();
1318        assert_eq!(warnings.len(), 1);
1319    }
1320
1321    #[test]
1322    fn fix_preserves_allowed_links() {
1323        let rule = rule_inline_disallowed();
1324        let content = "Already [ref][r] is fine.\n\n[r]: https://example.com\n";
1325        let fixed = assert_round_trip_clean(&rule, content);
1326        assert_eq!(fixed, content);
1327    }
1328
1329    #[test]
1330    fn fix_keeps_the_closing_bracket_of_a_link_wrapping_a_collapsed_image() {
1331        // The link's text is the whole `![alt][]`. A text read short of the
1332        // image's trailing `[]` drops the outer link's closing bracket from
1333        // the rewrite and leaves `[![alt][][alt-2]` behind, which no longer
1334        // parses as a link.
1335        let rule = rule_inline_disallowed();
1336        let content = "[![alt][]](target.md)\n\n[alt]: exists.png\n";
1337        let fixed = assert_round_trip_clean(&rule, content);
1338        assert_eq!(fixed, "[![alt][]][alt-2]\n\n[alt]: exists.png\n\n[alt-2]: target.md\n");
1339    }
1340
1341    // -------------------------------------------------------------------
1342    // preferred_style override
1343    // -------------------------------------------------------------------
1344
1345    #[test]
1346    fn fix_preferred_style_explicit_full() {
1347        let config = md054_config::MD054Config {
1348            inline: false,
1349            preferred_style: PreferredStyles::single(PreferredStyle::Full),
1350            ..Default::default()
1351        };
1352        let rule = MD054LinkImageStyle::from_config_struct(config);
1353        let content = "[docs](https://example.com)\n";
1354        let fixed = assert_round_trip_clean(&rule, content);
1355        assert!(fixed.contains("[docs][docs]"));
1356    }
1357
1358    #[test]
1359    fn fix_inline_to_collapsed_emits_matching_ref_def() {
1360        // Disallow inline, prefer collapsed. Inline → collapsed must produce
1361        // `[anchor][]` AND emit a `[anchor]: url` definition so the resulting
1362        // link still resolves to the original URL.
1363        let config = md054_config::MD054Config {
1364            inline: false,
1365            preferred_style: PreferredStyles::single(PreferredStyle::Collapsed),
1366            ..Default::default()
1367        };
1368        let rule = MD054LinkImageStyle::from_config_struct(config);
1369        let content = "[anchor](https://example.com)\n";
1370        let fixed = assert_round_trip_clean(&rule, content);
1371        assert!(fixed.contains("[anchor][]"), "got:\n{fixed}");
1372        assert!(fixed.contains("[anchor]: https://example.com"), "got:\n{fixed}");
1373    }
1374
1375    #[test]
1376    fn fix_inline_to_shortcut_emits_matching_ref_def() {
1377        let config = md054_config::MD054Config {
1378            inline: false,
1379            preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1380            ..Default::default()
1381        };
1382        let rule = MD054LinkImageStyle::from_config_struct(config);
1383        // Trailing period guarantees the shortcut isn't followed by `[` or `(`.
1384        let content = "See [anchor](https://example.com).\n";
1385        let fixed = assert_round_trip_clean(&rule, content);
1386        assert!(fixed.contains("[anchor]"), "got:\n{fixed}");
1387        assert!(!fixed.contains("[anchor]("), "shortcut form, not inline: {fixed}");
1388        assert!(fixed.contains("[anchor]: https://example.com"), "got:\n{fixed}");
1389    }
1390
1391    #[test]
1392    fn fix_inline_to_collapsed_skips_empty_text() {
1393        // `[](url)` has no text — collapsed/shortcut emission would produce
1394        // `[][]` / `[]`, which CommonMark cannot parse as a link. The fix must
1395        // back off and leave the inline form intact.
1396        let config = md054_config::MD054Config {
1397            inline: false,
1398            preferred_style: PreferredStyles::single(PreferredStyle::Collapsed),
1399            ..Default::default()
1400        };
1401        let rule = MD054LinkImageStyle::from_config_struct(config);
1402        let content = "[](https://example.com)\n";
1403        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1404        let fixed = rule.fix(&ctx).unwrap();
1405        assert_eq!(fixed, content, "empty text must not collapse: {fixed}");
1406    }
1407
1408    #[test]
1409    fn fix_inline_to_shortcut_skips_empty_text() {
1410        let config = md054_config::MD054Config {
1411            inline: false,
1412            preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1413            ..Default::default()
1414        };
1415        let rule = MD054LinkImageStyle::from_config_struct(config);
1416        let content = "See [](https://example.com).\n";
1417        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1418        let fixed = rule.fix(&ctx).unwrap();
1419        assert_eq!(fixed, content);
1420    }
1421
1422    #[test]
1423    fn fix_inline_to_collapsed_skips_text_with_brackets() {
1424        // Text containing literal `[` / `]` cannot be spliced into a label
1425        // without escaping; emit nothing rather than produce a broken link.
1426        let config = md054_config::MD054Config {
1427            inline: false,
1428            preferred_style: PreferredStyles::single(PreferredStyle::Collapsed),
1429            ..Default::default()
1430        };
1431        let rule = MD054LinkImageStyle::from_config_struct(config);
1432        let content = "See [`a[0]` index](https://example.com).\n";
1433        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1434        let fixed = rule.fix(&ctx).unwrap();
1435        assert_eq!(fixed, content, "text containing `[` / `]` must not collapse: {fixed}");
1436    }
1437
1438    #[test]
1439    fn fix_inline_to_full_url_with_space_uses_angle_brackets_in_def() {
1440        // A URL containing a space must be carried in the angle-bracket
1441        // destination form on both sides of the conversion. The source already
1442        // uses `<...>`; the appended ref def must do the same so the resulting
1443        // `[label]: <url with space>` line round-trips through CommonMark.
1444        let rule = rule_inline_disallowed();
1445        let content = "See [docs](<./has space.md>).\n";
1446        let fixed = assert_round_trip_clean(&rule, content);
1447        assert!(
1448            fixed.contains("[docs]: <./has space.md>"),
1449            "ref def must wrap URL in angle brackets: {fixed}"
1450        );
1451    }
1452
1453    #[test]
1454    fn fix_inline_to_full_url_with_unbalanced_paren_uses_angle_brackets_in_def() {
1455        // Unbalanced parens can't appear in the bare ref-def destination
1456        // either — the appended `[label]: url` must use angle-bracket form.
1457        let rule = rule_inline_disallowed();
1458        let content = "See [docs](<https://example.com/a)b>).\n";
1459        let fixed = assert_round_trip_clean(&rule, content);
1460        assert!(
1461            fixed.contains("[docs]: <https://example.com/a)b>"),
1462            "ref def must wrap unbalanced-paren URL in angle brackets: {fixed}"
1463        );
1464    }
1465
1466    #[test]
1467    fn fix_full_to_inline_preserves_backslash_unescaped_title() {
1468        // pulldown-cmark unescapes `\"` → `"` inside titles. The fix must use
1469        // pulldown-cmark's resolved title (not the regex-captured raw form),
1470        // and re-quote it appropriately when emitting the inline destination.
1471        let rule = rule_only_inline();
1472        let content = "See [docs][d].\n\n[d]: https://example.com \"He said \\\"hi\\\"\"\n";
1473        let fixed = assert_round_trip_clean(&rule, content);
1474        // Pulldown-cmark gives us the title `He said "hi"` (unescaped).
1475        // The serializer chooses parentheses since `\"` appears, but either way
1476        // the round-trip must reproduce the same logical title without losing
1477        // backslashes or quotes.
1478        assert!(fixed.contains("https://example.com"), "URL must round-trip: {fixed}");
1479        assert!(
1480            fixed.contains(r#"\"hi\""#) || fixed.contains(r#"He said "hi""#),
1481            "title must round-trip with quotes preserved: {fixed}"
1482        );
1483    }
1484
1485    #[test]
1486    fn fix_full_to_inline_url_with_close_paren_uses_angle_brackets() {
1487        // Existing ref def points to a URL containing `)`. Splicing it into
1488        // an inline `[t](url)` destination would terminate the destination
1489        // early. The fix must use the angle-bracket form `<url)>`.
1490        let rule = rule_only_inline();
1491        let content = "See [t][r].\n\n[r]: <https://example.com/a)b>\n";
1492        let fixed = assert_round_trip_clean(&rule, content);
1493        assert!(
1494            fixed.contains("[t](<https://example.com/a)b>)"),
1495            "inline form must use angle brackets for `)` URLs: {fixed}"
1496        );
1497    }
1498
1499    #[test]
1500    fn fix_inline_to_collapsed_skips_when_label_collides_with_different_url() {
1501        // Existing ref def for `anchor` points to a DIFFERENT URL. Converting
1502        // `[anchor](other.com)` to collapsed would produce a broken/wrong link
1503        // (CommonMark would resolve `[anchor][]` to the existing def). The fix
1504        // must back off rather than silently change the link target.
1505        let config = md054_config::MD054Config {
1506            inline: false,
1507            preferred_style: PreferredStyles::single(PreferredStyle::Collapsed),
1508            ..Default::default()
1509        };
1510        let rule = MD054LinkImageStyle::from_config_struct(config);
1511        let content = "[other][anchor]\n[anchor](https://other.com)\n\n[anchor]: https://existing.com\n";
1512        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1513        let fixed = rule.fix(&ctx).unwrap();
1514        // Inline link is left alone (no safe conversion); the warning persists.
1515        assert!(fixed.contains("[anchor](https://other.com)"), "got:\n{fixed}");
1516        // The original `[anchor]` definition is unchanged.
1517        assert!(fixed.contains("[anchor]: https://existing.com"));
1518    }
1519
1520    #[test]
1521    fn fix_preferred_style_list_picks_first_reachable() {
1522        // List `[autolink, full]`: an autolinkable URL must convert to autolink
1523        // because it appears first AND is reachable.
1524        let config = md054_config::MD054Config {
1525            url_inline: false,
1526            preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Full]),
1527            ..Default::default()
1528        };
1529        let rule = MD054LinkImageStyle::from_config_struct(config);
1530        let content = "[https://example.com](https://example.com)\n";
1531        let fixed = assert_round_trip_clean(&rule, content);
1532        assert!(
1533            fixed.contains("<https://example.com>"),
1534            "expected autolink form, got:\n{fixed}"
1535        );
1536    }
1537
1538    #[test]
1539    fn fix_preferred_style_list_falls_back_to_next_when_first_unreachable() {
1540        // List `[autolink, full]`: URL is not autolinkable (relative), so the
1541        // first entry isn't reachable. Must fall back to the second (`full`).
1542        let config = md054_config::MD054Config {
1543            inline: false,
1544            preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Full]),
1545            ..Default::default()
1546        };
1547        let rule = MD054LinkImageStyle::from_config_struct(config);
1548        let content = "[docs](./guide.md)\n";
1549        let fixed = assert_round_trip_clean(&rule, content);
1550        assert!(
1551            fixed.contains("[docs][docs]"),
1552            "expected fallback to full, got:\n{fixed}"
1553        );
1554        assert!(
1555            fixed.contains("[docs]: ./guide.md"),
1556            "expected matching ref def, got:\n{fixed}"
1557        );
1558    }
1559
1560    #[test]
1561    fn fix_preferred_style_auto_in_list_acts_as_wildcard_fallback() {
1562        // `[autolink, auto]` for a non-autolinkable URL must fall through to
1563        // the source-aware Auto candidates (which for an inline source defaults
1564        // to `full`).
1565        let config = md054_config::MD054Config {
1566            inline: false,
1567            preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Auto]),
1568            ..Default::default()
1569        };
1570        let rule = MD054LinkImageStyle::from_config_struct(config);
1571        let content = "[docs](./guide.md)\n";
1572        let fixed = assert_round_trip_clean(&rule, content);
1573        assert!(
1574            fixed.contains("[docs][docs]"),
1575            "Auto fallback should pick full for inline-disallowed config, got:\n{fixed}"
1576        );
1577    }
1578
1579    #[test]
1580    fn fix_default_auto_prefers_autolink_for_url_inline_source() {
1581        // Source: `[url](url)` (url-inline). Disallow url-inline; default Auto.
1582        // Autolink must win over Full because `<url>` is the tightest form when
1583        // text equals the URL and the URL is autolinkable.
1584        let rule = MD054LinkImageStyle::new(true, true, true, true, true, false);
1585        let content = "[https://example.com](https://example.com)\n";
1586        let fixed = assert_round_trip_clean(&rule, content);
1587        assert!(
1588            fixed.contains("<https://example.com>"),
1589            "expected autolink, got:\n{fixed}"
1590        );
1591        assert!(
1592            !fixed.contains("[https://example.com]["),
1593            "should not produce reference form when autolink is reachable, got:\n{fixed}"
1594        );
1595    }
1596
1597    #[test]
1598    fn fix_default_auto_falls_back_when_autolink_disallowed() {
1599        // Same shape as above but with autolink disallowed: must skip autolink
1600        // and pick the next Auto candidate (`full`).
1601        let rule = MD054LinkImageStyle::new(false, true, true, true, true, false);
1602        let content = "[https://example.com](https://example.com)\n";
1603        let fixed = assert_round_trip_clean(&rule, content);
1604        assert!(
1605            fixed.contains("[https://example.com][https-example-com]"),
1606            "expected full form, got:\n{fixed}"
1607        );
1608        assert!(
1609            fixed.contains("[https-example-com]: https://example.com"),
1610            "missing ref def, got:\n{fixed}"
1611        );
1612    }
1613
1614    #[test]
1615    fn fix_preferred_style_explicit_no_match_skips_fix() {
1616        // Single-entry list pinning a target that's neither allowed nor reachable
1617        // must produce no fix (warning persists, content unchanged).
1618        let config = md054_config::MD054Config {
1619            inline: false,
1620            // Pinning `Inline` for an Inline source — same style; not reachable.
1621            preferred_style: PreferredStyles::single(PreferredStyle::Inline),
1622            ..Default::default()
1623        };
1624        let rule = MD054LinkImageStyle::from_config_struct(config);
1625        let content = "[docs](./guide.md)\n";
1626        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1627        let fixed = rule.fix(&ctx).unwrap();
1628        assert_eq!(fixed, content, "expected no-op fix, got:\n{fixed}");
1629    }
1630
1631    // -------------------------------------------------------------------
1632    // Mixed / interaction scenarios
1633    // -------------------------------------------------------------------
1634
1635    #[test]
1636    fn fix_mixes_inline_and_image_in_same_doc() {
1637        let rule = rule_inline_disallowed();
1638        let content = "Text [link](https://example.com) and ![pic](https://example.com/p.png).\n";
1639        let fixed = assert_round_trip_clean(&rule, content);
1640        assert!(fixed.contains("[link][link]"));
1641        assert!(fixed.contains("![pic][pic]"));
1642        assert!(fixed.contains("[link]: https://example.com"));
1643        assert!(fixed.contains("[pic]: https://example.com/p.png"));
1644    }
1645
1646    #[test]
1647    fn fix_appends_one_blank_line_separator() {
1648        let rule = rule_inline_disallowed();
1649        let content = "Plain prose.\n\n[link](https://x.com)\n";
1650        let fixed = assert_round_trip_clean(&rule, content);
1651        // Exactly one blank line between body and ref-def block.
1652        assert!(fixed.ends_with("\n[link]: https://x.com\n"));
1653        assert!(!fixed.contains("\n\n\n[link]"));
1654    }
1655
1656    // -------------------------------------------------------------------
1657    // Overlapping edits / nested constructs
1658    // -------------------------------------------------------------------
1659
1660    #[test]
1661    fn fix_nested_image_in_link_does_not_panic_or_corrupt() {
1662        // `[![alt](img.png)](https://x)` is an inline link whose text is itself
1663        // an inline image. The two spans overlap (the image lives entirely
1664        // inside the link span). With `inline = false`, both are flagged and
1665        // both produce candidate edits. Applying both edits to the same byte
1666        // range would corrupt the document — the planner must drop overlapping
1667        // edits.
1668        let rule = rule_inline_disallowed();
1669        let content = "See [![alt](img.png)](https://x.com).\n";
1670        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1671        // Must not panic, even though both candidate edits overlap.
1672        let fixed = rule.fix(&ctx).unwrap();
1673        // Both candidate edits got dropped, so the doc is unchanged and the
1674        // warnings persist for the user to resolve manually.
1675        assert_eq!(fixed, content);
1676        let warnings = rule.check(&ctx).unwrap();
1677        assert_eq!(warnings.len(), 2, "both nested constructs should still warn");
1678    }
1679
1680    // -------------------------------------------------------------------
1681    // Email autolinks (CommonMark §6.5: bare email resolves to mailto:URL)
1682    // -------------------------------------------------------------------
1683
1684    #[test]
1685    fn fix_email_autolink_to_inline_preserves_mailto_prefix() {
1686        // `<me@example.com>` is an email autolink. Per CommonMark §6.5 it
1687        // resolves to destination `mailto:me@example.com` while displaying the
1688        // bare email. Converting to any non-autolink form must preserve that
1689        // resolved destination — losing the `mailto:` prefix would silently
1690        // retarget the link to a relative path.
1691        let rule = MD054LinkImageStyle::new(false, true, true, true, true, true);
1692        let content = "Reach <me@example.com> for support.\n";
1693        let fixed = assert_round_trip_clean(&rule, content);
1694        assert!(
1695            fixed.contains("[me@example.com](mailto:me@example.com)"),
1696            "expected mailto: prefix on resolved destination, got:\n{fixed}"
1697        );
1698    }
1699
1700    #[test]
1701    fn fix_email_autolink_to_full_preserves_mailto_in_ref_def() {
1702        // Same invariant as the inline conversion, but routed through `full`
1703        // when inline is disallowed: the generated ref-def URL must be
1704        // `mailto:me@example.com`, not the bare email.
1705        let rule = MD054LinkImageStyle::new(false, true, true, false, true, false);
1706        let content = "Reach <me@example.com> for support.\n";
1707        let fixed = assert_round_trip_clean(&rule, content);
1708        assert!(
1709            fixed.contains("]: mailto:me@example.com"),
1710            "ref def should carry the mailto: prefix, got:\n{fixed}"
1711        );
1712    }
1713
1714    #[test]
1715    fn fix_rejects_bare_email_as_autolink_target() {
1716        // A `url-inline` link whose URL is a bare email must NOT be rewritten
1717        // to `<bare-email>`: that wraps the bare email in autolink syntax,
1718        // which the parser then resolves to `mailto:bare-email` — silently
1719        // changing the destination. The fix must fall through to a non-autolink
1720        // target instead.
1721        let config = md054_config::MD054Config {
1722            url_inline: false,
1723            preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Auto]),
1724            ..Default::default()
1725        };
1726        let rule = MD054LinkImageStyle::from_config_struct(config);
1727        let content = "[me@example.com](me@example.com)\n";
1728        let fixed = assert_round_trip_clean(&rule, content);
1729        assert!(
1730            !fixed.contains("<me@example.com>"),
1731            "bare-email autolink target would silently retarget to mailto:, got:\n{fixed}"
1732        );
1733    }
1734
1735    // -------------------------------------------------------------------
1736    // Autolink target rejected when title would be lost
1737    // -------------------------------------------------------------------
1738
1739    // -------------------------------------------------------------------
1740    // Generated ref defs round-trip through rumdl's own ref-def parser
1741    // -------------------------------------------------------------------
1742
1743    #[test]
1744    fn fix_generated_ref_def_with_both_quote_types_round_trips_to_ctx() {
1745        // A title containing both `"` and `'` forces format_title onto the
1746        // paren-form CommonMark §4.7 delimiter. The generated ref def must
1747        // re-parse through rumdl's `parse_reference_defs` so downstream rules
1748        // (MD053 unused refs, MD057 link validity) still see the definition
1749        // — otherwise the URL silently disappears from `ctx.reference_definitions()`.
1750        let rule = rule_inline_disallowed();
1751        let content = "See [docs](https://example.com/x \"and 'both' quotes\") today.\n";
1752        let fixed = assert_round_trip_clean(&rule, content);
1753        // Confirm the fixer chose the paren form (the only valid CommonMark
1754        // delimiter when both quote types appear in the title).
1755        assert!(
1756            fixed.contains("(and 'both' quotes)") || fixed.contains("\"and 'both' quotes\""),
1757            "title should round-trip through some valid delimiter, got:\n{fixed}"
1758        );
1759        // The crucial invariant: the generated ref def is visible to rumdl's
1760        // own parser. `assert_round_trip_clean` already checks the URL set
1761        // round-trips, but let's also pin the title down explicitly.
1762        let ctx = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
1763        let def = ctx
1764            .reference_definitions()
1765            .iter()
1766            .find(|d| d.url == "https://example.com/x")
1767            .expect("generated ref def must round-trip through parse_reference_defs");
1768        assert_eq!(
1769            def.title.as_deref(),
1770            Some("and 'both' quotes"),
1771            "title content must survive the round-trip"
1772        );
1773    }
1774
1775    // -------------------------------------------------------------------
1776    // Generated ref-def block matches the document's line-ending style
1777    // -------------------------------------------------------------------
1778
1779    #[test]
1780    fn fix_appends_generated_refs_with_crlf_when_source_is_crlf() {
1781        // A CRLF document must come back with CRLF endings — including the
1782        // separator and the per-ref lines we append. Otherwise `--fix` would
1783        // produce sequences like `\r\n\n[ref]: ...`, which `git diff` (and
1784        // any line-ending-strict tooling) flags as whole-file churn even
1785        // when only one link was rewritten.
1786        let rule = rule_inline_disallowed();
1787        let content = "See [docs](https://example.com/x).\r\n";
1788        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1789        let warnings = rule.check(&ctx).expect("check must succeed");
1790        assert!(!warnings.is_empty(), "expected at least one warning");
1791        let fixed = rule.fix(&ctx).expect("fix must succeed");
1792        assert!(
1793            fixed.contains("\r\n"),
1794            "fixed output must preserve CRLF, got:\n{fixed:?}"
1795        );
1796        assert!(
1797            !fixed.lines().any(|l| l.ends_with('\r')) || !fixed.contains("\n\n"),
1798            "no line should end with stray \\r and there should be no naked LF blanks; got:\n{fixed:?}"
1799        );
1800        // The fixed buffer must not contain a naked LF (i.e. `\n` not preceded
1801        // by `\r`) anywhere — that would be the mixed-ending bug.
1802        let bytes = fixed.as_bytes();
1803        for (i, &b) in bytes.iter().enumerate() {
1804            if b == b'\n' {
1805                assert!(
1806                    i > 0 && bytes[i - 1] == b'\r',
1807                    "found naked LF at byte {i} in CRLF document, full output:\n{fixed:?}"
1808                );
1809            }
1810        }
1811    }
1812
1813    #[test]
1814    fn fix_appends_generated_refs_with_lf_when_source_is_lf() {
1815        // Mirror of the CRLF test: an LF-only document must stay LF-only.
1816        let rule = rule_inline_disallowed();
1817        let content = "See [docs](https://example.com/x).\n";
1818        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1819        let fixed = rule.fix(&ctx).expect("fix must succeed");
1820        assert!(
1821            !fixed.contains('\r'),
1822            "LF document must not gain any CR characters, got:\n{fixed:?}"
1823        );
1824    }
1825
1826    // -------------------------------------------------------------------
1827    // Shortcut target rejected when follower would reparse the link
1828    // -------------------------------------------------------------------
1829
1830    #[test]
1831    fn fix_rejects_shortcut_target_when_followed_by_paren() {
1832        // `[docs](url)(suffix)` is a disallowed inline link followed by literal
1833        // `(suffix)`. Naively rewriting the link to shortcut form yields
1834        // `[docs](suffix)`, which CommonMark reparses as an inline link with
1835        // destination `suffix` — silently retargeting to the wrong URL.
1836        // The planner must reject Shortcut for this source.
1837        let config = md054_config::MD054Config {
1838            inline: false,
1839            preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1840            ..Default::default()
1841        };
1842        let rule = MD054LinkImageStyle::from_config_struct(config);
1843        let content = "[docs](https://example.com/x)(suffix)\n";
1844        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1845        let fixed = rule.fix(&ctx).unwrap();
1846        // Shortcut target is unreachable; with no other allowed style chosen
1847        // explicitly, the fix is a no-op.
1848        assert_eq!(fixed, content, "shortcut target was unsafe; fix should be a no-op");
1849    }
1850
1851    #[test]
1852    fn fix_rejects_shortcut_target_when_followed_by_bracket() {
1853        // `[docs](url)[next]` rewritten to `[docs][next]` would parse as a
1854        // full reference link with label `next` — completely different
1855        // semantics. Reject Shortcut for this case.
1856        let config = md054_config::MD054Config {
1857            inline: false,
1858            preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1859            ..Default::default()
1860        };
1861        let rule = MD054LinkImageStyle::from_config_struct(config);
1862        let content = "[docs](https://example.com/x)[next]\n\n[next]: https://example.com/n\n";
1863        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1864        let fixed = rule.fix(&ctx).unwrap();
1865        assert_eq!(fixed, content, "shortcut target was unsafe; fix should be a no-op");
1866    }
1867
1868    #[test]
1869    fn fix_allows_shortcut_target_when_follower_is_safe() {
1870        // Sanity: when the follower is plain text (period, space, EOL), the
1871        // shortcut conversion is safe and proceeds normally. This guards
1872        // against an over-eager rejection that would block all shortcut fixes.
1873        let config = md054_config::MD054Config {
1874            inline: false,
1875            preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1876            ..Default::default()
1877        };
1878        let rule = MD054LinkImageStyle::from_config_struct(config);
1879        let content = "See [docs](https://example.com/x). Also nice.\n";
1880        let fixed = assert_round_trip_clean(&rule, content);
1881        assert!(fixed.contains("[docs]"), "expected shortcut form, got:\n{fixed}");
1882        assert!(fixed.contains("[docs]: https://example.com/x"));
1883    }
1884
1885    // -------------------------------------------------------------------
1886    // Fix metadata is reachable through FixCoordinator
1887    // -------------------------------------------------------------------
1888
1889    #[test]
1890    fn check_attaches_fix_for_self_contained_rewrites() {
1891        // For rewrites where the per-warning Fix carries the entire change
1892        // (no paired ref-def needed), check() must attach the Fix so editor
1893        // quick-fix paths (which apply only `warning.fix.range/replacement`)
1894        // produce a correct result. autolink → url-inline is fully encoded
1895        // in a single span replacement.
1896        let rule = MD054LinkImageStyle::new(false, true, true, true, true, true);
1897        let content = "See <https://example.com>.\n";
1898        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1899        let warnings = rule.check(&ctx).unwrap();
1900        assert_eq!(warnings.len(), 1, "should warn about the autolink");
1901        let fix = warnings[0]
1902            .fix
1903            .as_ref()
1904            .expect("self-contained rewrite must carry a Fix so quick-fix paths can apply it");
1905        assert_eq!(&content[fix.range.clone()], "<https://example.com>");
1906        assert_eq!(fix.replacement, "[https://example.com](https://example.com)");
1907    }
1908
1909    #[test]
1910    fn check_carries_atomic_fix_when_rewrite_requires_new_ref_def() {
1911        // inline → collapsed/full/shortcut requires appending `[label]: url`
1912        // at end-of-file. The per-warning Fix carries the in-place rewrite
1913        // as its primary edit and the EOF ref-def insertion as an
1914        // additional_edit, so quick-fix paths that apply a single warning
1915        // produce a complete, parseable result without relying on a follow-up
1916        // fix-all pass to materialize the definition.
1917        let rule = rule_inline_disallowed();
1918        let content = "See [docs](https://example.com).\n";
1919        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1920        let warnings = rule.check(&ctx).unwrap();
1921        assert_eq!(warnings.len(), 1, "should warn about the inline link");
1922        let fix = warnings[0]
1923            .fix
1924            .as_ref()
1925            .expect("ref-emitting rewrite must carry an atomic per-warning Fix");
1926        assert_eq!(&content[fix.range.clone()], "[docs](https://example.com)");
1927        assert!(
1928            fix.replacement.starts_with("[docs]"),
1929            "primary replacement should rewrite the link to a reference form, got: {:?}",
1930            fix.replacement
1931        );
1932        assert_eq!(
1933            fix.additional_edits.len(),
1934            1,
1935            "ref-emitting fix should carry one additional_edit for the ref-def"
1936        );
1937        let extra = &fix.additional_edits[0];
1938        assert_eq!(
1939            extra.range,
1940            content.len()..content.len(),
1941            "ref-def insertion should be a zero-width edit at EOF"
1942        );
1943        assert!(
1944            extra.replacement.contains("[docs]: https://example.com"),
1945            "additional_edit should append the ref-def, got: {:?}",
1946            extra.replacement
1947        );
1948        // Applying the per-warning fix in isolation must yield the same shape
1949        // the whole-document fix() path produces: link rewritten to a
1950        // reference form AND the ref def appended at EOF.
1951        let applied = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
1952        let from_fix_all = rule.fix(&ctx).unwrap();
1953        assert!(
1954            applied.contains("[docs]: https://example.com"),
1955            "single-warning application must include ref-def, got:\n{applied}"
1956        );
1957        assert!(
1958            !applied.contains("[docs](https://example.com)"),
1959            "single-warning application must rewrite the inline link, got:\n{applied}"
1960        );
1961        // Both paths must still drive the document into a stable, fixed shape
1962        // — but exact equality isn't required because per-warning EOF
1963        // insertions don't deduplicate trailing newlines the way the
1964        // whole-document apply() does.
1965        assert!(
1966            from_fix_all.contains("[docs]: https://example.com"),
1967            "fix-all path must also produce the ref-def, got:\n{from_fix_all}"
1968        );
1969    }
1970
1971    #[test]
1972    fn check_attaches_no_fix_when_target_unreachable() {
1973        // When no allowed style is reachable, no edit is produced — so the
1974        // warning carries no Fix and the coordinator skips fix-all for it.
1975        // This avoids advertising an "automatic fix" the user can't actually
1976        // accept.
1977        let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
1978        let content = "See [docs](https://example.com).\n";
1979        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1980        let warnings = rule.check(&ctx).unwrap();
1981        assert_eq!(warnings.len(), 1);
1982        assert!(warnings[0].fix.is_none(), "unreachable target should leave fix empty");
1983    }
1984
1985    #[test]
1986    fn fix_skips_autolink_target_when_title_present() {
1987        // Autolink syntax has no slot for a title. Rewriting
1988        // `[url](url "title")` to `<url>` would silently drop the title text,
1989        // so the planner must reject Autolink as a target and fall through to
1990        // a reference style.
1991        let config = md054_config::MD054Config {
1992            url_inline: false,
1993            preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Auto]),
1994            ..Default::default()
1995        };
1996        let rule = MD054LinkImageStyle::from_config_struct(config);
1997        let content = "[https://example.com](https://example.com \"Homepage\")\n";
1998        let fixed = assert_round_trip_clean(&rule, content);
1999        assert!(
2000            !fixed.contains("<https://example.com>"),
2001            "autolink target would drop the title, got:\n{fixed}"
2002        );
2003        assert!(
2004            fixed.contains("\"Homepage\""),
2005            "title text must survive the conversion, got:\n{fixed}"
2006        );
2007    }
2008
2009    #[test]
2010    fn default_config_section_emits_clean_user_facing_defaults() {
2011        // `rumdl config --defaults` reads `default_config_section()` and prints the
2012        // values verbatim. The polymorphic sentinel is a schema-only marker — it
2013        // must never appear in user-facing output, otherwise the documented default
2014        // table contains a placeholder string the user can't actually paste back.
2015        let rule = MD054LinkImageStyle::default();
2016        let (_, value) = rule.default_config_section().expect("md054 has defaults");
2017        let table = value.as_table().expect("config section is a table");
2018        let preferred = table
2019            .get("preferred-style")
2020            .expect("preferred-style key must be present in defaults");
2021        assert!(
2022            !crate::rule_config_serde::is_polymorphic_sentinel(preferred),
2023            "preferred-style in user-facing defaults must be the serialized scalar, not the sentinel; got {preferred:?}"
2024        );
2025        // The serialized default of a single-element PreferredStyles collapses to a
2026        // scalar string. Verify the actual shape so a future serde change is caught.
2027        assert!(
2028            preferred.is_str(),
2029            "preferred-style default should serialize as a scalar string; got {preferred:?}"
2030        );
2031    }
2032
2033    #[test]
2034    fn registry_marks_preferred_style_polymorphic_for_validation() {
2035        // The schema view (consumed by the validator) must carry the sentinel so
2036        // the alternative list form of `preferred-style` is accepted alongside the
2037        // serialized scalar default. This is the counterpart to
2038        // `default_config_section_emits_clean_user_facing_defaults`: the same key
2039        // looks different in the two views, by design.
2040        let registry = crate::config::registry::default_registry();
2041        let expected = registry
2042            .expected_value_for("MD054", "preferred-style")
2043            .or_else(|| registry.expected_value_for("MD054", "preferred_style"));
2044        // `expected_value_for` returns None precisely when the entry was filtered
2045        // as a sentinel — that's the contract the validator uses to skip type
2046        // checking. Any other return value would reintroduce the original bug
2047        // where the list form is rejected.
2048        assert!(
2049            expected.is_none(),
2050            "preferred-style must be sentinel-marked in the schema so type checking is skipped; got {expected:?}"
2051        );
2052        // Sanity check: the key is still recognized as valid (only the type check
2053        // is skipped, not the key-name check).
2054        let keys = registry.config_keys_for("MD054").expect("md054 must be registered");
2055        assert!(keys.contains("preferred-style"));
2056    }
2057}
2058
2059#[cfg(test)]
2060mod autolink_literal_tests {
2061    use super::*;
2062    fn rendered(source: &str) -> String {
2063        let mut output = String::new();
2064        pulldown_cmark::html::push_html(&mut output, pulldown_cmark::Parser::new(source));
2065        output
2066    }
2067    #[test]
2068    fn md054_autolink_conversions_preserve_literal_display_and_destination() {
2069        for rule in [
2070            MD054LinkImageStyle::new(false, false, false, true, false, true),
2071            MD054LinkImageStyle::new(false, false, true, false, false, false),
2072        ] {
2073            for value in [
2074                "https://example.org/a*b*c",
2075                "https://example.org/a`b`c",
2076                "https://example.org/a&copy;b",
2077                "https://example.org/a\\*b*c",
2078                "https://example.org/a\\(b)",
2079                "https://example.org/[b]",
2080                "a*b*c@example.org",
2081            ] {
2082                for ending in ["\n", "\r\n"] {
2083                    let source = format!("内容 <{value}>{ending}");
2084                    let ctx =
2085                        crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
2086                    let fixed = rule.fix(&ctx).unwrap();
2087                    assert_ne!(fixed, source, "conversion remains available: {source}");
2088                    assert_eq!(rendered(&fixed), rendered(&source), "{source} -> {fixed}");
2089                    let ctx =
2090                        crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2091                    assert!(rule.check(&ctx).unwrap().is_empty(), "{fixed}");
2092                    assert_eq!(rule.fix(&ctx).unwrap(), fixed);
2093                }
2094            }
2095        }
2096    }
2097    #[test]
2098    fn md054_inline_url_style_round_trips_escaped_literal_labels() {
2099        let rule = MD054LinkImageStyle::new(false, false, false, false, false, true);
2100        let source = "<https://example.org/a*b*c>\n";
2101        let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2102        let fixed = rule.fix(&ctx).unwrap();
2103        assert_eq!(rendered(&fixed), rendered(source));
2104        let ctx = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2105        assert!(rule.check(&ctx).unwrap().is_empty(), "{fixed}");
2106        assert_eq!(rule.fix(&ctx).unwrap(), fixed);
2107    }
2108    #[test]
2109    fn md054_preserves_authored_markdown_link_labels() {
2110        let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2111        let source = "[*formatted* and `code`](https://example.org)\n";
2112        let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2113        let fixed = rule.fix(&ctx).unwrap();
2114        assert!(fixed.starts_with("[*formatted* and `code`]["));
2115        assert_eq!(rendered(&fixed), rendered(source));
2116    }
2117}
2118
2119#[cfg(test)]
2120mod formatted_url_label_tests {
2121    use super::*;
2122    fn rendered(source: &str) -> String {
2123        let mut html = String::new();
2124        pulldown_cmark::html::push_html(
2125            &mut html,
2126            pulldown_cmark::Parser::new_ext(source, pulldown_cmark::Options::ENABLE_STRIKETHROUGH),
2127        );
2128        html
2129    }
2130    #[test]
2131    fn md054_formatted_url_labels_cannot_become_literal_autolinks() {
2132        let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
2133        for url in [
2134            "https://example.org/a*b*c",
2135            "https://example.org/a`b`c",
2136            "https://example.org/a~~b~~c",
2137            "https://example.org/?q=**bold**",
2138        ] {
2139            let source = format!("内容 [{url}]({url})\n");
2140            let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
2141            let warnings = rule.check(&ctx).unwrap();
2142            assert_eq!(warnings.len(), 1);
2143            assert!(warnings[0].fix.is_none(), "conversion would lose formatting: {source}");
2144            let fixed = rule.fix(&ctx).unwrap();
2145            assert_eq!(fixed, source);
2146            assert_eq!(rendered(&fixed), rendered(&source));
2147        }
2148    }
2149    #[test]
2150    fn md054_formatted_url_labels_use_inline_style() {
2151        let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
2152        let source = "[https://example.org/a*b*c](https://example.org/a*b*c)\n";
2153        let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2154        assert!(rule.check(&ctx).unwrap().is_empty());
2155        assert_eq!(rule.fix(&ctx).unwrap(), source);
2156    }
2157    #[test]
2158    fn md054_literal_url_labels_remain_convertible() {
2159        let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
2160        for (label, url) in [
2161            ("https://example.org/plain", "https://example.org/plain"),
2162            ("https://example.org/a_b_c", "https://example.org/a_b_c"),
2163            ("https://example.org/a*b", "https://example.org/a*b"),
2164            ("https://example.org/a\\*b\\*c", "https://example.org/a*b*c"),
2165        ] {
2166            let source = format!("[{label}]({url})\n");
2167            let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
2168            let fixed = rule.fix(&ctx).unwrap();
2169            assert_eq!(fixed, format!("<{url}>\n"));
2170            assert_eq!(rendered(&fixed), rendered(&source));
2171        }
2172    }
2173}
2174
2175#[cfg(test)]
2176mod template_context_tests {
2177    use super::*;
2178    #[test]
2179    fn md054_template_strings_preserve_link_and_image_syntax() {
2180        let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2181        for prefix in ["", "!"] {
2182            let literal = format!("{prefix}[literal](https://literal.example.org)");
2183            for (flavor, template) in [
2184                (
2185                    crate::config::MarkdownFlavor::Standard,
2186                    format!("{{{{ \"{literal}\" }}}}"),
2187                ),
2188                (
2189                    crate::config::MarkdownFlavor::Standard,
2190                    format!("{{% set value=\"{literal}\" %}}"),
2191                ),
2192                (
2193                    crate::config::MarkdownFlavor::Hugo,
2194                    format!("{{{{< note title=\"{literal}\" >}}}}"),
2195                ),
2196                (
2197                    crate::config::MarkdownFlavor::Hugo,
2198                    format!("{{{{% note title=`{literal}` %}}}}"),
2199                ),
2200                (
2201                    crate::config::MarkdownFlavor::Quarto,
2202                    format!("{{{{< note title=\"{literal}\" >}}}}"),
2203                ),
2204            ] {
2205                for ending in ["\n", "\r\n"] {
2206                    let source =
2207                        format!("Before {template} after {prefix}[visible](https://visible.example.org){ending}");
2208                    let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
2209                    let warnings = rule.check(&ctx).unwrap();
2210                    assert_eq!(warnings.len(), 1, "{source}");
2211                    let fixed = rule.fix(&ctx).unwrap();
2212                    assert!(fixed.contains(&template), "{source} -> {fixed}");
2213                    assert!(!fixed.contains("[visible](https://visible.example.org)"));
2214                    assert!(
2215                        !fixed.contains("[literal]:"),
2216                        "literal-only references must not be appended: {fixed}"
2217                    );
2218                    let ctx = crate::lint_context::LintContext::new(&fixed, flavor, None);
2219                    assert!(rule.check(&ctx).unwrap().is_empty(), "{fixed}");
2220                    assert_eq!(rule.fix(&ctx).unwrap(), fixed);
2221                }
2222            }
2223        }
2224    }
2225    #[test]
2226    fn md054_template_code_inside_link_destinations_is_preserved() {
2227        let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2228        for prefix in ["", "!"] {
2229            let source = format!(
2230                "{prefix}[literal](<{{{{ \"&amp;copy;\"|length }}}}>)\n\n[visible](https://visible.example.org)\n"
2231            );
2232            let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
2233            assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2234            let fixed = rule.fix(&ctx).unwrap();
2235            assert!(fixed.starts_with(source.split("\n\n").next().unwrap()), "{fixed}");
2236            assert!(!fixed.contains("[literal]:"));
2237        }
2238    }
2239    #[test]
2240    fn md054_myst_comments_do_not_contribute_style_warnings_or_fixes() {
2241        let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2242        for marker in ["%", "% ", "  %\t", "   % "] {
2243            for prefix in ["", "!"] {
2244                let comment = format!("{marker}{prefix}[literal](https://literal.example.org)");
2245                let source = format!("{comment}\n\n{prefix}[visible](https://visible.example.org)\n");
2246                let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::MyST, None);
2247                assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source}");
2248                let fixed = rule.fix(&ctx).unwrap();
2249                assert!(fixed.starts_with(&comment), "{fixed}");
2250                assert!(!fixed.contains("[literal]:"));
2251                assert!(!fixed.contains("[visible](https://visible.example.org)"));
2252            }
2253        }
2254    }
2255    #[test]
2256    fn md054_disabled_rules_keep_framework_warning_filtering() {
2257        let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2258        let source = "<!-- markdownlint-disable MD054 -->\n\n[text](https://example.org)\n";
2259        let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2260        assert!(ctx.is_rule_disabled("MD054", 3));
2261        // The framework filters disabled diagnostics; the planner withholds edits.
2262        let warnings = rule.check(&ctx).unwrap();
2263        assert_eq!(warnings.len(), 1);
2264        assert!(warnings[0].fix.is_none());
2265        assert_eq!(rule.fix(&ctx).unwrap(), source);
2266    }
2267}
2268
2269#[cfg(test)]
2270mod template_reference_tests {
2271    use super::*;
2272
2273    #[test]
2274    fn md054_template_reference_definitions_are_not_reused() {
2275        let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2276        for prefix in ["", "!"] {
2277            for ending in ["\n", "\r\n"] {
2278                for hidden_url in ["https://example.org", "https://other.example.org"] {
2279                    for title in ["", " \"title\""] {
2280                        let template =
2281                            format!("{{% set unused=\"\n[reuse]: {hidden_url}\n\" %}}{{{{ unused|length }}}}")
2282                                .replace('\n', ending);
2283                        let source =
2284                            format!("{template}{ending}{ending}{prefix}[reuse](https://example.org{title}){ending}");
2285                        let ctx = crate::lint_context::LintContext::new(
2286                            &source,
2287                            crate::config::MarkdownFlavor::Standard,
2288                            None,
2289                        );
2290                        assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2291                        let fixed = rule.fix(&ctx).unwrap();
2292                        assert!(fixed.starts_with(&template), "{fixed}");
2293                        assert!(
2294                            fixed.contains(&format!("{prefix}[reuse][reuse-2]")),
2295                            "{source} -> {fixed}"
2296                        );
2297                        assert!(
2298                            fixed.contains(&format!("[reuse-2]: https://example.org{title}")),
2299                            "{fixed}"
2300                        );
2301                        let ctx = crate::lint_context::LintContext::new(
2302                            &fixed,
2303                            crate::config::MarkdownFlavor::Standard,
2304                            None,
2305                        );
2306                        assert!(rule.check(&ctx).unwrap().is_empty(), "{fixed}");
2307                        assert_eq!(rule.fix(&ctx).unwrap(), fixed);
2308                    }
2309                }
2310            }
2311        }
2312    }
2313
2314    #[test]
2315    fn md054_collapsed_and_shortcut_do_not_reuse_template_labels() {
2316        for rule in [
2317            MD054LinkImageStyle::new(false, true, false, false, false, false),
2318            MD054LinkImageStyle::new(false, false, false, false, true, false),
2319        ] {
2320            let source = "{% set unused=\"\n[reuse]: https://example.org\n\" %}{{ unused|length }}\n\n[reuse](https://example.org)\n";
2321            let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2322            // These styles cannot choose a different label without changing display text.
2323            assert!(rule.check(&ctx).unwrap()[0].fix.is_none());
2324            assert_eq!(rule.fix(&ctx).unwrap(), source);
2325        }
2326    }
2327
2328    #[test]
2329    fn md054_real_prose_references_remain_reusable_after_templates() {
2330        let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2331        let source = "{% set unused=\"\n[hidden]: https://example.org\n\" %}{{ unused|length }}\n\n[real]: https://example.org\n\n[reuse](https://example.org)\n";
2332        let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2333        assert_eq!(
2334            rule.fix(&ctx).unwrap(),
2335            source.replace("[reuse](https://example.org)", "[reuse][real]")
2336        );
2337    }
2338}