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