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