rumdl_lib/rules/
md059_link_text.rs

1use crate::config::Config;
2use crate::lint_context::LintContext;
3use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
4use crate::rule_config_serde::RuleConfig;
5use serde::{Deserialize, Serialize};
6
7/// Configuration for MD059 (Link text should be descriptive)
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9#[serde(rename_all = "kebab-case")]
10pub struct MD059Config {
11    /// List of prohibited link text phrases (case-insensitive)
12    #[serde(default = "default_prohibited_texts")]
13    pub prohibited_texts: Vec<String>,
14}
15
16fn default_prohibited_texts() -> Vec<String> {
17    vec![
18        "click here".to_string(),
19        "here".to_string(),
20        "link".to_string(),
21        "more".to_string(),
22    ]
23}
24
25impl Default for MD059Config {
26    fn default() -> Self {
27        Self {
28            prohibited_texts: default_prohibited_texts(),
29        }
30    }
31}
32
33impl RuleConfig for MD059Config {
34    const RULE_NAME: &'static str = "MD059";
35}
36
37/// Rule MD059: Link text should be descriptive
38///
39/// See [docs/md059.md](../../docs/md059.md) for full documentation, configuration, and examples.
40///
41/// This rule enforces that markdown links use meaningful, descriptive text rather than generic
42/// phrases. It triggers when link text matches prohibited terms like "click here," "here," "link,"
43/// or "more."
44///
45/// ## Rationale
46///
47/// Descriptive link text is crucial for accessibility. Screen readers often present links without
48/// surrounding context, making generic text problematic for users relying on assistive technologies.
49///
50/// ## Examples
51///
52/// ```markdown
53/// <!-- Bad -->
54/// [click here](docs.md)
55/// [link](api.md)
56/// [more](details.md)
57///
58/// <!-- Good -->
59/// [API documentation](docs.md)
60/// [Installation guide](install.md)
61/// [Full details](details.md)
62/// ```
63///
64/// ## Configuration
65///
66/// ```toml
67/// [MD059]
68/// prohibited_texts = ["click here", "here", "link", "more"]
69/// ```
70///
71/// For non-English content, customize the prohibited texts:
72///
73/// ```toml
74/// [MD059]
75/// prohibited_texts = ["hier klicken", "hier", "link", "mehr"]
76/// ```
77#[derive(Clone)]
78pub struct MD059LinkText {
79    config: MD059Config,
80    /// Cached lowercase versions of prohibited texts for performance
81    prohibited_lowercase: Vec<String>,
82}
83
84impl MD059LinkText {
85    pub fn new(prohibited_texts: Vec<String>) -> Self {
86        let prohibited_lowercase = prohibited_texts.iter().map(|s| s.to_lowercase()).collect();
87
88        Self {
89            config: MD059Config { prohibited_texts },
90            prohibited_lowercase,
91        }
92    }
93
94    pub fn from_config_struct(config: MD059Config) -> Self {
95        let prohibited_lowercase = config.prohibited_texts.iter().map(|s| s.to_lowercase()).collect();
96
97        Self {
98            config,
99            prohibited_lowercase,
100        }
101    }
102
103    /// Check if link text is prohibited, returning the matched prohibited text
104    fn is_prohibited(&self, link_text: &str) -> Option<&str> {
105        let normalized = link_text.trim().to_lowercase();
106
107        self.prohibited_lowercase
108            .iter()
109            .zip(&self.config.prohibited_texts)
110            .find(|(lower, _)| **lower == normalized)
111            .map(|(_, original)| original.as_str())
112    }
113}
114
115impl Default for MD059LinkText {
116    fn default() -> Self {
117        Self::from_config_struct(MD059Config::default())
118    }
119}
120
121impl Rule for MD059LinkText {
122    fn name(&self) -> &'static str {
123        "MD059"
124    }
125
126    fn description(&self) -> &'static str {
127        "Link text should be descriptive"
128    }
129
130    fn category(&self) -> RuleCategory {
131        RuleCategory::Link
132    }
133
134    fn as_any(&self) -> &dyn std::any::Any {
135        self
136    }
137
138    fn default_config_section(&self) -> Option<(String, toml::Value)> {
139        let json_value = serde_json::to_value(&self.config).ok()?;
140        Some((
141            self.name().to_string(),
142            crate::rule_config_serde::json_to_toml_value(&json_value)?,
143        ))
144    }
145
146    fn fix_capability(&self) -> crate::rule::FixCapability {
147        crate::rule::FixCapability::Unfixable
148    }
149
150    fn from_config(config: &Config) -> Box<dyn Rule>
151    where
152        Self: Sized,
153    {
154        let rule_config = crate::rule_config_serde::load_rule_config::<MD059Config>(config);
155        Box::new(Self::from_config_struct(rule_config))
156    }
157
158    fn check(&self, ctx: &LintContext) -> LintResult {
159        let mut warnings = Vec::new();
160
161        for link in &ctx.links {
162            // Skip empty link text
163            if link.text.trim().is_empty() {
164                continue;
165            }
166
167            // Check if link text is prohibited
168            if self.is_prohibited(&link.text).is_some() {
169                warnings.push(LintWarning {
170                    line: link.line,
171                    column: link.start_col + 2, // Point to first char of text (skip '[')
172                    end_line: link.line,
173                    end_column: link.end_col,
174                    message: "Link text should be descriptive".to_string(),
175                    severity: Severity::Warning,
176                    fix: None, // Not auto-fixable - requires human judgment
177                    rule_name: Some(self.name().to_string()),
178                });
179            }
180        }
181
182        Ok(warnings)
183    }
184
185    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
186        // MD059 is not auto-fixable because choosing descriptive link text
187        // requires human judgment and understanding of the link's context and destination
188        Ok(ctx.content.to_string())
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::config::MarkdownFlavor;
196
197    #[test]
198    fn test_default_prohibited_texts() {
199        let rule = MD059LinkText::default();
200        let ctx = LintContext::new(
201            "[click here](url)\n[here](url)\n[link](url)\n[more](url)",
202            MarkdownFlavor::Standard,
203        );
204
205        let warnings = rule.check(&ctx).unwrap();
206        assert_eq!(warnings.len(), 4);
207
208        // All warnings should have the same descriptive message
209        for warning in &warnings {
210            assert_eq!(warning.message, "Link text should be descriptive");
211        }
212    }
213
214    #[test]
215    fn test_case_insensitive() {
216        let rule = MD059LinkText::default();
217        let ctx = LintContext::new("[CLICK HERE](url)\n[Here](url)\n[LINK](url)", MarkdownFlavor::Standard);
218
219        let warnings = rule.check(&ctx).unwrap();
220        assert_eq!(warnings.len(), 3);
221    }
222
223    #[test]
224    fn test_whitespace_trimming() {
225        let rule = MD059LinkText::default();
226        let ctx = LintContext::new("[  click here  ](url)\n[  here  ](url)", MarkdownFlavor::Standard);
227
228        let warnings = rule.check(&ctx).unwrap();
229        assert_eq!(warnings.len(), 2);
230    }
231
232    #[test]
233    fn test_descriptive_text_allowed() {
234        let rule = MD059LinkText::default();
235        let ctx = LintContext::new(
236            "[API documentation](url)\n[Installation guide](url)\n[Read the tutorial](url)",
237            MarkdownFlavor::Standard,
238        );
239
240        let warnings = rule.check(&ctx).unwrap();
241        assert_eq!(warnings.len(), 0);
242    }
243
244    #[test]
245    fn test_substring_not_matched() {
246        let rule = MD059LinkText::default();
247        let ctx = LintContext::new(
248            "[click here for more info](url)\n[see here](url)\n[hyperlink](url)",
249            MarkdownFlavor::Standard,
250        );
251
252        let warnings = rule.check(&ctx).unwrap();
253        assert_eq!(warnings.len(), 0, "Should not match when prohibited text is substring");
254    }
255
256    #[test]
257    fn test_empty_text_skipped() {
258        let rule = MD059LinkText::default();
259        let ctx = LintContext::new("[](url)", MarkdownFlavor::Standard);
260
261        let warnings = rule.check(&ctx).unwrap();
262        assert_eq!(warnings.len(), 0, "Empty link text should be skipped");
263    }
264
265    #[test]
266    fn test_custom_prohibited_texts() {
267        let rule = MD059LinkText::new(vec!["bad".to_string(), "poor".to_string()]);
268        let ctx = LintContext::new("[bad](url)\n[poor](url)", MarkdownFlavor::Standard);
269
270        let warnings = rule.check(&ctx).unwrap();
271        assert_eq!(warnings.len(), 2);
272    }
273
274    #[test]
275    fn test_reference_links() {
276        let rule = MD059LinkText::default();
277        let ctx = LintContext::new("[click here][ref]\n[ref]: url", MarkdownFlavor::Standard);
278
279        let warnings = rule.check(&ctx).unwrap();
280        assert_eq!(warnings.len(), 1, "Should check reference links");
281    }
282
283    #[test]
284    fn test_fix_not_supported() {
285        let rule = MD059LinkText::default();
286        let content = "[click here](url)";
287        let ctx = LintContext::new(content, MarkdownFlavor::Standard);
288
289        // MD059 is not auto-fixable, so fix() returns unchanged content
290        let result = rule.fix(&ctx);
291        assert!(result.is_ok());
292        assert_eq!(result.unwrap(), content);
293    }
294
295    #[test]
296    fn test_non_english() {
297        let rule = MD059LinkText::new(vec!["hier klicken".to_string(), "hier".to_string(), "link".to_string()]);
298        let ctx = LintContext::new("[hier klicken](url)\n[hier](url)", MarkdownFlavor::Standard);
299
300        let warnings = rule.check(&ctx).unwrap();
301        assert_eq!(warnings.len(), 2);
302    }
303}