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_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 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 #[test]
1254 fn fix_autolink_to_inline_form() {
1255 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 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 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 #[test]
1297 fn fix_no_op_when_target_unreachable() {
1298 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 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 #[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 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 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 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 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 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 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 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 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 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 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 assert!(fixed.contains("[anchor](https://other.com)"), "got:\n{fixed}");
1493 assert!(fixed.contains("[anchor]: https://existing.com"));
1495 }
1496
1497 #[test]
1498 fn fix_preferred_style_list_picks_first_reachable() {
1499 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 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 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 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 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 let config = md054_config::MD054Config {
1596 inline: false,
1597 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 #[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 .\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 assert!(fixed.ends_with("\n[link]: https://x.com\n"));
1630 assert!(!fixed.contains("\n\n\n[link]"));
1631 }
1632
1633 #[test]
1638 fn fix_nested_image_in_link_does_not_panic_or_corrupt() {
1639 let rule = rule_inline_disallowed();
1646 let content = "See [](https://x.com).\n";
1647 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1648 let fixed = rule.fix(&ctx).unwrap();
1650 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 #[test]
1662 fn fix_email_autolink_to_inline_preserves_mailto_prefix() {
1663 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 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 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 #[test]
1721 fn fix_generated_ref_def_with_both_quote_types_round_trips_to_ctx() {
1722 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 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 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 #[test]
1757 fn fix_appends_generated_refs_with_crlf_when_source_is_crlf() {
1758 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 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 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 #[test]
1808 fn fix_rejects_shortcut_target_when_followed_by_paren() {
1809 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 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 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 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 #[test]
1867 fn check_attaches_fix_for_self_contained_rewrites() {
1868 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 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 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 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 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 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 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 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 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 assert!(
2026 expected.is_none(),
2027 "preferred-style must be sentinel-marked in the schema so type checking is skipped; got {expected:?}"
2028 );
2029 let keys = registry.config_keys_for("MD054").expect("md054 must be registered");
2032 assert!(keys.contains("preferred-style"));
2033 }
2034}