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!(
187 link.link_type,
188 LinkType::Reference | LinkType::Collapsed | LinkType::Shortcut
189 ) && link.url.is_empty()
190 {
191 continue;
192 }
193
194 let style = match link.link_type {
195 LinkType::Autolink | LinkType::Email => "autolink",
196 LinkType::Inline => {
197 if link_text_matches_url(&link.text, &link.url) {
198 "url-inline"
199 } else {
200 "inline"
201 }
202 }
203 LinkType::Reference => "full",
204 LinkType::Collapsed => "collapsed",
205 LinkType::Shortcut => "shortcut",
206 _ => continue,
207 };
208
209 if transform::skip_non_prose(ctx, link.line, link.byte_offset, link.byte_end) {
210 continue;
211 }
212
213 if !self.is_style_allowed(style) {
214 warnings.push(LintWarning {
215 rule_name: Some(self.name().to_string()),
216 line: link.line,
217 column: link.start_col + 1,
218 end_line: link.end_line,
219 end_column: link.end_col + 1,
220 message: format!("Link/image style '{style}' is not allowed"),
221 severity: Severity::Warning,
222 fix: build_fix(link.byte_offset),
223 });
224 }
225 }
226
227 for image in ctx.images() {
229 if matches!(
231 image.link_type,
232 LinkType::Reference | LinkType::Collapsed | LinkType::Shortcut
233 ) && image.url.is_empty()
234 {
235 continue;
236 }
237
238 let style = match image.link_type {
239 LinkType::Autolink | LinkType::Email => "autolink",
240 LinkType::Inline => {
241 if image.alt_text == image.url {
242 "url-inline"
243 } else {
244 "inline"
245 }
246 }
247 LinkType::Reference => "full",
248 LinkType::Collapsed => "collapsed",
249 LinkType::Shortcut => "shortcut",
250 _ => continue,
251 };
252
253 if transform::skip_non_prose(ctx, image.line, image.byte_offset, image.byte_end) {
254 continue;
255 }
256
257 if !self.is_style_allowed(style) {
258 warnings.push(LintWarning {
259 rule_name: Some(self.name().to_string()),
260 line: image.line,
261 column: image.start_col + 1,
262 end_line: image.end_line,
263 end_column: image.end_col + 1,
264 message: format!("Link/image style '{style}' is not allowed"),
265 severity: Severity::Warning,
266 fix: build_fix(image.byte_offset),
267 });
268 }
269 }
270
271 Ok(warnings)
272 }
273
274 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
275 if self.should_skip(ctx) {
276 return Ok(ctx.content.to_string());
277 }
278 let plan = transform::plan(ctx, &self.config);
279 Ok(transform::apply(ctx.content, plan))
280 }
281
282 fn fix_capability(&self) -> crate::rule::FixCapability {
283 crate::rule::FixCapability::ConditionallyFixable
286 }
287
288 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
289 ctx.content.is_empty() || (!ctx.likely_has_links_or_images() && !ctx.likely_has_html())
290 }
291
292 fn as_any(&self) -> &dyn std::any::Any {
293 self
294 }
295
296 crate::impl_rule_config_methods!(MD054Config);
297
298 fn polymorphic_config_keys(&self) -> &'static [&'static str] {
299 &["preferred-style"]
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use crate::lint_context::LintContext;
312
313 #[test]
314 fn diagnostic_positions_match_source_spans() {
315 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
318 let prefix = "é日本🙂e\u{301}\t".repeat(100);
319 let source = format!(
320 "{prefix}[标签](target)  <https://example.com> <user@example.com>\n\n\
321 {prefix}[ref][] ![ref][] [ref] ![ref] [full][ref] ![full][ref]\n\n\
322 {prefix}[multi\n{prefix}line](target) \n\n\
323 [](target) [target](target)\n\n[ref]: target\n"
324 );
325 for flavor in crate::format_proof::ALL_FLAVORS {
326 for newline in ["\n", "\r\n"] {
327 let content = source.replace('\n', newline);
328 let ctx = LintContext::new(&content, flavor, None);
329 let warnings = rule.check(&ctx).unwrap();
330 let spans: Vec<_> = ctx
331 .links()
332 .iter()
333 .map(|link| (link.byte_offset, link.byte_end))
334 .chain(ctx.images().iter().map(|image| (image.byte_offset, image.byte_end)))
335 .collect();
336 assert_eq!(warnings.len(), 15, "{flavor:?}");
337 assert_eq!(warnings.len(), spans.len());
338 for (warning, (start, end)) in warnings.iter().zip(spans) {
339 let position = |offset| {
341 let before = &content[..offset];
342 let line = before.bytes().filter(|&b| b == b'\n').count() + 1;
343 let column = before.rsplit('\n').next().unwrap().chars().count() + 1;
344 (line, column)
345 };
346 assert_eq!((warning.line, warning.column), position(start), "{flavor:?}");
347 assert_eq!((warning.end_line, warning.end_column), position(end), "{flavor:?}");
348 }
349 }
350 }
351 }
352
353 #[test]
354 fn test_all_styles_allowed_by_default() {
355 let rule = MD054LinkImageStyle::new(true, true, true, true, true, true);
356 let content = "[inline](url) [ref][] [ref] <https://autolink.com> [full][ref] [url](url)\n\n[ref]: url";
357 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
358 let result = rule.check(&ctx).unwrap();
359
360 assert_eq!(result.len(), 0);
361 }
362
363 #[test]
364 fn test_only_inline_allowed() {
365 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
366 let content = "[allowed](url) [not][ref] <https://bad.com> [collapsed][] [shortcut]\n\n[ref]: url\n[shortcut]: url\n[collapsed]: url";
368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
369 let result = rule.check(&ctx).unwrap();
370
371 assert_eq!(result.len(), 4, "Expected 4 warnings, got: {result:?}");
372 assert!(result[0].message.contains("'full'"));
373 assert!(result[1].message.contains("'autolink'"));
374 assert!(result[2].message.contains("'collapsed'"));
375 assert!(result[3].message.contains("'shortcut'"));
376 }
377
378 #[test]
379 fn test_only_autolink_allowed() {
380 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
381 let content = "<https://good.com> [bad](url) [bad][ref]\n\n[ref]: url";
382 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
383 let result = rule.check(&ctx).unwrap();
384
385 assert_eq!(result.len(), 2, "Expected 2 warnings, got: {result:?}");
386 assert!(result[0].message.contains("'inline'"));
387 assert!(result[1].message.contains("'full'"));
388 }
389
390 #[test]
391 fn test_url_inline_detection() {
392 let rule = MD054LinkImageStyle::new(false, false, false, true, false, true);
393 let content = "[https://example.com](https://example.com) [text](https://example.com)";
394 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
395 let result = rule.check(&ctx).unwrap();
396
397 assert_eq!(result.len(), 0);
399 }
400
401 #[test]
402 fn test_url_inline_not_allowed() {
403 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
404 let content = "[https://example.com](https://example.com)";
405 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
406 let result = rule.check(&ctx).unwrap();
407
408 assert_eq!(result.len(), 1);
409 assert!(result[0].message.contains("'url-inline'"));
410 }
411
412 #[test]
413 fn test_shortcut_vs_full_detection() {
414 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
415 let content = "[shortcut] [full][ref]\n\n[shortcut]: url\n[ref]: url2";
416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
417 let result = rule.check(&ctx).unwrap();
418
419 assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
421 assert!(result[0].message.contains("'shortcut'"));
422 }
423
424 #[test]
425 fn test_collapsed_reference() {
426 let rule = MD054LinkImageStyle::new(false, true, false, false, false, false);
427 let content = "[collapsed][] [bad][ref]\n\n[collapsed]: url\n[ref]: url2";
428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
429 let result = rule.check(&ctx).unwrap();
430
431 assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
432 assert!(result[0].message.contains("'full'"));
433 }
434
435 #[test]
436 fn test_code_blocks_ignored() {
437 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
438 let content = "```\n[ignored](url) <https://ignored.com>\n```\n\n[checked](url)";
439 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
440 let result = rule.check(&ctx).unwrap();
441
442 assert_eq!(result.len(), 0);
444 }
445
446 #[test]
447 fn test_code_spans_ignored() {
448 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
449 let content = "`[ignored](url)` and `<https://ignored.com>` but [checked](url)";
450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
451 let result = rule.check(&ctx).unwrap();
452
453 assert_eq!(result.len(), 0);
455 }
456
457 #[test]
458 fn test_reference_definitions_ignored() {
459 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
460 let content = "[ref]: https://example.com\n[ref2]: <https://example2.com>";
461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
462 let result = rule.check(&ctx).unwrap();
463
464 assert_eq!(result.len(), 0);
466 }
467
468 #[test]
469 fn test_html_comments_ignored() {
470 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
471 let content = "<!-- [ignored](url) -->\n <!-- <https://ignored.com> -->";
472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
473 let result = rule.check(&ctx).unwrap();
474
475 assert_eq!(result.len(), 0);
476 }
477
478 #[test]
479 fn test_unicode_support() {
480 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
481 let content = "[cafe](https://cafe.com) [emoji](url) [korean](url) [hebrew](url)";
482 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
483 let result = rule.check(&ctx).unwrap();
484
485 assert_eq!(result.len(), 0);
487 }
488
489 #[test]
490 fn test_line_positions() {
491 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
492 let content = "Line 1\n\nLine 3 with <https://bad.com> here";
493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
494 let result = rule.check(&ctx).unwrap();
495
496 assert_eq!(result.len(), 1);
497 assert_eq!(result[0].line, 3);
498 assert_eq!(result[0].column, 13); }
500
501 #[test]
502 fn test_multiple_links_same_line() {
503 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
504 let content = "[ok](url) but <https://good.com> and [also][bad]\n\n[bad]: url";
505 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
506 let result = rule.check(&ctx).unwrap();
507
508 assert_eq!(result.len(), 2, "Expected 2 warnings, got: {result:?}");
509 assert!(result[0].message.contains("'autolink'"));
510 assert!(result[1].message.contains("'full'"));
511 }
512
513 #[test]
514 fn test_empty_content() {
515 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
516 let content = "";
517 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
518 let result = rule.check(&ctx).unwrap();
519
520 assert_eq!(result.len(), 0);
521 }
522
523 #[test]
524 fn test_no_links() {
525 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
526 let content = "Just plain text without any links";
527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
528 let result = rule.check(&ctx).unwrap();
529
530 assert_eq!(result.len(), 0);
531 }
532
533 #[test]
534 fn test_fix_unreachable_target_is_noop() {
535 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
539 let content = "[link](url)";
540 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
541 let fixed = rule.fix(&ctx).unwrap();
542 assert_eq!(fixed, content);
543 }
544
545 #[test]
546 fn test_priority_order() {
547 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
548 let content = "[text][ref] not detected as [shortcut]\n\n[ref]: url\n[shortcut]: url2";
550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
551 let result = rule.check(&ctx).unwrap();
552
553 assert_eq!(result.len(), 2, "Expected 2 warnings, got: {result:?}");
554 assert!(result[0].message.contains("'full'"));
555 assert!(result[1].message.contains("'shortcut'"));
556 }
557
558 #[test]
559 fn test_not_shortcut_when_followed_by_bracket() {
560 let rule = MD054LinkImageStyle::new(false, false, false, true, true, false);
561 let content = "[text][ more text\n[text](url) is inline";
563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
564 let result = rule.check(&ctx).unwrap();
565
566 assert_eq!(result.len(), 0);
568 }
569
570 #[test]
571 fn test_cjk_correct_column_positions() {
572 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
573 let content = "日本語テスト <https://example.com>";
574 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
575 let result = rule.check(&ctx).unwrap();
576
577 assert_eq!(result.len(), 1);
578 assert!(result[0].message.contains("'autolink'"));
579 assert_eq!(
582 result[0].column, 8,
583 "Column should be 1-indexed character position of '<'"
584 );
585 }
586
587 #[test]
588 fn test_code_span_detection_with_cjk_prefix() {
589 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
590 let content = "日本語 `[link](url)` text";
592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
593 let result = rule.check(&ctx).unwrap();
594
595 assert_eq!(result.len(), 0, "Link inside code span should not be flagged");
597 }
598
599 #[test]
600 fn test_complex_unicode_with_zwj() {
601 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
602 let content = "[family](url) [cafe](https://cafe.com)";
603 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
604 let result = rule.check(&ctx).unwrap();
605
606 assert_eq!(result.len(), 0);
608 }
609
610 #[test]
611 fn test_gfm_alert_not_flagged_as_shortcut() {
612 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
613 let content = "> [!NOTE]\n> This is a note.\n";
614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
615 let result = rule.check(&ctx).unwrap();
616 assert!(
617 result.is_empty(),
618 "GFM alert should not be flagged as shortcut link, got: {result:?}"
619 );
620 }
621
622 #[test]
623 fn test_various_alert_types_not_flagged() {
624 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
625 for alert_type in ["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION", "note", "info"] {
626 let content = format!("> [!{alert_type}]\n> Content.\n");
627 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
628 let result = rule.check(&ctx).unwrap();
629 assert!(
630 result.is_empty(),
631 "Alert type {alert_type} should not be flagged, got: {result:?}"
632 );
633 }
634 }
635
636 #[test]
637 fn test_shortcut_link_still_flagged_when_disallowed() {
638 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
639 let content = "See [reference] for details.\n\n[reference]: https://example.com\n";
640 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
641 let result = rule.check(&ctx).unwrap();
642 assert!(!result.is_empty(), "Regular shortcut links should still be flagged");
643 }
644
645 #[test]
646 fn test_alert_with_frontmatter_not_flagged() {
647 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
648 let content = "---\ntitle: heading\n---\n\n> [!note]\n> Content for the note.\n";
649 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
650 let result = rule.check(&ctx).unwrap();
651 assert!(
652 result.is_empty(),
653 "Alert in blockquote with frontmatter should not be flagged, got: {result:?}"
654 );
655 }
656
657 #[test]
658 fn test_alert_without_blockquote_prefix_not_flagged() {
659 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
662 let content = "[!NOTE]\nSome content\n";
663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
664 let result = rule.check(&ctx).unwrap();
665 assert!(
666 result.is_empty(),
667 "[!NOTE] without blockquote prefix should not be flagged, got: {result:?}"
668 );
669 }
670
671 #[test]
672 fn test_alert_custom_types_not_flagged() {
673 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
675 for alert_type in ["bug", "example", "quote", "abstract", "todo", "faq"] {
676 let content = format!("> [!{alert_type}]\n> Content.\n");
677 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
678 let result = rule.check(&ctx).unwrap();
679 assert!(
680 result.is_empty(),
681 "Custom alert type {alert_type} should not be flagged, got: {result:?}"
682 );
683 }
684 }
685
686 #[test]
689 fn test_code_span_with_brackets_in_inline_link() {
690 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
691 let content = "Link to [`[myArray]`](#info).";
692 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
693 let result = rule.check(&ctx).unwrap();
694 assert!(
696 result.is_empty(),
697 "Code span with brackets in inline link should not be flagged, got: {result:?}"
698 );
699 }
700
701 #[test]
702 fn test_code_span_with_array_index_in_inline_link() {
703 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
704 let content = "See [`item[0]`](#info) for details.";
705 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
706 let result = rule.check(&ctx).unwrap();
707 assert!(
708 result.is_empty(),
709 "Array index in code span should not be flagged, got: {result:?}"
710 );
711 }
712
713 #[test]
714 fn test_code_span_with_hash_brackets_in_inline_link() {
715 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
716 let content = r#"See [`hash["key"]`](#info) for details."#;
717 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
718 let result = rule.check(&ctx).unwrap();
719 assert!(
720 result.is_empty(),
721 "Hash access in code span should not be flagged, got: {result:?}"
722 );
723 }
724
725 #[test]
726 fn test_issue_488_full_reproduction() {
727 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
729 let content = "---\ntitle: heading\n---\n\nLink to information about [`[myArray]`](#information-on-myarray).\n\n## Information on `[myArray]`\n\nSome section content.\n";
730 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
731 let result = rule.check(&ctx).unwrap();
732 assert!(
733 result.is_empty(),
734 "Issue #488 reproduction case should produce no warnings, got: {result:?}"
735 );
736 }
737
738 #[test]
739 fn test_bracket_text_without_definition_not_flagged() {
740 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
743 let content = "Some [noref] text without a definition.";
744 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
745 let result = rule.check(&ctx).unwrap();
746 assert!(
747 result.is_empty(),
748 "Bracket text without definition should not be flagged as a link, got: {result:?}"
749 );
750 }
751
752 #[test]
753 fn test_array_index_notation_not_flagged() {
754 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
756 let content = "Access `arr[0]` and use [1] or [optional] in your code.";
757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
758 let result = rule.check(&ctx).unwrap();
759 assert!(
760 result.is_empty(),
761 "Array indices and bracket text should not be flagged, got: {result:?}"
762 );
763 }
764
765 #[test]
766 fn test_real_shortcut_reference_still_flagged() {
767 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
769 let content = "See [example] for details.\n\n[example]: https://example.com\n";
770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
771 let result = rule.check(&ctx).unwrap();
772 assert_eq!(
773 result.len(),
774 1,
775 "Real shortcut reference with definition should be flagged, got: {result:?}"
776 );
777 assert!(result[0].message.contains("'shortcut'"));
778 }
779
780 #[test]
781 fn test_footnote_syntax_not_flagged_as_shortcut() {
782 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
784 let content = "See [^1] for details.";
785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786 let result = rule.check(&ctx).unwrap();
787 assert!(
788 result.is_empty(),
789 "Footnote syntax should not be flagged as shortcut, got: {result:?}"
790 );
791 }
792
793 #[test]
794 fn test_inline_link_with_code_span_detected_as_inline() {
795 let rule = MD054LinkImageStyle::new(true, true, true, false, true, true);
797 let content = "See [`[myArray]`](#info) for details.";
798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
799 let result = rule.check(&ctx).unwrap();
800 assert_eq!(
801 result.len(),
802 1,
803 "Inline link with code span should be flagged when inline is disallowed"
804 );
805 assert!(
806 result[0].message.contains("'inline'"),
807 "Should be flagged as 'inline' style, got: {}",
808 result[0].message
809 );
810 }
811
812 #[test]
813 fn test_autolink_only_document_not_skipped() {
814 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
816 let content = "Visit <https://example.com> for more info.";
817 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
818 assert!(
819 !rule.should_skip(&ctx),
820 "should_skip must return false for autolink-only documents"
821 );
822 let result = rule.check(&ctx).unwrap();
823 assert_eq!(result.len(), 1, "Autolink should be flagged when disallowed");
824 assert!(result[0].message.contains("'autolink'"));
825 }
826
827 #[test]
828 fn test_nested_image_in_link() {
829 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
831 let content = "[](https://example.com)";
832 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
833 let result = rule.check(&ctx).unwrap();
834 assert!(
836 result.len() >= 2,
837 "Nested image-in-link should detect both elements, got: {result:?}"
838 );
839 }
840
841 #[test]
842 fn test_multi_line_link() {
843 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
844 let content = "[long link\ntext](url)";
845 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
846 let result = rule.check(&ctx).unwrap();
847 assert_eq!(result.len(), 1, "Multi-line inline link should be detected");
848 assert!(result[0].message.contains("'inline'"));
849 }
850
851 #[test]
852 fn test_link_with_title() {
853 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
854 let content = r#"[text](url "title")"#;
855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
856 let result = rule.check(&ctx).unwrap();
857 assert_eq!(result.len(), 1, "Link with title should be detected as inline");
858 assert!(result[0].message.contains("'inline'"));
859 }
860
861 #[test]
862 fn test_empty_link_text() {
863 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
864 let content = "[](url)";
865 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866 let result = rule.check(&ctx).unwrap();
867 assert_eq!(result.len(), 1, "Empty link text should be detected");
868 assert!(result[0].message.contains("'inline'"));
869 }
870
871 #[test]
872 fn test_escaped_brackets_not_detected() {
873 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
874 let content = r"\[not a link\] and also \[not this either\]";
875 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
876 let result = rule.check(&ctx).unwrap();
877 assert!(
878 result.is_empty(),
879 "Escaped brackets should not be flagged, got: {result:?}"
880 );
881 }
882
883 #[test]
884 fn test_links_in_blockquotes() {
885 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
886 let content = "> [link](url) in a blockquote";
887 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
888 let result = rule.check(&ctx).unwrap();
889 assert_eq!(result.len(), 1, "Links in blockquotes should be detected");
890 assert!(result[0].message.contains("'inline'"));
891 }
892
893 #[test]
894 fn test_image_detection() {
895 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
896 let content = "";
897 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898 let result = rule.check(&ctx).unwrap();
899 assert_eq!(result.len(), 1, "Inline image should be detected");
900 assert!(result[0].message.contains("'inline'"));
901 }
902}
903
904#[cfg(test)]
905mod fix_tests {
906 use super::*;
907 use crate::config::MarkdownFlavor;
908 use crate::lint_context::LintContext;
909 use md054_config::PreferredStyle;
910 use pulldown_cmark::LinkType;
911
912 fn canonical_link_url(link_type: LinkType, url: &str) -> String {
918 match link_type {
919 LinkType::Email => format!("mailto:{url}"),
920 _ => url.to_string(),
921 }
922 }
923
924 fn rule_inline_disallowed() -> MD054LinkImageStyle {
927 MD054LinkImageStyle::new(true, true, true, false, true, true)
929 }
930
931 fn rule_only_inline() -> MD054LinkImageStyle {
933 MD054LinkImageStyle::new(false, false, false, true, false, false)
934 }
935
936 fn assert_round_trip_clean(rule: &MD054LinkImageStyle, content: &str) -> String {
943 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
944 let before_link_urls: Vec<String> = ctx
945 .links()
946 .iter()
947 .map(|l| canonical_link_url(l.link_type, &l.url))
948 .filter(|u| !u.is_empty())
949 .collect();
950 let before_image_urls: Vec<String> = ctx
951 .images()
952 .iter()
953 .map(|i| canonical_link_url(i.link_type, &i.url))
954 .filter(|u| !u.is_empty())
955 .collect();
956
957 let fixed = rule.fix(&ctx).unwrap();
958
959 let ctx2 = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
960 let warnings = rule.check(&ctx2).unwrap();
961 assert!(
962 warnings.is_empty(),
963 "fix() left disallowed-style warnings: {warnings:?} in:\n{fixed}"
964 );
965
966 let mut after_link_urls: Vec<String> = ctx2
967 .links()
968 .iter()
969 .map(|l| canonical_link_url(l.link_type, &l.url))
970 .filter(|u| !u.is_empty())
971 .collect();
972 let mut after_image_urls: Vec<String> = ctx2
973 .images()
974 .iter()
975 .map(|i| canonical_link_url(i.link_type, &i.url))
976 .filter(|u| !u.is_empty())
977 .collect();
978 let mut before_link_urls_sorted = before_link_urls;
979 let mut before_image_urls_sorted = before_image_urls;
980 before_link_urls_sorted.sort();
981 before_image_urls_sorted.sort();
982 after_link_urls.sort();
983 after_image_urls.sort();
984 assert_eq!(
985 before_link_urls_sorted, after_link_urls,
986 "fix() changed the set of link URLs.\nbefore: {before_link_urls_sorted:?}\nafter: {after_link_urls:?}\nfixed:\n{fixed}"
987 );
988 assert_eq!(
989 before_image_urls_sorted, after_image_urls,
990 "fix() changed the set of image URLs.\nbefore: {before_image_urls_sorted:?}\nafter: {after_image_urls:?}\nfixed:\n{fixed}"
991 );
992
993 let fixed2 = rule.fix(&ctx2).unwrap();
994 assert_eq!(fixed, fixed2, "fix() is not idempotent");
995 fixed
996 }
997
998 #[test]
1003 fn fix_inline_to_full_single_link() {
1004 let rule = rule_inline_disallowed();
1005 let content = "See the [documentation](https://example.com/docs) for details.\n";
1006 let fixed = assert_round_trip_clean(&rule, content);
1007 assert_eq!(
1008 fixed,
1009 "See the [documentation][documentation] for details.\n\n\
1010 [documentation]: https://example.com/docs\n"
1011 );
1012 }
1013
1014 #[test]
1015 fn fix_inline_to_full_multiple_links_dedup_by_url() {
1016 let rule = rule_inline_disallowed();
1017 let content = "First [docs](https://example.com/x).\nAgain [docs](https://example.com/x).\n";
1018 let fixed = assert_round_trip_clean(&rule, content);
1019 assert_eq!(
1021 fixed,
1022 "First [docs][docs].\nAgain [docs][docs].\n\n\
1023 [docs]: https://example.com/x\n"
1024 );
1025 }
1026
1027 #[test]
1028 fn fix_inline_to_full_same_url_different_titles_keeps_both_titles() {
1029 let rule = rule_inline_disallowed();
1033 let content = "First [a](https://example.com \"Title A\").\nLater [b](https://example.com \"Title B\").\n";
1034 let fixed = assert_round_trip_clean(&rule, content);
1035 assert!(fixed.contains(r#""Title A""#), "Title A lost in conversion: {fixed}");
1037 assert!(fixed.contains(r#""Title B""#), "Title B lost in conversion: {fixed}");
1038 let def_count = fixed.matches("]: https://example.com").count();
1040 assert_eq!(def_count, 2, "expected two ref defs (one per title), got:\n{fixed}");
1041 }
1042
1043 #[test]
1044 fn fix_inline_to_full_collision_disambiguates_with_suffix() {
1045 let rule = rule_inline_disallowed();
1046 let content = "[docs](https://a.com) and [docs](https://b.com).\n";
1047 let fixed = assert_round_trip_clean(&rule, content);
1048 assert!(fixed.contains("[docs][docs]"));
1050 assert!(fixed.contains("[docs][docs-2]"));
1051 assert!(fixed.contains("[docs]: https://a.com"));
1052 assert!(fixed.contains("[docs-2]: https://b.com"));
1053 }
1054
1055 #[test]
1056 fn fix_inline_to_full_preserves_title() {
1057 let rule = rule_inline_disallowed();
1058 let content = "See [link](https://example.com \"My Title\").\n";
1059 let fixed = assert_round_trip_clean(&rule, content);
1060 assert!(fixed.contains("[link][link]"));
1061 assert!(fixed.contains(r#"[link]: https://example.com "My Title""#));
1062 }
1063
1064 #[test]
1065 fn fix_inline_to_full_title_with_double_quotes_uses_single_quotes() {
1066 let rule = rule_inline_disallowed();
1067 let content = "See [link](https://example.com 'has \"double\" quotes').\n";
1068 let fixed = assert_round_trip_clean(&rule, content);
1069 assert!(
1071 fixed.contains(r#"[link]: https://example.com 'has "double" quotes'"#),
1072 "got:\n{fixed}"
1073 );
1074 }
1075
1076 #[test]
1077 fn fix_inline_to_full_title_with_escaped_quote_unescapes_through_parser() {
1078 let rule = rule_inline_disallowed();
1085 let content = "See [link](https://example.com \"has \\\"escaped\\\" quotes\").\n";
1086 let fixed = assert_round_trip_clean(&rule, content);
1087 assert!(
1089 fixed.contains(r#"[link]: https://example.com 'has "escaped" quotes'"#),
1090 "expected unescaped title with single-quote delimiter, got:\n{fixed}"
1091 );
1092 assert!(
1094 !fixed.contains(r#"\""#),
1095 "title should be unescaped, not pass through literal `\\\"`:\n{fixed}"
1096 );
1097 }
1098
1099 #[test]
1100 fn fix_inline_to_full_image() {
1101 let rule = rule_inline_disallowed();
1102 let content = "Logo: .\n";
1103 let fixed = assert_round_trip_clean(&rule, content);
1104 assert!(fixed.contains("![Company logo][company-logo]"));
1105 assert!(fixed.contains("[company-logo]: https://example.com/logo.png"));
1106 }
1107
1108 #[test]
1109 fn fix_inline_to_full_unicode_text() {
1110 let rule = rule_inline_disallowed();
1111 let content = "Voir [café résumé](https://cafe.example.com).\n";
1112 let fixed = assert_round_trip_clean(&rule, content);
1113 assert!(fixed.contains("[café résumé][café-résumé]"));
1115 assert!(fixed.contains("[café-résumé]: https://cafe.example.com"));
1116 }
1117
1118 #[test]
1119 fn fix_inline_to_full_reuses_existing_ref_def_for_same_url() {
1120 let rule = rule_inline_disallowed();
1121 let content = "Old: [other][site]\n\
1122 New: [docs](https://example.com)\n\
1123 \n\
1124 [site]: https://example.com\n";
1125 let fixed = assert_round_trip_clean(&rule, content);
1126 assert!(
1128 fixed.contains("[docs][site]"),
1129 "expected reuse of existing label, got:\n{fixed}"
1130 );
1131 assert_eq!(fixed.matches("https://example.com").count(), 1);
1133 }
1134
1135 #[test]
1136 fn fix_inline_to_full_avoids_existing_label_collision() {
1137 let rule = rule_inline_disallowed();
1138 let content = "Old: [a][docs]\n\
1139 New: [docs](https://other.com)\n\
1140 \n\
1141 [docs]: https://existing.com\n";
1142 let fixed = assert_round_trip_clean(&rule, content);
1143 assert!(fixed.contains("[docs][docs-2]"));
1145 assert!(fixed.contains("[docs-2]: https://other.com"));
1146 assert!(fixed.contains("[docs]: https://existing.com"));
1148 }
1149
1150 #[test]
1151 fn fix_inline_to_full_no_trailing_newline() {
1152 let rule = rule_inline_disallowed();
1153 let content = "[docs](https://example.com)";
1154 let fixed = assert_round_trip_clean(&rule, content);
1155 assert_eq!(fixed, "[docs][docs]\n\n[docs]: https://example.com\n");
1156 }
1157
1158 #[test]
1159 fn fix_inline_to_full_skips_code_blocks() {
1160 let rule = rule_inline_disallowed();
1161 let content = "Outside [a](https://x.com).\n\n```\n[fenced](https://y.com)\n```\n";
1162 let fixed = assert_round_trip_clean(&rule, content);
1163 assert!(fixed.contains("[fenced](https://y.com)"));
1165 assert!(fixed.contains("[a][a]"));
1167 }
1168
1169 #[test]
1170 fn fix_inline_to_full_skips_frontmatter() {
1171 let rule = rule_inline_disallowed();
1172 let content = "---\nlink: [foo](https://x.com)\n---\n\n[doc](https://y.com)\n";
1173 let fixed = assert_round_trip_clean(&rule, content);
1174 assert!(fixed.contains("link: [foo](https://x.com)"));
1176 assert!(fixed.contains("[doc][doc]"));
1178 }
1179
1180 #[test]
1185 fn fix_full_to_inline() {
1186 let rule = rule_only_inline();
1187 let content = "See [docs][site].\n\n[site]: https://example.com\n";
1188 let fixed = assert_round_trip_clean(&rule, content);
1189 assert!(fixed.contains("[docs](https://example.com)"));
1191 }
1192
1193 #[test]
1194 fn fix_collapsed_to_inline() {
1195 let rule = rule_only_inline();
1196 let content = "See [docs][].\n\n[docs]: https://example.com\n";
1197 let fixed = assert_round_trip_clean(&rule, content);
1198 assert!(fixed.contains("[docs](https://example.com)"));
1199 }
1200
1201 #[test]
1202 fn fix_shortcut_to_inline() {
1203 let rule = rule_only_inline();
1204 let content = "See [docs].\n\n[docs]: https://example.com\n";
1205 let fixed = assert_round_trip_clean(&rule, content);
1206 assert!(fixed.contains("[docs](https://example.com)"));
1207 }
1208
1209 #[test]
1210 fn fix_full_to_inline_preserves_title() {
1211 let rule = rule_only_inline();
1212 let content = "See [docs][site].\n\n[site]: https://example.com \"Site Title\"\n";
1213 let fixed = assert_round_trip_clean(&rule, content);
1214 assert!(
1215 fixed.contains(r#"[docs](https://example.com "Site Title")"#),
1216 "title not preserved, got:\n{fixed}"
1217 );
1218 }
1219
1220 #[test]
1221 fn fix_inline_to_full_text_with_code_span_containing_brackets() {
1222 let rule = rule_inline_disallowed();
1226 let content = "See [`a[0]` index](https://example.com).\n";
1227 let fixed = assert_round_trip_clean(&rule, content);
1228 assert!(
1229 fixed.contains("[`a[0]` index]["),
1230 "code-span text not preserved, got:\n{fixed}"
1231 );
1232 assert!(
1233 fixed.contains("]: https://example.com"),
1234 "missing emitted ref def, got:\n{fixed}"
1235 );
1236 }
1237
1238 #[test]
1239 fn fix_full_to_inline_image() {
1240 let rule = rule_only_inline();
1241 let content = "Logo: ![alt][logo].\n\n[logo]: https://x.com/img.png\n";
1242 let fixed = assert_round_trip_clean(&rule, content);
1243 assert!(fixed.contains(""));
1244 }
1245
1246 #[test]
1251 fn fix_collapsed_to_full() {
1252 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
1254 let content = "[docs][].\n\n[docs]: https://example.com\n";
1255 let fixed = assert_round_trip_clean(&rule, content);
1256 assert_eq!(fixed, "[docs][docs].\n\n[docs]: https://example.com\n");
1257 }
1258
1259 #[test]
1260 fn fix_collapsed_to_full_with_trailing_content() {
1261 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
1266 let content = "See [docs][] for details.\n\n[docs]: https://example.com\n";
1267 let fixed = assert_round_trip_clean(&rule, content);
1268 assert_eq!(fixed, "See [docs][docs] for details.\n\n[docs]: https://example.com\n");
1269 }
1270
1271 #[test]
1272 fn fix_shortcut_to_full() {
1273 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
1274 let content = "See [docs].\n\n[docs]: https://example.com\n";
1275 let fixed = assert_round_trip_clean(&rule, content);
1276 assert!(fixed.contains("See [docs][docs]"));
1277 }
1278
1279 #[test]
1280 fn fix_shortcut_to_collapsed() {
1281 let rule = MD054LinkImageStyle::new(false, true, false, false, false, false);
1282 let content = "See [docs].\n\n[docs]: https://example.com\n";
1283 let fixed = assert_round_trip_clean(&rule, content);
1284 assert!(fixed.contains("See [docs][]"));
1285 }
1286
1287 #[test]
1292 fn fix_autolink_to_inline_form() {
1293 let rule = MD054LinkImageStyle::new(false, true, true, true, true, true);
1298 let content = "Visit <https://example.com> today.\n";
1299 let fixed = assert_round_trip_clean(&rule, content);
1300 assert!(
1301 fixed.contains("[https://example.com](https://example.com)"),
1302 "got: {fixed:?}"
1303 );
1304 }
1305
1306 #[test]
1307 fn fix_autolink_to_full_when_inline_styles_disallowed() {
1308 let rule = MD054LinkImageStyle::new(false, true, true, false, true, false);
1312 let content = "Visit <https://example.com> today.\n";
1313 let fixed = assert_round_trip_clean(&rule, content);
1314 assert!(
1315 fixed.contains("[https://example.com][https-example-com]"),
1316 "got: {fixed:?}"
1317 );
1318 assert!(fixed.contains("[https-example-com]: https://example.com"));
1319 }
1320
1321 #[test]
1322 fn fix_url_inline_to_autolink() {
1323 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
1325 let content = "Visit [https://example.com](https://example.com).\n";
1326 let fixed = assert_round_trip_clean(&rule, content);
1327 assert!(fixed.contains("<https://example.com>"));
1328 }
1329
1330 #[test]
1335 fn fix_no_op_when_target_unreachable() {
1336 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
1339 let content = "See [docs](https://example.com).\n";
1340 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1341 let fixed = rule.fix(&ctx).unwrap();
1342 assert_eq!(fixed, content);
1343 let warnings = rule.check(&ctx).unwrap();
1345 assert_eq!(warnings.len(), 1);
1346 }
1347
1348 #[test]
1349 fn fix_preserves_allowed_links() {
1350 let rule = rule_inline_disallowed();
1351 let content = "Already [ref][r] is fine.\n\n[r]: https://example.com\n";
1352 let fixed = assert_round_trip_clean(&rule, content);
1353 assert_eq!(fixed, content);
1354 }
1355
1356 #[test]
1357 fn fix_keeps_the_closing_bracket_of_a_link_wrapping_a_collapsed_image() {
1358 let rule = rule_inline_disallowed();
1363 let content = "[![alt][]](target.md)\n\n[alt]: exists.png\n";
1364 let fixed = assert_round_trip_clean(&rule, content);
1365 assert_eq!(fixed, "[![alt][]][alt-2]\n\n[alt]: exists.png\n\n[alt-2]: target.md\n");
1366 }
1367
1368 #[test]
1373 fn fix_preferred_style_explicit_full() {
1374 let config = md054_config::MD054Config {
1375 inline: false,
1376 preferred_style: PreferredStyles::single(PreferredStyle::Full),
1377 ..Default::default()
1378 };
1379 let rule = MD054LinkImageStyle::from_config_struct(config);
1380 let content = "[docs](https://example.com)\n";
1381 let fixed = assert_round_trip_clean(&rule, content);
1382 assert!(fixed.contains("[docs][docs]"));
1383 }
1384
1385 #[test]
1386 fn fix_inline_to_collapsed_emits_matching_ref_def() {
1387 let config = md054_config::MD054Config {
1391 inline: false,
1392 preferred_style: PreferredStyles::single(PreferredStyle::Collapsed),
1393 ..Default::default()
1394 };
1395 let rule = MD054LinkImageStyle::from_config_struct(config);
1396 let content = "[anchor](https://example.com)\n";
1397 let fixed = assert_round_trip_clean(&rule, content);
1398 assert!(fixed.contains("[anchor][]"), "got:\n{fixed}");
1399 assert!(fixed.contains("[anchor]: https://example.com"), "got:\n{fixed}");
1400 }
1401
1402 #[test]
1403 fn fix_inline_to_shortcut_emits_matching_ref_def() {
1404 let config = md054_config::MD054Config {
1405 inline: false,
1406 preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1407 ..Default::default()
1408 };
1409 let rule = MD054LinkImageStyle::from_config_struct(config);
1410 let content = "See [anchor](https://example.com).\n";
1412 let fixed = assert_round_trip_clean(&rule, content);
1413 assert!(fixed.contains("[anchor]"), "got:\n{fixed}");
1414 assert!(!fixed.contains("[anchor]("), "shortcut form, not inline: {fixed}");
1415 assert!(fixed.contains("[anchor]: https://example.com"), "got:\n{fixed}");
1416 }
1417
1418 #[test]
1419 fn fix_inline_to_collapsed_skips_empty_text() {
1420 let config = md054_config::MD054Config {
1424 inline: false,
1425 preferred_style: PreferredStyles::single(PreferredStyle::Collapsed),
1426 ..Default::default()
1427 };
1428 let rule = MD054LinkImageStyle::from_config_struct(config);
1429 let content = "[](https://example.com)\n";
1430 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1431 let fixed = rule.fix(&ctx).unwrap();
1432 assert_eq!(fixed, content, "empty text must not collapse: {fixed}");
1433 }
1434
1435 #[test]
1436 fn fix_inline_to_shortcut_skips_empty_text() {
1437 let config = md054_config::MD054Config {
1438 inline: false,
1439 preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1440 ..Default::default()
1441 };
1442 let rule = MD054LinkImageStyle::from_config_struct(config);
1443 let content = "See [](https://example.com).\n";
1444 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1445 let fixed = rule.fix(&ctx).unwrap();
1446 assert_eq!(fixed, content);
1447 }
1448
1449 #[test]
1450 fn fix_inline_to_collapsed_skips_text_with_brackets() {
1451 let config = md054_config::MD054Config {
1454 inline: false,
1455 preferred_style: PreferredStyles::single(PreferredStyle::Collapsed),
1456 ..Default::default()
1457 };
1458 let rule = MD054LinkImageStyle::from_config_struct(config);
1459 let content = "See [`a[0]` index](https://example.com).\n";
1460 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1461 let fixed = rule.fix(&ctx).unwrap();
1462 assert_eq!(fixed, content, "text containing `[` / `]` must not collapse: {fixed}");
1463 }
1464
1465 #[test]
1466 fn fix_inline_to_full_url_with_space_uses_angle_brackets_in_def() {
1467 let rule = rule_inline_disallowed();
1472 let content = "See [docs](<./has space.md>).\n";
1473 let fixed = assert_round_trip_clean(&rule, content);
1474 assert!(
1475 fixed.contains("[docs]: <./has space.md>"),
1476 "ref def must wrap URL in angle brackets: {fixed}"
1477 );
1478 }
1479
1480 #[test]
1481 fn fix_inline_to_full_url_with_unbalanced_paren_uses_angle_brackets_in_def() {
1482 let rule = rule_inline_disallowed();
1485 let content = "See [docs](<https://example.com/a)b>).\n";
1486 let fixed = assert_round_trip_clean(&rule, content);
1487 assert!(
1488 fixed.contains("[docs]: <https://example.com/a)b>"),
1489 "ref def must wrap unbalanced-paren URL in angle brackets: {fixed}"
1490 );
1491 }
1492
1493 #[test]
1494 fn fix_full_to_inline_preserves_backslash_unescaped_title() {
1495 let rule = rule_only_inline();
1499 let content = "See [docs][d].\n\n[d]: https://example.com \"He said \\\"hi\\\"\"\n";
1500 let fixed = assert_round_trip_clean(&rule, content);
1501 assert!(fixed.contains("https://example.com"), "URL must round-trip: {fixed}");
1506 assert!(
1507 fixed.contains(r#"\"hi\""#) || fixed.contains(r#"He said "hi""#),
1508 "title must round-trip with quotes preserved: {fixed}"
1509 );
1510 }
1511
1512 #[test]
1513 fn fix_full_to_inline_url_with_close_paren_uses_angle_brackets() {
1514 let rule = rule_only_inline();
1518 let content = "See [t][r].\n\n[r]: <https://example.com/a)b>\n";
1519 let fixed = assert_round_trip_clean(&rule, content);
1520 assert!(
1521 fixed.contains("[t](<https://example.com/a)b>)"),
1522 "inline form must use angle brackets for `)` URLs: {fixed}"
1523 );
1524 }
1525
1526 #[test]
1527 fn fix_inline_to_collapsed_skips_when_label_collides_with_different_url() {
1528 let config = md054_config::MD054Config {
1533 inline: false,
1534 preferred_style: PreferredStyles::single(PreferredStyle::Collapsed),
1535 ..Default::default()
1536 };
1537 let rule = MD054LinkImageStyle::from_config_struct(config);
1538 let content = "[other][anchor]\n[anchor](https://other.com)\n\n[anchor]: https://existing.com\n";
1539 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1540 let fixed = rule.fix(&ctx).unwrap();
1541 assert!(fixed.contains("[anchor](https://other.com)"), "got:\n{fixed}");
1543 assert!(fixed.contains("[anchor]: https://existing.com"));
1545 }
1546
1547 #[test]
1548 fn fix_preferred_style_list_picks_first_reachable() {
1549 let config = md054_config::MD054Config {
1552 url_inline: false,
1553 preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Full]),
1554 ..Default::default()
1555 };
1556 let rule = MD054LinkImageStyle::from_config_struct(config);
1557 let content = "[https://example.com](https://example.com)\n";
1558 let fixed = assert_round_trip_clean(&rule, content);
1559 assert!(
1560 fixed.contains("<https://example.com>"),
1561 "expected autolink form, got:\n{fixed}"
1562 );
1563 }
1564
1565 #[test]
1566 fn fix_preferred_style_list_falls_back_to_next_when_first_unreachable() {
1567 let config = md054_config::MD054Config {
1570 inline: false,
1571 preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Full]),
1572 ..Default::default()
1573 };
1574 let rule = MD054LinkImageStyle::from_config_struct(config);
1575 let content = "[docs](./guide.md)\n";
1576 let fixed = assert_round_trip_clean(&rule, content);
1577 assert!(
1578 fixed.contains("[docs][docs]"),
1579 "expected fallback to full, got:\n{fixed}"
1580 );
1581 assert!(
1582 fixed.contains("[docs]: ./guide.md"),
1583 "expected matching ref def, got:\n{fixed}"
1584 );
1585 }
1586
1587 #[test]
1588 fn fix_preferred_style_auto_in_list_acts_as_wildcard_fallback() {
1589 let config = md054_config::MD054Config {
1593 inline: false,
1594 preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Auto]),
1595 ..Default::default()
1596 };
1597 let rule = MD054LinkImageStyle::from_config_struct(config);
1598 let content = "[docs](./guide.md)\n";
1599 let fixed = assert_round_trip_clean(&rule, content);
1600 assert!(
1601 fixed.contains("[docs][docs]"),
1602 "Auto fallback should pick full for inline-disallowed config, got:\n{fixed}"
1603 );
1604 }
1605
1606 #[test]
1607 fn fix_default_auto_prefers_autolink_for_url_inline_source() {
1608 let rule = MD054LinkImageStyle::new(true, true, true, true, true, false);
1612 let content = "[https://example.com](https://example.com)\n";
1613 let fixed = assert_round_trip_clean(&rule, content);
1614 assert!(
1615 fixed.contains("<https://example.com>"),
1616 "expected autolink, got:\n{fixed}"
1617 );
1618 assert!(
1619 !fixed.contains("[https://example.com]["),
1620 "should not produce reference form when autolink is reachable, got:\n{fixed}"
1621 );
1622 }
1623
1624 #[test]
1625 fn fix_default_auto_falls_back_when_autolink_disallowed() {
1626 let rule = MD054LinkImageStyle::new(false, true, true, true, true, false);
1629 let content = "[https://example.com](https://example.com)\n";
1630 let fixed = assert_round_trip_clean(&rule, content);
1631 assert!(
1632 fixed.contains("[https://example.com][https-example-com]"),
1633 "expected full form, got:\n{fixed}"
1634 );
1635 assert!(
1636 fixed.contains("[https-example-com]: https://example.com"),
1637 "missing ref def, got:\n{fixed}"
1638 );
1639 }
1640
1641 #[test]
1642 fn fix_preferred_style_explicit_no_match_skips_fix() {
1643 let config = md054_config::MD054Config {
1646 inline: false,
1647 preferred_style: PreferredStyles::single(PreferredStyle::Inline),
1649 ..Default::default()
1650 };
1651 let rule = MD054LinkImageStyle::from_config_struct(config);
1652 let content = "[docs](./guide.md)\n";
1653 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1654 let fixed = rule.fix(&ctx).unwrap();
1655 assert_eq!(fixed, content, "expected no-op fix, got:\n{fixed}");
1656 }
1657
1658 #[test]
1663 fn fix_mixes_inline_and_image_in_same_doc() {
1664 let rule = rule_inline_disallowed();
1665 let content = "Text [link](https://example.com) and .\n";
1666 let fixed = assert_round_trip_clean(&rule, content);
1667 assert!(fixed.contains("[link][link]"));
1668 assert!(fixed.contains("![pic][pic]"));
1669 assert!(fixed.contains("[link]: https://example.com"));
1670 assert!(fixed.contains("[pic]: https://example.com/p.png"));
1671 }
1672
1673 #[test]
1674 fn fix_appends_one_blank_line_separator() {
1675 let rule = rule_inline_disallowed();
1676 let content = "Plain prose.\n\n[link](https://x.com)\n";
1677 let fixed = assert_round_trip_clean(&rule, content);
1678 assert!(fixed.ends_with("\n[link]: https://x.com\n"));
1680 assert!(!fixed.contains("\n\n\n[link]"));
1681 }
1682
1683 #[test]
1688 fn fix_nested_image_in_link_does_not_panic_or_corrupt() {
1689 let rule = rule_inline_disallowed();
1696 let content = "See [](https://x.com).\n";
1697 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1698 let fixed = rule.fix(&ctx).unwrap();
1700 assert_eq!(fixed, content);
1703 let warnings = rule.check(&ctx).unwrap();
1704 assert_eq!(warnings.len(), 2, "both nested constructs should still warn");
1705 }
1706
1707 #[test]
1712 fn fix_email_autolink_to_inline_preserves_mailto_prefix() {
1713 let rule = MD054LinkImageStyle::new(false, true, true, true, true, true);
1719 let content = "Reach <me@example.com> for support.\n";
1720 let fixed = assert_round_trip_clean(&rule, content);
1721 assert!(
1722 fixed.contains("[me@example.com](mailto:me@example.com)"),
1723 "expected mailto: prefix on resolved destination, got:\n{fixed}"
1724 );
1725 }
1726
1727 #[test]
1728 fn fix_email_autolink_to_full_preserves_mailto_in_ref_def() {
1729 let rule = MD054LinkImageStyle::new(false, true, true, false, true, false);
1733 let content = "Reach <me@example.com> for support.\n";
1734 let fixed = assert_round_trip_clean(&rule, content);
1735 assert!(
1736 fixed.contains("]: mailto:me@example.com"),
1737 "ref def should carry the mailto: prefix, got:\n{fixed}"
1738 );
1739 }
1740
1741 #[test]
1742 fn fix_rejects_bare_email_as_autolink_target() {
1743 let config = md054_config::MD054Config {
1749 url_inline: false,
1750 preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Auto]),
1751 ..Default::default()
1752 };
1753 let rule = MD054LinkImageStyle::from_config_struct(config);
1754 let content = "[me@example.com](me@example.com)\n";
1755 let fixed = assert_round_trip_clean(&rule, content);
1756 assert!(
1757 !fixed.contains("<me@example.com>"),
1758 "bare-email autolink target would silently retarget to mailto:, got:\n{fixed}"
1759 );
1760 }
1761
1762 #[test]
1771 fn fix_generated_ref_def_with_both_quote_types_round_trips_to_ctx() {
1772 let rule = rule_inline_disallowed();
1778 let content = "See [docs](https://example.com/x \"and 'both' quotes\") today.\n";
1779 let fixed = assert_round_trip_clean(&rule, content);
1780 assert!(
1783 fixed.contains("(and 'both' quotes)") || fixed.contains("\"and 'both' quotes\""),
1784 "title should round-trip through some valid delimiter, got:\n{fixed}"
1785 );
1786 let ctx = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
1790 let def = ctx
1791 .reference_definitions()
1792 .iter()
1793 .find(|d| d.url == "https://example.com/x")
1794 .expect("generated ref def must round-trip through parse_reference_defs");
1795 assert_eq!(
1796 def.title.as_deref(),
1797 Some("and 'both' quotes"),
1798 "title content must survive the round-trip"
1799 );
1800 }
1801
1802 #[test]
1807 fn fix_appends_generated_refs_with_crlf_when_source_is_crlf() {
1808 let rule = rule_inline_disallowed();
1814 let content = "See [docs](https://example.com/x).\r\n";
1815 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1816 let warnings = rule.check(&ctx).expect("check must succeed");
1817 assert!(!warnings.is_empty(), "expected at least one warning");
1818 let fixed = rule.fix(&ctx).expect("fix must succeed");
1819 assert!(
1820 fixed.contains("\r\n"),
1821 "fixed output must preserve CRLF, got:\n{fixed:?}"
1822 );
1823 assert!(
1824 !fixed.lines().any(|l| l.ends_with('\r')) || !fixed.contains("\n\n"),
1825 "no line should end with stray \\r and there should be no naked LF blanks; got:\n{fixed:?}"
1826 );
1827 let bytes = fixed.as_bytes();
1830 for (i, &b) in bytes.iter().enumerate() {
1831 if b == b'\n' {
1832 assert!(
1833 i > 0 && bytes[i - 1] == b'\r',
1834 "found naked LF at byte {i} in CRLF document, full output:\n{fixed:?}"
1835 );
1836 }
1837 }
1838 }
1839
1840 #[test]
1841 fn fix_appends_generated_refs_with_lf_when_source_is_lf() {
1842 let rule = rule_inline_disallowed();
1844 let content = "See [docs](https://example.com/x).\n";
1845 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1846 let fixed = rule.fix(&ctx).expect("fix must succeed");
1847 assert!(
1848 !fixed.contains('\r'),
1849 "LF document must not gain any CR characters, got:\n{fixed:?}"
1850 );
1851 }
1852
1853 #[test]
1858 fn fix_rejects_shortcut_target_when_followed_by_paren() {
1859 let config = md054_config::MD054Config {
1865 inline: false,
1866 preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1867 ..Default::default()
1868 };
1869 let rule = MD054LinkImageStyle::from_config_struct(config);
1870 let content = "[docs](https://example.com/x)(suffix)\n";
1871 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1872 let fixed = rule.fix(&ctx).unwrap();
1873 assert_eq!(fixed, content, "shortcut target was unsafe; fix should be a no-op");
1876 }
1877
1878 #[test]
1879 fn fix_rejects_shortcut_target_when_followed_by_bracket() {
1880 let config = md054_config::MD054Config {
1884 inline: false,
1885 preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1886 ..Default::default()
1887 };
1888 let rule = MD054LinkImageStyle::from_config_struct(config);
1889 let content = "[docs](https://example.com/x)[next]\n\n[next]: https://example.com/n\n";
1890 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1891 let fixed = rule.fix(&ctx).unwrap();
1892 assert_eq!(fixed, content, "shortcut target was unsafe; fix should be a no-op");
1893 }
1894
1895 #[test]
1896 fn fix_allows_shortcut_target_when_follower_is_safe() {
1897 let config = md054_config::MD054Config {
1901 inline: false,
1902 preferred_style: PreferredStyles::single(PreferredStyle::Shortcut),
1903 ..Default::default()
1904 };
1905 let rule = MD054LinkImageStyle::from_config_struct(config);
1906 let content = "See [docs](https://example.com/x). Also nice.\n";
1907 let fixed = assert_round_trip_clean(&rule, content);
1908 assert!(fixed.contains("[docs]"), "expected shortcut form, got:\n{fixed}");
1909 assert!(fixed.contains("[docs]: https://example.com/x"));
1910 }
1911
1912 #[test]
1917 fn check_attaches_fix_for_self_contained_rewrites() {
1918 let rule = MD054LinkImageStyle::new(false, true, true, true, true, true);
1924 let content = "See <https://example.com>.\n";
1925 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1926 let warnings = rule.check(&ctx).unwrap();
1927 assert_eq!(warnings.len(), 1, "should warn about the autolink");
1928 let fix = warnings[0]
1929 .fix
1930 .as_ref()
1931 .expect("self-contained rewrite must carry a Fix so quick-fix paths can apply it");
1932 assert_eq!(&content[fix.range.clone()], "<https://example.com>");
1933 assert_eq!(fix.replacement, "[https://example.com](https://example.com)");
1934 }
1935
1936 #[test]
1937 fn check_carries_atomic_fix_when_rewrite_requires_new_ref_def() {
1938 let rule = rule_inline_disallowed();
1945 let content = "See [docs](https://example.com).\n";
1946 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1947 let warnings = rule.check(&ctx).unwrap();
1948 assert_eq!(warnings.len(), 1, "should warn about the inline link");
1949 let fix = warnings[0]
1950 .fix
1951 .as_ref()
1952 .expect("ref-emitting rewrite must carry an atomic per-warning Fix");
1953 assert_eq!(&content[fix.range.clone()], "[docs](https://example.com)");
1954 assert!(
1955 fix.replacement.starts_with("[docs]"),
1956 "primary replacement should rewrite the link to a reference form, got: {:?}",
1957 fix.replacement
1958 );
1959 assert_eq!(
1960 fix.additional_edits.len(),
1961 1,
1962 "ref-emitting fix should carry one additional_edit for the ref-def"
1963 );
1964 let extra = &fix.additional_edits[0];
1965 assert_eq!(
1966 extra.range,
1967 content.len()..content.len(),
1968 "ref-def insertion should be a zero-width edit at EOF"
1969 );
1970 assert!(
1971 extra.replacement.contains("[docs]: https://example.com"),
1972 "additional_edit should append the ref-def, got: {:?}",
1973 extra.replacement
1974 );
1975 let applied = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
1979 let from_fix_all = rule.fix(&ctx).unwrap();
1980 assert!(
1981 applied.contains("[docs]: https://example.com"),
1982 "single-warning application must include ref-def, got:\n{applied}"
1983 );
1984 assert!(
1985 !applied.contains("[docs](https://example.com)"),
1986 "single-warning application must rewrite the inline link, got:\n{applied}"
1987 );
1988 assert!(
1993 from_fix_all.contains("[docs]: https://example.com"),
1994 "fix-all path must also produce the ref-def, got:\n{from_fix_all}"
1995 );
1996 }
1997
1998 #[test]
1999 fn check_attaches_no_fix_when_target_unreachable() {
2000 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
2005 let content = "See [docs](https://example.com).\n";
2006 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2007 let warnings = rule.check(&ctx).unwrap();
2008 assert_eq!(warnings.len(), 1);
2009 assert!(warnings[0].fix.is_none(), "unreachable target should leave fix empty");
2010 }
2011
2012 #[test]
2013 fn fix_skips_autolink_target_when_title_present() {
2014 let config = md054_config::MD054Config {
2019 url_inline: false,
2020 preferred_style: PreferredStyles::from_iter([PreferredStyle::Autolink, PreferredStyle::Auto]),
2021 ..Default::default()
2022 };
2023 let rule = MD054LinkImageStyle::from_config_struct(config);
2024 let content = "[https://example.com](https://example.com \"Homepage\")\n";
2025 let fixed = assert_round_trip_clean(&rule, content);
2026 assert!(
2027 !fixed.contains("<https://example.com>"),
2028 "autolink target would drop the title, got:\n{fixed}"
2029 );
2030 assert!(
2031 fixed.contains("\"Homepage\""),
2032 "title text must survive the conversion, got:\n{fixed}"
2033 );
2034 }
2035
2036 #[test]
2037 fn default_config_section_emits_clean_user_facing_defaults() {
2038 let rule = MD054LinkImageStyle::default();
2043 let (_, value) = rule.default_config_section().expect("md054 has defaults");
2044 let table = value.as_table().expect("config section is a table");
2045 let preferred = table
2046 .get("preferred-style")
2047 .expect("preferred-style key must be present in defaults");
2048 assert!(
2049 !crate::rule_config_serde::is_polymorphic_sentinel(preferred),
2050 "preferred-style in user-facing defaults must be the serialized scalar, not the sentinel; got {preferred:?}"
2051 );
2052 assert!(
2055 preferred.is_str(),
2056 "preferred-style default should serialize as a scalar string; got {preferred:?}"
2057 );
2058 }
2059
2060 #[test]
2061 fn registry_marks_preferred_style_polymorphic_for_validation() {
2062 let registry = crate::config::registry::default_registry();
2068 let expected = registry
2069 .expected_value_for("MD054", "preferred-style")
2070 .or_else(|| registry.expected_value_for("MD054", "preferred_style"));
2071 assert!(
2076 expected.is_none(),
2077 "preferred-style must be sentinel-marked in the schema so type checking is skipped; got {expected:?}"
2078 );
2079 let keys = registry.config_keys_for("MD054").expect("md054 must be registered");
2082 assert!(keys.contains("preferred-style"));
2083 }
2084}
2085
2086#[cfg(test)]
2087mod autolink_literal_tests {
2088 use super::*;
2089 fn rendered(source: &str) -> String {
2090 let mut output = String::new();
2091 pulldown_cmark::html::push_html(&mut output, pulldown_cmark::Parser::new(source));
2092 output
2093 }
2094 #[test]
2095 fn md054_autolink_conversions_preserve_literal_display_and_destination() {
2096 for rule in [
2097 MD054LinkImageStyle::new(false, false, false, true, false, true),
2098 MD054LinkImageStyle::new(false, false, true, false, false, false),
2099 ] {
2100 for value in [
2101 "https://example.org/a*b*c",
2102 "https://example.org/a`b`c",
2103 "https://example.org/a©b",
2104 "https://example.org/a\\*b*c",
2105 "https://example.org/a\\(b)",
2106 "https://example.org/[b]",
2107 "a*b*c@example.org",
2108 ] {
2109 for ending in ["\n", "\r\n"] {
2110 let source = format!("内容 <{value}>{ending}");
2111 let ctx =
2112 crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
2113 let fixed = rule.fix(&ctx).unwrap();
2114 assert_ne!(fixed, source, "conversion remains available: {source}");
2115 assert_eq!(rendered(&fixed), rendered(&source), "{source} -> {fixed}");
2116 let ctx =
2117 crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2118 assert!(rule.check(&ctx).unwrap().is_empty(), "{fixed}");
2119 assert_eq!(rule.fix(&ctx).unwrap(), fixed);
2120 }
2121 }
2122 }
2123 }
2124 #[test]
2125 fn md054_inline_url_style_round_trips_escaped_literal_labels() {
2126 let rule = MD054LinkImageStyle::new(false, false, false, false, false, true);
2127 let source = "<https://example.org/a*b*c>\n";
2128 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2129 let fixed = rule.fix(&ctx).unwrap();
2130 assert_eq!(rendered(&fixed), rendered(source));
2131 let ctx = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2132 assert!(rule.check(&ctx).unwrap().is_empty(), "{fixed}");
2133 assert_eq!(rule.fix(&ctx).unwrap(), fixed);
2134 }
2135 #[test]
2136 fn md054_preserves_authored_markdown_link_labels() {
2137 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2138 let source = "[*formatted* and `code`](https://example.org)\n";
2139 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2140 let fixed = rule.fix(&ctx).unwrap();
2141 assert!(fixed.starts_with("[*formatted* and `code`]["));
2142 assert_eq!(rendered(&fixed), rendered(source));
2143 }
2144}
2145
2146#[cfg(test)]
2147mod formatted_url_label_tests {
2148 use super::*;
2149 fn rendered(source: &str) -> String {
2150 let mut html = String::new();
2151 pulldown_cmark::html::push_html(
2152 &mut html,
2153 pulldown_cmark::Parser::new_ext(source, pulldown_cmark::Options::ENABLE_STRIKETHROUGH),
2154 );
2155 html
2156 }
2157 #[test]
2158 fn md054_formatted_url_labels_cannot_become_literal_autolinks() {
2159 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
2160 for url in [
2161 "https://example.org/a*b*c",
2162 "https://example.org/a`b`c",
2163 "https://example.org/a~~b~~c",
2164 "https://example.org/?q=**bold**",
2165 ] {
2166 let source = format!("内容 [{url}]({url})\n");
2167 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
2168 let warnings = rule.check(&ctx).unwrap();
2169 assert_eq!(warnings.len(), 1);
2170 assert!(warnings[0].fix.is_none(), "conversion would lose formatting: {source}");
2171 let fixed = rule.fix(&ctx).unwrap();
2172 assert_eq!(fixed, source);
2173 assert_eq!(rendered(&fixed), rendered(&source));
2174 }
2175 }
2176 #[test]
2177 fn md054_formatted_url_labels_use_inline_style() {
2178 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
2179 let source = "[https://example.org/a*b*c](https://example.org/a*b*c)\n";
2180 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2181 assert!(rule.check(&ctx).unwrap().is_empty());
2182 assert_eq!(rule.fix(&ctx).unwrap(), source);
2183 }
2184 #[test]
2185 fn md054_literal_url_labels_remain_convertible() {
2186 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
2187 for (label, url) in [
2188 ("https://example.org/plain", "https://example.org/plain"),
2189 ("https://example.org/a_b_c", "https://example.org/a_b_c"),
2190 ("https://example.org/a*b", "https://example.org/a*b"),
2191 ("https://example.org/a\\*b\\*c", "https://example.org/a*b*c"),
2192 ] {
2193 let source = format!("[{label}]({url})\n");
2194 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
2195 let fixed = rule.fix(&ctx).unwrap();
2196 assert_eq!(fixed, format!("<{url}>\n"));
2197 assert_eq!(rendered(&fixed), rendered(&source));
2198 }
2199 }
2200}
2201
2202#[cfg(test)]
2203mod template_context_tests {
2204 use super::*;
2205 #[test]
2206 fn md054_template_strings_preserve_link_and_image_syntax() {
2207 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2208 for prefix in ["", "!"] {
2209 let literal = format!("{prefix}[literal](https://literal.example.org)");
2210 for (flavor, template) in [
2211 (
2212 crate::config::MarkdownFlavor::Standard,
2213 format!("{{{{ \"{literal}\" }}}}"),
2214 ),
2215 (
2216 crate::config::MarkdownFlavor::Standard,
2217 format!("{{% set value=\"{literal}\" %}}"),
2218 ),
2219 (
2220 crate::config::MarkdownFlavor::Hugo,
2221 format!("{{{{< note title=\"{literal}\" >}}}}"),
2222 ),
2223 (
2224 crate::config::MarkdownFlavor::Hugo,
2225 format!("{{{{% note title=`{literal}` %}}}}"),
2226 ),
2227 (
2228 crate::config::MarkdownFlavor::Quarto,
2229 format!("{{{{< note title=\"{literal}\" >}}}}"),
2230 ),
2231 ] {
2232 for ending in ["\n", "\r\n"] {
2233 let source =
2234 format!("Before {template} after {prefix}[visible](https://visible.example.org){ending}");
2235 let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
2236 let warnings = rule.check(&ctx).unwrap();
2237 assert_eq!(warnings.len(), 1, "{source}");
2238 let fixed = rule.fix(&ctx).unwrap();
2239 assert!(fixed.contains(&template), "{source} -> {fixed}");
2240 assert!(!fixed.contains("[visible](https://visible.example.org)"));
2241 assert!(
2242 !fixed.contains("[literal]:"),
2243 "literal-only references must not be appended: {fixed}"
2244 );
2245 let ctx = crate::lint_context::LintContext::new(&fixed, flavor, None);
2246 assert!(rule.check(&ctx).unwrap().is_empty(), "{fixed}");
2247 assert_eq!(rule.fix(&ctx).unwrap(), fixed);
2248 }
2249 }
2250 }
2251 }
2252 #[test]
2253 fn md054_template_code_inside_link_destinations_is_preserved() {
2254 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2255 for prefix in ["", "!"] {
2256 let source = format!(
2257 "{prefix}[literal](<{{{{ \"&copy;\"|length }}}}>)\n\n[visible](https://visible.example.org)\n"
2258 );
2259 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
2260 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2261 let fixed = rule.fix(&ctx).unwrap();
2262 assert!(fixed.starts_with(source.split("\n\n").next().unwrap()), "{fixed}");
2263 assert!(!fixed.contains("[literal]:"));
2264 }
2265 }
2266 #[test]
2267 fn md054_myst_comments_do_not_contribute_style_warnings_or_fixes() {
2268 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2269 for marker in ["%", "% ", " %\t", " % "] {
2270 for prefix in ["", "!"] {
2271 let comment = format!("{marker}{prefix}[literal](https://literal.example.org)");
2272 let source = format!("{comment}\n\n{prefix}[visible](https://visible.example.org)\n");
2273 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::MyST, None);
2274 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source}");
2275 let fixed = rule.fix(&ctx).unwrap();
2276 assert!(fixed.starts_with(&comment), "{fixed}");
2277 assert!(!fixed.contains("[literal]:"));
2278 assert!(!fixed.contains("[visible](https://visible.example.org)"));
2279 }
2280 }
2281 }
2282 #[test]
2283 fn md054_disabled_rules_keep_framework_warning_filtering() {
2284 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2285 let source = "<!-- markdownlint-disable MD054 -->\n\n[text](https://example.org)\n";
2286 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2287 assert!(ctx.is_rule_disabled("MD054", 3));
2288 let warnings = rule.check(&ctx).unwrap();
2290 assert_eq!(warnings.len(), 1);
2291 assert!(warnings[0].fix.is_none());
2292 assert_eq!(rule.fix(&ctx).unwrap(), source);
2293 }
2294}
2295
2296#[cfg(test)]
2297mod template_reference_tests {
2298 use super::*;
2299
2300 #[test]
2301 fn md054_template_reference_definitions_are_not_reused() {
2302 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2303 for prefix in ["", "!"] {
2304 for ending in ["\n", "\r\n"] {
2305 for hidden_url in ["https://example.org", "https://other.example.org"] {
2306 for title in ["", " \"title\""] {
2307 let template =
2308 format!("{{% set unused=\"\n[reuse]: {hidden_url}\n\" %}}{{{{ unused|length }}}}")
2309 .replace('\n', ending);
2310 let source =
2311 format!("{template}{ending}{ending}{prefix}[reuse](https://example.org{title}){ending}");
2312 let ctx = crate::lint_context::LintContext::new(
2313 &source,
2314 crate::config::MarkdownFlavor::Standard,
2315 None,
2316 );
2317 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2318 let fixed = rule.fix(&ctx).unwrap();
2319 assert!(fixed.starts_with(&template), "{fixed}");
2320 assert!(
2321 fixed.contains(&format!("{prefix}[reuse][reuse-2]")),
2322 "{source} -> {fixed}"
2323 );
2324 assert!(
2325 fixed.contains(&format!("[reuse-2]: https://example.org{title}")),
2326 "{fixed}"
2327 );
2328 let ctx = crate::lint_context::LintContext::new(
2329 &fixed,
2330 crate::config::MarkdownFlavor::Standard,
2331 None,
2332 );
2333 assert!(rule.check(&ctx).unwrap().is_empty(), "{fixed}");
2334 assert_eq!(rule.fix(&ctx).unwrap(), fixed);
2335 }
2336 }
2337 }
2338 }
2339 }
2340
2341 #[test]
2342 fn md054_collapsed_and_shortcut_do_not_reuse_template_labels() {
2343 for rule in [
2344 MD054LinkImageStyle::new(false, true, false, false, false, false),
2345 MD054LinkImageStyle::new(false, false, false, false, true, false),
2346 ] {
2347 let source = "{% set unused=\"\n[reuse]: https://example.org\n\" %}{{ unused|length }}\n\n[reuse](https://example.org)\n";
2348 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2349 assert!(rule.check(&ctx).unwrap()[0].fix.is_none());
2351 assert_eq!(rule.fix(&ctx).unwrap(), source);
2352 }
2353 }
2354
2355 #[test]
2356 fn md054_real_prose_references_remain_reusable_after_templates() {
2357 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
2358 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";
2359 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2360 assert_eq!(
2361 rule.fix(&ctx).unwrap(),
2362 source.replace("[reuse](https://example.org)", "[reuse][real]")
2363 );
2364 }
2365}