1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
7use pulldown_cmark::LinkType;
8
9mod md054_config;
10use md054_config::MD054Config;
11
12#[derive(Debug, Default, Clone)]
57pub struct MD054LinkImageStyle {
58 config: MD054Config,
59}
60
61impl MD054LinkImageStyle {
62 pub fn new(autolink: bool, collapsed: bool, full: bool, inline: bool, shortcut: bool, url_inline: bool) -> Self {
63 Self {
64 config: MD054Config {
65 autolink,
66 collapsed,
67 full,
68 inline,
69 shortcut,
70 url_inline,
71 },
72 }
73 }
74
75 pub fn from_config_struct(config: MD054Config) -> Self {
76 Self { config }
77 }
78
79 fn byte_to_char_col(content: &str, byte_offset: usize) -> usize {
82 let before = &content[..byte_offset];
83 let last_newline = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
84 before[last_newline..].chars().count() + 1
85 }
86
87 fn is_style_allowed(&self, style: &str) -> bool {
89 match style {
90 "autolink" => self.config.autolink,
91 "collapsed" => self.config.collapsed,
92 "full" => self.config.full,
93 "inline" => self.config.inline,
94 "shortcut" => self.config.shortcut,
95 "url-inline" => self.config.url_inline,
96 _ => false,
97 }
98 }
99}
100
101impl Rule for MD054LinkImageStyle {
102 fn name(&self) -> &'static str {
103 "MD054"
104 }
105
106 fn description(&self) -> &'static str {
107 "Link and image style should be consistent"
108 }
109
110 fn category(&self) -> RuleCategory {
111 RuleCategory::Link
112 }
113
114 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
115 let content = ctx.content;
116 let mut warnings = Vec::new();
117
118 for link in &ctx.links {
120 if matches!(
122 link.link_type,
123 LinkType::Reference | LinkType::Collapsed | LinkType::Shortcut
124 ) && link.url.is_empty()
125 {
126 continue;
127 }
128
129 let style = match link.link_type {
130 LinkType::Autolink | LinkType::Email => "autolink",
131 LinkType::Inline => {
132 if link.text == link.url {
133 "url-inline"
134 } else {
135 "inline"
136 }
137 }
138 LinkType::Reference => "full",
139 LinkType::Collapsed => "collapsed",
140 LinkType::Shortcut => "shortcut",
141 _ => continue,
142 };
143
144 if ctx
146 .line_info(link.line)
147 .is_some_and(|info| info.in_front_matter || info.in_code_block)
148 {
149 continue;
150 }
151
152 if !self.is_style_allowed(style) {
153 let start_col = Self::byte_to_char_col(content, link.byte_offset);
154 let (end_line, _) = ctx.offset_to_line_col(link.byte_end);
155 let end_col = Self::byte_to_char_col(content, link.byte_end);
156
157 warnings.push(LintWarning {
158 rule_name: Some(self.name().to_string()),
159 line: link.line,
160 column: start_col,
161 end_line,
162 end_column: end_col,
163 message: format!("Link/image style '{style}' is not allowed"),
164 severity: Severity::Warning,
165 fix: None,
166 });
167 }
168 }
169
170 for image in &ctx.images {
172 if matches!(
174 image.link_type,
175 LinkType::Reference | LinkType::Collapsed | LinkType::Shortcut
176 ) && image.url.is_empty()
177 {
178 continue;
179 }
180
181 let style = match image.link_type {
182 LinkType::Autolink | LinkType::Email => "autolink",
183 LinkType::Inline => {
184 if image.alt_text == image.url {
185 "url-inline"
186 } else {
187 "inline"
188 }
189 }
190 LinkType::Reference => "full",
191 LinkType::Collapsed => "collapsed",
192 LinkType::Shortcut => "shortcut",
193 _ => continue,
194 };
195
196 if ctx
198 .line_info(image.line)
199 .is_some_and(|info| info.in_front_matter || info.in_code_block)
200 {
201 continue;
202 }
203
204 if !self.is_style_allowed(style) {
205 let start_col = Self::byte_to_char_col(content, image.byte_offset);
206 let (end_line, _) = ctx.offset_to_line_col(image.byte_end);
207 let end_col = Self::byte_to_char_col(content, image.byte_end);
208
209 warnings.push(LintWarning {
210 rule_name: Some(self.name().to_string()),
211 line: image.line,
212 column: start_col,
213 end_line,
214 end_column: end_col,
215 message: format!("Link/image style '{style}' is not allowed"),
216 severity: Severity::Warning,
217 fix: None,
218 });
219 }
220 }
221
222 Ok(warnings)
223 }
224
225 fn fix(&self, _ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
226 Err(LintError::FixFailed(
228 "MD054 does not support automatic fixing of link/image style consistency.".to_string(),
229 ))
230 }
231
232 fn fix_capability(&self) -> crate::rule::FixCapability {
233 crate::rule::FixCapability::Unfixable
234 }
235
236 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
237 ctx.content.is_empty() || (!ctx.likely_has_links_or_images() && !ctx.likely_has_html())
238 }
239
240 fn as_any(&self) -> &dyn std::any::Any {
241 self
242 }
243
244 fn default_config_section(&self) -> Option<(String, toml::Value)> {
245 let json_value = serde_json::to_value(&self.config).ok()?;
246 Some((
247 self.name().to_string(),
248 crate::rule_config_serde::json_to_toml_value(&json_value)?,
249 ))
250 }
251
252 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
253 where
254 Self: Sized,
255 {
256 let rule_config = crate::rule_config_serde::load_rule_config::<MD054Config>(config);
257 Box::new(Self::from_config_struct(rule_config))
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use crate::lint_context::LintContext;
265
266 #[test]
267 fn test_all_styles_allowed_by_default() {
268 let rule = MD054LinkImageStyle::new(true, true, true, true, true, true);
269 let content = "[inline](url) [ref][] [ref] <https://autolink.com> [full][ref] [url](url)\n\n[ref]: url";
270 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
271 let result = rule.check(&ctx).unwrap();
272
273 assert_eq!(result.len(), 0);
274 }
275
276 #[test]
277 fn test_only_inline_allowed() {
278 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
279 let content = "[allowed](url) [not][ref] <https://bad.com> [collapsed][] [shortcut]\n\n[ref]: url\n[shortcut]: url\n[collapsed]: url";
281 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
282 let result = rule.check(&ctx).unwrap();
283
284 assert_eq!(result.len(), 4, "Expected 4 warnings, got: {result:?}");
285 assert!(result[0].message.contains("'full'"));
286 assert!(result[1].message.contains("'autolink'"));
287 assert!(result[2].message.contains("'collapsed'"));
288 assert!(result[3].message.contains("'shortcut'"));
289 }
290
291 #[test]
292 fn test_only_autolink_allowed() {
293 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
294 let content = "<https://good.com> [bad](url) [bad][ref]\n\n[ref]: url";
295 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
296 let result = rule.check(&ctx).unwrap();
297
298 assert_eq!(result.len(), 2, "Expected 2 warnings, got: {result:?}");
299 assert!(result[0].message.contains("'inline'"));
300 assert!(result[1].message.contains("'full'"));
301 }
302
303 #[test]
304 fn test_url_inline_detection() {
305 let rule = MD054LinkImageStyle::new(false, false, false, true, false, true);
306 let content = "[https://example.com](https://example.com) [text](https://example.com)";
307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
308 let result = rule.check(&ctx).unwrap();
309
310 assert_eq!(result.len(), 0);
312 }
313
314 #[test]
315 fn test_url_inline_not_allowed() {
316 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
317 let content = "[https://example.com](https://example.com)";
318 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
319 let result = rule.check(&ctx).unwrap();
320
321 assert_eq!(result.len(), 1);
322 assert!(result[0].message.contains("'url-inline'"));
323 }
324
325 #[test]
326 fn test_shortcut_vs_full_detection() {
327 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
328 let content = "[shortcut] [full][ref]\n\n[shortcut]: url\n[ref]: url2";
329 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
330 let result = rule.check(&ctx).unwrap();
331
332 assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
334 assert!(result[0].message.contains("'shortcut'"));
335 }
336
337 #[test]
338 fn test_collapsed_reference() {
339 let rule = MD054LinkImageStyle::new(false, true, false, false, false, false);
340 let content = "[collapsed][] [bad][ref]\n\n[collapsed]: url\n[ref]: url2";
341 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
342 let result = rule.check(&ctx).unwrap();
343
344 assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
345 assert!(result[0].message.contains("'full'"));
346 }
347
348 #[test]
349 fn test_code_blocks_ignored() {
350 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
351 let content = "```\n[ignored](url) <https://ignored.com>\n```\n\n[checked](url)";
352 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
353 let result = rule.check(&ctx).unwrap();
354
355 assert_eq!(result.len(), 0);
357 }
358
359 #[test]
360 fn test_code_spans_ignored() {
361 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
362 let content = "`[ignored](url)` and `<https://ignored.com>` but [checked](url)";
363 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
364 let result = rule.check(&ctx).unwrap();
365
366 assert_eq!(result.len(), 0);
368 }
369
370 #[test]
371 fn test_reference_definitions_ignored() {
372 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
373 let content = "[ref]: https://example.com\n[ref2]: <https://example2.com>";
374 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
375 let result = rule.check(&ctx).unwrap();
376
377 assert_eq!(result.len(), 0);
379 }
380
381 #[test]
382 fn test_html_comments_ignored() {
383 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
384 let content = "<!-- [ignored](url) -->\n <!-- <https://ignored.com> -->";
385 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
386 let result = rule.check(&ctx).unwrap();
387
388 assert_eq!(result.len(), 0);
389 }
390
391 #[test]
392 fn test_unicode_support() {
393 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
394 let content = "[cafe](https://cafe.com) [emoji](url) [korean](url) [hebrew](url)";
395 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
396 let result = rule.check(&ctx).unwrap();
397
398 assert_eq!(result.len(), 0);
400 }
401
402 #[test]
403 fn test_line_positions() {
404 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
405 let content = "Line 1\n\nLine 3 with <https://bad.com> here";
406 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
407 let result = rule.check(&ctx).unwrap();
408
409 assert_eq!(result.len(), 1);
410 assert_eq!(result[0].line, 3);
411 assert_eq!(result[0].column, 13); }
413
414 #[test]
415 fn test_multiple_links_same_line() {
416 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
417 let content = "[ok](url) but <https://good.com> and [also][bad]\n\n[bad]: url";
418 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
419 let result = rule.check(&ctx).unwrap();
420
421 assert_eq!(result.len(), 2, "Expected 2 warnings, got: {result:?}");
422 assert!(result[0].message.contains("'autolink'"));
423 assert!(result[1].message.contains("'full'"));
424 }
425
426 #[test]
427 fn test_empty_content() {
428 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
429 let content = "";
430 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
431 let result = rule.check(&ctx).unwrap();
432
433 assert_eq!(result.len(), 0);
434 }
435
436 #[test]
437 fn test_no_links() {
438 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
439 let content = "Just plain text without any links";
440 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
441 let result = rule.check(&ctx).unwrap();
442
443 assert_eq!(result.len(), 0);
444 }
445
446 #[test]
447 fn test_fix_returns_error() {
448 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
449 let content = "[link](url)";
450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
451 let result = rule.fix(&ctx);
452
453 assert!(result.is_err());
454 if let Err(LintError::FixFailed(msg)) = result {
455 assert!(msg.contains("does not support automatic fixing"));
456 }
457 }
458
459 #[test]
460 fn test_priority_order() {
461 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
462 let content = "[text][ref] not detected as [shortcut]\n\n[ref]: url\n[shortcut]: url2";
464 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
465 let result = rule.check(&ctx).unwrap();
466
467 assert_eq!(result.len(), 2, "Expected 2 warnings, got: {result:?}");
468 assert!(result[0].message.contains("'full'"));
469 assert!(result[1].message.contains("'shortcut'"));
470 }
471
472 #[test]
473 fn test_not_shortcut_when_followed_by_bracket() {
474 let rule = MD054LinkImageStyle::new(false, false, false, true, true, false);
475 let content = "[text][ more text\n[text](url) is inline";
477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
478 let result = rule.check(&ctx).unwrap();
479
480 assert_eq!(result.len(), 0);
482 }
483
484 #[test]
485 fn test_cjk_correct_column_positions() {
486 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
487 let content = "日本語テスト <https://example.com>";
488 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
489 let result = rule.check(&ctx).unwrap();
490
491 assert_eq!(result.len(), 1);
492 assert!(result[0].message.contains("'autolink'"));
493 assert_eq!(
496 result[0].column, 8,
497 "Column should be 1-indexed character position of '<'"
498 );
499 }
500
501 #[test]
502 fn test_code_span_detection_with_cjk_prefix() {
503 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
504 let content = "日本語 `[link](url)` text";
506 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
507 let result = rule.check(&ctx).unwrap();
508
509 assert_eq!(result.len(), 0, "Link inside code span should not be flagged");
511 }
512
513 #[test]
514 fn test_complex_unicode_with_zwj() {
515 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
516 let content = "[family](url) [cafe](https://cafe.com)";
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);
522 }
523
524 #[test]
525 fn test_gfm_alert_not_flagged_as_shortcut() {
526 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
527 let content = "> [!NOTE]\n> This is a note.\n";
528 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
529 let result = rule.check(&ctx).unwrap();
530 assert!(
531 result.is_empty(),
532 "GFM alert should not be flagged as shortcut link, got: {result:?}"
533 );
534 }
535
536 #[test]
537 fn test_various_alert_types_not_flagged() {
538 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
539 for alert_type in ["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION", "note", "info"] {
540 let content = format!("> [!{alert_type}]\n> Content.\n");
541 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
542 let result = rule.check(&ctx).unwrap();
543 assert!(
544 result.is_empty(),
545 "Alert type {alert_type} should not be flagged, got: {result:?}"
546 );
547 }
548 }
549
550 #[test]
551 fn test_shortcut_link_still_flagged_when_disallowed() {
552 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
553 let content = "See [reference] for details.\n\n[reference]: https://example.com\n";
554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
555 let result = rule.check(&ctx).unwrap();
556 assert!(!result.is_empty(), "Regular shortcut links should still be flagged");
557 }
558
559 #[test]
560 fn test_alert_with_frontmatter_not_flagged() {
561 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
562 let content = "---\ntitle: heading\n---\n\n> [!note]\n> Content for the note.\n";
563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
564 let result = rule.check(&ctx).unwrap();
565 assert!(
566 result.is_empty(),
567 "Alert in blockquote with frontmatter should not be flagged, got: {result:?}"
568 );
569 }
570
571 #[test]
572 fn test_alert_without_blockquote_prefix_not_flagged() {
573 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
576 let content = "[!NOTE]\nSome content\n";
577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
578 let result = rule.check(&ctx).unwrap();
579 assert!(
580 result.is_empty(),
581 "[!NOTE] without blockquote prefix should not be flagged, got: {result:?}"
582 );
583 }
584
585 #[test]
586 fn test_alert_custom_types_not_flagged() {
587 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
589 for alert_type in ["bug", "example", "quote", "abstract", "todo", "faq"] {
590 let content = format!("> [!{alert_type}]\n> Content.\n");
591 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
592 let result = rule.check(&ctx).unwrap();
593 assert!(
594 result.is_empty(),
595 "Custom alert type {alert_type} should not be flagged, got: {result:?}"
596 );
597 }
598 }
599
600 #[test]
603 fn test_code_span_with_brackets_in_inline_link() {
604 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
605 let content = "Link to [`[myArray]`](#info).";
606 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
607 let result = rule.check(&ctx).unwrap();
608 assert!(
610 result.is_empty(),
611 "Code span with brackets in inline link should not be flagged, got: {result:?}"
612 );
613 }
614
615 #[test]
616 fn test_code_span_with_array_index_in_inline_link() {
617 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
618 let content = "See [`item[0]`](#info) for details.";
619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
620 let result = rule.check(&ctx).unwrap();
621 assert!(
622 result.is_empty(),
623 "Array index in code span should not be flagged, got: {result:?}"
624 );
625 }
626
627 #[test]
628 fn test_code_span_with_hash_brackets_in_inline_link() {
629 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
630 let content = r#"See [`hash["key"]`](#info) for details."#;
631 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
632 let result = rule.check(&ctx).unwrap();
633 assert!(
634 result.is_empty(),
635 "Hash access in code span should not be flagged, got: {result:?}"
636 );
637 }
638
639 #[test]
640 fn test_issue_488_full_reproduction() {
641 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
643 let content = "---\ntitle: heading\n---\n\nLink to information about [`[myArray]`](#information-on-myarray).\n\n## Information on `[myArray]`\n\nSome section content.\n";
644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
645 let result = rule.check(&ctx).unwrap();
646 assert!(
647 result.is_empty(),
648 "Issue #488 reproduction case should produce no warnings, got: {result:?}"
649 );
650 }
651
652 #[test]
653 fn test_bracket_text_without_definition_not_flagged() {
654 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
657 let content = "Some [noref] text without a definition.";
658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
659 let result = rule.check(&ctx).unwrap();
660 assert!(
661 result.is_empty(),
662 "Bracket text without definition should not be flagged as a link, got: {result:?}"
663 );
664 }
665
666 #[test]
667 fn test_array_index_notation_not_flagged() {
668 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
670 let content = "Access `arr[0]` and use [1] or [optional] in your code.";
671 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
672 let result = rule.check(&ctx).unwrap();
673 assert!(
674 result.is_empty(),
675 "Array indices and bracket text should not be flagged, got: {result:?}"
676 );
677 }
678
679 #[test]
680 fn test_real_shortcut_reference_still_flagged() {
681 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
683 let content = "See [example] for details.\n\n[example]: https://example.com\n";
684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
685 let result = rule.check(&ctx).unwrap();
686 assert_eq!(
687 result.len(),
688 1,
689 "Real shortcut reference with definition should be flagged, got: {result:?}"
690 );
691 assert!(result[0].message.contains("'shortcut'"));
692 }
693
694 #[test]
695 fn test_footnote_syntax_not_flagged_as_shortcut() {
696 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
698 let content = "See [^1] for details.";
699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700 let result = rule.check(&ctx).unwrap();
701 assert!(
702 result.is_empty(),
703 "Footnote syntax should not be flagged as shortcut, got: {result:?}"
704 );
705 }
706
707 #[test]
708 fn test_inline_link_with_code_span_detected_as_inline() {
709 let rule = MD054LinkImageStyle::new(true, true, true, false, true, true);
711 let content = "See [`[myArray]`](#info) for details.";
712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
713 let result = rule.check(&ctx).unwrap();
714 assert_eq!(
715 result.len(),
716 1,
717 "Inline link with code span should be flagged when inline is disallowed"
718 );
719 assert!(
720 result[0].message.contains("'inline'"),
721 "Should be flagged as 'inline' style, got: {}",
722 result[0].message
723 );
724 }
725
726 #[test]
727 fn test_autolink_only_document_not_skipped() {
728 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
730 let content = "Visit <https://example.com> for more info.";
731 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
732 assert!(
733 !rule.should_skip(&ctx),
734 "should_skip must return false for autolink-only documents"
735 );
736 let result = rule.check(&ctx).unwrap();
737 assert_eq!(result.len(), 1, "Autolink should be flagged when disallowed");
738 assert!(result[0].message.contains("'autolink'"));
739 }
740
741 #[test]
742 fn test_nested_image_in_link() {
743 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
745 let content = "[](https://example.com)";
746 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
747 let result = rule.check(&ctx).unwrap();
748 assert!(
750 result.len() >= 2,
751 "Nested image-in-link should detect both elements, got: {result:?}"
752 );
753 }
754
755 #[test]
756 fn test_multi_line_link() {
757 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
758 let content = "[long link\ntext](url)";
759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
760 let result = rule.check(&ctx).unwrap();
761 assert_eq!(result.len(), 1, "Multi-line inline link should be detected");
762 assert!(result[0].message.contains("'inline'"));
763 }
764
765 #[test]
766 fn test_link_with_title() {
767 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
768 let content = r#"[text](url "title")"#;
769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770 let result = rule.check(&ctx).unwrap();
771 assert_eq!(result.len(), 1, "Link with title should be detected as inline");
772 assert!(result[0].message.contains("'inline'"));
773 }
774
775 #[test]
776 fn test_empty_link_text() {
777 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
778 let content = "[](url)";
779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
780 let result = rule.check(&ctx).unwrap();
781 assert_eq!(result.len(), 1, "Empty link text should be detected");
782 assert!(result[0].message.contains("'inline'"));
783 }
784
785 #[test]
786 fn test_escaped_brackets_not_detected() {
787 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
788 let content = r"\[not a link\] and also \[not this either\]";
789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790 let result = rule.check(&ctx).unwrap();
791 assert!(
792 result.is_empty(),
793 "Escaped brackets should not be flagged, got: {result:?}"
794 );
795 }
796
797 #[test]
798 fn test_links_in_blockquotes() {
799 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
800 let content = "> [link](url) in a blockquote";
801 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
802 let result = rule.check(&ctx).unwrap();
803 assert_eq!(result.len(), 1, "Links in blockquotes should be detected");
804 assert!(result[0].message.contains("'inline'"));
805 }
806
807 #[test]
808 fn test_image_detection() {
809 let rule = MD054LinkImageStyle::new(false, false, false, false, false, false);
810 let content = "";
811 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
812 let result = rule.check(&ctx).unwrap();
813 assert_eq!(result.len(), 1, "Inline image should be detected");
814 assert!(result[0].message.contains("'inline'"));
815 }
816}