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: Vec<&str> = content.lines().collect();
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 all_matches.push(LinkMatch {
164 style: "autolink",
165 start: m.start(),
166 end: m.end(),
167 });
168 }
169
170 for cap in FULL_RE.captures_iter(line) {
172 let m = cap.get(0).unwrap();
173 all_matches.push(LinkMatch {
174 style: "full",
175 start: m.start(),
176 end: m.end(),
177 });
178 }
179
180 for cap in COLLAPSED_RE.captures_iter(line) {
182 let m = cap.get(0).unwrap();
183 all_matches.push(LinkMatch {
184 style: "collapsed",
185 start: m.start(),
186 end: m.end(),
187 });
188 }
189
190 for cap in INLINE_RE.captures_iter(line) {
192 let m = cap.get(0).unwrap();
193 let text = cap.get(1).unwrap().as_str();
194 let url = cap.get(2).unwrap().as_str();
195 all_matches.push(LinkMatch {
196 style: if text == url { "url_inline" } else { "inline" },
197 start: m.start(),
198 end: m.end(),
199 });
200 }
201
202 all_matches.sort_by_key(|m| m.start);
204
205 let mut last_end = 0;
207 for m in all_matches {
208 if m.start >= last_end {
209 last_end = m.end;
210 for byte_pos in m.start..m.end {
212 occupied_ranges.insert(byte_pos);
213 }
214 filtered_matches.push(m);
215 }
216 }
217
218 for cap in SHORTCUT_RE.captures_iter(line) {
221 let m = cap.get(0).unwrap();
222 let start = m.start();
223 let end = m.end();
224
225 let overlaps = (start..end).any(|byte_pos| occupied_ranges.contains(&byte_pos));
227
228 if !overlaps {
229 let after = &line[end..];
231 if !after.starts_with('(') && !after.starts_with('[') {
232 for byte_pos in start..end {
234 occupied_ranges.insert(byte_pos);
235 }
236 filtered_matches.push(LinkMatch {
237 style: "shortcut",
238 start,
239 end,
240 });
241 }
242 }
243 }
244
245 filtered_matches.sort_by_key(|m| m.start);
247
248 for m in filtered_matches {
250 let match_start_char = line[..m.start].chars().count();
251
252 if !ctx.is_in_code_span(line_num + 1, match_start_char) && !self.is_style_allowed(m.style) {
253 let match_len = line[m.start..m.end].chars().count();
254 let (start_line, start_col, end_line, end_col) =
255 calculate_match_range(line_num + 1, line, match_start_char, match_len);
256
257 warnings.push(LintWarning {
258 rule_name: Some(self.name().to_string()),
259 line: start_line,
260 column: start_col,
261 end_line,
262 end_column: end_col,
263 message: format!("Link/image style '{}' is not allowed", m.style),
264 severity: Severity::Warning,
265 fix: None,
266 });
267 }
268 }
269 }
270 Ok(warnings)
271 }
272
273 fn fix(&self, _ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
274 Err(LintError::FixFailed(
276 "MD054 does not support automatic fixing of link/image style consistency.".to_string(),
277 ))
278 }
279
280 fn fix_capability(&self) -> crate::rule::FixCapability {
281 crate::rule::FixCapability::Unfixable
282 }
283
284 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
285 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
286 }
287
288 fn as_any(&self) -> &dyn std::any::Any {
289 self
290 }
291
292 fn default_config_section(&self) -> Option<(String, toml::Value)> {
293 let json_value = serde_json::to_value(&self.config).ok()?;
294 Some((
295 self.name().to_string(),
296 crate::rule_config_serde::json_to_toml_value(&json_value)?,
297 ))
298 }
299
300 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
301 where
302 Self: Sized,
303 {
304 let rule_config = crate::rule_config_serde::load_rule_config::<MD054Config>(config);
305 Box::new(Self::from_config_struct(rule_config))
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use crate::lint_context::LintContext;
313
314 #[test]
315 fn test_all_styles_allowed_by_default() {
316 let rule = MD054LinkImageStyle::new(true, true, true, true, true, true);
317 let content = "[inline](url) [ref][] [ref] <autolink> [full][ref] [url](url)\n\n[ref]: url";
318 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
319 let result = rule.check(&ctx).unwrap();
320
321 assert_eq!(result.len(), 0);
322 }
323
324 #[test]
325 fn test_only_inline_allowed() {
326 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
327 let content = "[allowed](url) [not][ref] <https://bad.com> [bad][] [shortcut]\n\n[ref]: url\n[shortcut]: url";
328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
329 let result = rule.check(&ctx).unwrap();
330
331 assert_eq!(result.len(), 4);
332 assert!(result[0].message.contains("'full'"));
333 assert!(result[1].message.contains("'autolink'"));
334 assert!(result[2].message.contains("'collapsed'"));
335 assert!(result[3].message.contains("'shortcut'"));
336 }
337
338 #[test]
339 fn test_only_autolink_allowed() {
340 let rule = MD054LinkImageStyle::new(true, false, false, false, false, false);
341 let content = "<https://good.com> [bad](url) [bad][ref]\n\n[ref]: url";
342 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
343 let result = rule.check(&ctx).unwrap();
344
345 assert_eq!(result.len(), 2);
346 assert!(result[0].message.contains("'inline'"));
347 assert!(result[1].message.contains("'full'"));
348 }
349
350 #[test]
351 fn test_url_inline_detection() {
352 let rule = MD054LinkImageStyle::new(false, false, false, true, false, true);
353 let content = "[https://example.com](https://example.com) [text](https://example.com)";
354 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
355 let result = rule.check(&ctx).unwrap();
356
357 assert_eq!(result.len(), 0);
359 }
360
361 #[test]
362 fn test_url_inline_not_allowed() {
363 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
364 let content = "[https://example.com](https://example.com)";
365 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
366 let result = rule.check(&ctx).unwrap();
367
368 assert_eq!(result.len(), 1);
369 assert!(result[0].message.contains("'url_inline'"));
370 }
371
372 #[test]
373 fn test_shortcut_vs_full_detection() {
374 let rule = MD054LinkImageStyle::new(false, false, true, false, false, false);
375 let content = "[shortcut] [full][ref]\n\n[shortcut]: url\n[ref]: url2";
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);
381 assert!(result[0].message.contains("'shortcut'"));
382 }
383
384 #[test]
385 fn test_collapsed_reference() {
386 let rule = MD054LinkImageStyle::new(false, true, false, false, false, false);
387 let content = "[collapsed][] [bad][ref]\n\n[collapsed]: url\n[ref]: url2";
388 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
389 let result = rule.check(&ctx).unwrap();
390
391 assert_eq!(result.len(), 1);
392 assert!(result[0].message.contains("'full'"));
393 }
394
395 #[test]
396 fn test_code_blocks_ignored() {
397 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
398 let content = "```\n[ignored](url) <https://ignored.com>\n```\n\n[checked](url)";
399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
400 let result = rule.check(&ctx).unwrap();
401
402 assert_eq!(result.len(), 0);
404 }
405
406 #[test]
407 fn test_code_spans_ignored() {
408 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
409 let content = "`[ignored](url)` and `<https://ignored.com>` but [checked](url)";
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);
415 }
416
417 #[test]
418 fn test_reference_definitions_ignored() {
419 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
420 let content = "[ref]: https://example.com\n[ref2]: <https://example2.com>";
421 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
422 let result = rule.check(&ctx).unwrap();
423
424 assert_eq!(result.len(), 0);
426 }
427
428 #[test]
429 fn test_html_comments_ignored() {
430 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
431 let content = "<!-- [ignored](url) -->\n <!-- <https://ignored.com> -->";
432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
433 let result = rule.check(&ctx).unwrap();
434
435 assert_eq!(result.len(), 0);
436 }
437
438 #[test]
439 fn test_unicode_support() {
440 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
441 let content = "[café ☕](https://café.com) [emoji 😀](url) [한글](url) [עברית](url)";
442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
443 let result = rule.check(&ctx).unwrap();
444
445 assert_eq!(result.len(), 0);
447 }
448
449 #[test]
450 fn test_line_positions() {
451 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
452 let content = "Line 1\n\nLine 3 with <https://bad.com> here";
453 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
454 let result = rule.check(&ctx).unwrap();
455
456 assert_eq!(result.len(), 1);
457 assert_eq!(result[0].line, 3);
458 assert_eq!(result[0].column, 13); }
460
461 #[test]
462 fn test_multiple_links_same_line() {
463 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
464 let content = "[ok](url) but <bad> and [also][bad]\n\n[bad]: url";
465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
466 let result = rule.check(&ctx).unwrap();
467
468 assert_eq!(result.len(), 2);
469 assert!(result[0].message.contains("'autolink'"));
470 assert!(result[1].message.contains("'full'"));
471 }
472
473 #[test]
474 fn test_empty_content() {
475 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
476 let content = "";
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);
481 }
482
483 #[test]
484 fn test_no_links() {
485 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
486 let content = "Just plain text without any links";
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);
491 }
492
493 #[test]
494 fn test_fix_returns_error() {
495 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
496 let content = "[link](url)";
497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
498 let result = rule.fix(&ctx);
499
500 assert!(result.is_err());
501 if let Err(LintError::FixFailed(msg)) = result {
502 assert!(msg.contains("does not support automatic fixing"));
503 }
504 }
505
506 #[test]
507 fn test_priority_order() {
508 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
509 let content = "[text][ref] not detected as [shortcut]\n\n[ref]: url\n[shortcut]: url2";
511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
512 let result = rule.check(&ctx).unwrap();
513
514 assert_eq!(result.len(), 2);
515 assert!(result[0].message.contains("'full'"));
516 assert!(result[1].message.contains("'shortcut'"));
517 }
518
519 #[test]
520 fn test_not_shortcut_when_followed_by_bracket() {
521 let rule = MD054LinkImageStyle::new(false, false, false, true, true, false);
522 let content = "[text][ more text\n[text](url) is inline";
524 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
525 let result = rule.check(&ctx).unwrap();
526
527 assert_eq!(result.len(), 0);
529 }
530
531 #[test]
532 fn test_complex_unicode_with_zwj() {
533 let rule = MD054LinkImageStyle::new(false, false, false, true, false, false);
534 let content = "[👨👩👧👦 family](url) [café☕](https://café.com)";
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}