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());
static RE_REF_LINK: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?:^|[^!])\[([^\]]*)\]\[([^\]]+)\]").unwrap());
static RE_COLLAPSED_REF_LINK: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?:^|[^!])\[([^\]]+)\]\[\]").unwrap());
pub(crate) struct MD039Linter {
context: Rc<Context>,
violations: Vec<RuleViolation>,
}
impl MD039Linter {
pub fn new(context: Rc<Context>) -> Self {
Self {
context,
violations: Vec::new(),
}
}
}
impl RuleLinter for MD039Linter {
fn feed(&mut self, node: &Node) {
if node.kind() == "link" {
self.check_link_for_spaces(node);
} else if node.kind() == "inline" {
self.check_inline_for_links(node);
}
}
fn finalize(&mut self) -> Vec<RuleViolation> {
std::mem::take(&mut self.violations)
}
}
impl MD039Linter {
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("")
.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(label_match) = caps.get(1) {
let label_text = label_match.as_str();
self.check_label_for_spaces(label_text, node);
}
}
for caps in RE_REF_LINK.captures_iter(text) {
if let Some(label_match) = caps.get(1) {
let label_text = label_match.as_str();
self.check_label_for_spaces(label_text, node);
}
}
for caps in RE_COLLAPSED_REF_LINK.captures_iter(text) {
if let Some(label_match) = caps.get(1) {
let label_text = label_match.as_str();
self.check_label_for_spaces(label_text, node);
}
}
}
fn check_label_for_spaces(&mut self, label_text: &str, node: &Node) {
if label_text.len() != label_text.trim_start().len() {
self.create_space_violation(node, true);
}
if label_text.len() != label_text.trim_end().len() {
self.create_space_violation(node, false);
}
}
fn check_link_for_spaces(&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("")
.to_string()
};
if let Some(bracket_start) = link_text.find('[') {
if let Some(bracket_end) = link_text.find(']') {
if bracket_end > bracket_start {
let label_text = &link_text[bracket_start + 1..bracket_end];
if label_text.len() != label_text.trim_start().len() {
self.create_space_violation(link_node, true);
}
if label_text.len() != label_text.trim_end().len() {
self.create_space_violation(link_node, false);
}
}
}
}
}
fn create_space_violation(&mut self, node: &Node, is_leading: bool) {
let space_type = if is_leading { "leading" } else { "trailing" };
let message = format!("Spaces inside link text ({space_type})");
self.violations.push(RuleViolation::new(
&MD039,
message,
self.context.file_path.clone(),
range_from_tree_sitter(&node.range()),
));
}
}
pub const MD039: Rule = Rule {
id: "MD039",
alias: "no-space-in-links",
tags: &["whitespace", "links"],
description: "Spaces inside link text",
rule_type: RuleType::Token,
required_nodes: &["link", "inline"], new_linter: |context| Box::new(MD039Linter::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-space-in-links", RuleSeverity::Error),
("heading-style", RuleSeverity::Off),
("heading-increment", RuleSeverity::Off),
("line-length", RuleSeverity::Off),
])
}
#[test]
fn test_no_spaces_in_link_text() {
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_leading_space_in_link_text() {
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!(1, violations.len());
let violation = &violations[0];
assert_eq!("MD039", violation.rule().id);
assert!(violation.message().contains("Spaces inside link text"));
}
#[test]
fn test_trailing_space_in_link_text() {
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!(1, violations.len());
let violation = &violations[0];
assert_eq!("MD039", violation.rule().id);
assert!(violation.message().contains("Spaces inside link text"));
}
#[test]
fn test_both_leading_and_trailing_spaces() {
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!(2, violations.len());
for violation in &violations {
assert_eq!("MD039", violation.rule().id);
assert!(violation.message().contains("Spaces inside link text"));
}
}
#[test]
fn test_reference_link_with_spaces() {
let input = "[ link text ][ref]\n\n[ref]: 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!("MD039", violation.rule().id);
}
}
#[test]
fn test_shortcut_reference_link_with_spaces() {
let input = "[ link text ][]\n\n[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!(2, violations.len());
for violation in &violations {
assert_eq!("MD039", violation.rule().id);
}
}
#[test]
fn test_image_not_affected() {
let input = "";
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_text_with_spaces() {
let input = "[ ](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!("MD039", violation.rule().id);
}
}
#[test]
fn test_multiple_links() {
let input = "[good link](url1) and [ bad link ](url2) and [another good](url3)";
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!("MD039", violation.rule().id);
}
}
}