use crate::error::Result;
use crate::rule::{Rule, RuleCategory, RuleMetadata};
use crate::{
Document,
violation::{Severity, Violation},
};
pub struct MD039;
impl MD039 {
fn check_line_links(&self, line: &str, line_number: usize) -> Vec<Violation> {
let mut violations = Vec::new();
let chars: Vec<char> = line.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '[' {
if i > 0 && chars[i - 1] == '!' {
i += 1;
continue;
}
if let Some(end_bracket) = self.find_closing_bracket(&chars, i + 1) {
let link_text = &chars[i + 1..end_bracket];
let is_link = if end_bracket + 1 < chars.len() {
chars[end_bracket + 1] == '(' || chars[end_bracket + 1] == '['
} else {
false
};
if is_link && self.has_unnecessary_spaces(link_text) {
violations.push(self.create_violation(
"Spaces inside link text".to_string(),
line_number,
i + 1, Severity::Warning,
));
}
i = end_bracket + 1;
} else {
i += 1;
}
} else {
i += 1;
}
}
violations
}
fn find_closing_bracket(&self, chars: &[char], start: usize) -> Option<usize> {
let mut bracket_count = 1;
let mut i = start;
while i < chars.len() && bracket_count > 0 {
match chars[i] {
'[' => bracket_count += 1,
']' => bracket_count -= 1,
'\\' => {
i += 1;
}
_ => {}
}
if bracket_count == 0 {
return Some(i);
}
i += 1;
}
None
}
fn has_unnecessary_spaces(&self, link_text: &[char]) -> bool {
if link_text.is_empty() {
return false;
}
let has_leading_space = link_text[0].is_whitespace();
let has_trailing_space = link_text[link_text.len() - 1].is_whitespace();
has_leading_space || has_trailing_space
}
fn get_code_block_ranges(&self, lines: &[&str]) -> Vec<bool> {
let mut in_code_block = vec![false; lines.len()];
let mut in_fenced_block = false;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_fenced_block = !in_fenced_block;
in_code_block[i] = true;
continue;
}
if in_fenced_block {
in_code_block[i] = true;
continue;
}
}
in_code_block
}
}
impl Rule for MD039 {
fn id(&self) -> &'static str {
"MD039"
}
fn name(&self) -> &'static str {
"no-space-in-links"
}
fn description(&self) -> &'static str {
"Spaces inside link text"
}
fn metadata(&self) -> RuleMetadata {
RuleMetadata::stable(RuleCategory::Content).introduced_in("mdbook-lint v0.1.0")
}
fn check_with_ast<'a>(
&self,
document: &Document,
_ast: Option<&'a comrak::nodes::AstNode<'a>>,
) -> Result<Vec<Violation>> {
let mut violations = Vec::new();
let lines: Vec<&str> = document.content.lines().collect();
let in_code_block = self.get_code_block_ranges(&lines);
for (line_number, line) in lines.iter().enumerate() {
let line_number = line_number + 1;
if in_code_block[line_number - 1] {
continue;
}
violations.extend(self.check_line_links(line, line_number));
}
Ok(violations)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rule::Rule;
use std::path::PathBuf;
fn create_test_document(content: &str) -> Document {
Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
}
#[test]
fn test_md039_normal_links_valid() {
let content = r#"Here is a [normal link](http://example.com).
Another [link with text](http://example.com) works fine.
Reference link [with text][ref] is also okay.
[ref]: http://example.com
"#;
let document = create_test_document(content);
let rule = MD039;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md039_leading_space_violation() {
let content = r#"Here is a [ leading space](http://example.com) link.
Another [ spaced link](http://example.com) here.
"#;
let document = create_test_document(content);
let rule = MD039;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert_eq!(violations[0].rule_id, "MD039");
assert_eq!(violations[0].line, 1);
assert_eq!(violations[1].line, 3);
}
#[test]
fn test_md039_trailing_space_violation() {
let content = r#"Here is a [trailing space ](http://example.com) link.
Another [spaced link ](http://example.com) here.
"#;
let document = create_test_document(content);
let rule = MD039;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert_eq!(violations[0].line, 1);
assert_eq!(violations[1].line, 3);
}
#[test]
fn test_md039_both_spaces_violation() {
let content = r#"Here is a [ both spaces ](http://example.com) link.
Multiple [ spaced ](http://example.com) spaces.
"#;
let document = create_test_document(content);
let rule = MD039;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert_eq!(violations[0].line, 1);
assert_eq!(violations[1].line, 3);
}
#[test]
fn test_md039_reference_links() {
let content = r#"Good [reference link][good] here.
Bad [ spaced reference][bad] link.
Another [reference with space ][also-bad] here.
[good]: http://example.com
[bad]: http://example.com
[also-bad]: http://example.com
"#;
let document = create_test_document(content);
let rule = MD039;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert_eq!(violations[0].line, 3);
assert_eq!(violations[1].line, 5);
}
#[test]
fn test_md039_nested_brackets() {
let content = r#"This has [link with [nested] brackets](http://example.com).
This has [ link with [nested] and space](http://example.com).
"#;
let document = create_test_document(content);
let rule = MD039;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].line, 3);
}
#[test]
fn test_md039_not_links() {
let content = r#"This has [brackets] but no link.
This has [ spaced brackets] but no link.
This has [reference] but no definition.
"#;
let document = create_test_document(content);
let rule = MD039;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0); }
#[test]
fn test_md039_images_ignored() {
let content = r#"This has  which is an image.
And  text.
"#;
let document = create_test_document(content);
let rule = MD039;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0); }
#[test]
fn test_md039_code_blocks_ignored() {
let content = r#"This has [normal link](http://example.com).
```
This has [ spaced link](http://example.com) in code.
```
This has [ spaced link](http://example.com) that should be flagged.
"#;
let document = create_test_document(content);
let rule = MD039;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].line, 7);
}
#[test]
fn test_md039_escaped_brackets() {
let content = r#"This has [link with \] escaped bracket](http://example.com).
This has [ link with \] and space](http://example.com).
"#;
let document = create_test_document(content);
let rule = MD039;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].line, 3);
}
#[test]
fn test_md039_autolinks() {
let content = r#"Autolinks like <http://example.com> are not checked.
Email autolinks <user@example.com> are also not checked.
Regular [normal link](http://example.com) is fine.
Bad [ spaced link](http://example.com) is flagged.
"#;
let document = create_test_document(content);
let rule = MD039;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].line, 7);
}
#[test]
fn test_md039_empty_link_text() {
let content = r#"Empty link [](http://example.com) is not flagged for spaces.
Link with just space [ ](http://example.com) is flagged.
"#;
let document = create_test_document(content);
let rule = MD039;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].line, 3);
}
#[test]
fn test_md039_multiple_links_per_line() {
let content = r#"Multiple [good link](http://example.com) and [ bad link](http://example.com) on same line.
More [good](http://example.com) and [also good](http://example.com) links.
"#;
let document = create_test_document(content);
let rule = MD039;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].line, 1);
}
}