use crate::error::Result;
use crate::rule::{AstRule, RuleCategory, RuleMetadata};
use crate::{
Document,
violation::{Severity, Violation},
};
use comrak::nodes::AstNode;
pub struct MD011;
impl AstRule for MD011 {
fn id(&self) -> &'static str {
"MD011"
}
fn name(&self) -> &'static str {
"no-reversed-links"
}
fn description(&self) -> &'static str {
"Reversed link syntax"
}
fn metadata(&self) -> RuleMetadata {
RuleMetadata::stable(RuleCategory::Content).introduced_in("mdbook-lint v0.1.0")
}
fn check_ast<'a>(&self, document: &Document, _ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
let mut violations = Vec::new();
let mut in_code_block = false;
for (line_number, line) in document.lines.iter().enumerate() {
if line.trim_start().starts_with("```") {
in_code_block = !in_code_block;
continue;
}
if in_code_block {
continue;
}
let chars: Vec<char> = line.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '`' {
i += 1;
while i < chars.len() && chars[i] != '`' {
i += 1;
}
if i < chars.len() {
i += 1; }
continue;
}
if chars[i] == '(' {
if let Some((text, url, start_pos, end_pos)) =
self.parse_reversed_link(&chars, i)
{
violations.push(self.create_violation(
format!(
"Reversed link syntax: ({text})[{url}]. Should be: [{text}]({url})"
),
line_number + 1, start_pos + 1, Severity::Error,
));
i = end_pos;
} else {
i += 1;
}
} else {
i += 1;
}
}
}
Ok(violations)
}
}
impl MD011 {
fn parse_reversed_link(
&self,
chars: &[char],
start: usize,
) -> Option<(String, String, usize, usize)> {
if start >= chars.len() || chars[start] != '(' {
return None;
}
let mut i = start + 1;
let mut text = String::new();
while i < chars.len() && chars[i] != ')' {
text.push(chars[i]);
i += 1;
}
if i >= chars.len() || chars[i] != ')' {
return None;
}
i += 1;
if i >= chars.len() || chars[i] != '[' {
return None;
}
i += 1;
let mut url = String::new();
while i < chars.len() && chars[i] != ']' {
url.push(chars[i]);
i += 1;
}
if i >= chars.len() || chars[i] != ']' {
return None;
}
Some((text, url, start, i))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Document;
use crate::rule::Rule;
use std::path::PathBuf;
#[test]
fn test_md011_no_violations() {
let content = r#"# Valid Links
Here's a [valid link](https://example.com) that works correctly.
Another [good link](./relative/path.md) here.
[Email link](mailto:test@example.com) is also fine.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD011;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md011_reversed_link_violation() {
let content = r#"# Document with Reversed Link
This has (reversed link)[https://example.com] syntax.
Some content here.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD011;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("Reversed link syntax"));
assert!(
violations[0]
.message
.contains("(reversed link)[https://example.com]")
);
assert!(
violations[0]
.message
.contains("Should be: [reversed link](https://example.com)")
);
assert_eq!(violations[0].line, 3);
}
#[test]
fn test_md011_multiple_reversed_links() {
let content = r#"# Multiple Issues
First (bad link)[url1] here.
Second (another bad)[url2] there.
And a (third one)[url3] at the end.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD011;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 3);
assert_eq!(violations[0].line, 3);
assert!(violations[0].message.contains("bad link"));
assert!(violations[0].message.contains("url1"));
assert_eq!(violations[1].line, 5);
assert!(violations[1].message.contains("another bad"));
assert!(violations[1].message.contains("url2"));
assert_eq!(violations[2].line, 7);
assert!(violations[2].message.contains("third one"));
assert!(violations[2].message.contains("url3"));
}
#[test]
fn test_md011_mixed_valid_and_invalid() {
let content = r#"# Mixed Links
This [valid link](https://good.com) is fine.
But this (bad link)[https://bad.com] is not.
Another [good one](./path.md) here.
And another (problem)[./bad-path.md] there.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD011;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert_eq!(violations[0].line, 5);
assert_eq!(violations[1].line, 9);
}
#[test]
fn test_md011_code_blocks_ignored() {
let content = r#"# Code Examples
This (bad link)[url] should be detected.
```
This (code example)[url] should be ignored.
```
`This (inline code)[url] should be ignored.`
Another (bad link)[url2] should be detected.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD011;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert_eq!(violations[0].line, 3);
assert_eq!(violations[1].line, 11);
}
#[test]
fn test_md011_empty_text_and_url() {
let content = r#"# Edge Cases
This ()[empty text] has empty parts.
This ()[url] has empty text.
This (text)[] has empty URL.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD011;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 3);
assert!(violations[0].message.contains("Should be: [](empty text)"));
assert!(violations[1].message.contains("Should be: [](url)"));
assert!(violations[2].message.contains("Should be: [text]()"));
}
#[test]
fn test_md011_complex_urls() {
let content = r#"# Complex URLs
This (complex link)[https://example.com/path?param=value&other=test#anchor] is wrong.
This (relative link)[../parent/file.md#section] is also wrong.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD011;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert!(violations[0].message.contains("complex link"));
assert!(
violations[0]
.message
.contains("https://example.com/path?param=value&other=test#anchor")
);
assert!(violations[1].message.contains("relative link"));
assert!(violations[1].message.contains("../parent/file.md#section"));
}
}