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 + 1,
171 end_line: link.end_line,
172 end_column: link.end_col + 1,
173 message: "Link text should be descriptive".to_string(),
174 severity: Severity::Warning,
175 fix: None, rule_name: Some(self.name().to_string()),
177 });
178 }
179 }
180
181 Ok(warnings)
182 }
183
184 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
185 Ok(ctx.content.to_string())
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use crate::config::MarkdownFlavor;
195
196 #[test]
197 fn test_default_prohibited_texts() {
198 let rule = MD059LinkText::default();
199 let ctx = LintContext::new(
200 "[click here](url)\n[here](url)\n[link](url)\n[more](url)",
201 MarkdownFlavor::Standard,
202 None,
203 );
204
205 let warnings = rule.check(&ctx).unwrap();
206 assert_eq!(warnings.len(), 4);
207
208 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(
218 "[CLICK HERE](url)\n[Here](url)\n[LINK](url)",
219 MarkdownFlavor::Standard,
220 None,
221 );
222
223 let warnings = rule.check(&ctx).unwrap();
224 assert_eq!(warnings.len(), 3);
225 }
226
227 #[test]
228 fn test_whitespace_trimming() {
229 let rule = MD059LinkText::default();
230 let ctx = LintContext::new("[ click here ](url)\n[ here ](url)", MarkdownFlavor::Standard, None);
231
232 let warnings = rule.check(&ctx).unwrap();
233 assert_eq!(warnings.len(), 2);
234 }
235
236 #[test]
237 fn test_descriptive_text_allowed() {
238 let rule = MD059LinkText::default();
239 let ctx = LintContext::new(
240 "[API documentation](url)\n[Installation guide](url)\n[Read the tutorial](url)",
241 MarkdownFlavor::Standard,
242 None,
243 );
244
245 let warnings = rule.check(&ctx).unwrap();
246 assert_eq!(warnings.len(), 0);
247 }
248
249 #[test]
250 fn test_substring_not_matched() {
251 let rule = MD059LinkText::default();
252 let ctx = LintContext::new(
253 "[click here for more info](url)\n[see here](url)\n[hyperlink](url)",
254 MarkdownFlavor::Standard,
255 None,
256 );
257
258 let warnings = rule.check(&ctx).unwrap();
259 assert_eq!(warnings.len(), 0, "Should not match when prohibited text is substring");
260 }
261
262 #[test]
263 fn test_empty_text_skipped() {
264 let rule = MD059LinkText::default();
265 let ctx = LintContext::new("[](url)", MarkdownFlavor::Standard, None);
266
267 let warnings = rule.check(&ctx).unwrap();
268 assert_eq!(warnings.len(), 0, "Empty link text should be skipped");
269 }
270
271 #[test]
272 fn test_custom_prohibited_texts() {
273 let rule = MD059LinkText::new(vec!["bad".to_string(), "poor".to_string()]);
274 let ctx = LintContext::new("[bad](url)\n[poor](url)", MarkdownFlavor::Standard, None);
275
276 let warnings = rule.check(&ctx).unwrap();
277 assert_eq!(warnings.len(), 2);
278 }
279
280 #[test]
281 fn test_reference_links() {
282 let rule = MD059LinkText::default();
283 let ctx = LintContext::new("[click here][ref]\n[ref]: url", MarkdownFlavor::Standard, None);
284
285 let warnings = rule.check(&ctx).unwrap();
286 assert_eq!(warnings.len(), 1, "Should check reference links");
287 }
288
289 #[test]
290 fn test_fix_not_supported() {
291 let rule = MD059LinkText::default();
292 let content = "[click here](url)";
293 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
294
295 let result = rule.fix(&ctx);
297 assert!(result.is_ok());
298 assert_eq!(result.unwrap(), content);
299 }
300
301 #[test]
302 fn test_non_english() {
303 let rule = MD059LinkText::new(vec!["hier klicken".to_string(), "hier".to_string(), "link".to_string()]);
304 let ctx = LintContext::new("[hier klicken](url)\n[hier](url)", MarkdownFlavor::Standard, None);
305
306 let warnings = rule.check(&ctx).unwrap();
307 assert_eq!(warnings.len(), 2);
308 }
309}