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