rumdl_lib/rules/
md059_link_text.rs1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8#[serde(rename_all = "kebab-case")]
9pub struct MD059Config {
10 #[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#[derive(Clone)]
77pub struct MD059LinkText {
78 config: MD059Config,
79 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 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 if link.text.trim().is_empty() {
153 continue;
154 }
155
156 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
158 continue;
159 }
160
161 if self.is_prohibited(&link.text).is_some() {
163 warnings.push(LintWarning {
164 line: link.line,
165 column: link.start_col + 2, 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, 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 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 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 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}