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