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
16fn link_text_matches_url(text: &str, url: &str) -> bool {
19 let equal = text == url;
20 if equal && !text.contains(['\\', '`', '*', '_', '~', '[', ']', '&', '<', '>']) {
21 return true;
22 }
23 if !equal && !text.contains('\\') {
24 return false;
25 }
26 let mut remaining = url;
27 for event in pulldown_cmark::Parser::new_ext(text, pulldown_cmark::Options::ENABLE_STRIKETHROUGH) {
28 match event {
29 pulldown_cmark::Event::Start(pulldown_cmark::Tag::Paragraph)
30 | pulldown_cmark::Event::End(pulldown_cmark::TagEnd::Paragraph) => {}
31 pulldown_cmark::Event::Text(value) => {
32 let Some(rest) = remaining.strip_prefix(value.as_ref()) else {
33 return false;
34 };
35 remaining = rest;
36 }
37 _ => return false,
38 }
39 }
40 remaining.is_empty()
41}
42
43#[derive(Debug, Default, Clone)]
88pub struct MD054LinkImageStyle {
89 config: MD054Config,
90}
91
92impl MD054LinkImageStyle {
93 pub fn new(autolink: bool, collapsed: bool, full: bool, inline: bool, shortcut: bool, url_inline: bool) -> Self {
94 Self {
95 config: MD054Config {
96 autolink,
97 collapsed,
98 full,
99 inline,
100 shortcut,
101 url_inline,
102 preferred_style: PreferredStyles::default(),
103 },
104 }
105 }
106
107 pub fn from_config_struct(config: MD054Config) -> Self {
108 Self { config }
109 }
110
111 fn byte_to_char_col(content: &str, byte_offset: usize) -> usize {
114 let before = &content[..byte_offset];
115 let last_newline = before.rfind('\n').map_or(0, |i| i + 1);
116 before[last_newline..].chars().count() + 1
117 }
118
119 fn is_style_allowed(&self, style: &str) -> bool {
121 match style {
122 "autolink" => self.config.autolink,
123 "collapsed" => self.config.collapsed,
124 "full" => self.config.full,
125 "inline" => self.config.inline,
126 "shortcut" => self.config.shortcut,
127 "url-inline" => self.config.url_inline,
128 _ => false,
129 }
130 }
131}
132
133impl Rule for MD054LinkImageStyle {
134 fn name(&self) -> &'static str {
135 "MD054"
136 }
137
138 fn description(&self) -> &'static str {
139 "Link and image style should be consistent"
140 }
141
142 fn category(&self) -> RuleCategory {
143 RuleCategory::Link
144 }
145
146 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
147 let content = ctx.content;
148 let mut warnings = Vec::new();
149
150 let plan = if self.should_skip(ctx) {
160 transform::FixPlan::default()
161 } else {
162 transform::plan(ctx, &self.config)
163 };
164 let entries_by_offset: HashMap<usize, &transform::PlannedEdit> =
165 plan.entries.iter().map(|e| (e.edit.range.start, e)).collect();
166 let build_fix = |offset: usize| -> Option<Fix> {
167 let entry = entries_by_offset.get(&offset)?;
168 let primary_range = entry.edit.range.clone();
169 let primary_replacement = entry.edit.replacement.clone();
170 match &entry.new_ref {
171 None => Some(Fix::new(primary_range, primary_replacement)),
172 Some(def) => {
173 let appended = transform::render_ref_def_append(content, def)?;
174 let eof_range = content.len()..content.len();
175 Some(Fix::with_additional_edits(
176 primary_range,
177 primary_replacement,
178 vec![Fix::new(eof_range, appended)],
179 ))
180 }
181 }
182 };
183
184 for link in ctx.links() {
186 if matches!(
188 link.link_type,
189 LinkType::Reference | LinkType::Collapsed | LinkType::Shortcut
190 ) && link.url.is_empty()
191 {
192 continue;
193 }
194
195 let style = match link.link_type {
196 LinkType::Autolink | LinkType::Email => "autolink",
197 LinkType::Inline => {
198 if link_text_matches_url(&link.text, &link.url) {
199 "url-inline"
200 } else {
201 "inline"
202 }
203 }
204 LinkType::Reference => "full",
205 LinkType::Collapsed => "collapsed",
206 LinkType::Shortcut => "shortcut",
207 _ => continue,
208 };
209
210 if transform::skip_non_prose(ctx, link.line, link.byte_offset, link.byte_end) {
211 continue;
212 }
213
214 if !self.is_style_allowed(style) {
215 let start_col = Self::byte_to_char_col(content, link.byte_offset);
216 let (end_line, _) = ctx.offset_to_line_col(link.byte_end);
217 let end_col = Self::byte_to_char_col(content, link.byte_end);
218
219 warnings.push(LintWarning {
220 rule_name: Some(self.name().to_string()),
221 line: link.line,
222 column: start_col,
223 end_line,
224 end_column: end_col,
225 message: format!("Link/image style '{style}' is not allowed"),
226 severity: Severity::Warning,
227 fix: build_fix(link.byte_offset),
228 });
229 }
230 }
231
232 for image in ctx.images() {
234 if matches!(
236 image.link_type,
237 LinkType::Reference | LinkType::Collapsed | LinkType::Shortcut
238 ) && image.url.is_empty()
239 {
240 continue;
241 }
242
243 let style = match image.link_type {
244 LinkType::Autolink | LinkType::Email => "autolink",
245 LinkType::Inline => {
246 if image.alt_text == image.url {
247 "url-inline"
248 } else {
249 "inline"
250 }
251 }
252 LinkType::Reference => "full",
253 LinkType::Collapsed => "collapsed",
254 LinkType::Shortcut => "shortcut",
255 _ => continue,
256 };
257
258 if transform::skip_non_prose(ctx, image.line, image.byte_offset, image.byte_end) {
259 continue;
260 }
261
262 if !self.is_style_allowed(style) {
263 let start_col = Self::byte_to_char_col(content, image.byte_offset);
264 let (end_line, _) = ctx.offset_to_line_col(image.byte_end);
265 let end_col = Self::byte_to_char_col(content, image.byte_end);
266
267 warnings.push(LintWarning {
268 rule_name: Some(self.name().to_string()),
269 line: image.line,
270 column: start_col,
271 end_line,
272 end_column: end_col,
273 message: format!("Link/image style '{style}' is not allowed"),
274 severity: Severity::Warning,
275 fix: build_fix(image.byte_offset),
276 });
277 }
278 }
279
280 Ok(warnings)
281 }
282
283 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
284 if self.should_skip(ctx) {
285 return Ok(ctx.content.to_string());
286 }
287 let plan = transform::plan(ctx, &self.config);
288 Ok(transform::apply(ctx.content, plan))
289 }
290
291 fn fix_capability(&self) -> crate::rule::FixCapability {
292 crate::rule::FixCapability::ConditionallyFixable
295 }
296
297 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
298 ctx.content.is_empty() || (!ctx.likely_has_links_or_images() && !ctx.likely_has_html())
299 }
300
301 fn as_any(&self) -> &dyn std::any::Any {
302 self
303 }
304
305 crate::impl_rule_config_methods!(MD054Config);
306
307 fn polymorphic_config_keys(&self) -> &'static [&'static str] {
308 &["preferred-style"]
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use crate::lint_context::LintContext;
321
322 #[test]
323 fn test_all_styles_allowed_by_default() {
324 let rule = MD054LinkImageStyle::new(true, true, true, true, true, true);
325 let content = "[inline](url) [ref][] [ref] <https://autolink.com> [full][ref] [url](url)\n\n[ref]: url";
326 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
327 let result = rule.check(&ctx).unwrap();
328
329 assert_eq!(result.len(), 0);
330 }
331
332 #[test]
333 fn test_only_inline_allowed() {
334 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
335 let content = "[allowed](url) [not][ref] <https://bad.com> [collapsed][] [shortcut]\n\n[ref]: url\n[shortcut]: url\n[collapsed]: url";
337 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
338 let result = rule.check(&ctx).unwrap();
339
340 assert_eq!(result.len(), 4, "Expected 4 warnings, got: {result:?}");
341 assert!(result[0].message.contains("'full'"));
342 assert!(result[1].message.contains("'autolink'"));
343 assert!(result[2].message.contains("'collapsed'"));
344 assert!(result[3].message.contains("'shortcut'"));
345 }
346
347 #[test]
348 fn test_only_autolink_allowed() {
349 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
350 let content = "<https://good.com> [bad](url) [bad][ref]\n\n[ref]: url";
351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
352 let result = rule.check(&ctx).unwrap();
353
354 assert_eq!(result.len(), 2, "Expected 2 warnings, got: {result:?}");
355 assert!(result[0].message.contains("'inline'"));
356 assert!(result[1].message.contains("'full'"));
357 }
358
359 #[test]
360 fn test_url_inline_detection() {
361 let rule = MD054LinkImageStyle::new(false, false, false, true, false, true);
362 let content = "[https://example.com](https://example.com) [text](https://example.com)";
363 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
364 let result = rule.check(&ctx).unwrap();
365
366 assert_eq!(result.len(), 0);
368 }
369
370 #[test]
371 fn test_url_inline_not_allowed() {
372 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
373 let content = "[https://example.com](https://example.com)";
374 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
375 let result = rule.check(&ctx).unwrap();
376
377 assert_eq!(result.len(), 1);
378 assert!(result[0].message.contains("'url-inline'"));
379 }
380
381 #[test]
382 fn test_shortcut_vs_full_detection() {
383 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
384 let content = "[shortcut] [full][ref]\n\n[shortcut]: url\n[ref]: url2";
385 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
386 let result = rule.check(&ctx).unwrap();
387
388 assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
390 assert!(result[0].message.contains("'shortcut'"));
391 }
392
393 #[test]
394 fn test_collapsed_reference() {
395 let rule = MD054LinkImageStyle::new(false, true, false, false, false, false);
396 let content = "[collapsed][] [bad][ref]\n\n[collapsed]: url\n[ref]: url2";
397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
398 let result = rule.check(&ctx).unwrap();
399
400 assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
401 assert!(result[0].message.contains("'full'"));
402 }
403
404 #[test]
405 fn test_code_blocks_ignored() {
406 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
407 let content = "```\n[ignored](url) <https://ignored.com>\n```\n\n[checked](url)";
408 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
409 let result = rule.check(&ctx).unwrap();
410
411 assert_eq!(result.len(), 0);
413 }
414
415 #[test]
416 fn test_code_spans_ignored() {
417 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
418 let content = "`[ignored](url)` and `<https://ignored.com>` but [checked](url)";
419 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
420 let result = rule.check(&ctx).unwrap();
421
422 assert_eq!(result.len(), 0);
424 }
425
426 #[test]
427 fn test_reference_definitions_ignored() {
428 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
429 let content = "[ref]: https://example.com\n[ref2]: <https://example2.com>";
430 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
431 let result = rule.check(&ctx).unwrap();
432
433 assert_eq!(result.len(), 0);
435 }
436
437 #[test]
438 fn test_html_comments_ignored() {
439 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
440 let content = "<!-- [ignored](url) -->\n <!-- <https://ignored.com> -->";
441 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
442 let result = rule.check(&ctx).unwrap();
443
444 assert_eq!(result.len(), 0);
445 }
446
447 #[test]
448 fn test_unicode_support() {
449 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
450 let content = "[cafe](https://cafe.com) [emoji](url) [korean](url) [hebrew](url)";
451 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
452 let result = rule.check(&ctx).unwrap();
453
454 assert_eq!(result.len(), 0);
456 }
457
458 #[test]
459 fn test_line_positions() {
460 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
461 let content = "Line 1\n\nLine 3 with <https://bad.com> here";
462 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
463 let result = rule.check(&ctx).unwrap();
464
465 assert_eq!(result.len(), 1);
466 assert_eq!(result[0].line, 3);
467 assert_eq!(result[0].column, 13); }
469
470 #[test]
471 fn test_multiple_links_same_line() {
472 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
473 let content = "[ok](url) but <https://good.com> and [also][bad]\n\n[bad]: url";
474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
475 let result = rule.check(&ctx).unwrap();
476
477 assert_eq!(result.len(), 2, "Expected 2 warnings, got: {result:?}");
478 assert!(result[0].message.contains("'autolink'"));
479 assert!(result[1].message.contains("'full'"));
480 }
481
482 #[test]
483 fn test_empty_content() {
484 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
485 let content = "";
486 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
487 let result = rule.check(&ctx).unwrap();
488
489 assert_eq!(result.len(), 0);
490 }
491
492 #[test]
493 fn test_no_links() {
494 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
495 let content = "Just plain text without any links";
496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
497 let result = rule.check(&ctx).unwrap();
498
499 assert_eq!(result.len(), 0);
500 }
501
502 #[test]
503 fn test_fix_unreachable_target_is_noop() {
504 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
508 let content = "[link](url)";
509 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
510 let fixed = rule.fix(&ctx).unwrap();
511 assert_eq!(fixed, content);
512 }
513
514 #[test]
515 fn test_priority_order() {
516 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
517 let content = "[text][ref] not detected as [shortcut]\n\n[ref]: url\n[shortcut]: url2";
519 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
520 let result = rule.check(&ctx).unwrap();
521
522 assert_eq!(result.len(), 2, "Expected 2 warnings, got: {result:?}");
523 assert!(result[0].message.contains("'full'"));
524 assert!(result[1].message.contains("'shortcut'"));
525 }
526
527 #[test]
528 fn test_not_shortcut_when_followed_by_bracket() {
529 let rule = MD054LinkImageStyle::new(false, false, false, true, true, false);
530 let content = "[text][ more text\n[text](url) is inline";
532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
533 let result = rule.check(&ctx).unwrap();
534
535 assert_eq!(result.len(), 0);
537 }
538
539 #[test]
540 fn test_cjk_correct_column_positions() {
541 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
542 let content = "日本語テスト <https://example.com>";
543 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
544 let result = rule.check(&ctx).unwrap();
545
546 assert_eq!(result.len(), 1);
547 assert!(result[0].message.contains("'autolink'"));
548 assert_eq!(
551 result[0].column, 8,
552 "Column should be 1-indexed character position of '<'"
553 );
554 }
555
556 #[test]
557 fn test_code_span_detection_with_cjk_prefix() {
558 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
559 let content = "日本語 `[link](url)` text";
561 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
562 let result = rule.check(&ctx).unwrap();
563
564 assert_eq!(result.len(), 0, "Link inside code span should not be flagged");
566 }
567
568 #[test]
569 fn test_complex_unicode_with_zwj() {
570 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
571 let content = "[family](url) [cafe](https://cafe.com)";
572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
573 let result = rule.check(&ctx).unwrap();
574
575 assert_eq!(result.len(), 0);
577 }
578
579 #[test]
580 fn test_gfm_alert_not_flagged_as_shortcut() {
581 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
582 let content = "> [!NOTE]\n> This is a note.\n";
583 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
584 let result = rule.check(&ctx).unwrap();
585 assert!(
586 result.is_empty(),
587 "GFM alert should not be flagged as shortcut link, got: {result:?}"
588 );
589 }
590
591 #[test]
592 fn test_various_alert_types_not_flagged() {
593 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
594 for alert_type in ["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION", "note", "info"] {
595 let content = format!("> [!{alert_type}]\n> Content.\n");
596 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
597 let result = rule.check(&ctx).unwrap();
598 assert!(
599 result.is_empty(),
600 "Alert type {alert_type} should not be flagged, got: {result:?}"
601 );
602 }
603 }
604
605 #[test]
606 fn test_shortcut_link_still_flagged_when_disallowed() {
607 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
608 let content = "See [reference] for details.\n\n[reference]: https://example.com\n";
609 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
610 let result = rule.check(&ctx).unwrap();
611 assert!(!result.is_empty(), "Regular shortcut links should still be flagged");
612 }
613
614 #[test]
615 fn test_alert_with_frontmatter_not_flagged() {
616 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
617 let content = "---\ntitle: heading\n---\n\n> [!note]\n> Content for the note.\n";
618 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
619 let result = rule.check(&ctx).unwrap();
620 assert!(
621 result.is_empty(),
622 "Alert in blockquote with frontmatter should not be flagged, got: {result:?}"
623 );
624 }
625
626 #[test]
627 fn test_alert_without_blockquote_prefix_not_flagged() {
628 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
631 let content = "[!NOTE]\nSome content\n";
632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
633 let result = rule.check(&ctx).unwrap();
634 assert!(
635 result.is_empty(),
636 "[!NOTE] without blockquote prefix should not be flagged, got: {result:?}"
637 );
638 }
639
640 #[test]
641 fn test_alert_custom_types_not_flagged() {
642 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
644 for alert_type in ["bug", "example", "quote", "abstract", "todo", "faq"] {
645 let content = format!("> [!{alert_type}]\n> Content.\n");
646 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
647 let result = rule.check(&ctx).unwrap();
648 assert!(
649 result.is_empty(),
650 "Custom alert type {alert_type} should not be flagged, got: {result:?}"
651 );
652 }
653 }
654
655 #[test]
658 fn test_code_span_with_brackets_in_inline_link() {
659 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
660 let content = "Link to [`[myArray]`](#info).";
661 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
662 let result = rule.check(&ctx).unwrap();
663 assert!(
665 result.is_empty(),
666 "Code span with brackets in inline link should not be flagged, got: {result:?}"
667 );
668 }
669
670 #[test]
671 fn test_code_span_with_array_index_in_inline_link() {
672 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
673 let content = "See [`item[0]`](#info) for details.";
674 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
675 let result = rule.check(&ctx).unwrap();
676 assert!(
677 result.is_empty(),
678 "Array index in code span should not be flagged, got: {result:?}"
679 );
680 }
681
682 #[test]
683 fn test_code_span_with_hash_brackets_in_inline_link() {
684 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
685 let content = r#"See [`hash["key"]`](#info) for details."#;
686 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
687 let result = rule.check(&ctx).unwrap();
688 assert!(
689 result.is_empty(),
690 "Hash access in code span should not be flagged, got: {result:?}"
691 );
692 }
693
694 #[test]
695 fn test_issue_488_full_reproduction() {
696 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
698 let content = "---\ntitle: heading\n---\n\nLink to information about [`[myArray]`](#information-on-myarray).\n\n## Information on `[myArray]`\n\nSome section content.\n";
699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700 let result = rule.check(&ctx).unwrap();
701 assert!(
702 result.is_empty(),
703 "Issue #488 reproduction case should produce no warnings, got: {result:?}"
704 );
705 }
706
707 #[test]
708 fn test_bracket_text_without_definition_not_flagged() {
709 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
712 let content = "Some [noref] text without a definition.";
713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
714 let result = rule.check(&ctx).unwrap();
715 assert!(
716 result.is_empty(),
717 "Bracket text without definition should not be flagged as a link, got: {result:?}"
718 );
719 }
720
721 #[test]
722 fn test_array_index_notation_not_flagged() {
723 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
725 let content = "Access `arr[0]` and use [1] or [optional] in your code.";
726 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
727 let result = rule.check(&ctx).unwrap();
728 assert!(
729 result.is_empty(),
730 "Array indices and bracket text should not be flagged, got: {result:?}"
731 );
732 }
733
734 #[test]
735 fn test_real_shortcut_reference_still_flagged() {
736 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
738 let content = "See [example] for details.\n\n[example]: https://example.com\n";
739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
740 let result = rule.check(&ctx).unwrap();
741 assert_eq!(
742 result.len(),
743 1,
744 "Real shortcut reference with definition should be flagged, got: {result:?}"
745 );
746 assert!(result[0].message.contains("'shortcut'"));
747 }
748
749 #[test]
750 fn test_footnote_syntax_not_flagged_as_shortcut() {
751 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
753 let content = "See [^1] for details.";
754 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
755 let result = rule.check(&ctx).unwrap();
756 assert!(
757 result.is_empty(),
758 "Footnote syntax should not be flagged as shortcut, got: {result:?}"
759 );
760 }
761
762 #[test]
763 fn test_inline_link_with_code_span_detected_as_inline() {
764 let rule = MD054LinkImageStyle::new(true, true, true, false, true, true);
766 let content = "See [`[myArray]`](#info) for details.";
767 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
768 let result = rule.check(&ctx).unwrap();
769 assert_eq!(
770 result.len(),
771 1,
772 "Inline link with code span should be flagged when inline is disallowed"
773 );
774 assert!(
775 result[0].message.contains("'inline'"),
776 "Should be flagged as 'inline' style, got: {}",
777 result[0].message
778 );
779 }
780
781 #[test]
782 fn test_autolink_only_document_not_skipped() {
783 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
785 let content = "Visit <https://example.com> for more info.";
786 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
787 assert!(
788 !rule.should_skip(&ctx),
789 "should_skip must return false for autolink-only documents"
790 );
791 let result = rule.check(&ctx).unwrap();
792 assert_eq!(result.len(), 1, "Autolink should be flagged when disallowed");
793 assert!(result[0].message.contains("'autolink'"));
794 }
795
796 #[test]
797 fn test_nested_image_in_link() {
798 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
800 let content = "[](https://example.com)";
801 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
802 let result = rule.check(&ctx).unwrap();
803 assert!(
805 result.len() >= 2,
806 "Nested image-in-link should detect both elements, got: {result:?}"
807 );
808 }
809
810 #[test]
811 fn test_multi_line_link() {
812 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
813 let content = "[long link\ntext](url)";
814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
815 let result = rule.check(&ctx).unwrap();
816 assert_eq!(result.len(), 1, "Multi-line inline link should be detected");
817 assert!(result[0].message.contains("'inline'"));
818 }
819
820 #[test]
821 fn test_link_with_title() {
822 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
823 let content = r#"[text](url "title")"#;
824 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
825 let result = rule.check(&ctx).unwrap();
826 assert_eq!(result.len(), 1, "Link with title should be detected as inline");
827 assert!(result[0].message.contains("'inline'"));
828 }
829
830 #[test]
831 fn test_empty_link_text() {
832 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
833 let content = "[](url)";
834 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
835 let result = rule.check(&ctx).unwrap();
836 assert_eq!(result.len(), 1, "Empty link text should be detected");
837 assert!(result[0].message.contains("'inline'"));
838 }
839
840 #[test]
841 fn test_escaped_brackets_not_detected() {
842 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
843 let content = r"\[not a link\] and also \[not this either\]";
844 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
845 let result = rule.check(&ctx).unwrap();
846 assert!(
847 result.is_empty(),
848 "Escaped brackets should not be flagged, got: {result:?}"
849 );
850 }
851
852 #[test]
853 fn test_links_in_blockquotes() {
854 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
855 let content = "> [link](url) in a blockquote";
856 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
857 let result = rule.check(&ctx).unwrap();
858 assert_eq!(result.len(), 1, "Links in blockquotes should be detected");
859 assert!(result[0].message.contains("'inline'"));
860 }
861
862 #[test]
863 fn test_image_detection() {
864 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
865 let content = "";
866 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
867 let result = rule.check(&ctx).unwrap();
868 assert_eq!(result.len(), 1, "Inline image should be detected");
869 assert!(result[0].message.contains("'inline'"));
870 }
871}
872
873#[cfg(test)]
874mod fix_tests {
875 use super::*;
876 use crate::config::MarkdownFlavor;
877 use crate::lint_context::LintContext;
878 use md054_config::PreferredStyle;
879 use pulldown_cmark::LinkType;
880
881 fn canonical_link_url(link_type: LinkType, url: &str) -> String {
887 match link_type {
888 LinkType::Email => format!("mailto:{url}"),
889 _ => url.to_string(),
890 }
891 }
892
893 fn rule_inline_disallowed() -> MD054LinkImageStyle {
896 MD054LinkImageStyle::new(true, true, true, false, true, true)
898 }
899
900 fn rule_only_inline() -> MD054LinkImageStyle {
902 MD054LinkImageStyle::new(false, false, false, true, false, false)
903 }
904
905 fn assert_round_trip_clean(rule: &MD054LinkImageStyle, content: &str) -> String {
912 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
913 let before_link_urls: Vec<String> = ctx
914 .links()
915 .iter()
916 .map(|l| canonical_link_url(l.link_type, &l.url))
917 .filter(|u| !u.is_empty())
918 .collect();
919 let before_image_urls: Vec<String> = ctx
920 .images()
921 .iter()
922 .map(|i| canonical_link_url(i.link_type, &i.url))
923 .filter(|u| !u.is_empty())
924 .collect();
925
926 let fixed = rule.fix(&ctx).unwrap();
927
928 let ctx2 = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
929 let warnings = rule.check(&ctx2).unwrap();
930 assert!(
931 warnings.is_empty(),
932 "fix() left disallowed-style warnings: {warnings:?} in:\n{fixed}"
933 );
934
935 let mut after_link_urls: Vec<String> = ctx2
936 .links()
937 .iter()
938 .map(|l| canonical_link_url(l.link_type, &l.url))
939 .filter(|u| !u.is_empty())
940 .collect();
941 let mut after_image_urls: Vec<String> = ctx2
942 .images()
943 .iter()
944 .map(|i| canonical_link_url(i.link_type, &i.url))
945 .filter(|u| !u.is_empty())
946 .collect();
947 let mut before_link_urls_sorted = before_link_urls;
948 let mut before_image_urls_sorted = before_image_urls;
949 before_link_urls_sorted.sort();
950 before_image_urls_sorted.sort();
951 after_link_urls.sort();
952 after_image_urls.sort();
953 assert_eq!(
954 before_link_urls_sorted, after_link_urls,
955 "fix() changed the set of link URLs.\nbefore: {before_link_urls_sorted:?}\nafter: {after_link_urls:?}\nfixed:\n{fixed}"
956 );
957 assert_eq!(
958 before_image_urls_sorted, after_image_urls,
959 "fix() changed the set of image URLs.\nbefore: {before_image_urls_sorted:?}\nafter: {after_image_urls:?}\nfixed:\n{fixed}"
960 );
961
962 let fixed2 = rule.fix(&ctx2).unwrap();
963 assert_eq!(fixed, fixed2, "fix() is not idempotent");
964 fixed
965 }
966
967 #[test]
972 fn fix_inline_to_full_single_link() {
973 let rule = rule_inline_disallowed();
974 let content = "See the [documentation](https://example.com/docs) for details.\n";
975 let fixed = assert_round_trip_clean(&rule, content);
976 assert_eq!(
977 fixed,
978 "See the [documentation][documentation] for details.\n\n\
979 [documentation]: https://example.com/docs\n"
980 );
981 }
982
983 #[test]
984 fn fix_inline_to_full_multiple_links_dedup_by_url() {
985 let rule = rule_inline_disallowed();
986 let content = "First [docs](https://example.com/x).\nAgain [docs](https://example.com/x).\n";
987 let fixed = assert_round_trip_clean(&rule, content);
988 assert_eq!(
990 fixed,
991 "First [docs][docs].\nAgain [docs][docs].\n\n\
992 [docs]: https://example.com/x\n"
993 );
994 }
995
996 #[test]
997 fn fix_inline_to_full_same_url_different_titles_keeps_both_titles() {
998 let rule = rule_inline_disallowed();
1002 let content = "First [a](https://example.com \"Title A\").\nLater [b](https://example.com \"Title B\").\n";
1003 let fixed = assert_round_trip_clean(&rule, content);
1004 assert!(fixed.contains(r#""Title A""#), "Title A lost in conversion: {fixed}");
1006 assert!(fixed.contains(r#""Title B""#), "Title B lost in conversion: {fixed}");
1007 let def_count = fixed.matches("]: https://example.com").count();
1009 assert_eq!(def_count, 2, "expected two ref defs (one per title), got:\n{fixed}");
1010 }
1011
1012 #[test]
1013 fn fix_inline_to_full_collision_disambiguates_with_suffix() {
1014 let rule = rule_inline_disallowed();
1015 let content = "[docs](https://a.com) and [docs](https://b.com).\n";
1016 let fixed = assert_round_trip_clean(&rule, content);
1017 assert!(fixed.contains("[docs][docs]"));
1019 assert!(fixed.contains("[docs][docs-2]"));
1020 assert!(fixed.contains("[docs]: https://a.com"));
1021 assert!(fixed.contains("[docs-2]: https://b.com"));
1022 }
1023
1024 #[test]
1025 fn fix_inline_to_full_preserves_title() {
1026 let rule = rule_inline_disallowed();
1027 let content = "See [link](https://example.com \"My Title\").\n";
1028 let fixed = assert_round_trip_clean(&rule, content);
1029 assert!(fixed.contains("[link][link]"));
1030 assert!(fixed.contains(r#"[link]: https://example.com "My Title""#));
1031 }
1032
1033 #[test]
1034 fn fix_inline_to_full_title_with_double_quotes_uses_single_quotes() {
1035 let rule = rule_inline_disallowed();
1036 let content = "See [link](https://example.com 'has \"double\" quotes').\n";
1037 let fixed = assert_round_trip_clean(&rule, content);
1038 assert!(
1040 fixed.contains(r#"[link]: https://example.com 'has "double" quotes'"#),
1041 "got:\n{fixed}"
1042 );
1043 }
1044
1045 #[test]
1046 fn fix_inline_to_full_title_with_escaped_quote_unescapes_through_parser() {
1047 let rule = rule_inline_disallowed();
1054 let content = "See [link](https://example.com \"has \\\"escaped\\\" quotes\").\n";
1055 let fixed = assert_round_trip_clean(&rule, content);
1056 assert!(
1058 fixed.contains(r#"[link]: https://example.com 'has "escaped" quotes'"#),
1059 "expected unescaped title with single-quote delimiter, got:\n{fixed}"
1060 );
1061 assert!(
1063 !fixed.contains(r#"\""#),
1064 "title should be unescaped, not pass through literal `\\\"`:\n{fixed}"
1065 );
1066 }
1067
1068 #[test]
1069 fn fix_inline_to_full_image() {
1070 let rule = rule_inline_disallowed();
1071 let content = "Logo: .\n";
1072 let fixed = assert_round_trip_clean(&rule, content);
1073 assert!(fixed.contains("![Company logo][company-logo]"));
1074 assert!(fixed.contains("[company-logo]: https://example.com/logo.png"));
1075 }
1076
1077 #[test]
1078 fn fix_inline_to_full_unicode_text() {
1079 let rule = rule_inline_disallowed();
1080 let content = "Voir [café résumé](https://cafe.example.com).\n";
1081 let fixed = assert_round_trip_clean(&rule, content);
1082 assert!(fixed.contains("[café résumé][café-résumé]"));
1084 assert!(fixed.contains("[café-résumé]: https://cafe.example.com"));
1085 }
1086
1087 #[test]
1088 fn fix_inline_to_full_reuses_existing_ref_def_for_same_url() {
1089 let rule = rule_inline_disallowed();
1090 let content = "Old: [other][site]\n\
1091 New: [docs](https://example.com)\n\
1092 \n\
1093 [site]: https://example.com\n";
1094 let fixed = assert_round_trip_clean(&rule, content);
1095 assert!(
1097 fixed.contains("[docs][site]"),
1098 "expected reuse of existing label, got:\n{fixed}"
1099 );
1100 assert_eq!(fixed.matches("https://example.com").count(), 1);
1102 }
1103
1104 #[test]
1105 fn fix_inline_to_full_avoids_existing_label_collision() {
1106 let rule = rule_inline_disallowed();
1107 let content = "Old: [a][docs]\n\
1108 New: [docs](https://other.com)\n\
1109 \n\
1110 [docs]: https://existing.com\n";
1111 let fixed = assert_round_trip_clean(&rule, content);
1112 assert!(fixed.contains("[docs][docs-2]"));
1114 assert!(fixed.contains("[docs-2]: https://other.com"));
1115 assert!(fixed.contains("[docs]: https://existing.com"));
1117 }
1118
1119 #[test]
1120 fn fix_inline_to_full_no_trailing_newline() {
1121 let rule = rule_inline_disallowed();
1122 let content = "[docs](https://example.com)";
1123 let fixed = assert_round_trip_clean(&rule, content);
1124 assert_eq!(fixed, "[docs][docs]\n\n[docs]: https://example.com\n");
1125 }
1126
1127 #[test]
1128 fn fix_inline_to_full_skips_code_blocks() {
1129 let rule = rule_inline_disallowed();
1130 let content = "Outside [a](https://x.com).\n\n```\n[fenced](https://y.com)\n```\n";
1131 let fixed = assert_round_trip_clean(&rule, content);
1132 assert!(fixed.contains("[fenced](https://y.com)"));
1134 assert!(fixed.contains("[a][a]"));
1136 }
1137
1138 #[test]
1139 fn fix_inline_to_full_skips_frontmatter() {
1140 let rule = rule_inline_disallowed();
1141 let content = "---\nlink: [foo](https://x.com)\n---\n\n[doc](https://y.com)\n";
1142 let fixed = assert_round_trip_clean(&rule, content);
1143 assert!(fixed.contains("link: [foo](https://x.com)"));
1145 assert!(fixed.contains("[doc][doc]"));
1147 }
1148
1149 #[test]
1154 fn fix_full_to_inline() {
1155 let rule = rule_only_inline();
1156 let content = "See [docs][site].\n\n[site]: https://example.com\n";
1157 let fixed = assert_round_trip_clean(&rule, content);
1158 assert!(fixed.contains("[docs](https://example.com)"));
1160 }
1161
1162 #[test]
1163 fn fix_collapsed_to_inline() {
1164 let rule = rule_only_inline();
1165 let content = "See [docs][].\n\n[docs]: https://example.com\n";
1166 let fixed = assert_round_trip_clean(&rule, content);
1167 assert!(fixed.contains("[docs](https://example.com)"));
1168 }
1169
1170 #[test]
1171 fn fix_shortcut_to_inline() {
1172 let rule = rule_only_inline();
1173 let content = "See [docs].\n\n[docs]: https://example.com\n";
1174 let fixed = assert_round_trip_clean(&rule, content);
1175 assert!(fixed.contains("[docs](https://example.com)"));
1176 }
1177
1178 #[test]
1179 fn fix_full_to_inline_preserves_title() {
1180 let rule = rule_only_inline();
1181 let content = "See [docs][site].\n\n[site]: https://example.com \"Site Title\"\n";
1182 let fixed = assert_round_trip_clean(&rule, content);
1183 assert!(
1184 fixed.contains(r#"[docs](https://example.com "Site Title")"#),
1185 "title not preserved, got:\n{fixed}"
1186 );
1187 }
1188
1189 #[test]
1190 fn fix_inline_to_full_text_with_code_span_containing_brackets() {
1191 let rule = rule_inline_disallowed();
1195 let content = "See [`a[0]` index](https://example.com).\n";
1196 let fixed = assert_round_trip_clean(&rule, content);
1197 assert!(
1198 fixed.contains("[`a[0]` index]["),
1199 "code-span text not preserved, got:\n{fixed}"
1200 );
1201 assert!(
1202 fixed.contains("]: https://example.com"),
1203 "missing emitted ref def, got:\n{fixed}"
1204 );
1205 }
1206
1207 #[test]
1208 fn fix_full_to_inline_image() {
1209 let rule = rule_only_inline();
1210 let content = "Logo: ![alt][logo].\n\n[logo]: https://x.com/img.png\n";
1211 let fixed = assert_round_trip_clean(&rule, content);
1212 assert!(fixed.contains(""));
1213 }
1214
1215 #[test]
1220 fn fix_collapsed_to_full() {
1221 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
1223 let content = "[docs][].\n\n[docs]: https://example.com\n";
1224 let fixed = assert_round_trip_clean(&rule, content);
1225 assert_eq!(fixed, "[docs][docs].\n\n[docs]: https://example.com\n");
1226 }
1227
1228 #[test]
1229 fn fix_collapsed_to_full_with_trailing_content() {
1230 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
1235 let content = "See [docs][] for details.\n\n[docs]: https://example.com\n";
1236 let fixed = assert_round_trip_clean(&rule, content);
1237 assert_eq!(fixed, "See [docs][docs] for details.\n\n[docs]: https://example.com\n");
1238 }
1239
1240 #[test]
1241 fn fix_shortcut_to_full() {
1242 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
1243 let content = "See [docs].\n\n[docs]: https://example.com\n";
1244 let fixed = assert_round_trip_clean(&rule, content);
1245 assert!(fixed.contains("See [docs][docs]"));
1246 }
1247
1248 #[test]
1249 fn fix_shortcut_to_collapsed() {
1250 let rule = MD054LinkImageStyle::new(false, true, false, false, false, false);
1251 let content = "See [docs].\n\n[docs]: https://example.com\n";
1252 let fixed = assert_round_trip_clean(&rule, content);
1253 assert!(fixed.contains("See [docs][]"));
1254 }
1255
1256 #[test]
1261 fn fix_autolink_to_inline_form() {
1262 let rule = MD054LinkImageStyle::new(false, true, true, true, true, true);
1267 let content = "Visit <https://example.com> today.\n";
1268 let fixed = assert_round_trip_clean(&rule, content);
1269 assert!(
1270 fixed.contains("[https://example.com](https://example.com)"),
1271 "got: {fixed:?}"
1272 );
1273 }
1274
1275 #[test]
1276 fn fix_autolink_to_full_when_inline_styles_disallowed() {
1277 let rule = MD054LinkImageStyle::new(false, true, true, false, true, false);
1281 let content = "Visit <https://example.com> today.\n";
1282 let fixed = assert_round_trip_clean(&rule, content);
1283 assert!(
1284 fixed.contains("[https://example.com][https-example-com]"),
1285 "got: {fixed:?}"
1286 );
1287 assert!(fixed.contains("[https-example-com]: https://example.com"));
1288 }
1289
1290 #[test]
1291 fn fix_url_inline_to_autolink() {
1292 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
1294 let content = "Visit [https://example.com](https://example.com).\n";
1295 let fixed = assert_round_trip_clean(&rule, content);
1296 assert!(fixed.contains("<https://example.com>"));
1297 }
1298
1299 #[test]
1304 fn fix_no_op_when_target_unreachable() {
1305 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
1308 let content = "See [docs](https://example.com).\n";
1309 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1310 let fixed = rule.fix(&ctx).unwrap();
1311 assert_eq!(fixed, content);
1312 let warnings = rule.check(&ctx).unwrap();
1314 assert_eq!(warnings.len(), 1);
1315 }
1316
1317 #[test]
1318 fn fix_preserves_allowed_links() {
1319 let rule = rule_inline_disallowed();
1320 let content = "Already [ref][r] is fine.\n\n[r]: https://example.com\n";
1321 let fixed = assert_round_trip_clean(&rule, content);
1322 assert_eq!(fixed, content);
1323 }
1324
1325 #[test]
1326 fn fix_keeps_the_closing_bracket_of_a_link_wrapping_a_collapsed_image() {
1327 let rule = rule_inline_disallowed();
1332 let content = "[![alt][]](target.md)\n\n[alt]: exists.png\n";
1333 let fixed = assert_round_trip_clean(&rule, content);
1334 assert_eq!(fixed, "[![alt][]][alt-2]\n\n[alt]: exists.png\n\n[alt-2]: target.md\n");
1335 }
1336
1337 #[test]
1342 fn fix_preferred_style_explicit_full() {
1343 let config = md054_config::MD054Config {
1344 inline: false,
1345 preferred_style: PreferredStyles::single(PreferredStyle::Full),
1346 ..Default::default()
1347 };
1348 let rule = MD054LinkImageStyle::from_config_struct(config);
1349 let content = "[docs](https://example.com)\n";
1350 let fixed = assert_round_trip_clean(&rule, content);
1351 assert!(fixed.contains("[docs][docs]"));
1352 }
1353
1354 #[test]
1355 fn fix_inline_to_collapsed_emits_matching_ref_def() {
1356 let config = md054_config::MD054Config {
1360 inline: false,
1361 preferred_style: PreferredStyles::single(PreferredStyle::Collapsed),
1362 ..Default::default()
1363 };
1364 let rule = MD054LinkImageStyle::from_config_struct(config);
1365 let content = "[anchor](https://example.com)\n";
1366 let fixed = assert_round_trip_clean(&rule, content);
1367 assert!(fixed.contains("[anchor][]"), "got:\n{fixed}");
1368 assert!(fixed.contains("[anchor]: https://example.com"), "got:\n{fixed}");
1369 }
1370
1371 #[test]
1372 fn fix_inline_to_shortcut_emits_matching_ref_def() {
1373 let config = md054_config::MD054Config {
1374 inline: false,
1375 preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1376 ..Default::default()
1377 };
1378 let rule = MD054LinkImageStyle::from_config_struct(config);
1379 let content = "See [anchor](https://example.com).\n";
1381 let fixed = assert_round_trip_clean(&rule, content);
1382 assert!(fixed.contains("[anchor]"), "got:\n{fixed}");
1383 assert!(!fixed.contains("[anchor]("), "shortcut form, not inline: {fixed}");
1384 assert!(fixed.contains("[anchor]: https://example.com"), "got:\n{fixed}");
1385 }
1386
1387 #[test]
1388 fn fix_inline_to_collapsed_skips_empty_text() {
1389 let config = md054_config::MD054Config {
1393 inline: false,
1394 preferred_style: PreferredStyles::single(PreferredStyle::Collapsed),
1395 ..Default::default()
1396 };
1397 let rule = MD054LinkImageStyle::from_config_struct(config);
1398 let content = "[](https://example.com)\n";
1399 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1400 let fixed = rule.fix(&ctx).unwrap();
1401 assert_eq!(fixed, content, "empty text must not collapse: {fixed}");
1402 }
1403
1404 #[test]
1405 fn fix_inline_to_shortcut_skips_empty_text() {
1406 let config = md054_config::MD054Config {
1407 inline: false,
1408 preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1409 ..Default::default()
1410 };
1411 let rule = MD054LinkImageStyle::from_config_struct(config);
1412 let content = "See [](https://example.com).\n";
1413 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1414 let fixed = rule.fix(&ctx).unwrap();
1415 assert_eq!(fixed, content);
1416 }
1417
1418 #[test]
1419 fn fix_inline_to_collapsed_skips_text_with_brackets() {
1420 let config = md054_config::MD054Config {
1423 inline: false,
1424 preferred_style: PreferredStyles::single(PreferredStyle::Collapsed),
1425 ..Default::default()
1426 };
1427 let rule = MD054LinkImageStyle::from_config_struct(config);
1428 let content = "See [`a[0]` index](https://example.com).\n";
1429 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1430 let fixed = rule.fix(&ctx).unwrap();
1431 assert_eq!(fixed, content, "text containing `[` / `]` must not collapse: {fixed}");
1432 }
1433
1434 #[test]
1435 fn fix_inline_to_full_url_with_space_uses_angle_brackets_in_def() {
1436 let rule = rule_inline_disallowed();
1441 let content = "See [docs](<./has space.md>).\n";
1442 let fixed = assert_round_trip_clean(&rule, content);
1443 assert!(
1444 fixed.contains("[docs]: <./has space.md>"),
1445 "ref def must wrap URL in angle brackets: {fixed}"
1446 );
1447 }
1448
1449 #[test]
1450 fn fix_inline_to_full_url_with_unbalanced_paren_uses_angle_brackets_in_def() {
1451 let rule = rule_inline_disallowed();
1454 let content = "See [docs](<https://example.com/a)b>).\n";
1455 let fixed = assert_round_trip_clean(&rule, content);
1456 assert!(
1457 fixed.contains("[docs]: <https://example.com/a)b>"),
1458 "ref def must wrap unbalanced-paren URL in angle brackets: {fixed}"
1459 );
1460 }
1461
1462 #[test]
1463 fn fix_full_to_inline_preserves_backslash_unescaped_title() {
1464 let rule = rule_only_inline();
1468 let content = "See [docs][d].\n\n[d]: https://example.com \"He said \\\"hi\\\"\"\n";
1469 let fixed = assert_round_trip_clean(&rule, content);
1470 assert!(fixed.contains("https://example.com"), "URL must round-trip: {fixed}");
1475 assert!(
1476 fixed.contains(r#"\"hi\""#) || fixed.contains(r#"He said "hi""#),
1477 "title must round-trip with quotes preserved: {fixed}"
1478 );
1479 }
1480
1481 #[test]
1482 fn fix_full_to_inline_url_with_close_paren_uses_angle_brackets() {
1483 let rule = rule_only_inline();
1487 let content = "See [t][r].\n\n[r]: <https://example.com/a)b>\n";
1488 let fixed = assert_round_trip_clean(&rule, content);
1489 assert!(
1490 fixed.contains("[t](<https://example.com/a)b>)"),
1491 "inline form must use angle brackets for `)` URLs: {fixed}"
1492 );
1493 }
1494
1495 #[test]
1496 fn fix_inline_to_collapsed_skips_when_label_collides_with_different_url() {
1497 let config = md054_config::MD054Config {
1502 inline: false,
1503 preferred_style: PreferredStyles::single(PreferredStyle::Collapsed),
1504 ..Default::default()
1505 };
1506 let rule = MD054LinkImageStyle::from_config_struct(config);
1507 let content = "[other][anchor]\n[anchor](https://other.com)\n\n[anchor]: https://existing.com\n";
1508 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1509 let fixed = rule.fix(&ctx).unwrap();
1510 assert!(fixed.contains("[anchor](https://other.com)"), "got:\n{fixed}");
1512 assert!(fixed.contains("[anchor]: https://existing.com"));
1514 }
1515
1516 #[test]
1517 fn fix_preferred_style_list_picks_first_reachable() {
1518 let config = md054_config::MD054Config {
1521 url_inline: false,
1522 preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Full]),
1523 ..Default::default()
1524 };
1525 let rule = MD054LinkImageStyle::from_config_struct(config);
1526 let content = "[https://example.com](https://example.com)\n";
1527 let fixed = assert_round_trip_clean(&rule, content);
1528 assert!(
1529 fixed.contains("<https://example.com>"),
1530 "expected autolink form, got:\n{fixed}"
1531 );
1532 }
1533
1534 #[test]
1535 fn fix_preferred_style_list_falls_back_to_next_when_first_unreachable() {
1536 let config = md054_config::MD054Config {
1539 inline: false,
1540 preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Full]),
1541 ..Default::default()
1542 };
1543 let rule = MD054LinkImageStyle::from_config_struct(config);
1544 let content = "[docs](./guide.md)\n";
1545 let fixed = assert_round_trip_clean(&rule, content);
1546 assert!(
1547 fixed.contains("[docs][docs]"),
1548 "expected fallback to full, got:\n{fixed}"
1549 );
1550 assert!(
1551 fixed.contains("[docs]: ./guide.md"),
1552 "expected matching ref def, got:\n{fixed}"
1553 );
1554 }
1555
1556 #[test]
1557 fn fix_preferred_style_auto_in_list_acts_as_wildcard_fallback() {
1558 let config = md054_config::MD054Config {
1562 inline: false,
1563 preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Auto]),
1564 ..Default::default()
1565 };
1566 let rule = MD054LinkImageStyle::from_config_struct(config);
1567 let content = "[docs](./guide.md)\n";
1568 let fixed = assert_round_trip_clean(&rule, content);
1569 assert!(
1570 fixed.contains("[docs][docs]"),
1571 "Auto fallback should pick full for inline-disallowed config, got:\n{fixed}"
1572 );
1573 }
1574
1575 #[test]
1576 fn fix_default_auto_prefers_autolink_for_url_inline_source() {
1577 let rule = MD054LinkImageStyle::new(true, true, true, true, true, false);
1581 let content = "[https://example.com](https://example.com)\n";
1582 let fixed = assert_round_trip_clean(&rule, content);
1583 assert!(
1584 fixed.contains("<https://example.com>"),
1585 "expected autolink, got:\n{fixed}"
1586 );
1587 assert!(
1588 !fixed.contains("[https://example.com]["),
1589 "should not produce reference form when autolink is reachable, got:\n{fixed}"
1590 );
1591 }
1592
1593 #[test]
1594 fn fix_default_auto_falls_back_when_autolink_disallowed() {
1595 let rule = MD054LinkImageStyle::new(false, true, true, true, true, false);
1598 let content = "[https://example.com](https://example.com)\n";
1599 let fixed = assert_round_trip_clean(&rule, content);
1600 assert!(
1601 fixed.contains("[https://example.com][https-example-com]"),
1602 "expected full form, got:\n{fixed}"
1603 );
1604 assert!(
1605 fixed.contains("[https-example-com]: https://example.com"),
1606 "missing ref def, got:\n{fixed}"
1607 );
1608 }
1609
1610 #[test]
1611 fn fix_preferred_style_explicit_no_match_skips_fix() {
1612 let config = md054_config::MD054Config {
1615 inline: false,
1616 preferred_style: PreferredStyles::single(PreferredStyle::Inline),
1618 ..Default::default()
1619 };
1620 let rule = MD054LinkImageStyle::from_config_struct(config);
1621 let content = "[docs](./guide.md)\n";
1622 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1623 let fixed = rule.fix(&ctx).unwrap();
1624 assert_eq!(fixed, content, "expected no-op fix, got:\n{fixed}");
1625 }
1626
1627 #[test]
1632 fn fix_mixes_inline_and_image_in_same_doc() {
1633 let rule = rule_inline_disallowed();
1634 let content = "Text [link](https://example.com) and .\n";
1635 let fixed = assert_round_trip_clean(&rule, content);
1636 assert!(fixed.contains("[link][link]"));
1637 assert!(fixed.contains("![pic][pic]"));
1638 assert!(fixed.contains("[link]: https://example.com"));
1639 assert!(fixed.contains("[pic]: https://example.com/p.png"));
1640 }
1641
1642 #[test]
1643 fn fix_appends_one_blank_line_separator() {
1644 let rule = rule_inline_disallowed();
1645 let content = "Plain prose.\n\n[link](https://x.com)\n";
1646 let fixed = assert_round_trip_clean(&rule, content);
1647 assert!(fixed.ends_with("\n[link]: https://x.com\n"));
1649 assert!(!fixed.contains("\n\n\n[link]"));
1650 }
1651
1652 #[test]
1657 fn fix_nested_image_in_link_does_not_panic_or_corrupt() {
1658 let rule = rule_inline_disallowed();
1665 let content = "See [](https://x.com).\n";
1666 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1667 let fixed = rule.fix(&ctx).unwrap();
1669 assert_eq!(fixed, content);
1672 let warnings = rule.check(&ctx).unwrap();
1673 assert_eq!(warnings.len(), 2, "both nested constructs should still warn");
1674 }
1675
1676 #[test]
1681 fn fix_email_autolink_to_inline_preserves_mailto_prefix() {
1682 let rule = MD054LinkImageStyle::new(false, true, true, true, true, true);
1688 let content = "Reach <me@example.com> for support.\n";
1689 let fixed = assert_round_trip_clean(&rule, content);
1690 assert!(
1691 fixed.contains("[me@example.com](mailto:me@example.com)"),
1692 "expected mailto: prefix on resolved destination, got:\n{fixed}"
1693 );
1694 }
1695
1696 #[test]
1697 fn fix_email_autolink_to_full_preserves_mailto_in_ref_def() {
1698 let rule = MD054LinkImageStyle::new(false, true, true, false, true, false);
1702 let content = "Reach <me@example.com> for support.\n";
1703 let fixed = assert_round_trip_clean(&rule, content);
1704 assert!(
1705 fixed.contains("]: mailto:me@example.com"),
1706 "ref def should carry the mailto: prefix, got:\n{fixed}"
1707 );
1708 }
1709
1710 #[test]
1711 fn fix_rejects_bare_email_as_autolink_target() {
1712 let config = md054_config::MD054Config {
1718 url_inline: false,
1719 preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Auto]),
1720 ..Default::default()
1721 };
1722 let rule = MD054LinkImageStyle::from_config_struct(config);
1723 let content = "[me@example.com](me@example.com)\n";
1724 let fixed = assert_round_trip_clean(&rule, content);
1725 assert!(
1726 !fixed.contains("<me@example.com>"),
1727 "bare-email autolink target would silently retarget to mailto:, got:\n{fixed}"
1728 );
1729 }
1730
1731 #[test]
1740 fn fix_generated_ref_def_with_both_quote_types_round_trips_to_ctx() {
1741 let rule = rule_inline_disallowed();
1747 let content = "See [docs](https://example.com/x \"and 'both' quotes\") today.\n";
1748 let fixed = assert_round_trip_clean(&rule, content);
1749 assert!(
1752 fixed.contains("(and 'both' quotes)") || fixed.contains("\"and 'both' quotes\""),
1753 "title should round-trip through some valid delimiter, got:\n{fixed}"
1754 );
1755 let ctx = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
1759 let def = ctx
1760 .reference_definitions()
1761 .iter()
1762 .find(|d| d.url == "https://example.com/x")
1763 .expect("generated ref def must round-trip through parse_reference_defs");
1764 assert_eq!(
1765 def.title.as_deref(),
1766 Some("and 'both' quotes"),
1767 "title content must survive the round-trip"
1768 );
1769 }
1770
1771 #[test]
1776 fn fix_appends_generated_refs_with_crlf_when_source_is_crlf() {
1777 let rule = rule_inline_disallowed();
1783 let content = "See [docs](https://example.com/x).\r\n";
1784 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1785 let warnings = rule.check(&ctx).expect("check must succeed");
1786 assert!(!warnings.is_empty(), "expected at least one warning");
1787 let fixed = rule.fix(&ctx).expect("fix must succeed");
1788 assert!(
1789 fixed.contains("\r\n"),
1790 "fixed output must preserve CRLF, got:\n{fixed:?}"
1791 );
1792 assert!(
1793 !fixed.lines().any(|l| l.ends_with('\r')) || !fixed.contains("\n\n"),
1794 "no line should end with stray \\r and there should be no naked LF blanks; got:\n{fixed:?}"
1795 );
1796 let bytes = fixed.as_bytes();
1799 for (i, &b) in bytes.iter().enumerate() {
1800 if b == b'\n' {
1801 assert!(
1802 i > 0 && bytes[i - 1] == b'\r',
1803 "found naked LF at byte {i} in CRLF document, full output:\n{fixed:?}"
1804 );
1805 }
1806 }
1807 }
1808
1809 #[test]
1810 fn fix_appends_generated_refs_with_lf_when_source_is_lf() {
1811 let rule = rule_inline_disallowed();
1813 let content = "See [docs](https://example.com/x).\n";
1814 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1815 let fixed = rule.fix(&ctx).expect("fix must succeed");
1816 assert!(
1817 !fixed.contains('\r'),
1818 "LF document must not gain any CR characters, got:\n{fixed:?}"
1819 );
1820 }
1821
1822 #[test]
1827 fn fix_rejects_shortcut_target_when_followed_by_paren() {
1828 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)(suffix)\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");
1845 }
1846
1847 #[test]
1848 fn fix_rejects_shortcut_target_when_followed_by_bracket() {
1849 let config = md054_config::MD054Config {
1853 inline: false,
1854 preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1855 ..Default::default()
1856 };
1857 let rule = MD054LinkImageStyle::from_config_struct(config);
1858 let content = "[docs](https://example.com/x)[next]\n\n[next]: https://example.com/n\n";
1859 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1860 let fixed = rule.fix(&ctx).unwrap();
1861 assert_eq!(fixed, content, "shortcut target was unsafe; fix should be a no-op");
1862 }
1863
1864 #[test]
1865 fn fix_allows_shortcut_target_when_follower_is_safe() {
1866 let config = md054_config::MD054Config {
1870 inline: false,
1871 preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1872 ..Default::default()
1873 };
1874 let rule = MD054LinkImageStyle::from_config_struct(config);
1875 let content = "See [docs](https://example.com/x). Also nice.\n";
1876 let fixed = assert_round_trip_clean(&rule, content);
1877 assert!(fixed.contains("[docs]"), "expected shortcut form, got:\n{fixed}");
1878 assert!(fixed.contains("[docs]: https://example.com/x"));
1879 }
1880
1881 #[test]
1886 fn check_attaches_fix_for_self_contained_rewrites() {
1887 let rule = MD054LinkImageStyle::new(false, true, true, true, true, true);
1893 let content = "See <https://example.com>.\n";
1894 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1895 let warnings = rule.check(&ctx).unwrap();
1896 assert_eq!(warnings.len(), 1, "should warn about the autolink");
1897 let fix = warnings[0]
1898 .fix
1899 .as_ref()
1900 .expect("self-contained rewrite must carry a Fix so quick-fix paths can apply it");
1901 assert_eq!(&content[fix.range.clone()], "<https://example.com>");
1902 assert_eq!(fix.replacement, "[https://example.com](https://example.com)");
1903 }
1904
1905 #[test]
1906 fn check_carries_atomic_fix_when_rewrite_requires_new_ref_def() {
1907 let rule = rule_inline_disallowed();
1914 let content = "See [docs](https://example.com).\n";
1915 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1916 let warnings = rule.check(&ctx).unwrap();
1917 assert_eq!(warnings.len(), 1, "should warn about the inline link");
1918 let fix = warnings[0]
1919 .fix
1920 .as_ref()
1921 .expect("ref-emitting rewrite must carry an atomic per-warning Fix");
1922 assert_eq!(&content[fix.range.clone()], "[docs](https://example.com)");
1923 assert!(
1924 fix.replacement.starts_with("[docs]"),
1925 "primary replacement should rewrite the link to a reference form, got: {:?}",
1926 fix.replacement
1927 );
1928 assert_eq!(
1929 fix.additional_edits.len(),
1930 1,
1931 "ref-emitting fix should carry one additional_edit for the ref-def"
1932 );
1933 let extra = &fix.additional_edits[0];
1934 assert_eq!(
1935 extra.range,
1936 content.len()..content.len(),
1937 "ref-def insertion should be a zero-width edit at EOF"
1938 );
1939 assert!(
1940 extra.replacement.contains("[docs]: https://example.com"),
1941 "additional_edit should append the ref-def, got: {:?}",
1942 extra.replacement
1943 );
1944 let applied = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
1948 let from_fix_all = rule.fix(&ctx).unwrap();
1949 assert!(
1950 applied.contains("[docs]: https://example.com"),
1951 "single-warning application must include ref-def, got:\n{applied}"
1952 );
1953 assert!(
1954 !applied.contains("[docs](https://example.com)"),
1955 "single-warning application must rewrite the inline link, got:\n{applied}"
1956 );
1957 assert!(
1962 from_fix_all.contains("[docs]: https://example.com"),
1963 "fix-all path must also produce the ref-def, got:\n{from_fix_all}"
1964 );
1965 }
1966
1967 #[test]
1968 fn check_attaches_no_fix_when_target_unreachable() {
1969 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
1974 let content = "See [docs](https://example.com).\n";
1975 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1976 let warnings = rule.check(&ctx).unwrap();
1977 assert_eq!(warnings.len(), 1);
1978 assert!(warnings[0].fix.is_none(), "unreachable target should leave fix empty");
1979 }
1980
1981 #[test]
1982 fn fix_skips_autolink_target_when_title_present() {
1983 let config = md054_config::MD054Config {
1988 url_inline: false,
1989 preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Auto]),
1990 ..Default::default()
1991 };
1992 let rule = MD054LinkImageStyle::from_config_struct(config);
1993 let content = "[https://example.com](https://example.com \"Homepage\")\n";
1994 let fixed = assert_round_trip_clean(&rule, content);
1995 assert!(
1996 !fixed.contains("<https://example.com>"),
1997 "autolink target would drop the title, got:\n{fixed}"
1998 );
1999 assert!(
2000 fixed.contains("\"Homepage\""),
2001 "title text must survive the conversion, got:\n{fixed}"
2002 );
2003 }
2004
2005 #[test]
2006 fn default_config_section_emits_clean_user_facing_defaults() {
2007 let rule = MD054LinkImageStyle::default();
2012 let (_, value) = rule.default_config_section().expect("md054 has defaults");
2013 let table = value.as_table().expect("config section is a table");
2014 let preferred = table
2015 .get("preferred-style")
2016 .expect("preferred-style key must be present in defaults");
2017 assert!(
2018 !crate::rule_config_serde::is_polymorphic_sentinel(preferred),
2019 "preferred-style in user-facing defaults must be the serialized scalar, not the sentinel; got {preferred:?}"
2020 );
2021 assert!(
2024 preferred.is_str(),
2025 "preferred-style default should serialize as a scalar string; got {preferred:?}"
2026 );
2027 }
2028
2029 #[test]
2030 fn registry_marks_preferred_style_polymorphic_for_validation() {
2031 let registry = crate::config::registry::default_registry();
2037 let expected = registry
2038 .expected_value_for("MD054", "preferred-style")
2039 .or_else(|| registry.expected_value_for("MD054", "preferred_style"));
2040 assert!(
2045 expected.is_none(),
2046 "preferred-style must be sentinel-marked in the schema so type checking is skipped; got {expected:?}"
2047 );
2048 let keys = registry.config_keys_for("MD054").expect("md054 must be registered");
2051 assert!(keys.contains("preferred-style"));
2052 }
2053}
2054
2055#[cfg(test)]
2056mod autolink_literal_tests {
2057 use super::*;
2058 fn rendered(source: &str) -> String {
2059 let mut output = String::new();
2060 pulldown_cmark::html::push_html(&mut output, pulldown_cmark::Parser::new(source));
2061 output
2062 }
2063 #[test]
2064 fn md054_autolink_conversions_preserve_literal_display_and_destination() {
2065 for rule in [
2066 MD054LinkImageStyle::new(false, false, false, true, false, true),
2067 MD054LinkImageStyle::new(false, false, true, false, false, false),
2068 ] {
2069 for value in [
2070 "https://example.org/a*b*c",
2071 "https://example.org/a`b`c",
2072 "https://example.org/a©b",
2073 "https://example.org/a\\*b*c",
2074 "https://example.org/a\\(b)",
2075 "https://example.org/[b]",
2076 "a*b*c@example.org",
2077 ] {
2078 for ending in ["\n", "\r\n"] {
2079 let source = format!("内容 <{value}>{ending}");
2080 let ctx =
2081 crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
2082 let fixed = rule.fix(&ctx).unwrap();
2083 assert_ne!(fixed, source, "conversion remains available: {source}");
2084 assert_eq!(rendered(&fixed), rendered(&source), "{source} -> {fixed}");
2085 let ctx =
2086 crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2087 assert!(rule.check(&ctx).unwrap().is_empty(), "{fixed}");
2088 assert_eq!(rule.fix(&ctx).unwrap(), fixed);
2089 }
2090 }
2091 }
2092 }
2093 #[test]
2094 fn md054_inline_url_style_round_trips_escaped_literal_labels() {
2095 let rule = MD054LinkImageStyle::new(false, false, false, false, false, true);
2096 let source = "<https://example.org/a*b*c>\n";
2097 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2098 let fixed = rule.fix(&ctx).unwrap();
2099 assert_eq!(rendered(&fixed), rendered(source));
2100 let ctx = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2101 assert!(rule.check(&ctx).unwrap().is_empty(), "{fixed}");
2102 assert_eq!(rule.fix(&ctx).unwrap(), fixed);
2103 }
2104 #[test]
2105 fn md054_preserves_authored_markdown_link_labels() {
2106 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2107 let source = "[*formatted* and `code`](https://example.org)\n";
2108 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2109 let fixed = rule.fix(&ctx).unwrap();
2110 assert!(fixed.starts_with("[*formatted* and `code`]["));
2111 assert_eq!(rendered(&fixed), rendered(source));
2112 }
2113}
2114
2115#[cfg(test)]
2116mod formatted_url_label_tests {
2117 use super::*;
2118 fn rendered(source: &str) -> String {
2119 let mut html = String::new();
2120 pulldown_cmark::html::push_html(
2121 &mut html,
2122 pulldown_cmark::Parser::new_ext(source, pulldown_cmark::Options::ENABLE_STRIKETHROUGH),
2123 );
2124 html
2125 }
2126 #[test]
2127 fn md054_formatted_url_labels_cannot_become_literal_autolinks() {
2128 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
2129 for url in [
2130 "https://example.org/a*b*c",
2131 "https://example.org/a`b`c",
2132 "https://example.org/a~~b~~c",
2133 "https://example.org/?q=**bold**",
2134 ] {
2135 let source = format!("内容 [{url}]({url})\n");
2136 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
2137 let warnings = rule.check(&ctx).unwrap();
2138 assert_eq!(warnings.len(), 1);
2139 assert!(warnings[0].fix.is_none(), "conversion would lose formatting: {source}");
2140 let fixed = rule.fix(&ctx).unwrap();
2141 assert_eq!(fixed, source);
2142 assert_eq!(rendered(&fixed), rendered(&source));
2143 }
2144 }
2145 #[test]
2146 fn md054_formatted_url_labels_use_inline_style() {
2147 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
2148 let source = "[https://example.org/a*b*c](https://example.org/a*b*c)\n";
2149 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2150 assert!(rule.check(&ctx).unwrap().is_empty());
2151 assert_eq!(rule.fix(&ctx).unwrap(), source);
2152 }
2153 #[test]
2154 fn md054_literal_url_labels_remain_convertible() {
2155 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
2156 for (label, url) in [
2157 ("https://example.org/plain", "https://example.org/plain"),
2158 ("https://example.org/a_b_c", "https://example.org/a_b_c"),
2159 ("https://example.org/a*b", "https://example.org/a*b"),
2160 ("https://example.org/a\\*b\\*c", "https://example.org/a*b*c"),
2161 ] {
2162 let source = format!("[{label}]({url})\n");
2163 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
2164 let fixed = rule.fix(&ctx).unwrap();
2165 assert_eq!(fixed, format!("<{url}>\n"));
2166 assert_eq!(rendered(&fixed), rendered(&source));
2167 }
2168 }
2169}
2170
2171#[cfg(test)]
2172mod template_context_tests {
2173 use super::*;
2174 #[test]
2175 fn md054_template_strings_preserve_link_and_image_syntax() {
2176 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2177 for prefix in ["", "!"] {
2178 let literal = format!("{prefix}[literal](https://literal.example.org)");
2179 for (flavor, template) in [
2180 (
2181 crate::config::MarkdownFlavor::Standard,
2182 format!("{{{{ \"{literal}\" }}}}"),
2183 ),
2184 (
2185 crate::config::MarkdownFlavor::Standard,
2186 format!("{{% set value=\"{literal}\" %}}"),
2187 ),
2188 (
2189 crate::config::MarkdownFlavor::Hugo,
2190 format!("{{{{< note title=\"{literal}\" >}}}}"),
2191 ),
2192 (
2193 crate::config::MarkdownFlavor::Hugo,
2194 format!("{{{{% note title=`{literal}` %}}}}"),
2195 ),
2196 (
2197 crate::config::MarkdownFlavor::Quarto,
2198 format!("{{{{< note title=\"{literal}\" >}}}}"),
2199 ),
2200 ] {
2201 for ending in ["\n", "\r\n"] {
2202 let source =
2203 format!("Before {template} after {prefix}[visible](https://visible.example.org){ending}");
2204 let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
2205 let warnings = rule.check(&ctx).unwrap();
2206 assert_eq!(warnings.len(), 1, "{source}");
2207 let fixed = rule.fix(&ctx).unwrap();
2208 assert!(fixed.contains(&template), "{source} -> {fixed}");
2209 assert!(!fixed.contains("[visible](https://visible.example.org)"));
2210 assert!(
2211 !fixed.contains("[literal]:"),
2212 "literal-only references must not be appended: {fixed}"
2213 );
2214 let ctx = crate::lint_context::LintContext::new(&fixed, flavor, None);
2215 assert!(rule.check(&ctx).unwrap().is_empty(), "{fixed}");
2216 assert_eq!(rule.fix(&ctx).unwrap(), fixed);
2217 }
2218 }
2219 }
2220 }
2221 #[test]
2222 fn md054_template_code_inside_link_destinations_is_preserved() {
2223 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2224 for prefix in ["", "!"] {
2225 let source = format!(
2226 "{prefix}[literal](<{{{{ \"&copy;\"|length }}}}>)\n\n[visible](https://visible.example.org)\n"
2227 );
2228 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
2229 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2230 let fixed = rule.fix(&ctx).unwrap();
2231 assert!(fixed.starts_with(source.split("\n\n").next().unwrap()), "{fixed}");
2232 assert!(!fixed.contains("[literal]:"));
2233 }
2234 }
2235 #[test]
2236 fn md054_myst_comments_do_not_contribute_style_warnings_or_fixes() {
2237 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2238 for marker in ["%", "% ", " %\t", " % "] {
2239 for prefix in ["", "!"] {
2240 let comment = format!("{marker}{prefix}[literal](https://literal.example.org)");
2241 let source = format!("{comment}\n\n{prefix}[visible](https://visible.example.org)\n");
2242 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::MyST, None);
2243 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source}");
2244 let fixed = rule.fix(&ctx).unwrap();
2245 assert!(fixed.starts_with(&comment), "{fixed}");
2246 assert!(!fixed.contains("[literal]:"));
2247 assert!(!fixed.contains("[visible](https://visible.example.org)"));
2248 }
2249 }
2250 }
2251 #[test]
2252 fn md054_disabled_rules_keep_framework_warning_filtering() {
2253 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2254 let source = "<!-- markdownlint-disable MD054 -->\n\n[text](https://example.org)\n";
2255 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2256 assert!(ctx.is_rule_disabled("MD054", 3));
2257 let warnings = rule.check(&ctx).unwrap();
2259 assert_eq!(warnings.len(), 1);
2260 assert!(warnings[0].fix.is_none());
2261 assert_eq!(rule.fix(&ctx).unwrap(), source);
2262 }
2263}
2264
2265#[cfg(test)]
2266mod template_reference_tests {
2267 use super::*;
2268
2269 #[test]
2270 fn md054_template_reference_definitions_are_not_reused() {
2271 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2272 for prefix in ["", "!"] {
2273 for ending in ["\n", "\r\n"] {
2274 for hidden_url in ["https://example.org", "https://other.example.org"] {
2275 for title in ["", " \"title\""] {
2276 let template =
2277 format!("{{% set unused=\"\n[reuse]: {hidden_url}\n\" %}}{{{{ unused|length }}}}")
2278 .replace('\n', ending);
2279 let source =
2280 format!("{template}{ending}{ending}{prefix}[reuse](https://example.org{title}){ending}");
2281 let ctx = crate::lint_context::LintContext::new(
2282 &source,
2283 crate::config::MarkdownFlavor::Standard,
2284 None,
2285 );
2286 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2287 let fixed = rule.fix(&ctx).unwrap();
2288 assert!(fixed.starts_with(&template), "{fixed}");
2289 assert!(
2290 fixed.contains(&format!("{prefix}[reuse][reuse-2]")),
2291 "{source} -> {fixed}"
2292 );
2293 assert!(
2294 fixed.contains(&format!("[reuse-2]: https://example.org{title}")),
2295 "{fixed}"
2296 );
2297 let ctx = crate::lint_context::LintContext::new(
2298 &fixed,
2299 crate::config::MarkdownFlavor::Standard,
2300 None,
2301 );
2302 assert!(rule.check(&ctx).unwrap().is_empty(), "{fixed}");
2303 assert_eq!(rule.fix(&ctx).unwrap(), fixed);
2304 }
2305 }
2306 }
2307 }
2308 }
2309
2310 #[test]
2311 fn md054_collapsed_and_shortcut_do_not_reuse_template_labels() {
2312 for rule in [
2313 MD054LinkImageStyle::new(false, true, false, false, false, false),
2314 MD054LinkImageStyle::new(false, false, false, false, true, false),
2315 ] {
2316 let source = "{% set unused=\"\n[reuse]: https://example.org\n\" %}{{ unused|length }}\n\n[reuse](https://example.org)\n";
2317 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2318 assert!(rule.check(&ctx).unwrap()[0].fix.is_none());
2320 assert_eq!(rule.fix(&ctx).unwrap(), source);
2321 }
2322 }
2323
2324 #[test]
2325 fn md054_real_prose_references_remain_reusable_after_templates() {
2326 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2327 let source = "{% set unused=\"\n[hidden]: https://example.org\n\" %}{{ unused|length }}\n\n[real]: https://example.org\n\n[reuse](https://example.org)\n";
2328 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2329 assert_eq!(
2330 rule.fix(&ctx).unwrap(),
2331 source.replace("[reuse](https://example.org)", "[reuse][real]")
2332 );
2333 }
2334}