use std::rc::Rc;
use once_cell::sync::Lazy;
use regex::Regex;
use tree_sitter::Node;
use crate::{
linter::{range_from_tree_sitter, RuleViolation},
rules::{Context, Rule, RuleLinter, RuleType},
};
static RE_INLINE_LINK: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?:^|[^!])\[([^\]]*)\]\(([^)]*)\)").unwrap());
pub(crate) struct MD042Linter {
context: Rc<Context>,
violations: Vec<RuleViolation>,
}
impl RuleLinter for MD042Linter {
fn feed(&mut self, node: &Node) {
match node.kind() {
"link" => self.check_link_for_empty_destination(node),
"inline" => self.check_inline_for_links(node),
_ => {}
}
}
fn finalize(&mut self) -> Vec<RuleViolation> {
std::mem::take(&mut self.violations)
}
}
impl MD042Linter {
pub fn new(context: Rc<Context>) -> Self {
Self {
context,
violations: Vec::new(),
}
}
fn check_inline_for_links(&mut self, inline_node: &Node) {
let link_text = {
let document_content = self.context.document_content.borrow();
inline_node
.utf8_text(document_content.as_bytes())
.unwrap_or_default()
.to_string()
};
self.check_text_for_link_patterns(&link_text, inline_node);
}
fn check_text_for_link_patterns(&mut self, text: &str, node: &Node) {
for caps in RE_INLINE_LINK.captures_iter(text) {
if let Some(url_match) = caps.get(2) {
if self.is_empty_link_destination(url_match.as_str()) {
self.create_empty_link_violation(node);
}
}
}
}
fn check_link_for_empty_destination(&mut self, link_node: &Node) {
let link_text = {
let document_content = self.context.document_content.borrow();
link_node
.utf8_text(document_content.as_bytes())
.unwrap_or_default()
.to_string()
};
self.check_text_for_link_patterns(&link_text, link_node);
}
fn is_empty_link_destination(&self, url: &str) -> bool {
let trimmed = url.trim();
trimmed.is_empty() || trimmed == "#"
}
fn create_empty_link_violation(&mut self, node: &Node) {
self.violations.push(RuleViolation::new(
&MD042,
MD042.description.to_string(),
self.context.file_path.clone(),
range_from_tree_sitter(&node.range()),
));
}
}
pub const MD042: Rule = Rule {
id: "MD042",
alias: "no-empty-links",
tags: &["links"],
description: "No empty links",
rule_type: RuleType::Token,
required_nodes: &["link", "inline"], new_linter: |context| Box::new(MD042Linter::new(context)),
};
#[cfg(test)]
mod test {
use std::path::PathBuf;
use crate::config::RuleSeverity;
use crate::linter::MultiRuleLinter;
use crate::test_utils::test_helpers::test_config_with_rules;
fn test_config() -> crate::config::QuickmarkConfig {
test_config_with_rules(vec![
("no-empty-links", RuleSeverity::Error),
("heading-style", RuleSeverity::Off),
("heading-increment", RuleSeverity::Off),
("line-length", RuleSeverity::Off),
])
}
#[test]
fn test_valid_link() {
let input = "[link text](https://example.com)";
let config = test_config();
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_empty_link_url() {
let input = "[empty link]()";
let config = test_config();
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(1, violations.len());
let violation = &violations[0];
assert_eq!("MD042", violation.rule().id);
assert_eq!("No empty links", violation.message());
}
#[test]
fn test_fragment_only_link() {
let input = "[fragment only](#)";
let config = test_config();
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(1, violations.len());
let violation = &violations[0];
assert_eq!("MD042", violation.rule().id);
assert_eq!("No empty links", violation.message());
}
#[test]
fn test_valid_fragment_link() {
let input = "[section link](#section)";
let config = test_config();
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_empty_reference_link() {
let input = "[link text][]";
let config = test_config();
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_image_not_affected() {
let input = "![image alt]()";
let config = test_config();
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_multiple_links_with_one_empty() {
let input = "[good link](https://example.com) and [empty link]() and [another good](https://other.com)";
let config = test_config();
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(1, violations.len());
let violation = &violations[0];
assert_eq!("MD042", violation.rule().id);
}
#[test]
fn test_mixed_empty_links() {
let input = "[empty1]() and [fragment](#) and [valid](https://example.com)";
let config = test_config();
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(2, violations.len());
for violation in &violations {
assert_eq!("MD042", violation.rule().id);
}
}
#[test]
fn test_sequential_links_bug_prevention() {
let input = "[link1](https://example.com)\n[link2]()\n[link3](https://example.com)\n[link4](https://example.com)";
let config = test_config();
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(1, violations.len());
let violation = &violations[0];
assert_eq!("MD042", violation.rule().id);
}
#[test]
fn test_footnote_style_empty_links() {
let input = "[^gh-md]: <> \"Like here on GitHub.\"";
let config = test_config();
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
println!("Footnote test violations: {}", violations.len());
for violation in &violations {
println!(" {}: {}", violation.rule().id, violation.message());
}
}
#[test]
fn test_empty_link_with_title() {
let input = "[link text]( \"title\")";
let config = test_config();
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_fragment_with_content() {
let input = "[section](#introduction)";
let config = test_config();
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_whitespace_only_urls() {
let input = "[empty]( ) and [tabs](\t) and [newline](\n)";
let config = test_config();
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(3, violations.len());
for violation in &violations {
assert_eq!("MD042", violation.rule().id);
}
}
}