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 crate::impl_rule_config_methods!(MD054Config);
287
288 fn polymorphic_config_keys(&self) -> &'static [&'static str] {
289 &["preferred-style"]
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use crate::lint_context::LintContext;
302
303 #[test]
304 fn test_all_styles_allowed_by_default() {
305 let rule = MD054LinkImageStyle::new(true, true, true, true, true, true);
306 let content = "[inline](url) [ref][] [ref] <https://autolink.com> [full][ref] [url](url)\n\n[ref]: url";
307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
308 let result = rule.check(&ctx).unwrap();
309
310 assert_eq!(result.len(), 0);
311 }
312
313 #[test]
314 fn test_only_inline_allowed() {
315 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
316 let content = "[allowed](url) [not][ref] <https://bad.com> [collapsed][] [shortcut]\n\n[ref]: url\n[shortcut]: url\n[collapsed]: url";
318 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
319 let result = rule.check(&ctx).unwrap();
320
321 assert_eq!(result.len(), 4, "Expected 4 warnings, got: {result:?}");
322 assert!(result[0].message.contains("'full'"));
323 assert!(result[1].message.contains("'autolink'"));
324 assert!(result[2].message.contains("'collapsed'"));
325 assert!(result[3].message.contains("'shortcut'"));
326 }
327
328 #[test]
329 fn test_only_autolink_allowed() {
330 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
331 let content = "<https://good.com> [bad](url) [bad][ref]\n\n[ref]: url";
332 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
333 let result = rule.check(&ctx).unwrap();
334
335 assert_eq!(result.len(), 2, "Expected 2 warnings, got: {result:?}");
336 assert!(result[0].message.contains("'inline'"));
337 assert!(result[1].message.contains("'full'"));
338 }
339
340 #[test]
341 fn test_url_inline_detection() {
342 let rule = MD054LinkImageStyle::new(false, false, false, true, false, true);
343 let content = "[https://example.com](https://example.com) [text](https://example.com)";
344 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
345 let result = rule.check(&ctx).unwrap();
346
347 assert_eq!(result.len(), 0);
349 }
350
351 #[test]
352 fn test_url_inline_not_allowed() {
353 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
354 let content = "[https://example.com](https://example.com)";
355 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
356 let result = rule.check(&ctx).unwrap();
357
358 assert_eq!(result.len(), 1);
359 assert!(result[0].message.contains("'url-inline'"));
360 }
361
362 #[test]
363 fn test_shortcut_vs_full_detection() {
364 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
365 let content = "[shortcut] [full][ref]\n\n[shortcut]: url\n[ref]: url2";
366 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
367 let result = rule.check(&ctx).unwrap();
368
369 assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
371 assert!(result[0].message.contains("'shortcut'"));
372 }
373
374 #[test]
375 fn test_collapsed_reference() {
376 let rule = MD054LinkImageStyle::new(false, true, false, false, false, false);
377 let content = "[collapsed][] [bad][ref]\n\n[collapsed]: 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:?}");
382 assert!(result[0].message.contains("'full'"));
383 }
384
385 #[test]
386 fn test_code_blocks_ignored() {
387 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
388 let content = "```\n[ignored](url) <https://ignored.com>\n```\n\n[checked](url)";
389 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
390 let result = rule.check(&ctx).unwrap();
391
392 assert_eq!(result.len(), 0);
394 }
395
396 #[test]
397 fn test_code_spans_ignored() {
398 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
399 let content = "`[ignored](url)` and `<https://ignored.com>` but [checked](url)";
400 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
401 let result = rule.check(&ctx).unwrap();
402
403 assert_eq!(result.len(), 0);
405 }
406
407 #[test]
408 fn test_reference_definitions_ignored() {
409 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
410 let content = "[ref]: https://example.com\n[ref2]: <https://example2.com>";
411 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
412 let result = rule.check(&ctx).unwrap();
413
414 assert_eq!(result.len(), 0);
416 }
417
418 #[test]
419 fn test_html_comments_ignored() {
420 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
421 let content = "<!-- [ignored](url) -->\n <!-- <https://ignored.com> -->";
422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
423 let result = rule.check(&ctx).unwrap();
424
425 assert_eq!(result.len(), 0);
426 }
427
428 #[test]
429 fn test_unicode_support() {
430 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
431 let content = "[cafe](https://cafe.com) [emoji](url) [korean](url) [hebrew](url)";
432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
433 let result = rule.check(&ctx).unwrap();
434
435 assert_eq!(result.len(), 0);
437 }
438
439 #[test]
440 fn test_line_positions() {
441 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
442 let content = "Line 1\n\nLine 3 with <https://bad.com> here";
443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
444 let result = rule.check(&ctx).unwrap();
445
446 assert_eq!(result.len(), 1);
447 assert_eq!(result[0].line, 3);
448 assert_eq!(result[0].column, 13); }
450
451 #[test]
452 fn test_multiple_links_same_line() {
453 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
454 let content = "[ok](url) but <https://good.com> and [also][bad]\n\n[bad]: url";
455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
456 let result = rule.check(&ctx).unwrap();
457
458 assert_eq!(result.len(), 2, "Expected 2 warnings, got: {result:?}");
459 assert!(result[0].message.contains("'autolink'"));
460 assert!(result[1].message.contains("'full'"));
461 }
462
463 #[test]
464 fn test_empty_content() {
465 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
466 let content = "";
467 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
468 let result = rule.check(&ctx).unwrap();
469
470 assert_eq!(result.len(), 0);
471 }
472
473 #[test]
474 fn test_no_links() {
475 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
476 let content = "Just plain text without any links";
477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
478 let result = rule.check(&ctx).unwrap();
479
480 assert_eq!(result.len(), 0);
481 }
482
483 #[test]
484 fn test_fix_unreachable_target_is_noop() {
485 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
489 let content = "[link](url)";
490 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
491 let fixed = rule.fix(&ctx).unwrap();
492 assert_eq!(fixed, content);
493 }
494
495 #[test]
496 fn test_priority_order() {
497 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
498 let content = "[text][ref] not detected as [shortcut]\n\n[ref]: url\n[shortcut]: url2";
500 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
501 let result = rule.check(&ctx).unwrap();
502
503 assert_eq!(result.len(), 2, "Expected 2 warnings, got: {result:?}");
504 assert!(result[0].message.contains("'full'"));
505 assert!(result[1].message.contains("'shortcut'"));
506 }
507
508 #[test]
509 fn test_not_shortcut_when_followed_by_bracket() {
510 let rule = MD054LinkImageStyle::new(false, false, false, true, true, false);
511 let content = "[text][ more text\n[text](url) is inline";
513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
514 let result = rule.check(&ctx).unwrap();
515
516 assert_eq!(result.len(), 0);
518 }
519
520 #[test]
521 fn test_cjk_correct_column_positions() {
522 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
523 let content = "日本語テスト <https://example.com>";
524 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
525 let result = rule.check(&ctx).unwrap();
526
527 assert_eq!(result.len(), 1);
528 assert!(result[0].message.contains("'autolink'"));
529 assert_eq!(
532 result[0].column, 8,
533 "Column should be 1-indexed character position of '<'"
534 );
535 }
536
537 #[test]
538 fn test_code_span_detection_with_cjk_prefix() {
539 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
540 let content = "日本語 `[link](url)` text";
542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543 let result = rule.check(&ctx).unwrap();
544
545 assert_eq!(result.len(), 0, "Link inside code span should not be flagged");
547 }
548
549 #[test]
550 fn test_complex_unicode_with_zwj() {
551 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
552 let content = "[family](url) [cafe](https://cafe.com)";
553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
554 let result = rule.check(&ctx).unwrap();
555
556 assert_eq!(result.len(), 0);
558 }
559
560 #[test]
561 fn test_gfm_alert_not_flagged_as_shortcut() {
562 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
563 let content = "> [!NOTE]\n> This is a note.\n";
564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565 let result = rule.check(&ctx).unwrap();
566 assert!(
567 result.is_empty(),
568 "GFM alert should not be flagged as shortcut link, got: {result:?}"
569 );
570 }
571
572 #[test]
573 fn test_various_alert_types_not_flagged() {
574 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
575 for alert_type in ["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION", "note", "info"] {
576 let content = format!("> [!{alert_type}]\n> Content.\n");
577 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
578 let result = rule.check(&ctx).unwrap();
579 assert!(
580 result.is_empty(),
581 "Alert type {alert_type} should not be flagged, got: {result:?}"
582 );
583 }
584 }
585
586 #[test]
587 fn test_shortcut_link_still_flagged_when_disallowed() {
588 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
589 let content = "See [reference] for details.\n\n[reference]: https://example.com\n";
590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
591 let result = rule.check(&ctx).unwrap();
592 assert!(!result.is_empty(), "Regular shortcut links should still be flagged");
593 }
594
595 #[test]
596 fn test_alert_with_frontmatter_not_flagged() {
597 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
598 let content = "---\ntitle: heading\n---\n\n> [!note]\n> Content for the note.\n";
599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
600 let result = rule.check(&ctx).unwrap();
601 assert!(
602 result.is_empty(),
603 "Alert in blockquote with frontmatter should not be flagged, got: {result:?}"
604 );
605 }
606
607 #[test]
608 fn test_alert_without_blockquote_prefix_not_flagged() {
609 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
612 let content = "[!NOTE]\nSome content\n";
613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
614 let result = rule.check(&ctx).unwrap();
615 assert!(
616 result.is_empty(),
617 "[!NOTE] without blockquote prefix should not be flagged, got: {result:?}"
618 );
619 }
620
621 #[test]
622 fn test_alert_custom_types_not_flagged() {
623 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
625 for alert_type in ["bug", "example", "quote", "abstract", "todo", "faq"] {
626 let content = format!("> [!{alert_type}]\n> Content.\n");
627 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
628 let result = rule.check(&ctx).unwrap();
629 assert!(
630 result.is_empty(),
631 "Custom alert type {alert_type} should not be flagged, got: {result:?}"
632 );
633 }
634 }
635
636 #[test]
639 fn test_code_span_with_brackets_in_inline_link() {
640 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
641 let content = "Link to [`[myArray]`](#info).";
642 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
643 let result = rule.check(&ctx).unwrap();
644 assert!(
646 result.is_empty(),
647 "Code span with brackets in inline link should not be flagged, got: {result:?}"
648 );
649 }
650
651 #[test]
652 fn test_code_span_with_array_index_in_inline_link() {
653 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
654 let content = "See [`item[0]`](#info) for details.";
655 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
656 let result = rule.check(&ctx).unwrap();
657 assert!(
658 result.is_empty(),
659 "Array index in code span should not be flagged, got: {result:?}"
660 );
661 }
662
663 #[test]
664 fn test_code_span_with_hash_brackets_in_inline_link() {
665 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
666 let content = r#"See [`hash["key"]`](#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 "Hash access in code span should not be flagged, got: {result:?}"
672 );
673 }
674
675 #[test]
676 fn test_issue_488_full_reproduction() {
677 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
679 let content = "---\ntitle: heading\n---\n\nLink to information about [`[myArray]`](#information-on-myarray).\n\n## Information on `[myArray]`\n\nSome section content.\n";
680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
681 let result = rule.check(&ctx).unwrap();
682 assert!(
683 result.is_empty(),
684 "Issue #488 reproduction case should produce no warnings, got: {result:?}"
685 );
686 }
687
688 #[test]
689 fn test_bracket_text_without_definition_not_flagged() {
690 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
693 let content = "Some [noref] text without a definition.";
694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
695 let result = rule.check(&ctx).unwrap();
696 assert!(
697 result.is_empty(),
698 "Bracket text without definition should not be flagged as a link, got: {result:?}"
699 );
700 }
701
702 #[test]
703 fn test_array_index_notation_not_flagged() {
704 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
706 let content = "Access `arr[0]` and use [1] or [optional] in your code.";
707 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
708 let result = rule.check(&ctx).unwrap();
709 assert!(
710 result.is_empty(),
711 "Array indices and bracket text should not be flagged, got: {result:?}"
712 );
713 }
714
715 #[test]
716 fn test_real_shortcut_reference_still_flagged() {
717 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
719 let content = "See [example] for details.\n\n[example]: https://example.com\n";
720 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
721 let result = rule.check(&ctx).unwrap();
722 assert_eq!(
723 result.len(),
724 1,
725 "Real shortcut reference with definition should be flagged, got: {result:?}"
726 );
727 assert!(result[0].message.contains("'shortcut'"));
728 }
729
730 #[test]
731 fn test_footnote_syntax_not_flagged_as_shortcut() {
732 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
734 let content = "See [^1] for details.";
735 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
736 let result = rule.check(&ctx).unwrap();
737 assert!(
738 result.is_empty(),
739 "Footnote syntax should not be flagged as shortcut, got: {result:?}"
740 );
741 }
742
743 #[test]
744 fn test_inline_link_with_code_span_detected_as_inline() {
745 let rule = MD054LinkImageStyle::new(true, true, true, false, true, true);
747 let content = "See [`[myArray]`](#info) for details.";
748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749 let result = rule.check(&ctx).unwrap();
750 assert_eq!(
751 result.len(),
752 1,
753 "Inline link with code span should be flagged when inline is disallowed"
754 );
755 assert!(
756 result[0].message.contains("'inline'"),
757 "Should be flagged as 'inline' style, got: {}",
758 result[0].message
759 );
760 }
761
762 #[test]
763 fn test_autolink_only_document_not_skipped() {
764 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
766 let content = "Visit <https://example.com> for more info.";
767 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
768 assert!(
769 !rule.should_skip(&ctx),
770 "should_skip must return false for autolink-only documents"
771 );
772 let result = rule.check(&ctx).unwrap();
773 assert_eq!(result.len(), 1, "Autolink should be flagged when disallowed");
774 assert!(result[0].message.contains("'autolink'"));
775 }
776
777 #[test]
778 fn test_nested_image_in_link() {
779 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
781 let content = "[](https://example.com)";
782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783 let result = rule.check(&ctx).unwrap();
784 assert!(
786 result.len() >= 2,
787 "Nested image-in-link should detect both elements, got: {result:?}"
788 );
789 }
790
791 #[test]
792 fn test_multi_line_link() {
793 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
794 let content = "[long link\ntext](url)";
795 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
796 let result = rule.check(&ctx).unwrap();
797 assert_eq!(result.len(), 1, "Multi-line inline link should be detected");
798 assert!(result[0].message.contains("'inline'"));
799 }
800
801 #[test]
802 fn test_link_with_title() {
803 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
804 let content = r#"[text](url "title")"#;
805 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
806 let result = rule.check(&ctx).unwrap();
807 assert_eq!(result.len(), 1, "Link with title should be detected as inline");
808 assert!(result[0].message.contains("'inline'"));
809 }
810
811 #[test]
812 fn test_empty_link_text() {
813 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
814 let content = "[](url)";
815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
816 let result = rule.check(&ctx).unwrap();
817 assert_eq!(result.len(), 1, "Empty link text should be detected");
818 assert!(result[0].message.contains("'inline'"));
819 }
820
821 #[test]
822 fn test_escaped_brackets_not_detected() {
823 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
824 let content = r"\[not a link\] and also \[not this either\]";
825 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
826 let result = rule.check(&ctx).unwrap();
827 assert!(
828 result.is_empty(),
829 "Escaped brackets should not be flagged, got: {result:?}"
830 );
831 }
832
833 #[test]
834 fn test_links_in_blockquotes() {
835 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
836 let content = "> [link](url) in a blockquote";
837 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
838 let result = rule.check(&ctx).unwrap();
839 assert_eq!(result.len(), 1, "Links in blockquotes should be detected");
840 assert!(result[0].message.contains("'inline'"));
841 }
842
843 #[test]
844 fn test_image_detection() {
845 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
846 let content = "";
847 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
848 let result = rule.check(&ctx).unwrap();
849 assert_eq!(result.len(), 1, "Inline image should be detected");
850 assert!(result[0].message.contains("'inline'"));
851 }
852}
853
854#[cfg(test)]
855mod fix_tests {
856 use super::*;
857 use crate::config::MarkdownFlavor;
858 use crate::lint_context::LintContext;
859 use md054_config::PreferredStyle;
860 use pulldown_cmark::LinkType;
861
862 fn canonical_link_url(link_type: LinkType, url: &str) -> String {
868 match link_type {
869 LinkType::Email => format!("mailto:{url}"),
870 _ => url.to_string(),
871 }
872 }
873
874 fn rule_inline_disallowed() -> MD054LinkImageStyle {
877 MD054LinkImageStyle::new(true, true, true, false, true, true)
879 }
880
881 fn rule_only_inline() -> MD054LinkImageStyle {
883 MD054LinkImageStyle::new(false, false, false, true, false, false)
884 }
885
886 fn assert_round_trip_clean(rule: &MD054LinkImageStyle, content: &str) -> String {
893 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
894 let before_link_urls: Vec<String> = ctx
895 .links()
896 .iter()
897 .map(|l| canonical_link_url(l.link_type, &l.url))
898 .filter(|u| !u.is_empty())
899 .collect();
900 let before_image_urls: Vec<String> = ctx
901 .images()
902 .iter()
903 .map(|i| canonical_link_url(i.link_type, &i.url))
904 .filter(|u| !u.is_empty())
905 .collect();
906
907 let fixed = rule.fix(&ctx).unwrap();
908
909 let ctx2 = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
910 let warnings = rule.check(&ctx2).unwrap();
911 assert!(
912 warnings.is_empty(),
913 "fix() left disallowed-style warnings: {warnings:?} in:\n{fixed}"
914 );
915
916 let mut after_link_urls: Vec<String> = ctx2
917 .links()
918 .iter()
919 .map(|l| canonical_link_url(l.link_type, &l.url))
920 .filter(|u| !u.is_empty())
921 .collect();
922 let mut after_image_urls: Vec<String> = ctx2
923 .images()
924 .iter()
925 .map(|i| canonical_link_url(i.link_type, &i.url))
926 .filter(|u| !u.is_empty())
927 .collect();
928 let mut before_link_urls_sorted = before_link_urls;
929 let mut before_image_urls_sorted = before_image_urls;
930 before_link_urls_sorted.sort();
931 before_image_urls_sorted.sort();
932 after_link_urls.sort();
933 after_image_urls.sort();
934 assert_eq!(
935 before_link_urls_sorted, after_link_urls,
936 "fix() changed the set of link URLs.\nbefore: {before_link_urls_sorted:?}\nafter: {after_link_urls:?}\nfixed:\n{fixed}"
937 );
938 assert_eq!(
939 before_image_urls_sorted, after_image_urls,
940 "fix() changed the set of image URLs.\nbefore: {before_image_urls_sorted:?}\nafter: {after_image_urls:?}\nfixed:\n{fixed}"
941 );
942
943 let fixed2 = rule.fix(&ctx2).unwrap();
944 assert_eq!(fixed, fixed2, "fix() is not idempotent");
945 fixed
946 }
947
948 #[test]
953 fn fix_inline_to_full_single_link() {
954 let rule = rule_inline_disallowed();
955 let content = "See the [documentation](https://example.com/docs) for details.\n";
956 let fixed = assert_round_trip_clean(&rule, content);
957 assert_eq!(
958 fixed,
959 "See the [documentation][documentation] for details.\n\n\
960 [documentation]: https://example.com/docs\n"
961 );
962 }
963
964 #[test]
965 fn fix_inline_to_full_multiple_links_dedup_by_url() {
966 let rule = rule_inline_disallowed();
967 let content = "First [docs](https://example.com/x).\nAgain [docs](https://example.com/x).\n";
968 let fixed = assert_round_trip_clean(&rule, content);
969 assert_eq!(
971 fixed,
972 "First [docs][docs].\nAgain [docs][docs].\n\n\
973 [docs]: https://example.com/x\n"
974 );
975 }
976
977 #[test]
978 fn fix_inline_to_full_same_url_different_titles_keeps_both_titles() {
979 let rule = rule_inline_disallowed();
983 let content = "First [a](https://example.com \"Title A\").\nLater [b](https://example.com \"Title B\").\n";
984 let fixed = assert_round_trip_clean(&rule, content);
985 assert!(fixed.contains(r#""Title A""#), "Title A lost in conversion: {fixed}");
987 assert!(fixed.contains(r#""Title B""#), "Title B lost in conversion: {fixed}");
988 let def_count = fixed.matches("]: https://example.com").count();
990 assert_eq!(def_count, 2, "expected two ref defs (one per title), got:\n{fixed}");
991 }
992
993 #[test]
994 fn fix_inline_to_full_collision_disambiguates_with_suffix() {
995 let rule = rule_inline_disallowed();
996 let content = "[docs](https://a.com) and [docs](https://b.com).\n";
997 let fixed = assert_round_trip_clean(&rule, content);
998 assert!(fixed.contains("[docs][docs]"));
1000 assert!(fixed.contains("[docs][docs-2]"));
1001 assert!(fixed.contains("[docs]: https://a.com"));
1002 assert!(fixed.contains("[docs-2]: https://b.com"));
1003 }
1004
1005 #[test]
1006 fn fix_inline_to_full_preserves_title() {
1007 let rule = rule_inline_disallowed();
1008 let content = "See [link](https://example.com \"My Title\").\n";
1009 let fixed = assert_round_trip_clean(&rule, content);
1010 assert!(fixed.contains("[link][link]"));
1011 assert!(fixed.contains(r#"[link]: https://example.com "My Title""#));
1012 }
1013
1014 #[test]
1015 fn fix_inline_to_full_title_with_double_quotes_uses_single_quotes() {
1016 let rule = rule_inline_disallowed();
1017 let content = "See [link](https://example.com 'has \"double\" quotes').\n";
1018 let fixed = assert_round_trip_clean(&rule, content);
1019 assert!(
1021 fixed.contains(r#"[link]: https://example.com 'has "double" quotes'"#),
1022 "got:\n{fixed}"
1023 );
1024 }
1025
1026 #[test]
1027 fn fix_inline_to_full_title_with_escaped_quote_unescapes_through_parser() {
1028 let rule = rule_inline_disallowed();
1035 let content = "See [link](https://example.com \"has \\\"escaped\\\" quotes\").\n";
1036 let fixed = assert_round_trip_clean(&rule, content);
1037 assert!(
1039 fixed.contains(r#"[link]: https://example.com 'has "escaped" quotes'"#),
1040 "expected unescaped title with single-quote delimiter, got:\n{fixed}"
1041 );
1042 assert!(
1044 !fixed.contains(r#"\""#),
1045 "title should be unescaped, not pass through literal `\\\"`:\n{fixed}"
1046 );
1047 }
1048
1049 #[test]
1050 fn fix_inline_to_full_image() {
1051 let rule = rule_inline_disallowed();
1052 let content = "Logo: .\n";
1053 let fixed = assert_round_trip_clean(&rule, content);
1054 assert!(fixed.contains("![Company logo][company-logo]"));
1055 assert!(fixed.contains("[company-logo]: https://example.com/logo.png"));
1056 }
1057
1058 #[test]
1059 fn fix_inline_to_full_unicode_text() {
1060 let rule = rule_inline_disallowed();
1061 let content = "Voir [café résumé](https://cafe.example.com).\n";
1062 let fixed = assert_round_trip_clean(&rule, content);
1063 assert!(fixed.contains("[café résumé][café-résumé]"));
1065 assert!(fixed.contains("[café-résumé]: https://cafe.example.com"));
1066 }
1067
1068 #[test]
1069 fn fix_inline_to_full_reuses_existing_ref_def_for_same_url() {
1070 let rule = rule_inline_disallowed();
1071 let content = "Old: [other][site]\n\
1072 New: [docs](https://example.com)\n\
1073 \n\
1074 [site]: https://example.com\n";
1075 let fixed = assert_round_trip_clean(&rule, content);
1076 assert!(
1078 fixed.contains("[docs][site]"),
1079 "expected reuse of existing label, got:\n{fixed}"
1080 );
1081 assert_eq!(fixed.matches("https://example.com").count(), 1);
1083 }
1084
1085 #[test]
1086 fn fix_inline_to_full_avoids_existing_label_collision() {
1087 let rule = rule_inline_disallowed();
1088 let content = "Old: [a][docs]\n\
1089 New: [docs](https://other.com)\n\
1090 \n\
1091 [docs]: https://existing.com\n";
1092 let fixed = assert_round_trip_clean(&rule, content);
1093 assert!(fixed.contains("[docs][docs-2]"));
1095 assert!(fixed.contains("[docs-2]: https://other.com"));
1096 assert!(fixed.contains("[docs]: https://existing.com"));
1098 }
1099
1100 #[test]
1101 fn fix_inline_to_full_no_trailing_newline() {
1102 let rule = rule_inline_disallowed();
1103 let content = "[docs](https://example.com)";
1104 let fixed = assert_round_trip_clean(&rule, content);
1105 assert_eq!(fixed, "[docs][docs]\n\n[docs]: https://example.com\n");
1106 }
1107
1108 #[test]
1109 fn fix_inline_to_full_skips_code_blocks() {
1110 let rule = rule_inline_disallowed();
1111 let content = "Outside [a](https://x.com).\n\n```\n[fenced](https://y.com)\n```\n";
1112 let fixed = assert_round_trip_clean(&rule, content);
1113 assert!(fixed.contains("[fenced](https://y.com)"));
1115 assert!(fixed.contains("[a][a]"));
1117 }
1118
1119 #[test]
1120 fn fix_inline_to_full_skips_frontmatter() {
1121 let rule = rule_inline_disallowed();
1122 let content = "---\nlink: [foo](https://x.com)\n---\n\n[doc](https://y.com)\n";
1123 let fixed = assert_round_trip_clean(&rule, content);
1124 assert!(fixed.contains("link: [foo](https://x.com)"));
1126 assert!(fixed.contains("[doc][doc]"));
1128 }
1129
1130 #[test]
1135 fn fix_full_to_inline() {
1136 let rule = rule_only_inline();
1137 let content = "See [docs][site].\n\n[site]: https://example.com\n";
1138 let fixed = assert_round_trip_clean(&rule, content);
1139 assert!(fixed.contains("[docs](https://example.com)"));
1141 }
1142
1143 #[test]
1144 fn fix_collapsed_to_inline() {
1145 let rule = rule_only_inline();
1146 let content = "See [docs][].\n\n[docs]: https://example.com\n";
1147 let fixed = assert_round_trip_clean(&rule, content);
1148 assert!(fixed.contains("[docs](https://example.com)"));
1149 }
1150
1151 #[test]
1152 fn fix_shortcut_to_inline() {
1153 let rule = rule_only_inline();
1154 let content = "See [docs].\n\n[docs]: https://example.com\n";
1155 let fixed = assert_round_trip_clean(&rule, content);
1156 assert!(fixed.contains("[docs](https://example.com)"));
1157 }
1158
1159 #[test]
1160 fn fix_full_to_inline_preserves_title() {
1161 let rule = rule_only_inline();
1162 let content = "See [docs][site].\n\n[site]: https://example.com \"Site Title\"\n";
1163 let fixed = assert_round_trip_clean(&rule, content);
1164 assert!(
1165 fixed.contains(r#"[docs](https://example.com "Site Title")"#),
1166 "title not preserved, got:\n{fixed}"
1167 );
1168 }
1169
1170 #[test]
1171 fn fix_inline_to_full_text_with_code_span_containing_brackets() {
1172 let rule = rule_inline_disallowed();
1176 let content = "See [`a[0]` index](https://example.com).\n";
1177 let fixed = assert_round_trip_clean(&rule, content);
1178 assert!(
1179 fixed.contains("[`a[0]` index]["),
1180 "code-span text not preserved, got:\n{fixed}"
1181 );
1182 assert!(
1183 fixed.contains("]: https://example.com"),
1184 "missing emitted ref def, got:\n{fixed}"
1185 );
1186 }
1187
1188 #[test]
1189 fn fix_full_to_inline_image() {
1190 let rule = rule_only_inline();
1191 let content = "Logo: ![alt][logo].\n\n[logo]: https://x.com/img.png\n";
1192 let fixed = assert_round_trip_clean(&rule, content);
1193 assert!(fixed.contains(""));
1194 }
1195
1196 #[test]
1201 fn fix_collapsed_to_full() {
1202 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
1204 let content = "[docs][].\n\n[docs]: https://example.com\n";
1205 let fixed = assert_round_trip_clean(&rule, content);
1206 assert_eq!(fixed, "[docs][docs].\n\n[docs]: https://example.com\n");
1207 }
1208
1209 #[test]
1210 fn fix_collapsed_to_full_with_trailing_content() {
1211 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
1216 let content = "See [docs][] for details.\n\n[docs]: https://example.com\n";
1217 let fixed = assert_round_trip_clean(&rule, content);
1218 assert_eq!(fixed, "See [docs][docs] for details.\n\n[docs]: https://example.com\n");
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]
1307 fn fix_keeps_the_closing_bracket_of_a_link_wrapping_a_collapsed_image() {
1308 let rule = rule_inline_disallowed();
1313 let content = "[![alt][]](target.md)\n\n[alt]: exists.png\n";
1314 let fixed = assert_round_trip_clean(&rule, content);
1315 assert_eq!(fixed, "[![alt][]][alt-2]\n\n[alt]: exists.png\n\n[alt-2]: target.md\n");
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_definitions()
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}