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