Skip to main content

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            // Skip links inside PyMdown blocks
168            if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
169                continue;
170            }
171
172            // Check if link text is prohibited
173            if self.is_prohibited(&link.text).is_some() {
174                warnings.push(LintWarning {
175                    line: link.line,
176                    column: link.start_col + 2, // Point to first char of text (skip '[')
177                    end_line: link.line,
178                    end_column: link.end_col,
179                    message: "Link text should be descriptive".to_string(),
180                    severity: Severity::Warning,
181                    fix: None, // Not auto-fixable - requires human judgment
182                    rule_name: Some(self.name().to_string()),
183                });
184            }
185        }
186
187        Ok(warnings)
188    }
189
190    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
191        // MD059 is not auto-fixable because choosing descriptive link text
192        // requires human judgment and understanding of the link's context and destination
193        Ok(ctx.content.to_string())
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::config::MarkdownFlavor;
201
202    #[test]
203    fn test_default_prohibited_texts() {
204        let rule = MD059LinkText::default();
205        let ctx = LintContext::new(
206            "[click here](url)\n[here](url)\n[link](url)\n[more](url)",
207            MarkdownFlavor::Standard,
208            None,
209        );
210
211        let warnings = rule.check(&ctx).unwrap();
212        assert_eq!(warnings.len(), 4);
213
214        // All warnings should have the same descriptive message
215        for warning in &warnings {
216            assert_eq!(warning.message, "Link text should be descriptive");
217        }
218    }
219
220    #[test]
221    fn test_case_insensitive() {
222        let rule = MD059LinkText::default();
223        let ctx = LintContext::new(
224            "[CLICK HERE](url)\n[Here](url)\n[LINK](url)",
225            MarkdownFlavor::Standard,
226            None,
227        );
228
229        let warnings = rule.check(&ctx).unwrap();
230        assert_eq!(warnings.len(), 3);
231    }
232
233    #[test]
234    fn test_whitespace_trimming() {
235        let rule = MD059LinkText::default();
236        let ctx = LintContext::new("[  click here  ](url)\n[  here  ](url)", MarkdownFlavor::Standard, None);
237
238        let warnings = rule.check(&ctx).unwrap();
239        assert_eq!(warnings.len(), 2);
240    }
241
242    #[test]
243    fn test_descriptive_text_allowed() {
244        let rule = MD059LinkText::default();
245        let ctx = LintContext::new(
246            "[API documentation](url)\n[Installation guide](url)\n[Read the tutorial](url)",
247            MarkdownFlavor::Standard,
248            None,
249        );
250
251        let warnings = rule.check(&ctx).unwrap();
252        assert_eq!(warnings.len(), 0);
253    }
254
255    #[test]
256    fn test_substring_not_matched() {
257        let rule = MD059LinkText::default();
258        let ctx = LintContext::new(
259            "[click here for more info](url)\n[see here](url)\n[hyperlink](url)",
260            MarkdownFlavor::Standard,
261            None,
262        );
263
264        let warnings = rule.check(&ctx).unwrap();
265        assert_eq!(warnings.len(), 0, "Should not match when prohibited text is substring");
266    }
267
268    #[test]
269    fn test_empty_text_skipped() {
270        let rule = MD059LinkText::default();
271        let ctx = LintContext::new("[](url)", MarkdownFlavor::Standard, None);
272
273        let warnings = rule.check(&ctx).unwrap();
274        assert_eq!(warnings.len(), 0, "Empty link text should be skipped");
275    }
276
277    #[test]
278    fn test_custom_prohibited_texts() {
279        let rule = MD059LinkText::new(vec!["bad".to_string(), "poor".to_string()]);
280        let ctx = LintContext::new("[bad](url)\n[poor](url)", MarkdownFlavor::Standard, None);
281
282        let warnings = rule.check(&ctx).unwrap();
283        assert_eq!(warnings.len(), 2);
284    }
285
286    #[test]
287    fn test_reference_links() {
288        let rule = MD059LinkText::default();
289        let ctx = LintContext::new("[click here][ref]\n[ref]: url", MarkdownFlavor::Standard, None);
290
291        let warnings = rule.check(&ctx).unwrap();
292        assert_eq!(warnings.len(), 1, "Should check reference links");
293    }
294
295    #[test]
296    fn test_fix_not_supported() {
297        let rule = MD059LinkText::default();
298        let content = "[click here](url)";
299        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
300
301        // MD059 is not auto-fixable, so fix() returns unchanged content
302        let result = rule.fix(&ctx);
303        assert!(result.is_ok());
304        assert_eq!(result.unwrap(), content);
305    }
306
307    #[test]
308    fn test_non_english() {
309        let rule = MD059LinkText::new(vec!["hier klicken".to_string(), "hier".to_string(), "link".to_string()]);
310        let ctx = LintContext::new("[hier klicken](url)\n[hier](url)", MarkdownFlavor::Standard, None);
311
312        let warnings = rule.check(&ctx).unwrap();
313        assert_eq!(warnings.len(), 2);
314    }
315}