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