mdbook-lint 0.2.0

A fast markdown linter for mdBook
Documentation
//! MD011: Reversed link syntax
//!
//! This rule checks for reversed link syntax: (text)\[url\] instead of \[text\](url).

use crate::error::Result;
use crate::rule::{AstRule, RuleCategory, RuleMetadata};
use crate::{
    Document,
    violation::{Severity, Violation},
};
use comrak::nodes::AstNode;

/// Rule to check for reversed link syntax
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() {
            // Track code block state
            if line.trim_start().starts_with("```") {
                in_code_block = !in_code_block;
                continue;
            }

            // Skip lines inside code blocks
            if in_code_block {
                continue;
            }

            // Parse the line character by character looking for (text)[url] pattern
            // but skip content inside inline code spans
            let chars: Vec<char> = line.chars().collect();
            let mut i = 0;

            while i < chars.len() {
                // Skip inline code spans
                if chars[i] == '`' {
                    i += 1;
                    // Find the closing backtick
                    while i < chars.len() && chars[i] != '`' {
                        i += 1;
                    }
                    if i < chars.len() {
                        i += 1; // Skip closing backtick
                    }
                    continue;
                }

                if chars[i] == '(' {
                    // Found opening parenthesis, look for the pattern (text)[url]
                    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, // 1-based line numbers
                            start_pos + 1,   // 1-based column
                            Severity::Error,
                        ));
                        i = end_pos;
                    } else {
                        i += 1;
                    }
                } else {
                    i += 1;
                }
            }
        }

        Ok(violations)
    }
}

impl MD011 {
    /// Parse a potential reversed link starting at position `start`
    /// Returns (text, url, start_pos, end_pos) if a reversed link is found
    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();

        // Parse text inside parentheses
        while i < chars.len() && chars[i] != ')' {
            text.push(chars[i]);
            i += 1;
        }

        // Must find closing parenthesis
        if i >= chars.len() || chars[i] != ')' {
            return None;
        }
        i += 1; // Skip ')'

        // Must find opening bracket
        if i >= chars.len() || chars[i] != '[' {
            return None;
        }
        i += 1; // Skip '['

        let mut url = String::new();

        // Parse URL inside brackets
        while i < chars.len() && chars[i] != ']' {
            url.push(chars[i]);
            i += 1;
        }

        // Must find closing bracket
        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"));
    }
}