1use 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#[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 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 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 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 for link in &ctx.links {
159 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 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 for image in &ctx.images {
211 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 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 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"]
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 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 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 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 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 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 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 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); }
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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
793 let content = "[](https://example.com)";
794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
795 let result = rule.check(&ctx).unwrap();
796 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 = "";
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 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 fn rule_inline_disallowed() -> MD054LinkImageStyle {
889 MD054LinkImageStyle::new(true, true, true, false, true, true)
891 }
892
893 fn rule_only_inline() -> MD054LinkImageStyle {
895 MD054LinkImageStyle::new(false, false, false, true, false, false)
896 }
897
898 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 #[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 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 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 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 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 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 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 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 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 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: .\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 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 assert!(
1090 fixed.contains("[docs][site]"),
1091 "expected reuse of existing label, got:\n{fixed}"
1092 );
1093 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 assert!(fixed.contains("[docs][docs-2]"));
1107 assert!(fixed.contains("[docs-2]: https://other.com"));
1108 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 assert!(fixed.contains("[fenced](https://y.com)"));
1127 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 assert!(fixed.contains("link: [foo](https://x.com)"));
1138 assert!(fixed.contains("[doc][doc]"));
1140 }
1141
1142 #[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 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 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(""));
1206 }
1207
1208 #[test]
1213 fn fix_collapsed_to_full() {
1214 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!(fixed.contains("[docs][docs]"));
1219 }
1220
1221 #[test]
1222 fn fix_shortcut_to_full() {
1223 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
1224 let content = "See [docs].\n\n[docs]: https://example.com\n";
1225 let fixed = assert_round_trip_clean(&rule, content);
1226 assert!(fixed.contains("See [docs][docs]"));
1227 }
1228
1229 #[test]
1230 fn fix_shortcut_to_collapsed() {
1231 let rule = MD054LinkImageStyle::new(false, true, false, false, false, false);
1232 let content = "See [docs].\n\n[docs]: https://example.com\n";
1233 let fixed = assert_round_trip_clean(&rule, content);
1234 assert!(fixed.contains("See [docs][]"));
1235 }
1236
1237 #[test]
1242 fn fix_autolink_to_inline_form() {
1243 let rule = MD054LinkImageStyle::new(false, true, true, true, true, true);
1248 let content = "Visit <https://example.com> today.\n";
1249 let fixed = assert_round_trip_clean(&rule, content);
1250 assert!(
1251 fixed.contains("[https://example.com](https://example.com)"),
1252 "got: {fixed:?}"
1253 );
1254 }
1255
1256 #[test]
1257 fn fix_autolink_to_full_when_inline_styles_disallowed() {
1258 let rule = MD054LinkImageStyle::new(false, true, true, false, true, false);
1262 let content = "Visit <https://example.com> today.\n";
1263 let fixed = assert_round_trip_clean(&rule, content);
1264 assert!(
1265 fixed.contains("[https://example.com][https-example-com]"),
1266 "got: {fixed:?}"
1267 );
1268 assert!(fixed.contains("[https-example-com]: https://example.com"));
1269 }
1270
1271 #[test]
1272 fn fix_url_inline_to_autolink() {
1273 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
1275 let content = "Visit [https://example.com](https://example.com).\n";
1276 let fixed = assert_round_trip_clean(&rule, content);
1277 assert!(fixed.contains("<https://example.com>"));
1278 }
1279
1280 #[test]
1285 fn fix_no_op_when_target_unreachable() {
1286 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
1289 let content = "See [docs](https://example.com).\n";
1290 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1291 let fixed = rule.fix(&ctx).unwrap();
1292 assert_eq!(fixed, content);
1293 let warnings = rule.check(&ctx).unwrap();
1295 assert_eq!(warnings.len(), 1);
1296 }
1297
1298 #[test]
1299 fn fix_preserves_allowed_links() {
1300 let rule = rule_inline_disallowed();
1301 let content = "Already [ref][r] is fine.\n\n[r]: https://example.com\n";
1302 let fixed = assert_round_trip_clean(&rule, content);
1303 assert_eq!(fixed, content);
1304 }
1305
1306 #[test]
1311 fn fix_preferred_style_explicit_full() {
1312 let config = md054_config::MD054Config {
1313 inline: false,
1314 preferred_style: PreferredStyles::single(PreferredStyle::Full),
1315 ..Default::default()
1316 };
1317 let rule = MD054LinkImageStyle::from_config_struct(config);
1318 let content = "[docs](https://example.com)\n";
1319 let fixed = assert_round_trip_clean(&rule, content);
1320 assert!(fixed.contains("[docs][docs]"));
1321 }
1322
1323 #[test]
1324 fn fix_inline_to_collapsed_emits_matching_ref_def() {
1325 let config = md054_config::MD054Config {
1329 inline: false,
1330 preferred_style: PreferredStyles::single(PreferredStyle::Collapsed),
1331 ..Default::default()
1332 };
1333 let rule = MD054LinkImageStyle::from_config_struct(config);
1334 let content = "[anchor](https://example.com)\n";
1335 let fixed = assert_round_trip_clean(&rule, content);
1336 assert!(fixed.contains("[anchor][]"), "got:\n{fixed}");
1337 assert!(fixed.contains("[anchor]: https://example.com"), "got:\n{fixed}");
1338 }
1339
1340 #[test]
1341 fn fix_inline_to_shortcut_emits_matching_ref_def() {
1342 let config = md054_config::MD054Config {
1343 inline: false,
1344 preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1345 ..Default::default()
1346 };
1347 let rule = MD054LinkImageStyle::from_config_struct(config);
1348 let content = "See [anchor](https://example.com).\n";
1350 let fixed = assert_round_trip_clean(&rule, content);
1351 assert!(fixed.contains("[anchor]"), "got:\n{fixed}");
1352 assert!(!fixed.contains("[anchor]("), "shortcut form, not inline: {fixed}");
1353 assert!(fixed.contains("[anchor]: https://example.com"), "got:\n{fixed}");
1354 }
1355
1356 #[test]
1357 fn fix_inline_to_collapsed_skips_empty_text() {
1358 let config = md054_config::MD054Config {
1362 inline: false,
1363 preferred_style: PreferredStyles::single(PreferredStyle::Collapsed),
1364 ..Default::default()
1365 };
1366 let rule = MD054LinkImageStyle::from_config_struct(config);
1367 let content = "[](https://example.com)\n";
1368 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1369 let fixed = rule.fix(&ctx).unwrap();
1370 assert_eq!(fixed, content, "empty text must not collapse: {fixed}");
1371 }
1372
1373 #[test]
1374 fn fix_inline_to_shortcut_skips_empty_text() {
1375 let config = md054_config::MD054Config {
1376 inline: false,
1377 preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1378 ..Default::default()
1379 };
1380 let rule = MD054LinkImageStyle::from_config_struct(config);
1381 let content = "See [](https://example.com).\n";
1382 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1383 let fixed = rule.fix(&ctx).unwrap();
1384 assert_eq!(fixed, content);
1385 }
1386
1387 #[test]
1388 fn fix_inline_to_collapsed_skips_text_with_brackets() {
1389 let config = md054_config::MD054Config {
1392 inline: false,
1393 preferred_style: PreferredStyles::single(PreferredStyle::Collapsed),
1394 ..Default::default()
1395 };
1396 let rule = MD054LinkImageStyle::from_config_struct(config);
1397 let content = "See [`a[0]` index](https://example.com).\n";
1398 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1399 let fixed = rule.fix(&ctx).unwrap();
1400 assert_eq!(fixed, content, "text containing `[` / `]` must not collapse: {fixed}");
1401 }
1402
1403 #[test]
1404 fn fix_inline_to_full_url_with_space_uses_angle_brackets_in_def() {
1405 let rule = rule_inline_disallowed();
1410 let content = "See [docs](<./has space.md>).\n";
1411 let fixed = assert_round_trip_clean(&rule, content);
1412 assert!(
1413 fixed.contains("[docs]: <./has space.md>"),
1414 "ref def must wrap URL in angle brackets: {fixed}"
1415 );
1416 }
1417
1418 #[test]
1419 fn fix_inline_to_full_url_with_unbalanced_paren_uses_angle_brackets_in_def() {
1420 let rule = rule_inline_disallowed();
1423 let content = "See [docs](<https://example.com/a)b>).\n";
1424 let fixed = assert_round_trip_clean(&rule, content);
1425 assert!(
1426 fixed.contains("[docs]: <https://example.com/a)b>"),
1427 "ref def must wrap unbalanced-paren URL in angle brackets: {fixed}"
1428 );
1429 }
1430
1431 #[test]
1432 fn fix_full_to_inline_preserves_backslash_unescaped_title() {
1433 let rule = rule_only_inline();
1437 let content = "See [docs][d].\n\n[d]: https://example.com \"He said \\\"hi\\\"\"\n";
1438 let fixed = assert_round_trip_clean(&rule, content);
1439 assert!(fixed.contains("https://example.com"), "URL must round-trip: {fixed}");
1444 assert!(
1445 fixed.contains(r#"\"hi\""#) || fixed.contains(r#"He said "hi""#),
1446 "title must round-trip with quotes preserved: {fixed}"
1447 );
1448 }
1449
1450 #[test]
1451 fn fix_full_to_inline_url_with_close_paren_uses_angle_brackets() {
1452 let rule = rule_only_inline();
1456 let content = "See [t][r].\n\n[r]: <https://example.com/a)b>\n";
1457 let fixed = assert_round_trip_clean(&rule, content);
1458 assert!(
1459 fixed.contains("[t](<https://example.com/a)b>)"),
1460 "inline form must use angle brackets for `)` URLs: {fixed}"
1461 );
1462 }
1463
1464 #[test]
1465 fn fix_inline_to_collapsed_skips_when_label_collides_with_different_url() {
1466 let config = md054_config::MD054Config {
1471 inline: false,
1472 preferred_style: PreferredStyles::single(PreferredStyle::Collapsed),
1473 ..Default::default()
1474 };
1475 let rule = MD054LinkImageStyle::from_config_struct(config);
1476 let content = "[other][anchor]\n[anchor](https://other.com)\n\n[anchor]: https://existing.com\n";
1477 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1478 let fixed = rule.fix(&ctx).unwrap();
1479 assert!(fixed.contains("[anchor](https://other.com)"), "got:\n{fixed}");
1481 assert!(fixed.contains("[anchor]: https://existing.com"));
1483 }
1484
1485 #[test]
1486 fn fix_preferred_style_list_picks_first_reachable() {
1487 let config = md054_config::MD054Config {
1490 url_inline: false,
1491 preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Full]),
1492 ..Default::default()
1493 };
1494 let rule = MD054LinkImageStyle::from_config_struct(config);
1495 let content = "[https://example.com](https://example.com)\n";
1496 let fixed = assert_round_trip_clean(&rule, content);
1497 assert!(
1498 fixed.contains("<https://example.com>"),
1499 "expected autolink form, got:\n{fixed}"
1500 );
1501 }
1502
1503 #[test]
1504 fn fix_preferred_style_list_falls_back_to_next_when_first_unreachable() {
1505 let config = md054_config::MD054Config {
1508 inline: false,
1509 preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Full]),
1510 ..Default::default()
1511 };
1512 let rule = MD054LinkImageStyle::from_config_struct(config);
1513 let content = "[docs](./guide.md)\n";
1514 let fixed = assert_round_trip_clean(&rule, content);
1515 assert!(
1516 fixed.contains("[docs][docs]"),
1517 "expected fallback to full, got:\n{fixed}"
1518 );
1519 assert!(
1520 fixed.contains("[docs]: ./guide.md"),
1521 "expected matching ref def, got:\n{fixed}"
1522 );
1523 }
1524
1525 #[test]
1526 fn fix_preferred_style_auto_in_list_acts_as_wildcard_fallback() {
1527 let config = md054_config::MD054Config {
1531 inline: false,
1532 preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Auto]),
1533 ..Default::default()
1534 };
1535 let rule = MD054LinkImageStyle::from_config_struct(config);
1536 let content = "[docs](./guide.md)\n";
1537 let fixed = assert_round_trip_clean(&rule, content);
1538 assert!(
1539 fixed.contains("[docs][docs]"),
1540 "Auto fallback should pick full for inline-disallowed config, got:\n{fixed}"
1541 );
1542 }
1543
1544 #[test]
1545 fn fix_default_auto_prefers_autolink_for_url_inline_source() {
1546 let rule = MD054LinkImageStyle::new(true, true, true, true, true, false);
1550 let content = "[https://example.com](https://example.com)\n";
1551 let fixed = assert_round_trip_clean(&rule, content);
1552 assert!(
1553 fixed.contains("<https://example.com>"),
1554 "expected autolink, got:\n{fixed}"
1555 );
1556 assert!(
1557 !fixed.contains("[https://example.com]["),
1558 "should not produce reference form when autolink is reachable, got:\n{fixed}"
1559 );
1560 }
1561
1562 #[test]
1563 fn fix_default_auto_falls_back_when_autolink_disallowed() {
1564 let rule = MD054LinkImageStyle::new(false, true, true, true, true, false);
1567 let content = "[https://example.com](https://example.com)\n";
1568 let fixed = assert_round_trip_clean(&rule, content);
1569 assert!(
1570 fixed.contains("[https://example.com][https-example-com]"),
1571 "expected full form, got:\n{fixed}"
1572 );
1573 assert!(
1574 fixed.contains("[https-example-com]: https://example.com"),
1575 "missing ref def, got:\n{fixed}"
1576 );
1577 }
1578
1579 #[test]
1580 fn fix_preferred_style_explicit_no_match_skips_fix() {
1581 let config = md054_config::MD054Config {
1584 inline: false,
1585 preferred_style: PreferredStyles::single(PreferredStyle::Inline),
1587 ..Default::default()
1588 };
1589 let rule = MD054LinkImageStyle::from_config_struct(config);
1590 let content = "[docs](./guide.md)\n";
1591 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1592 let fixed = rule.fix(&ctx).unwrap();
1593 assert_eq!(fixed, content, "expected no-op fix, got:\n{fixed}");
1594 }
1595
1596 #[test]
1601 fn fix_mixes_inline_and_image_in_same_doc() {
1602 let rule = rule_inline_disallowed();
1603 let content = "Text [link](https://example.com) and .\n";
1604 let fixed = assert_round_trip_clean(&rule, content);
1605 assert!(fixed.contains("[link][link]"));
1606 assert!(fixed.contains("![pic][pic]"));
1607 assert!(fixed.contains("[link]: https://example.com"));
1608 assert!(fixed.contains("[pic]: https://example.com/p.png"));
1609 }
1610
1611 #[test]
1612 fn fix_appends_one_blank_line_separator() {
1613 let rule = rule_inline_disallowed();
1614 let content = "Plain prose.\n\n[link](https://x.com)\n";
1615 let fixed = assert_round_trip_clean(&rule, content);
1616 assert!(fixed.ends_with("\n[link]: https://x.com\n"));
1618 assert!(!fixed.contains("\n\n\n[link]"));
1619 }
1620
1621 #[test]
1626 fn fix_nested_image_in_link_does_not_panic_or_corrupt() {
1627 let rule = rule_inline_disallowed();
1634 let content = "See [](https://x.com).\n";
1635 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1636 let fixed = rule.fix(&ctx).unwrap();
1638 assert_eq!(fixed, content);
1641 let warnings = rule.check(&ctx).unwrap();
1642 assert_eq!(warnings.len(), 2, "both nested constructs should still warn");
1643 }
1644
1645 #[test]
1650 fn fix_email_autolink_to_inline_preserves_mailto_prefix() {
1651 let rule = MD054LinkImageStyle::new(false, true, true, true, true, true);
1657 let content = "Reach <me@example.com> for support.\n";
1658 let fixed = assert_round_trip_clean(&rule, content);
1659 assert!(
1660 fixed.contains("[me@example.com](mailto:me@example.com)"),
1661 "expected mailto: prefix on resolved destination, got:\n{fixed}"
1662 );
1663 }
1664
1665 #[test]
1666 fn fix_email_autolink_to_full_preserves_mailto_in_ref_def() {
1667 let rule = MD054LinkImageStyle::new(false, true, true, false, true, false);
1671 let content = "Reach <me@example.com> for support.\n";
1672 let fixed = assert_round_trip_clean(&rule, content);
1673 assert!(
1674 fixed.contains("]: mailto:me@example.com"),
1675 "ref def should carry the mailto: prefix, got:\n{fixed}"
1676 );
1677 }
1678
1679 #[test]
1680 fn fix_rejects_bare_email_as_autolink_target() {
1681 let config = md054_config::MD054Config {
1687 url_inline: false,
1688 preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Auto]),
1689 ..Default::default()
1690 };
1691 let rule = MD054LinkImageStyle::from_config_struct(config);
1692 let content = "[me@example.com](me@example.com)\n";
1693 let fixed = assert_round_trip_clean(&rule, content);
1694 assert!(
1695 !fixed.contains("<me@example.com>"),
1696 "bare-email autolink target would silently retarget to mailto:, got:\n{fixed}"
1697 );
1698 }
1699
1700 #[test]
1709 fn fix_generated_ref_def_with_both_quote_types_round_trips_to_ctx() {
1710 let rule = rule_inline_disallowed();
1716 let content = "See [docs](https://example.com/x \"and 'both' quotes\") today.\n";
1717 let fixed = assert_round_trip_clean(&rule, content);
1718 assert!(
1721 fixed.contains("(and 'both' quotes)") || fixed.contains("\"and 'both' quotes\""),
1722 "title should round-trip through some valid delimiter, got:\n{fixed}"
1723 );
1724 let ctx = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
1728 let def = ctx
1729 .reference_defs
1730 .iter()
1731 .find(|d| d.url == "https://example.com/x")
1732 .expect("generated ref def must round-trip through parse_reference_defs");
1733 assert_eq!(
1734 def.title.as_deref(),
1735 Some("and 'both' quotes"),
1736 "title content must survive the round-trip"
1737 );
1738 }
1739
1740 #[test]
1745 fn fix_appends_generated_refs_with_crlf_when_source_is_crlf() {
1746 let rule = rule_inline_disallowed();
1752 let content = "See [docs](https://example.com/x).\r\n";
1753 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1754 let warnings = rule.check(&ctx).expect("check must succeed");
1755 assert!(!warnings.is_empty(), "expected at least one warning");
1756 let fixed = rule.fix(&ctx).expect("fix must succeed");
1757 assert!(
1758 fixed.contains("\r\n"),
1759 "fixed output must preserve CRLF, got:\n{fixed:?}"
1760 );
1761 assert!(
1762 !fixed.lines().any(|l| l.ends_with('\r')) || !fixed.contains("\n\n"),
1763 "no line should end with stray \\r and there should be no naked LF blanks; got:\n{fixed:?}"
1764 );
1765 let bytes = fixed.as_bytes();
1768 for (i, &b) in bytes.iter().enumerate() {
1769 if b == b'\n' {
1770 assert!(
1771 i > 0 && bytes[i - 1] == b'\r',
1772 "found naked LF at byte {i} in CRLF document, full output:\n{fixed:?}"
1773 );
1774 }
1775 }
1776 }
1777
1778 #[test]
1779 fn fix_appends_generated_refs_with_lf_when_source_is_lf() {
1780 let rule = rule_inline_disallowed();
1782 let content = "See [docs](https://example.com/x).\n";
1783 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1784 let fixed = rule.fix(&ctx).expect("fix must succeed");
1785 assert!(
1786 !fixed.contains('\r'),
1787 "LF document must not gain any CR characters, got:\n{fixed:?}"
1788 );
1789 }
1790
1791 #[test]
1796 fn fix_rejects_shortcut_target_when_followed_by_paren() {
1797 let config = md054_config::MD054Config {
1803 inline: false,
1804 preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1805 ..Default::default()
1806 };
1807 let rule = MD054LinkImageStyle::from_config_struct(config);
1808 let content = "[docs](https://example.com/x)(suffix)\n";
1809 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1810 let fixed = rule.fix(&ctx).unwrap();
1811 assert_eq!(fixed, content, "shortcut target was unsafe; fix should be a no-op");
1814 }
1815
1816 #[test]
1817 fn fix_rejects_shortcut_target_when_followed_by_bracket() {
1818 let config = md054_config::MD054Config {
1822 inline: false,
1823 preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1824 ..Default::default()
1825 };
1826 let rule = MD054LinkImageStyle::from_config_struct(config);
1827 let content = "[docs](https://example.com/x)[next]\n\n[next]: https://example.com/n\n";
1828 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1829 let fixed = rule.fix(&ctx).unwrap();
1830 assert_eq!(fixed, content, "shortcut target was unsafe; fix should be a no-op");
1831 }
1832
1833 #[test]
1834 fn fix_allows_shortcut_target_when_follower_is_safe() {
1835 let config = md054_config::MD054Config {
1839 inline: false,
1840 preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1841 ..Default::default()
1842 };
1843 let rule = MD054LinkImageStyle::from_config_struct(config);
1844 let content = "See [docs](https://example.com/x). Also nice.\n";
1845 let fixed = assert_round_trip_clean(&rule, content);
1846 assert!(fixed.contains("[docs]"), "expected shortcut form, got:\n{fixed}");
1847 assert!(fixed.contains("[docs]: https://example.com/x"));
1848 }
1849
1850 #[test]
1855 fn check_attaches_fix_for_self_contained_rewrites() {
1856 let rule = MD054LinkImageStyle::new(false, true, true, true, true, true);
1862 let content = "See <https://example.com>.\n";
1863 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1864 let warnings = rule.check(&ctx).unwrap();
1865 assert_eq!(warnings.len(), 1, "should warn about the autolink");
1866 let fix = warnings[0]
1867 .fix
1868 .as_ref()
1869 .expect("self-contained rewrite must carry a Fix so quick-fix paths can apply it");
1870 assert_eq!(&content[fix.range.clone()], "<https://example.com>");
1871 assert_eq!(fix.replacement, "[https://example.com](https://example.com)");
1872 }
1873
1874 #[test]
1875 fn check_carries_atomic_fix_when_rewrite_requires_new_ref_def() {
1876 let rule = rule_inline_disallowed();
1883 let content = "See [docs](https://example.com).\n";
1884 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1885 let warnings = rule.check(&ctx).unwrap();
1886 assert_eq!(warnings.len(), 1, "should warn about the inline link");
1887 let fix = warnings[0]
1888 .fix
1889 .as_ref()
1890 .expect("ref-emitting rewrite must carry an atomic per-warning Fix");
1891 assert_eq!(&content[fix.range.clone()], "[docs](https://example.com)");
1892 assert!(
1893 fix.replacement.starts_with("[docs]"),
1894 "primary replacement should rewrite the link to a reference form, got: {:?}",
1895 fix.replacement
1896 );
1897 assert_eq!(
1898 fix.additional_edits.len(),
1899 1,
1900 "ref-emitting fix should carry one additional_edit for the ref-def"
1901 );
1902 let extra = &fix.additional_edits[0];
1903 assert_eq!(
1904 extra.range,
1905 content.len()..content.len(),
1906 "ref-def insertion should be a zero-width edit at EOF"
1907 );
1908 assert!(
1909 extra.replacement.contains("[docs]: https://example.com"),
1910 "additional_edit should append the ref-def, got: {:?}",
1911 extra.replacement
1912 );
1913 let applied = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
1917 let from_fix_all = rule.fix(&ctx).unwrap();
1918 assert!(
1919 applied.contains("[docs]: https://example.com"),
1920 "single-warning application must include ref-def, got:\n{applied}"
1921 );
1922 assert!(
1923 !applied.contains("[docs](https://example.com)"),
1924 "single-warning application must rewrite the inline link, got:\n{applied}"
1925 );
1926 assert!(
1931 from_fix_all.contains("[docs]: https://example.com"),
1932 "fix-all path must also produce the ref-def, got:\n{from_fix_all}"
1933 );
1934 }
1935
1936 #[test]
1937 fn check_attaches_no_fix_when_target_unreachable() {
1938 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
1943 let content = "See [docs](https://example.com).\n";
1944 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1945 let warnings = rule.check(&ctx).unwrap();
1946 assert_eq!(warnings.len(), 1);
1947 assert!(warnings[0].fix.is_none(), "unreachable target should leave fix empty");
1948 }
1949
1950 #[test]
1951 fn fix_skips_autolink_target_when_title_present() {
1952 let config = md054_config::MD054Config {
1957 url_inline: false,
1958 preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Auto]),
1959 ..Default::default()
1960 };
1961 let rule = MD054LinkImageStyle::from_config_struct(config);
1962 let content = "[https://example.com](https://example.com \"Homepage\")\n";
1963 let fixed = assert_round_trip_clean(&rule, content);
1964 assert!(
1965 !fixed.contains("<https://example.com>"),
1966 "autolink target would drop the title, got:\n{fixed}"
1967 );
1968 assert!(
1969 fixed.contains("\"Homepage\""),
1970 "title text must survive the conversion, got:\n{fixed}"
1971 );
1972 }
1973
1974 #[test]
1975 fn default_config_section_emits_clean_user_facing_defaults() {
1976 let rule = MD054LinkImageStyle::default();
1981 let (_, value) = rule.default_config_section().expect("md054 has defaults");
1982 let table = value.as_table().expect("config section is a table");
1983 let preferred = table
1984 .get("preferred-style")
1985 .expect("preferred-style key must be present in defaults");
1986 assert!(
1987 !crate::rule_config_serde::is_polymorphic_sentinel(preferred),
1988 "preferred-style in user-facing defaults must be the serialized scalar, not the sentinel; got {preferred:?}"
1989 );
1990 assert!(
1993 preferred.is_str(),
1994 "preferred-style default should serialize as a scalar string; got {preferred:?}"
1995 );
1996 }
1997
1998 #[test]
1999 fn registry_marks_preferred_style_polymorphic_for_validation() {
2000 let registry = crate::config::registry::default_registry();
2006 let expected = registry
2007 .expected_value_for("MD054", "preferred-style")
2008 .or_else(|| registry.expected_value_for("MD054", "preferred_style"));
2009 assert!(
2014 expected.is_none(),
2015 "preferred-style must be sentinel-marked in the schema so type checking is skipped; got {expected:?}"
2016 );
2017 let keys = registry.config_keys_for("MD054").expect("md054 must be registered");
2020 assert!(keys.contains("preferred-style"));
2021 }
2022}