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