rumdl_lib/rules/
md059_link_text.rs1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9#[serde(rename_all = "kebab-case")]
10pub struct MD059Config {
11 #[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#[derive(Clone)]
78pub struct MD059LinkText {
79 config: MD059Config,
80 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 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 if link.text.trim().is_empty() {
168 continue;
169 }
170
171 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
173 continue;
174 }
175
176 if self.is_prohibited(&link.text).is_some() {
178 warnings.push(LintWarning {
179 line: link.line,
180 column: link.start_col + 2, 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, 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 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 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 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}