Skip to main content

rumdl_lib/rules/
md054_link_image_style.rs

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