Skip to main content

mdlint/lint/rules/
md044.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::Violation;
4use regex::Regex;
5use serde_json::Value;
6
7pub struct MD044;
8
9impl Rule for MD044 {
10    fn name(&self) -> &'static str {
11        "MD044"
12    }
13
14    fn description(&self) -> &'static str {
15        "Proper names should have the correct capitalization"
16    }
17
18    fn tags(&self) -> &[&str] {
19        &["spelling"]
20    }
21
22    fn check(&self, parser: &MarkdownParser, config: Option<&Value>) -> Vec<Violation> {
23        let names = config
24            .and_then(|c| c.get("names"))
25            .and_then(|v| v.as_array())
26            .map(|arr| {
27                arr.iter()
28                    .filter_map(|v| v.as_str().map(str::to_owned))
29                    .collect::<Vec<_>>()
30            });
31
32        let code_blocks = config
33            .and_then(|c| c.get("code_blocks"))
34            .and_then(serde_json::Value::as_bool)
35            .unwrap_or(true);
36
37        // If no names are specified, skip check
38        let proper_names = match names {
39            Some(n) if !n.is_empty() => n,
40            _ => return Vec::new(),
41        };
42
43        let mut violations = Vec::new();
44
45        for (line_num, line) in parser.lines().iter().enumerate() {
46            let line_number = line_num + 1;
47
48            // Skip code blocks if configured
49            if !code_blocks
50                && (line.starts_with("    ") || line.starts_with('\t') || line.contains("```"))
51            {
52                continue;
53            }
54
55            // Check each proper name
56            for name in &proper_names {
57                // Create case-insensitive regex with word boundaries
58                let pattern = format!(r"(?i)\b{}\b", regex::escape(name));
59                if let Ok(re) = Regex::new(&pattern) {
60                    for mat in re.find_iter(line) {
61                        let found = mat.as_str();
62                        // Check if capitalization matches
63                        if found != name {
64                            violations.push(Violation {
65                                line: line_number,
66                                column: Some(mat.start() + 1),
67                                rule: self.name().to_owned(),
68                                message: format!(
69                                    "Proper name '{found}' should be capitalized as '{name}'"
70                                ),
71                                fix: None,
72                            });
73                        }
74                    }
75                }
76            }
77        }
78
79        violations
80    }
81
82    fn fixable(&self) -> bool {
83        false
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn test_no_config() {
93        let content = "This mentions javascript and rust";
94        let parser = MarkdownParser::new(content);
95        let rule = MD044;
96        let violations = rule.check(&parser, None);
97
98        assert_eq!(violations.len(), 0); // No names configured
99    }
100
101    #[test]
102    fn test_correct_capitalization() {
103        let content = "We use JavaScript and TypeScript.";
104        let parser = MarkdownParser::new(content);
105        let rule = MD044;
106        let config = serde_json::json!({
107            "names": ["JavaScript", "TypeScript"]
108        });
109        let violations = rule.check(&parser, Some(&config));
110
111        assert_eq!(violations.len(), 0);
112    }
113
114    #[test]
115    fn test_incorrect_capitalization() {
116        let content = "We use javascript and typescript.";
117        let parser = MarkdownParser::new(content);
118        let rule = MD044;
119        let config = serde_json::json!({
120            "names": ["JavaScript", "TypeScript"]
121        });
122        let violations = rule.check(&parser, Some(&config));
123
124        assert_eq!(violations.len(), 2);
125    }
126
127    #[test]
128    fn test_partial_match() {
129        let content = "JavaScriptCore is different from JavaScript";
130        let parser = MarkdownParser::new(content);
131        let rule = MD044;
132        let config = serde_json::json!({
133            "names": ["JavaScript"]
134        });
135        let violations = rule.check(&parser, Some(&config));
136
137        // Should only match whole word "JavaScript", not "JavaScriptCore"
138        assert_eq!(violations.len(), 0);
139    }
140}