Skip to main content

rumdl_lib/rules/
md054_link_image_style.rs

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