1use crate::rule::{LintError, LintResult, LintWarning, Rule, Severity};
2use crate::rule_config_serde::RuleConfig;
3use crate::utils::document_structure::DocumentStructure;
4use crate::utils::range_utils::calculate_line_range;
5use fancy_regex::Regex as FancyRegex;
6use lazy_static::lazy_static;
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, HashSet};
10
11lazy_static! {
12 static ref SHORTCUT_REFERENCE_REGEX: FancyRegex =
21 FancyRegex::new(r"(?<!\!)\[([^\]]+)\](?!\s*[\[:])").unwrap();
22
23 static ref REFERENCE_DEFINITION_REGEX: Regex =
29 Regex::new(r"^\s*\[([^\]]+)\]:\s+(.+)$").unwrap();
30
31 static ref CONTINUATION_REGEX: Regex = Regex::new(r"^\s+(.+)$").unwrap();
33
34 static ref CODE_BLOCK_START_REGEX: Regex = Regex::new(r"^```").unwrap();
36 static ref CODE_BLOCK_END_REGEX: Regex = Regex::new(r"^```\s*$").unwrap();
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
41#[serde(rename_all = "kebab-case")]
42pub struct MD053Config {
43 #[serde(default = "default_ignored_definitions")]
45 pub ignored_definitions: Vec<String>,
46}
47
48impl Default for MD053Config {
49 fn default() -> Self {
50 Self {
51 ignored_definitions: default_ignored_definitions(),
52 }
53 }
54}
55
56fn default_ignored_definitions() -> Vec<String> {
57 Vec::new()
58}
59
60impl RuleConfig for MD053Config {
61 const RULE_NAME: &'static str = "MD053";
62}
63
64#[derive(Clone)]
116pub struct MD053LinkImageReferenceDefinitions {
117 config: MD053Config,
118}
119
120impl MD053LinkImageReferenceDefinitions {
121 pub fn new() -> Self {
123 Self {
124 config: MD053Config::default(),
125 }
126 }
127
128 pub fn from_config_struct(config: MD053Config) -> Self {
130 Self { config }
131 }
132
133 fn unescape_reference(reference: &str) -> String {
140 reference.replace("\\", "")
142 }
143
144 fn find_definitions(
148 &self,
149 ctx: &crate::lint_context::LintContext,
150 doc_structure: &DocumentStructure,
151 ) -> HashMap<String, Vec<(usize, usize)>> {
152 let mut definitions: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
153
154 for ref_def in &ctx.reference_defs {
156 let normalized_id = Self::unescape_reference(&ref_def.id); definitions
159 .entry(normalized_id)
160 .or_default()
161 .push((ref_def.line - 1, ref_def.line - 1)); }
163
164 let lines = &ctx.lines;
166 let mut i = 0;
167 while i < lines.len() {
168 let line_info = &lines[i];
169 let line = &line_info.content;
170
171 if line_info.in_code_block || doc_structure.is_in_front_matter(i + 1) {
173 i += 1;
174 continue;
175 }
176
177 if i > 0 && CONTINUATION_REGEX.is_match(line) {
179 let mut def_start = i - 1;
181 while def_start > 0 && !REFERENCE_DEFINITION_REGEX.is_match(&lines[def_start].content) {
182 def_start -= 1;
183 }
184
185 if let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(&lines[def_start].content) {
186 let ref_id = caps.get(1).unwrap().as_str().trim();
187 let normalized_id = Self::unescape_reference(ref_id).to_lowercase();
188
189 if let Some(ranges) = definitions.get_mut(&normalized_id)
191 && let Some(last_range) = ranges.last_mut()
192 && last_range.0 == def_start
193 {
194 last_range.1 = i;
195 }
196 }
197 }
198 i += 1;
199 }
200 definitions
201 }
202
203 fn find_usages(
208 &self,
209 doc_structure: &DocumentStructure,
210 ctx: &crate::lint_context::LintContext,
211 ) -> HashSet<String> {
212 let mut usages: HashSet<String> = HashSet::new();
213
214 for link in &ctx.links {
216 if link.is_reference
217 && let Some(ref_id) = &link.reference_id
218 {
219 if !doc_structure.is_in_code_block(link.line) {
221 usages.insert(Self::unescape_reference(ref_id).to_lowercase());
222 }
223 }
224 }
225
226 for image in &ctx.images {
228 if image.is_reference
229 && let Some(ref_id) = &image.reference_id
230 {
231 if !doc_structure.is_in_code_block(image.line) {
233 usages.insert(Self::unescape_reference(ref_id).to_lowercase());
234 }
235 }
236 }
237
238 let code_spans = ctx.code_spans();
242
243 for (i, line_info) in ctx.lines.iter().enumerate() {
244 let line_num = i + 1; if line_info.in_code_block || doc_structure.is_in_front_matter(line_num) {
248 continue;
249 }
250
251 for caps in SHORTCUT_REFERENCE_REGEX.captures_iter(&line_info.content).flatten() {
253 if let Some(full_match) = caps.get(0)
254 && let Some(ref_id_match) = caps.get(1)
255 {
256 let match_byte_offset = line_info.byte_offset + full_match.start();
258 let in_code_span = code_spans
259 .iter()
260 .any(|span| match_byte_offset >= span.byte_offset && match_byte_offset < span.byte_end);
261
262 if !in_code_span {
263 let ref_id = ref_id_match.as_str().trim();
264 let normalized_id = Self::unescape_reference(ref_id).to_lowercase();
265 usages.insert(normalized_id);
266 }
267 }
268 }
269 }
270
271 usages
277 }
278
279 fn get_unused_references(
286 &self,
287 definitions: &HashMap<String, Vec<(usize, usize)>>,
288 usages: &HashSet<String>,
289 ) -> Vec<(String, usize, usize)> {
290 let mut unused = Vec::new();
291 for (id, ranges) in definitions {
292 if !usages.contains(id) && !self.is_ignored_definition(id) {
294 for (start, end) in ranges {
295 unused.push((id.clone(), *start, *end));
296 }
297 }
298 }
299 unused
300 }
301
302 fn is_ignored_definition(&self, definition_id: &str) -> bool {
304 self.config
305 .ignored_definitions
306 .iter()
307 .any(|ignored| ignored.eq_ignore_ascii_case(definition_id))
308 }
309}
310
311impl Default for MD053LinkImageReferenceDefinitions {
312 fn default() -> Self {
313 Self::new()
314 }
315}
316
317impl Rule for MD053LinkImageReferenceDefinitions {
318 fn name(&self) -> &'static str {
319 "MD053"
320 }
321
322 fn description(&self) -> &'static str {
323 "Link and image reference definitions should be needed"
324 }
325
326 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
330 let content = ctx.content;
331 let doc_structure = DocumentStructure::new(content);
333
334 let definitions = self.find_definitions(ctx, &doc_structure);
336 let usages = self.find_usages(&doc_structure, ctx);
337
338 let unused_refs = self.get_unused_references(&definitions, &usages);
340
341 let mut warnings = Vec::new();
342
343 for (definition, start, _end) in unused_refs {
345 let line_num = start + 1; let line_content = ctx.lines.get(start).map(|l| l.content.as_str()).unwrap_or("");
347
348 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
350
351 warnings.push(LintWarning {
352 rule_name: Some(self.name()),
353 line: start_line,
354 column: start_col,
355 end_line,
356 end_column: end_col,
357 message: format!("Unused link/image reference: [{definition}]"),
358 severity: Severity::Warning,
359 fix: None, });
361 }
362
363 Ok(warnings)
364 }
365
366 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
368 Ok(ctx.content.to_string())
370 }
371
372 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
374 ctx.content.is_empty() || !ctx.content.contains("]:")
376 }
377
378 fn as_any(&self) -> &dyn std::any::Any {
379 self
380 }
381
382 fn default_config_section(&self) -> Option<(String, toml::Value)> {
383 let default_config = MD053Config::default();
384 let json_value = serde_json::to_value(&default_config).ok()?;
385 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
386 if let toml::Value::Table(table) = toml_value {
387 if !table.is_empty() {
388 Some((MD053Config::RULE_NAME.to_string(), toml::Value::Table(table)))
389 } else {
390 None
391 }
392 } else {
393 None
394 }
395 }
396
397 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
398 where
399 Self: Sized,
400 {
401 let rule_config = crate::rule_config_serde::load_rule_config::<MD053Config>(config);
402 Box::new(MD053LinkImageReferenceDefinitions::from_config_struct(rule_config))
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use crate::lint_context::LintContext;
410
411 #[test]
412 fn test_used_reference_link() {
413 let rule = MD053LinkImageReferenceDefinitions::new();
414 let content = "[text][ref]\n\n[ref]: https://example.com";
415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
416 let result = rule.check(&ctx).unwrap();
417
418 assert_eq!(result.len(), 0);
419 }
420
421 #[test]
422 fn test_unused_reference_definition() {
423 let rule = MD053LinkImageReferenceDefinitions::new();
424 let content = "[unused]: https://example.com";
425 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
426 let result = rule.check(&ctx).unwrap();
427
428 assert_eq!(result.len(), 1);
429 assert!(result[0].message.contains("Unused link/image reference: [unused]"));
430 }
431
432 #[test]
433 fn test_used_reference_image() {
434 let rule = MD053LinkImageReferenceDefinitions::new();
435 let content = "![alt][img]\n\n[img]: image.jpg";
436 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
437 let result = rule.check(&ctx).unwrap();
438
439 assert_eq!(result.len(), 0);
440 }
441
442 #[test]
443 fn test_case_insensitive_matching() {
444 let rule = MD053LinkImageReferenceDefinitions::new();
445 let content = "[Text][REF]\n\n[ref]: https://example.com";
446 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
447 let result = rule.check(&ctx).unwrap();
448
449 assert_eq!(result.len(), 0);
450 }
451
452 #[test]
453 fn test_shortcut_reference() {
454 let rule = MD053LinkImageReferenceDefinitions::new();
455 let content = "[ref]\n\n[ref]: https://example.com";
456 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
457 let result = rule.check(&ctx).unwrap();
458
459 assert_eq!(result.len(), 0);
460 }
461
462 #[test]
463 fn test_collapsed_reference() {
464 let rule = MD053LinkImageReferenceDefinitions::new();
465 let content = "[ref][]\n\n[ref]: https://example.com";
466 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
467 let result = rule.check(&ctx).unwrap();
468
469 assert_eq!(result.len(), 0);
470 }
471
472 #[test]
473 fn test_multiple_unused_definitions() {
474 let rule = MD053LinkImageReferenceDefinitions::new();
475 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
477 let result = rule.check(&ctx).unwrap();
478
479 assert_eq!(result.len(), 3);
480
481 let messages: Vec<String> = result.iter().map(|w| w.message.clone()).collect();
483 assert!(messages.iter().any(|m| m.contains("unused1")));
484 assert!(messages.iter().any(|m| m.contains("unused2")));
485 assert!(messages.iter().any(|m| m.contains("unused3")));
486 }
487
488 #[test]
489 fn test_mixed_used_and_unused() {
490 let rule = MD053LinkImageReferenceDefinitions::new();
491 let content = "[used]\n\n[used]: url1\n[unused]: url2";
492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
493 let result = rule.check(&ctx).unwrap();
494
495 assert_eq!(result.len(), 1);
496 assert!(result[0].message.contains("unused"));
497 }
498
499 #[test]
500 fn test_multiline_definition() {
501 let rule = MD053LinkImageReferenceDefinitions::new();
502 let content = "[ref]: https://example.com\n \"Title on next line\"";
503 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
504 let result = rule.check(&ctx).unwrap();
505
506 assert_eq!(result.len(), 1); }
508
509 #[test]
510 fn test_reference_in_code_block() {
511 let rule = MD053LinkImageReferenceDefinitions::new();
512 let content = "```\n[ref]\n```\n\n[ref]: https://example.com";
513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
514 let result = rule.check(&ctx).unwrap();
515
516 assert_eq!(result.len(), 1);
518 }
519
520 #[test]
521 fn test_reference_in_inline_code() {
522 let rule = MD053LinkImageReferenceDefinitions::new();
523 let content = "`[ref]`\n\n[ref]: https://example.com";
524 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
525 let result = rule.check(&ctx).unwrap();
526
527 assert_eq!(result.len(), 1);
529 }
530
531 #[test]
532 fn test_escaped_reference() {
533 let rule = MD053LinkImageReferenceDefinitions::new();
534 let content = "[example\\-ref]\n\n[example-ref]: https://example.com";
535 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
536 let result = rule.check(&ctx).unwrap();
537
538 assert_eq!(result.len(), 0);
540 }
541
542 #[test]
543 fn test_duplicate_definitions() {
544 let rule = MD053LinkImageReferenceDefinitions::new();
545 let content = "[ref]: url1\n[ref]: url2\n\n[ref]";
546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
547 let result = rule.check(&ctx).unwrap();
548
549 assert_eq!(result.len(), 0);
551 }
552
553 #[test]
554 fn test_fix_returns_original() {
555 let rule = MD053LinkImageReferenceDefinitions::new();
557 let content = "[used]\n\n[used]: url1\n[unused]: url2\n\nMore content";
558 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
559 let fixed = rule.fix(&ctx).unwrap();
560
561 assert_eq!(fixed, content);
562 }
563
564 #[test]
565 fn test_fix_preserves_content() {
566 let rule = MD053LinkImageReferenceDefinitions::new();
568 let content = "Content\n\n[unused]: url\n\nMore content";
569 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
570 let fixed = rule.fix(&ctx).unwrap();
571
572 assert_eq!(fixed, content);
573 }
574
575 #[test]
576 fn test_fix_does_not_remove() {
577 let rule = MD053LinkImageReferenceDefinitions::new();
579 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
581 let fixed = rule.fix(&ctx).unwrap();
582
583 assert_eq!(fixed, content);
584 }
585
586 #[test]
587 fn test_special_characters_in_reference() {
588 let rule = MD053LinkImageReferenceDefinitions::new();
589 let content = "[ref-with_special.chars]\n\n[ref-with_special.chars]: url";
590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
591 let result = rule.check(&ctx).unwrap();
592
593 assert_eq!(result.len(), 0);
594 }
595
596 #[test]
597 fn test_find_definitions() {
598 let rule = MD053LinkImageReferenceDefinitions::new();
599 let content = "[ref1]: url1\n[ref2]: url2\nSome text\n[ref3]: url3";
600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
601 let doc = DocumentStructure::new(content);
602 let defs = rule.find_definitions(&ctx, &doc);
603
604 assert_eq!(defs.len(), 3);
605 assert!(defs.contains_key("ref1"));
606 assert!(defs.contains_key("ref2"));
607 assert!(defs.contains_key("ref3"));
608 }
609
610 #[test]
611 fn test_find_usages() {
612 let rule = MD053LinkImageReferenceDefinitions::new();
613 let content = "[text][ref1] and [ref2] and ![img][ref3]";
614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
615 let doc = DocumentStructure::new(content);
616 let usages = rule.find_usages(&doc, &ctx);
617
618 assert!(usages.contains("ref1"));
619 assert!(usages.contains("ref2"));
620 assert!(usages.contains("ref3"));
621 }
622
623 #[test]
624 fn test_ignored_definitions_config() {
625 let config = MD053Config {
627 ignored_definitions: vec!["todo".to_string(), "draft".to_string()],
628 };
629 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
630
631 let content = "[todo]: https://example.com/todo\n[draft]: https://example.com/draft\n[unused]: https://example.com/unused";
632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
633 let result = rule.check(&ctx).unwrap();
634
635 assert_eq!(result.len(), 1);
637 assert!(result[0].message.contains("unused"));
638 assert!(!result[0].message.contains("todo"));
639 assert!(!result[0].message.contains("draft"));
640 }
641
642 #[test]
643 fn test_ignored_definitions_case_insensitive() {
644 let config = MD053Config {
646 ignored_definitions: vec!["TODO".to_string()],
647 };
648 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
649
650 let content = "[todo]: https://example.com/todo\n[unused]: https://example.com/unused";
651 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
652 let result = rule.check(&ctx).unwrap();
653
654 assert_eq!(result.len(), 1);
656 assert!(result[0].message.contains("unused"));
657 assert!(!result[0].message.contains("todo"));
658 }
659
660 #[test]
661 fn test_default_config_section() {
662 let rule = MD053LinkImageReferenceDefinitions::default();
663 let config_section = rule.default_config_section();
664
665 assert!(config_section.is_some());
666 let (name, value) = config_section.unwrap();
667 assert_eq!(name, "MD053");
668
669 if let toml::Value::Table(table) = value {
671 assert!(table.contains_key("ignored-definitions"));
672 assert_eq!(table["ignored-definitions"], toml::Value::Array(vec![]));
673 } else {
674 panic!("Expected TOML table");
675 }
676 }
677
678 #[test]
679 fn test_fix_with_ignored_definitions() {
680 let config = MD053Config {
682 ignored_definitions: vec!["template".to_string()],
683 };
684 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
685
686 let content = "[template]: https://example.com/template\n[unused]: https://example.com/unused\n\nSome content.";
687 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
688 let fixed = rule.fix(&ctx).unwrap();
689
690 assert_eq!(fixed, content);
692 }
693}