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 is_style_allowed(&self, style: &str) -> bool {
113 match style {
114 "autolink" => self.config.autolink,
115 "collapsed" => self.config.collapsed,
116 "full" => self.config.full,
117 "inline" => self.config.inline,
118 "shortcut" => self.config.shortcut,
119 "url-inline" => self.config.url_inline,
120 _ => false,
121 }
122 }
123}
124
125impl Rule for MD054LinkImageStyle {
126 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
127 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(54))
128 }
129
130 fn name(&self) -> &'static str {
131 "MD054"
132 }
133
134 fn description(&self) -> &'static str {
135 "Link and image style should be consistent"
136 }
137
138 fn category(&self) -> RuleCategory {
139 RuleCategory::Link
140 }
141
142 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
143 let content = ctx.content;
144 let mut warnings = Vec::new();
145
146 let plan = if self.should_skip(ctx) {
156 transform::FixPlan::default()
157 } else {
158 transform::plan(ctx, &self.config)
159 };
160 let entries_by_offset: HashMap<usize, &transform::PlannedEdit> =
161 plan.entries.iter().map(|e| (e.edit.range.start, e)).collect();
162 let ref_appender = std::cell::LazyCell::new(|| transform::RefDefAppender::new(content));
165 let build_fix = |offset: usize| -> Option<Fix> {
166 let entry = entries_by_offset.get(&offset)?;
167 let primary_range = entry.edit.range.clone();
168 let primary_replacement = entry.edit.replacement.clone();
169 match &entry.new_ref {
170 None => Some(Fix::new(primary_range, primary_replacement)),
171 Some(def) => {
172 let appended = ref_appender.render(def)?;
173 let eof_range = content.len()..content.len();
174 Some(Fix::with_additional_edits(
175 primary_range,
176 primary_replacement,
177 vec![Fix::new(eof_range, appended)],
178 ))
179 }
180 }
181 };
182
183 for link in ctx.links() {
185 if matches!(
189 link.link_type,
190 LinkType::Reference | LinkType::Collapsed | LinkType::Shortcut
191 ) && link.url.is_empty()
192 {
193 continue;
194 }
195
196 let style = match link.link_type {
197 LinkType::Autolink | LinkType::Email => "autolink",
198 LinkType::Inline => {
199 if link_text_matches_url(&link.text, &link.url) {
200 "url-inline"
201 } else {
202 "inline"
203 }
204 }
205 LinkType::Reference => "full",
206 LinkType::Collapsed => "collapsed",
207 LinkType::Shortcut => "shortcut",
208 _ => continue,
209 };
210
211 if transform::skip_non_prose(ctx, link.line, link.byte_offset, link.byte_end) {
212 continue;
213 }
214
215 if !self.is_style_allowed(style) {
216 warnings.push(LintWarning {
217 rule_name: Some(self.name().to_string()),
218 line: link.line,
219 column: link.start_col + 1,
220 end_line: link.end_line,
221 end_column: link.end_col + 1,
222 message: format!("Link/image style '{style}' is not allowed"),
223 severity: Severity::Warning,
224 fix: build_fix(link.byte_offset),
225 });
226 }
227 }
228
229 for image in ctx.images() {
231 if matches!(
235 image.link_type,
236 LinkType::Reference | LinkType::Collapsed | LinkType::Shortcut
237 ) && image.url.is_empty()
238 {
239 continue;
240 }
241
242 let style = match image.link_type {
243 LinkType::Autolink | LinkType::Email => "autolink",
244 LinkType::Inline => {
245 if image.alt_text == image.url {
246 "url-inline"
247 } else {
248 "inline"
249 }
250 }
251 LinkType::Reference => "full",
252 LinkType::Collapsed => "collapsed",
253 LinkType::Shortcut => "shortcut",
254 _ => continue,
255 };
256
257 if transform::skip_non_prose(ctx, image.line, image.byte_offset, image.byte_end) {
258 continue;
259 }
260
261 if !self.is_style_allowed(style) {
262 warnings.push(LintWarning {
263 rule_name: Some(self.name().to_string()),
264 line: image.line,
265 column: image.start_col + 1,
266 end_line: image.end_line,
267 end_column: image.end_col + 1,
268 message: format!("Link/image style '{style}' is not allowed"),
269 severity: Severity::Warning,
270 fix: build_fix(image.byte_offset),
271 });
272 }
273 }
274
275 Ok(warnings)
276 }
277
278 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
279 if self.should_skip(ctx) {
280 return Ok(ctx.content.to_string());
281 }
282 let plan = transform::plan(ctx, &self.config);
283 Ok(transform::apply(ctx.content, plan))
284 }
285
286 fn fix_capability(&self) -> crate::rule::FixCapability {
287 crate::rule::FixCapability::ConditionallyFixable
290 }
291
292 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
293 ctx.content.is_empty() || (!ctx.likely_has_links_or_images() && !ctx.likely_has_html())
294 }
295
296 fn as_any(&self) -> &dyn std::any::Any {
297 self
298 }
299
300 crate::impl_rule_config_methods!(MD054Config);
301
302 fn polymorphic_config_keys(&self) -> &'static [&'static str] {
303 &["preferred-style"]
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use crate::lint_context::LintContext;
316
317 #[test]
318 fn diagnostic_positions_match_source_spans() {
319 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
322 let prefix = "é日本🙂e\u{301}\t".repeat(100);
323 let source = format!(
324 "{prefix}[标签](target)  <https://example.com> <user@example.com>\n\n\
325 {prefix}[ref][] ![ref][] [ref] ![ref] [full][ref] ![full][ref]\n\n\
326 {prefix}[multi\n{prefix}line](target) \n\n\
327 [](target) [target](target)\n\n[ref]: target\n"
328 );
329 for flavor in crate::format_proof::ALL_FLAVORS {
330 for newline in ["\n", "\r\n"] {
331 let content = source.replace('\n', newline);
332 let ctx = LintContext::new(&content, flavor, None);
333 let warnings = rule.check(&ctx).unwrap();
334 let spans: Vec<_> = ctx
335 .links()
336 .iter()
337 .map(|link| (link.byte_offset, link.byte_end))
338 .chain(ctx.images().iter().map(|image| (image.byte_offset, image.byte_end)))
339 .collect();
340 assert_eq!(warnings.len(), 15, "{flavor:?}");
341 assert_eq!(warnings.len(), spans.len());
342 for (warning, (start, end)) in warnings.iter().zip(spans) {
343 let position = |offset| {
345 let before = &content[..offset];
346 let line = before.bytes().filter(|&b| b == b'\n').count() + 1;
347 let column = before.rsplit('\n').next().unwrap().chars().count() + 1;
348 (line, column)
349 };
350 assert_eq!((warning.line, warning.column), position(start), "{flavor:?}");
351 assert_eq!((warning.end_line, warning.end_column), position(end), "{flavor:?}");
352 }
353 }
354 }
355 }
356
357 #[test]
358 fn test_all_styles_allowed_by_default() {
359 let rule = MD054LinkImageStyle::new(true, true, true, true, true, true);
360 let content = "[inline](url) [ref][] [ref] <https://autolink.com> [full][ref] [url](url)\n\n[ref]: url";
361 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
362 let result = rule.check(&ctx).unwrap();
363
364 assert_eq!(result.len(), 0);
365 }
366
367 #[test]
368 fn test_only_inline_allowed() {
369 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
370 let content = "[allowed](url) [not][ref] <https://bad.com> [collapsed][] [shortcut]\n\n[ref]: url\n[shortcut]: url\n[collapsed]: url";
372 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
373 let result = rule.check(&ctx).unwrap();
374
375 assert_eq!(result.len(), 4, "Expected 4 warnings, got: {result:?}");
376 assert!(result[0].message.contains("'full'"));
377 assert!(result[1].message.contains("'autolink'"));
378 assert!(result[2].message.contains("'collapsed'"));
379 assert!(result[3].message.contains("'shortcut'"));
380 }
381
382 #[test]
383 fn test_only_autolink_allowed() {
384 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
385 let content = "<https://good.com> [bad](url) [bad][ref]\n\n[ref]: url";
386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
387 let result = rule.check(&ctx).unwrap();
388
389 assert_eq!(result.len(), 2, "Expected 2 warnings, got: {result:?}");
390 assert!(result[0].message.contains("'inline'"));
391 assert!(result[1].message.contains("'full'"));
392 }
393
394 #[test]
395 fn test_url_inline_detection() {
396 let rule = MD054LinkImageStyle::new(false, false, false, true, false, true);
397 let content = "[https://example.com](https://example.com) [text](https://example.com)";
398 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
399 let result = rule.check(&ctx).unwrap();
400
401 assert_eq!(result.len(), 0);
403 }
404
405 #[test]
406 fn test_url_inline_not_allowed() {
407 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
408 let content = "[https://example.com](https://example.com)";
409 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
410 let result = rule.check(&ctx).unwrap();
411
412 assert_eq!(result.len(), 1);
413 assert!(result[0].message.contains("'url-inline'"));
414 }
415
416 #[test]
417 fn test_shortcut_vs_full_detection() {
418 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
419 let content = "[shortcut] [full][ref]\n\n[shortcut]: url\n[ref]: url2";
420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
421 let result = rule.check(&ctx).unwrap();
422
423 assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
425 assert!(result[0].message.contains("'shortcut'"));
426 }
427
428 #[test]
429 fn test_collapsed_reference() {
430 let rule = MD054LinkImageStyle::new(false, true, false, false, false, false);
431 let content = "[collapsed][] [bad][ref]\n\n[collapsed]: url\n[ref]: url2";
432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
433 let result = rule.check(&ctx).unwrap();
434
435 assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
436 assert!(result[0].message.contains("'full'"));
437 }
438
439 #[test]
440 fn test_code_blocks_ignored() {
441 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
442 let content = "```\n[ignored](url) <https://ignored.com>\n```\n\n[checked](url)";
443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
444 let result = rule.check(&ctx).unwrap();
445
446 assert_eq!(result.len(), 0);
448 }
449
450 #[test]
451 fn test_code_spans_ignored() {
452 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
453 let content = "`[ignored](url)` and `<https://ignored.com>` but [checked](url)";
454 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
455 let result = rule.check(&ctx).unwrap();
456
457 assert_eq!(result.len(), 0);
459 }
460
461 #[test]
462 fn test_reference_definitions_ignored() {
463 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
464 let content = "[ref]: https://example.com\n[ref2]: <https://example2.com>";
465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
466 let result = rule.check(&ctx).unwrap();
467
468 assert_eq!(result.len(), 0);
470 }
471
472 #[test]
473 fn test_html_comments_ignored() {
474 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
475 let content = "<!-- [ignored](url) -->\n <!-- <https://ignored.com> -->";
476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
477 let result = rule.check(&ctx).unwrap();
478
479 assert_eq!(result.len(), 0);
480 }
481
482 #[test]
483 fn test_unicode_support() {
484 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
485 let content = "[cafe](https://cafe.com) [emoji](url) [korean](url) [hebrew](url)";
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);
491 }
492
493 #[test]
494 fn test_line_positions() {
495 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
496 let content = "Line 1\n\nLine 3 with <https://bad.com> here";
497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
498 let result = rule.check(&ctx).unwrap();
499
500 assert_eq!(result.len(), 1);
501 assert_eq!(result[0].line, 3);
502 assert_eq!(result[0].column, 13); }
504
505 #[test]
506 fn test_multiple_links_same_line() {
507 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
508 let content = "[ok](url) but <https://good.com> and [also][bad]\n\n[bad]: url";
509 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
510 let result = rule.check(&ctx).unwrap();
511
512 assert_eq!(result.len(), 2, "Expected 2 warnings, got: {result:?}");
513 assert!(result[0].message.contains("'autolink'"));
514 assert!(result[1].message.contains("'full'"));
515 }
516
517 #[test]
518 fn test_empty_content() {
519 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
520 let content = "";
521 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
522 let result = rule.check(&ctx).unwrap();
523
524 assert_eq!(result.len(), 0);
525 }
526
527 #[test]
528 fn test_no_links() {
529 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
530 let content = "Just plain text without any links";
531 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532 let result = rule.check(&ctx).unwrap();
533
534 assert_eq!(result.len(), 0);
535 }
536
537 #[test]
538 fn test_fix_unreachable_target_is_noop() {
539 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
543 let content = "[link](url)";
544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
545 let fixed = rule.fix(&ctx).unwrap();
546 assert_eq!(fixed, content);
547 }
548
549 #[test]
550 fn test_priority_order() {
551 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
552 let content = "[text][ref] not detected as [shortcut]\n\n[ref]: url\n[shortcut]: url2";
554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
555 let result = rule.check(&ctx).unwrap();
556
557 assert_eq!(result.len(), 2, "Expected 2 warnings, got: {result:?}");
558 assert!(result[0].message.contains("'full'"));
559 assert!(result[1].message.contains("'shortcut'"));
560 }
561
562 #[test]
563 fn test_not_shortcut_when_followed_by_bracket() {
564 let rule = MD054LinkImageStyle::new(false, false, false, true, true, false);
565 let content = "[text][ more text\n[text](url) is inline";
567 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
568 let result = rule.check(&ctx).unwrap();
569
570 assert_eq!(result.len(), 0);
572 }
573
574 #[test]
575 fn test_cjk_correct_column_positions() {
576 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
577 let content = "日本語テスト <https://example.com>";
578 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
579 let result = rule.check(&ctx).unwrap();
580
581 assert_eq!(result.len(), 1);
582 assert!(result[0].message.contains("'autolink'"));
583 assert_eq!(
586 result[0].column, 8,
587 "Column should be 1-indexed character position of '<'"
588 );
589 }
590
591 #[test]
592 fn test_code_span_detection_with_cjk_prefix() {
593 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
594 let content = "日本語 `[link](url)` text";
596 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
597 let result = rule.check(&ctx).unwrap();
598
599 assert_eq!(result.len(), 0, "Link inside code span should not be flagged");
601 }
602
603 #[test]
604 fn test_complex_unicode_with_zwj() {
605 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
606 let content = "[family](url) [cafe](https://cafe.com)";
607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608 let result = rule.check(&ctx).unwrap();
609
610 assert_eq!(result.len(), 0);
612 }
613
614 #[test]
615 fn test_gfm_alert_not_flagged_as_shortcut() {
616 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
617 let content = "> [!NOTE]\n> This is a 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 "GFM alert should not be flagged as shortcut link, got: {result:?}"
623 );
624 }
625
626 #[test]
627 fn test_various_alert_types_not_flagged() {
628 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
629 for alert_type in ["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION", "note", "info"] {
630 let content = format!("> [!{alert_type}]\n> Content.\n");
631 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
632 let result = rule.check(&ctx).unwrap();
633 assert!(
634 result.is_empty(),
635 "Alert type {alert_type} should not be flagged, got: {result:?}"
636 );
637 }
638 }
639
640 #[test]
641 fn test_shortcut_link_still_flagged_when_disallowed() {
642 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
643 let content = "See [reference] for details.\n\n[reference]: https://example.com\n";
644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
645 let result = rule.check(&ctx).unwrap();
646 assert!(!result.is_empty(), "Regular shortcut links should still be flagged");
647 }
648
649 #[test]
650 fn test_alert_with_frontmatter_not_flagged() {
651 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
652 let content = "---\ntitle: heading\n---\n\n> [!note]\n> Content for the note.\n";
653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
654 let result = rule.check(&ctx).unwrap();
655 assert!(
656 result.is_empty(),
657 "Alert in blockquote with frontmatter should not be flagged, got: {result:?}"
658 );
659 }
660
661 #[test]
662 fn test_alert_without_blockquote_prefix_not_flagged() {
663 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
666 let content = "[!NOTE]\nSome content\n";
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 "[!NOTE] without blockquote prefix should not be flagged, got: {result:?}"
672 );
673 }
674
675 #[test]
676 fn test_alert_custom_types_not_flagged() {
677 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
679 for alert_type in ["bug", "example", "quote", "abstract", "todo", "faq"] {
680 let content = format!("> [!{alert_type}]\n> Content.\n");
681 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
682 let result = rule.check(&ctx).unwrap();
683 assert!(
684 result.is_empty(),
685 "Custom alert type {alert_type} should not be flagged, got: {result:?}"
686 );
687 }
688 }
689
690 #[test]
693 fn test_code_span_with_brackets_in_inline_link() {
694 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
695 let content = "Link to [`[myArray]`](#info).";
696 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
697 let result = rule.check(&ctx).unwrap();
698 assert!(
700 result.is_empty(),
701 "Code span with brackets in inline link should not be flagged, got: {result:?}"
702 );
703 }
704
705 #[test]
706 fn test_code_span_with_array_index_in_inline_link() {
707 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
708 let content = "See [`item[0]`](#info) for details.";
709 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
710 let result = rule.check(&ctx).unwrap();
711 assert!(
712 result.is_empty(),
713 "Array index in code span should not be flagged, got: {result:?}"
714 );
715 }
716
717 #[test]
718 fn test_code_span_with_hash_brackets_in_inline_link() {
719 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
720 let content = r#"See [`hash["key"]`](#info) for details."#;
721 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
722 let result = rule.check(&ctx).unwrap();
723 assert!(
724 result.is_empty(),
725 "Hash access in code span should not be flagged, got: {result:?}"
726 );
727 }
728
729 #[test]
730 fn test_issue_488_full_reproduction() {
731 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
733 let content = "---\ntitle: heading\n---\n\nLink to information about [`[myArray]`](#information-on-myarray).\n\n## Information on `[myArray]`\n\nSome section content.\n";
734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
735 let result = rule.check(&ctx).unwrap();
736 assert!(
737 result.is_empty(),
738 "Issue #488 reproduction case should produce no warnings, got: {result:?}"
739 );
740 }
741
742 #[test]
743 fn test_bracket_text_without_definition_not_flagged() {
744 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
747 let content = "Some [noref] text without a definition.";
748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749 let result = rule.check(&ctx).unwrap();
750 assert!(
751 result.is_empty(),
752 "Bracket text without definition should not be flagged as a link, got: {result:?}"
753 );
754 }
755
756 #[test]
757 fn test_array_index_notation_not_flagged() {
758 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
760 let content = "Access `arr[0]` and use [1] or [optional] in your code.";
761 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
762 let result = rule.check(&ctx).unwrap();
763 assert!(
764 result.is_empty(),
765 "Array indices and bracket text should not be flagged, got: {result:?}"
766 );
767 }
768
769 #[test]
770 fn test_real_shortcut_reference_still_flagged() {
771 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
773 let content = "See [example] for details.\n\n[example]: https://example.com\n";
774 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
775 let result = rule.check(&ctx).unwrap();
776 assert_eq!(
777 result.len(),
778 1,
779 "Real shortcut reference with definition should be flagged, got: {result:?}"
780 );
781 assert!(result[0].message.contains("'shortcut'"));
782 }
783
784 #[test]
785 fn test_footnote_syntax_not_flagged_as_shortcut() {
786 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
788 let content = "See [^1] for details.";
789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790 let result = rule.check(&ctx).unwrap();
791 assert!(
792 result.is_empty(),
793 "Footnote syntax should not be flagged as shortcut, got: {result:?}"
794 );
795 }
796
797 #[test]
798 fn test_inline_link_with_code_span_detected_as_inline() {
799 let rule = MD054LinkImageStyle::new(true, true, true, false, true, true);
801 let content = "See [`[myArray]`](#info) for details.";
802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
803 let result = rule.check(&ctx).unwrap();
804 assert_eq!(
805 result.len(),
806 1,
807 "Inline link with code span should be flagged when inline is disallowed"
808 );
809 assert!(
810 result[0].message.contains("'inline'"),
811 "Should be flagged as 'inline' style, got: {}",
812 result[0].message
813 );
814 }
815
816 #[test]
817 fn test_autolink_only_document_not_skipped() {
818 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
820 let content = "Visit <https://example.com> for more info.";
821 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
822 assert!(
823 !rule.should_skip(&ctx),
824 "should_skip must return false for autolink-only documents"
825 );
826 let result = rule.check(&ctx).unwrap();
827 assert_eq!(result.len(), 1, "Autolink should be flagged when disallowed");
828 assert!(result[0].message.contains("'autolink'"));
829 }
830
831 #[test]
832 fn test_nested_image_in_link() {
833 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
835 let content = "[](https://example.com)";
836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837 let result = rule.check(&ctx).unwrap();
838 assert!(
840 result.len() >= 2,
841 "Nested image-in-link should detect both elements, got: {result:?}"
842 );
843 }
844
845 #[test]
846 fn test_multi_line_link() {
847 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
848 let content = "[long link\ntext](url)";
849 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
850 let result = rule.check(&ctx).unwrap();
851 assert_eq!(result.len(), 1, "Multi-line inline link should be detected");
852 assert!(result[0].message.contains("'inline'"));
853 }
854
855 #[test]
856 fn test_link_with_title() {
857 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
858 let content = r#"[text](url "title")"#;
859 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
860 let result = rule.check(&ctx).unwrap();
861 assert_eq!(result.len(), 1, "Link with title should be detected as inline");
862 assert!(result[0].message.contains("'inline'"));
863 }
864
865 #[test]
866 fn test_empty_link_text() {
867 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
868 let content = "[](url)";
869 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
870 let result = rule.check(&ctx).unwrap();
871 assert_eq!(result.len(), 1, "Empty link text should be detected");
872 assert!(result[0].message.contains("'inline'"));
873 }
874
875 #[test]
876 fn test_escaped_brackets_not_detected() {
877 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
878 let content = r"\[not a link\] and also \[not this either\]";
879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
880 let result = rule.check(&ctx).unwrap();
881 assert!(
882 result.is_empty(),
883 "Escaped brackets should not be flagged, got: {result:?}"
884 );
885 }
886
887 #[test]
888 fn test_links_in_blockquotes() {
889 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
890 let content = "> [link](url) in a blockquote";
891 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
892 let result = rule.check(&ctx).unwrap();
893 assert_eq!(result.len(), 1, "Links in blockquotes should be detected");
894 assert!(result[0].message.contains("'inline'"));
895 }
896
897 #[test]
898 fn test_image_detection() {
899 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
900 let content = "";
901 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
902 let result = rule.check(&ctx).unwrap();
903 assert_eq!(result.len(), 1, "Inline image should be detected");
904 assert!(result[0].message.contains("'inline'"));
905 }
906}
907
908#[cfg(test)]
909mod fix_tests {
910 use super::*;
911 use crate::config::MarkdownFlavor;
912 use crate::lint_context::LintContext;
913 use md054_config::PreferredStyle;
914 use pulldown_cmark::LinkType;
915
916 fn canonical_link_url(link_type: LinkType, url: &str) -> String {
922 match link_type {
923 LinkType::Email => format!("mailto:{url}"),
924 _ => url.to_string(),
925 }
926 }
927
928 fn rule_inline_disallowed() -> MD054LinkImageStyle {
931 MD054LinkImageStyle::new(true, true, true, false, true, true)
933 }
934
935 fn rule_only_inline() -> MD054LinkImageStyle {
937 MD054LinkImageStyle::new(false, false, false, true, false, false)
938 }
939
940 fn assert_round_trip_clean(rule: &MD054LinkImageStyle, content: &str) -> String {
947 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
948 let before_link_urls: Vec<String> = ctx
949 .links()
950 .iter()
951 .map(|l| canonical_link_url(l.link_type, &l.url))
952 .filter(|u| !u.is_empty())
953 .collect();
954 let before_image_urls: Vec<String> = ctx
955 .images()
956 .iter()
957 .map(|i| canonical_link_url(i.link_type, &i.url))
958 .filter(|u| !u.is_empty())
959 .collect();
960
961 let fixed = rule.fix(&ctx).unwrap();
962
963 let ctx2 = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
964 let warnings = rule.check(&ctx2).unwrap();
965 assert!(
966 warnings.is_empty(),
967 "fix() left disallowed-style warnings: {warnings:?} in:\n{fixed}"
968 );
969
970 let mut after_link_urls: Vec<String> = ctx2
971 .links()
972 .iter()
973 .map(|l| canonical_link_url(l.link_type, &l.url))
974 .filter(|u| !u.is_empty())
975 .collect();
976 let mut after_image_urls: Vec<String> = ctx2
977 .images()
978 .iter()
979 .map(|i| canonical_link_url(i.link_type, &i.url))
980 .filter(|u| !u.is_empty())
981 .collect();
982 let mut before_link_urls_sorted = before_link_urls;
983 let mut before_image_urls_sorted = before_image_urls;
984 before_link_urls_sorted.sort();
985 before_image_urls_sorted.sort();
986 after_link_urls.sort();
987 after_image_urls.sort();
988 assert_eq!(
989 before_link_urls_sorted, after_link_urls,
990 "fix() changed the set of link URLs.\nbefore: {before_link_urls_sorted:?}\nafter: {after_link_urls:?}\nfixed:\n{fixed}"
991 );
992 assert_eq!(
993 before_image_urls_sorted, after_image_urls,
994 "fix() changed the set of image URLs.\nbefore: {before_image_urls_sorted:?}\nafter: {after_image_urls:?}\nfixed:\n{fixed}"
995 );
996
997 let fixed2 = rule.fix(&ctx2).unwrap();
998 assert_eq!(fixed, fixed2, "fix() is not idempotent");
999 fixed
1000 }
1001
1002 #[test]
1007 fn fix_inline_to_full_single_link() {
1008 let rule = rule_inline_disallowed();
1009 let content = "See the [documentation](https://example.com/docs) for details.\n";
1010 let fixed = assert_round_trip_clean(&rule, content);
1011 assert_eq!(
1012 fixed,
1013 "See the [documentation][documentation] for details.\n\n\
1014 [documentation]: https://example.com/docs\n"
1015 );
1016 }
1017
1018 #[test]
1019 fn fix_inline_to_full_multiple_links_dedup_by_url() {
1020 let rule = rule_inline_disallowed();
1021 let content = "First [docs](https://example.com/x).\nAgain [docs](https://example.com/x).\n";
1022 let fixed = assert_round_trip_clean(&rule, content);
1023 assert_eq!(
1025 fixed,
1026 "First [docs][docs].\nAgain [docs][docs].\n\n\
1027 [docs]: https://example.com/x\n"
1028 );
1029 }
1030
1031 #[test]
1032 fn fix_inline_to_full_same_url_different_titles_keeps_both_titles() {
1033 let rule = rule_inline_disallowed();
1037 let content = "First [a](https://example.com \"Title A\").\nLater [b](https://example.com \"Title B\").\n";
1038 let fixed = assert_round_trip_clean(&rule, content);
1039 assert!(fixed.contains(r#""Title A""#), "Title A lost in conversion: {fixed}");
1041 assert!(fixed.contains(r#""Title B""#), "Title B lost in conversion: {fixed}");
1042 let def_count = fixed.matches("]: https://example.com").count();
1044 assert_eq!(def_count, 2, "expected two ref defs (one per title), got:\n{fixed}");
1045 }
1046
1047 #[test]
1048 fn fix_inline_to_full_collision_disambiguates_with_suffix() {
1049 let rule = rule_inline_disallowed();
1050 let content = "[docs](https://a.com) and [docs](https://b.com).\n";
1051 let fixed = assert_round_trip_clean(&rule, content);
1052 assert!(fixed.contains("[docs][docs]"));
1054 assert!(fixed.contains("[docs][docs-2]"));
1055 assert!(fixed.contains("[docs]: https://a.com"));
1056 assert!(fixed.contains("[docs-2]: https://b.com"));
1057 }
1058
1059 #[test]
1060 fn fix_inline_to_full_preserves_title() {
1061 let rule = rule_inline_disallowed();
1062 let content = "See [link](https://example.com \"My Title\").\n";
1063 let fixed = assert_round_trip_clean(&rule, content);
1064 assert!(fixed.contains("[link][link]"));
1065 assert!(fixed.contains(r#"[link]: https://example.com "My Title""#));
1066 }
1067
1068 #[test]
1069 fn fix_inline_to_full_title_with_double_quotes_uses_single_quotes() {
1070 let rule = rule_inline_disallowed();
1071 let content = "See [link](https://example.com 'has \"double\" quotes').\n";
1072 let fixed = assert_round_trip_clean(&rule, content);
1073 assert!(
1075 fixed.contains(r#"[link]: https://example.com 'has "double" quotes'"#),
1076 "got:\n{fixed}"
1077 );
1078 }
1079
1080 #[test]
1081 fn fix_inline_to_full_title_with_escaped_quote_unescapes_through_parser() {
1082 let rule = rule_inline_disallowed();
1089 let content = "See [link](https://example.com \"has \\\"escaped\\\" quotes\").\n";
1090 let fixed = assert_round_trip_clean(&rule, content);
1091 assert!(
1093 fixed.contains(r#"[link]: https://example.com 'has "escaped" quotes'"#),
1094 "expected unescaped title with single-quote delimiter, got:\n{fixed}"
1095 );
1096 assert!(
1098 !fixed.contains(r#"\""#),
1099 "title should be unescaped, not pass through literal `\\\"`:\n{fixed}"
1100 );
1101 }
1102
1103 #[test]
1104 fn fix_inline_to_full_image() {
1105 let rule = rule_inline_disallowed();
1106 let content = "Logo: .\n";
1107 let fixed = assert_round_trip_clean(&rule, content);
1108 assert!(fixed.contains("![Company logo][company-logo]"));
1109 assert!(fixed.contains("[company-logo]: https://example.com/logo.png"));
1110 }
1111
1112 #[test]
1113 fn fix_inline_to_full_unicode_text() {
1114 let rule = rule_inline_disallowed();
1115 let content = "Voir [café résumé](https://cafe.example.com).\n";
1116 let fixed = assert_round_trip_clean(&rule, content);
1117 assert!(fixed.contains("[café résumé][café-résumé]"));
1119 assert!(fixed.contains("[café-résumé]: https://cafe.example.com"));
1120 }
1121
1122 #[test]
1123 fn fix_inline_to_full_reuses_existing_ref_def_for_same_url() {
1124 let rule = rule_inline_disallowed();
1125 let content = "Old: [other][site]\n\
1126 New: [docs](https://example.com)\n\
1127 \n\
1128 [site]: https://example.com\n";
1129 let fixed = assert_round_trip_clean(&rule, content);
1130 assert!(
1132 fixed.contains("[docs][site]"),
1133 "expected reuse of existing label, got:\n{fixed}"
1134 );
1135 assert_eq!(fixed.matches("https://example.com").count(), 1);
1137 }
1138
1139 #[test]
1140 fn fix_inline_to_full_avoids_existing_label_collision() {
1141 let rule = rule_inline_disallowed();
1142 let content = "Old: [a][docs]\n\
1143 New: [docs](https://other.com)\n\
1144 \n\
1145 [docs]: https://existing.com\n";
1146 let fixed = assert_round_trip_clean(&rule, content);
1147 assert!(fixed.contains("[docs][docs-2]"));
1149 assert!(fixed.contains("[docs-2]: https://other.com"));
1150 assert!(fixed.contains("[docs]: https://existing.com"));
1152 }
1153
1154 #[test]
1155 fn fix_inline_to_full_no_trailing_newline() {
1156 let rule = rule_inline_disallowed();
1157 let content = "[docs](https://example.com)";
1158 let fixed = assert_round_trip_clean(&rule, content);
1159 assert_eq!(fixed, "[docs][docs]\n\n[docs]: https://example.com\n");
1160 }
1161
1162 #[test]
1163 fn fix_inline_to_full_skips_code_blocks() {
1164 let rule = rule_inline_disallowed();
1165 let content = "Outside [a](https://x.com).\n\n```\n[fenced](https://y.com)\n```\n";
1166 let fixed = assert_round_trip_clean(&rule, content);
1167 assert!(fixed.contains("[fenced](https://y.com)"));
1169 assert!(fixed.contains("[a][a]"));
1171 }
1172
1173 #[test]
1174 fn fix_inline_to_full_skips_frontmatter() {
1175 let rule = rule_inline_disallowed();
1176 let content = "---\nlink: [foo](https://x.com)\n---\n\n[doc](https://y.com)\n";
1177 let fixed = assert_round_trip_clean(&rule, content);
1178 assert!(fixed.contains("link: [foo](https://x.com)"));
1180 assert!(fixed.contains("[doc][doc]"));
1182 }
1183
1184 #[test]
1189 fn fix_full_to_inline() {
1190 let rule = rule_only_inline();
1191 let content = "See [docs][site].\n\n[site]: https://example.com\n";
1192 let fixed = assert_round_trip_clean(&rule, content);
1193 assert!(fixed.contains("[docs](https://example.com)"));
1195 }
1196
1197 #[test]
1198 fn fix_collapsed_to_inline() {
1199 let rule = rule_only_inline();
1200 let content = "See [docs][].\n\n[docs]: https://example.com\n";
1201 let fixed = assert_round_trip_clean(&rule, content);
1202 assert!(fixed.contains("[docs](https://example.com)"));
1203 }
1204
1205 #[test]
1206 fn fix_shortcut_to_inline() {
1207 let rule = rule_only_inline();
1208 let content = "See [docs].\n\n[docs]: https://example.com\n";
1209 let fixed = assert_round_trip_clean(&rule, content);
1210 assert!(fixed.contains("[docs](https://example.com)"));
1211 }
1212
1213 #[test]
1214 fn fix_full_to_inline_preserves_title() {
1215 let rule = rule_only_inline();
1216 let content = "See [docs][site].\n\n[site]: https://example.com \"Site Title\"\n";
1217 let fixed = assert_round_trip_clean(&rule, content);
1218 assert!(
1219 fixed.contains(r#"[docs](https://example.com "Site Title")"#),
1220 "title not preserved, got:\n{fixed}"
1221 );
1222 }
1223
1224 #[test]
1225 fn fix_inline_to_full_text_with_code_span_containing_brackets() {
1226 let rule = rule_inline_disallowed();
1230 let content = "See [`a[0]` index](https://example.com).\n";
1231 let fixed = assert_round_trip_clean(&rule, content);
1232 assert!(
1233 fixed.contains("[`a[0]` index]["),
1234 "code-span text not preserved, got:\n{fixed}"
1235 );
1236 assert!(
1237 fixed.contains("]: https://example.com"),
1238 "missing emitted ref def, got:\n{fixed}"
1239 );
1240 }
1241
1242 #[test]
1243 fn fix_full_to_inline_image() {
1244 let rule = rule_only_inline();
1245 let content = "Logo: ![alt][logo].\n\n[logo]: https://x.com/img.png\n";
1246 let fixed = assert_round_trip_clean(&rule, content);
1247 assert!(fixed.contains(""));
1248 }
1249
1250 #[test]
1255 fn fix_collapsed_to_full() {
1256 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
1258 let content = "[docs][].\n\n[docs]: https://example.com\n";
1259 let fixed = assert_round_trip_clean(&rule, content);
1260 assert_eq!(fixed, "[docs][docs].\n\n[docs]: https://example.com\n");
1261 }
1262
1263 #[test]
1264 fn fix_collapsed_to_full_with_trailing_content() {
1265 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
1270 let content = "See [docs][] for details.\n\n[docs]: https://example.com\n";
1271 let fixed = assert_round_trip_clean(&rule, content);
1272 assert_eq!(fixed, "See [docs][docs] for details.\n\n[docs]: https://example.com\n");
1273 }
1274
1275 #[test]
1276 fn fix_shortcut_to_full() {
1277 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
1278 let content = "See [docs].\n\n[docs]: https://example.com\n";
1279 let fixed = assert_round_trip_clean(&rule, content);
1280 assert!(fixed.contains("See [docs][docs]"));
1281 }
1282
1283 #[test]
1284 fn fix_shortcut_to_collapsed() {
1285 let rule = MD054LinkImageStyle::new(false, true, false, false, false, false);
1286 let content = "See [docs].\n\n[docs]: https://example.com\n";
1287 let fixed = assert_round_trip_clean(&rule, content);
1288 assert!(fixed.contains("See [docs][]"));
1289 }
1290
1291 #[test]
1296 fn fix_autolink_to_inline_form() {
1297 let rule = MD054LinkImageStyle::new(false, true, true, true, true, true);
1302 let content = "Visit <https://example.com> today.\n";
1303 let fixed = assert_round_trip_clean(&rule, content);
1304 assert!(
1305 fixed.contains("[https://example.com](https://example.com)"),
1306 "got: {fixed:?}"
1307 );
1308 }
1309
1310 #[test]
1311 fn fix_autolink_to_full_when_inline_styles_disallowed() {
1312 let rule = MD054LinkImageStyle::new(false, true, true, false, true, false);
1316 let content = "Visit <https://example.com> today.\n";
1317 let fixed = assert_round_trip_clean(&rule, content);
1318 assert!(
1319 fixed.contains("[https://example.com][https-example-com]"),
1320 "got: {fixed:?}"
1321 );
1322 assert!(fixed.contains("[https-example-com]: https://example.com"));
1323 }
1324
1325 #[test]
1326 fn fix_url_inline_to_autolink() {
1327 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
1329 let content = "Visit [https://example.com](https://example.com).\n";
1330 let fixed = assert_round_trip_clean(&rule, content);
1331 assert!(fixed.contains("<https://example.com>"));
1332 }
1333
1334 #[test]
1339 fn fix_no_op_when_target_unreachable() {
1340 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
1343 let content = "See [docs](https://example.com).\n";
1344 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1345 let fixed = rule.fix(&ctx).unwrap();
1346 assert_eq!(fixed, content);
1347 let warnings = rule.check(&ctx).unwrap();
1349 assert_eq!(warnings.len(), 1);
1350 }
1351
1352 #[test]
1353 fn fix_preserves_allowed_links() {
1354 let rule = rule_inline_disallowed();
1355 let content = "Already [ref][r] is fine.\n\n[r]: https://example.com\n";
1356 let fixed = assert_round_trip_clean(&rule, content);
1357 assert_eq!(fixed, content);
1358 }
1359
1360 #[test]
1361 fn fix_keeps_the_closing_bracket_of_a_link_wrapping_a_collapsed_image() {
1362 let rule = rule_inline_disallowed();
1367 let content = "[![alt][]](target.md)\n\n[alt]: exists.png\n";
1368 let fixed = assert_round_trip_clean(&rule, content);
1369 assert_eq!(fixed, "[![alt][]][alt-2]\n\n[alt]: exists.png\n\n[alt-2]: target.md\n");
1370 }
1371
1372 #[test]
1377 fn fix_preferred_style_explicit_full() {
1378 let config = md054_config::MD054Config {
1379 inline: false,
1380 preferred_style: PreferredStyles::single(PreferredStyle::Full),
1381 ..Default::default()
1382 };
1383 let rule = MD054LinkImageStyle::from_config_struct(config);
1384 let content = "[docs](https://example.com)\n";
1385 let fixed = assert_round_trip_clean(&rule, content);
1386 assert!(fixed.contains("[docs][docs]"));
1387 }
1388
1389 #[test]
1390 fn fix_inline_to_collapsed_emits_matching_ref_def() {
1391 let config = md054_config::MD054Config {
1395 inline: false,
1396 preferred_style: PreferredStyles::single(PreferredStyle::Collapsed),
1397 ..Default::default()
1398 };
1399 let rule = MD054LinkImageStyle::from_config_struct(config);
1400 let content = "[anchor](https://example.com)\n";
1401 let fixed = assert_round_trip_clean(&rule, content);
1402 assert!(fixed.contains("[anchor][]"), "got:\n{fixed}");
1403 assert!(fixed.contains("[anchor]: https://example.com"), "got:\n{fixed}");
1404 }
1405
1406 #[test]
1407 fn fix_inline_to_shortcut_emits_matching_ref_def() {
1408 let config = md054_config::MD054Config {
1409 inline: false,
1410 preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1411 ..Default::default()
1412 };
1413 let rule = MD054LinkImageStyle::from_config_struct(config);
1414 let content = "See [anchor](https://example.com).\n";
1416 let fixed = assert_round_trip_clean(&rule, content);
1417 assert!(fixed.contains("[anchor]"), "got:\n{fixed}");
1418 assert!(!fixed.contains("[anchor]("), "shortcut form, not inline: {fixed}");
1419 assert!(fixed.contains("[anchor]: https://example.com"), "got:\n{fixed}");
1420 }
1421
1422 #[test]
1423 fn fix_inline_to_collapsed_skips_empty_text() {
1424 let config = md054_config::MD054Config {
1428 inline: false,
1429 preferred_style: PreferredStyles::single(PreferredStyle::Collapsed),
1430 ..Default::default()
1431 };
1432 let rule = MD054LinkImageStyle::from_config_struct(config);
1433 let content = "[](https://example.com)\n";
1434 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1435 let fixed = rule.fix(&ctx).unwrap();
1436 assert_eq!(fixed, content, "empty text must not collapse: {fixed}");
1437 }
1438
1439 #[test]
1440 fn fix_inline_to_shortcut_skips_empty_text() {
1441 let config = md054_config::MD054Config {
1442 inline: false,
1443 preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1444 ..Default::default()
1445 };
1446 let rule = MD054LinkImageStyle::from_config_struct(config);
1447 let content = "See [](https://example.com).\n";
1448 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1449 let fixed = rule.fix(&ctx).unwrap();
1450 assert_eq!(fixed, content);
1451 }
1452
1453 #[test]
1454 fn fix_inline_to_collapsed_skips_text_with_brackets() {
1455 let config = md054_config::MD054Config {
1458 inline: false,
1459 preferred_style: PreferredStyles::single(PreferredStyle::Collapsed),
1460 ..Default::default()
1461 };
1462 let rule = MD054LinkImageStyle::from_config_struct(config);
1463 let content = "See [`a[0]` index](https://example.com).\n";
1464 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1465 let fixed = rule.fix(&ctx).unwrap();
1466 assert_eq!(fixed, content, "text containing `[` / `]` must not collapse: {fixed}");
1467 }
1468
1469 #[test]
1470 fn fix_inline_to_full_url_with_space_uses_angle_brackets_in_def() {
1471 let rule = rule_inline_disallowed();
1476 let content = "See [docs](<./has space.md>).\n";
1477 let fixed = assert_round_trip_clean(&rule, content);
1478 assert!(
1479 fixed.contains("[docs]: <./has space.md>"),
1480 "ref def must wrap URL in angle brackets: {fixed}"
1481 );
1482 }
1483
1484 #[test]
1485 fn fix_inline_to_full_url_with_unbalanced_paren_uses_angle_brackets_in_def() {
1486 let rule = rule_inline_disallowed();
1489 let content = "See [docs](<https://example.com/a)b>).\n";
1490 let fixed = assert_round_trip_clean(&rule, content);
1491 assert!(
1492 fixed.contains("[docs]: <https://example.com/a)b>"),
1493 "ref def must wrap unbalanced-paren URL in angle brackets: {fixed}"
1494 );
1495 }
1496
1497 #[test]
1498 fn fix_full_to_inline_preserves_backslash_unescaped_title() {
1499 let rule = rule_only_inline();
1503 let content = "See [docs][d].\n\n[d]: https://example.com \"He said \\\"hi\\\"\"\n";
1504 let fixed = assert_round_trip_clean(&rule, content);
1505 assert!(fixed.contains("https://example.com"), "URL must round-trip: {fixed}");
1510 assert!(
1511 fixed.contains(r#"\"hi\""#) || fixed.contains(r#"He said "hi""#),
1512 "title must round-trip with quotes preserved: {fixed}"
1513 );
1514 }
1515
1516 #[test]
1517 fn fix_full_to_inline_url_with_close_paren_uses_angle_brackets() {
1518 let rule = rule_only_inline();
1522 let content = "See [t][r].\n\n[r]: <https://example.com/a)b>\n";
1523 let fixed = assert_round_trip_clean(&rule, content);
1524 assert!(
1525 fixed.contains("[t](<https://example.com/a)b>)"),
1526 "inline form must use angle brackets for `)` URLs: {fixed}"
1527 );
1528 }
1529
1530 #[test]
1531 fn fix_inline_to_collapsed_skips_when_label_collides_with_different_url() {
1532 let config = md054_config::MD054Config {
1537 inline: false,
1538 preferred_style: PreferredStyles::single(PreferredStyle::Collapsed),
1539 ..Default::default()
1540 };
1541 let rule = MD054LinkImageStyle::from_config_struct(config);
1542 let content = "[other][anchor]\n[anchor](https://other.com)\n\n[anchor]: https://existing.com\n";
1543 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1544 let fixed = rule.fix(&ctx).unwrap();
1545 assert!(fixed.contains("[anchor](https://other.com)"), "got:\n{fixed}");
1547 assert!(fixed.contains("[anchor]: https://existing.com"));
1549 }
1550
1551 #[test]
1552 fn fix_preferred_style_list_picks_first_reachable() {
1553 let config = md054_config::MD054Config {
1556 url_inline: false,
1557 preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Full]),
1558 ..Default::default()
1559 };
1560 let rule = MD054LinkImageStyle::from_config_struct(config);
1561 let content = "[https://example.com](https://example.com)\n";
1562 let fixed = assert_round_trip_clean(&rule, content);
1563 assert!(
1564 fixed.contains("<https://example.com>"),
1565 "expected autolink form, got:\n{fixed}"
1566 );
1567 }
1568
1569 #[test]
1570 fn fix_preferred_style_list_falls_back_to_next_when_first_unreachable() {
1571 let config = md054_config::MD054Config {
1574 inline: false,
1575 preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Full]),
1576 ..Default::default()
1577 };
1578 let rule = MD054LinkImageStyle::from_config_struct(config);
1579 let content = "[docs](./guide.md)\n";
1580 let fixed = assert_round_trip_clean(&rule, content);
1581 assert!(
1582 fixed.contains("[docs][docs]"),
1583 "expected fallback to full, got:\n{fixed}"
1584 );
1585 assert!(
1586 fixed.contains("[docs]: ./guide.md"),
1587 "expected matching ref def, got:\n{fixed}"
1588 );
1589 }
1590
1591 #[test]
1592 fn fix_preferred_style_auto_in_list_acts_as_wildcard_fallback() {
1593 let config = md054_config::MD054Config {
1597 inline: false,
1598 preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Auto]),
1599 ..Default::default()
1600 };
1601 let rule = MD054LinkImageStyle::from_config_struct(config);
1602 let content = "[docs](./guide.md)\n";
1603 let fixed = assert_round_trip_clean(&rule, content);
1604 assert!(
1605 fixed.contains("[docs][docs]"),
1606 "Auto fallback should pick full for inline-disallowed config, got:\n{fixed}"
1607 );
1608 }
1609
1610 #[test]
1611 fn fix_default_auto_prefers_autolink_for_url_inline_source() {
1612 let rule = MD054LinkImageStyle::new(true, true, true, true, true, false);
1616 let content = "[https://example.com](https://example.com)\n";
1617 let fixed = assert_round_trip_clean(&rule, content);
1618 assert!(
1619 fixed.contains("<https://example.com>"),
1620 "expected autolink, got:\n{fixed}"
1621 );
1622 assert!(
1623 !fixed.contains("[https://example.com]["),
1624 "should not produce reference form when autolink is reachable, got:\n{fixed}"
1625 );
1626 }
1627
1628 #[test]
1629 fn fix_default_auto_falls_back_when_autolink_disallowed() {
1630 let rule = MD054LinkImageStyle::new(false, true, true, true, true, false);
1633 let content = "[https://example.com](https://example.com)\n";
1634 let fixed = assert_round_trip_clean(&rule, content);
1635 assert!(
1636 fixed.contains("[https://example.com][https-example-com]"),
1637 "expected full form, got:\n{fixed}"
1638 );
1639 assert!(
1640 fixed.contains("[https-example-com]: https://example.com"),
1641 "missing ref def, got:\n{fixed}"
1642 );
1643 }
1644
1645 #[test]
1646 fn fix_preferred_style_explicit_no_match_skips_fix() {
1647 let config = md054_config::MD054Config {
1650 inline: false,
1651 preferred_style: PreferredStyles::single(PreferredStyle::Inline),
1653 ..Default::default()
1654 };
1655 let rule = MD054LinkImageStyle::from_config_struct(config);
1656 let content = "[docs](./guide.md)\n";
1657 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1658 let fixed = rule.fix(&ctx).unwrap();
1659 assert_eq!(fixed, content, "expected no-op fix, got:\n{fixed}");
1660 }
1661
1662 #[test]
1667 fn fix_mixes_inline_and_image_in_same_doc() {
1668 let rule = rule_inline_disallowed();
1669 let content = "Text [link](https://example.com) and .\n";
1670 let fixed = assert_round_trip_clean(&rule, content);
1671 assert!(fixed.contains("[link][link]"));
1672 assert!(fixed.contains("![pic][pic]"));
1673 assert!(fixed.contains("[link]: https://example.com"));
1674 assert!(fixed.contains("[pic]: https://example.com/p.png"));
1675 }
1676
1677 #[test]
1678 fn fix_appends_one_blank_line_separator() {
1679 let rule = rule_inline_disallowed();
1680 let content = "Plain prose.\n\n[link](https://x.com)\n";
1681 let fixed = assert_round_trip_clean(&rule, content);
1682 assert!(fixed.ends_with("\n[link]: https://x.com\n"));
1684 assert!(!fixed.contains("\n\n\n[link]"));
1685 }
1686
1687 #[test]
1692 fn fix_nested_image_in_link_does_not_panic_or_corrupt() {
1693 let rule = rule_inline_disallowed();
1700 let content = "See [](https://x.com).\n";
1701 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1702 let fixed = rule.fix(&ctx).unwrap();
1704 assert_eq!(fixed, content);
1707 let warnings = rule.check(&ctx).unwrap();
1708 assert_eq!(warnings.len(), 2, "both nested constructs should still warn");
1709 }
1710
1711 #[test]
1716 fn fix_email_autolink_to_inline_preserves_mailto_prefix() {
1717 let rule = MD054LinkImageStyle::new(false, true, true, true, true, true);
1723 let content = "Reach <me@example.com> for support.\n";
1724 let fixed = assert_round_trip_clean(&rule, content);
1725 assert!(
1726 fixed.contains("[me@example.com](mailto:me@example.com)"),
1727 "expected mailto: prefix on resolved destination, got:\n{fixed}"
1728 );
1729 }
1730
1731 #[test]
1732 fn fix_email_autolink_to_full_preserves_mailto_in_ref_def() {
1733 let rule = MD054LinkImageStyle::new(false, true, true, false, true, false);
1737 let content = "Reach <me@example.com> for support.\n";
1738 let fixed = assert_round_trip_clean(&rule, content);
1739 assert!(
1740 fixed.contains("]: mailto:me@example.com"),
1741 "ref def should carry the mailto: prefix, got:\n{fixed}"
1742 );
1743 }
1744
1745 #[test]
1746 fn fix_rejects_bare_email_as_autolink_target() {
1747 let config = md054_config::MD054Config {
1753 url_inline: false,
1754 preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Auto]),
1755 ..Default::default()
1756 };
1757 let rule = MD054LinkImageStyle::from_config_struct(config);
1758 let content = "[me@example.com](me@example.com)\n";
1759 let fixed = assert_round_trip_clean(&rule, content);
1760 assert!(
1761 !fixed.contains("<me@example.com>"),
1762 "bare-email autolink target would silently retarget to mailto:, got:\n{fixed}"
1763 );
1764 }
1765
1766 #[test]
1775 fn fix_generated_ref_def_with_both_quote_types_round_trips_to_ctx() {
1776 let rule = rule_inline_disallowed();
1782 let content = "See [docs](https://example.com/x \"and 'both' quotes\") today.\n";
1783 let fixed = assert_round_trip_clean(&rule, content);
1784 assert!(
1787 fixed.contains("(and 'both' quotes)") || fixed.contains("\"and 'both' quotes\""),
1788 "title should round-trip through some valid delimiter, got:\n{fixed}"
1789 );
1790 let ctx = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
1794 let def = ctx
1795 .reference_definitions()
1796 .iter()
1797 .find(|d| d.url == "https://example.com/x")
1798 .expect("generated ref def must round-trip through parse_reference_defs");
1799 assert_eq!(
1800 def.title.as_deref(),
1801 Some("and 'both' quotes"),
1802 "title content must survive the round-trip"
1803 );
1804 }
1805
1806 #[test]
1811 fn fix_appends_generated_refs_with_crlf_when_source_is_crlf() {
1812 let rule = rule_inline_disallowed();
1818 let content = "See [docs](https://example.com/x).\r\n";
1819 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1820 let warnings = rule.check(&ctx).expect("check must succeed");
1821 assert!(!warnings.is_empty(), "expected at least one warning");
1822 let fixed = rule.fix(&ctx).expect("fix must succeed");
1823 assert!(
1824 fixed.contains("\r\n"),
1825 "fixed output must preserve CRLF, got:\n{fixed:?}"
1826 );
1827 assert!(
1828 !fixed.lines().any(|l| l.ends_with('\r')) || !fixed.contains("\n\n"),
1829 "no line should end with stray \\r and there should be no naked LF blanks; got:\n{fixed:?}"
1830 );
1831 let bytes = fixed.as_bytes();
1834 for (i, &b) in bytes.iter().enumerate() {
1835 if b == b'\n' {
1836 assert!(
1837 i > 0 && bytes[i - 1] == b'\r',
1838 "found naked LF at byte {i} in CRLF document, full output:\n{fixed:?}"
1839 );
1840 }
1841 }
1842 }
1843
1844 #[test]
1845 fn fix_appends_generated_refs_with_lf_when_source_is_lf() {
1846 let rule = rule_inline_disallowed();
1848 let content = "See [docs](https://example.com/x).\n";
1849 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1850 let fixed = rule.fix(&ctx).expect("fix must succeed");
1851 assert!(
1852 !fixed.contains('\r'),
1853 "LF document must not gain any CR characters, got:\n{fixed:?}"
1854 );
1855 }
1856
1857 #[test]
1862 fn fix_rejects_shortcut_target_when_followed_by_paren() {
1863 let config = md054_config::MD054Config {
1869 inline: false,
1870 preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1871 ..Default::default()
1872 };
1873 let rule = MD054LinkImageStyle::from_config_struct(config);
1874 let content = "[docs](https://example.com/x)(suffix)\n";
1875 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1876 let fixed = rule.fix(&ctx).unwrap();
1877 assert_eq!(fixed, content, "shortcut target was unsafe; fix should be a no-op");
1880 }
1881
1882 #[test]
1883 fn fix_rejects_shortcut_target_when_followed_by_bracket() {
1884 let config = md054_config::MD054Config {
1888 inline: false,
1889 preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1890 ..Default::default()
1891 };
1892 let rule = MD054LinkImageStyle::from_config_struct(config);
1893 let content = "[docs](https://example.com/x)[next]\n\n[next]: https://example.com/n\n";
1894 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1895 let fixed = rule.fix(&ctx).unwrap();
1896 assert_eq!(fixed, content, "shortcut target was unsafe; fix should be a no-op");
1897 }
1898
1899 #[test]
1900 fn fix_allows_shortcut_target_when_follower_is_safe() {
1901 let config = md054_config::MD054Config {
1905 inline: false,
1906 preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1907 ..Default::default()
1908 };
1909 let rule = MD054LinkImageStyle::from_config_struct(config);
1910 let content = "See [docs](https://example.com/x). Also nice.\n";
1911 let fixed = assert_round_trip_clean(&rule, content);
1912 assert!(fixed.contains("[docs]"), "expected shortcut form, got:\n{fixed}");
1913 assert!(fixed.contains("[docs]: https://example.com/x"));
1914 }
1915
1916 #[test]
1921 fn check_attaches_fix_for_self_contained_rewrites() {
1922 let rule = MD054LinkImageStyle::new(false, true, true, true, true, true);
1928 let content = "See <https://example.com>.\n";
1929 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1930 let warnings = rule.check(&ctx).unwrap();
1931 assert_eq!(warnings.len(), 1, "should warn about the autolink");
1932 let fix = warnings[0]
1933 .fix
1934 .as_ref()
1935 .expect("self-contained rewrite must carry a Fix so quick-fix paths can apply it");
1936 assert_eq!(&content[fix.range.clone()], "<https://example.com>");
1937 assert_eq!(fix.replacement, "[https://example.com](https://example.com)");
1938 }
1939
1940 #[test]
1941 fn check_carries_atomic_fix_when_rewrite_requires_new_ref_def() {
1942 let rule = rule_inline_disallowed();
1949 let content = "See [docs](https://example.com).\n";
1950 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1951 let warnings = rule.check(&ctx).unwrap();
1952 assert_eq!(warnings.len(), 1, "should warn about the inline link");
1953 let fix = warnings[0]
1954 .fix
1955 .as_ref()
1956 .expect("ref-emitting rewrite must carry an atomic per-warning Fix");
1957 assert_eq!(&content[fix.range.clone()], "[docs](https://example.com)");
1958 assert!(
1959 fix.replacement.starts_with("[docs]"),
1960 "primary replacement should rewrite the link to a reference form, got: {:?}",
1961 fix.replacement
1962 );
1963 assert_eq!(
1964 fix.additional_edits.len(),
1965 1,
1966 "ref-emitting fix should carry one additional_edit for the ref-def"
1967 );
1968 let extra = &fix.additional_edits[0];
1969 assert_eq!(
1970 extra.range,
1971 content.len()..content.len(),
1972 "ref-def insertion should be a zero-width edit at EOF"
1973 );
1974 assert!(
1975 extra.replacement.contains("[docs]: https://example.com"),
1976 "additional_edit should append the ref-def, got: {:?}",
1977 extra.replacement
1978 );
1979 let applied = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
1983 let from_fix_all = rule.fix(&ctx).unwrap();
1984 assert!(
1985 applied.contains("[docs]: https://example.com"),
1986 "single-warning application must include ref-def, got:\n{applied}"
1987 );
1988 assert!(
1989 !applied.contains("[docs](https://example.com)"),
1990 "single-warning application must rewrite the inline link, got:\n{applied}"
1991 );
1992 assert!(
1997 from_fix_all.contains("[docs]: https://example.com"),
1998 "fix-all path must also produce the ref-def, got:\n{from_fix_all}"
1999 );
2000 }
2001
2002 #[test]
2003 fn check_attaches_no_fix_when_target_unreachable() {
2004 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
2009 let content = "See [docs](https://example.com).\n";
2010 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2011 let warnings = rule.check(&ctx).unwrap();
2012 assert_eq!(warnings.len(), 1);
2013 assert!(warnings[0].fix.is_none(), "unreachable target should leave fix empty");
2014 }
2015
2016 #[test]
2017 fn fix_skips_autolink_target_when_title_present() {
2018 let config = md054_config::MD054Config {
2023 url_inline: false,
2024 preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Auto]),
2025 ..Default::default()
2026 };
2027 let rule = MD054LinkImageStyle::from_config_struct(config);
2028 let content = "[https://example.com](https://example.com \"Homepage\")\n";
2029 let fixed = assert_round_trip_clean(&rule, content);
2030 assert!(
2031 !fixed.contains("<https://example.com>"),
2032 "autolink target would drop the title, got:\n{fixed}"
2033 );
2034 assert!(
2035 fixed.contains("\"Homepage\""),
2036 "title text must survive the conversion, got:\n{fixed}"
2037 );
2038 }
2039
2040 #[test]
2041 fn default_config_section_emits_clean_user_facing_defaults() {
2042 let rule = MD054LinkImageStyle::default();
2047 let (_, value) = rule.default_config_section().expect("md054 has defaults");
2048 let table = value.as_table().expect("config section is a table");
2049 let preferred = table
2050 .get("preferred-style")
2051 .expect("preferred-style key must be present in defaults");
2052 assert!(
2053 !crate::rule_config_serde::is_polymorphic_sentinel(preferred),
2054 "preferred-style in user-facing defaults must be the serialized scalar, not the sentinel; got {preferred:?}"
2055 );
2056 assert!(
2059 preferred.is_str(),
2060 "preferred-style default should serialize as a scalar string; got {preferred:?}"
2061 );
2062 }
2063
2064 #[test]
2065 fn registry_marks_preferred_style_polymorphic_for_validation() {
2066 let registry = crate::config::registry::default_registry();
2072 let expected = registry
2073 .expected_value_for("MD054", "preferred-style")
2074 .or_else(|| registry.expected_value_for("MD054", "preferred_style"));
2075 assert!(
2080 expected.is_none(),
2081 "preferred-style must be sentinel-marked in the schema so type checking is skipped; got {expected:?}"
2082 );
2083 let keys = registry.config_keys_for("MD054").expect("md054 must be registered");
2086 assert!(keys.contains("preferred-style"));
2087 }
2088}
2089
2090#[cfg(test)]
2091mod autolink_literal_tests {
2092 use super::*;
2093 fn rendered(source: &str) -> String {
2094 let mut output = String::new();
2095 pulldown_cmark::html::push_html(&mut output, pulldown_cmark::Parser::new(source));
2096 output
2097 }
2098 #[test]
2099 fn md054_autolink_conversions_preserve_literal_display_and_destination() {
2100 for rule in [
2101 MD054LinkImageStyle::new(false, false, false, true, false, true),
2102 MD054LinkImageStyle::new(false, false, true, false, false, false),
2103 ] {
2104 for value in [
2105 "https://example.org/a*b*c",
2106 "https://example.org/a`b`c",
2107 "https://example.org/a©b",
2108 "https://example.org/a\\*b*c",
2109 "https://example.org/a\\(b)",
2110 "https://example.org/[b]",
2111 "a*b*c@example.org",
2112 ] {
2113 for ending in ["\n", "\r\n"] {
2114 let source = format!("内容 <{value}>{ending}");
2115 let ctx =
2116 crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
2117 let fixed = rule.fix(&ctx).unwrap();
2118 assert_ne!(fixed, source, "conversion remains available: {source}");
2119 assert_eq!(rendered(&fixed), rendered(&source), "{source} -> {fixed}");
2120 let ctx =
2121 crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2122 assert!(rule.check(&ctx).unwrap().is_empty(), "{fixed}");
2123 assert_eq!(rule.fix(&ctx).unwrap(), fixed);
2124 }
2125 }
2126 }
2127 }
2128 #[test]
2129 fn md054_inline_url_style_round_trips_escaped_literal_labels() {
2130 let rule = MD054LinkImageStyle::new(false, false, false, false, false, true);
2131 let source = "<https://example.org/a*b*c>\n";
2132 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2133 let fixed = rule.fix(&ctx).unwrap();
2134 assert_eq!(rendered(&fixed), rendered(source));
2135 let ctx = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2136 assert!(rule.check(&ctx).unwrap().is_empty(), "{fixed}");
2137 assert_eq!(rule.fix(&ctx).unwrap(), fixed);
2138 }
2139 #[test]
2140 fn md054_preserves_authored_markdown_link_labels() {
2141 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2142 let source = "[*formatted* and `code`](https://example.org)\n";
2143 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2144 let fixed = rule.fix(&ctx).unwrap();
2145 assert!(fixed.starts_with("[*formatted* and `code`]["));
2146 assert_eq!(rendered(&fixed), rendered(source));
2147 }
2148}
2149
2150#[cfg(test)]
2151mod formatted_url_label_tests {
2152 use super::*;
2153 fn rendered(source: &str) -> String {
2154 let mut html = String::new();
2155 pulldown_cmark::html::push_html(
2156 &mut html,
2157 pulldown_cmark::Parser::new_ext(source, pulldown_cmark::Options::ENABLE_STRIKETHROUGH),
2158 );
2159 html
2160 }
2161 #[test]
2162 fn md054_formatted_url_labels_cannot_become_literal_autolinks() {
2163 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
2164 for url in [
2165 "https://example.org/a*b*c",
2166 "https://example.org/a`b`c",
2167 "https://example.org/a~~b~~c",
2168 "https://example.org/?q=**bold**",
2169 ] {
2170 let source = format!("内容 [{url}]({url})\n");
2171 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
2172 let warnings = rule.check(&ctx).unwrap();
2173 assert_eq!(warnings.len(), 1);
2174 assert!(warnings[0].fix.is_none(), "conversion would lose formatting: {source}");
2175 let fixed = rule.fix(&ctx).unwrap();
2176 assert_eq!(fixed, source);
2177 assert_eq!(rendered(&fixed), rendered(&source));
2178 }
2179 }
2180 #[test]
2181 fn md054_formatted_url_labels_use_inline_style() {
2182 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
2183 let source = "[https://example.org/a*b*c](https://example.org/a*b*c)\n";
2184 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2185 assert!(rule.check(&ctx).unwrap().is_empty());
2186 assert_eq!(rule.fix(&ctx).unwrap(), source);
2187 }
2188 #[test]
2189 fn md054_literal_url_labels_remain_convertible() {
2190 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
2191 for (label, url) in [
2192 ("https://example.org/plain", "https://example.org/plain"),
2193 ("https://example.org/a_b_c", "https://example.org/a_b_c"),
2194 ("https://example.org/a*b", "https://example.org/a*b"),
2195 ("https://example.org/a\\*b\\*c", "https://example.org/a*b*c"),
2196 ] {
2197 let source = format!("[{label}]({url})\n");
2198 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
2199 let fixed = rule.fix(&ctx).unwrap();
2200 assert_eq!(fixed, format!("<{url}>\n"));
2201 assert_eq!(rendered(&fixed), rendered(&source));
2202 }
2203 }
2204}
2205
2206#[cfg(test)]
2207mod template_context_tests {
2208 use super::*;
2209 #[test]
2210 fn md054_template_strings_preserve_link_and_image_syntax() {
2211 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2212 for prefix in ["", "!"] {
2213 let literal = format!("{prefix}[literal](https://literal.example.org)");
2214 for (flavor, template) in [
2215 (
2216 crate::config::MarkdownFlavor::Standard,
2217 format!("{{{{ \"{literal}\" }}}}"),
2218 ),
2219 (
2220 crate::config::MarkdownFlavor::Standard,
2221 format!("{{% set value=\"{literal}\" %}}"),
2222 ),
2223 (
2224 crate::config::MarkdownFlavor::Hugo,
2225 format!("{{{{< note title=\"{literal}\" >}}}}"),
2226 ),
2227 (
2228 crate::config::MarkdownFlavor::Hugo,
2229 format!("{{{{% note title=`{literal}` %}}}}"),
2230 ),
2231 (
2232 crate::config::MarkdownFlavor::Quarto,
2233 format!("{{{{< note title=\"{literal}\" >}}}}"),
2234 ),
2235 ] {
2236 for ending in ["\n", "\r\n"] {
2237 let source =
2238 format!("Before {template} after {prefix}[visible](https://visible.example.org){ending}");
2239 let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
2240 let warnings = rule.check(&ctx).unwrap();
2241 assert_eq!(warnings.len(), 1, "{source}");
2242 let fixed = rule.fix(&ctx).unwrap();
2243 assert!(fixed.contains(&template), "{source} -> {fixed}");
2244 assert!(!fixed.contains("[visible](https://visible.example.org)"));
2245 assert!(
2246 !fixed.contains("[literal]:"),
2247 "literal-only references must not be appended: {fixed}"
2248 );
2249 let ctx = crate::lint_context::LintContext::new(&fixed, flavor, None);
2250 assert!(rule.check(&ctx).unwrap().is_empty(), "{fixed}");
2251 assert_eq!(rule.fix(&ctx).unwrap(), fixed);
2252 }
2253 }
2254 }
2255 }
2256 #[test]
2257 fn md054_template_code_inside_link_destinations_is_preserved() {
2258 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2259 for prefix in ["", "!"] {
2260 let source = format!(
2261 "{prefix}[literal](<{{{{ \"&copy;\"|length }}}}>)\n\n[visible](https://visible.example.org)\n"
2262 );
2263 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
2264 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2265 let fixed = rule.fix(&ctx).unwrap();
2266 assert!(fixed.starts_with(source.split("\n\n").next().unwrap()), "{fixed}");
2267 assert!(!fixed.contains("[literal]:"));
2268 }
2269 }
2270 #[test]
2271 fn md054_myst_comments_do_not_contribute_style_warnings_or_fixes() {
2272 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2273 for marker in ["%", "% ", " %\t", " % "] {
2274 for prefix in ["", "!"] {
2275 let comment = format!("{marker}{prefix}[literal](https://literal.example.org)");
2276 let source = format!("{comment}\n\n{prefix}[visible](https://visible.example.org)\n");
2277 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::MyST, None);
2278 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source}");
2279 let fixed = rule.fix(&ctx).unwrap();
2280 assert!(fixed.starts_with(&comment), "{fixed}");
2281 assert!(!fixed.contains("[literal]:"));
2282 assert!(!fixed.contains("[visible](https://visible.example.org)"));
2283 }
2284 }
2285 }
2286 #[test]
2287 fn md054_disabled_rules_keep_framework_warning_filtering() {
2288 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2289 let source = "<!-- markdownlint-disable MD054 -->\n\n[text](https://example.org)\n";
2290 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2291 assert!(ctx.is_rule_disabled("MD054", 3));
2292 let warnings = rule.check(&ctx).unwrap();
2294 assert_eq!(warnings.len(), 1);
2295 assert!(warnings[0].fix.is_none());
2296 assert_eq!(rule.fix(&ctx).unwrap(), source);
2297 }
2298}
2299
2300#[cfg(test)]
2301mod template_reference_tests {
2302 use super::*;
2303
2304 #[test]
2305 fn md054_template_reference_definitions_are_not_reused() {
2306 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2307 for prefix in ["", "!"] {
2308 for ending in ["\n", "\r\n"] {
2309 for hidden_url in ["https://example.org", "https://other.example.org"] {
2310 for title in ["", " \"title\""] {
2311 let template =
2312 format!("{{% set unused=\"\n[reuse]: {hidden_url}\n\" %}}{{{{ unused|length }}}}")
2313 .replace('\n', ending);
2314 let source =
2315 format!("{template}{ending}{ending}{prefix}[reuse](https://example.org{title}){ending}");
2316 let ctx = crate::lint_context::LintContext::new(
2317 &source,
2318 crate::config::MarkdownFlavor::Standard,
2319 None,
2320 );
2321 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2322 let fixed = rule.fix(&ctx).unwrap();
2323 assert!(fixed.starts_with(&template), "{fixed}");
2324 assert!(
2325 fixed.contains(&format!("{prefix}[reuse][reuse-2]")),
2326 "{source} -> {fixed}"
2327 );
2328 assert!(
2329 fixed.contains(&format!("[reuse-2]: https://example.org{title}")),
2330 "{fixed}"
2331 );
2332 let ctx = crate::lint_context::LintContext::new(
2333 &fixed,
2334 crate::config::MarkdownFlavor::Standard,
2335 None,
2336 );
2337 assert!(rule.check(&ctx).unwrap().is_empty(), "{fixed}");
2338 assert_eq!(rule.fix(&ctx).unwrap(), fixed);
2339 }
2340 }
2341 }
2342 }
2343 }
2344
2345 #[test]
2346 fn md054_collapsed_and_shortcut_do_not_reuse_template_labels() {
2347 for rule in [
2348 MD054LinkImageStyle::new(false, true, false, false, false, false),
2349 MD054LinkImageStyle::new(false, false, false, false, true, false),
2350 ] {
2351 let source = "{% set unused=\"\n[reuse]: https://example.org\n\" %}{{ unused|length }}\n\n[reuse](https://example.org)\n";
2352 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2353 assert!(rule.check(&ctx).unwrap()[0].fix.is_none());
2355 assert_eq!(rule.fix(&ctx).unwrap(), source);
2356 }
2357 }
2358
2359 #[test]
2360 fn md054_real_prose_references_remain_reusable_after_templates() {
2361 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2362 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";
2363 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2364 assert_eq!(
2365 rule.fix(&ctx).unwrap(),
2366 source.replace("[reuse](https://example.org)", "[reuse][real]")
2367 );
2368 }
2369}