Skip to main content

rumdl_lib/rules/
md059_link_text.rs

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