use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::rule_config_serde::RuleConfig;
use crate::utils::range_utils::calculate_line_range;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::sync::LazyLock;
static SHORTCUT_REFERENCE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]").unwrap());
static REFERENCE_DEFINITION_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\s*\[([^\]]+)\]:\s+(.+)$").unwrap());
static CONTINUATION_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s+(.+)$").unwrap());
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub struct MD053Config {
#[serde(default = "default_ignored_definitions")]
pub ignored_definitions: Vec<String>,
}
impl Default for MD053Config {
fn default() -> Self {
Self {
ignored_definitions: default_ignored_definitions(),
}
}
}
fn default_ignored_definitions() -> Vec<String> {
Vec::new()
}
impl RuleConfig for MD053Config {
const RULE_NAME: &'static str = "MD053";
}
#[derive(Clone)]
pub struct MD053LinkImageReferenceDefinitions {
config: MD053Config,
}
impl MD053LinkImageReferenceDefinitions {
pub fn new() -> Self {
Self {
config: MD053Config::default(),
}
}
pub fn from_config_struct(config: MD053Config) -> Self {
Self { config }
}
fn should_skip_pattern(text: &str) -> bool {
if text.contains(':') && text.chars().all(|c| c.is_ascii_digit() || c == ':') {
return true;
}
if text == "*" || text == "..." || text == "**" {
return true;
}
if text.chars().all(|c| !c.is_alphanumeric() && c != ' ') {
return true;
}
if text.len() <= 2 && !text.chars().all(|c| c.is_alphanumeric()) {
return true;
}
if text.contains(':') && text.contains(' ') && !text.contains('`') {
if let Some((before_colon, _)) = text.split_once(':') {
let before_trimmed = before_colon.trim();
let word_count = before_trimmed.split_whitespace().count();
if word_count >= 3 {
return true;
}
}
}
if text.starts_with('!') {
return true;
}
false
}
fn unescape_reference(reference: &str) -> String {
reference.replace("\\", "")
}
fn is_likely_comment_reference(ref_id: &str, url: &str) -> bool {
const COMMENT_LABELS: &[&str] = &[
"//", "comment", "note", "todo", "fixme", "hack", ];
let normalized_id = ref_id.trim().to_lowercase();
let normalized_url = url.trim();
if COMMENT_LABELS.contains(&normalized_id.as_str()) && normalized_url.starts_with('#') {
return true;
}
if normalized_url == "#" {
return true;
}
false
}
fn find_definitions(&self, ctx: &crate::lint_context::LintContext) -> HashMap<String, Vec<(usize, usize)>> {
let mut definitions: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
for ref_def in &ctx.reference_defs {
if Self::is_likely_comment_reference(&ref_def.id, &ref_def.url) {
continue;
}
let normalized_id = Self::unescape_reference(&ref_def.id); definitions
.entry(normalized_id)
.or_default()
.push((ref_def.line - 1, ref_def.line - 1)); }
let lines = &ctx.lines;
let mut last_def_line: Option<usize> = None;
let mut last_def_id: Option<String> = None;
for (i, line_info) in lines.iter().enumerate() {
if line_info.in_code_block || line_info.in_front_matter {
last_def_line = None;
last_def_id = None;
continue;
}
let line = line_info.content(ctx.content);
if let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(line) {
let ref_id = caps.get(1).unwrap().as_str().trim();
let normalized_id = Self::unescape_reference(ref_id).to_lowercase();
last_def_line = Some(i);
last_def_id = Some(normalized_id);
} else if let Some(def_start) = last_def_line
&& let Some(ref def_id) = last_def_id
&& CONTINUATION_REGEX.is_match(line)
{
if let Some(ranges) = definitions.get_mut(def_id.as_str())
&& let Some(last_range) = ranges.last_mut()
&& last_range.0 == def_start
{
last_range.1 = i;
}
} else {
last_def_line = None;
last_def_id = None;
}
}
definitions
}
fn find_usages(&self, ctx: &crate::lint_context::LintContext) -> HashSet<String> {
let mut usages: HashSet<String> = HashSet::new();
for link in &ctx.links {
if link.is_reference
&& let Some(ref_id) = &link.reference_id
&& !ctx.line_info(link.line).is_some_and(|info| info.in_code_block)
{
usages.insert(Self::unescape_reference(ref_id).to_lowercase());
}
}
for image in &ctx.images {
if image.is_reference
&& let Some(ref_id) = &image.reference_id
&& !ctx.line_info(image.line).is_some_and(|info| info.in_code_block)
{
usages.insert(Self::unescape_reference(ref_id).to_lowercase());
}
}
for footnote_ref in &ctx.footnote_refs {
if !ctx.line_info(footnote_ref.line).is_some_and(|info| info.in_code_block) {
let ref_id = format!("^{}", footnote_ref.id);
usages.insert(ref_id.to_lowercase());
}
}
let code_spans = ctx.code_spans();
let mut span_ranges: Vec<(usize, usize)> = code_spans
.iter()
.map(|span| (span.byte_offset, span.byte_end))
.collect();
span_ranges.sort_unstable_by_key(|&(start, _)| start);
for line_info in ctx.lines.iter() {
if line_info.in_code_block || line_info.in_front_matter {
continue;
}
let line_content = line_info.content(ctx.content);
if !line_content.contains('[') {
continue;
}
if REFERENCE_DEFINITION_REGEX.is_match(line_content) {
continue;
}
for caps in SHORTCUT_REFERENCE_REGEX.captures_iter(line_content) {
if let Some(full_match) = caps.get(0)
&& let Some(ref_id_match) = caps.get(1)
{
let match_start = full_match.start();
if match_start > 0 && line_content.as_bytes()[match_start - 1] == b'!' {
continue;
}
let match_end = full_match.end();
if match_end < line_content.len() && line_content.as_bytes()[match_end] == b'[' {
continue;
}
let match_byte_offset = line_info.byte_offset + match_start;
let in_code_span = span_ranges
.binary_search_by(|&(start, end)| {
if match_byte_offset < start {
std::cmp::Ordering::Greater
} else if match_byte_offset >= end {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Equal
}
})
.is_ok();
if !in_code_span {
let ref_id = ref_id_match.as_str().trim();
if !Self::should_skip_pattern(ref_id) {
let normalized_id = Self::unescape_reference(ref_id).to_lowercase();
usages.insert(normalized_id);
}
}
}
}
}
usages
}
fn get_unused_references(
&self,
definitions: &HashMap<String, Vec<(usize, usize)>>,
usages: &HashSet<String>,
) -> Vec<(String, usize, usize)> {
let mut unused = Vec::new();
for (id, ranges) in definitions {
if !usages.contains(id) && !self.is_ignored_definition(id) {
if ranges.len() == 1 {
let (start, end) = ranges[0];
unused.push((id.clone(), start, end));
}
}
}
unused
}
fn is_ignored_definition(&self, definition_id: &str) -> bool {
self.config
.ignored_definitions
.iter()
.any(|ignored| ignored.eq_ignore_ascii_case(definition_id))
}
}
impl Default for MD053LinkImageReferenceDefinitions {
fn default() -> Self {
Self::new()
}
}
impl Rule for MD053LinkImageReferenceDefinitions {
fn name(&self) -> &'static str {
"MD053"
}
fn description(&self) -> &'static str {
"Link and image reference definitions should be needed"
}
fn category(&self) -> RuleCategory {
RuleCategory::Link
}
fn fix_capability(&self) -> FixCapability {
FixCapability::Unfixable
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let definitions = self.find_definitions(ctx);
let usages = self.find_usages(ctx);
let unused_refs = self.get_unused_references(&definitions, &usages);
let mut warnings = Vec::new();
let mut seen_definitions: HashMap<String, (String, usize)> = HashMap::new();
for (definition_id, ranges) in &definitions {
if self.is_ignored_definition(definition_id) {
continue;
}
if ranges.len() > 1 {
for (i, &(start_line, _)) in ranges.iter().enumerate() {
if i > 0 {
let line_num = start_line + 1;
let line_content = ctx.lines.get(start_line).map(|l| l.content(ctx.content)).unwrap_or("");
let (start_line_1idx, start_col, end_line, end_col) =
calculate_line_range(line_num, line_content);
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: start_line_1idx,
column: start_col,
end_line,
end_column: end_col,
message: format!("Duplicate link or image reference definition: [{definition_id}]"),
severity: Severity::Warning,
fix: None,
});
}
}
}
if let Some(&(start_line, _)) = ranges.first() {
if let Some(line_info) = ctx.lines.get(start_line)
&& let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(line_info.content(ctx.content))
{
let original_id = caps.get(1).unwrap().as_str().trim();
let lower_id = original_id.to_lowercase();
if let Some((first_original, first_line)) = seen_definitions.get(&lower_id) {
if first_original != original_id {
let line_num = start_line + 1;
let line_content = line_info.content(ctx.content);
let (start_line_1idx, start_col, end_line, end_col) =
calculate_line_range(line_num, line_content);
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: start_line_1idx,
column: start_col,
end_line,
end_column: end_col,
message: format!("Duplicate link or image reference definition: [{}] (conflicts with [{}] on line {})",
original_id, first_original, first_line + 1),
severity: Severity::Warning,
fix: None,
});
}
} else {
seen_definitions.insert(lower_id, (original_id.to_string(), start_line));
}
}
}
}
for (definition, start, _end) in unused_refs {
let line_num = start + 1; let line_content = ctx.lines.get(start).map(|l| l.content(ctx.content)).unwrap_or("");
let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
message: format!("Unused link/image reference: [{definition}]"),
severity: Severity::Warning,
fix: None, });
}
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
Ok(ctx.content.to_string())
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
ctx.content.is_empty() || !ctx.likely_has_links_or_images()
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn default_config_section(&self) -> Option<(String, toml::Value)> {
let default_config = MD053Config::default();
let json_value = serde_json::to_value(&default_config).ok()?;
let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
if let toml::Value::Table(table) = toml_value {
if !table.is_empty() {
Some((MD053Config::RULE_NAME.to_string(), toml::Value::Table(table)))
} else {
None
}
} else {
None
}
}
fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
let rule_config = crate::rule_config_serde::load_rule_config::<MD053Config>(config);
Box::new(MD053LinkImageReferenceDefinitions::from_config_struct(rule_config))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
#[test]
fn test_used_reference_link() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[text][ref]\n\n[ref]: https://example.com";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_unused_reference_definition() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[unused]: https://example.com";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("Unused link/image reference: [unused]"));
}
#[test]
fn test_used_reference_image() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "![alt][img]\n\n[img]: image.jpg";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_case_insensitive_matching() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[Text][REF]\n\n[ref]: https://example.com";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_shortcut_reference() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[ref]\n\n[ref]: https://example.com";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_collapsed_reference() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[ref][]\n\n[ref]: https://example.com";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_multiple_unused_definitions() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 3);
let messages: Vec<String> = result.iter().map(|w| w.message.clone()).collect();
assert!(messages.iter().any(|m| m.contains("unused1")));
assert!(messages.iter().any(|m| m.contains("unused2")));
assert!(messages.iter().any(|m| m.contains("unused3")));
}
#[test]
fn test_mixed_used_and_unused() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[used]\n\n[used]: url1\n[unused]: url2";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("unused"));
}
#[test]
fn test_multiline_definition() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[ref]: https://example.com\n \"Title on next line\"";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1); }
#[test]
fn test_reference_in_code_block() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "```\n[ref]\n```\n\n[ref]: https://example.com";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_reference_in_inline_code() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "`[ref]`\n\n[ref]: https://example.com";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_escaped_reference() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[example\\-ref]\n\n[example-ref]: https://example.com";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_duplicate_definitions() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[ref]: url1\n[ref]: url2\n\n[ref]";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_fix_returns_original() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[used]\n\n[used]: url1\n[unused]: url2\n\nMore content";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content);
}
#[test]
fn test_fix_preserves_content() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "Content\n\n[unused]: url\n\nMore content";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content);
}
#[test]
fn test_fix_does_not_remove() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content);
}
#[test]
fn test_special_characters_in_reference() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[ref-with_special.chars]\n\n[ref-with_special.chars]: url";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_find_definitions() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[ref1]: url1\n[ref2]: url2\nSome text\n[ref3]: url3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let defs = rule.find_definitions(&ctx);
assert_eq!(defs.len(), 3);
assert!(defs.contains_key("ref1"));
assert!(defs.contains_key("ref2"));
assert!(defs.contains_key("ref3"));
}
#[test]
fn test_find_usages() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[text][ref1] and [ref2] and ![img][ref3]";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let usages = rule.find_usages(&ctx);
assert!(usages.contains("ref1"));
assert!(usages.contains("ref2"));
assert!(usages.contains("ref3"));
}
#[test]
fn test_ignored_definitions_config() {
let config = MD053Config {
ignored_definitions: vec!["todo".to_string(), "draft".to_string()],
};
let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
let content = "[todo]: https://example.com/todo\n[draft]: https://example.com/draft\n[unused]: https://example.com/unused";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("unused"));
assert!(!result[0].message.contains("todo"));
assert!(!result[0].message.contains("draft"));
}
#[test]
fn test_ignored_definitions_case_insensitive() {
let config = MD053Config {
ignored_definitions: vec!["TODO".to_string()],
};
let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
let content = "[todo]: https://example.com/todo\n[unused]: https://example.com/unused";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("unused"));
assert!(!result[0].message.contains("todo"));
}
#[test]
fn test_default_config_section() {
let rule = MD053LinkImageReferenceDefinitions::default();
let config_section = rule.default_config_section();
assert!(config_section.is_some());
let (name, value) = config_section.unwrap();
assert_eq!(name, "MD053");
if let toml::Value::Table(table) = value {
assert!(table.contains_key("ignored-definitions"));
assert_eq!(table["ignored-definitions"], toml::Value::Array(vec![]));
} else {
panic!("Expected TOML table");
}
}
#[test]
fn test_fix_with_ignored_definitions() {
let config = MD053Config {
ignored_definitions: vec!["template".to_string()],
};
let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
let content = "[template]: https://example.com/template\n[unused]: https://example.com/unused\n\nSome content.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content);
}
#[test]
fn test_duplicate_definitions_exact_case() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[ref]: url1\n[ref]: url2\n[ref]: url3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
assert_eq!(duplicate_warnings.len(), 2);
assert_eq!(duplicate_warnings[0].line, 2);
assert_eq!(duplicate_warnings[1].line, 3);
}
#[test]
fn test_duplicate_definitions_case_variants() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content =
"[method resolution order]: url1\n[Method Resolution Order]: url2\n[METHOD RESOLUTION ORDER]: url3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
assert_eq!(duplicate_warnings.len(), 2);
assert_eq!(duplicate_warnings[0].line, 2);
assert_eq!(duplicate_warnings[1].line, 3);
}
#[test]
fn test_duplicate_and_unused() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[used]\n[used]: url1\n[used]: url2\n[unused]: url3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
assert_eq!(duplicate_warnings.len(), 1);
assert_eq!(unused_warnings.len(), 1);
assert_eq!(duplicate_warnings[0].line, 3); assert_eq!(unused_warnings[0].line, 4); }
#[test]
fn test_duplicate_with_usage() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[ref]\n\n[ref]: url1\n[ref]: url2";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
assert_eq!(duplicate_warnings.len(), 1);
assert_eq!(unused_warnings.len(), 0);
assert_eq!(duplicate_warnings[0].line, 4);
}
#[test]
fn test_no_duplicate_different_ids() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[ref1]: url1\n[ref2]: url2\n[ref3]: url3";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
assert_eq!(duplicate_warnings.len(), 0);
}
#[test]
fn test_comment_style_reference_double_slash() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[//]: # (This is a comment)\n\nSome regular text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0, "Comment-style reference [//]: # should not be flagged");
}
#[test]
fn test_comment_style_reference_comment_label() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[comment]: # (This is a semantic comment)\n\n[note]: # (This is a note)";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0, "Comment-style references should not be flagged");
}
#[test]
fn test_comment_style_reference_todo_fixme() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[todo]: # (Add more examples)\n[fixme]: # (Fix this later)\n[hack]: # (Temporary workaround)";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0, "TODO/FIXME comment patterns should not be flagged");
}
#[test]
fn test_comment_style_reference_fragment_only() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[anything]: #\n[ref]: #\n\nSome text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0, "References with just '#' URL should not be flagged");
}
#[test]
fn test_comment_vs_real_reference() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[//]: # (This is a comment)\n[real-ref]: https://example.com\n\nSome text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Only real unused references should be flagged");
assert!(result[0].message.contains("real-ref"), "Should flag the real reference");
}
#[test]
fn test_comment_with_fragment_section() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "[//]: #section (Comment about section)\n\nSome text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0, "Comment with fragment section should not be flagged");
}
#[test]
fn test_is_likely_comment_reference_helper() {
assert!(
MD053LinkImageReferenceDefinitions::is_likely_comment_reference("//", "#"),
"[//]: # should be recognized as comment"
);
assert!(
MD053LinkImageReferenceDefinitions::is_likely_comment_reference("comment", "#section"),
"[comment]: #section should be recognized as comment"
);
assert!(
MD053LinkImageReferenceDefinitions::is_likely_comment_reference("note", "#"),
"[note]: # should be recognized as comment"
);
assert!(
MD053LinkImageReferenceDefinitions::is_likely_comment_reference("todo", "#"),
"[todo]: # should be recognized as comment"
);
assert!(
MD053LinkImageReferenceDefinitions::is_likely_comment_reference("anything", "#"),
"Any label with just '#' should be recognized as comment"
);
assert!(
!MD053LinkImageReferenceDefinitions::is_likely_comment_reference("ref", "https://example.com"),
"Real URL should not be recognized as comment"
);
assert!(
!MD053LinkImageReferenceDefinitions::is_likely_comment_reference("link", "http://test.com"),
"Real URL should not be recognized as comment"
);
}
#[test]
fn test_reference_with_colon_in_name() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "Check [RFC: 1234] for specs.\n\n[RFC: 1234]: https://example.com\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Reference with colon should be recognized as used, got warnings: {result:?}"
);
}
#[test]
fn test_reference_with_colon_various_styles() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = r#"See [RFC: 1234] and [Issue: 42] and [PR: 100].
[RFC: 1234]: https://example.com/rfc1234
[Issue: 42]: https://example.com/issue42
[PR: 100]: https://example.com/pr100
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"All colon-style references should be recognized as used, got warnings: {result:?}"
);
}
#[test]
fn test_should_skip_pattern_allows_rfc_style() {
assert!(
!MD053LinkImageReferenceDefinitions::should_skip_pattern("RFC: 1234"),
"RFC-style references should NOT be skipped"
);
assert!(
!MD053LinkImageReferenceDefinitions::should_skip_pattern("Issue: 42"),
"Issue-style references should NOT be skipped"
);
assert!(
!MD053LinkImageReferenceDefinitions::should_skip_pattern("PR: 100"),
"PR-style references should NOT be skipped"
);
assert!(
!MD053LinkImageReferenceDefinitions::should_skip_pattern("See: Section 2"),
"References with 'See:' should NOT be skipped"
);
assert!(
!MD053LinkImageReferenceDefinitions::should_skip_pattern("foo:bar"),
"References without space after colon should NOT be skipped"
);
}
#[test]
fn test_should_skip_pattern_skips_prose() {
assert!(
MD053LinkImageReferenceDefinitions::should_skip_pattern("default value is: something"),
"Prose with 3+ words before colon SHOULD be skipped"
);
assert!(
MD053LinkImageReferenceDefinitions::should_skip_pattern("this is a label: description"),
"Prose with 4 words before colon SHOULD be skipped"
);
assert!(
MD053LinkImageReferenceDefinitions::should_skip_pattern("the project root: path/to/dir"),
"Prose-like descriptions SHOULD be skipped"
);
}
#[test]
fn test_many_code_spans_with_shortcut_references() {
let rule = MD053LinkImageReferenceDefinitions::new();
let mut lines = Vec::new();
for i in 0..100 {
lines.push(format!("Some `code{i}` text and [used_ref] here"));
}
lines.push(String::new());
lines.push("[used_ref]: https://example.com".to_string());
lines.push("[unused_ref]: https://unused.com".to_string());
let content = lines.join("\n");
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("unused_ref"));
}
#[test]
fn test_multiline_definition_continuation_tracking() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "\
[ref1]: https://example.com
\"Title on next line\"
[ref2]: https://example2.com
\"Another title\"
Some text using [ref1] here.
";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("ref2"));
}
#[test]
fn test_code_span_at_boundary_does_not_hide_reference() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "`code`[ref]\n\n[ref]: https://example.com";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_reference_inside_code_span_not_counted() {
let rule = MD053LinkImageReferenceDefinitions::new();
let content = "Use `[ref]` in code\n\n[ref]: https://example.com";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_shortcut_ref_at_byte_zero() {
let rule = MD053LinkImageReferenceDefinitions::default();
let content = "[example]\n\n[example]: https://example.com\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"[ref] at byte 0 should be recognized as usage: {result:?}"
);
}
#[test]
fn test_shortcut_ref_at_end_of_line() {
let rule = MD053LinkImageReferenceDefinitions::default();
let content = "Text [example]\n\n[example]: https://example.com\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"[ref] at end of line should be recognized as usage: {result:?}"
);
}
}