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 );
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("[CLICK HERE](url)\n[Here](url)\n[LINK](url)", MarkdownFlavor::Standard);
218
219 let warnings = rule.check(&ctx).unwrap();
220 assert_eq!(warnings.len(), 3);
221 }
222
223 #[test]
224 fn test_whitespace_trimming() {
225 let rule = MD059LinkText::default();
226 let ctx = LintContext::new("[ click here ](url)\n[ here ](url)", MarkdownFlavor::Standard);
227
228 let warnings = rule.check(&ctx).unwrap();
229 assert_eq!(warnings.len(), 2);
230 }
231
232 #[test]
233 fn test_descriptive_text_allowed() {
234 let rule = MD059LinkText::default();
235 let ctx = LintContext::new(
236 "[API documentation](url)\n[Installation guide](url)\n[Read the tutorial](url)",
237 MarkdownFlavor::Standard,
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 );
251
252 let warnings = rule.check(&ctx).unwrap();
253 assert_eq!(warnings.len(), 0, "Should not match when prohibited text is substring");
254 }
255
256 #[test]
257 fn test_empty_text_skipped() {
258 let rule = MD059LinkText::default();
259 let ctx = LintContext::new("[](url)", MarkdownFlavor::Standard);
260
261 let warnings = rule.check(&ctx).unwrap();
262 assert_eq!(warnings.len(), 0, "Empty link text should be skipped");
263 }
264
265 #[test]
266 fn test_custom_prohibited_texts() {
267 let rule = MD059LinkText::new(vec!["bad".to_string(), "poor".to_string()]);
268 let ctx = LintContext::new("[bad](url)\n[poor](url)", MarkdownFlavor::Standard);
269
270 let warnings = rule.check(&ctx).unwrap();
271 assert_eq!(warnings.len(), 2);
272 }
273
274 #[test]
275 fn test_reference_links() {
276 let rule = MD059LinkText::default();
277 let ctx = LintContext::new("[click here][ref]\n[ref]: url", MarkdownFlavor::Standard);
278
279 let warnings = rule.check(&ctx).unwrap();
280 assert_eq!(warnings.len(), 1, "Should check reference links");
281 }
282
283 #[test]
284 fn test_fix_not_supported() {
285 let rule = MD059LinkText::default();
286 let content = "[click here](url)";
287 let ctx = LintContext::new(content, MarkdownFlavor::Standard);
288
289 let result = rule.fix(&ctx);
291 assert!(result.is_ok());
292 assert_eq!(result.unwrap(), content);
293 }
294
295 #[test]
296 fn test_non_english() {
297 let rule = MD059LinkText::new(vec!["hier klicken".to_string(), "hier".to_string(), "link".to_string()]);
298 let ctx = LintContext::new("[hier klicken](url)\n[hier](url)", MarkdownFlavor::Standard);
299
300 let warnings = rule.check(&ctx).unwrap();
301 assert_eq!(warnings.len(), 2);
302 }
303}