1use crate::rule::{LintError, LintResult, LintWarning, Rule, Severity};
7use crate::utils::range_utils::calculate_match_range;
8use regex::Regex;
9use std::collections::BTreeSet;
10use std::sync::LazyLock;
11
12mod md054_config;
13use md054_config::MD054Config;
14
15static AUTOLINK_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<([^<>]+)>").unwrap());
17static INLINE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap());
18static SHORTCUT_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]").unwrap());
19static COLLAPSED_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\[\]").unwrap());
20static FULL_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\[([^\]]+)\]").unwrap());
21static REFERENCE_DEF_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\[([^\]]+)\]:\s+(.+)$").unwrap());
22
23#[derive(Debug, Default, Clone)]
68pub struct MD054LinkImageStyle {
69 config: MD054Config,
70}
71
72impl MD054LinkImageStyle {
73 pub fn new(autolink: bool, collapsed: bool, full: bool, inline: bool, shortcut: bool, url_inline: bool) -> Self {
74 Self {
75 config: MD054Config {
76 autolink,
77 collapsed,
78 full,
79 inline,
80 shortcut,
81 url_inline,
82 },
83 }
84 }
85
86 pub fn from_config_struct(config: MD054Config) -> Self {
87 Self { config }
88 }
89
90 fn is_style_allowed(&self, style: &str) -> bool {
92 match style {
93 "autolink" => self.config.autolink,
94 "collapsed" => self.config.collapsed,
95 "full" => self.config.full,
96 "inline" => self.config.inline,
97 "shortcut" => self.config.shortcut,
98 "url-inline" => self.config.url_inline,
99 _ => false,
100 }
101 }
102}
103
104#[derive(Debug)]
105struct LinkMatch {
106 style: &'static str,
107 start: usize,
108 end: usize,
109}
110
111impl Rule for MD054LinkImageStyle {
112 fn name(&self) -> &'static str {
113 "MD054"
114 }
115
116 fn description(&self) -> &'static str {
117 "Link and image style should be consistent"
118 }
119
120 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
121 let content = ctx.content;
122
123 if content.is_empty() {
125 return Ok(Vec::new());
126 }
127
128 if !content.contains('[') && !content.contains('<') {
130 return Ok(Vec::new());
131 }
132
133 let mut warnings = Vec::new();
134 let lines = ctx.raw_lines();
135
136 for (line_num, line) in lines.iter().enumerate() {
137 if ctx.line_info(line_num + 1).is_some_and(|info| info.in_code_block) {
139 continue;
140 }
141 if REFERENCE_DEF_RE.is_match(line) {
142 continue;
143 }
144 if line.trim_start().starts_with("<!--") {
145 continue;
146 }
147
148 if !line.contains('[') && !line.contains('<') {
150 continue;
151 }
152
153 let mut occupied_ranges = BTreeSet::new();
155 let mut filtered_matches = Vec::new();
156
157 let mut all_matches = Vec::new();
159
160 for cap in AUTOLINK_RE.captures_iter(line) {
162 let m = cap.get(0).unwrap();
163 let content = cap.get(1).unwrap().as_str();
164
165 let is_url = content.starts_with("http://")
168 || content.starts_with("https://")
169 || content.starts_with("ftp://")
170 || content.starts_with("ftps://")
171 || content.starts_with("mailto:");
172
173 if is_url {
174 all_matches.push(LinkMatch {
175 style: "autolink",
176 start: m.start(),
177 end: m.end(),
178 });
179 }
180 }
181
182 for cap in FULL_RE.captures_iter(line) {
184 let m = cap.get(0).unwrap();
185 all_matches.push(LinkMatch {
186 style: "full",
187 start: m.start(),
188 end: m.end(),
189 });
190 }
191
192 for cap in COLLAPSED_RE.captures_iter(line) {
194 let m = cap.get(0).unwrap();
195 all_matches.push(LinkMatch {
196 style: "collapsed",
197 start: m.start(),
198 end: m.end(),
199 });
200 }
201
202 for cap in INLINE_RE.captures_iter(line) {
204 let m = cap.get(0).unwrap();
205 let text = cap.get(1).unwrap().as_str();
206 let url = cap.get(2).unwrap().as_str();
207 all_matches.push(LinkMatch {
208 style: if text == url { "url-inline" } else { "inline" },
209 start: m.start(),
210 end: m.end(),
211 });
212 }
213
214 all_matches.sort_by_key(|m| m.start);
216
217 let mut last_end = 0;
219 for m in all_matches {
220 if m.start >= last_end {
221 last_end = m.end;
222 for byte_pos in m.start..m.end {
224 occupied_ranges.insert(byte_pos);
225 }
226 filtered_matches.push(m);
227 }
228 }
229
230 for cap in SHORTCUT_RE.captures_iter(line) {
233 let m = cap.get(0).unwrap();
234 let start = m.start();
235 let end = m.end();
236 let link_text = cap.get(1).unwrap().as_str();
237
238 if link_text.starts_with('!') {
241 continue;
242 }
243
244 if link_text.trim() == "" || link_text == "x" || link_text == "X" {
248 if start > 0 {
250 let before = &line[..start];
251 let trimmed_before = before.trim_start();
253 if let Some(marker_char) = trimmed_before.chars().next()
255 && (marker_char == '*' || marker_char == '-' || marker_char == '+')
256 && trimmed_before.len() > 1
257 {
258 let after_marker = &trimmed_before[1..];
259 if after_marker.chars().next().is_some_and(|c| c.is_whitespace()) {
260 continue;
262 }
263 }
264 }
265 }
266
267 let overlaps = (start..end).any(|byte_pos| occupied_ranges.contains(&byte_pos));
269
270 if !overlaps {
271 let after = &line[end..];
273 if !after.starts_with('(') && !after.starts_with('[') {
274 for byte_pos in start..end {
276 occupied_ranges.insert(byte_pos);
277 }
278 filtered_matches.push(LinkMatch {
279 style: "shortcut",
280 start,
281 end,
282 });
283 }
284 }
285 }
286
287 filtered_matches.sort_by_key(|m| m.start);
289
290 for m in filtered_matches {
292 let match_start_char = line[..m.start].chars().count();
293
294 if !ctx.is_in_code_span(line_num + 1, match_start_char + 1) && !self.is_style_allowed(m.style) {
296 let match_byte_len = m.end - m.start;
298 let (start_line, start_col, end_line, end_col) =
299 calculate_match_range(line_num + 1, line, m.start, match_byte_len);
300
301 warnings.push(LintWarning {
302 rule_name: Some(self.name().to_string()),
303 line: start_line,
304 column: start_col,
305 end_line,
306 end_column: end_col,
307 message: format!("Link/image style '{}' is not allowed", m.style),
308 severity: Severity::Warning,
309 fix: None,
310 });
311 }
312 }
313 }
314 Ok(warnings)
315 }
316
317 fn fix(&self, _ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
318 Err(LintError::FixFailed(
320 "MD054 does not support automatic fixing of link/image style consistency.".to_string(),
321 ))
322 }
323
324 fn fix_capability(&self) -> crate::rule::FixCapability {
325 crate::rule::FixCapability::Unfixable
326 }
327
328 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
329 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
330 }
331
332 fn as_any(&self) -> &dyn std::any::Any {
333 self
334 }
335
336 fn default_config_section(&self) -> Option<(String, toml::Value)> {
337 let json_value = serde_json::to_value(&self.config).ok()?;
338 Some((
339 self.name().to_string(),
340 crate::rule_config_serde::json_to_toml_value(&json_value)?,
341 ))
342 }
343
344 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
345 where
346 Self: Sized,
347 {
348 let rule_config = crate::rule_config_serde::load_rule_config::<MD054Config>(config);
349 Box::new(Self::from_config_struct(rule_config))
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356 use crate::lint_context::LintContext;
357
358 #[test]
359 fn test_all_styles_allowed_by_default() {
360 let rule = MD054LinkImageStyle::new(true, true, true, true, true, true);
361 let content = "[inline](url) [ref][] [ref] <autolink> [full][ref] [url](url)\n\n[ref]: url";
362 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
363 let result = rule.check(&ctx).unwrap();
364
365 assert_eq!(result.len(), 0);
366 }
367
368 #[test]
369 fn test_only_inline_allowed() {
370 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
371 let content = "[allowed](url) [not][ref] <https://bad.com> [bad][] [shortcut]\n\n[ref]: url\n[shortcut]: url";
372 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
373 let result = rule.check(&ctx).unwrap();
374
375 assert_eq!(result.len(), 4);
376 assert!(result[0].message.contains("'full'"));
377 assert!(result[1].message.contains("'autolink'"));
378 assert!(result[2].message.contains("'collapsed'"));
379 assert!(result[3].message.contains("'shortcut'"));
380 }
381
382 #[test]
383 fn test_only_autolink_allowed() {
384 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
385 let content = "<https://good.com> [bad](url) [bad][ref]\n\n[ref]: url";
386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
387 let result = rule.check(&ctx).unwrap();
388
389 assert_eq!(result.len(), 2);
390 assert!(result[0].message.contains("'inline'"));
391 assert!(result[1].message.contains("'full'"));
392 }
393
394 #[test]
395 fn test_url_inline_detection() {
396 let rule = MD054LinkImageStyle::new(false, false, false, true, false, true);
397 let content = "[https://example.com](https://example.com) [text](https://example.com)";
398 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
399 let result = rule.check(&ctx).unwrap();
400
401 assert_eq!(result.len(), 0);
403 }
404
405 #[test]
406 fn test_url_inline_not_allowed() {
407 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
408 let content = "[https://example.com](https://example.com)";
409 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
410 let result = rule.check(&ctx).unwrap();
411
412 assert_eq!(result.len(), 1);
413 assert!(result[0].message.contains("'url-inline'"));
414 }
415
416 #[test]
417 fn test_shortcut_vs_full_detection() {
418 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
419 let content = "[shortcut] [full][ref]\n\n[shortcut]: url\n[ref]: url2";
420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
421 let result = rule.check(&ctx).unwrap();
422
423 assert_eq!(result.len(), 1);
425 assert!(result[0].message.contains("'shortcut'"));
426 }
427
428 #[test]
429 fn test_collapsed_reference() {
430 let rule = MD054LinkImageStyle::new(false, true, false, false, false, false);
431 let content = "[collapsed][] [bad][ref]\n\n[collapsed]: url\n[ref]: url2";
432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
433 let result = rule.check(&ctx).unwrap();
434
435 assert_eq!(result.len(), 1);
436 assert!(result[0].message.contains("'full'"));
437 }
438
439 #[test]
440 fn test_code_blocks_ignored() {
441 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
442 let content = "```\n[ignored](url) <https://ignored.com>\n```\n\n[checked](url)";
443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
444 let result = rule.check(&ctx).unwrap();
445
446 assert_eq!(result.len(), 0);
448 }
449
450 #[test]
451 fn test_code_spans_ignored() {
452 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
453 let content = "`[ignored](url)` and `<https://ignored.com>` but [checked](url)";
454 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
455 let result = rule.check(&ctx).unwrap();
456
457 assert_eq!(result.len(), 0);
459 }
460
461 #[test]
462 fn test_reference_definitions_ignored() {
463 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
464 let content = "[ref]: https://example.com\n[ref2]: <https://example2.com>";
465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
466 let result = rule.check(&ctx).unwrap();
467
468 assert_eq!(result.len(), 0);
470 }
471
472 #[test]
473 fn test_html_comments_ignored() {
474 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
475 let content = "<!-- [ignored](url) -->\n <!-- <https://ignored.com> -->";
476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
477 let result = rule.check(&ctx).unwrap();
478
479 assert_eq!(result.len(), 0);
480 }
481
482 #[test]
483 fn test_unicode_support() {
484 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
485 let content = "[café ☕](https://café.com) [emoji 😀](url) [한글](url) [עברית](url)";
486 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
487 let result = rule.check(&ctx).unwrap();
488
489 assert_eq!(result.len(), 0);
491 }
492
493 #[test]
494 fn test_line_positions() {
495 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
496 let content = "Line 1\n\nLine 3 with <https://bad.com> here";
497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
498 let result = rule.check(&ctx).unwrap();
499
500 assert_eq!(result.len(), 1);
501 assert_eq!(result[0].line, 3);
502 assert_eq!(result[0].column, 13); }
504
505 #[test]
506 fn test_multiple_links_same_line() {
507 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
508 let content = "[ok](url) but <https://good.com> and [also][bad]\n\n[bad]: url";
509 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
510 let result = rule.check(&ctx).unwrap();
511
512 assert_eq!(result.len(), 2);
513 assert!(result[0].message.contains("'autolink'"));
514 assert!(result[1].message.contains("'full'"));
515 }
516
517 #[test]
518 fn test_empty_content() {
519 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
520 let content = "";
521 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
522 let result = rule.check(&ctx).unwrap();
523
524 assert_eq!(result.len(), 0);
525 }
526
527 #[test]
528 fn test_no_links() {
529 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
530 let content = "Just plain text without any links";
531 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532 let result = rule.check(&ctx).unwrap();
533
534 assert_eq!(result.len(), 0);
535 }
536
537 #[test]
538 fn test_fix_returns_error() {
539 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
540 let content = "[link](url)";
541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
542 let result = rule.fix(&ctx);
543
544 assert!(result.is_err());
545 if let Err(LintError::FixFailed(msg)) = result {
546 assert!(msg.contains("does not support automatic fixing"));
547 }
548 }
549
550 #[test]
551 fn test_priority_order() {
552 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
553 let content = "[text][ref] not detected as [shortcut]\n\n[ref]: url\n[shortcut]: url2";
555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
556 let result = rule.check(&ctx).unwrap();
557
558 assert_eq!(result.len(), 2);
559 assert!(result[0].message.contains("'full'"));
560 assert!(result[1].message.contains("'shortcut'"));
561 }
562
563 #[test]
564 fn test_not_shortcut_when_followed_by_bracket() {
565 let rule = MD054LinkImageStyle::new(false, false, false, true, true, false);
566 let content = "[text][ more text\n[text](url) is inline";
568 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
569 let result = rule.check(&ctx).unwrap();
570
571 assert_eq!(result.len(), 0);
573 }
574
575 #[test]
576 fn test_cjk_correct_column_positions() {
577 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
580 let content = "日本語テスト <https://example.com>";
582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
583 let result = rule.check(&ctx).unwrap();
584
585 assert_eq!(result.len(), 1);
586 assert!(result[0].message.contains("'autolink'"));
587 assert_eq!(
590 result[0].column, 8,
591 "Column should be 1-indexed character position of '<'"
592 );
593 }
594
595 #[test]
596 fn test_code_span_detection_with_cjk_prefix() {
597 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
600 let content = "日本語 `[link](url)` text";
602 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
603 let result = rule.check(&ctx).unwrap();
604
605 assert_eq!(result.len(), 0, "Link inside code span should not be flagged");
607 }
608
609 #[test]
610 fn test_complex_unicode_with_zwj() {
611 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
612 let content = "[👨👩👧👦 family](url) [café☕](https://café.com)";
614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
615 let result = rule.check(&ctx).unwrap();
616
617 assert_eq!(result.len(), 0);
619 }
620
621 #[test]
622 fn test_gfm_alert_not_flagged_as_shortcut() {
623 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
624 let content = "> [!NOTE]\n> This is a note.\n";
625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
626 let result = rule.check(&ctx).unwrap();
627 assert!(
628 result.is_empty(),
629 "GFM alert should not be flagged as shortcut link, got: {result:?}"
630 );
631 }
632
633 #[test]
634 fn test_various_alert_types_not_flagged() {
635 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
636 for alert_type in ["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION", "note", "info"] {
637 let content = format!("> [!{alert_type}]\n> Content.\n");
638 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
639 let result = rule.check(&ctx).unwrap();
640 assert!(
641 result.is_empty(),
642 "Alert type {alert_type} should not be flagged, got: {result:?}"
643 );
644 }
645 }
646
647 #[test]
648 fn test_shortcut_link_still_flagged_when_disallowed() {
649 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
650 let content = "See [reference] for details.\n\n[reference]: https://example.com\n";
651 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
652 let result = rule.check(&ctx).unwrap();
653 assert!(!result.is_empty(), "Regular shortcut links should still be flagged");
654 }
655
656 #[test]
657 fn test_alert_with_frontmatter_not_flagged() {
658 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
659 let content = "---\ntitle: heading\n---\n\n> [!note]\n> Content for the note.\n";
660 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
661 let result = rule.check(&ctx).unwrap();
662 assert!(
663 result.is_empty(),
664 "Alert in blockquote with frontmatter should not be flagged, got: {result:?}"
665 );
666 }
667
668 #[test]
669 fn test_alert_without_blockquote_prefix_not_flagged() {
670 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
673 let content = "[!NOTE]\nSome content\n";
674 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
675 let result = rule.check(&ctx).unwrap();
676 assert!(
677 result.is_empty(),
678 "[!NOTE] without blockquote prefix should not be flagged, got: {result:?}"
679 );
680 }
681
682 #[test]
683 fn test_alert_custom_types_not_flagged() {
684 let rule = MD054LinkImageStyle::new(true, true, true, true, false, true);
686 for alert_type in ["bug", "example", "quote", "abstract", "todo", "faq"] {
687 let content = format!("> [!{alert_type}]\n> Content.\n");
688 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
689 let result = rule.check(&ctx).unwrap();
690 assert!(
691 result.is_empty(),
692 "Custom alert type {alert_type} should not be flagged, got: {result:?}"
693 );
694 }
695 }
696}