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